From 916de99fda57f4c20c7a6ca2c8403aed47df7fc7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 22:22:12 -0400 Subject: [PATCH 01/24] M9.8: filmcraft-vp9 clean-room VP9 decoder (profiles 0-3, tiles, superframes, scaling) with ffmpeg oracle tests Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 9 + Cargo.toml | 1 + crates/vp9/Cargo.toml | 21 + crates/vp9/src/boolcoder.rs | 181 ++++ crates/vp9/src/decoder.rs | 539 +++++++++++ crates/vp9/src/error.rs | 31 + crates/vp9/src/frame.rs | 113 +++ crates/vp9/src/header.rs | 661 +++++++++++++ crates/vp9/src/inter.rs | 185 ++++ crates/vp9/src/intra.rs | 214 +++++ crates/vp9/src/lib.rs | 124 +++ crates/vp9/src/loopfilter.rs | 256 +++++ crates/vp9/src/probs.rs | 315 ++++++ crates/vp9/src/spec_tables.rs | 1427 ++++++++++++++++++++++++++++ crates/vp9/src/tables.rs | 285 ++++++ crates/vp9/src/tile.rs | 1080 +++++++++++++++++++++ crates/vp9/src/tile/recon.rs | 336 +++++++ crates/vp9/src/transform.rs | 318 +++++++ crates/vp9/src/transform_gen.rs | 480 ++++++++++ crates/vp9/tests/common/mod.rs | 303 ++++++ crates/vp9/tests/conformance.rs | 152 +++ crates/vp9/tools/extract_tables.py | 99 ++ crates/vp9/tools/gen_transforms.py | 258 +++++ xtask/src/main.rs | 1 + 24 files changed, 7389 insertions(+) create mode 100644 crates/vp9/Cargo.toml create mode 100644 crates/vp9/src/boolcoder.rs create mode 100644 crates/vp9/src/decoder.rs create mode 100644 crates/vp9/src/error.rs create mode 100644 crates/vp9/src/frame.rs create mode 100644 crates/vp9/src/header.rs create mode 100644 crates/vp9/src/inter.rs create mode 100644 crates/vp9/src/intra.rs create mode 100644 crates/vp9/src/lib.rs create mode 100644 crates/vp9/src/loopfilter.rs create mode 100644 crates/vp9/src/probs.rs create mode 100644 crates/vp9/src/spec_tables.rs create mode 100644 crates/vp9/src/tables.rs create mode 100644 crates/vp9/src/tile.rs create mode 100644 crates/vp9/src/tile/recon.rs create mode 100644 crates/vp9/src/transform.rs create mode 100644 crates/vp9/src/transform_gen.rs create mode 100644 crates/vp9/tests/common/mod.rs create mode 100644 crates/vp9/tests/conformance.rs create mode 100644 crates/vp9/tools/extract_tables.py create mode 100644 crates/vp9/tools/gen_transforms.py diff --git a/Cargo.lock b/Cargo.lock index 090956989..e9deb8fcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1585,6 +1585,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "filmcraft-vp9" +version = "0.1.0" +dependencies = [ + "filmcraft-bitstream", + "rayon", + "thiserror 2.0.21", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" diff --git a/Cargo.toml b/Cargo.toml index 0093b3675..63fd9f26f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ filmcraft-isobmff = { path = "crates/isobmff" } filmcraft-matroska = { path = "crates/matroska" } filmcraft-h264 = { path = "crates/h264" } filmcraft-hevc = { path = "crates/hevc" } +filmcraft-vp9 = { path = "crates/vp9" } filmcraft-prores = { path = "crates/prores" } filmcraft-aac = { path = "crates/aac" } filmcraft-h264enc = { path = "crates/h264enc" } diff --git a/crates/vp9/Cargo.toml b/crates/vp9/Cargo.toml new file mode 100644 index 000000000..5e9fd06e3 --- /dev/null +++ b/crates/vp9/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "filmcraft-vp9" +description = "Clean-room pure-Rust VP9 decoder (VP9 Bitstream & Decoding Process Specification)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[features] +default = ["threads"] +threads = ["dep:rayon"] + +[dependencies] +filmcraft-bitstream = { workspace = true } +thiserror = { workspace = true } +rayon = { workspace = true, optional = true } + +[dev-dependencies] diff --git a/crates/vp9/src/boolcoder.rs b/crates/vp9/src/boolcoder.rs new file mode 100644 index 000000000..7980f2940 --- /dev/null +++ b/crates/vp9/src/boolcoder.rs @@ -0,0 +1,181 @@ +//! Boolean (arithmetic) decoder of section 9.2. +//! +//! The specification keeps an 8-bit `BoolValue` and shifts in one bit per renormalisation step. +//! This implementation keeps `BoolValue` in the top 8 bits of a 64-bit window whose lower bits +//! hold the next bits of the input, which is equivalent and needs a refill only every few bytes. +//! Reading past the end of the data yields zero bits, as the specification does once +//! `BoolMaxBits` is exhausted. + +pub struct BoolDecoder<'a> { + data: &'a [u8], + pos: usize, + /// BoolValue in bits 56..63, followed by `count` valid input bits. + value: u64, + count: i32, + range: u32, + /// Number of zero bytes appended after the end of the data (detects runaway reads). + overrun: u32, +} + +impl<'a> BoolDecoder<'a> { + /// init_bool (9.2.1): the marker bit is read and returned in `Err` position when non-zero is + /// not treated as fatal (libvpx ignores it too), so this only returns the decoder. + pub fn new(data: &'a [u8]) -> Self { + let mut d = BoolDecoder { data, pos: 0, value: 0, count: -8, range: 255, overrun: 0 }; + d.fill(); + d.read_bool(128); + d + } + + #[inline] + fn fill(&mut self) { + // Room for whole bytes below the top 8 bits and the `count` valid bits. + let mut shift = 48 - self.count; + if self.pos + 8 <= self.data.len() { + let bytes: [u8; 8] = self.data[self.pos..self.pos + 8].try_into().expect("8 bytes"); + let v = u64::from_be_bytes(bytes); + let n = ((shift + 8) / 8) as usize; // bytes that fit + let n = n.min(7); + // Take the top n bytes of v. + let take = v >> (64 - 8 * n); + self.value |= take << (shift + 8 - 8 * n as i32); + self.pos += n; + self.count += 8 * n as i32; + return; + } + while shift >= 0 { + let b = if self.pos < self.data.len() { + let b = self.data[self.pos]; + self.pos += 1; + b + } else { + self.overrun = self.overrun.saturating_add(1); + 0 + }; + self.value |= (b as u64) << shift; + self.count += 8; + shift -= 8; + } + } + + /// read_bool (9.2.2). + #[inline(always)] + pub fn read_bool(&mut self, p: u8) -> bool { + if self.count < 8 { + self.fill(); + } + let split = 1 + (((self.range - 1) * p as u32) >> 8); + let bigsplit = (split as u64) << 56; + let bit = if self.value >= bigsplit { + self.range -= split; + self.value -= bigsplit; + true + } else { + self.range = split; + false + }; + let shift = self.range.leading_zeros() - 24; + self.range <<= shift; + self.value <<= shift; + self.count -= shift as i32; + bit + } + + /// read_literal (9.2.4). + pub fn read_literal(&mut self, n: u32) -> u32 { + let mut x = 0; + for _ in 0..n { + x = (x << 1) | self.read_bool(128) as u32; + } + x + } + + /// Tree decoding process (9.3.3) with probabilities `probs[node]`. + #[inline] + pub fn read_tree(&mut self, tree: &[i8], probs: &[u8]) -> u8 { + let mut n = 0i8; + loop { + n = tree[(n + self.read_bool(probs[(n >> 1) as usize]) as i8) as usize]; + if n <= 0 { + return (-n) as u8; + } + } + } + + /// True when the decoder has consumed far more bits than the data holds (a damaged stream). + pub fn overrun(&self) -> bool { + self.overrun > 16 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Straightforward implementation of 9.2.2 for comparison. + struct SpecDecoder<'a> { + data: &'a [u8], + bitpos: usize, + value: u32, + range: u32, + max_bits: i64, + } + + impl<'a> SpecDecoder<'a> { + fn new(data: &'a [u8]) -> Self { + let mut d = SpecDecoder { data, bitpos: 0, value: 0, range: 255, max_bits: 8 * data.len() as i64 - 8 }; + for _ in 0..8 { + d.value = (d.value << 1) | d.bit(); + } + d.read(128); + d + } + fn bit(&mut self) -> u32 { + let b = self.data.get(self.bitpos / 8).map(|b| (b >> (7 - self.bitpos % 8)) & 1).unwrap_or(0); + self.bitpos += 1; + b as u32 + } + fn read(&mut self, p: u8) -> bool { + let split = 1 + (((self.range - 1) * p as u32) >> 8); + let bit = if self.value < split { + self.range = split; + false + } else { + self.range -= split; + self.value -= split; + true + }; + while self.range < 128 { + let nb = if self.max_bits > 0 { + self.max_bits -= 1; + self.bit() + } else { + 0 + }; + self.range *= 2; + self.value = (self.value << 1) + nb; + } + bit + } + } + + #[test] + fn matches_spec_model() { + let mut seed = 0x1234_5678u32; + let mut rnd = || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed + }; + for len in [1usize, 2, 3, 7, 8, 9, 15, 16, 17, 100, 1000] { + let data: Vec = (0..len).map(|_| rnd() as u8).collect(); + let mut a = BoolDecoder::new(&data); + let mut b = SpecDecoder::new(&data); + for i in 0..len * 12 { + let p = (rnd() % 255 + 1) as u8; + assert_eq!(a.read_bool(p), b.read(p), "len {len} bit {i}"); + } + } + } +} diff --git a/crates/vp9/src/decoder.rs b/crates/vp9/src/decoder.rs new file mode 100644 index 000000000..d6541eed5 --- /dev/null +++ b/crates/vp9/src/decoder.rs @@ -0,0 +1,539 @@ +//! Top-level decoder: superframes, frame header handling, tile scheduling (tile columns in +//! parallel), frame assembly, loop filter (superblock wavefront in parallel), probability +//! adaptation and reference frame management. + +use crate::error::{Error, Result, ensure}; +use crate::frame::{Frame, MiGrid, MiInfo, Plane as FPlane, plane_geometry}; +use crate::header::{FrameHeader, HeaderState, KEY_FRAME, RefInfo, parse_compressed, parse_uncompressed, split_superframe}; +use crate::loopfilter::{LfFrame, PlaneView, filter_superblock}; +use crate::probs::{Counts, adapt_coef_probs, adapt_noncoef_probs}; +use crate::tables::*; +use crate::tile::{FrameShared, RefUse, Strip, TileDecoder}; +use crate::{ColorInfo, Picture, Plane}; +use std::sync::Arc; + +/// Counters describing what the decoder has seen (useful to check test coverage). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DecodeStats { + pub frames: u64, + pub shown: u64, + pub hidden: u64, + pub key_frames: u64, + pub intra_only: u64, + pub inter_frames: u64, + pub show_existing: u64, + pub superframes: u64, + pub error_resilient: u64, + pub no_backward_adaptation: u64, + pub size_changes: u64, + pub scaled_ref_blocks: u64, + pub compound_blocks: u64, + pub intra_blocks: u64, + pub inter_blocks: u64, + pub lossless_frames: u64, + pub max_tile_cols: u32, + pub max_tile_rows: u32, + pub segmentation_frames: u64, + pub tx_select_frames: u64, + pub switchable_interp_frames: u64, + pub high_precision_mv_frames: u64, + pub compound_frames: u64, + pub bit_depths: [u64; 3], + pub profiles: [u64; 4], +} + +/// VP9 decoder. +pub struct Decoder { + st: HeaderState, + slots: [Option>; 8], + prev_mi: Option>, + prev_seg_ids: Vec, + last_size: Option<(u32, u32)>, + last_show_frame: bool, + stats: DecodeStats, + threads: usize, + #[cfg(feature = "threads")] + pool: Option, +} + +impl Default for Decoder { + fn default() -> Self { + Self::new() + } +} + +fn default_threads() -> usize { + #[cfg(all(feature = "threads", not(target_arch = "wasm32")))] + { + std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1).min(16) + } + #[cfg(not(all(feature = "threads", not(target_arch = "wasm32"))))] + { + 1 + } +} + +/// get_tile_offset (6.4.1). +fn tile_offset(i: usize, mis: usize, log2: u32) -> usize { + let sbs = (mis + 7) >> 3; + (((i * sbs) >> log2) << 3).min(mis) +} + +impl Decoder { + /// A decoder using all available cores (with the `threads` feature). + pub fn new() -> Self { + Self::with_threads(default_threads()) + } + + /// A decoder using up to `threads` worker threads (1 = decode on the calling thread). + pub fn with_threads(threads: usize) -> Self { + #[cfg(feature = "threads")] + let pool = if threads > 1 && cfg!(not(target_arch = "wasm32")) { + rayon::ThreadPoolBuilder::new().num_threads(threads).thread_name(|i| format!("vp9-{i}")).build().ok() + } else { + None + }; + Decoder { + st: HeaderState::default(), + slots: Default::default(), + prev_mi: None, + prev_seg_ids: Vec::new(), + last_size: None, + last_show_frame: false, + stats: DecodeStats::default(), + threads: threads.max(1), + #[cfg(feature = "threads")] + pool, + } + } + + /// Statistics accumulated so far. + pub fn stats(&self) -> DecodeStats { + self.stats.clone() + } + + /// Decode one chunk (a frame or a superframe, as stored in one IVF / WebM / MP4 sample). + /// Returns the frames to show, in order; each carries `pts`. + pub fn decode(&mut self, data: &[u8], pts: i64) -> Result> { + let frames = split_superframe(data); + if frames.len() > 1 { + self.stats.superframes += 1; + } + let mut out = Vec::new(); + for f in frames { + if f.is_empty() { + continue; + } + if let Some(frame) = self.decode_frame(f)? { + out.push(make_picture(&frame, pts)); + } + } + Ok(out) + } + + /// End of stream. VP9 has no reordering delay, so this returns no pictures; it resets the + /// reference state so that a new stream can follow. + pub fn flush(&mut self) -> Vec { + Vec::new() + } + + fn decode_frame(&mut self, data: &[u8]) -> Result>> { + let refs: [Option; 8] = std::array::from_fn(|i| self.slots[i].as_ref().map(|f| RefInfo { width: f.width, height: f.height })); + let mut h = parse_uncompressed(data, &mut self.st, &refs)?; + if h.show_existing_frame { + self.stats.show_existing += 1; + let f = self.slots[h.frame_to_show_map_idx as usize] + .clone() + .ok_or_else(|| Error::MissingReference(format!("show_existing_frame of empty slot {}", h.frame_to_show_map_idx)))?; + self.stats.shown += 1; + return Ok(Some(f)); + } + ensure!(h.width <= 16384 && h.height <= 16384, "frame size {}x{} too large", h.width, h.height); + let (mi_rows, mi_cols) = (h.mi_rows as usize, h.mi_cols as usize); + // compute_image_size semantics (7.2.6). + let size = (h.width, h.height); + let size_changed = self.last_size != Some(size); + if size_changed && self.last_size.is_some() { + self.stats.size_changes += 1; + } + if self.st.reset_segment_map || size_changed || self.prev_seg_ids.len() != mi_rows * mi_cols { + self.prev_seg_ids = vec![0; mi_rows * mi_cols]; + self.st.reset_segment_map = false; + } + let use_prev = !size_changed + && self.last_show_frame + && !h.error_resilient_mode + && !h.frame_is_intra + && self.prev_mi.as_ref().is_some_and(|m| m.rows == mi_rows && m.cols == mi_cols); + self.last_size = Some(size); + self.last_show_frame = h.show_frame; + + let off = h.uncompressed_size; + let hs = h.header_size_in_bytes as usize; + ensure!(off + hs <= data.len(), "compressed header exceeds frame data"); + let mut fc = self.st.contexts[h.frame_context_idx as usize].clone(); + parse_compressed(&data[off..off + hs], &mut h, &mut fc)?; + self.count_frame(&h); + + // References (8.5.2.3). + let mut refs: [Option; 3] = [None, None, None]; + let ref_frames: Vec>> = + (0..3).map(|i| if h.frame_is_intra { None } else { self.slots[h.ref_frame_idx[i] as usize].clone() }).collect(); + if !h.frame_is_intra { + for i in 0..3 { + let Some(rf) = &ref_frames[i] else { + return Err(Error::MissingReference(format!("reference slot {} is empty", h.ref_frame_idx[i]))); + }; + ensure!( + rf.bit_depth == h.color.bit_depth && rf.ss_x == h.color.subsampling_x && rf.ss_y == h.color.subsampling_y, + "reference frame format differs from the current frame" + ); + let (rw, rh) = (rf.width as i64, rf.height as i64); + let (w, hh) = (h.width as i64, h.height as i64); + if 2 * w >= rw && 2 * hh >= rh && w <= 16 * rw && hh <= 16 * rh { + let x_scale = ((rw << 14) / w) as i32; + let y_scale = ((rh << 14) / hh) as i32; + refs[i] = Some(RefUse { frame: rf, x_scale, y_scale, x_step: (16 * x_scale) >> 14, y_step: (16 * y_scale) >> 14 }); + } + } + } + // Quantizers per segment (8.6.1). + let bd_idx = ((h.color.bit_depth - 8) >> 1) as usize; + let seg = self.st.seg.clone(); + let mut seg_q = [[[0i32; 2]; 2]; 8]; + for (s, q) in seg_q.iter_mut().enumerate() { + let qindex = if seg.feature_active(s as u8, SEG_LVL_ALT_Q) { + let d = seg.feature_data[s][SEG_LVL_ALT_Q] as i32; + (if seg.abs_or_delta_update { d } else { h.base_q_idx as i32 + d }).clamp(0, 255) + } else { + h.base_q_idx as i32 + }; + let dc = |b: i32| DC_QLOOKUP[bd_idx * 256 + b.clamp(0, 255) as usize]; + let ac = |b: i32| AC_QLOOKUP[bd_idx * 256 + b.clamp(0, 255) as usize]; + q[0] = [dc(qindex + h.delta_q_y_dc as i32), ac(qindex)]; + q[1] = [dc(qindex + h.delta_q_uv_dc as i32), ac(qindex + h.delta_q_uv_ac as i32)]; + } + // Tile layout and data (6.4). + let tile_cols = 1usize << h.tile_cols_log2; + let tile_rows = 1usize << h.tile_rows_log2; + let mut pos = off + hs; + let mut tile_data: Vec> = vec![Vec::with_capacity(tile_rows); tile_cols]; + for tr in 0..tile_rows { + for (tc, td) in tile_data.iter_mut().enumerate() { + let last = tr == tile_rows - 1 && tc == tile_cols - 1; + let sz = if last { + data.len() - pos + } else { + ensure!(pos + 4 <= data.len(), "tile size beyond frame data"); + let s = u32::from_be_bytes(data[pos..pos + 4].try_into().expect("4 bytes")) as usize; + pos += 4; + ensure!(s <= data.len() - pos, "tile size {s} beyond frame data"); + s + }; + td.push(&data[pos..pos + sz]); + pos += sz; + } + } + let counting = !h.error_resilient_mode && !h.frame_parallel_decoding_mode; + let prev_mi = if use_prev { self.prev_mi.clone() } else { None }; + let shared = FrameShared { h: &h, fc: &fc, seg: &seg, prev_seg_ids: &self.prev_seg_ids, prev_mi: prev_mi.as_deref(), refs, seg_q, counting }; + let col_bounds: Vec<(usize, usize)> = + (0..tile_cols).map(|i| (tile_offset(i, mi_cols, h.tile_cols_log2), tile_offset(i + 1, mi_cols, h.tile_cols_log2))).collect(); + let row_bounds: Vec<(usize, usize)> = + (0..tile_rows).map(|i| (tile_offset(i, mi_rows, h.tile_rows_log2), tile_offset(i + 1, mi_rows, h.tile_rows_log2))).collect(); + let decode_col = |ci: usize| -> (Strip, Option) { + let (cs, ce) = col_bounds[ci]; + let mut td = TileDecoder::new(&shared, cs, ce); + let mut err = None; + if ce > cs { + for (ri, &(rs, re)) in row_bounds.iter().enumerate() { + if !td.decode_tile(tile_data[ci][ri], rs, re) { + err = Some(Error::Invalid("tile data exhausted".into())); + break; + } + } + } + let e = td.error.take().or(err); + (td.strip, e) + }; + let results: Vec<(Strip, Option)> = self.run_parallel(tile_cols, &decode_col); + let mut counts = Counts::default(); + let mut strips = Vec::with_capacity(results.len()); + for (s, e) in results { + if let Some(e) = e { + return Err(e); + } + if counting { + counts.add(&s.counts); + } + self.stats.compound_blocks += s.compound_blocks; + self.stats.scaled_ref_blocks += s.scaled_blocks; + self.stats.intra_blocks += s.intra_blocks; + self.stats.inter_blocks += s.inter_blocks; + strips.push(s); + } + // Assemble the frame and mode info. + let geo = plane_geometry(h.width, h.height, h.color.subsampling_x, h.color.subsampling_y); + let (planes, mi, seg_ids) = assemble(strips, &geo, mi_rows, mi_cols); + let mut frame = Frame { + planes, + width: h.width, + height: h.height, + ss_x: h.color.subsampling_x, + ss_y: h.color.subsampling_y, + bit_depth: h.color.bit_depth, + color_space: h.color.color_space, + color_range: h.color.color_range, + render_width: h.render_width, + render_height: h.render_height, + key: h.frame_type == KEY_FRAME, + intra_only: h.intra_only, + }; + // Loop filter (8.8). + if self.st.lf.level > 0 { + let lf = LfFrame::new(&self.st.lf, &seg, mi_rows, mi_cols, h.color.subsampling_x, h.color.subsampling_y, h.color.bit_depth); + self.loop_filter(&mut frame, &mi, &lf); + } + // refresh_probs (6.1.2). + if counting { + let saved = &self.st.contexts[h.frame_context_idx as usize]; + let mut a = saved.clone(); + a.tx = fc.tx; + a.skip = fc.skip; + adapt_coef_probs(&mut a, &counts, h.frame_is_intra, self.st.last_frame_type == KEY_FRAME); + if !h.frame_is_intra { + a.tx = saved.tx; + a.skip = saved.skip; + adapt_noncoef_probs(&mut a, &counts, h.interp_filter == SWITCHABLE, h.tx_mode == TX_MODE_SELECT, h.allow_high_precision_mv); + } + fc = a; + } + if h.refresh_frame_context { + self.st.contexts[h.frame_context_idx as usize] = fc; + } + if seg.enabled && seg.update_map { + self.prev_seg_ids = seg_ids; + } + let frame = Arc::new(frame); + for i in 0..8 { + if (h.refresh_frame_flags >> i) & 1 == 1 { + self.slots[i] = Some(frame.clone()); + } + } + self.prev_mi = Some(Arc::new(mi)); + if h.show_frame { + self.stats.shown += 1; + Ok(Some(frame)) + } else { + self.stats.hidden += 1; + Ok(None) + } + } + + fn count_frame(&mut self, h: &FrameHeader) { + let s = &mut self.stats; + s.frames += 1; + if h.frame_type == KEY_FRAME { + s.key_frames += 1; + } else if h.intra_only { + s.intra_only += 1; + } else { + s.inter_frames += 1; + } + s.error_resilient += h.error_resilient_mode as u64; + s.no_backward_adaptation += (h.error_resilient_mode || h.frame_parallel_decoding_mode) as u64; + s.lossless_frames += h.lossless as u64; + s.max_tile_cols = s.max_tile_cols.max(1 << h.tile_cols_log2); + s.max_tile_rows = s.max_tile_rows.max(1 << h.tile_rows_log2); + s.segmentation_frames += self.st.seg.enabled as u64; + s.tx_select_frames += (h.tx_mode == TX_MODE_SELECT) as u64; + s.switchable_interp_frames += (!h.frame_is_intra && h.interp_filter == SWITCHABLE) as u64; + s.high_precision_mv_frames += (!h.frame_is_intra && h.allow_high_precision_mv) as u64; + s.compound_frames += (!h.frame_is_intra && h.reference_mode != 0) as u64; + s.bit_depths[((h.color.bit_depth - 8) >> 1) as usize] += 1; + s.profiles[h.profile as usize] += 1; + } + + /// Run `f(0..n)`, in parallel when a pool is available. + fn run_parallel(&self, n: usize, f: &(dyn Fn(usize) -> T + Sync)) -> Vec { + #[cfg(feature = "threads")] + if let Some(pool) = &self.pool + && n > 1 + { + use rayon::prelude::*; + return pool.install(|| (0..n).into_par_iter().map(f).collect()); + } + (0..n).map(f).collect() + } + + fn loop_filter(&self, frame: &mut Frame, mi: &MiGrid, lf: &LfFrame) { + let sb_rows = lf.mi_rows.div_ceil(8); + let sb_cols = lf.mi_cols.div_ceil(8); + #[cfg(feature = "threads")] + if let Some(pool) = &self.pool + && sb_rows > 1 + { + lf_parallel(pool, self.threads, frame, mi, lf, sb_rows, sb_cols); + return; + } + let _ = self.threads; + let [p0, p1, p2] = &mut frame.planes; + let mut views = [ + PlaneView { stride: p0.stride, data: &mut p0.data, ox: 0, oy: 0 }, + PlaneView { stride: p1.stride, data: &mut p1.data, ox: 0, oy: 0 }, + PlaneView { stride: p2.stride, data: &mut p2.data, ox: 0, oy: 0 }, + ]; + for r in 0..sb_rows { + for c in 0..sb_cols { + filter_superblock(&mut views, mi, lf, r * 8, c * 8); + } + } + } +} + +/// Loop filter with a superblock wavefront: superblock (r, c) runs once (r - 1, c + 1) is done. +/// Each superblock copies the samples it may touch (its area plus 8 samples above / left) out of +/// the shared frame, filters them locally and writes them back. +#[cfg(feature = "threads")] +fn lf_parallel(pool: &rayon::ThreadPool, threads: usize, frame: &mut Frame, mi: &MiGrid, lf: &LfFrame, sb_rows: usize, sb_cols: usize) { + use rayon::prelude::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Condvar, Mutex}; + let strides = [frame.planes[0].stride, frame.planes[1].stride, frame.planes[2].stride]; + let heights = [frame.planes[0].data.len() / strides[0], frame.planes[1].data.len() / strides[1], frame.planes[2].data.len() / strides[2]]; + let planes = Mutex::new([std::mem::take(&mut frame.planes[0].data), std::mem::take(&mut frame.planes[1].data), std::mem::take(&mut frame.planes[2].data)]); + let progress = Mutex::new(vec![0usize; sb_rows]); + let cv = Condvar::new(); + let next = AtomicUsize::new(0); + let sub = [(0usize, 0usize), (lf.ss_x, lf.ss_y), (lf.ss_x, lf.ss_y)]; + pool.install(|| { + (0..threads.min(sb_rows)).into_par_iter().for_each(|_| { + let mut bufs: [Vec; 3] = [vec![0; 72 * 72], vec![0; 72 * 72], vec![0; 72 * 72]]; + loop { + let r = next.fetch_add(1, Ordering::Relaxed); + if r >= sb_rows { + break; + } + for c in 0..sb_cols { + if r > 0 { + let need = (c + 2).min(sb_cols); + let mut p = progress.lock().expect("lf progress"); + while p[r - 1] < need { + p = cv.wait(p).expect("lf progress"); + } + } + // Region of each plane: [x0 - 8, x0 + size) x [y0 - 8, y0 + size). + let mut regions = [(0usize, 0usize, 0usize, 0usize); 3]; + { + let pl = planes.lock().expect("lf planes"); + for p in 0..3 { + let (sx, sy) = sub[p]; + let (x0, y0) = ((c * 64) >> sx, (r * 64) >> sy); + let (rx, ry) = (x0.saturating_sub(8), y0.saturating_sub(8)); + let rw = (x0 + (64 >> sx)).min(strides[p]) - rx; + let rh = (y0 + (64 >> sy)).min(heights[p]) - ry; + regions[p] = (rx, ry, rw, rh); + for yy in 0..rh { + let s = (ry + yy) * strides[p] + rx; + bufs[p][yy * rw..yy * rw + rw].copy_from_slice(&pl[p][s..s + rw]); + } + } + } + { + let [b0, b1, b2] = &mut bufs; + let mut views = [ + PlaneView { data: b0, stride: regions[0].2, ox: regions[0].0, oy: regions[0].1 }, + PlaneView { data: b1, stride: regions[1].2, ox: regions[1].0, oy: regions[1].1 }, + PlaneView { data: b2, stride: regions[2].2, ox: regions[2].0, oy: regions[2].1 }, + ]; + filter_superblock(&mut views, mi, lf, r * 8, c * 8); + } + { + let mut pl = planes.lock().expect("lf planes"); + for p in 0..3 { + let (rx, ry, rw, rh) = regions[p]; + for yy in 0..rh { + let s = (ry + yy) * strides[p] + rx; + pl[p][s..s + rw].copy_from_slice(&bufs[p][yy * rw..yy * rw + rw]); + } + } + } + let mut p = progress.lock().expect("lf progress"); + p[r] = c + 1; + cv.notify_all(); + } + } + }); + }); + let [a, b, c] = planes.into_inner().expect("lf planes"); + frame.planes[0].data = a; + frame.planes[1].data = b; + frame.planes[2].data = c; +} + +/// Build full-frame planes, mode info and segment ids from the tile column strips. +fn assemble(mut strips: Vec, geo: &[(usize, usize, usize, usize); 3], mi_rows: usize, mi_cols: usize) -> ([FPlane; 3], MiGrid, Vec) { + if strips.len() == 1 { + let s = strips.pop().expect("one strip"); + let [a, b, c] = s.planes; + let mk = |d: Vec, g: (usize, usize, usize, usize)| FPlane { data: d, stride: g.0, width: g.2, height: g.3 }; + return ([mk(a, geo[0]), mk(b, geo[1]), mk(c, geo[2])], MiGrid { cols: mi_cols, rows: mi_rows, mi: s.mi }, s.seg_ids); + } + let mut planes = [ + FPlane::new(geo[0].0, geo[0].1, geo[0].2, geo[0].3), + FPlane::new(geo[1].0, geo[1].1, geo[1].2, geo[1].3), + FPlane::new(geo[2].0, geo[2].1, geo[2].2, geo[2].3), + ]; + let mut mi = vec![MiInfo::default(); mi_rows * mi_cols]; + let mut seg = vec![0u8; mi_rows * mi_cols]; + for s in &strips { + for p in 0..3 { + let ss = s.strides[p]; + let fs = planes[p].stride; + let x0 = s.x_off[p]; + let w = ss.min(fs - x0); + let rows = s.planes[p].len() / ss; + for y in 0..rows { + planes[p].data[y * fs + x0..y * fs + x0 + w].copy_from_slice(&s.planes[p][y * ss..y * ss + w]); + } + } + let mw = s.mi_col_end - s.mi_col_start; + for r in 0..mi_rows { + mi[r * mi_cols + s.mi_col_start..r * mi_cols + s.mi_col_end].copy_from_slice(&s.mi[r * mw..r * mw + mw]); + seg[r * mi_cols + s.mi_col_start..r * mi_cols + s.mi_col_end].copy_from_slice(&s.seg_ids[r * mw..r * mw + mw]); + } + } + (planes, MiGrid { cols: mi_cols, rows: mi_rows, mi }, seg) +} + +fn make_picture(f: &Frame, pts: i64) -> Picture { + let crop = |p: &FPlane| -> Vec { + let mut v = Vec::with_capacity(p.width * p.height); + for y in 0..p.height { + v.extend_from_slice(&p.row(y)[..p.width]); + } + v + }; + let conv = |v: Vec| if f.bit_depth == 8 { Plane::U8(v.into_iter().map(|s| s as u8).collect()) } else { Plane::U16(v) }; + let [y, u, v] = &f.planes; + Picture { + width: f.width, + height: f.height, + chroma_width: u.width as u32, + chroma_height: u.height as u32, + bit_depth: f.bit_depth as u32, + subsampling_x: f.ss_x, + subsampling_y: f.ss_y, + y: conv(crop(y)), + u: conv(crop(u)), + v: conv(crop(v)), + y_stride: y.width, + uv_stride: u.width, + pts, + key: f.key, + intra_only: f.intra_only, + color: ColorInfo { color_space: f.color_space, full_range: f.color_range }, + render_width: f.render_width, + render_height: f.render_height, + } +} diff --git a/crates/vp9/src/error.rs b/crates/vp9/src/error.rs new file mode 100644 index 000000000..77e59a27b --- /dev/null +++ b/crates/vp9/src/error.rs @@ -0,0 +1,31 @@ +use filmcraft_bitstream::BitError; + +/// Errors produced by the decoder. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The uncompressed header ended early. + #[error("bitstream error: {0}")] + Bitstream(#[from] BitError), + /// A syntax element had a value outside its legal range, or a size did not fit the data. + #[error("invalid bitstream: {0}")] + Invalid(String), + /// A valid feature this decoder does not implement. + #[error("unsupported: {0}")] + Unsupported(String), + /// An inter frame referenced a reference slot that was never filled (e.g. decoding started + /// at a non-key frame). + #[error("missing reference frame: {0}")] + MissingReference(String), +} + +pub type Result = std::result::Result; + +/// `ensure!(cond, "message")` returns `Error::Invalid` when `cond` is false. +macro_rules! ensure { + ($cond:expr, $($arg:tt)+) => { + if !$cond { + return Err($crate::error::Error::Invalid(format!($($arg)+))); + } + }; +} +pub(crate) use ensure; diff --git a/crates/vp9/src/frame.rs b/crates/vp9/src/frame.rs new file mode 100644 index 000000000..a6ed51b26 --- /dev/null +++ b/crates/vp9/src/frame.rs @@ -0,0 +1,113 @@ +//! Decoded frame storage and the per-8x8 mode info grid. + +use crate::tables::{INTRA_FRAME, NONE}; + +/// A motion vector in 1/8 sample units. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Mv { + pub row: i16, + pub col: i16, +} + +impl Mv { + pub const ZERO: Mv = Mv { row: 0, col: 0 }; + pub fn new(row: i32, col: i32) -> Mv { + Mv { row: row as i16, col: col as i16 } + } +} + +/// Mode info of one 8x8 block (the arrays of 6.4.4: Skips, TxSizes, MiSizes, YModes, SubModes, +/// RefFrames, InterpFilters, Mvs / SubMvs). +#[derive(Clone, Copy, Debug)] +pub struct MiInfo { + pub sb_size: u8, + pub skip: bool, + pub tx_size: u8, + pub y_mode: u8, + pub sub_modes: [u8; 4], + pub seg_id: u8, + pub ref_frame: [i8; 2], + pub interp_filter: u8, + /// SubMvs[refList][b]; Mvs[refList] is `mv[refList][3]`. + pub mv: [[Mv; 4]; 2], +} + +impl Default for MiInfo { + fn default() -> Self { + MiInfo { + sb_size: 0, + skip: false, + tx_size: 0, + y_mode: 0, + sub_modes: [0; 4], + seg_id: 0, + ref_frame: [INTRA_FRAME, NONE], + interp_filter: 0, + mv: [[Mv::ZERO; 4]; 2], + } + } +} + +/// Mode info of a whole frame (MiRows x MiCols). +#[derive(Clone, Debug, Default)] +pub struct MiGrid { + pub cols: usize, + pub rows: usize, + pub mi: Vec, +} + +impl MiGrid { + #[inline] + pub fn at(&self, r: usize, c: usize) -> &MiInfo { + &self.mi[r * self.cols + c] + } +} + +/// One plane of samples. The allocation covers whole 64x64 superblocks; `width` x `height` is the +/// visible (cropped) area. +#[derive(Clone, Debug, Default)] +pub struct Plane { + pub data: Vec, + pub stride: usize, + pub width: usize, + pub height: usize, +} + +impl Plane { + pub fn new(alloc_w: usize, alloc_h: usize, width: usize, height: usize) -> Plane { + Plane { data: vec![0; alloc_w * alloc_h], stride: alloc_w, width, height } + } + + #[inline] + pub fn row(&self, y: usize) -> &[u16] { + &self.data[y * self.stride..(y + 1) * self.stride] + } +} + +/// A decoded frame (also used as reference). +#[derive(Clone, Debug)] +pub struct Frame { + pub planes: [Plane; 3], + pub width: u32, + pub height: u32, + pub ss_x: bool, + pub ss_y: bool, + pub bit_depth: u8, + pub color_space: u8, + pub color_range: bool, + pub render_width: u32, + pub render_height: u32, + pub key: bool, + pub intra_only: bool, +} + +/// Geometry of the frame planes: allocation sizes for (MiCols, MiRows) rounded to superblocks. +pub fn plane_geometry(width: u32, height: u32, ss_x: bool, ss_y: bool) -> [(usize, usize, usize, usize); 3] { + let sb_cols = (width as usize).div_ceil(64); + let sb_rows = (height as usize).div_ceil(64); + let (aw, ah) = (sb_cols * 64, sb_rows * 64); + let (sx, sy) = (ss_x as usize, ss_y as usize); + let cw = (width as usize + sx) >> sx; + let ch = (height as usize + sy) >> sy; + [(aw, ah, width as usize, height as usize), (aw >> sx, ah >> sy, cw, ch), (aw >> sx, ah >> sy, cw, ch)] +} diff --git a/crates/vp9/src/header.rs b/crates/vp9/src/header.rs new file mode 100644 index 000000000..2d76f302f --- /dev/null +++ b/crates/vp9/src/header.rs @@ -0,0 +1,661 @@ +//! Uncompressed header (6.2) and compressed header (6.3) parsing, plus the state that persists +//! between frames (loop filter deltas, segmentation parameters, colour configuration). + +use crate::boolcoder::BoolDecoder; +use crate::error::{Error, Result, ensure}; +use crate::probs::FrameContext; +use crate::tables::*; +use filmcraft_bitstream::BitReader; + +pub const KEY_FRAME: u8 = 0; + +/// Loop filter parameters (6.2.8); the deltas persist across frames. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct LoopFilterParams { + pub level: u8, + pub sharpness: u8, + pub delta_enabled: bool, + pub ref_deltas: [i8; 4], + pub mode_deltas: [i8; 2], +} + +/// Segmentation parameters (6.2.11); feature data persists across frames. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Segmentation { + pub enabled: bool, + pub update_map: bool, + pub tree_probs: [u8; 7], + pub pred_probs: [u8; 3], + pub temporal_update: bool, + pub abs_or_delta_update: bool, + pub feature_enabled: [[bool; 4]; 8], + pub feature_data: [[i16; 4]; 8], +} + +impl Segmentation { + #[inline] + pub fn feature_active(&self, segment_id: u8, feature: usize) -> bool { + self.enabled && self.feature_enabled[segment_id as usize][feature] + } +} + +/// Colour configuration (6.2.2); persists for inter frames. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ColorConfig { + pub bit_depth: u8, + pub color_space: u8, + pub color_range: bool, + pub subsampling_x: bool, + pub subsampling_y: bool, +} + +impl Default for ColorConfig { + fn default() -> Self { + ColorConfig { bit_depth: 8, color_space: 0, color_range: false, subsampling_x: true, subsampling_y: true } + } +} + +/// Everything read from the headers of one frame. +#[derive(Clone, Debug, Default)] +pub struct FrameHeader { + pub profile: u8, + pub show_existing_frame: bool, + pub frame_to_show_map_idx: u8, + pub frame_type: u8, + pub show_frame: bool, + pub error_resilient_mode: bool, + pub intra_only: bool, + pub reset_frame_context: u8, + pub color: ColorConfig, + pub refresh_frame_flags: u8, + pub ref_frame_idx: [u8; 3], + /// Indexed by reference frame (LAST_FRAME..ALTREF_FRAME); index 0 unused. + pub ref_frame_sign_bias: [bool; 4], + pub width: u32, + pub height: u32, + pub render_width: u32, + pub render_height: u32, + pub allow_high_precision_mv: bool, + pub interp_filter: u8, + pub refresh_frame_context: bool, + pub frame_parallel_decoding_mode: bool, + pub frame_context_idx: u8, + pub base_q_idx: u8, + pub delta_q_y_dc: i8, + pub delta_q_uv_dc: i8, + pub delta_q_uv_ac: i8, + pub lossless: bool, + pub tile_cols_log2: u32, + pub tile_rows_log2: u32, + pub header_size_in_bytes: u32, + /// Size of the uncompressed header in bytes. + pub uncompressed_size: usize, + // Compressed header. + pub tx_mode: u8, + pub reference_mode: u8, + pub comp_fixed_ref: i8, + pub comp_var_ref: [i8; 2], + // Derived. + pub mi_cols: u32, + pub mi_rows: u32, + pub sb64_cols: u32, + pub sb64_rows: u32, + pub frame_is_intra: bool, + /// The frame size was taken from reference `found_ref` (for statistics). + pub size_from_ref: bool, +} + +/// Reference slot information needed while parsing (frame_size_with_refs). +#[derive(Clone, Copy, Debug)] +pub struct RefInfo { + pub width: u32, + pub height: u32, +} + +/// State carried between frames that header parsing reads and updates. +pub struct HeaderState { + pub contexts: [FrameContext; 4], + pub lf: LoopFilterParams, + pub seg: Segmentation, + pub color: ColorConfig, + pub last_frame_type: u8, + pub frame_type: u8, + /// Set by setup_past_independence; cleared once consumed by the decoder (PrevSegmentIds reset). + pub reset_segment_map: bool, +} + +impl Default for HeaderState { + fn default() -> Self { + let fc = FrameContext::default(); + HeaderState { + contexts: [fc.clone(), fc.clone(), fc.clone(), fc], + lf: LoopFilterParams { ref_deltas: [1, 0, -1, -1], delta_enabled: true, ..Default::default() }, + seg: Segmentation::default(), + color: ColorConfig::default(), + last_frame_type: KEY_FRAME, + frame_type: KEY_FRAME, + reset_segment_map: false, + } + } +} + +fn read_s(r: &mut BitReader, bits: u32) -> Result { + let v = r.read_bits(bits)? as i32; + Ok(if r.read_flag()? { -v } else { v }) +} + +fn read_delta_q(r: &mut BitReader) -> Result { + Ok(if r.read_flag()? { read_s(r, 4)? as i8 } else { 0 }) +} + +fn read_prob(r: &mut BitReader) -> Result { + Ok(if r.read_flag()? { r.read_bits(8)? as u8 } else { 255 }) +} + +fn frame_sync_code(r: &mut BitReader) -> Result<()> { + let (a, b, c) = (r.read_bits(8)?, r.read_bits(8)?, r.read_bits(8)?); + ensure!(a == 0x49 && b == 0x83 && c == 0x42, "invalid frame sync code"); + Ok(()) +} + +fn color_config(r: &mut BitReader, profile: u8) -> Result { + let bit_depth = if profile >= 2 { if r.read_flag()? { 12 } else { 10 } } else { 8 }; + let color_space = r.read_bits(3)? as u8; + let mut c = ColorConfig { bit_depth, color_space, color_range: true, subsampling_x: true, subsampling_y: true }; + if color_space != 7 { + c.color_range = r.read_flag()?; + if profile == 1 || profile == 3 { + c.subsampling_x = r.read_flag()?; + c.subsampling_y = r.read_flag()?; + ensure!(!(c.subsampling_x && c.subsampling_y), "4:2:0 subsampling in profile {profile}"); + ensure!(!r.read_flag()?, "reserved bit set in color config"); + } + } else { + ensure!(profile == 1 || profile == 3, "RGB color space in profile {profile}"); + c.subsampling_x = false; + c.subsampling_y = false; + ensure!(!r.read_flag()?, "reserved bit set in color config"); + } + Ok(c) +} + +fn frame_size(r: &mut BitReader, h: &mut FrameHeader) -> Result<()> { + h.width = r.read_bits(16)? + 1; + h.height = r.read_bits(16)? + 1; + Ok(()) +} + +fn render_size(r: &mut BitReader, h: &mut FrameHeader) -> Result<()> { + if r.read_flag()? { + h.render_width = r.read_bits(16)? + 1; + h.render_height = r.read_bits(16)? + 1; + } else { + h.render_width = h.width; + h.render_height = h.height; + } + Ok(()) +} + +fn compute_image_size(h: &mut FrameHeader) { + h.mi_cols = (h.width + 7) >> 3; + h.mi_rows = (h.height + 7) >> 3; + h.sb64_cols = (h.mi_cols + 7) >> 3; + h.sb64_rows = (h.mi_rows + 7) >> 3; +} + +/// setup_past_independence (7.2): resets segmentation features, loop filter deltas and the +/// probabilities of `fc`. +fn setup_past_independence(st: &mut HeaderState, fc: &mut FrameContext) { + st.seg.feature_data = [[0; 4]; 8]; + st.seg.feature_enabled = [[false; 4]; 8]; + st.seg.abs_or_delta_update = false; + st.lf.delta_enabled = true; + st.lf.ref_deltas = [1, 0, -1, -1]; + st.lf.mode_deltas = [0, 0]; + st.reset_segment_map = true; + *fc = FrameContext::default(); +} + +/// Parse the uncompressed header (6.2). `refs` gives the sizes of the eight reference slots. +/// Updates the persistent state (probability contexts on reset, loop filter deltas, +/// segmentation, colour configuration). +pub fn parse_uncompressed(data: &[u8], st: &mut HeaderState, refs: &[Option; 8]) -> Result { + let mut r = BitReader::new(data); + let mut h = FrameHeader::default(); + ensure!(r.read_bits(2)? == 2, "invalid frame marker"); + let lo = r.read_bits(1)? as u8; + let hi = r.read_bits(1)? as u8; + h.profile = (hi << 1) | lo; + if h.profile == 3 { + ensure!(r.read_bits(1)? == 0, "reserved bit set after profile"); + } + h.show_existing_frame = r.read_flag()?; + if h.show_existing_frame { + h.frame_to_show_map_idx = r.read_bits(3)? as u8; + h.show_frame = true; + h.uncompressed_size = r.position().div_ceil(8); + return Ok(h); + } + st.last_frame_type = st.frame_type; + h.frame_type = r.read_bits(1)? as u8; + h.show_frame = r.read_flag()?; + h.error_resilient_mode = r.read_flag()?; + if h.frame_type == KEY_FRAME { + frame_sync_code(&mut r)?; + h.color = color_config(&mut r, h.profile)?; + frame_size(&mut r, &mut h)?; + render_size(&mut r, &mut h)?; + h.refresh_frame_flags = 0xff; + h.frame_is_intra = true; + } else { + h.intra_only = if h.show_frame { false } else { r.read_flag()? }; + h.frame_is_intra = h.intra_only; + h.reset_frame_context = if h.error_resilient_mode { 0 } else { r.read_bits(2)? as u8 }; + if h.intra_only { + frame_sync_code(&mut r)?; + h.color = if h.profile > 0 { + color_config(&mut r, h.profile)? + } else { + ColorConfig { bit_depth: 8, color_space: 1, color_range: false, subsampling_x: true, subsampling_y: true } + }; + h.refresh_frame_flags = r.read_bits(8)? as u8; + frame_size(&mut r, &mut h)?; + render_size(&mut r, &mut h)?; + } else { + h.color = st.color; + h.refresh_frame_flags = r.read_bits(8)? as u8; + for i in 0..3 { + h.ref_frame_idx[i] = r.read_bits(3)? as u8; + h.ref_frame_sign_bias[LAST_FRAME as usize + i] = r.read_flag()?; + } + // frame_size_with_refs (6.2.5) + let mut found = false; + for i in 0..3 { + if r.read_flag()? { + let idx = h.ref_frame_idx[i] as usize; + let Some(info) = refs[idx] else { + return Err(Error::MissingReference(format!("frame size from empty slot {idx}"))); + }; + h.width = info.width; + h.height = info.height; + found = true; + break; + } + } + if !found { + frame_size(&mut r, &mut h)?; + } + h.size_from_ref = found; + render_size(&mut r, &mut h)?; + h.allow_high_precision_mv = r.read_flag()?; + // read_interpolation_filter (6.2.7) + h.interp_filter = if r.read_flag()? { SWITCHABLE } else { LITERAL_TO_TYPE[r.read_bits(2)? as usize] }; + } + } + compute_image_size(&mut h); + if !h.error_resilient_mode { + h.refresh_frame_context = r.read_flag()?; + h.frame_parallel_decoding_mode = r.read_flag()?; + } else { + h.refresh_frame_context = false; + h.frame_parallel_decoding_mode = true; + } + h.frame_context_idx = r.read_bits(2)? as u8; + if h.frame_is_intra || h.error_resilient_mode { + let mut fc = FrameContext::default(); + setup_past_independence(st, &mut fc); + if h.frame_type == KEY_FRAME || h.error_resilient_mode || h.reset_frame_context == 3 { + for c in st.contexts.iter_mut() { + *c = fc.clone(); + } + } else if h.reset_frame_context == 2 { + st.contexts[h.frame_context_idx as usize] = fc.clone(); + } + h.frame_context_idx = 0; + } + // loop_filter_params (6.2.8) + st.lf.level = r.read_bits(6)? as u8; + st.lf.sharpness = r.read_bits(3)? as u8; + st.lf.delta_enabled = r.read_flag()?; + if st.lf.delta_enabled && r.read_flag()? { + for i in 0..4 { + if r.read_flag()? { + st.lf.ref_deltas[i] = read_s(&mut r, 6)? as i8; + } + } + for i in 0..2 { + if r.read_flag()? { + st.lf.mode_deltas[i] = read_s(&mut r, 6)? as i8; + } + } + } + // quantization_params (6.2.9) + h.base_q_idx = r.read_bits(8)? as u8; + h.delta_q_y_dc = read_delta_q(&mut r)?; + h.delta_q_uv_dc = read_delta_q(&mut r)?; + h.delta_q_uv_ac = read_delta_q(&mut r)?; + h.lossless = h.base_q_idx == 0 && h.delta_q_y_dc == 0 && h.delta_q_uv_dc == 0 && h.delta_q_uv_ac == 0; + // segmentation_params (6.2.11) + let seg = &mut st.seg; + seg.enabled = r.read_flag()?; + seg.update_map = false; + seg.temporal_update = false; + if seg.enabled { + seg.update_map = r.read_flag()?; + if seg.update_map { + for i in 0..7 { + seg.tree_probs[i] = read_prob(&mut r)?; + } + seg.temporal_update = r.read_flag()?; + for i in 0..3 { + seg.pred_probs[i] = if seg.temporal_update { read_prob(&mut r)? } else { 255 }; + } + } + if r.read_flag()? { + seg.abs_or_delta_update = r.read_flag()?; + for i in 0..MAX_SEGMENTS { + for j in 0..4 { + let mut value = 0i32; + let enabled = r.read_flag()?; + seg.feature_enabled[i][j] = enabled; + if enabled { + value = r.read_bits(SEGMENTATION_FEATURE_BITS[j])? as i32; + if SEGMENTATION_FEATURE_SIGNED[j] && r.read_flag()? { + value = -value; + } + } + seg.feature_data[i][j] = value as i16; + } + } + } + } + // tile_info (6.2.13) + let mut min_log2 = 0; + while (MAX_TILE_WIDTH_B64 << min_log2) < h.sb64_cols { + min_log2 += 1; + } + let mut max_log2 = 1; + while (h.sb64_cols >> max_log2) >= MIN_TILE_WIDTH_B64 { + max_log2 += 1; + } + max_log2 -= 1; + h.tile_cols_log2 = min_log2; + while h.tile_cols_log2 < max_log2 { + if r.read_flag()? { + h.tile_cols_log2 += 1; + } else { + break; + } + } + h.tile_rows_log2 = r.read_bits(1)?; + if h.tile_rows_log2 == 1 { + h.tile_rows_log2 += r.read_bits(1)?; + } + h.header_size_in_bytes = r.read_bits(16)?; + // trailing_bits + h.uncompressed_size = r.position().div_ceil(8); + if h.frame_is_intra { + st.color = h.color; + } + st.frame_type = h.frame_type; + ensure!(h.header_size_in_bytes > 0, "empty compressed header"); + Ok(h) +} + +/// diff_update_prob (6.3.3). +fn diff_update_prob(bd: &mut BoolDecoder, prob: &mut u8) { + if bd.read_bool(252) { + let delta = decode_term_subexp(bd); + *prob = inv_remap_prob(delta, *prob); + } +} + +fn decode_term_subexp(bd: &mut BoolDecoder) -> usize { + if bd.read_literal(1) == 0 { + return bd.read_literal(4) as usize; + } + if bd.read_literal(1) == 0 { + return bd.read_literal(4) as usize + 16; + } + if bd.read_literal(1) == 0 { + return bd.read_literal(5) as usize + 32; + } + let v = bd.read_literal(7) as usize; + if v < 65 { + return v + 64; + } + let bit = bd.read_literal(1) as usize; + (v << 1) - 1 + bit +} + +fn inv_recenter_nonneg(v: i32, m: i32) -> i32 { + if v > 2 * m { + v + } else if v & 1 != 0 { + m - ((v + 1) >> 1) + } else { + m + (v >> 1) + } +} + +fn inv_remap_prob(delta: usize, prob: u8) -> u8 { + let v = INV_MAP_TABLE[delta.min(254)] as i32; + let m = prob as i32 - 1; + let r = if (m << 1) <= 255 { 1 + inv_recenter_nonneg(v, m) } else { 255 - inv_recenter_nonneg(v, 255 - 1 - m) }; + r.clamp(1, 255) as u8 +} + +/// update_mv_prob (6.3.17). +fn update_mv_prob(bd: &mut BoolDecoder, prob: &mut u8) { + if bd.read_bool(252) { + *prob = ((bd.read_literal(7) << 1) | 1) as u8; + } +} + +/// Parse the compressed header (6.3) into `h` and the probabilities `fc`. +pub fn parse_compressed(data: &[u8], h: &mut FrameHeader, fc: &mut FrameContext) -> Result<()> { + let mut bd = BoolDecoder::new(data); + // read_tx_mode (6.3.1) + h.tx_mode = if h.lossless { + 0 + } else { + let mut m = bd.read_literal(2) as u8; + if m == 3 { + m += bd.read_literal(1) as u8; + } + m + }; + if h.tx_mode == TX_MODE_SELECT { + for i in 0..2 { + diff_update_prob(&mut bd, &mut fc.tx[1][i][0]); + } + for i in 0..2 { + for j in 0..2 { + diff_update_prob(&mut bd, &mut fc.tx[2][i][j]); + } + } + for i in 0..2 { + for j in 0..3 { + diff_update_prob(&mut bd, &mut fc.tx[3][i][j]); + } + } + } + // read_coef_probs (6.3.7) + let max_tx = TX_MODE_TO_BIGGEST_TX_SIZE[h.tx_mode as usize] as usize; + for tx in 0..=max_tx { + if bd.read_literal(1) == 1 { + for i in 0..2 { + for j in 0..2 { + for k in 0..6 { + let max_l = if k == 0 { 3 } else { 6 }; + for l in 0..max_l { + for m in 0..3 { + diff_update_prob(&mut bd, &mut fc.coef[tx][i][j][k][l][m]); + } + } + } + } + } + } + } + for i in 0..3 { + diff_update_prob(&mut bd, &mut fc.skip[i]); + } + if !h.frame_is_intra { + for i in 0..7 { + for j in 0..3 { + diff_update_prob(&mut bd, &mut fc.inter_mode[i][j]); + } + } + if h.interp_filter == SWITCHABLE { + for j in 0..4 { + for i in 0..2 { + diff_update_prob(&mut bd, &mut fc.interp_filter[j][i]); + } + } + } + for i in 0..4 { + diff_update_prob(&mut bd, &mut fc.is_inter[i]); + } + // frame_reference_mode (6.3.12) + let bias = h.ref_frame_sign_bias; + let compound_allowed = bias[2] != bias[1] || bias[3] != bias[1]; + h.reference_mode = 0; + if compound_allowed { + if bd.read_literal(1) == 1 { + h.reference_mode = if bd.read_literal(1) == 1 { 2 } else { 1 }; + } + // setup_compound_reference_mode (6.3.18) + if bias[LAST_FRAME as usize] == bias[GOLDEN_FRAME as usize] { + h.comp_fixed_ref = ALTREF_FRAME; + h.comp_var_ref = [LAST_FRAME, GOLDEN_FRAME]; + } else if bias[LAST_FRAME as usize] == bias[ALTREF_FRAME as usize] { + h.comp_fixed_ref = GOLDEN_FRAME; + h.comp_var_ref = [LAST_FRAME, ALTREF_FRAME]; + } else { + h.comp_fixed_ref = LAST_FRAME; + h.comp_var_ref = [GOLDEN_FRAME, ALTREF_FRAME]; + } + } + // frame_reference_mode_probs (6.3.13) + if h.reference_mode == 2 { + for i in 0..5 { + diff_update_prob(&mut bd, &mut fc.comp_mode[i]); + } + } + if h.reference_mode != 1 { + for i in 0..5 { + diff_update_prob(&mut bd, &mut fc.single_ref[i][0]); + diff_update_prob(&mut bd, &mut fc.single_ref[i][1]); + } + } + if h.reference_mode != 0 { + for i in 0..5 { + diff_update_prob(&mut bd, &mut fc.comp_ref[i]); + } + } + for i in 0..4 { + for j in 0..9 { + diff_update_prob(&mut bd, &mut fc.y_mode[i][j]); + } + } + for i in 0..16 { + for j in 0..3 { + diff_update_prob(&mut bd, &mut fc.partition[i][j]); + } + } + // mv_probs (6.3.16) + for j in 0..3 { + update_mv_prob(&mut bd, &mut fc.mv_joint[j]); + } + for i in 0..2 { + update_mv_prob(&mut bd, &mut fc.mv_sign[i]); + for j in 0..10 { + update_mv_prob(&mut bd, &mut fc.mv_class[i][j]); + } + update_mv_prob(&mut bd, &mut fc.mv_class0_bit[i]); + for j in 0..10 { + update_mv_prob(&mut bd, &mut fc.mv_bits[i][j]); + } + } + for i in 0..2 { + for j in 0..2 { + for k in 0..3 { + update_mv_prob(&mut bd, &mut fc.mv_class0_fr[i][j][k]); + } + } + for k in 0..3 { + update_mv_prob(&mut bd, &mut fc.mv_fr[i][k]); + } + } + if h.allow_high_precision_mv { + for i in 0..2 { + update_mv_prob(&mut bd, &mut fc.mv_class0_hp[i]); + update_mv_prob(&mut bd, &mut fc.mv_hp[i]); + } + } + } + Ok(()) +} + +/// Superframe index parsing (Annex B): the frames of a chunk. +pub fn split_superframe(data: &[u8]) -> Vec<&[u8]> { + if let Some(&last) = data.last() + && last & 0xe0 == 0xc0 + { + let sz_bytes = ((last >> 3) & 3) as usize + 1; + let num = (last & 7) as usize + 1; + let index_size = 2 + num * sz_bytes; + if data.len() >= index_size && data[data.len() - index_size] == last { + let mut frames = Vec::with_capacity(num); + let mut pos = 0usize; + let mut p = data.len() - index_size + 1; + let end = data.len() - index_size; + for _ in 0..num { + let mut sz = 0usize; + for b in 0..sz_bytes { + sz |= (data[p + b] as usize) << (8 * b); + } + p += sz_bytes; + if pos + sz > end { + frames.push(&data[pos..end]); + return frames; + } + frames.push(&data[pos..pos + sz]); + pos += sz; + } + return frames; + } + } + vec![data] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn superframe_index() { + // Two frames of 3 and 2 bytes, 1 byte per size. + let mut d = vec![1u8, 2, 3, 4, 5]; + let marker = 0xc0 | 1; // 2 frames, 1 byte sizes + d.extend([marker, 3, 2, marker]); + let f = split_superframe(&d); + assert_eq!(f, vec![&[1u8, 2, 3][..], &[4u8, 5][..]]); + // Not a superframe: last byte is a marker but the first index byte does not match. + let d2 = vec![9u8, 9, 0xc1]; + assert_eq!(split_superframe(&d2), vec![&d2[..]]); + } + + #[test] + fn inv_remap_is_bijective_per_prob() { + for p in 1..=255u8 { + let mut seen = std::collections::HashSet::new(); + for d in 0..254 { + seen.insert(inv_remap_prob(d, p)); + } + // Every other probability value is reachable. + assert!(seen.len() >= 253, "p {p}: {}", seen.len()); + } + } +} diff --git a/crates/vp9/src/inter.rs b/crates/vp9/src/inter.rs new file mode 100644 index 000000000..5b3a7266b --- /dev/null +++ b/crates/vp9/src/inter.rs @@ -0,0 +1,185 @@ +//! Block inter prediction process (8.5.2.4): separable 8-tap sub-sample interpolation with edge +//! clamping, for unscaled (step 16) and scaled references. + +use crate::tables::SUBPEL_FILTERS; + +/// Filter taps of `filter` (0..3) at sub-sample position `frac` (0..15). +#[inline(always)] +fn taps(filter: u8, frac: usize) -> &'static [i16] { + let o = (filter as usize * 16 + frac) * 8; + &SUBPEL_FILTERS[o..o + 8] +} + +/// A reference plane: samples addressed `data[y * stride + x]`, valid for x <= last_x, y <= last_y. +pub struct RefPlane<'a> { + pub data: &'a [u16], + pub stride: usize, + pub last_x: i32, + pub last_y: i32, +} + +/// Predict a `w` x `h` block whose top-left sample is at (`x`, `y`) in 1/16 sample units of the +/// reference, stepping `step_x` / `step_y` (16 = unscaled). Output samples go to `out` (stride +/// `w`). `tmp` must hold at least `(h * step_y / 16 + 8) * w` entries. +#[allow(clippy::too_many_arguments)] +pub fn predict(r: &RefPlane, x: i32, y: i32, step_x: i32, step_y: i32, w: usize, h: usize, filter: u8, bit_depth: u8, out: &mut [u16], tmp: &mut [u16]) { + if step_x == 16 && step_y == 16 { + predict_unscaled(r, x, y, w, h, filter, bit_depth, out, tmp); + } else { + predict_scaled(r, x, y, step_x, step_y, w, h, filter, bit_depth, out, tmp); + } +} + +/// Copy the (w + 7) x (h + 7) source window starting at (x0 - 3, y0 - 3) with edge clamping. +fn fetch_window(r: &RefPlane, x0: i32, y0: i32, w: usize, h: usize, win: &mut [u16]) -> usize { + let ws = w + 7; + for row in 0..h + 7 { + let yy = (y0 - 3 + row as i32).clamp(0, r.last_y) as usize; + let src = &r.data[yy * r.stride..]; + let dst = &mut win[row * ws..row * ws + ws]; + let xs = x0 - 3; + if xs >= 0 && xs + ws as i32 - 1 <= r.last_x { + dst.copy_from_slice(&src[xs as usize..xs as usize + ws]); + } else { + for (c, d) in dst.iter_mut().enumerate() { + *d = src[(xs + c as i32).clamp(0, r.last_x) as usize]; + } + } + } + ws +} + +#[allow(clippy::too_many_arguments)] +fn predict_unscaled(r: &RefPlane, x: i32, y: i32, w: usize, h: usize, filter: u8, bit_depth: u8, out: &mut [u16], tmp: &mut [u16]) { + let (x0, y0) = (x >> 4, y >> 4); + let (fx, fy) = ((x & 15) as usize, (y & 15) as usize); + let max = (1i32 << bit_depth) - 1; + let inside = x0 - 3 >= 0 && y0 - 3 >= 0 && x0 + w as i32 + 4 <= r.last_x && y0 + h as i32 + 4 <= r.last_y; + // Source view with origin at (x0 - 3, y0 - 3). + let mut win = [0u16; 71 * 71]; + let (src, ss, so): (&[u16], usize, usize) = if inside { + (r.data, r.stride, (y0 - 3) as usize * r.stride + (x0 - 3) as usize) + } else { + let ws = fetch_window(r, x0, y0, w, h, &mut win); + (&win[..], ws, 0) + }; + match (fx, fy) { + (0, 0) => { + for i in 0..h { + let s = so + (i + 3) * ss + 3; + out[i * w..i * w + w].copy_from_slice(&src[s..s + w]); + } + } + (_, 0) => { + let f = taps(filter, fx); + for i in 0..h { + let s = &src[so + (i + 3) * ss..]; + let o = &mut out[i * w..i * w + w]; + for (j, d) in o.iter_mut().enumerate() { + let p = &s[j..j + 8]; + let mut acc = 0i32; + for t in 0..8 { + acc += f[t] as i32 * p[t] as i32; + } + *d = ((acc + 64) >> 7).clamp(0, max) as u16; + } + } + } + (0, _) => { + let f = taps(filter, fy); + for i in 0..h { + let o = &mut out[i * w..i * w + w]; + for (j, d) in o.iter_mut().enumerate() { + let base = so + i * ss + j + 3; + let mut acc = 0i32; + for t in 0..8 { + acc += f[t] as i32 * src[base + t * ss] as i32; + } + *d = ((acc + 64) >> 7).clamp(0, max) as u16; + } + } + } + _ => { + let fh = taps(filter, fx); + let fv = taps(filter, fy); + for i in 0..h + 7 { + let s = &src[so + i * ss..]; + let o = &mut tmp[i * w..i * w + w]; + for (j, d) in o.iter_mut().enumerate() { + let p = &s[j..j + 8]; + let mut acc = 0i32; + for t in 0..8 { + acc += fh[t] as i32 * p[t] as i32; + } + *d = ((acc + 64) >> 7).clamp(0, max) as u16; + } + } + for i in 0..h { + let o = &mut out[i * w..i * w + w]; + for (j, d) in o.iter_mut().enumerate() { + let mut acc = 0i32; + for t in 0..8 { + acc += fv[t] as i32 * tmp[(i + t) * w + j] as i32; + } + *d = ((acc + 64) >> 7).clamp(0, max) as u16; + } + } + } + } +} + +/// General process of 8.5.2.4 (any step). +#[allow(clippy::too_many_arguments)] +fn predict_scaled(r: &RefPlane, x: i32, y: i32, step_x: i32, step_y: i32, w: usize, h: usize, filter: u8, bit_depth: u8, out: &mut [u16], tmp: &mut [u16]) { + let max = (1i32 << bit_depth) - 1; + let ih = ((((h as i32 - 1) * step_y + 15) >> 4) + 8) as usize; + for row in 0..ih { + let yy = ((y >> 4) + row as i32 - 3).clamp(0, r.last_y) as usize; + let src = &r.data[yy * r.stride..]; + for c in 0..w { + let p = x + step_x * c as i32; + let f = taps(filter, (p & 15) as usize); + let mut acc = 0i32; + for t in 0..8 { + acc += f[t] as i32 * src[((p >> 4) + t as i32 - 3).clamp(0, r.last_x) as usize] as i32; + } + tmp[row * w + c] = ((acc + 64) >> 7).clamp(0, max) as u16; + } + } + for rr in 0..h { + let p = (y & 15) + step_y * rr as i32; + let f = taps(filter, (p & 15) as usize); + let base = (p >> 4) as usize; + for c in 0..w { + let mut acc = 0i32; + for t in 0..8 { + acc += f[t] as i32 * tmp[(base + t) * w + c] as i32; + } + out[rr * w + c] = ((acc + 64) >> 7).clamp(0, max) as u16; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scaled_path_with_unit_step_matches_unscaled() { + let (w, h) = (40usize, 30usize); + let data: Vec = (0..w * h).map(|i| ((i * 37 + (i / w) * 11) % 256) as u16).collect(); + let r = RefPlane { data: &data, stride: w, last_x: w as i32 - 1, last_y: h as i32 - 1 }; + let mut tmp = vec![0u16; 80 * 80]; + for filter in 0..4u8 { + for &(x, y) in &[(0, 0), (5, 3), (16 * 7 + 5, 16 * 9 + 11), (-40, -3), (16 * 36 + 1, 16 * 25 + 15), (16 * 10, 16 * 10 + 8)] { + for &(bw, bh) in &[(4usize, 4usize), (8, 8), (16, 8)] { + let mut a = vec![0u16; bw * bh]; + let mut b = vec![0u16; bw * bh]; + predict_unscaled(&r, x, y, bw, bh, filter, 8, &mut a, &mut tmp); + predict_scaled(&r, x, y, 16, 16, bw, bh, filter, 8, &mut b, &mut tmp); + assert_eq!(a, b, "filter {filter} pos ({x},{y}) {bw}x{bh}"); + } + } + } + } +} diff --git a/crates/vp9/src/intra.rs b/crates/vp9/src/intra.rs new file mode 100644 index 000000000..9c797eea3 --- /dev/null +++ b/crates/vp9/src/intra.rs @@ -0,0 +1,214 @@ +//! Intra prediction process (8.5.1). + +use crate::tables::*; + +/// Edge availability and geometry of one intra-predicted transform block. +pub struct IntraEdge { + pub have_left: bool, + pub have_above: bool, + pub not_on_right: bool, + pub tx_size: u8, + /// Last valid column / row of the plane (in plane coordinates). + pub max_x: usize, + pub max_y: usize, + pub bit_depth: u8, +} + +/// Predict a `4 << tx_size` square block at (`x`, `y`) of a plane buffer. `buf` is addressed as +/// `buf[y * stride + x - x_off]` (the buffer may be a tile-column strip starting at column `x_off`). +pub fn predict(buf: &mut [u16], stride: usize, x_off: usize, x: usize, y: usize, mode: u8, e: &IntraEdge) { + let log2 = e.tx_size as usize + 2; + let size = 1usize << log2; + let base = 1i32 << (e.bit_depth - 1); + let at = |xx: usize, yy: usize| yy * stride + xx - x_off; + // aboveRow[-1..2*size) stored at above[0..2*size+1); leftCol[0..size). + let mut above = [0i32; 65]; + let mut left = [0i32; 32]; + if !e.have_above { + above[1..=2 * size].fill(base - 1); + } else { + let row = (y - 1) * stride; + for i in 0..size { + above[1 + i] = buf[row + (x + i).min(e.max_x) - x_off] as i32; + } + if e.not_on_right && e.tx_size == 0 { + for i in size..2 * size { + above[1 + i] = buf[row + (x + i).min(e.max_x) - x_off] as i32; + } + } else { + let v = above[size]; + above[1 + size..=2 * size].fill(v); + } + } + above[0] = if e.have_above && e.have_left { + buf[at((x - 1).min(e.max_x), y - 1)] as i32 + } else if e.have_above { + base + 1 + } else { + base - 1 + }; + if e.have_left { + for i in 0..size { + left[i] = buf[at(x - 1, (y + i).min(e.max_y))] as i32; + } + } else { + left[..size].fill(base + 1); + } + let a = |i: isize| above[(i + 1) as usize]; + let max = (1i32 << e.bit_depth) - 1; + let o = at(x, y); + let mut put = |i: usize, j: usize, v: i32| buf[o + i * stride + j] = v as u16; + let r1 = |v: i32| (v + 1) >> 1; + let r2 = |v: i32| (v + 2) >> 2; + match mode { + V_PRED => { + for i in 0..size { + for j in 0..size { + put(i, j, a(j as isize)); + } + } + } + H_PRED => { + for i in 0..size { + for j in 0..size { + put(i, j, left[i]); + } + } + } + D207_PRED => { + let mut p = [[0i32; 32]; 32]; + for j in 0..size { + p[size - 1][j] = left[size - 1]; + } + for i in 0..size - 1 { + p[i][0] = r1(left[i] + left[i + 1]); + } + for i in 0..size.saturating_sub(2) { + p[i][1] = r2(left[i] + 2 * left[i + 1] + left[i + 2]); + } + p[size - 2][1] = r2(left[size - 2] + 3 * left[size - 1]); + for j in 2..size { + for i in (0..size - 1).rev() { + p[i][j] = p[i + 1][j - 2]; + } + } + for i in 0..size { + for j in 0..size { + put(i, j, p[i][j]); + } + } + } + D45_PRED => { + for i in 0..size { + for j in 0..size { + let k = (i + j) as isize; + let v = if i + j + 2 < 2 * size { r2(a(k) + 2 * a(k + 1) + a(k + 2)) } else { a(2 * size as isize - 1) }; + put(i, j, v); + } + } + } + D63_PRED => { + for i in 0..size { + for j in 0..size { + let k = (i / 2 + j) as isize; + let v = if i & 1 == 1 { r2(a(k) + 2 * a(k + 1) + a(k + 2)) } else { r1(a(k) + a(k + 1)) }; + put(i, j, v); + } + } + } + D117_PRED => { + let mut p = [[0i32; 32]; 32]; + for j in 0..size { + p[0][j] = r1(a(j as isize - 1) + a(j as isize)); + } + p[1][0] = r2(left[0] + 2 * a(-1) + a(0)); + for j in 1..size { + p[1][j] = r2(a(j as isize - 2) + 2 * a(j as isize - 1) + a(j as isize)); + } + p[2][0] = r2(a(-1) + 2 * left[0] + left[1]); + for i in 3..size { + p[i][0] = r2(left[i - 3] + 2 * left[i - 2] + left[i - 1]); + } + for i in 2..size { + for j in 1..size { + p[i][j] = p[i - 2][j - 1]; + } + } + for i in 0..size { + for j in 0..size { + put(i, j, p[i][j]); + } + } + } + D135_PRED => { + let mut p = [[0i32; 32]; 32]; + p[0][0] = r2(left[0] + 2 * a(-1) + a(0)); + for j in 1..size { + p[0][j] = r2(a(j as isize - 2) + 2 * a(j as isize - 1) + a(j as isize)); + } + p[1][0] = r2(a(-1) + 2 * left[0] + left[1]); + for i in 2..size { + p[i][0] = r2(left[i - 2] + 2 * left[i - 1] + left[i]); + } + for i in 1..size { + for j in 1..size { + p[i][j] = p[i - 1][j - 1]; + } + } + for i in 0..size { + for j in 0..size { + put(i, j, p[i][j]); + } + } + } + D153_PRED => { + let mut p = [[0i32; 32]; 32]; + p[0][0] = r1(left[0] + a(-1)); + for i in 1..size { + p[i][0] = r1(left[i - 1] + left[i]); + } + p[0][1] = r2(left[0] + 2 * a(-1) + a(0)); + p[1][1] = r2(a(-1) + 2 * left[0] + left[1]); + for i in 2..size { + p[i][1] = r2(left[i - 2] + 2 * left[i - 1] + left[i]); + } + for j in 2..size { + p[0][j] = r2(a(j as isize - 3) + 2 * a(j as isize - 2) + a(j as isize - 1)); + } + for i in 1..size { + for j in 2..size { + p[i][j] = p[i - 1][j - 2]; + } + } + for i in 0..size { + for j in 0..size { + put(i, j, p[i][j]); + } + } + } + TM_PRED => { + for i in 0..size { + for j in 0..size { + put(i, j, (a(j as isize) + left[i] - a(-1)).clamp(0, max)); + } + } + } + _ => { + // DC_PRED + let v = match (e.have_left, e.have_above) { + (true, true) => { + let sum: i32 = left[..size].iter().sum::() + above[1..=size].iter().sum::(); + (sum + size as i32) >> (log2 + 1) + } + (true, false) => (left[..size].iter().sum::() + (1 << (log2 - 1))) >> log2, + (false, true) => (above[1..=size].iter().sum::() + (1 << (log2 - 1))) >> log2, + (false, false) => base, + }; + for i in 0..size { + for j in 0..size { + put(i, j, v); + } + } + } + } +} diff --git a/crates/vp9/src/lib.rs b/crates/vp9/src/lib.rs new file mode 100644 index 000000000..a6fe0ff5d --- /dev/null +++ b/crates/vp9/src/lib.rs @@ -0,0 +1,124 @@ +//! Clean-room pure-Rust VP9 decoder, implemented from the public VP9 Bitstream & Decoding Process +//! Specification (v0.6, WebM project). +//! +//! Supported: profiles 0-3 (8 / 10 / 12-bit; 4:2:0, 4:2:2, 4:4:0, 4:4:4), all coding tools: +//! boolean decoder, forward and backward probability adaptation, segmentation, loop filter with +//! deltas, all intra modes, inter prediction (8-tap regular / smooth / sharp and bilinear, +//! scaled references), compound prediction, motion vector prediction, DCT / ADST 4..32 and the +//! Walsh-Hadamard lossless transform, tiles (tile columns decode in parallel), superframes, +//! hidden frames, show_existing_frame, intra-only frames, reference frame resizing and error +//! resilient / frame parallel modes. +//! +//! ```no_run +//! let mut dec = filmcraft_vp9::Decoder::new(); +//! let chunk: Vec = Vec::new(); // one IVF / WebM frame +//! for pic in dec.decode(&chunk, 0).unwrap() { +//! println!("{}x{} {}-bit", pic.width, pic.height, pic.bit_depth); +//! } +//! ``` + +#![allow(clippy::needless_range_loop, clippy::too_many_arguments)] + +mod boolcoder; +mod decoder; +mod error; +mod frame; +mod header; +mod inter; +mod intra; +mod loopfilter; +mod probs; +#[rustfmt::skip] +#[allow(dead_code)] +mod spec_tables; +mod tables; +mod tile; +mod transform; +#[rustfmt::skip] +#[allow(dead_code, clippy::all)] +mod transform_gen; + +pub use decoder::{DecodeStats, Decoder}; +pub use error::{Error, Result}; + +/// Colour information from the uncompressed header. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ColorInfo { + /// `color_space` (0 unknown, 1 BT.601, 2 BT.709, 3 SMPTE-170, 4 SMPTE-240, 5 BT.2020, + /// 6 reserved, 7 sRGB). + pub color_space: u8, + /// `color_range`: full swing when true, studio swing otherwise. + pub full_range: bool, +} + +/// Samples of one plane: 8-bit streams produce `U8`, higher bit depths `U16`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Plane { + U8(Vec), + U16(Vec), +} + +impl Plane { + pub fn len(&self) -> usize { + match self { + Plane::U8(v) => v.len(), + Plane::U16(v) => v.len(), + } + } + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + pub fn as_u8(&self) -> Option<&[u8]> { + match self { + Plane::U8(v) => Some(v), + Plane::U16(_) => None, + } + } + pub fn as_u16(&self) -> Option<&[u16]> { + match self { + Plane::U8(_) => None, + Plane::U16(v) => Some(v), + } + } + /// Sample `i` widened to u16. + pub fn get(&self, i: usize) -> u16 { + match self { + Plane::U8(v) => v[i] as u16, + Plane::U16(v) => v[i], + } + } + /// Raw bytes as written by ffmpeg `rawvideo` (8-bit, or 16-bit little endian). + pub fn to_le_bytes(&self) -> Vec { + match self { + Plane::U8(v) => v.clone(), + Plane::U16(v) => v.iter().flat_map(|s| s.to_le_bytes()).collect(), + } + } +} + +/// A decoded, cropped planar picture. +#[derive(Clone, Debug)] +pub struct Picture { + /// Frame width / height (decoded size). + pub width: u32, + pub height: u32, + pub chroma_width: u32, + pub chroma_height: u32, + pub bit_depth: u32, + pub subsampling_x: bool, + pub subsampling_y: bool, + pub y: Plane, + pub u: Plane, + pub v: Plane, + /// Strides in samples. + pub y_stride: usize, + pub uv_stride: usize, + /// `pts` passed to [`Decoder::decode`] with the chunk that produced this picture. + pub pts: i64, + pub key: bool, + pub intra_only: bool, + pub color: ColorInfo, + /// Intended display size (render_size); has no effect on decoding. + pub render_width: u32, + pub render_height: u32, +} diff --git a/crates/vp9/src/loopfilter.rs b/crates/vp9/src/loopfilter.rs new file mode 100644 index 000000000..918d6e774 --- /dev/null +++ b/crates/vp9/src/loopfilter.rs @@ -0,0 +1,256 @@ +//! Loop filter process (8.8). + +use crate::frame::{MiGrid, MiInfo}; +use crate::header::{LoopFilterParams, Segmentation}; +use crate::tables::*; + +/// Frame-level loop filter setup (8.8.1) plus the per-level limits of 8.8.4. +pub struct LfFrame { + /// LvlLookup[segment_id][ref][modeType]. + pub lvl: [[[u8; 2]; 4]; 8], + /// (limit, blimit, thresh) per filter level. + pub limits: [(i32, i32, i32); 64], + pub mi_rows: usize, + pub mi_cols: usize, + pub ss_x: usize, + pub ss_y: usize, + pub bit_depth: u8, +} + +impl LfFrame { + pub fn new(lf: &LoopFilterParams, seg: &Segmentation, mi_rows: usize, mi_cols: usize, ss_x: bool, ss_y: bool, bit_depth: u8) -> LfFrame { + let mut lvl = [[[0u8; 2]; 4]; 8]; + let n_shift = lf.level >> 5; + for (seg_id, l) in lvl.iter_mut().enumerate() { + let mut lvl_seg = lf.level as i32; + if seg.feature_active(seg_id as u8, SEG_LVL_ALT_L) { + let d = seg.feature_data[seg_id][SEG_LVL_ALT_L] as i32; + lvl_seg = if seg.abs_or_delta_update { d } else { d + lf.level as i32 }; + lvl_seg = lvl_seg.clamp(0, MAX_LOOP_FILTER); + } + if !lf.delta_enabled { + *l = [[lvl_seg as u8; 2]; 4]; + } else { + let intra = lvl_seg + ((lf.ref_deltas[0] as i32) << n_shift); + l[0][0] = intra.clamp(0, MAX_LOOP_FILTER) as u8; + l[0][1] = l[0][0]; + for rf in 1..4 { + for mode in 0..2 { + let v = lvl_seg + ((lf.ref_deltas[rf] as i32) << n_shift) + ((lf.mode_deltas[mode] as i32) << n_shift); + l[rf][mode] = v.clamp(0, MAX_LOOP_FILTER) as u8; + } + } + } + } + let mut limits = [(0, 0, 0); 64]; + let sh = lf.sharpness as i32; + let shift = if sh > 4 { + 2 + } else if sh > 0 { + 1 + } else { + 0 + }; + for (l, e) in limits.iter_mut().enumerate() { + let l = l as i32; + let limit = if sh > 0 { (l >> shift).clamp(1, 9 - sh) } else { (l >> shift).max(1) }; + *e = (limit, 2 * (l + 2) + limit, l >> 4); + } + LfFrame { lvl, limits, mi_rows, mi_cols, ss_x: ss_x as usize, ss_y: ss_y as usize, bit_depth } + } + + fn level(&self, mi: &MiInfo) -> u8 { + let mode_type = matches!(mi.y_mode, NEARESTMV | NEARMV | NEWMV) as usize; + let rf = mi.ref_frame[0].max(0) as usize; + self.lvl[mi.seg_id as usize & 7][rf][mode_type] + } +} + +/// A mutable view of one plane: sample (x, y) of the plane is `data[(y - oy) * stride + x - ox]`. +pub struct PlaneView<'a> { + pub data: &'a mut [u16], + pub stride: usize, + pub ox: usize, + pub oy: usize, +} + +/// Loop filter one superblock (all planes, both passes) at (`row`, `col`) in MI units (8.8.2). +pub fn filter_superblock(views: &mut [PlaneView; 3], mi: &MiGrid, f: &LfFrame, row: usize, col: usize) { + for (plane, view) in views.iter_mut().enumerate() { + for pass in 0..2 { + filter_sb_plane(view, mi, f, plane, pass, row, col); + } + } +} + +fn filter_sb_plane(v: &mut PlaneView, mi: &MiGrid, f: &LfFrame, plane: usize, pass: usize, row: usize, col: usize) { + let (sub_x, sub_y) = if plane > 0 { (f.ss_x, f.ss_y) } else { (0, 0) }; + let (sub, edge_len) = if pass == 0 { (sub_x, 64 >> sub_y) } else { (sub_y, 64 >> sub_x) }; + let (mi_rows, mi_cols) = (f.mi_rows, f.mi_cols); + // Step between the samples across the edge. + let across = if pass == 0 { 1isize } else { v.stride as isize }; + for edge in 0..(16 >> sub) { + let mut i = 0; + while i < edge_len { + // Group of 8 samples sharing loopRow / loopCol. + let (x, y) = + if pass == 0 { (col * 8 + edge * (4 << sub_x), row * 8 + (i << sub_y)) } else { (col * 8 + (i << sub_x), row * 8 + edge * (4 << sub_y)) }; + // Skip groups entirely off screen (the varying coordinate is re-checked per sample). + if x >= 8 * mi_cols || y >= 8 * mi_rows || (pass == 0 && x == 0) || (pass == 1 && y == 0) { + i += 8; + continue; + } + let loop_col = ((x >> 3) >> sub_x) << sub_x; + let loop_row = ((y >> 3) >> sub_y) << sub_y; + let m = mi.at(loop_row, loop_col); + let tx_sz = if plane > 0 { + if m.sb_size < BLOCK_8X8 { 0 } else { m.tx_size.min(MAX_TXSIZE[SS_SIZE_LOOKUP[m.sb_size as usize][f.ss_x][f.ss_y].min(12) as usize]) } + } else { + m.tx_size + }; + let sb_size = if sub == 0 { m.sb_size } else { m.sb_size.max(BLOCK_16X16) } as usize; + let skip = m.skip; + let is_intra = m.ref_frame[0] <= INTRA_FRAME; + let is_block_edge = if pass == 0 { x % (8 * NUM_8X8_WIDE[sb_size] as usize) == 0 } else { y % (8 * NUM_8X8_HIGH[sb_size] as usize) == 0 }; + let is32 = edge % 8 == 0; + let lvl = f.level(m); + if lvl == 0 { + i += 8; + continue; + } + let base_size = if tx_sz == TX_4X4 && is32 { TX_8X8 } else { tx_sz.min(TX_16X16) }; + let filter_size = + if base_size == TX_16X16 && ((pass == 0 && sub_x == 1 && (x >> 3) == mi_cols - 1) || (pass == 1 && sub_y == 1 && (y >> 3) == mi_rows - 1)) { + TX_8X8 + } else { + base_size + }; + let (limit, blimit, thresh) = f.limits[lvl as usize]; + let tx_edge_normal = edge % (1 << tx_sz) == 0; + for k in i..(i + 8).min(edge_len) { + let (sx, sy) = if pass == 0 { (x, row * 8 + (k << sub_y)) } else { (col * 8 + (k << sub_x), y) }; + if sx >= 8 * mi_cols || sy >= 8 * mi_rows { + continue; + } + let is_tx_edge = if pass == 1 && sub_x == 1 && mi_cols & 1 == 1 && edge & 1 == 1 && sx + 8 >= mi_cols * 8 { false } else { tx_edge_normal }; + let apply = is_block_edge || (is_tx_edge && (is_intra || !skip)); + if !apply { + continue; + } + let px = sx >> sub_x; + let py = sy >> sub_y; + let pos = ((py - v.oy) * v.stride + px - v.ox) as isize; + filter_sample(v.data, pos, across, limit, blimit, thresh, filter_size, f.bit_depth); + } + i += 8; + } + } +} + +/// Sample filtering process (8.8.5) at `pos` (the q0 sample), samples across the edge `step` +/// apart. +#[allow(clippy::too_many_arguments)] +#[inline] +fn filter_sample(d: &mut [u16], pos: isize, step: isize, limit: i32, blimit: i32, thresh: i32, filter_size: u8, bit_depth: u8) { + let at = |k: isize| (pos + k * step) as usize; + let s = |d: &[u16], k: isize| d[at(k)] as i32; + let q0 = s(d, 0); + let q1 = s(d, 1); + let q2 = s(d, 2); + let q3 = s(d, 3); + let p0 = s(d, -1); + let p1 = s(d, -2); + let p2 = s(d, -3); + let p3 = s(d, -4); + let shift = bit_depth as u32 - 8; + // Filter mask process (8.8.5.1). + let limit_bd = limit << shift; + let blimit_bd = blimit << shift; + if (p3 - p2).abs() > limit_bd + || (p2 - p1).abs() > limit_bd + || (p1 - p0).abs() > limit_bd + || (q1 - q0).abs() > limit_bd + || (q2 - q1).abs() > limit_bd + || (q3 - q2).abs() > limit_bd + || (p0 - q0).abs() * 2 + (p1 - q1).abs() / 2 > blimit_bd + { + return; + } + let thresh_bd = thresh << shift; + let hev = (p1 - p0).abs() > thresh_bd || (q1 - q0).abs() > thresh_bd; + let one = 1 << shift; + let flat = filter_size >= TX_8X8 + && (p1 - p0).abs() <= one + && (q1 - q0).abs() <= one + && (p2 - p0).abs() <= one + && (q2 - q0).abs() <= one + && (p3 - p0).abs() <= one + && (q3 - q0).abs() <= one; + if filter_size == TX_4X4 || !flat { + narrow_filter(d, pos, step, hev, bit_depth, p1, p0, q0, q1); + return; + } + let flat2 = filter_size >= TX_16X16 && { + let mut ok = true; + for k in 4..8 { + if (s(d, -(k + 1)) - p0).abs() > one || (s(d, k) - q0).abs() > one { + ok = false; + break; + } + } + ok + }; + if filter_size == TX_8X8 || !flat2 { + wide_filter(d, pos, step, 3); + } else { + wide_filter(d, pos, step, 4); + } +} + +/// Narrow filter process (8.8.5.2). +#[allow(clippy::too_many_arguments)] +#[inline] +fn narrow_filter(d: &mut [u16], pos: isize, step: isize, hev: bool, bit_depth: u8, p1: i32, p0: i32, q0: i32, q1: i32) { + let shift = bit_depth as u32 - 8; + let lo = -(1 << (bit_depth - 1)); + let hi = (1 << (bit_depth - 1)) - 1; + let c = |v: i32| v.clamp(lo, hi); + let off = 0x80 << shift; + let (ps1, ps0, qs0, qs1) = (p1 - off, p0 - off, q0 - off, q1 - off); + let mut filter = if hev { c(ps1 - qs1) } else { 0 }; + filter = c(filter + 3 * (qs0 - ps0)); + let filter1 = c(filter + 4) >> 3; + let filter2 = c(filter + 3) >> 3; + let at = |k: isize| (pos + k * step) as usize; + d[at(0)] = (c(qs0 - filter1) + off) as u16; + d[at(-1)] = (c(ps0 + filter2) + off) as u16; + if !hev { + let f = (filter1 + 1) >> 1; + d[at(1)] = (c(qs1 - f) + off) as u16; + d[at(-2)] = (c(ps1 + f) + off) as u16; + } +} + +/// Wide filter process (8.8.5.3) with 2^log2 taps. +#[inline] +fn wide_filter(d: &mut [u16], pos: isize, step: isize, log2: u32) { + let n = (1isize << (log2 - 1)) - 1; + let at = |k: isize| (pos + k * step) as usize; + let mut src = [0i32; 16]; + // src[k + 8] = sample at offset k, k in -8..8. + for k in -(n + 1)..=n { + src[(k + 8) as usize] = d[at(k)] as i32; + } + let mut f = [0i32; 16]; + for i in -n..n { + let mut t = src[(i + 8) as usize]; + for j in -n..=n { + let p = (i + j).clamp(-(n + 1), n); + t += src[(p + 8) as usize]; + } + f[(i + 8) as usize] = (t + (1 << (log2 - 1))) >> log2; + } + for i in -n..n { + d[at(i)] = f[(i + 8) as usize] as u16; + } +} diff --git a/crates/vp9/src/probs.rs b/crates/vp9/src/probs.rs new file mode 100644 index 000000000..6ed83a30d --- /dev/null +++ b/crates/vp9/src/probs.rs @@ -0,0 +1,315 @@ +//! Probability tables (a "frame context"), syntax element counters and the backward adaptation +//! processes of section 8.4. + +use crate::tables::*; + +/// coef_probs[txSz][plane > 0][is_inter][band][ctx][node]. +pub type CoefProbs = [[[[[[u8; 3]; 6]; 6]; 2]; 2]; 4]; + +/// All adaptive probabilities of one frame context (10.5). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct FrameContext { + /// tx_probs[maxTxSize][ctx][node] (index 0 unused). + pub tx: [[[u8; 3]; 2]; 4], + pub coef: CoefProbs, + pub skip: [u8; 3], + pub inter_mode: [[u8; 3]; 7], + pub interp_filter: [[u8; 2]; 4], + pub is_inter: [u8; 4], + pub comp_mode: [u8; 5], + pub single_ref: [[u8; 2]; 5], + pub comp_ref: [u8; 5], + pub y_mode: [[u8; 9]; 4], + pub uv_mode: [[u8; 9]; 10], + pub partition: [[u8; 3]; 16], + pub mv_joint: [u8; 3], + pub mv_sign: [u8; 2], + pub mv_class: [[u8; 10]; 2], + pub mv_class0_bit: [u8; 2], + pub mv_bits: [[u8; 10]; 2], + pub mv_class0_fr: [[[u8; 3]; 2]; 2], + pub mv_fr: [[u8; 3]; 2], + pub mv_class0_hp: [u8; 2], + pub mv_hp: [u8; 2], +} + +fn arr(s: &[u8]) -> [u8; N] { + s.try_into().expect("table size") +} + +impl Default for FrameContext { + fn default() -> Self { + let mut coef = [[[[[[0u8; 3]; 6]; 6]; 2]; 2]; 4]; + let mut k = 0; + for t in coef.iter_mut() { + for i in t.iter_mut() { + for j in i.iter_mut() { + for b in j.iter_mut() { + for c in b.iter_mut() { + *c = arr(&DEFAULT_COEF_PROBS[k..k + 3]); + k += 3; + } + } + } + } + } + let mut tx = [[[0u8; 3]; 2]; 4]; + for (m, t) in tx.iter_mut().enumerate() { + for (c, p) in t.iter_mut().enumerate() { + *p = arr(&DEFAULT_TX_PROBS[(m * 2 + c) * 3..(m * 2 + c) * 3 + 3]); + } + } + FrameContext { + tx, + coef, + skip: DEFAULT_SKIP_PROB, + inter_mode: std::array::from_fn(|i| arr(&DEFAULT_INTER_MODE_PROBS[i * 3..i * 3 + 3])), + interp_filter: std::array::from_fn(|i| arr(&DEFAULT_INTERP_FILTER_PROBS[i * 2..i * 2 + 2])), + is_inter: DEFAULT_IS_INTER_PROB, + comp_mode: DEFAULT_COMP_MODE_PROB, + single_ref: std::array::from_fn(|i| arr(&DEFAULT_SINGLE_REF_PROB[i * 2..i * 2 + 2])), + comp_ref: DEFAULT_COMP_REF_PROB, + y_mode: std::array::from_fn(|i| arr(&DEFAULT_Y_MODE_PROBS[i * 9..i * 9 + 9])), + uv_mode: std::array::from_fn(|i| arr(&DEFAULT_UV_MODE_PROBS[i * 9..i * 9 + 9])), + partition: std::array::from_fn(|i| arr(&DEFAULT_PARTITION_PROBS[i * 3..i * 3 + 3])), + mv_joint: DEFAULT_MV_JOINT_PROBS, + mv_sign: DEFAULT_MV_SIGN_PROB, + mv_class: std::array::from_fn(|i| arr(&DEFAULT_MV_CLASS_PROBS[i * 10..i * 10 + 10])), + mv_class0_bit: DEFAULT_MV_CLASS0_BIT_PROB, + mv_bits: std::array::from_fn(|i| arr(&DEFAULT_MV_BITS_PROB[i * 10..i * 10 + 10])), + mv_class0_fr: std::array::from_fn(|i| std::array::from_fn(|j| arr(&DEFAULT_MV_CLASS0_FR_PROBS[(i * 2 + j) * 3..(i * 2 + j) * 3 + 3]))), + mv_fr: std::array::from_fn(|i| arr(&DEFAULT_MV_FR_PROBS[i * 3..i * 3 + 3])), + mv_class0_hp: DEFAULT_MV_CLASS0_HP_PROB, + mv_hp: DEFAULT_MV_HP_PROB, + } + } +} + +/// Syntax element counters (8.3). +#[derive(Clone, Default)] +pub struct Counts { + pub intra_mode: [[u32; 10]; 4], + pub uv_mode: [[u32; 10]; 10], + pub partition: [[u32; 4]; 16], + pub interp_filter: [[u32; 3]; 4], + pub inter_mode: [[u32; 4]; 7], + /// tx_size[maxTxSize][ctx][value]. + pub tx: [[[u32; 4]; 2]; 4], + pub is_inter: [[u32; 2]; 4], + pub comp_mode: [[u32; 2]; 5], + pub single_ref: [[[u32; 2]; 2]; 5], + pub comp_ref: [[u32; 2]; 5], + pub skip: [[u32; 2]; 3], + pub mv_joint: [u32; 4], + pub mv_sign: [[u32; 2]; 2], + pub mv_class: [[u32; 11]; 2], + pub mv_class0_bit: [[u32; 2]; 2], + pub mv_class0_fr: [[[u32; 4]; 2]; 2], + pub mv_class0_hp: [[u32; 2]; 2], + pub mv_bits: [[[u32; 2]; 10]; 2], + pub mv_fr: [[u32; 4]; 2], + pub mv_hp: [[u32; 2]; 2], + /// token[txSz][plane>0][is_inter][band][ctx][min(2, token)]. + pub token: [[[[[[u32; 3]; 6]; 6]; 2]; 2]; 4], + /// more_coefs[txSz][plane>0][is_inter][band][ctx][value]. + pub more_coefs: [[[[[[u32; 2]; 6]; 6]; 2]; 2]; 4], +} + +fn add_slice(a: &mut [u32], b: &[u32]) { + for (x, y) in a.iter_mut().zip(b) { + *x += y; + } +} + +macro_rules! add_nested { + ($a:expr, $b:expr, 1) => { + add_slice(&mut $a[..], &$b[..]) + }; + ($a:expr, $b:expr, 2) => { + for (x, y) in $a.iter_mut().zip($b.iter()) { + add_slice(&mut x[..], &y[..]); + } + }; + ($a:expr, $b:expr, 3) => { + for (x, y) in $a.iter_mut().zip($b.iter()) { + add_nested!(x, y, 2); + } + }; +} + +impl Counts { + /// Accumulate the counts of another tile. + pub fn add(&mut self, o: &Counts) { + add_nested!(self.intra_mode, o.intra_mode, 2); + add_nested!(self.uv_mode, o.uv_mode, 2); + add_nested!(self.partition, o.partition, 2); + add_nested!(self.interp_filter, o.interp_filter, 2); + add_nested!(self.inter_mode, o.inter_mode, 2); + add_nested!(self.tx, o.tx, 3); + add_nested!(self.is_inter, o.is_inter, 2); + add_nested!(self.comp_mode, o.comp_mode, 2); + add_nested!(self.single_ref, o.single_ref, 3); + add_nested!(self.comp_ref, o.comp_ref, 2); + add_nested!(self.skip, o.skip, 2); + add_nested!(self.mv_joint, o.mv_joint, 1); + add_nested!(self.mv_sign, o.mv_sign, 2); + add_nested!(self.mv_class, o.mv_class, 2); + add_nested!(self.mv_class0_bit, o.mv_class0_bit, 2); + add_nested!(self.mv_class0_fr, o.mv_class0_fr, 3); + add_nested!(self.mv_class0_hp, o.mv_class0_hp, 2); + add_nested!(self.mv_bits, o.mv_bits, 3); + add_nested!(self.mv_fr, o.mv_fr, 2); + add_nested!(self.mv_hp, o.mv_hp, 2); + for (a, b) in self.token.iter_mut().zip(o.token.iter()) { + for (a, b) in a.iter_mut().zip(b.iter()) { + for (a, b) in a.iter_mut().zip(b.iter()) { + for (a, b) in a.iter_mut().zip(b.iter()) { + add_nested!(a, b, 2); + } + } + } + } + for (a, b) in self.more_coefs.iter_mut().zip(o.more_coefs.iter()) { + for (a, b) in a.iter_mut().zip(b.iter()) { + for (a, b) in a.iter_mut().zip(b.iter()) { + for (a, b) in a.iter_mut().zip(b.iter()) { + add_nested!(a, b, 2); + } + } + } + } + } +} + +/// merge_prob (8.4.1). +fn merge_prob(pre: u8, ct0: u32, ct1: u32, count_sat: u32, max_update_factor: u32) -> u8 { + let den = ct0 + ct1; + let prob = if den == 0 { 128 } else { ((ct0 as u64 * 256 + (den as u64 >> 1)) / den as u64).clamp(1, 255) as u32 }; + let count = den.min(count_sat); + let factor = max_update_factor * count / count_sat; + ((pre as u32 * (256 - factor) + prob * factor + 128) >> 8) as u8 +} + +/// merge_probs (8.4.2). +fn merge_probs(tree: &[i8], i: usize, probs: &mut [u8], counts: &[u32], count_sat: u32, max_update_factor: u32) -> u32 { + let s = tree[i]; + let left = if s <= 0 { counts[(-s) as usize] } else { merge_probs(tree, s as usize, probs, counts, count_sat, max_update_factor) }; + let r = tree[i + 1]; + let right = if r <= 0 { counts[(-r) as usize] } else { merge_probs(tree, r as usize, probs, counts, count_sat, max_update_factor) }; + probs[i >> 1] = merge_prob(probs[i >> 1], left, right, count_sat, max_update_factor); + left + right +} + +fn adapt_probs(tree: &[i8], probs: &mut [u8], counts: &[u32]) { + merge_probs(tree, 0, probs, counts, COUNT_SAT, MAX_UPDATE_FACTOR); +} + +fn adapt_prob(prob: &mut u8, counts: &[u32; 2]) { + *prob = merge_prob(*prob, counts[0], counts[1], COUNT_SAT, MAX_UPDATE_FACTOR); +} + +/// Coefficient probability adaptation (8.4.3). `fc` holds the probabilities loaded from the +/// saved frame context. +pub fn adapt_coef_probs(fc: &mut FrameContext, counts: &Counts, frame_is_intra: bool, last_frame_was_key: bool) { + let update_factor = if frame_is_intra { + 112 + } else if last_frame_was_key { + 128 + } else { + 112 + }; + for t in 0..4 { + for i in 0..2 { + for j in 0..2 { + for k in 0..6 { + let max_l = if k == 0 { 3 } else { 6 }; + for l in 0..max_l { + let p = &mut fc.coef[t][i][j][k][l]; + merge_probs(&SMALL_TOKEN_TREE, 2, p, &counts.token[t][i][j][k][l], 24, update_factor); + merge_probs(&BINARY_TREE, 0, p, &counts.more_coefs[t][i][j][k][l], 24, update_factor); + } + } + } + } + } +} + +/// Non-coefficient probability adaptation (8.4.4). +pub fn adapt_noncoef_probs(fc: &mut FrameContext, c: &Counts, interp_switchable: bool, tx_select: bool, allow_hp: bool) { + for i in 0..4 { + adapt_prob(&mut fc.is_inter[i], &c.is_inter[i]); + } + for i in 0..5 { + adapt_prob(&mut fc.comp_mode[i], &c.comp_mode[i]); + adapt_prob(&mut fc.comp_ref[i], &c.comp_ref[i]); + for j in 0..2 { + adapt_prob(&mut fc.single_ref[i][j], &c.single_ref[i][j]); + } + } + for i in 0..7 { + adapt_probs(&INTER_MODE_TREE, &mut fc.inter_mode[i], &c.inter_mode[i]); + } + for i in 0..4 { + adapt_probs(&INTRA_MODE_TREE, &mut fc.y_mode[i], &c.intra_mode[i]); + } + for i in 0..10 { + adapt_probs(&INTRA_MODE_TREE, &mut fc.uv_mode[i], &c.uv_mode[i]); + } + for i in 0..16 { + adapt_probs(&PARTITION_TREE, &mut fc.partition[i], &c.partition[i]); + } + for i in 0..3 { + adapt_prob(&mut fc.skip[i], &c.skip[i]); + } + if interp_switchable { + for i in 0..4 { + adapt_probs(&INTERP_FILTER_TREE, &mut fc.interp_filter[i], &c.interp_filter[i]); + } + } + if tx_select { + for i in 0..2 { + adapt_probs(&TX_SIZE_8_TREE, &mut fc.tx[1][i], &c.tx[1][i]); + adapt_probs(&TX_SIZE_16_TREE, &mut fc.tx[2][i], &c.tx[2][i]); + adapt_probs(&TX_SIZE_32_TREE, &mut fc.tx[3][i], &c.tx[3][i]); + } + } + adapt_probs(&MV_JOINT_TREE, &mut fc.mv_joint, &c.mv_joint); + for i in 0..2 { + adapt_prob(&mut fc.mv_sign[i], &c.mv_sign[i]); + adapt_probs(&MV_CLASS_TREE, &mut fc.mv_class[i], &c.mv_class[i]); + adapt_prob(&mut fc.mv_class0_bit[i], &c.mv_class0_bit[i]); + for j in 0..10 { + adapt_prob(&mut fc.mv_bits[i][j], &c.mv_bits[i][j]); + } + for j in 0..2 { + adapt_probs(&MV_FR_TREE, &mut fc.mv_class0_fr[i][j], &c.mv_class0_fr[i][j]); + } + adapt_probs(&MV_FR_TREE, &mut fc.mv_fr[i], &c.mv_fr[i]); + if allow_hp { + adapt_prob(&mut fc.mv_class0_hp[i], &c.mv_class0_hp[i]); + adapt_prob(&mut fc.mv_hp[i], &c.mv_hp[i]); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_prob_identity_without_counts() { + for p in 1..=255u8 { + assert_eq!(merge_prob(p, 0, 0, 20, 128), p); + } + // Saturated counts move halfway towards the observed probability. + assert_eq!(merge_prob(128, 20, 0, 20, 128), 192); + } + + #[test] + fn defaults_load() { + let fc = FrameContext::default(); + assert_eq!(fc.coef[0][0][0][0][0], [195, 29, 183]); + assert_eq!(fc.tx[3][1], [5, 52, 13]); + assert_eq!(fc.mv_class0_fr[1][1], [96, 112, 64]); + } +} diff --git a/crates/vp9/src/spec_tables.rs b/crates/vp9/src/spec_tables.rs new file mode 100644 index 000000000..686d64fcf --- /dev/null +++ b/crates/vp9/src/spec_tables.rs @@ -0,0 +1,1427 @@ +// Generated by tools/extract_tables.py from the VP9 Bitstream & Decoding Process Specification +// v0.6 (section numbers refer to that document). Do not edit by hand. + +pub const INV_MAP_TABLE: [u8; 255] = [ + 7, 20, 33, 46, 59, 72, 85, 98, 111, 124, 137, 150, 163, 176, 189, 202, + 215, 228, 241, 254, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, + 14, 15, 16, 17, 18, 19, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, + 31, 32, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 47, 48, + 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 60, 61, 62, 63, 64, 65, + 66, 67, 68, 69, 70, 71, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, + 83, 84, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 99, 100, + 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 112, 113, 114, 115, 116, 117, + 118, 119, 120, 121, 122, 123, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, + 135, 136, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 151, 152, + 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 164, 165, 166, 167, 168, 169, + 170, 171, 172, 173, 174, 175, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, + 187, 188, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 203, 204, + 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 216, 217, 218, 219, 220, 221, + 222, 223, 224, 225, 226, 227, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, + 239, 240, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 253, +]; + +pub const SUBPEL_FILTERS: [i16; 512] = [ + 0, 0, 0, 128, 0, 0, 0, 0, + 0, 1, -5, 126, 8, -3, 1, 0, + -1, 3, -10, 122, 18, -6, 2, 0, + -1, 4, -13, 118, 27, -9, 3, -1, + -1, 4, -16, 112, 37, -11, 4, -1, + -1, 5, -18, 105, 48, -14, 4, -1, + -1, 5, -19, 97, 58, -16, 5, -1, + -1, 6, -19, 88, 68, -18, 5, -1, + -1, 6, -19, 78, 78, -19, 6, -1, + -1, 5, -18, 68, 88, -19, 6, -1, + -1, 5, -16, 58, 97, -19, 5, -1, + -1, 4, -14, 48, 105, -18, 5, -1, + -1, 4, -11, 37, 112, -16, 4, -1, + -1, 3, -9, 27, 118, -13, 4, -1, + 0, 2, -6, 18, 122, -10, 3, -1, + 0, 1, -3, 8, 126, -5, 1, 0, + 0, 0, 0, 128, 0, 0, 0, 0, + -3, -1, 32, 64, 38, 1, -3, 0, + -2, -2, 29, 63, 41, 2, -3, 0, + -2, -2, 26, 63, 43, 4, -4, 0, + -2, -3, 24, 62, 46, 5, -4, 0, + -2, -3, 21, 60, 49, 7, -4, 0, + -1, -4, 18, 59, 51, 9, -4, 0, + -1, -4, 16, 57, 53, 12, -4, -1, + -1, -4, 14, 55, 55, 14, -4, -1, + -1, -4, 12, 53, 57, 16, -4, -1, + 0, -4, 9, 51, 59, 18, -4, -1, + 0, -4, 7, 49, 60, 21, -3, -2, + 0, -4, 5, 46, 62, 24, -3, -2, + 0, -4, 4, 43, 63, 26, -2, -2, + 0, -3, 2, 41, 63, 29, -2, -2, + 0, -3, 1, 38, 64, 32, -1, -3, + 0, 0, 0, 128, 0, 0, 0, 0, + -1, 3, -7, 127, 8, -3, 1, 0, + -2, 5, -13, 125, 17, -6, 3, -1, + -3, 7, -17, 121, 27, -10, 5, -2, + -4, 9, -20, 115, 37, -13, 6, -2, + -4, 10, -23, 108, 48, -16, 8, -3, + -4, 10, -24, 100, 59, -19, 9, -3, + -4, 11, -24, 90, 70, -21, 10, -4, + -4, 11, -23, 80, 80, -23, 11, -4, + -4, 10, -21, 70, 90, -24, 11, -4, + -3, 9, -19, 59, 100, -24, 10, -4, + -3, 8, -16, 48, 108, -23, 10, -4, + -2, 6, -13, 37, 115, -20, 9, -4, + -2, 5, -10, 27, 121, -17, 7, -3, + -1, 3, -6, 17, 125, -13, 5, -2, + 0, 1, -3, 8, 127, -7, 3, -1, + 0, 0, 0, 128, 0, 0, 0, 0, + 0, 0, 0, 120, 8, 0, 0, 0, + 0, 0, 0, 112, 16, 0, 0, 0, + 0, 0, 0, 104, 24, 0, 0, 0, + 0, 0, 0, 96, 32, 0, 0, 0, + 0, 0, 0, 88, 40, 0, 0, 0, + 0, 0, 0, 80, 48, 0, 0, 0, + 0, 0, 0, 72, 56, 0, 0, 0, + 0, 0, 0, 64, 64, 0, 0, 0, + 0, 0, 0, 56, 72, 0, 0, 0, + 0, 0, 0, 48, 80, 0, 0, 0, + 0, 0, 0, 40, 88, 0, 0, 0, + 0, 0, 0, 32, 96, 0, 0, 0, + 0, 0, 0, 24, 104, 0, 0, 0, + 0, 0, 0, 16, 112, 0, 0, 0, + 0, 0, 0, 8, 120, 0, 0, 0, +]; + +pub const DC_QLOOKUP: [i32; 768] = [ + 4, 8, 8, 9, 10, 11, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, + 20, 21, 22, 23, 24, 25, 26, 26, 27, 28, 29, 30, 31, 32, 32, 33, + 34, 35, 36, 37, 38, 38, 39, 40, 41, 42, 43, 43, 44, 45, 46, 47, + 48, 48, 49, 50, 51, 52, 53, 53, 54, 55, 56, 57, 57, 58, 59, 60, + 61, 62, 62, 63, 64, 65, 66, 66, 67, 68, 69, 70, 70, 71, 72, 73, + 74, 74, 75, 76, 77, 78, 78, 79, 80, 81, 81, 82, 83, 84, 85, 85, + 87, 88, 90, 92, 93, 95, 96, 98, 99, 101, 102, 104, 105, 107, 108, 110, + 111, 113, 114, 116, 117, 118, 120, 121, 123, 125, 127, 129, 131, 134, 136, 138, + 140, 142, 144, 146, 148, 150, 152, 154, 156, 158, 161, 164, 166, 169, 172, 174, + 177, 180, 182, 185, 187, 190, 192, 195, 199, 202, 205, 208, 211, 214, 217, 220, + 223, 226, 230, 233, 237, 240, 243, 247, 250, 253, 257, 261, 265, 269, 272, 276, + 280, 284, 288, 292, 296, 300, 304, 309, 313, 317, 322, 326, 330, 335, 340, 344, + 349, 354, 359, 364, 369, 374, 379, 384, 389, 395, 400, 406, 411, 417, 423, 429, + 435, 441, 447, 454, 461, 467, 475, 482, 489, 497, 505, 513, 522, 530, 539, 549, + 559, 569, 579, 590, 602, 614, 626, 640, 654, 668, 684, 700, 717, 736, 755, 775, + 796, 819, 843, 869, 896, 925, 955, 988, 1022, 1058, 1098, 1139, 1184, 1232, 1282, 1336, + 4, 9, 10, 13, 15, 17, 20, 22, 25, 28, 31, 34, 37, 40, 43, 47, + 50, 53, 57, 60, 64, 68, 71, 75, 78, 82, 86, 90, 93, 97, 101, 105, + 109, 113, 116, 120, 124, 128, 132, 136, 140, 143, 147, 151, 155, 159, 163, 166, + 170, 174, 178, 182, 185, 189, 193, 197, 200, 204, 208, 212, 215, 219, 223, 226, + 230, 233, 237, 241, 244, 248, 251, 255, 259, 262, 266, 269, 273, 276, 280, 283, + 287, 290, 293, 297, 300, 304, 307, 310, 314, 317, 321, 324, 327, 331, 334, 337, + 343, 350, 356, 362, 369, 375, 381, 387, 394, 400, 406, 412, 418, 424, 430, 436, + 442, 448, 454, 460, 466, 472, 478, 484, 490, 499, 507, 516, 525, 533, 542, 550, + 559, 567, 576, 584, 592, 601, 609, 617, 625, 634, 644, 655, 666, 676, 687, 698, + 708, 718, 729, 739, 749, 759, 770, 782, 795, 807, 819, 831, 844, 856, 868, 880, + 891, 906, 920, 933, 947, 961, 975, 988, 1001, 1015, 1030, 1045, 1061, 1076, 1090, 1105, + 1120, 1137, 1153, 1170, 1186, 1202, 1218, 1236, 1253, 1271, 1288, 1306, 1323, 1342, 1361, 1379, + 1398, 1416, 1436, 1456, 1476, 1496, 1516, 1537, 1559, 1580, 1601, 1624, 1647, 1670, 1692, 1717, + 1741, 1766, 1791, 1817, 1844, 1871, 1900, 1929, 1958, 1990, 2021, 2054, 2088, 2123, 2159, 2197, + 2236, 2276, 2319, 2363, 2410, 2458, 2508, 2561, 2616, 2675, 2737, 2802, 2871, 2944, 3020, 3102, + 3188, 3280, 3375, 3478, 3586, 3702, 3823, 3953, 4089, 4236, 4394, 4559, 4737, 4929, 5130, 5347, + 4, 12, 18, 25, 33, 41, 50, 60, 70, 80, 91, 103, 115, 127, 140, 153, + 166, 180, 194, 208, 222, 237, 251, 266, 281, 296, 312, 327, 343, 358, 374, 390, + 405, 421, 437, 453, 469, 484, 500, 516, 532, 548, 564, 580, 596, 611, 627, 643, + 659, 674, 690, 706, 721, 737, 752, 768, 783, 798, 814, 829, 844, 859, 874, 889, + 904, 919, 934, 949, 964, 978, 993, 1008, 1022, 1037, 1051, 1065, 1080, 1094, 1108, 1122, + 1136, 1151, 1165, 1179, 1192, 1206, 1220, 1234, 1248, 1261, 1275, 1288, 1302, 1315, 1329, 1342, + 1368, 1393, 1419, 1444, 1469, 1494, 1519, 1544, 1569, 1594, 1618, 1643, 1668, 1692, 1717, 1741, + 1765, 1789, 1814, 1838, 1862, 1885, 1909, 1933, 1957, 1992, 2027, 2061, 2096, 2130, 2165, 2199, + 2233, 2267, 2300, 2334, 2367, 2400, 2434, 2467, 2499, 2532, 2575, 2618, 2661, 2704, 2746, 2788, + 2830, 2872, 2913, 2954, 2995, 3036, 3076, 3127, 3177, 3226, 3275, 3324, 3373, 3421, 3469, 3517, + 3565, 3621, 3677, 3733, 3788, 3843, 3897, 3951, 4005, 4058, 4119, 4181, 4241, 4301, 4361, 4420, + 4479, 4546, 4612, 4677, 4742, 4807, 4871, 4942, 5013, 5083, 5153, 5222, 5291, 5367, 5442, 5517, + 5591, 5665, 5745, 5825, 5905, 5984, 6063, 6149, 6234, 6319, 6404, 6495, 6587, 6678, 6769, 6867, + 6966, 7064, 7163, 7269, 7376, 7483, 7599, 7715, 7832, 7958, 8085, 8214, 8352, 8492, 8635, 8788, + 8945, 9104, 9275, 9450, 9639, 9832, 10031, 10245, 10465, 10702, 10946, 11210, 11482, 11776, 12081, 12409, + 12750, 13118, 13501, 13913, 14343, 14807, 15290, 15812, 16356, 16943, 17575, 18237, 18949, 19718, 20521, 21387, +]; + +pub const AC_QLOOKUP: [i32; 768] = [ + 4, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, + 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, + 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, + 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, + 104, 106, 108, 110, 112, 114, 116, 118, 120, 122, 124, 126, 128, 130, 132, 134, + 136, 138, 140, 142, 144, 146, 148, 150, 152, 155, 158, 161, 164, 167, 170, 173, + 176, 179, 182, 185, 188, 191, 194, 197, 200, 203, 207, 211, 215, 219, 223, 227, + 231, 235, 239, 243, 247, 251, 255, 260, 265, 270, 275, 280, 285, 290, 295, 300, + 305, 311, 317, 323, 329, 335, 341, 347, 353, 359, 366, 373, 380, 387, 394, 401, + 408, 416, 424, 432, 440, 448, 456, 465, 474, 483, 492, 501, 510, 520, 530, 540, + 550, 560, 571, 582, 593, 604, 615, 627, 639, 651, 663, 676, 689, 702, 715, 729, + 743, 757, 771, 786, 801, 816, 832, 848, 864, 881, 898, 915, 933, 951, 969, 988, + 1007, 1026, 1046, 1066, 1087, 1108, 1129, 1151, 1173, 1196, 1219, 1243, 1267, 1292, 1317, 1343, + 1369, 1396, 1423, 1451, 1479, 1508, 1537, 1567, 1597, 1628, 1660, 1692, 1725, 1759, 1793, 1828, + 4, 9, 11, 13, 16, 18, 21, 24, 27, 30, 33, 37, 40, 44, 48, 51, + 55, 59, 63, 67, 71, 75, 79, 83, 88, 92, 96, 100, 105, 109, 114, 118, + 122, 127, 131, 136, 140, 145, 149, 154, 158, 163, 168, 172, 177, 181, 186, 190, + 195, 199, 204, 208, 213, 217, 222, 226, 231, 235, 240, 244, 249, 253, 258, 262, + 267, 271, 275, 280, 284, 289, 293, 297, 302, 306, 311, 315, 319, 324, 328, 332, + 337, 341, 345, 349, 354, 358, 362, 367, 371, 375, 379, 384, 388, 392, 396, 401, + 409, 417, 425, 433, 441, 449, 458, 466, 474, 482, 490, 498, 506, 514, 523, 531, + 539, 547, 555, 563, 571, 579, 588, 596, 604, 616, 628, 640, 652, 664, 676, 688, + 700, 713, 725, 737, 749, 761, 773, 785, 797, 809, 825, 841, 857, 873, 889, 905, + 922, 938, 954, 970, 986, 1002, 1018, 1038, 1058, 1078, 1098, 1118, 1138, 1158, 1178, 1198, + 1218, 1242, 1266, 1290, 1314, 1338, 1362, 1386, 1411, 1435, 1463, 1491, 1519, 1547, 1575, 1603, + 1631, 1663, 1695, 1727, 1759, 1791, 1823, 1859, 1895, 1931, 1967, 2003, 2039, 2079, 2119, 2159, + 2199, 2239, 2283, 2327, 2371, 2415, 2459, 2507, 2555, 2603, 2651, 2703, 2755, 2807, 2859, 2915, + 2971, 3027, 3083, 3143, 3203, 3263, 3327, 3391, 3455, 3523, 3591, 3659, 3731, 3803, 3876, 3952, + 4028, 4104, 4184, 4264, 4348, 4432, 4516, 4604, 4692, 4784, 4876, 4972, 5068, 5168, 5268, 5372, + 5476, 5584, 5692, 5804, 5916, 6032, 6148, 6268, 6388, 6512, 6640, 6768, 6900, 7036, 7172, 7312, + 4, 13, 19, 27, 35, 44, 54, 64, 75, 87, 99, 112, 126, 139, 154, 168, + 183, 199, 214, 230, 247, 263, 280, 297, 314, 331, 349, 366, 384, 402, 420, 438, + 456, 475, 493, 511, 530, 548, 567, 586, 604, 623, 642, 660, 679, 698, 716, 735, + 753, 772, 791, 809, 828, 846, 865, 884, 902, 920, 939, 957, 976, 994, 1012, 1030, + 1049, 1067, 1085, 1103, 1121, 1139, 1157, 1175, 1193, 1211, 1229, 1246, 1264, 1282, 1299, 1317, + 1335, 1352, 1370, 1387, 1405, 1422, 1440, 1457, 1474, 1491, 1509, 1526, 1543, 1560, 1577, 1595, + 1627, 1660, 1693, 1725, 1758, 1791, 1824, 1856, 1889, 1922, 1954, 1987, 2020, 2052, 2085, 2118, + 2150, 2183, 2216, 2248, 2281, 2313, 2346, 2378, 2411, 2459, 2508, 2556, 2605, 2653, 2701, 2750, + 2798, 2847, 2895, 2943, 2992, 3040, 3088, 3137, 3185, 3234, 3298, 3362, 3426, 3491, 3555, 3619, + 3684, 3748, 3812, 3876, 3941, 4005, 4069, 4149, 4230, 4310, 4390, 4470, 4550, 4631, 4711, 4791, + 4871, 4967, 5064, 5160, 5256, 5352, 5448, 5544, 5641, 5737, 5849, 5961, 6073, 6185, 6297, 6410, + 6522, 6650, 6778, 6906, 7034, 7162, 7290, 7435, 7579, 7723, 7867, 8011, 8155, 8315, 8475, 8635, + 8795, 8956, 9132, 9308, 9484, 9660, 9836, 10028, 10220, 10412, 10604, 10812, 11020, 11228, 11437, 11661, + 11885, 12109, 12333, 12573, 12813, 13053, 13309, 13565, 13821, 14093, 14365, 14637, 14925, 15213, 15502, 15806, + 16110, 16414, 16734, 17054, 17390, 17726, 18062, 18414, 18766, 19134, 19502, 19886, 20270, 20670, 21070, 21486, + 21902, 22334, 22766, 23214, 23662, 24126, 24590, 25070, 25551, 26047, 26559, 27071, 27599, 28143, 28687, 29247, +]; + +pub const COS64_LOOKUP: [i32; 33] = [ + 16384, 16364, 16305, 16207, 16069, 15893, 15679, 15426, 15137, 14811, 14449, 14053, 13623, 13160, 12665, 12140, + 11585, 11003, 10394, 9760, 9102, 8423, 7723, 7005, 6270, 5520, 4756, 3981, 3196, 2404, 1606, 804, + 0, +]; + +pub const DEFAULT_SCAN_4X4: [u16; 16] = [ + 0, 4, 1, 5, 8, 2, 12, 9, 3, 6, 13, 10, 7, 14, 11, 15, +]; + +pub const COL_SCAN_4X4: [u16; 16] = [ + 0, 4, 8, 1, 12, 5, 9, 2, 13, 6, 10, 3, 7, 14, 11, 15, +]; + +pub const ROW_SCAN_4X4: [u16; 16] = [ + 0, 1, 4, 2, 5, 3, 6, 8, 9, 7, 12, 10, 13, 11, 14, 15, +]; + +pub const DEFAULT_SCAN_8X8: [u16; 64] = [ + 0, 8, 1, 16, 9, 2, 17, 24, 10, 3, 18, 25, 32, 11, 4, 26, + 33, 19, 40, 12, 34, 27, 5, 41, 20, 48, 13, 35, 42, 28, 21, 6, + 49, 56, 36, 43, 29, 7, 14, 50, 57, 44, 22, 37, 15, 51, 58, 30, + 45, 23, 52, 59, 38, 31, 60, 53, 46, 39, 61, 54, 47, 62, 55, 63, +]; + +pub const COL_SCAN_8X8: [u16; 64] = [ + 0, 8, 16, 1, 24, 9, 32, 17, 2, 40, 25, 10, 33, 18, 48, 3, + 26, 41, 11, 56, 19, 34, 4, 49, 27, 42, 12, 35, 20, 57, 50, 28, + 5, 43, 13, 36, 58, 51, 21, 44, 6, 29, 59, 37, 14, 52, 22, 7, + 45, 60, 30, 15, 38, 53, 23, 46, 31, 61, 39, 54, 47, 62, 55, 63, +]; + +pub const ROW_SCAN_8X8: [u16; 64] = [ + 0, 1, 2, 8, 9, 3, 16, 10, 4, 17, 11, 24, 5, 18, 25, 12, + 19, 26, 32, 6, 13, 20, 33, 27, 7, 34, 40, 21, 28, 41, 14, 35, + 48, 42, 29, 36, 49, 22, 43, 15, 56, 37, 50, 44, 30, 57, 23, 51, + 58, 45, 38, 52, 31, 59, 53, 46, 60, 39, 61, 47, 54, 55, 62, 63, +]; + +pub const DEFAULT_SCAN_16X16: [u16; 256] = [ + 0, 16, 1, 32, 17, 2, 48, 33, 18, 3, 64, 34, 49, 19, 65, 80, + 50, 4, 35, 66, 20, 81, 96, 51, 5, 36, 82, 97, 67, 112, 21, 52, + 98, 37, 83, 113, 6, 68, 128, 53, 22, 99, 114, 84, 7, 129, 38, 69, + 100, 115, 144, 130, 85, 54, 23, 8, 145, 39, 70, 116, 101, 131, 160, 146, + 55, 86, 24, 71, 132, 117, 161, 40, 9, 102, 147, 176, 162, 87, 56, 25, + 133, 118, 177, 148, 72, 103, 41, 163, 10, 192, 178, 88, 57, 134, 149, 119, + 26, 164, 73, 104, 193, 42, 179, 208, 11, 135, 89, 165, 120, 150, 58, 194, + 180, 27, 74, 209, 105, 151, 136, 43, 90, 224, 166, 195, 181, 121, 210, 59, + 12, 152, 106, 167, 196, 75, 137, 225, 211, 240, 182, 122, 91, 28, 197, 13, + 226, 168, 183, 153, 44, 212, 138, 107, 241, 60, 29, 123, 198, 184, 227, 169, + 242, 76, 213, 154, 45, 92, 14, 199, 139, 61, 228, 214, 170, 185, 243, 108, + 77, 155, 30, 15, 200, 229, 124, 215, 244, 93, 46, 186, 171, 201, 109, 140, + 230, 62, 216, 245, 31, 125, 78, 156, 231, 47, 187, 202, 217, 94, 246, 141, + 63, 232, 172, 110, 247, 157, 79, 218, 203, 126, 233, 188, 248, 95, 173, 142, + 219, 111, 249, 234, 158, 127, 189, 204, 250, 235, 143, 174, 220, 205, 159, 251, + 190, 221, 175, 236, 237, 191, 206, 252, 222, 253, 207, 238, 223, 254, 239, 255, +]; + +pub const COL_SCAN_16X16: [u16; 256] = [ + 0, 16, 32, 48, 1, 64, 17, 80, 33, 96, 49, 2, 65, 112, 18, 81, + 34, 128, 50, 97, 3, 66, 144, 19, 113, 35, 82, 160, 98, 51, 129, 4, + 67, 176, 20, 114, 145, 83, 36, 99, 130, 52, 192, 5, 161, 68, 115, 21, + 146, 84, 208, 177, 37, 131, 100, 53, 162, 224, 69, 6, 116, 193, 147, 85, + 22, 240, 132, 38, 178, 101, 163, 54, 209, 117, 70, 7, 148, 194, 86, 179, + 225, 23, 133, 39, 164, 8, 102, 210, 241, 55, 195, 118, 149, 71, 180, 24, + 87, 226, 134, 165, 211, 40, 103, 56, 72, 150, 196, 242, 119, 9, 181, 227, + 88, 166, 25, 135, 41, 104, 212, 57, 151, 197, 120, 73, 243, 182, 136, 167, + 213, 89, 10, 228, 105, 152, 198, 26, 42, 121, 183, 244, 168, 58, 137, 229, + 74, 214, 90, 153, 199, 184, 11, 106, 245, 27, 122, 230, 169, 43, 215, 59, + 200, 138, 185, 246, 75, 12, 91, 154, 216, 231, 107, 28, 44, 201, 123, 170, + 60, 247, 232, 76, 139, 13, 92, 217, 186, 248, 155, 108, 29, 124, 45, 202, + 233, 171, 61, 14, 77, 140, 15, 249, 93, 30, 187, 156, 218, 46, 109, 125, + 62, 172, 78, 203, 31, 141, 234, 94, 47, 188, 63, 157, 110, 250, 219, 79, + 126, 204, 173, 142, 95, 189, 111, 235, 158, 220, 251, 127, 174, 143, 205, 236, + 159, 190, 221, 252, 175, 206, 237, 191, 253, 222, 238, 207, 254, 223, 239, 255, +]; + +pub const ROW_SCAN_16X16: [u16; 256] = [ + 0, 1, 2, 16, 3, 17, 4, 18, 32, 5, 33, 19, 6, 34, 48, 20, + 49, 7, 35, 21, 50, 64, 8, 36, 65, 22, 51, 37, 80, 9, 66, 52, + 23, 38, 81, 67, 10, 53, 24, 82, 68, 96, 39, 11, 54, 83, 97, 69, + 25, 98, 84, 40, 112, 55, 12, 70, 99, 113, 85, 26, 41, 56, 114, 100, + 13, 71, 128, 86, 27, 115, 101, 129, 42, 57, 72, 116, 14, 87, 130, 102, + 144, 73, 131, 117, 28, 58, 15, 88, 43, 145, 103, 132, 146, 118, 74, 160, + 89, 133, 104, 29, 59, 147, 119, 44, 161, 148, 90, 105, 134, 162, 120, 176, + 75, 135, 149, 30, 60, 163, 177, 45, 121, 91, 106, 164, 178, 150, 192, 136, + 165, 179, 31, 151, 193, 76, 122, 61, 137, 194, 107, 152, 180, 208, 46, 166, + 167, 195, 92, 181, 138, 209, 123, 153, 224, 196, 77, 168, 210, 182, 240, 108, + 197, 62, 154, 225, 183, 169, 211, 47, 139, 93, 184, 226, 212, 241, 198, 170, + 124, 155, 199, 78, 213, 185, 109, 227, 200, 63, 228, 242, 140, 214, 171, 186, + 156, 229, 243, 125, 94, 201, 244, 215, 216, 230, 141, 187, 202, 79, 172, 110, + 157, 245, 217, 231, 95, 246, 232, 126, 203, 247, 233, 173, 218, 142, 111, 158, + 188, 248, 127, 234, 219, 249, 189, 204, 143, 174, 159, 250, 235, 205, 220, 175, + 190, 251, 221, 191, 206, 236, 207, 237, 252, 222, 253, 223, 238, 239, 254, 255, +]; + +pub const DEFAULT_SCAN_32X32: [u16; 1024] = [ + 0, 32, 1, 64, 33, 2, 96, 65, 34, 128, 3, 97, 66, 160, 129, 35, + 98, 4, 67, 130, 161, 192, 36, 99, 224, 5, 162, 193, 68, 131, 37, 100, + 225, 194, 256, 163, 69, 132, 6, 226, 257, 288, 195, 101, 164, 38, 258, 7, + 227, 289, 133, 320, 70, 196, 165, 290, 259, 228, 39, 321, 102, 352, 8, 197, + 71, 134, 322, 291, 260, 353, 384, 229, 166, 103, 40, 354, 323, 292, 135, 385, + 198, 261, 72, 9, 416, 167, 386, 355, 230, 324, 104, 293, 41, 417, 199, 136, + 262, 387, 448, 325, 356, 10, 73, 418, 231, 168, 449, 294, 388, 105, 419, 263, + 42, 200, 357, 450, 137, 480, 74, 326, 232, 11, 389, 169, 295, 420, 106, 451, + 481, 358, 264, 327, 201, 43, 138, 512, 482, 390, 296, 233, 170, 421, 75, 452, + 359, 12, 513, 265, 483, 328, 107, 202, 514, 544, 422, 391, 453, 139, 44, 234, + 484, 297, 360, 171, 76, 515, 545, 266, 329, 454, 13, 423, 203, 108, 546, 485, + 576, 298, 235, 140, 361, 330, 172, 547, 45, 455, 267, 577, 486, 77, 204, 362, + 608, 14, 299, 578, 109, 236, 487, 609, 331, 141, 579, 46, 15, 173, 610, 363, + 78, 205, 16, 110, 237, 611, 142, 47, 174, 79, 206, 17, 111, 238, 48, 143, + 80, 175, 112, 207, 49, 18, 239, 81, 113, 19, 50, 82, 114, 51, 83, 115, + 640, 516, 392, 268, 144, 20, 672, 641, 548, 517, 424, 393, 300, 269, 176, 145, + 52, 21, 704, 673, 642, 580, 549, 518, 456, 425, 394, 332, 301, 270, 208, 177, + 146, 84, 53, 22, 736, 705, 674, 643, 612, 581, 550, 519, 488, 457, 426, 395, + 364, 333, 302, 271, 240, 209, 178, 147, 116, 85, 54, 23, 737, 706, 675, 613, + 582, 551, 489, 458, 427, 365, 334, 303, 241, 210, 179, 117, 86, 55, 738, 707, + 614, 583, 490, 459, 366, 335, 242, 211, 118, 87, 739, 615, 491, 367, 243, 119, + 768, 644, 520, 396, 272, 148, 24, 800, 769, 676, 645, 552, 521, 428, 397, 304, + 273, 180, 149, 56, 25, 832, 801, 770, 708, 677, 646, 584, 553, 522, 460, 429, + 398, 336, 305, 274, 212, 181, 150, 88, 57, 26, 864, 833, 802, 771, 740, 709, + 678, 647, 616, 585, 554, 523, 492, 461, 430, 399, 368, 337, 306, 275, 244, 213, + 182, 151, 120, 89, 58, 27, 865, 834, 803, 741, 710, 679, 617, 586, 555, 493, + 462, 431, 369, 338, 307, 245, 214, 183, 121, 90, 59, 866, 835, 742, 711, 618, + 587, 494, 463, 370, 339, 246, 215, 122, 91, 867, 743, 619, 495, 371, 247, 123, + 896, 772, 648, 524, 400, 276, 152, 28, 928, 897, 804, 773, 680, 649, 556, 525, + 432, 401, 308, 277, 184, 153, 60, 29, 960, 929, 898, 836, 805, 774, 712, 681, + 650, 588, 557, 526, 464, 433, 402, 340, 309, 278, 216, 185, 154, 92, 61, 30, + 992, 961, 930, 899, 868, 837, 806, 775, 744, 713, 682, 651, 620, 589, 558, 527, + 496, 465, 434, 403, 372, 341, 310, 279, 248, 217, 186, 155, 124, 93, 62, 31, + 993, 962, 931, 869, 838, 807, 745, 714, 683, 621, 590, 559, 497, 466, 435, 373, + 342, 311, 249, 218, 187, 125, 94, 63, 994, 963, 870, 839, 746, 715, 622, 591, + 498, 467, 374, 343, 250, 219, 126, 95, 995, 871, 747, 623, 499, 375, 251, 127, + 900, 776, 652, 528, 404, 280, 156, 932, 901, 808, 777, 684, 653, 560, 529, 436, + 405, 312, 281, 188, 157, 964, 933, 902, 840, 809, 778, 716, 685, 654, 592, 561, + 530, 468, 437, 406, 344, 313, 282, 220, 189, 158, 996, 965, 934, 903, 872, 841, + 810, 779, 748, 717, 686, 655, 624, 593, 562, 531, 500, 469, 438, 407, 376, 345, + 314, 283, 252, 221, 190, 159, 997, 966, 935, 873, 842, 811, 749, 718, 687, 625, + 594, 563, 501, 470, 439, 377, 346, 315, 253, 222, 191, 998, 967, 874, 843, 750, + 719, 626, 595, 502, 471, 378, 347, 254, 223, 999, 875, 751, 627, 503, 379, 255, + 904, 780, 656, 532, 408, 284, 936, 905, 812, 781, 688, 657, 564, 533, 440, 409, + 316, 285, 968, 937, 906, 844, 813, 782, 720, 689, 658, 596, 565, 534, 472, 441, + 410, 348, 317, 286, 1000, 969, 938, 907, 876, 845, 814, 783, 752, 721, 690, 659, + 628, 597, 566, 535, 504, 473, 442, 411, 380, 349, 318, 287, 1001, 970, 939, 877, + 846, 815, 753, 722, 691, 629, 598, 567, 505, 474, 443, 381, 350, 319, 1002, 971, + 878, 847, 754, 723, 630, 599, 506, 475, 382, 351, 1003, 879, 755, 631, 507, 383, + 908, 784, 660, 536, 412, 940, 909, 816, 785, 692, 661, 568, 537, 444, 413, 972, + 941, 910, 848, 817, 786, 724, 693, 662, 600, 569, 538, 476, 445, 414, 1004, 973, + 942, 911, 880, 849, 818, 787, 756, 725, 694, 663, 632, 601, 570, 539, 508, 477, + 446, 415, 1005, 974, 943, 881, 850, 819, 757, 726, 695, 633, 602, 571, 509, 478, + 447, 1006, 975, 882, 851, 758, 727, 634, 603, 510, 479, 1007, 883, 759, 635, 511, + 912, 788, 664, 540, 944, 913, 820, 789, 696, 665, 572, 541, 976, 945, 914, 852, + 821, 790, 728, 697, 666, 604, 573, 542, 1008, 977, 946, 915, 884, 853, 822, 791, + 760, 729, 698, 667, 636, 605, 574, 543, 1009, 978, 947, 885, 854, 823, 761, 730, + 699, 637, 606, 575, 1010, 979, 886, 855, 762, 731, 638, 607, 1011, 887, 763, 639, + 916, 792, 668, 948, 917, 824, 793, 700, 669, 980, 949, 918, 856, 825, 794, 732, + 701, 670, 1012, 981, 950, 919, 888, 857, 826, 795, 764, 733, 702, 671, 1013, 982, + 951, 889, 858, 827, 765, 734, 703, 1014, 983, 890, 859, 766, 735, 1015, 891, 767, + 920, 796, 952, 921, 828, 797, 984, 953, 922, 860, 829, 798, 1016, 985, 954, 923, + 892, 861, 830, 799, 1017, 986, 955, 893, 862, 831, 1018, 987, 894, 863, 1019, 895, + 924, 956, 925, 988, 957, 926, 1020, 989, 958, 927, 1021, 990, 959, 1022, 991, 1023, +]; + +pub const COEFBAND_4X4: [u8; 16] = [ + 0, 1, 1, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 5, 5, 5, +]; + +pub const COEFBAND_8X8PLUS: [u8; 1024] = [ + 0, 1, 1, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, + 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, +]; + +pub const ENERGY_CLASS: [u8; 12] = [ + 0, 1, 2, 3, 3, 4, 4, 5, 5, 5, 5, 5, +]; + +pub const PARETO_TABLE: [u8; 1024] = [ + 3, 86, 128, 6, 86, 23, 88, 29, + 9, 86, 129, 17, 88, 61, 94, 76, + 15, 87, 129, 28, 89, 93, 100, 110, + 20, 88, 130, 38, 91, 118, 106, 136, + 26, 89, 131, 48, 92, 139, 111, 156, + 31, 90, 131, 58, 94, 156, 117, 171, + 37, 90, 132, 66, 95, 171, 122, 184, + 42, 91, 132, 75, 97, 183, 127, 194, + 47, 92, 133, 83, 98, 193, 132, 202, + 52, 93, 133, 90, 100, 201, 137, 208, + 57, 94, 134, 98, 101, 208, 142, 214, + 62, 94, 135, 105, 103, 214, 146, 218, + 66, 95, 135, 111, 104, 219, 151, 222, + 71, 96, 136, 117, 106, 224, 155, 225, + 76, 97, 136, 123, 107, 227, 159, 228, + 80, 98, 137, 129, 109, 231, 162, 231, + 84, 98, 138, 134, 110, 234, 166, 233, + 89, 99, 138, 140, 112, 236, 170, 235, + 93, 100, 139, 145, 113, 238, 173, 236, + 97, 101, 140, 149, 115, 240, 176, 238, + 101, 102, 140, 154, 116, 242, 179, 239, + 105, 103, 141, 158, 118, 243, 182, 240, + 109, 104, 141, 162, 119, 244, 185, 241, + 113, 104, 142, 166, 120, 245, 187, 242, + 116, 105, 143, 170, 122, 246, 190, 243, + 120, 106, 143, 173, 123, 247, 192, 244, + 123, 107, 144, 177, 125, 248, 195, 244, + 127, 108, 145, 180, 126, 249, 197, 245, + 130, 109, 145, 183, 128, 249, 199, 245, + 134, 110, 146, 186, 129, 250, 201, 246, + 137, 111, 147, 189, 131, 251, 203, 246, + 140, 112, 147, 192, 132, 251, 205, 247, + 143, 113, 148, 194, 133, 251, 207, 247, + 146, 114, 149, 197, 135, 252, 208, 248, + 149, 115, 149, 199, 136, 252, 210, 248, + 152, 115, 150, 201, 138, 252, 211, 248, + 155, 116, 151, 204, 139, 253, 213, 249, + 158, 117, 151, 206, 140, 253, 214, 249, + 161, 118, 152, 208, 142, 253, 216, 249, + 163, 119, 153, 210, 143, 253, 217, 249, + 166, 120, 153, 212, 144, 254, 218, 250, + 168, 121, 154, 213, 146, 254, 220, 250, + 171, 122, 155, 215, 147, 254, 221, 250, + 173, 123, 155, 217, 148, 254, 222, 250, + 176, 124, 156, 218, 150, 254, 223, 250, + 178, 125, 157, 220, 151, 254, 224, 251, + 180, 126, 157, 221, 152, 254, 225, 251, + 183, 127, 158, 222, 153, 254, 226, 251, + 185, 128, 159, 224, 155, 255, 227, 251, + 187, 129, 160, 225, 156, 255, 228, 251, + 189, 131, 160, 226, 157, 255, 228, 251, + 191, 132, 161, 227, 159, 255, 229, 251, + 193, 133, 162, 228, 160, 255, 230, 252, + 195, 134, 163, 230, 161, 255, 231, 252, + 197, 135, 163, 231, 162, 255, 231, 252, + 199, 136, 164, 232, 163, 255, 232, 252, + 201, 137, 165, 233, 165, 255, 233, 252, + 202, 138, 166, 233, 166, 255, 233, 252, + 204, 139, 166, 234, 167, 255, 234, 252, + 206, 140, 167, 235, 168, 255, 235, 252, + 207, 141, 168, 236, 169, 255, 235, 252, + 209, 142, 169, 237, 171, 255, 236, 252, + 210, 144, 169, 237, 172, 255, 236, 252, + 212, 145, 170, 238, 173, 255, 237, 252, + 214, 146, 171, 239, 174, 255, 237, 253, + 215, 147, 172, 240, 175, 255, 238, 253, + 216, 148, 173, 240, 176, 255, 238, 253, + 218, 149, 173, 241, 177, 255, 239, 253, + 219, 150, 174, 241, 179, 255, 239, 253, + 220, 152, 175, 242, 180, 255, 240, 253, + 222, 153, 176, 242, 181, 255, 240, 253, + 223, 154, 177, 243, 182, 255, 240, 253, + 224, 155, 178, 244, 183, 255, 241, 253, + 225, 156, 178, 244, 184, 255, 241, 253, + 226, 158, 179, 244, 185, 255, 242, 253, + 228, 159, 180, 245, 186, 255, 242, 253, + 229, 160, 181, 245, 187, 255, 242, 253, + 230, 161, 182, 246, 188, 255, 243, 253, + 231, 163, 183, 246, 189, 255, 243, 253, + 232, 164, 184, 247, 190, 255, 243, 253, + 233, 165, 185, 247, 191, 255, 244, 253, + 234, 166, 185, 247, 192, 255, 244, 253, + 235, 168, 186, 248, 193, 255, 244, 253, + 236, 169, 187, 248, 194, 255, 244, 253, + 236, 170, 188, 248, 195, 255, 245, 253, + 237, 171, 189, 249, 196, 255, 245, 254, + 238, 173, 190, 249, 197, 255, 245, 254, + 239, 174, 191, 249, 198, 255, 245, 254, + 240, 175, 192, 249, 199, 255, 246, 254, + 240, 177, 193, 250, 200, 255, 246, 254, + 241, 178, 194, 250, 201, 255, 246, 254, + 242, 179, 195, 250, 202, 255, 246, 254, + 242, 181, 196, 250, 203, 255, 247, 254, + 243, 182, 197, 251, 204, 255, 247, 254, + 244, 184, 198, 251, 205, 255, 247, 254, + 244, 185, 199, 251, 206, 255, 247, 254, + 245, 186, 200, 251, 207, 255, 247, 254, + 246, 188, 201, 252, 207, 255, 248, 254, + 246, 189, 202, 252, 208, 255, 248, 254, + 247, 191, 203, 252, 209, 255, 248, 254, + 247, 192, 204, 252, 210, 255, 248, 254, + 248, 194, 205, 252, 211, 255, 248, 254, + 248, 195, 206, 252, 212, 255, 249, 254, + 249, 197, 207, 253, 213, 255, 249, 254, + 249, 198, 208, 253, 214, 255, 249, 254, + 250, 200, 210, 253, 215, 255, 249, 254, + 250, 201, 211, 253, 215, 255, 249, 254, + 250, 203, 212, 253, 216, 255, 249, 254, + 251, 204, 213, 253, 217, 255, 250, 254, + 251, 206, 214, 254, 218, 255, 250, 254, + 252, 207, 216, 254, 219, 255, 250, 254, + 252, 209, 217, 254, 220, 255, 250, 254, + 252, 211, 218, 254, 221, 255, 250, 254, + 253, 213, 219, 254, 222, 255, 250, 254, + 253, 214, 221, 254, 223, 255, 250, 254, + 253, 216, 222, 254, 224, 255, 251, 254, + 253, 218, 224, 254, 225, 255, 251, 254, + 254, 220, 225, 254, 225, 255, 251, 254, + 254, 222, 227, 255, 226, 255, 251, 254, + 254, 224, 228, 255, 227, 255, 251, 254, + 254, 226, 230, 255, 228, 255, 251, 254, + 255, 228, 231, 255, 230, 255, 251, 254, + 255, 230, 233, 255, 231, 255, 252, 254, + 255, 232, 235, 255, 232, 255, 252, 254, + 255, 235, 237, 255, 233, 255, 252, 254, + 255, 238, 240, 255, 235, 255, 252, 255, + 255, 241, 243, 255, 236, 255, 252, 254, + 255, 246, 247, 255, 239, 255, 253, 255, +]; + +pub const MV_REF_BLOCKS: [i8; 208] = [ + -1, 0, 0, -1, -1, -1, -2, 0, 0, -2, -2, -1, -1, -2, -2, -2, + -1, 0, 0, -1, -1, -1, -2, 0, 0, -2, -2, -1, -1, -2, -2, -2, + -1, 0, 0, -1, -1, -1, -2, 0, 0, -2, -2, -1, -1, -2, -2, -2, + -1, 0, 0, -1, -1, -1, -2, 0, 0, -2, -2, -1, -1, -2, -2, -2, + 0, -1, -1, 0, 1, -1, -1, -1, 0, -2, -2, 0, -2, -1, -1, -2, + -1, 0, 0, -1, -1, 1, -1, -1, -2, 0, 0, -2, -1, -2, -2, -1, + -1, 0, 0, -1, -1, 1, 1, -1, -1, -1, -3, 0, 0, -3, -3, -3, + 0, -1, -1, 0, 2, -1, -1, -1, -1, 1, 0, -3, -3, 0, -3, -3, + -1, 0, 0, -1, -1, 2, -1, -1, 1, -1, -3, 0, 0, -3, -3, -3, + -1, 1, 1, -1, -1, 2, 2, -1, -1, -1, -3, 0, 0, -3, -3, -3, + 0, -1, -1, 0, 4, -1, -1, 2, -1, -1, 0, -3, -3, 0, 2, -1, + -1, 0, 0, -1, -1, 4, 2, -1, -1, -1, -3, 0, 0, -3, -1, 2, + -1, 3, 3, -1, -1, 4, 4, -1, -1, -1, -1, 0, 0, -1, -1, 6, +]; + +pub const MODE_2_COUNTER: [u8; 14] = [ + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 0, 0, 3, 1, +]; + +pub const KF_PARTITION_PROBS: [u8; 48] = [ + 158, 97, 94, + 93, 24, 99, + 85, 119, 44, + 62, 59, 67, + 149, 53, 53, + 94, 20, 48, + 83, 53, 24, + 52, 18, 18, + 150, 40, 39, + 78, 12, 26, + 67, 33, 11, + 24, 7, 5, + 174, 35, 49, + 68, 11, 27, + 57, 15, 9, + 12, 3, 3, +]; + +pub const KF_Y_MODE_PROBS: [u8; 900] = [ + 137, 30, 42, 148, 151, 207, 70, 52, 91, + 92, 45, 102, 136, 116, 180, 74, 90, 100, + 73, 32, 19, 187, 222, 215, 46, 34, 100, + 91, 30, 32, 116, 121, 186, 93, 86, 94, + 72, 35, 36, 149, 68, 206, 68, 63, 105, + 73, 31, 28, 138, 57, 124, 55, 122, 151, + 67, 23, 21, 140, 126, 197, 40, 37, 171, + 86, 27, 28, 128, 154, 212, 45, 43, 53, + 74, 32, 27, 107, 86, 160, 63, 134, 102, + 59, 67, 44, 140, 161, 202, 78, 67, 119, + 63, 36, 126, 146, 123, 158, 60, 90, 96, + 43, 46, 168, 134, 107, 128, 69, 142, 92, + 44, 29, 68, 159, 201, 177, 50, 57, 77, + 58, 38, 76, 114, 97, 172, 78, 133, 92, + 46, 41, 76, 140, 63, 184, 69, 112, 57, + 38, 32, 85, 140, 46, 112, 54, 151, 133, + 39, 27, 61, 131, 110, 175, 44, 75, 136, + 52, 30, 74, 113, 130, 175, 51, 64, 58, + 47, 35, 80, 100, 74, 143, 64, 163, 74, + 36, 61, 116, 114, 128, 162, 80, 125, 82, + 82, 26, 26, 171, 208, 204, 44, 32, 105, + 55, 44, 68, 166, 179, 192, 57, 57, 108, + 42, 26, 11, 199, 241, 228, 23, 15, 85, + 68, 42, 19, 131, 160, 199, 55, 52, 83, + 58, 50, 25, 139, 115, 232, 39, 52, 118, + 50, 35, 33, 153, 104, 162, 64, 59, 131, + 44, 24, 16, 150, 177, 202, 33, 19, 156, + 55, 27, 12, 153, 203, 218, 26, 27, 49, + 53, 49, 21, 110, 116, 168, 59, 80, 76, + 38, 72, 19, 168, 203, 212, 50, 50, 107, + 103, 26, 36, 129, 132, 201, 83, 80, 93, + 59, 38, 83, 112, 103, 162, 98, 136, 90, + 62, 30, 23, 158, 200, 207, 59, 57, 50, + 67, 30, 29, 84, 86, 191, 102, 91, 59, + 60, 32, 33, 112, 71, 220, 64, 89, 104, + 53, 26, 34, 130, 56, 149, 84, 120, 103, + 53, 21, 23, 133, 109, 210, 56, 77, 172, + 77, 19, 29, 112, 142, 228, 55, 66, 36, + 61, 29, 29, 93, 97, 165, 83, 175, 162, + 47, 47, 43, 114, 137, 181, 100, 99, 95, + 69, 23, 29, 128, 83, 199, 46, 44, 101, + 53, 40, 55, 139, 69, 183, 61, 80, 110, + 40, 29, 19, 161, 180, 207, 43, 24, 91, + 60, 34, 19, 105, 61, 198, 53, 64, 89, + 52, 31, 22, 158, 40, 209, 58, 62, 89, + 44, 31, 29, 147, 46, 158, 56, 102, 198, + 35, 19, 12, 135, 87, 209, 41, 45, 167, + 55, 25, 21, 118, 95, 215, 38, 39, 66, + 51, 38, 25, 113, 58, 164, 70, 93, 97, + 47, 54, 34, 146, 108, 203, 72, 103, 151, + 64, 19, 37, 156, 66, 138, 49, 95, 133, + 46, 27, 80, 150, 55, 124, 55, 121, 135, + 36, 23, 27, 165, 149, 166, 54, 64, 118, + 53, 21, 36, 131, 63, 163, 60, 109, 81, + 40, 26, 35, 154, 40, 185, 51, 97, 123, + 35, 19, 34, 179, 19, 97, 48, 129, 124, + 36, 20, 26, 136, 62, 164, 33, 77, 154, + 45, 18, 32, 130, 90, 157, 40, 79, 91, + 45, 26, 28, 129, 45, 129, 49, 147, 123, + 38, 44, 51, 136, 74, 162, 57, 97, 121, + 75, 17, 22, 136, 138, 185, 32, 34, 166, + 56, 39, 58, 133, 117, 173, 48, 53, 187, + 35, 21, 12, 161, 212, 207, 20, 23, 145, + 56, 29, 19, 117, 109, 181, 55, 68, 112, + 47, 29, 17, 153, 64, 220, 59, 51, 114, + 46, 16, 24, 136, 76, 147, 41, 64, 172, + 34, 17, 11, 108, 152, 187, 13, 15, 209, + 51, 24, 14, 115, 133, 209, 32, 26, 104, + 55, 30, 18, 122, 79, 179, 44, 88, 116, + 37, 49, 25, 129, 168, 164, 41, 54, 148, + 82, 22, 32, 127, 143, 213, 39, 41, 70, + 62, 44, 61, 123, 105, 189, 48, 57, 64, + 47, 25, 17, 175, 222, 220, 24, 30, 86, + 68, 36, 17, 106, 102, 206, 59, 74, 74, + 57, 39, 23, 151, 68, 216, 55, 63, 58, + 49, 30, 35, 141, 70, 168, 82, 40, 115, + 51, 25, 15, 136, 129, 202, 38, 35, 139, + 68, 26, 16, 111, 141, 215, 29, 28, 28, + 59, 39, 19, 114, 75, 180, 77, 104, 42, + 40, 61, 26, 126, 152, 206, 61, 59, 93, + 78, 23, 39, 111, 117, 170, 74, 124, 94, + 48, 34, 86, 101, 92, 146, 78, 179, 134, + 47, 22, 24, 138, 187, 178, 68, 69, 59, + 56, 25, 33, 105, 112, 187, 95, 177, 129, + 48, 31, 27, 114, 63, 183, 82, 116, 56, + 43, 28, 37, 121, 63, 123, 61, 192, 169, + 42, 17, 24, 109, 97, 177, 56, 76, 122, + 58, 18, 28, 105, 139, 182, 70, 92, 63, + 46, 23, 32, 74, 86, 150, 67, 183, 88, + 36, 38, 48, 92, 122, 165, 88, 137, 91, + 65, 70, 60, 155, 159, 199, 61, 60, 81, + 44, 78, 115, 132, 119, 173, 71, 112, 93, + 39, 38, 21, 184, 227, 206, 42, 32, 64, + 58, 47, 36, 124, 137, 193, 80, 82, 78, + 49, 50, 35, 144, 95, 205, 63, 78, 59, + 41, 53, 52, 148, 71, 142, 65, 128, 51, + 40, 36, 28, 143, 143, 202, 40, 55, 137, + 52, 34, 29, 129, 183, 227, 42, 35, 43, + 42, 44, 44, 104, 105, 164, 64, 130, 80, + 43, 81, 53, 140, 169, 204, 68, 84, 72, +]; + +pub const KF_UV_MODE_PROBS: [u8; 90] = [ + 144, 11, 54, 157, 195, 130, 46, 58, 108, + 118, 15, 123, 148, 131, 101, 44, 93, 131, + 113, 12, 23, 188, 226, 142, 26, 32, 125, + 120, 11, 50, 123, 163, 135, 64, 77, 103, + 113, 9, 36, 155, 111, 157, 32, 44, 161, + 116, 9, 55, 176, 76, 96, 37, 61, 149, + 115, 9, 28, 141, 161, 167, 21, 25, 193, + 120, 12, 32, 145, 195, 142, 32, 38, 86, + 116, 12, 64, 120, 140, 125, 49, 115, 121, + 102, 19, 66, 162, 182, 122, 35, 59, 128, +]; + +pub const DEFAULT_PARTITION_PROBS: [u8; 48] = [ + 199, 122, 141, + 147, 63, 159, + 148, 133, 118, + 121, 104, 114, + 174, 73, 87, + 92, 41, 83, + 82, 99, 50, + 53, 39, 39, + 177, 58, 59, + 68, 26, 63, + 52, 79, 25, + 17, 14, 12, + 222, 34, 30, + 72, 16, 44, + 58, 32, 12, + 10, 7, 6, +]; + +pub const DEFAULT_Y_MODE_PROBS: [u8; 36] = [ + 65, 32, 18, 144, 162, 194, 41, 51, 98, + 132, 68, 18, 165, 217, 196, 45, 40, 78, + 173, 80, 19, 176, 240, 193, 64, 35, 46, + 221, 135, 38, 194, 248, 121, 96, 85, 29, +]; + +pub const DEFAULT_UV_MODE_PROBS: [u8; 90] = [ + 120, 7, 76, 176, 208, 126, 28, 54, 103, + 48, 12, 154, 155, 139, 90, 34, 117, 119, + 67, 6, 25, 204, 243, 158, 13, 21, 96, + 97, 5, 44, 131, 176, 139, 48, 68, 97, + 83, 5, 42, 156, 111, 152, 26, 49, 152, + 80, 5, 58, 178, 74, 83, 33, 62, 145, + 86, 5, 32, 154, 192, 168, 14, 22, 163, + 85, 5, 32, 156, 216, 148, 19, 29, 73, + 77, 7, 64, 116, 132, 122, 37, 126, 120, + 101, 21, 107, 181, 192, 103, 19, 67, 125, +]; + +pub const DEFAULT_SKIP_PROB: [u8; 3] = [ + 192, 128, 64, +]; + +pub const DEFAULT_IS_INTER_PROB: [u8; 4] = [ + 9, 102, 187, 225, +]; + +pub const DEFAULT_COMP_MODE_PROB: [u8; 5] = [ + 239, 183, 119, 96, 41, +]; + +pub const DEFAULT_COMP_REF_PROB: [u8; 5] = [ + 50, 126, 123, 221, 226, +]; + +pub const DEFAULT_SINGLE_REF_PROB: [u8; 10] = [ + 33, 16, + 77, 74, + 142, 142, + 172, 170, + 238, 247, +]; + +pub const DEFAULT_MV_SIGN_PROB: [u8; 2] = [ + 128, 128, +]; + +pub const DEFAULT_MV_BITS_PROB: [u8; 20] = [ + 136, 140, 148, 160, 176, 192, 224, 234, 234, 240, + 136, 140, 148, 160, 176, 192, 224, 234, 234, 240, +]; + +pub const DEFAULT_MV_CLASS0_BIT_PROB: [u8; 2] = [ + 216, 208, +]; + +pub const DEFAULT_TX_PROBS: [u8; 24] = [ + 0, 0, 0, + 0, 0, 0, + 100, 0, 0, + 66, 0, 0, + 20, 152, 0, + 15, 101, 0, + 3, 136, 37, + 5, 52, 13, +]; + +pub const DEFAULT_INTER_MODE_PROBS: [u8; 21] = [ + 2, 173, 34, + 7, 145, 85, + 7, 166, 63, + 7, 94, 66, + 8, 64, 46, + 17, 81, 31, + 25, 29, 30, +]; + +pub const DEFAULT_INTERP_FILTER_PROBS: [u8; 8] = [ + 235, 162, + 36, 255, + 34, 3, + 149, 144, +]; + +pub const DEFAULT_MV_JOINT_PROBS: [u8; 3] = [ + 32, 64, 96, +]; + +pub const DEFAULT_MV_CLASS_PROBS: [u8; 20] = [ + 224, 144, 192, 168, 192, 176, 192, 198, 198, 245, + 216, 128, 176, 160, 176, 176, 192, 198, 198, 208, +]; + +pub const DEFAULT_MV_CLASS0_FR_PROBS: [u8; 12] = [ + 128, 128, 64, + 96, 112, 64, + 128, 128, 64, + 96, 112, 64, +]; + +pub const DEFAULT_MV_CLASS0_HP_PROB: [u8; 2] = [ + 160, 160, +]; + +pub const DEFAULT_MV_FR_PROBS: [u8; 6] = [ + 64, 96, 64, + 64, 96, 64, +]; + +pub const DEFAULT_MV_HP_PROB: [u8; 2] = [ + 128, 128, +]; + +pub const DEFAULT_COEF_PROBS: [u8; 1728] = [ + 195, 29, 183, + 84, 49, 136, + 8, 42, 71, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 31, 107, 169, + 35, 99, 159, + 17, 82, 140, + 8, 66, 114, + 2, 44, 76, + 1, 19, 32, + 40, 132, 201, + 29, 114, 187, + 13, 91, 157, + 7, 75, 127, + 3, 58, 95, + 1, 28, 47, + 69, 142, 221, + 42, 122, 201, + 15, 91, 159, + 6, 67, 121, + 1, 42, 77, + 1, 17, 31, + 102, 148, 228, + 67, 117, 204, + 17, 82, 154, + 6, 59, 114, + 2, 39, 75, + 1, 15, 29, + 156, 57, 233, + 119, 57, 212, + 58, 48, 163, + 29, 40, 124, + 12, 30, 81, + 3, 12, 31, + 191, 107, 226, + 124, 117, 204, + 25, 99, 155, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 29, 148, 210, + 37, 126, 194, + 8, 93, 157, + 2, 68, 118, + 1, 39, 69, + 1, 17, 33, + 41, 151, 213, + 27, 123, 193, + 3, 82, 144, + 1, 58, 105, + 1, 32, 60, + 1, 13, 26, + 59, 159, 220, + 23, 126, 198, + 4, 88, 151, + 1, 66, 114, + 1, 38, 71, + 1, 18, 34, + 114, 136, 232, + 51, 114, 207, + 11, 83, 155, + 3, 56, 105, + 1, 33, 65, + 1, 17, 34, + 149, 65, 234, + 121, 57, 215, + 61, 49, 166, + 28, 36, 114, + 12, 25, 76, + 3, 16, 42, + 214, 49, 220, + 132, 63, 188, + 42, 65, 137, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 85, 137, 221, + 104, 131, 216, + 49, 111, 192, + 21, 87, 155, + 2, 49, 87, + 1, 16, 28, + 89, 163, 230, + 90, 137, 220, + 29, 100, 183, + 10, 70, 135, + 2, 42, 81, + 1, 17, 33, + 108, 167, 237, + 55, 133, 222, + 15, 97, 179, + 4, 72, 135, + 1, 45, 85, + 1, 19, 38, + 124, 146, 240, + 66, 124, 224, + 17, 88, 175, + 4, 58, 122, + 1, 36, 75, + 1, 18, 37, + 141, 79, 241, + 126, 70, 227, + 66, 58, 182, + 30, 44, 136, + 12, 34, 96, + 2, 20, 47, + 229, 99, 249, + 143, 111, 235, + 46, 109, 192, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 82, 158, 236, + 94, 146, 224, + 25, 117, 191, + 9, 87, 149, + 3, 56, 99, + 1, 33, 57, + 83, 167, 237, + 68, 145, 222, + 10, 103, 177, + 2, 72, 131, + 1, 41, 79, + 1, 20, 39, + 99, 167, 239, + 47, 141, 224, + 10, 104, 178, + 2, 73, 133, + 1, 44, 85, + 1, 22, 47, + 127, 145, 243, + 71, 129, 228, + 17, 93, 177, + 3, 61, 124, + 1, 41, 84, + 1, 21, 52, + 157, 78, 244, + 140, 72, 231, + 69, 58, 184, + 31, 44, 137, + 14, 38, 105, + 8, 23, 61, + 125, 34, 187, + 52, 41, 133, + 6, 31, 56, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 37, 109, 153, + 51, 102, 147, + 23, 87, 128, + 8, 67, 101, + 1, 41, 63, + 1, 19, 29, + 31, 154, 185, + 17, 127, 175, + 6, 96, 145, + 2, 73, 114, + 1, 51, 82, + 1, 28, 45, + 23, 163, 200, + 10, 131, 185, + 2, 93, 148, + 1, 67, 111, + 1, 41, 69, + 1, 14, 24, + 29, 176, 217, + 12, 145, 201, + 3, 101, 156, + 1, 69, 111, + 1, 39, 63, + 1, 14, 23, + 57, 192, 233, + 25, 154, 215, + 6, 109, 167, + 3, 78, 118, + 1, 48, 69, + 1, 21, 29, + 202, 105, 245, + 108, 106, 216, + 18, 90, 144, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 33, 172, 219, + 64, 149, 206, + 14, 117, 177, + 5, 90, 141, + 2, 61, 95, + 1, 37, 57, + 33, 179, 220, + 11, 140, 198, + 1, 89, 148, + 1, 60, 104, + 1, 33, 57, + 1, 12, 21, + 30, 181, 221, + 8, 141, 198, + 1, 87, 145, + 1, 58, 100, + 1, 31, 55, + 1, 12, 20, + 32, 186, 224, + 7, 142, 198, + 1, 86, 143, + 1, 58, 100, + 1, 31, 55, + 1, 12, 22, + 57, 192, 227, + 20, 143, 204, + 3, 96, 154, + 1, 68, 112, + 1, 42, 69, + 1, 19, 32, + 212, 35, 215, + 113, 47, 169, + 29, 48, 105, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 74, 129, 203, + 106, 120, 203, + 49, 107, 178, + 19, 84, 144, + 4, 50, 84, + 1, 15, 25, + 71, 172, 217, + 44, 141, 209, + 15, 102, 173, + 6, 76, 133, + 2, 51, 89, + 1, 24, 42, + 64, 185, 231, + 31, 148, 216, + 8, 103, 175, + 3, 74, 131, + 1, 46, 81, + 1, 18, 30, + 65, 196, 235, + 25, 157, 221, + 5, 105, 174, + 1, 67, 120, + 1, 38, 69, + 1, 15, 30, + 65, 204, 238, + 30, 156, 224, + 7, 107, 177, + 2, 70, 124, + 1, 42, 73, + 1, 18, 34, + 225, 86, 251, + 144, 104, 235, + 42, 99, 181, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 85, 175, 239, + 112, 165, 229, + 29, 136, 200, + 12, 103, 162, + 6, 77, 123, + 2, 53, 84, + 75, 183, 239, + 30, 155, 221, + 3, 106, 171, + 1, 74, 128, + 1, 44, 76, + 1, 17, 28, + 73, 185, 240, + 27, 159, 222, + 2, 107, 172, + 1, 75, 127, + 1, 42, 73, + 1, 17, 29, + 62, 190, 238, + 21, 159, 222, + 2, 107, 172, + 1, 72, 122, + 1, 40, 71, + 1, 18, 32, + 61, 199, 240, + 27, 161, 226, + 4, 113, 180, + 1, 76, 129, + 1, 46, 80, + 1, 23, 41, + 7, 27, 153, + 5, 30, 95, + 1, 16, 30, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 50, 75, 127, + 57, 75, 124, + 27, 67, 108, + 10, 54, 86, + 1, 33, 52, + 1, 12, 18, + 43, 125, 151, + 26, 108, 148, + 7, 83, 122, + 2, 59, 89, + 1, 38, 60, + 1, 17, 27, + 23, 144, 163, + 13, 112, 154, + 2, 75, 117, + 1, 50, 81, + 1, 31, 51, + 1, 14, 23, + 18, 162, 185, + 6, 123, 171, + 1, 78, 125, + 1, 51, 86, + 1, 31, 54, + 1, 14, 23, + 15, 199, 227, + 3, 150, 204, + 1, 91, 146, + 1, 55, 95, + 1, 30, 53, + 1, 11, 20, + 19, 55, 240, + 19, 59, 196, + 3, 52, 105, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 41, 166, 207, + 104, 153, 199, + 31, 123, 181, + 14, 101, 152, + 5, 72, 106, + 1, 36, 52, + 35, 176, 211, + 12, 131, 190, + 2, 88, 144, + 1, 60, 101, + 1, 36, 60, + 1, 16, 28, + 28, 183, 213, + 8, 134, 191, + 1, 86, 142, + 1, 56, 96, + 1, 30, 53, + 1, 12, 20, + 20, 190, 215, + 4, 135, 192, + 1, 84, 139, + 1, 53, 91, + 1, 28, 49, + 1, 11, 20, + 13, 196, 216, + 2, 137, 192, + 1, 86, 143, + 1, 57, 99, + 1, 32, 56, + 1, 13, 24, + 211, 29, 217, + 96, 47, 156, + 22, 43, 87, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 78, 120, 193, + 111, 116, 186, + 46, 102, 164, + 15, 80, 128, + 2, 49, 76, + 1, 18, 28, + 71, 161, 203, + 42, 132, 192, + 10, 98, 150, + 3, 69, 109, + 1, 44, 70, + 1, 18, 29, + 57, 186, 211, + 30, 140, 196, + 4, 93, 146, + 1, 62, 102, + 1, 38, 65, + 1, 16, 27, + 47, 199, 217, + 14, 145, 196, + 1, 88, 142, + 1, 57, 98, + 1, 36, 62, + 1, 15, 26, + 26, 219, 229, + 5, 155, 207, + 1, 94, 151, + 1, 60, 104, + 1, 36, 62, + 1, 16, 28, + 233, 29, 248, + 146, 47, 220, + 43, 52, 140, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 100, 163, 232, + 179, 161, 222, + 63, 142, 204, + 37, 113, 174, + 26, 89, 137, + 18, 68, 97, + 85, 181, 230, + 32, 146, 209, + 7, 100, 164, + 3, 71, 121, + 1, 45, 77, + 1, 18, 30, + 65, 187, 230, + 20, 148, 207, + 2, 97, 159, + 1, 68, 116, + 1, 40, 70, + 1, 14, 29, + 40, 194, 227, + 8, 147, 204, + 1, 94, 155, + 1, 65, 112, + 1, 39, 66, + 1, 14, 26, + 16, 208, 228, + 3, 151, 207, + 1, 98, 160, + 1, 67, 117, + 1, 41, 74, + 1, 17, 31, + 17, 38, 140, + 7, 34, 80, + 1, 17, 29, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 37, 75, 128, + 41, 76, 128, + 26, 66, 116, + 12, 52, 94, + 2, 32, 55, + 1, 10, 16, + 50, 127, 154, + 37, 109, 152, + 16, 82, 121, + 5, 59, 85, + 1, 35, 54, + 1, 13, 20, + 40, 142, 167, + 17, 110, 157, + 2, 71, 112, + 1, 44, 72, + 1, 27, 45, + 1, 11, 17, + 30, 175, 188, + 9, 124, 169, + 1, 74, 116, + 1, 48, 78, + 1, 30, 49, + 1, 11, 18, + 10, 222, 223, + 2, 150, 194, + 1, 83, 128, + 1, 48, 79, + 1, 27, 45, + 1, 11, 17, + 36, 41, 235, + 29, 36, 193, + 10, 27, 111, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 85, 165, 222, + 177, 162, 215, + 110, 135, 195, + 57, 113, 168, + 23, 83, 120, + 10, 49, 61, + 85, 190, 223, + 36, 139, 200, + 5, 90, 146, + 1, 60, 103, + 1, 38, 65, + 1, 18, 30, + 72, 202, 223, + 23, 141, 199, + 2, 86, 140, + 1, 56, 97, + 1, 36, 61, + 1, 16, 27, + 55, 218, 225, + 13, 145, 200, + 1, 86, 141, + 1, 57, 99, + 1, 35, 61, + 1, 13, 22, + 15, 235, 212, + 1, 132, 184, + 1, 84, 139, + 1, 57, 97, + 1, 34, 56, + 1, 14, 23, + 181, 21, 201, + 61, 37, 123, + 10, 38, 71, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 47, 106, 172, + 95, 104, 173, + 42, 93, 159, + 18, 77, 131, + 4, 50, 81, + 1, 17, 23, + 62, 147, 199, + 44, 130, 189, + 28, 102, 154, + 18, 75, 115, + 2, 44, 65, + 1, 12, 19, + 55, 153, 210, + 24, 130, 194, + 3, 93, 146, + 1, 61, 97, + 1, 31, 50, + 1, 10, 16, + 49, 186, 223, + 17, 148, 204, + 1, 96, 142, + 1, 53, 83, + 1, 26, 44, + 1, 11, 17, + 13, 217, 212, + 2, 136, 180, + 1, 78, 124, + 1, 50, 83, + 1, 29, 49, + 1, 14, 23, + 197, 13, 247, + 82, 17, 222, + 25, 17, 162, + 0, 0, 0, + 0, 0, 0, + 0, 0, 0, + 126, 186, 247, + 234, 191, 243, + 176, 177, 234, + 104, 158, 220, + 66, 128, 186, + 55, 90, 137, + 111, 197, 242, + 46, 158, 219, + 9, 104, 171, + 2, 65, 125, + 1, 44, 80, + 1, 17, 91, + 104, 208, 245, + 39, 168, 224, + 3, 109, 162, + 1, 79, 124, + 1, 50, 102, + 1, 43, 102, + 84, 220, 246, + 31, 177, 231, + 2, 115, 180, + 1, 79, 134, + 1, 55, 77, + 1, 60, 79, + 43, 243, 240, + 8, 180, 217, + 1, 115, 166, + 1, 84, 121, + 1, 51, 67, + 1, 16, 6, +]; + diff --git a/crates/vp9/src/tables.rs b/crates/vp9/src/tables.rs new file mode 100644 index 000000000..d11e35cf5 --- /dev/null +++ b/crates/vp9/src/tables.rs @@ -0,0 +1,285 @@ +//! Constants, small lookup tables and decode trees of the VP9 specification (sections 3, 6, 9.3 +//! and 10.2). The large tables are extracted from the specification text into `spec_tables`. + +pub use crate::spec_tables::*; + +// Block sizes (7.4.3). +pub const BLOCK_8X8: u8 = 3; +pub const BLOCK_16X16: u8 = 6; +pub const BLOCK_64X64: u8 = 12; +pub const BLOCK_INVALID: u8 = 14; + +// Transform sizes / modes. +pub const TX_4X4: u8 = 0; +pub const TX_8X8: u8 = 1; +pub const TX_16X16: u8 = 2; +pub const TX_32X32: u8 = 3; +pub const TX_MODE_SELECT: u8 = 4; + +// Prediction modes (7.4.5, 7.4.11). +pub const DC_PRED: u8 = 0; +pub const V_PRED: u8 = 1; +pub const H_PRED: u8 = 2; +pub const D45_PRED: u8 = 3; +pub const D135_PRED: u8 = 4; +pub const D117_PRED: u8 = 5; +pub const D153_PRED: u8 = 6; +pub const D207_PRED: u8 = 7; +pub const D63_PRED: u8 = 8; +pub const TM_PRED: u8 = 9; +pub const NEARESTMV: u8 = 10; +pub const NEARMV: u8 = 11; +pub const ZEROMV: u8 = 12; +pub const NEWMV: u8 = 13; + +// Reference frames. +pub const NONE: i8 = -1; +pub const INTRA_FRAME: i8 = 0; +pub const LAST_FRAME: i8 = 1; +pub const GOLDEN_FRAME: i8 = 2; +pub const ALTREF_FRAME: i8 = 3; + +// Transform types. +pub const DCT_DCT: u8 = 0; +pub const ADST_DCT: u8 = 1; +pub const DCT_ADST: u8 = 2; +pub const ADST_ADST: u8 = 3; + +// Interpolation filters. +pub const SWITCHABLE: u8 = 4; + +// Segmentation features. +pub const SEG_LVL_ALT_Q: usize = 0; +pub const SEG_LVL_ALT_L: usize = 1; +pub const SEG_LVL_REF_FRAME: usize = 2; +pub const SEG_LVL_SKIP: usize = 3; +pub const MAX_SEGMENTS: usize = 8; + +pub const MAX_LOOP_FILTER: i32 = 63; +pub const MIN_TILE_WIDTH_B64: u32 = 4; +pub const MAX_TILE_WIDTH_B64: u32 = 64; +pub const COMPANDED_MVREF_THRESH: i32 = 8; +pub const MV_BORDER: i32 = 128; +pub const INTERP_EXTEND: i32 = 4; +pub const BORDERINPIXELS: i32 = 160; +pub const COUNT_SAT: u32 = 20; +pub const MAX_UPDATE_FACTOR: u32 = 128; + +pub const B_WIDTH_LOG2: [u8; 13] = [0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4]; +pub const B_HEIGHT_LOG2: [u8; 13] = [0, 1, 0, 1, 2, 1, 2, 3, 2, 3, 4, 3, 4]; +pub const NUM_4X4_WIDE: [u8; 13] = [1, 1, 2, 2, 2, 4, 4, 4, 8, 8, 8, 16, 16]; +pub const NUM_4X4_HIGH: [u8; 13] = [1, 2, 1, 2, 4, 2, 4, 8, 4, 8, 16, 8, 16]; +pub const MI_WIDTH_LOG2: [u8; 13] = [0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3]; +pub const NUM_8X8_WIDE: [u8; 13] = [1, 1, 1, 1, 1, 2, 2, 2, 4, 4, 4, 8, 8]; +pub const NUM_8X8_HIGH: [u8; 13] = [1, 1, 1, 1, 2, 1, 2, 4, 2, 4, 8, 4, 8]; +pub const SIZE_GROUP: [u8; 13] = [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 3]; +pub const MAX_TXSIZE: [u8; 13] = [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 3]; +pub const TX_MODE_TO_BIGGEST_TX_SIZE: [u8; 5] = [0, 1, 2, 3, 3]; + +/// subsize_lookup[partition][bsize] (10.2). +pub const SUBSIZE_LOOKUP: [[u8; 13]; 4] = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + [14, 14, 14, 2, 14, 14, 5, 14, 14, 8, 14, 14, 11], + [14, 14, 14, 1, 14, 14, 4, 14, 14, 7, 14, 14, 10], + [14, 14, 14, 0, 14, 14, 3, 14, 14, 6, 14, 14, 9], +]; + +/// ss_size_lookup[bsize][subsampling_x][subsampling_y] (6.4.23). +pub const SS_SIZE_LOOKUP: [[[u8; 2]; 2]; 13] = [ + [[0, 14], [14, 14]], + [[1, 0], [14, 14]], + [[2, 14], [0, 14]], + [[3, 2], [1, 0]], + [[4, 3], [14, 1]], + [[5, 14], [3, 2]], + [[6, 5], [4, 3]], + [[7, 6], [14, 4]], + [[8, 14], [6, 5]], + [[9, 8], [7, 6]], + [[10, 9], [14, 7]], + [[11, 14], [9, 8]], + [[12, 11], [10, 9]], +]; + +/// mode2txfm_map (10.2). +pub const MODE2TXFM: [u8; 14] = + [DCT_DCT, ADST_DCT, DCT_ADST, DCT_DCT, ADST_ADST, ADST_DCT, DCT_ADST, DCT_ADST, ADST_DCT, ADST_ADST, DCT_DCT, DCT_DCT, DCT_DCT, DCT_DCT]; + +/// counter_to_context (6.5.1); INVALID_CASE entries are 9. +pub const COUNTER_TO_CONTEXT: [u8; 19] = [2, 3, 4, 1, 3, 9, 0, 9, 9, 5, 5, 9, 5, 9, 9, 9, 9, 9, 6]; + +/// idx_n_column_to_subblock (6.5.11). +pub const IDX_N_COLUMN_TO_SUBBLOCK: [[u8; 2]; 4] = [[1, 2], [1, 3], [3, 2], [3, 3]]; + +/// literal_to_type (6.2.7). +pub const LITERAL_TO_TYPE: [u8; 4] = [1, 0, 2, 3]; + +pub const SEGMENTATION_FEATURE_BITS: [u32; 4] = [8, 6, 2, 0]; +pub const SEGMENTATION_FEATURE_SIGNED: [bool; 4] = [true, true, false, false]; + +/// extra_bits[token] = (cat, numExtra, base) (6.4.26). +pub const EXTRA_BITS: [(u8, u8, u16); 11] = + [(0, 0, 0), (0, 0, 1), (0, 0, 2), (0, 0, 3), (0, 0, 4), (1, 1, 5), (2, 2, 7), (3, 3, 11), (4, 4, 19), (5, 5, 35), (6, 14, 67)]; + +/// cat_probs (6.4.26). +pub const CAT_PROBS: [&[u8]; 7] = [ + &[], + &[159], + &[165, 145], + &[173, 148, 140], + &[176, 155, 140, 135], + &[180, 157, 141, 134, 130], + &[254, 254, 254, 252, 249, 243, 230, 196, 177, 153, 140, 133, 130, 129], +]; + +// Decode trees (9.3.1); leaves are stored negated. +pub const PARTITION_TREE: [i8; 6] = [0, 2, -1, 4, -2, -3]; +pub const INTRA_MODE_TREE: [i8; 18] = [ + -(DC_PRED as i8), + 2, + -(TM_PRED as i8), + 4, + -(V_PRED as i8), + 6, + 8, + 12, + -(H_PRED as i8), + 10, + -(D135_PRED as i8), + -(D117_PRED as i8), + -(D45_PRED as i8), + 14, + -(D63_PRED as i8), + 16, + -(D153_PRED as i8), + -(D207_PRED as i8), +]; +pub const SEGMENT_TREE: [i8; 14] = [2, 4, 6, 8, 10, 12, 0, -1, -2, -3, -4, -5, -6, -7]; +pub const TX_SIZE_32_TREE: [i8; 6] = [0, 2, -1, 4, -2, -3]; +pub const TX_SIZE_16_TREE: [i8; 4] = [0, 2, -1, -2]; +pub const TX_SIZE_8_TREE: [i8; 2] = [0, -1]; +/// inter_mode_tree: values are inter_mode = y_mode - NEARESTMV. +pub const INTER_MODE_TREE: [i8; 6] = [-2, 2, 0, 4, -1, -3]; +pub const INTERP_FILTER_TREE: [i8; 4] = [0, 2, -1, -2]; +pub const MV_JOINT_TREE: [i8; 6] = [0, 2, -1, 4, -2, -3]; +pub const MV_CLASS_TREE: [i8; 20] = [0, 2, -1, 4, 6, 8, -2, -3, 10, 12, -4, -5, -6, 14, 16, 18, -7, -8, -9, -10]; +pub const MV_FR_TREE: [i8; 6] = [0, 2, -1, 4, -2, -3]; +/// small_token_tree used by coefficient probability adaptation (8.4.3). +pub const SMALL_TOKEN_TREE: [i8; 6] = [0, 0, 0, 4, -1, -2]; +pub const BINARY_TREE: [i8; 2] = [0, -1]; + +/// Probabilities of the full token tree for each model probability (9.3.2 pareto): row +/// `p - 1` holds the probabilities of token-tree nodes 2..9 when `coef_probs[..][2] == p`. +pub fn pareto_full() -> &'static [[u8; 8]; 255] { + use std::sync::OnceLock; + static T: OnceLock<[[u8; 8]; 255]> = OnceLock::new(); + T.get_or_init(|| { + let mut t = [[0u8; 8]; 255]; + for p in 1..=255usize { + let x = (p - 1) / 2; + for n in 0..8 { + t[p - 1][n] = + if p & 1 == 1 { PARETO_TABLE[x * 8 + n] } else { ((PARETO_TABLE[x * 8 + n] as u32 + PARETO_TABLE[(x + 1) * 8 + n] as u32) >> 1) as u8 }; + } + } + t + }) +} + +/// Scan order for a transform size and type (6.4.25). +pub fn scan(tx_size: u8, tx_type: u8) -> &'static [u16] { + match (tx_size, tx_type) { + (0, ADST_DCT) => &ROW_SCAN_4X4, + (0, DCT_ADST) => &COL_SCAN_4X4, + (0, _) => &DEFAULT_SCAN_4X4, + (1, ADST_DCT) => &ROW_SCAN_8X8, + (1, DCT_ADST) => &COL_SCAN_8X8, + (1, _) => &DEFAULT_SCAN_8X8, + (2, ADST_DCT) => &ROW_SCAN_16X16, + (2, DCT_ADST) => &COL_SCAN_16X16, + (2, _) => &DEFAULT_SCAN_16X16, + _ => &DEFAULT_SCAN_32X32, + } +} + +/// Coefficient context neighbours (9.3.2, `nb`) for every scan position of a transform size and +/// type: `[c] = (nb0, nb1)` positions in the block. +pub fn neighbors(tx_size: u8, tx_type: u8) -> &'static [(u16, u16)] { + use std::sync::OnceLock; + static T: OnceLock>> = OnceLock::new(); + let all = T.get_or_init(|| { + let mut v = Vec::new(); + for tx in 0..4u8 { + for ty in 0..4u8 { + let sc = scan(tx, ty); + let n = 4usize << tx; + let mut nb = vec![(0u16, 0u16); sc.len()]; + for (c, &pos) in sc.iter().enumerate().skip(1) { + let pos = pos as usize; + let (i, j) = (pos / n, pos % n); + nb[c] = if i > 0 && j > 0 { + let a = ((i - 1) * n + j) as u16; + let a2 = (i * n + j - 1) as u16; + // Only the 4x4..16x16 scans depend on the type; 32x32 is always DCT_DCT. + let ty = if tx == 3 { DCT_DCT } else { ty }; + match ty { + DCT_ADST => (a, a), + ADST_DCT => (a2, a2), + _ => (a, a2), + } + } else if i > 0 { + let a = ((i - 1) * n + j) as u16; + (a, a) + } else { + let a = (i * n + j - 1) as u16; + (a, a) + }; + } + v.push(nb); + } + } + v + }); + &all[(tx_size * 4 + tx_type) as usize] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trees_are_consistent() { + // Every leaf value appears exactly once. + fn leaves(t: &[i8]) -> Vec { + let mut v: Vec = + t.iter().enumerate().filter(|(i, x)| **x <= 0 && !(*i == 0 && t.len() > 2 && t[0] == 0 && t[1] == 0)).map(|(_, x)| -x).collect(); + v.sort(); + v + } + assert_eq!(leaves(&INTRA_MODE_TREE), (0..10).collect::>()); + assert_eq!(leaves(&MV_CLASS_TREE), (0..11).collect::>()); + assert_eq!(leaves(&PARTITION_TREE), (0..4).collect::>()); + assert_eq!(leaves(&INTER_MODE_TREE), (0..4).collect::>()); + } + + #[test] + fn pareto_rows() { + let p = pareto_full(); + assert_eq!(p[0], [3, 86, 128, 6, 86, 23, 88, 29]); + assert_eq!(p[254][..], PARETO_TABLE[127 * 8..128 * 8]); + } + + #[test] + fn default_coef_probs_unused_are_zero() { + for (k, chunk) in DEFAULT_COEF_PROBS.chunks(3 * 6).enumerate() { + let band = k % 6; + for (ctx, p) in chunk.chunks(3).enumerate() { + if band == 0 && ctx >= 3 { + assert_eq!(p, [0, 0, 0]); + } else { + assert!(p.iter().all(|&x| x > 0), "band {band} ctx {ctx}"); + } + } + } + } +} diff --git a/crates/vp9/src/tile.rs b/crates/vp9/src/tile.rs new file mode 100644 index 000000000..87a25ec14 --- /dev/null +++ b/crates/vp9/src/tile.rs @@ -0,0 +1,1080 @@ +//! Tile decoding: partition trees, mode info (6.4), and block reconstruction. One `TileDecoder` +//! decodes all tiles of one tile column (tile columns are independent of each other; tile rows +//! of a column are decoded in order). + +use crate::boolcoder::BoolDecoder; +use crate::frame::{Frame, MiGrid, MiInfo, Mv}; +use crate::header::{FrameHeader, Segmentation}; +use crate::probs::{Counts, FrameContext}; +use crate::tables::*; + +mod recon; + +/// A reference frame as used by the current frame, with its scale factors (8.5.2.3). +pub struct RefUse<'a> { + pub frame: &'a Frame, + pub x_scale: i32, + pub y_scale: i32, + pub x_step: i32, + pub y_step: i32, +} + +/// Read-only data shared by all tile columns of a frame. +pub struct FrameShared<'a> { + pub h: &'a FrameHeader, + pub fc: &'a FrameContext, + pub seg: &'a Segmentation, + /// PrevSegmentIds (MiRows x MiCols). + pub prev_seg_ids: &'a [u8], + /// Mode info of the previous frame when UsePrevFrameMvs is 1. + pub prev_mi: Option<&'a MiGrid>, + /// References for LAST, GOLDEN, ALTREF. + pub refs: [Option>; 3], + /// Quantizers per segment: [segment][plane > 0][dc = 0 / ac = 1]. + pub seg_q: [[[i32; 2]; 2]; 8], + /// Whether syntax elements need to be counted (backward adaptation enabled). + pub counting: bool, +} + +/// Output of one tile column: samples, mode info and segment ids of its columns. +pub struct Strip { + pub mi_col_start: usize, + pub mi_col_end: usize, + /// Mode info, (mi_col_end - mi_col_start) x MiRows. + pub mi: Vec, + pub seg_ids: Vec, + pub planes: [Vec; 3], + pub strides: [usize; 3], + /// Plane x coordinate of the first column of each plane buffer. + pub x_off: [usize; 3], + pub counts: Box, + /// Statistics. + pub compound_blocks: u64, + pub scaled_blocks: u64, + pub intra_blocks: u64, + pub inter_blocks: u64, +} + +/// Per-block state (the variables of 6.4.4 - 6.4.20). +#[derive(Clone, Copy, Default)] +struct Blk { + r: usize, + c: usize, + size: u8, + avail_u: bool, + avail_l: bool, + seg_id: u8, + skip: bool, + tx_size: u8, + is_inter: bool, + y_mode: u8, + sub_modes: [u8; 4], + uv_mode: u8, + ref_frame: [i8; 2], + interp_filter: u8, + block_mvs: [[Mv; 4]; 2], + left_ref: [i8; 2], + above_ref: [i8; 2], + left_intra: bool, + above_intra: bool, + left_single: bool, + above_single: bool, + eob_total: u32, +} + +pub struct TileDecoder<'a> { + s: &'a FrameShared<'a>, + h: &'a FrameHeader, + fc: &'a FrameContext, + pub strip: Strip, + mi_cols: usize, + mi_rows: usize, + ss_x: usize, + ss_y: usize, + bit_depth: u8, + // Contexts (indexed by absolute position). + above_nonzero: [Vec; 3], + left_nonzero: [Vec; 3], + above_partition: Vec, + left_partition: Vec, + above_seg_pred: Vec, + left_seg_pred: Vec, + // MV prediction state. + ref_list_mv: [Mv; 2], + ref_mv_count: usize, + mode_context: [u8; 4], + nearest_mv: [Mv; 2], + near_mv: [Mv; 2], + best_mv: [Mv; 2], + // Scratch. + coefs: Vec, + token_cache: Vec, + pred: Vec, + pred2: Vec, + mc_tmp: Vec, + /// First error met while decoding (e.g. a block referencing an unusable reference frame). + pub error: Option, +} + +impl<'a> TileDecoder<'a> { + pub fn new(s: &'a FrameShared<'a>, mi_col_start: usize, mi_col_end: usize) -> Self { + let h = s.h; + let (mi_cols, mi_rows) = (h.mi_cols as usize, h.mi_rows as usize); + let (ss_x, ss_y) = (h.color.subsampling_x as usize, h.color.subsampling_y as usize); + let sb_rows = h.sb64_rows as usize; + let sb_cols = h.sb64_cols as usize; + let strip_w = (mi_col_end - mi_col_start).div_ceil(8) * 64; + let strip_h = sb_rows * 64; + let x0 = mi_col_start * 8; + let planes = [vec![0u16; strip_w * strip_h], vec![0u16; (strip_w >> ss_x) * (strip_h >> ss_y)], vec![0u16; (strip_w >> ss_x) * (strip_h >> ss_y)]]; + let mi_w = mi_col_end - mi_col_start; + let strip = Strip { + mi_col_start, + mi_col_end, + mi: vec![MiInfo::default(); mi_w * mi_rows], + seg_ids: vec![0; mi_w * mi_rows], + planes, + strides: [strip_w, strip_w >> ss_x, strip_w >> ss_x], + x_off: [x0, x0 >> ss_x, x0 >> ss_x], + counts: Box::default(), + compound_blocks: 0, + scaled_blocks: 0, + intra_blocks: 0, + inter_blocks: 0, + }; + let an = sb_cols * 16 + 16; + let ln = sb_rows * 16 + 16; + TileDecoder { + s, + h, + fc: s.fc, + strip, + mi_cols, + mi_rows, + ss_x, + ss_y, + bit_depth: h.color.bit_depth, + above_nonzero: [vec![0; an], vec![0; an], vec![0; an]], + left_nonzero: [vec![0; ln], vec![0; ln], vec![0; ln]], + above_partition: vec![0; sb_cols * 8 + 8], + left_partition: vec![0; sb_rows * 8 + 8], + above_seg_pred: vec![0; sb_cols * 8 + 8], + left_seg_pred: vec![0; sb_rows * 8 + 8], + ref_list_mv: [Mv::ZERO; 2], + ref_mv_count: 0, + mode_context: [0; 4], + nearest_mv: [Mv::ZERO; 2], + near_mv: [Mv::ZERO; 2], + best_mv: [Mv::ZERO; 2], + coefs: vec![0; 1024], + token_cache: vec![0; 1024], + pred: vec![0; 64 * 64], + pred2: vec![0; 64 * 64], + mc_tmp: vec![0; 64 * (64 * 5 + 16)], + error: None, + } + } + + #[inline] + fn mi_at(&self, r: usize, c: usize) -> &MiInfo { + &self.strip.mi[r * (self.strip.mi_col_end - self.strip.mi_col_start) + c - self.strip.mi_col_start] + } + + /// decode_tile (6.4.2) for one tile of this column. Returns false if the data ran out badly. + pub fn decode_tile(&mut self, data: &[u8], mi_row_start: usize, mi_row_end: usize) -> bool { + let mut bd = BoolDecoder::new(data); + let (cs, ce) = (self.strip.mi_col_start, self.strip.mi_col_end); + for r in (mi_row_start..mi_row_end).step_by(8) { + // clear_left_context + for p in 0..3 { + let sy = if p > 0 { self.ss_y } else { 0 }; + let s = (r * 2) >> sy; + let e = (s + (16 >> sy)).min(self.left_nonzero[p].len()); + self.left_nonzero[p][s..e].fill(0); + } + self.left_partition[r..r + 8].fill(0); + self.left_seg_pred[r..r + 8].fill(0); + for c in (cs..ce).step_by(8) { + self.decode_partition(&mut bd, r, c, BLOCK_64X64); + } + if bd.overrun() { + return false; + } + } + true + } + + fn decode_partition(&mut self, bd: &mut BoolDecoder, r: usize, c: usize, bsize: u8) { + if r >= self.mi_rows || c >= self.mi_cols { + return; + } + let num8x8 = NUM_8X8_WIDE[bsize as usize] as usize; + let half = num8x8 >> 1; + let has_rows = (r + half) < self.mi_rows; + let has_cols = (c + half) < self.mi_cols; + let bsl = MI_WIDTH_LOG2[bsize as usize] as usize; + let boffset = 3 - bsl; + let mut above = 0u8; + let mut left = 0u8; + for i in 0..num8x8 { + above |= self.above_partition[c + i]; + left |= self.left_partition[r + i]; + } + let above = (above >> boffset) & 1; + let left = (left >> boffset) & 1; + let ctx = bsl * 4 + left as usize * 2 + above as usize; + let probs: &[u8] = if self.h.frame_is_intra { &KF_PARTITION_PROBS[ctx * 3..ctx * 3 + 3] } else { &self.fc.partition[ctx] }; + let partition = if has_rows && has_cols { + bd.read_tree(&PARTITION_TREE, probs) + } else if has_cols { + if bd.read_bool(probs[1]) { 3 } else { 1 } + } else if has_rows { + if bd.read_bool(probs[2]) { 3 } else { 2 } + } else { + 3 + }; + if self.s.counting { + self.strip.counts.partition[ctx][partition as usize] += 1; + } + let subsize = SUBSIZE_LOOKUP[partition as usize][bsize as usize]; + if subsize < BLOCK_8X8 || partition == 0 { + self.decode_block(bd, r, c, subsize); + } else if partition == 1 { + self.decode_block(bd, r, c, subsize); + if has_rows { + self.decode_block(bd, r + half, c, subsize); + } + } else if partition == 2 { + self.decode_block(bd, r, c, subsize); + if has_cols { + self.decode_block(bd, r, c + half, subsize); + } + } else { + self.decode_partition(bd, r, c, subsize); + self.decode_partition(bd, r, c + half, subsize); + self.decode_partition(bd, r + half, c, subsize); + self.decode_partition(bd, r + half, c + half, subsize); + } + if bsize == BLOCK_8X8 || partition != 3 { + let a = 15 >> B_WIDTH_LOG2[subsize as usize]; + let l = 15 >> B_HEIGHT_LOG2[subsize as usize]; + self.above_partition[c..c + num8x8].fill(a); + self.left_partition[r..r + num8x8].fill(l); + } + } + + fn decode_block(&mut self, bd: &mut BoolDecoder, r: usize, c: usize, size: u8) { + let mut b = Blk { r, c, size, avail_u: r > 0, avail_l: c > self.strip.mi_col_start, ..Default::default() }; + // Chroma block size must be valid (7.4.3); treat violations as a damaged stream. + if size >= BLOCK_8X8 && SS_SIZE_LOOKUP[size as usize][self.ss_x][self.ss_y] == BLOCK_INVALID { + b.size = BLOCK_8X8; + } + if self.h.frame_is_intra { + self.intra_frame_mode_info(bd, &mut b); + } else { + self.inter_frame_mode_info(bd, &mut b); + } + if b.is_inter { + self.strip.inter_blocks += 1; + } else { + self.strip.intra_blocks += 1; + } + self.residual(bd, &mut b); + if b.is_inter && b.size >= BLOCK_8X8 && b.eob_total == 0 { + b.skip = true; + } + let info = MiInfo { + sb_size: b.size, + skip: b.skip, + tx_size: b.tx_size, + y_mode: b.y_mode, + sub_modes: b.sub_modes, + seg_id: b.seg_id, + ref_frame: b.ref_frame, + interp_filter: b.interp_filter, + mv: b.block_mvs, + }; + let bh = NUM_8X8_HIGH[b.size as usize] as usize; + let bw = NUM_8X8_WIDE[b.size as usize] as usize; + let mi_w = self.strip.mi_col_end - self.strip.mi_col_start; + let (y1, x1) = ((r + bh).min(self.mi_rows), (c + bw).min(self.strip.mi_col_end)); + for y in r..y1 { + let row = y * mi_w; + for x in c..x1 { + self.strip.mi[row + x - self.strip.mi_col_start] = info; + self.strip.seg_ids[row + x - self.strip.mi_col_start] = b.seg_id; + } + } + } + + // ---------------------------------------------------------------- mode info (6.4.5 - 6.4.20) + + fn intra_frame_mode_info(&mut self, bd: &mut BoolDecoder, b: &mut Blk) { + // intra_segment_id + b.seg_id = if self.s.seg.enabled && self.s.seg.update_map { bd.read_tree(&SEGMENT_TREE, &self.s.seg.tree_probs) } else { 0 }; + self.read_skip(bd, b); + self.read_tx_size(bd, b, true); + b.ref_frame = [INTRA_FRAME, NONE]; + b.is_inter = false; + let above_mi = if b.avail_u { Some(*self.mi_at(b.r - 1, b.c)) } else { None }; + let left_mi = if b.avail_l { Some(*self.mi_at(b.r, b.c - 1)) } else { None }; + if b.size >= BLOCK_8X8 { + let above = above_mi.map_or(DC_PRED, |m| m.sub_modes[2]); + let left = left_mi.map_or(DC_PRED, |m| m.sub_modes[1]); + let o = (above as usize * 10 + left as usize) * 9; + let mode = bd.read_tree(&INTRA_MODE_TREE, &KF_Y_MODE_PROBS[o..o + 9]); + b.y_mode = mode; + b.sub_modes = [mode; 4]; + } else { + let n4w = NUM_4X4_WIDE[b.size as usize] as usize; + let n4h = NUM_4X4_HIGH[b.size as usize] as usize; + let mut mode = 0; + let mut idy = 0; + while idy < 2 { + let mut idx = 0; + while idx < 2 { + let above = if idy > 0 { b.sub_modes[idx] } else { above_mi.map_or(DC_PRED, |m| m.sub_modes[2 + idx]) }; + let left = if idx > 0 { b.sub_modes[idy * 2] } else { left_mi.map_or(DC_PRED, |m| m.sub_modes[1 + idy * 2]) }; + let o = (above as usize * 10 + left as usize) * 9; + mode = bd.read_tree(&INTRA_MODE_TREE, &KF_Y_MODE_PROBS[o..o + 9]); + for y2 in 0..n4h { + for x2 in 0..n4w { + b.sub_modes[(idy + y2) * 2 + idx + x2] = mode; + } + } + idx += n4w; + } + idy += n4h; + } + b.y_mode = mode; + } + let o = b.y_mode as usize * 9; + b.uv_mode = bd.read_tree(&INTRA_MODE_TREE, &KF_UV_MODE_PROBS[o..o + 9]); + } + + fn read_skip(&mut self, bd: &mut BoolDecoder, b: &mut Blk) { + if self.s.seg.feature_active(b.seg_id, SEG_LVL_SKIP) { + b.skip = true; + } else { + let mut ctx = 0; + if b.avail_u { + ctx += self.mi_at(b.r - 1, b.c).skip as usize; + } + if b.avail_l { + ctx += self.mi_at(b.r, b.c - 1).skip as usize; + } + b.skip = bd.read_bool(self.fc.skip[ctx]); + if self.s.counting { + self.strip.counts.skip[ctx][b.skip as usize] += 1; + } + } + } + + fn read_tx_size(&mut self, bd: &mut BoolDecoder, b: &mut Blk, allow_select: bool) { + let max_tx = MAX_TXSIZE[b.size as usize]; + if allow_select && self.h.tx_mode == TX_MODE_SELECT && b.size >= BLOCK_8X8 { + let mut above = max_tx; + let mut left = max_tx; + if b.avail_u { + let m = self.mi_at(b.r - 1, b.c); + if !m.skip { + above = m.tx_size; + } + } + if b.avail_l { + let m = self.mi_at(b.r, b.c - 1); + if !m.skip { + left = m.tx_size; + } + } + if !b.avail_l { + left = above; + } + if !b.avail_u { + above = left; + } + let ctx = ((above + left) > max_tx) as usize; + let tree: &[i8] = match max_tx { + 3 => &TX_SIZE_32_TREE, + 2 => &TX_SIZE_16_TREE, + _ => &TX_SIZE_8_TREE, + }; + b.tx_size = bd.read_tree(tree, &self.fc.tx[max_tx as usize][ctx]); + if self.s.counting { + self.strip.counts.tx[max_tx as usize][ctx][b.tx_size as usize] += 1; + } + } else { + b.tx_size = max_tx.min(TX_MODE_TO_BIGGEST_TX_SIZE[self.h.tx_mode as usize]); + } + } + + fn inter_frame_mode_info(&mut self, bd: &mut BoolDecoder, b: &mut Blk) { + b.left_ref = if b.avail_l { self.mi_at(b.r, b.c - 1).ref_frame } else { [INTRA_FRAME, NONE] }; + b.above_ref = if b.avail_u { self.mi_at(b.r - 1, b.c).ref_frame } else { [INTRA_FRAME, NONE] }; + b.left_intra = b.left_ref[0] <= INTRA_FRAME; + b.above_intra = b.above_ref[0] <= INTRA_FRAME; + b.left_single = b.left_ref[1] <= NONE; + b.above_single = b.above_ref[1] <= NONE; + self.inter_segment_id(bd, b); + self.read_skip(bd, b); + self.read_is_inter(bd, b); + let allow = !b.skip || !b.is_inter; + self.read_tx_size(bd, b, allow); + if b.is_inter { + self.inter_block_mode_info(bd, b); + } else { + self.intra_block_mode_info(bd, b); + } + } + + fn get_segment_id(&self, b: &Blk) -> u8 { + let bw = NUM_8X8_WIDE[b.size as usize] as usize; + let bh = NUM_8X8_HIGH[b.size as usize] as usize; + let xmis = (self.mi_cols - b.c).min(bw); + let ymis = (self.mi_rows - b.r).min(bh); + let mut seg = 7; + let prev = self.s.prev_seg_ids; + if prev.is_empty() { + return 0; + } + for y in 0..ymis { + for x in 0..xmis { + seg = seg.min(prev[(b.r + y) * self.mi_cols + b.c + x]); + } + } + seg + } + + fn inter_segment_id(&mut self, bd: &mut BoolDecoder, b: &mut Blk) { + let seg = self.s.seg; + if !seg.enabled { + b.seg_id = 0; + return; + } + let predicted = self.get_segment_id(b); + if !seg.update_map { + b.seg_id = predicted; + return; + } + if seg.temporal_update { + let ctx = (self.left_seg_pred[b.r] + self.above_seg_pred[b.c]) as usize; + let pred_flag = bd.read_bool(seg.pred_probs[ctx]); + b.seg_id = if pred_flag { predicted } else { bd.read_tree(&SEGMENT_TREE, &seg.tree_probs) }; + let bw = NUM_8X8_WIDE[b.size as usize] as usize; + let bh = NUM_8X8_HIGH[b.size as usize] as usize; + self.above_seg_pred[b.c..b.c + bw].fill(pred_flag as u8); + self.left_seg_pred[b.r..b.r + bh].fill(pred_flag as u8); + } else { + b.seg_id = bd.read_tree(&SEGMENT_TREE, &seg.tree_probs); + } + } + + fn read_is_inter(&mut self, bd: &mut BoolDecoder, b: &mut Blk) { + if self.s.seg.feature_active(b.seg_id, SEG_LVL_REF_FRAME) { + b.is_inter = self.s.seg.feature_data[b.seg_id as usize][SEG_LVL_REF_FRAME] != INTRA_FRAME as i16; + } else { + let ctx = if b.avail_u && b.avail_l { + if b.left_intra && b.above_intra { 3 } else { (b.left_intra || b.above_intra) as usize } + } else if b.avail_u || b.avail_l { + 2 * (if b.avail_u { b.above_intra } else { b.left_intra }) as usize + } else { + 0 + }; + b.is_inter = bd.read_bool(self.fc.is_inter[ctx]); + if self.s.counting { + self.strip.counts.is_inter[ctx][b.is_inter as usize] += 1; + } + } + } + + fn intra_block_mode_info(&mut self, bd: &mut BoolDecoder, b: &mut Blk) { + b.ref_frame = [INTRA_FRAME, NONE]; + if b.size >= BLOCK_8X8 { + let ctx = SIZE_GROUP[b.size as usize] as usize; + let mode = bd.read_tree(&INTRA_MODE_TREE, &self.fc.y_mode[ctx]); + if self.s.counting { + self.strip.counts.intra_mode[ctx][mode as usize] += 1; + } + b.y_mode = mode; + b.sub_modes = [mode; 4]; + } else { + let n4w = NUM_4X4_WIDE[b.size as usize] as usize; + let n4h = NUM_4X4_HIGH[b.size as usize] as usize; + let mut mode = 0; + let mut idy = 0; + while idy < 2 { + let mut idx = 0; + while idx < 2 { + mode = bd.read_tree(&INTRA_MODE_TREE, &self.fc.y_mode[0]); + if self.s.counting { + self.strip.counts.intra_mode[0][mode as usize] += 1; + } + for y2 in 0..n4h { + for x2 in 0..n4w { + b.sub_modes[(idy + y2) * 2 + idx + x2] = mode; + } + } + idx += n4w; + } + idy += n4h; + } + b.y_mode = mode; + } + b.uv_mode = bd.read_tree(&INTRA_MODE_TREE, &self.fc.uv_mode[b.y_mode as usize]); + if self.s.counting { + self.strip.counts.uv_mode[b.y_mode as usize][b.uv_mode as usize] += 1; + } + } + + fn inter_block_mode_info(&mut self, bd: &mut BoolDecoder, b: &mut Blk) { + self.read_ref_frames(bd, b); + let is_compound = b.ref_frame[1] > INTRA_FRAME; + if is_compound { + self.strip.compound_blocks += 1; + } + for j in 0..1 + is_compound as usize { + let rf = b.ref_frame[j]; + self.find_mv_refs(b, rf, -1); + self.find_best_ref_mvs(b, j); + } + b.sub_modes = [DC_PRED; 4]; + if self.s.seg.feature_active(b.seg_id, SEG_LVL_SKIP) { + b.y_mode = ZEROMV; + } else if b.size >= BLOCK_8X8 { + let ctx = self.mode_context[b.ref_frame[0] as usize] as usize; + let inter_mode = bd.read_tree(&INTER_MODE_TREE, &self.fc.inter_mode[ctx.min(6)]); + if self.s.counting { + self.strip.counts.inter_mode[ctx.min(6)][inter_mode as usize] += 1; + } + b.y_mode = NEARESTMV + inter_mode; + } + b.interp_filter = if self.h.interp_filter == SWITCHABLE { + let left = if b.avail_l && b.left_ref[0] > INTRA_FRAME { self.mi_at(b.r, b.c - 1).interp_filter } else { 3 }; + let above = if b.avail_u && b.above_ref[0] > INTRA_FRAME { self.mi_at(b.r - 1, b.c).interp_filter } else { 3 }; + let ctx = if left == above { + left + } else if left == 3 && above != 3 { + above + } else if left != 3 && above == 3 { + left + } else { + 3 + } as usize; + let f = bd.read_tree(&INTERP_FILTER_TREE, &self.fc.interp_filter[ctx]); + if self.s.counting { + self.strip.counts.interp_filter[ctx][f as usize] += 1; + } + f + } else { + self.h.interp_filter + }; + b.block_mvs = [[Mv::ZERO; 4]; 2]; + if b.size < BLOCK_8X8 { + let n4w = NUM_4X4_WIDE[b.size as usize] as usize; + let n4h = NUM_4X4_HIGH[b.size as usize] as usize; + let mut idy = 0; + while idy < 2 { + let mut idx = 0; + while idx < 2 { + let ctx = self.mode_context[b.ref_frame[0] as usize] as usize; + let inter_mode = bd.read_tree(&INTER_MODE_TREE, &self.fc.inter_mode[ctx.min(6)]); + if self.s.counting { + self.strip.counts.inter_mode[ctx.min(6)][inter_mode as usize] += 1; + } + b.y_mode = NEARESTMV + inter_mode; + if b.y_mode == NEARESTMV || b.y_mode == NEARMV { + for j in 0..1 + is_compound as usize { + self.append_sub8x8_mvs(b, (idy * 2 + idx) as i32, j); + } + } + let mv = self.assign_mv(bd, b, is_compound); + for y2 in 0..n4h { + for x2 in 0..n4w { + let blk = (idy + y2) * 2 + idx + x2; + for l in 0..1 + is_compound as usize { + b.block_mvs[l][blk] = mv[l]; + } + } + } + idx += n4w; + } + idy += n4h; + } + } else { + let mv = self.assign_mv(bd, b, is_compound); + for l in 0..1 + is_compound as usize { + b.block_mvs[l] = [mv[l]; 4]; + } + } + } + + fn read_ref_frames(&mut self, bd: &mut BoolDecoder, b: &mut Blk) { + let seg = self.s.seg; + if seg.feature_active(b.seg_id, SEG_LVL_REF_FRAME) { + b.ref_frame = [(seg.feature_data[b.seg_id as usize][SEG_LVL_REF_FRAME] as i8).clamp(LAST_FRAME, ALTREF_FRAME), NONE]; + return; + } + let h = self.h; + let comp_mode = if h.reference_mode == 2 { + let ctx = self.comp_mode_ctx(b); + let v = bd.read_bool(self.fc.comp_mode[ctx]); + if self.s.counting { + self.strip.counts.comp_mode[ctx][v as usize] += 1; + } + v as u8 + } else { + h.reference_mode + }; + if comp_mode == 1 { + let idx = h.ref_frame_sign_bias[h.comp_fixed_ref as usize] as usize; + let ctx = self.comp_ref_ctx(b); + let comp_ref = bd.read_bool(self.fc.comp_ref[ctx]); + if self.s.counting { + self.strip.counts.comp_ref[ctx][comp_ref as usize] += 1; + } + b.ref_frame[idx] = h.comp_fixed_ref; + b.ref_frame[1 - idx] = h.comp_var_ref[comp_ref as usize]; + } else { + let ctx = self.single_ref_p1_ctx(b); + let p1 = bd.read_bool(self.fc.single_ref[ctx][0]); + if self.s.counting { + self.strip.counts.single_ref[ctx][0][p1 as usize] += 1; + } + b.ref_frame[0] = if p1 { + let ctx2 = self.single_ref_p2_ctx(b); + let p2 = bd.read_bool(self.fc.single_ref[ctx2][1]); + if self.s.counting { + self.strip.counts.single_ref[ctx2][1][p2 as usize] += 1; + } + if p2 { ALTREF_FRAME } else { GOLDEN_FRAME } + } else { + LAST_FRAME + }; + b.ref_frame[1] = NONE; + } + } + + fn comp_mode_ctx(&self, b: &Blk) -> usize { + let fixed = self.h.comp_fixed_ref; + (if b.avail_u && b.avail_l { + if b.above_single && b.left_single { + ((b.above_ref[0] == fixed) ^ (b.left_ref[0] == fixed)) as u8 + } else if b.above_single { + 2 + (b.above_ref[0] == fixed || b.above_intra) as u8 + } else if b.left_single { + 2 + (b.left_ref[0] == fixed || b.left_intra) as u8 + } else { + 4 + } + } else if b.avail_u { + if b.above_single { (b.above_ref[0] == fixed) as u8 } else { 3 } + } else if b.avail_l { + if b.left_single { (b.left_ref[0] == fixed) as u8 } else { 3 } + } else { + 1 + }) as usize + } + + fn comp_ref_ctx(&self, b: &Blk) -> usize { + let h = self.h; + let fix_ref_idx = h.ref_frame_sign_bias[h.comp_fixed_ref as usize] as usize; + let var_ref_idx = 1 - fix_ref_idx; + let var0 = h.comp_var_ref[0]; + let var1 = h.comp_var_ref[1]; + let fixed = h.comp_fixed_ref; + (if b.avail_u && b.avail_l { + if b.above_intra && b.left_intra { + 2 + } else if b.left_intra { + if b.above_single { 1 + 2 * (b.above_ref[0] != var1) as u8 } else { 1 + 2 * (b.above_ref[var_ref_idx] != var1) as u8 } + } else if b.above_intra { + if b.left_single { 1 + 2 * (b.left_ref[0] != var1) as u8 } else { 1 + 2 * (b.left_ref[var_ref_idx] != var1) as u8 } + } else { + let vrfa = if b.above_single { b.above_ref[0] } else { b.above_ref[var_ref_idx] }; + let vrfl = if b.left_single { b.left_ref[0] } else { b.left_ref[var_ref_idx] }; + if vrfa == vrfl && var1 == vrfa { + 0 + } else if b.left_single && b.above_single { + if (vrfa == fixed && vrfl == var0) || (vrfl == fixed && vrfa == var0) { + 4 + } else if vrfa == vrfl { + 3 + } else { + 1 + } + } else if b.left_single || b.above_single { + let vrfc = if b.left_single { vrfa } else { vrfl }; + let rfs = if b.above_single { vrfa } else { vrfl }; + if vrfc == var1 && rfs != var1 { + 1 + } else if rfs == var1 && vrfc != var1 { + 2 + } else { + 4 + } + } else if vrfa == vrfl { + 4 + } else { + 2 + } + } + } else if b.avail_u { + if b.above_intra { + 2 + } else if b.above_single { + 3 * (b.above_ref[0] != var1) as u8 + } else { + 4 * (b.above_ref[var_ref_idx] != var1) as u8 + } + } else if b.avail_l { + if b.left_intra { + 2 + } else if b.left_single { + 3 * (b.left_ref[0] != var1) as u8 + } else { + 4 * (b.left_ref[var_ref_idx] != var1) as u8 + } + } else { + 2 + }) as usize + } + + fn single_ref_p1_ctx(&self, b: &Blk) -> usize { + let (a, l) = (b.above_ref, b.left_ref); + (if b.avail_u && b.avail_l { + if b.above_intra && b.left_intra { + 2 + } else if b.left_intra { + if b.above_single { 4 * (a[0] == LAST_FRAME) as u8 } else { 1 + (a[0] == LAST_FRAME || a[1] == LAST_FRAME) as u8 } + } else if b.above_intra { + if b.left_single { 4 * (l[0] == LAST_FRAME) as u8 } else { 1 + (l[0] == LAST_FRAME || l[1] == LAST_FRAME) as u8 } + } else if b.above_single && b.left_single { + 2 * (a[0] == LAST_FRAME) as u8 + 2 * (l[0] == LAST_FRAME) as u8 + } else if !b.above_single && !b.left_single { + 1 + (a[0] == LAST_FRAME || a[1] == LAST_FRAME || l[0] == LAST_FRAME || l[1] == LAST_FRAME) as u8 + } else { + let rfs = if b.above_single { a[0] } else { l[0] }; + let crf1 = if b.above_single { l[0] } else { a[0] }; + let crf2 = if b.above_single { l[1] } else { a[1] }; + if rfs == LAST_FRAME { 3 + (crf1 == LAST_FRAME || crf2 == LAST_FRAME) as u8 } else { (crf1 == LAST_FRAME || crf2 == LAST_FRAME) as u8 } + } + } else if b.avail_u { + if b.above_intra { + 2 + } else if b.above_single { + 4 * (a[0] == LAST_FRAME) as u8 + } else { + 1 + (a[0] == LAST_FRAME || a[1] == LAST_FRAME) as u8 + } + } else if b.avail_l { + if b.left_intra { + 2 + } else if b.left_single { + 4 * (l[0] == LAST_FRAME) as u8 + } else { + 1 + (l[0] == LAST_FRAME || l[1] == LAST_FRAME) as u8 + } + } else { + 2 + }) as usize + } + + fn single_ref_p2_ctx(&self, b: &Blk) -> usize { + let (a, l) = (b.above_ref, b.left_ref); + const G: i8 = GOLDEN_FRAME; + const L: i8 = LAST_FRAME; + (if b.avail_u && b.avail_l { + if b.above_intra && b.left_intra { + 2 + } else if b.left_intra { + if b.above_single { if a[0] == L { 3 } else { 4 * (a[0] == G) as u8 } } else { 1 + 2 * (a[0] == G || a[1] == G) as u8 } + } else if b.above_intra { + if b.left_single { if l[0] == L { 3 } else { 4 * (l[0] == G) as u8 } } else { 1 + 2 * (l[0] == G || l[1] == G) as u8 } + } else if b.above_single && b.left_single { + if a[0] == L && l[0] == L { + 3 + } else if a[0] == L { + 4 * (l[0] == G) as u8 + } else if l[0] == L { + 4 * (a[0] == G) as u8 + } else { + 2 * (a[0] == G) as u8 + 2 * (l[0] == G) as u8 + } + } else if !b.above_single && !b.left_single { + if a[0] == l[0] && a[1] == l[1] { 3 * (a[0] == G || a[1] == G) as u8 } else { 2 } + } else { + let rfs = if b.above_single { a[0] } else { l[0] }; + let crf1 = if b.above_single { l[0] } else { a[0] }; + let crf2 = if b.above_single { l[1] } else { a[1] }; + if rfs == G { + 3 + (crf1 == G || crf2 == G) as u8 + } else if rfs == ALTREF_FRAME { + (crf1 == G || crf2 == G) as u8 + } else { + 1 + 2 * (crf1 == G || crf2 == G) as u8 + } + } + } else if b.avail_u { + if b.above_intra || (a[0] == L && b.above_single) { + 2 + } else if b.above_single { + 4 * (a[0] == G) as u8 + } else { + 3 * (a[0] == G || a[1] == G) as u8 + } + } else if b.avail_l { + if b.left_intra || (l[0] == L && b.left_single) { + 2 + } else if b.left_single { + 4 * (l[0] == G) as u8 + } else { + 3 * (l[0] == G || l[1] == G) as u8 + } + } else { + 2 + }) as usize + } + + /// assign_mv (6.4.18): returns Mv[0..2]. + fn assign_mv(&mut self, bd: &mut BoolDecoder, b: &Blk, is_compound: bool) -> [Mv; 2] { + let mut mv = [Mv::ZERO; 2]; + for i in 0..1 + is_compound as usize { + mv[i] = match b.y_mode { + NEWMV => self.read_mv(bd, i), + NEARESTMV => self.nearest_mv[i], + NEARMV => self.near_mv[i], + _ => Mv::ZERO, + }; + } + mv + } + + fn read_mv(&mut self, bd: &mut BoolDecoder, r: usize) -> Mv { + let use_hp = self.h.allow_high_precision_mv && use_mv_hp(self.best_mv[r]); + let joint = bd.read_tree(&MV_JOINT_TREE, &self.fc.mv_joint); + if self.s.counting { + self.strip.counts.mv_joint[joint as usize] += 1; + } + let mut diff = [0i32; 2]; + if joint == 2 || joint == 3 { + diff[0] = self.read_mv_component(bd, 0, use_hp); + } + if joint == 1 || joint == 3 { + diff[1] = self.read_mv_component(bd, 1, use_hp); + } + Mv::new(self.best_mv[r].row as i32 + diff[0], self.best_mv[r].col as i32 + diff[1]) + } + + fn read_mv_component(&mut self, bd: &mut BoolDecoder, comp: usize, use_hp: bool) -> i32 { + let fc = self.fc; + let counting = self.s.counting; + let counts = &mut self.strip.counts; + let sign = bd.read_bool(fc.mv_sign[comp]); + let class = bd.read_tree(&MV_CLASS_TREE, &fc.mv_class[comp]) as usize; + let mag = if class == 0 { + let c0 = bd.read_bool(fc.mv_class0_bit[comp]) as usize; + let fr = bd.read_tree(&MV_FR_TREE, &fc.mv_class0_fr[comp][c0]) as usize; + let hp = if use_hp { bd.read_bool(fc.mv_class0_hp[comp]) as usize } else { 1 }; + if counting { + counts.mv_class0_bit[comp][c0] += 1; + counts.mv_class0_fr[comp][c0][fr] += 1; + counts.mv_class0_hp[comp][hp] += 1; + } + ((c0 << 3) | (fr << 1) | hp) + 1 + } else { + let mut d = 0usize; + for i in 0..class { + let bit = bd.read_bool(fc.mv_bits[comp][i]) as usize; + if counting { + counts.mv_bits[comp][i][bit] += 1; + } + d |= bit << i; + } + let base = 2usize << (class + 2); + let fr = bd.read_tree(&MV_FR_TREE, &fc.mv_fr[comp]) as usize; + let hp = if use_hp { bd.read_bool(fc.mv_hp[comp]) as usize } else { 1 }; + if counting { + counts.mv_fr[comp][fr] += 1; + counts.mv_hp[comp][hp] += 1; + } + base + ((d << 3) | (fr << 1) | hp) + 1 + }; + if counting { + counts.mv_sign[comp][sign as usize] += 1; + counts.mv_class[comp][class] += 1; + } + if sign { -(mag as i32) } else { mag as i32 } + } + + // ------------------------------------------------------------ motion vector prediction (6.5) + + #[inline] + fn is_inside(&self, r: isize, c: isize) -> bool { + r >= 0 && (r as usize) < self.mi_rows && c >= self.strip.mi_col_start as isize && (c as usize) < self.strip.mi_col_end + } + + #[inline] + fn add_mv_ref_list(&mut self, mv: Mv) { + if self.ref_mv_count >= 2 { + return; + } + if self.ref_mv_count > 0 && mv == self.ref_list_mv[0] { + return; + } + self.ref_list_mv[self.ref_mv_count] = mv; + self.ref_mv_count += 1; + } + + fn if_same_ref_frame_add(&mut self, m: &MiInfo, ref_frame: i8) { + for j in 0..2 { + if m.ref_frame[j] == ref_frame { + self.add_mv_ref_list(m.mv[j][3]); + return; + } + } + } + + fn if_diff_ref_frame_add(&mut self, m: &MiInfo, ref_frame: i8) { + let bias = &self.h.ref_frame_sign_bias; + let mvs = [m.mv[0][3], m.mv[1][3]]; + let same = mvs[0] == mvs[1]; + let scale = |f: i8, mv: Mv| -> Mv { if bias[f as usize] != bias[ref_frame as usize] { Mv::new(-(mv.row as i32), -(mv.col as i32)) } else { mv } }; + if m.ref_frame[0] > INTRA_FRAME && m.ref_frame[0] != ref_frame { + let mv = scale(m.ref_frame[0], mvs[0]); + self.add_mv_ref_list(mv); + } + if m.ref_frame[1] > INTRA_FRAME && m.ref_frame[1] != ref_frame && !same { + let mv = scale(m.ref_frame[1], mvs[1]); + self.add_mv_ref_list(mv); + } + } + + fn clamp_mv_row(&self, b: &Blk, v: i32, border: i32) -> i32 { + let bh = NUM_8X8_HIGH[b.size as usize] as i32; + let to_top = -((b.r as i32 * 8) * 8); + let to_bottom = ((self.mi_rows as i32 - bh - b.r as i32) * 8) * 8; + v.clamp(to_top - border, (to_bottom + border).max(to_top - border)) + } + + fn clamp_mv_col(&self, b: &Blk, v: i32, border: i32) -> i32 { + let bw = NUM_8X8_WIDE[b.size as usize] as i32; + let to_left = -((b.c as i32 * 8) * 8); + let to_right = ((self.mi_cols as i32 - bw - b.c as i32) * 8) * 8; + v.clamp(to_left - border, (to_right + border).max(to_left - border)) + } + + /// find_mv_refs (6.5.1) for `ref_frame`; `block` is -1 or the sub-8x8 block index. + fn find_mv_refs(&mut self, b: &Blk, ref_frame: i8, block: i32) { + self.ref_mv_count = 0; + self.ref_list_mv = [Mv::ZERO; 2]; + let mut different_ref_found = false; + let mut context_counter = 0usize; + let search = &MV_REF_BLOCKS[b.size as usize * 16..b.size as usize * 16 + 16]; + let (r, c) = (b.r as isize, b.c as isize); + for i in 0..2 { + let (cr, cc) = (r + search[i * 2] as isize, c + search[i * 2 + 1] as isize); + if self.is_inside(cr, cc) { + let m = *self.mi_at(cr as usize, cc as usize); + different_ref_found = true; + context_counter += MODE_2_COUNTER[m.y_mode as usize] as usize; + for j in 0..2 { + if m.ref_frame[j] == ref_frame { + let idx = if block >= 0 { IDX_N_COLUMN_TO_SUBBLOCK[block as usize][(search[i * 2 + 1] == 0) as usize] as usize } else { 3 }; + self.add_mv_ref_list(m.mv[j][idx]); + break; + } + } + } + } + for i in 2..8 { + let (cr, cc) = (r + search[i * 2] as isize, c + search[i * 2 + 1] as isize); + if self.is_inside(cr, cc) { + different_ref_found = true; + let m = *self.mi_at(cr as usize, cc as usize); + self.if_same_ref_frame_add(&m, ref_frame); + } + } + let prev = self.s.prev_mi.map(|g| *g.at(b.r, b.c)); + if let Some(p) = &prev { + self.if_same_ref_frame_add(p, ref_frame); + } + if different_ref_found { + for i in 0..8 { + let (cr, cc) = (r + search[i * 2] as isize, c + search[i * 2 + 1] as isize); + if self.is_inside(cr, cc) { + let m = *self.mi_at(cr as usize, cc as usize); + self.if_diff_ref_frame_add(&m, ref_frame); + } + } + } + if let Some(p) = &prev { + self.if_diff_ref_frame_add(p, ref_frame); + } + self.mode_context[ref_frame as usize] = COUNTER_TO_CONTEXT[context_counter.min(18)]; + for i in 0..2 { + let mv = self.ref_list_mv[i]; + self.ref_list_mv[i] = Mv::new(self.clamp_mv_row(b, mv.row as i32, MV_BORDER), self.clamp_mv_col(b, mv.col as i32, MV_BORDER)); + } + } + + /// find_best_ref_mvs (6.5.12). + fn find_best_ref_mvs(&mut self, b: &Blk, ref_list: usize) { + for i in 0..2 { + let mv = self.ref_list_mv[i]; + let (mut dr, mut dc) = (mv.row as i32, mv.col as i32); + if !self.h.allow_high_precision_mv || !use_mv_hp(mv) { + if dr & 1 != 0 { + dr += if dr > 0 { -1 } else { 1 }; + } + if dc & 1 != 0 { + dc += if dc > 0 { -1 } else { 1 }; + } + } + let border = (BORDERINPIXELS - INTERP_EXTEND) << 3; + self.ref_list_mv[i] = Mv::new(self.clamp_mv_row(b, dr, border), self.clamp_mv_col(b, dc, border)); + } + self.nearest_mv[ref_list] = self.ref_list_mv[0]; + self.near_mv[ref_list] = self.ref_list_mv[1]; + self.best_mv[ref_list] = self.ref_list_mv[0]; + } + + /// append_sub8x8_mvs (6.5.14). + fn append_sub8x8_mvs(&mut self, b: &Blk, block: i32, ref_list: usize) { + self.find_mv_refs(b, b.ref_frame[ref_list], block); + let mut sub = [Mv::ZERO; 2]; + let mut dst; + let bm = &b.block_mvs[ref_list]; + if block == 0 { + sub = self.ref_list_mv; + dst = 2; + } else if block <= 2 { + sub[0] = bm[0]; + dst = 1; + } else { + sub[0] = bm[2]; + dst = 1; + for idx in [1usize, 0] { + if dst < 2 && bm[idx] != sub[0] { + sub[dst] = bm[idx]; + dst += 1; + } + } + } + for n in 0..2 { + if dst < 2 && self.ref_list_mv[n] != sub[0] { + sub[dst] = self.ref_list_mv[n]; + dst += 1; + } + } + if dst < 2 { + sub[dst] = Mv::ZERO; + } + self.nearest_mv[ref_list] = sub[0]; + self.near_mv[ref_list] = sub[1]; + } +} + +#[inline] +pub fn use_mv_hp(mv: Mv) -> bool { + ((mv.row as i32).abs() >> 3) < COMPANDED_MVREF_THRESH && ((mv.col as i32).abs() >> 3) < COMPANDED_MVREF_THRESH +} diff --git a/crates/vp9/src/tile/recon.rs b/crates/vp9/src/tile/recon.rs new file mode 100644 index 000000000..dc52b99f5 --- /dev/null +++ b/crates/vp9/src/tile/recon.rs @@ -0,0 +1,336 @@ +//! Residual syntax (6.4.21 - 6.4.26) with the prediction and reconstruction processes it +//! triggers (8.5, 8.6). + +use super::{Blk, TileDecoder}; +use crate::boolcoder::BoolDecoder; +use crate::error::Error; +use crate::frame::Mv; +use crate::inter::{self, RefPlane}; +use crate::intra::{self, IntraEdge}; +use crate::tables::*; +use crate::transform; + +/// round_mv_comp_q2 / q4 (8.5.2.1). +fn round_q2(v: i32) -> i32 { + (if v < 0 { v - 1 } else { v + 1 }) / 2 +} +fn round_q4(v: i32) -> i32 { + (if v < 0 { v - 2 } else { v + 2 }) / 4 +} + +impl<'a> TileDecoder<'a> { + pub(super) fn residual(&mut self, bd: &mut BoolDecoder, b: &mut Blk) { + let bsize = b.size.max(BLOCK_8X8) as usize; + let (ssx, ssy) = (self.ss_x, self.ss_y); + let uv_tx = if b.size < BLOCK_8X8 { 0 } else { b.tx_size.min(MAX_TXSIZE[SS_SIZE_LOOKUP[b.size as usize][ssx][ssy] as usize]) }; + for plane in 0..3 { + let tx_sz = if plane > 0 { uv_tx } else { b.tx_size }; + let step = 1usize << tx_sz; + let (sx, sy) = if plane > 0 { (ssx, ssy) } else { (0, 0) }; + let plane_sz = if plane > 0 { SS_SIZE_LOOKUP[bsize][ssx][ssy] as usize } else { bsize }; + let n4w = NUM_4X4_WIDE[plane_sz] as usize; + let n4h = NUM_4X4_HIGH[plane_sz] as usize; + let base_x = (b.c * 8) >> sx; + let base_y = (b.r * 8) >> sy; + if b.is_inter { + if b.size < BLOCK_8X8 { + for y in 0..n4h { + for x in 0..n4w { + self.predict_inter(b, plane, base_x + 4 * x, base_y + 4 * y, 4, 4, y * n4w + x); + } + } + } else { + self.predict_inter(b, plane, base_x, base_y, n4w * 4, n4h * 4, 0); + } + } + let max_x = (self.mi_cols * 8) >> sx; + let max_y = (self.mi_rows * 8) >> sy; + let mut block_idx = 0; + for y in (0..n4h).step_by(step) { + for x in (0..n4w).step_by(step) { + let start_x = base_x + 4 * x; + let start_y = base_y + 4 * y; + let mut nonzero = false; + if start_x < max_x && start_y < max_y { + let tx_type = self.tx_type(b, plane, tx_sz, block_idx); + if !b.is_inter { + let mode = if plane > 0 { + b.uv_mode + } else if b.size >= BLOCK_8X8 { + b.y_mode + } else { + b.sub_modes[block_idx] + }; + let edge = IntraEdge { + have_left: b.avail_l || x > 0, + have_above: b.avail_u || y > 0, + not_on_right: x + step < n4w, + tx_size: tx_sz, + max_x: max_x - 1, + max_y: max_y - 1, + bit_depth: self.bit_depth, + }; + let (stride, x_off) = (self.strip.strides[plane], self.strip.x_off[plane]); + intra::predict(&mut self.strip.planes[plane], stride, x_off, start_x, start_y, mode, &edge); + } + if !b.skip { + let (eob, rows) = self.tokens(bd, b, plane, start_x, start_y, tx_sz, tx_type); + nonzero = eob > 0; + b.eob_total += nonzero as u32; + if eob > 0 { + let (stride, x_off) = (self.strip.strides[plane], self.strip.x_off[plane]); + let o = start_y * stride + start_x - x_off; + transform::inverse_transform_add( + &self.coefs, + tx_sz, + tx_type, + self.h.lossless, + eob, + rows, + self.bit_depth, + &mut self.strip.planes[plane][o..], + stride, + ); + let sc = scan(tx_sz, tx_type); + for &p in &sc[..eob] { + self.coefs[p as usize] = 0; + } + } + } + } + let ax = start_x >> 2; + let ly = start_y >> 2; + let an = &mut self.above_nonzero[plane]; + let e = (ax + step).min(an.len()); + an[ax.min(e)..e].fill(nonzero as u8); + let ln = &mut self.left_nonzero[plane]; + let e = (ly + step).min(ln.len()); + ln[ly.min(e)..e].fill(nonzero as u8); + block_idx += 1; + } + } + } + } + + /// Transform type (6.4.25). + fn tx_type(&self, b: &Blk, plane: usize, tx_sz: u8, block_idx: usize) -> u8 { + if plane > 0 || tx_sz == TX_32X32 { + DCT_DCT + } else if tx_sz == TX_4X4 { + if self.h.lossless || b.is_inter { DCT_DCT } else { MODE2TXFM[if b.size < BLOCK_8X8 { b.sub_modes[block_idx] } else { b.y_mode } as usize] } + } else { + MODE2TXFM[b.y_mode as usize] + } + } + + /// tokens (6.4.24): reads and dequantizes the coefficients into `self.coefs`. Returns the end + /// of block position and the number of leading coefficient rows that may be non-zero. + #[allow(clippy::too_many_arguments)] + fn tokens(&mut self, bd: &mut BoolDecoder, b: &Blk, plane: usize, start_x: usize, start_y: usize, tx_sz: u8, tx_type: u8) -> (usize, usize) { + let seg_eob = 16usize << (tx_sz << 1); + let sc = scan(tx_sz, tx_type); + let nb = neighbors(tx_sz, tx_type); + let bands: &[u8] = if tx_sz == 0 { &COEFBAND_4X4 } else { &COEFBAND_8X8PLUS }; + let ptype = (plane > 0) as usize; + let rtype = b.is_inter as usize; + let probs = &self.fc.coef[tx_sz as usize][ptype][rtype]; + let (sx, sy) = if plane > 0 { (self.ss_x, self.ss_y) } else { (0, 0) }; + let max_x4 = (2 * self.mi_cols) >> sx; + let max_y4 = (2 * self.mi_rows) >> sy; + let n = 1usize << tx_sz; + let (x4, y4) = (start_x >> 2, start_y >> 2); + let mut above = 0u8; + let mut left = 0u8; + for i in 0..n { + if x4 + i < max_x4 { + above |= self.above_nonzero[plane][x4 + i]; + } + if y4 + i < max_y4 { + left |= self.left_nonzero[plane][y4 + i]; + } + } + let ctx0 = (above + left) as usize; + let q = self.s.seg_q[b.seg_id as usize][ptype]; + let dq_shift = (tx_sz == TX_32X32) as u32; + let pareto = pareto_full(); + let counting = self.s.counting; + let log2n = 2 + tx_sz as usize; + let bit_depth = self.bit_depth; + let mut max_row = 0usize; + let mut check_eob = true; + let mut c = 0usize; + let tc = &mut self.token_cache; + let coefs = &mut self.coefs; + let counts = &mut self.strip.counts; + while c < seg_eob { + let pos = sc[c] as usize; + let band = bands[c] as usize; + let ctx = if c == 0 { + ctx0 + } else { + let (a, bb) = nb[c]; + ((1 + tc[a as usize] + tc[bb as usize]) >> 1) as usize + }; + let p = &probs[band][ctx]; + if check_eob { + let more = bd.read_bool(p[0]); + if counting { + counts.more_coefs[tx_sz as usize][ptype][rtype][band][ctx][more as usize] += 1; + } + if !more { + break; + } + } + if !bd.read_bool(p[1]) { + tc[pos] = 0; + if counting { + counts.token[tx_sz as usize][ptype][rtype][band][ctx][0] += 1; + } + check_eob = false; + c += 1; + continue; + } + check_eob = true; + let token: usize; + let coef: i64; + if !bd.read_bool(p[2]) { + if counting { + counts.token[tx_sz as usize][ptype][rtype][band][ctx][1] += 1; + } + token = 1; + coef = 1; + } else { + if counting { + counts.token[tx_sz as usize][ptype][rtype][band][ctx][2] += 1; + } + let pp = &pareto[p[2].max(1) as usize - 1]; + token = if !bd.read_bool(pp[0]) { + if !bd.read_bool(pp[1]) { + 2 + } else if !bd.read_bool(pp[2]) { + 3 + } else { + 4 + } + } else if !bd.read_bool(pp[3]) { + if !bd.read_bool(pp[4]) { 5 } else { 6 } + } else if !bd.read_bool(pp[5]) { + if !bd.read_bool(pp[6]) { 7 } else { 8 } + } else if !bd.read_bool(pp[7]) { + 9 + } else { + 10 + }; + // read_coef (6.4.26) + let (cat, num_extra, base) = EXTRA_BITS[token]; + let mut v = base as i64; + if token == 10 { + for e in 0..(bit_depth as u32 - 8) { + if bd.read_bool(255) { + v += 1 << (5 + bit_depth as u32 - e); + } + } + } + let cp = CAT_PROBS[cat as usize]; + for e in 0..num_extra as usize { + if bd.read_bool(cp[e]) { + v += 1 << (num_extra as usize - 1 - e); + } + } + coef = v; + } + tc[pos] = ENERGY_CLASS[token]; + let sign = bd.read_bool(128); + let qv = if pos == 0 { q[0] } else { q[1] } as i64; + let d = ((coef * qv) >> dq_shift) as i32; + coefs[pos] = if sign { d.wrapping_neg() } else { d }; + max_row = max_row.max(pos >> log2n); + c += 1; + } + (c, max_row + 1) + } + + /// Inter prediction process (8.5.2) for one block region. + #[allow(clippy::too_many_arguments)] + fn predict_inter(&mut self, b: &Blk, plane: usize, x: usize, y: usize, w: usize, h: usize, block_idx: usize) { + let is_compound = b.ref_frame[1] > INTRA_FRAME; + let (sx, sy) = if plane > 0 { (self.ss_x, self.ss_y) } else { (0, 0) }; + for rl in 0..1 + is_compound as usize { + // Motion vector selection (8.5.2.1). + let bm = &b.block_mvs[rl]; + let mv = if plane == 0 || b.size >= BLOCK_8X8 || (sx == 0 && sy == 0) { + bm[block_idx] + } else if sx == 0 && sy == 1 { + Mv::new(round_q2(bm[block_idx].row as i32 + bm[block_idx + 2].row as i32), round_q2(bm[block_idx].col as i32 + bm[block_idx + 2].col as i32)) + } else if sx == 1 && sy == 0 { + Mv::new(round_q2(bm[block_idx].row as i32 + bm[block_idx + 1].row as i32), round_q2(bm[block_idx].col as i32 + bm[block_idx + 1].col as i32)) + } else { + let sr: i32 = bm.iter().map(|m| m.row as i32).sum(); + let sc: i32 = bm.iter().map(|m| m.col as i32).sum(); + Mv::new(round_q4(sr), round_q4(sc)) + }; + // Motion vector clamping (8.5.2.2). + let bh = NUM_8X8_HIGH[b.size as usize] as i32; + let bw = NUM_8X8_WIDE[b.size as usize] as i32; + let (r, c) = (b.r as i32, b.c as i32); + let to_top = (-(r * 8 * 16)) >> sy; + let to_bottom = (((self.mi_rows as i32 - bh - r) * 8) * 16) >> sy; + let to_left = (-(c * 8 * 16)) >> sx; + let to_right = (((self.mi_cols as i32 - bw - c) * 8) * 16) >> sx; + let spel_left = (INTERP_EXTEND + ((bw * 8) >> sx)) << 4; + let spel_right = spel_left - 16; + let spel_top = (INTERP_EXTEND + ((bh * 8) >> sy)) << 4; + let spel_bottom = spel_top - 16; + let cmv_row = ((2 * mv.row as i32) >> sy).clamp(to_top - spel_top, (to_bottom + spel_bottom).max(to_top - spel_top)); + let cmv_col = ((2 * mv.col as i32) >> sx).clamp(to_left - spel_left, (to_right + spel_right).max(to_left - spel_left)); + let ri = (b.ref_frame[rl] - 1).clamp(0, 2) as usize; + let Some(rf) = &self.s.refs[ri] else { + if self.error.is_none() { + self.error = Some(Error::MissingReference(format!("block uses unusable reference {}", b.ref_frame[rl]))); + } + let out = if rl == 0 { &mut self.pred } else { &mut self.pred2 }; + out[..w * h].fill(1 << (self.bit_depth - 1)); + continue; + }; + // Motion vector scaling (8.5.2.3). + let (x_scale, y_scale) = (rf.x_scale as i64, rf.y_scale as i64); + let (start_x, start_y, step_x, step_y) = if rf.x_step == 16 && rf.y_step == 16 && x_scale == 1 << 14 && y_scale == 1 << 14 { + (((x as i32) << 4) + cmv_col, ((y as i32) << 4) + cmv_row, 16, 16) + } else { + self.strip.scaled_blocks += 1; + let base_x = (x as i64 * x_scale) >> 14; + let base_y = (y as i64 * y_scale) >> 14; + let luma_x = (x << sx) as i64; + let luma_y = (y << sy) as i64; + let frac_x = ((16 * luma_x * x_scale) >> 14) & 15; + let frac_y = ((16 * luma_y * y_scale) >> 14) & 15; + let dx = ((cmv_col as i64 * x_scale) >> 14) + frac_x; + let dy = ((cmv_row as i64 * y_scale) >> 14) + frac_y; + (((base_x << 4) + dx) as i32, ((base_y << 4) + dy) as i32, rf.x_step, rf.y_step) + }; + let f = rf.frame; + let p = &f.planes[plane]; + let rp = RefPlane { + data: &p.data, + stride: p.stride, + last_x: ((f.width as i32 + sx as i32) >> sx) - 1, + last_y: ((f.height as i32 + sy as i32) >> sy) - 1, + }; + let out = if rl == 0 { &mut self.pred } else { &mut self.pred2 }; + inter::predict(&rp, start_x, start_y, step_x, step_y, w, h, b.interp_filter, self.bit_depth, out, &mut self.mc_tmp); + } + let (stride, x_off) = (self.strip.strides[plane], self.strip.x_off[plane]); + let dst = &mut self.strip.planes[plane]; + for i in 0..h { + let o = (y + i) * stride + x - x_off; + let row = &mut dst[o..o + w]; + if is_compound { + for ((d, &a), &bb) in row.iter_mut().zip(&self.pred[i * w..i * w + w]).zip(&self.pred2[i * w..i * w + w]) { + *d = ((a as u32 + bb as u32 + 1) >> 1) as u16; + } + } else { + row.copy_from_slice(&self.pred[i * w..i * w + w]); + } + } + } +} diff --git a/crates/vp9/src/transform.rs b/crates/vp9/src/transform.rs new file mode 100644 index 000000000..83d590066 --- /dev/null +++ b/crates/vp9/src/transform.rs @@ -0,0 +1,318 @@ +//! 2D inverse transforms and reconstruction (8.6.2, 8.7.2). The 1D DCT / ADST butterflies are +//! generated from the specification's step lists (`transform_gen.rs`); ADST4 (8.7.1.6) and the +//! Walsh-Hadamard transform (8.7.1.10) are written out here. + +use crate::tables::{ADST_ADST, ADST_DCT, DCT_ADST, DCT_DCT}; +use crate::transform_gen::{narrow, wide}; + +const SINPI_1_9: i64 = 5283; +const SINPI_2_9: i64 = 9929; +const SINPI_3_9: i64 = 13377; +const SINPI_4_9: i64 = 15212; + +macro_rules! adst4 { + ($name:ident, $t:ty) => { + #[inline(always)] + fn $name(x: [$t; 4]) -> [$t; 4] { + let (s1_9, s2_9, s3_9, s4_9) = (SINPI_1_9 as $t, SINPI_2_9 as $t, SINPI_3_9 as $t, SINPI_4_9 as $t); + let s0 = s1_9 * x[0]; + let s1 = s2_9 * x[0]; + let s2 = s3_9 * x[1]; + let s3 = s4_9 * x[2]; + let s4 = s1_9 * x[2]; + let s5 = s2_9 * x[3]; + let s6 = s4_9 * x[3]; + let v = x[0] - x[2] + x[3]; + let s7 = s3_9 * v; + let x0 = s0 + s3 + s5; + let x1 = s1 - s4 - s6; + let x2 = s7; + let x3 = s2; + let r = |v: $t| (v + (1 << 13)) >> 14; + [r(x0 + x3), r(x1 + x3), r(x2), r(x0 + x1 - x3)] + } + }; +} +adst4!(iadst4_narrow, i32); +adst4!(iadst4_wide, i64); + +/// Inverse Walsh-Hadamard transform (8.7.1.10). +fn iwht4(x: [i32; 4], shift: u32) -> [i32; 4] { + let mut a = x[0] >> shift; + let mut c = x[1] >> shift; + let mut d = x[2] >> shift; + let mut b = x[3] >> shift; + a += c; + d -= b; + let e = (a - d) >> 1; + b = e - b; + c = e - c; + a -= b; + d += c; + [a, b, c, d] +} + +/// Apply a 1D transform of length `n` (4, 8, 16, 32) in place. +#[inline(always)] +fn tx1d_narrow(buf: &mut [i32], n: usize, adst: bool) { + match (n, adst) { + (4, false) => { + let r = narrow::idct4(buf[..4].try_into().unwrap()); + buf[..4].copy_from_slice(&r); + } + (4, true) => { + let r = iadst4_narrow(buf[..4].try_into().unwrap()); + buf[..4].copy_from_slice(&r); + } + (8, false) => { + let r = narrow::idct8(buf[..8].try_into().unwrap()); + buf[..8].copy_from_slice(&r); + } + (8, true) => { + let r = narrow::iadst8(buf[..8].try_into().unwrap()); + buf[..8].copy_from_slice(&r); + } + (16, false) => { + let r = narrow::idct16(buf[..16].try_into().unwrap()); + buf[..16].copy_from_slice(&r); + } + (16, true) => { + let r = narrow::iadst16(buf[..16].try_into().unwrap()); + buf[..16].copy_from_slice(&r); + } + _ => { + let r = narrow::idct32(buf[..32].try_into().unwrap()); + buf[..32].copy_from_slice(&r); + } + } +} + +#[inline(always)] +fn tx1d_wide(buf: &mut [i64], n: usize, adst: bool) { + match (n, adst) { + (4, false) => { + let r = wide::idct4(buf[..4].try_into().unwrap()); + buf[..4].copy_from_slice(&r); + } + (4, true) => { + let r = iadst4_wide(buf[..4].try_into().unwrap()); + buf[..4].copy_from_slice(&r); + } + (8, false) => { + let r = wide::idct8(buf[..8].try_into().unwrap()); + buf[..8].copy_from_slice(&r); + } + (8, true) => { + let r = wide::iadst8(buf[..8].try_into().unwrap()); + buf[..8].copy_from_slice(&r); + } + (16, false) => { + let r = wide::idct16(buf[..16].try_into().unwrap()); + buf[..16].copy_from_slice(&r); + } + (16, true) => { + let r = wide::iadst16(buf[..16].try_into().unwrap()); + buf[..16].copy_from_slice(&r); + } + _ => { + let r = wide::idct32(buf[..32].try_into().unwrap()); + buf[..32].copy_from_slice(&r); + } + } +} + +/// Clamp coefficients so that garbage streams cannot overflow the 32-bit arithmetic (conformant +/// streams keep far smaller values, 8 + BitDepth bits). +#[inline] +fn sat(v: i32, lim: i32) -> i32 { + v.clamp(-lim, lim) +} + +/// Inverse transform `coefs` (row-major n x n dequantized coefficients, `rows` = number of leading +/// rows that can hold non-zero values) and add the residual to `dst` (8.6.2 step 4). +#[allow(clippy::too_many_arguments)] +pub fn inverse_transform_add(coefs: &[i32], tx_size: u8, tx_type: u8, lossless: bool, eob: usize, rows: usize, bit_depth: u8, dst: &mut [u16], stride: usize) { + let n = 4usize << tx_size; + let max = (1i32 << bit_depth) - 1; + if lossless { + let mut t = [0i32; 16]; + for i in 0..4 { + let r = iwht4(coefs[i * 4..i * 4 + 4].try_into().unwrap(), 2); + t[i * 4..i * 4 + 4].copy_from_slice(&r); + } + for j in 0..4 { + let r = iwht4([t[j], t[4 + j], t[8 + j], t[12 + j]], 0); + for i in 0..4 { + let p = &mut dst[i * stride + j]; + *p = (*p as i32 + r[i]).clamp(0, max) as u16; + } + } + return; + } + let shift = (tx_size as u32 + 4).min(6); + let round = 1i32 << (shift - 1); + if eob == 1 && tx_type == DCT_DCT { + // Only the DC coefficient is present: every output of both passes is equal (8.7.2 with a + // single non-zero input). + let dc = coefs[0] as i64; + let a = (dc * 11585 + (1 << 13)) >> 14; + let b = (a * 11585 + (1 << 13)) >> 14; + let v = ((b + round as i64) >> shift).clamp(-(1 << 20), 1 << 20) as i32; + for i in 0..n { + for p in dst[i * stride..i * stride + n].iter_mut() { + *p = (*p as i32 + v).clamp(0, max) as u16; + } + } + return; + } + let row_adst = matches!(tx_type, DCT_ADST | ADST_ADST); + let col_adst = matches!(tx_type, ADST_DCT | ADST_ADST); + if bit_depth == 8 { + let lim = 1 << 24; + let mut t = [0i32; 1024]; + for i in 0..rows.min(n) { + let row = &mut t[i * n..i * n + n]; + for (d, s) in row.iter_mut().zip(&coefs[i * n..i * n + n]) { + *d = sat(*s, lim); + } + tx1d_narrow(row, n, row_adst); + for v in row.iter_mut() { + *v = sat(*v, lim); + } + } + let mut col = [0i32; 32]; + for j in 0..n { + for i in 0..n { + col[i] = t[i * n + j]; + } + tx1d_narrow(&mut col[..n], n, col_adst); + for i in 0..n { + let p = &mut dst[i * stride + j]; + *p = (*p as i32 + ((col[i] + round) >> shift)).clamp(0, max) as u16; + } + } + } else { + let lim = 1i64 << 40; + let mut t = [0i64; 1024]; + for i in 0..rows.min(n) { + let row = &mut t[i * n..i * n + n]; + for (d, s) in row.iter_mut().zip(&coefs[i * n..i * n + n]) { + *d = *s as i64; + } + tx1d_wide(row, n, row_adst); + for v in row.iter_mut() { + *v = (*v).clamp(-lim, lim); + } + } + let mut col = [0i64; 32]; + for j in 0..n { + for i in 0..n { + col[i] = t[i * n + j]; + } + tx1d_wide(&mut col[..n], n, col_adst); + for i in 0..n { + let p = &mut dst[i * stride + j]; + let r = ((col[i].clamp(-lim, lim) + round as i64) >> shift).clamp(-(1 << 30), 1 << 30) as i32; + *p = (*p as i32 + r).clamp(0, max) as u16; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Reference inverse DCT (orthonormal, scaled like VP9: DC gain 1/sqrt(2) per pass times + /// sqrt(2) ... ) — compare basis functions up to rounding. + fn float_idct(input: &[f64]) -> Vec { + let n = input.len(); + (0..n) + .map(|x| { + let mut s = input[0] / 2f64.sqrt(); + for (k, &v) in input.iter().enumerate().skip(1) { + s += v * ((2 * x + 1) as f64 * k as f64 * std::f64::consts::PI / (2 * n) as f64).cos(); + } + s + }) + .collect() + } + + #[test] + fn idct_matches_float_reference() { + for n in [4usize, 8, 16, 32] { + for k in 0..n { + let mut buf = vec![0i32; n]; + buf[k] = 1000; + tx1d_narrow(&mut buf, n, false); + let mut inp = vec![0f64; n]; + inp[k] = 1000.0; + let r = float_idct(&inp); + for (a, b) in buf.iter().zip(&r) { + assert!((*a as f64 - b).abs() <= 3.0, "n {n} k {k}: {buf:?} vs {r:?}"); + } + let mut w: Vec = vec![0; n]; + w[k] = 1000; + tx1d_wide(&mut w, n, false); + assert_eq!(w.iter().map(|&v| v as i32).collect::>(), buf); + } + } + } + + #[test] + fn adst_is_sine_basis() { + // VP9's ADST (for n = 8, 16) is a DST-IV variant: out[x] ~ sum in[k] sin(pi (2x+1)(2k+1) / 4n). + for n in [8usize, 16] { + for k in 0..n { + let mut buf = vec![0i32; n]; + buf[k] = 1000; + tx1d_narrow(&mut buf, n, true); + let r: Vec = (0..n) + .map(|x| 1000.0 * ((2 * x + 1) as f64 * (2 * k + 1) as f64 * std::f64::consts::PI / (4 * n) as f64).sin() * 2f64.sqrt() / 2f64.sqrt()) + .collect(); + for (a, b) in buf.iter().zip(&r) { + assert!((*a as f64 - b).abs() <= 4.0, "n {n} k {k}: {buf:?} vs {r:?}"); + } + } + } + // ADST4 is the sine transform sin(pi (x+1)(2k+1) / 9) scaled. + for k in 0..4 { + let mut buf = vec![0i32; 4]; + buf[k] = 1000; + tx1d_narrow(&mut buf, 4, true); + let r: Vec = + (0..4).map(|x| 1000.0 * (std::f64::consts::PI * (x + 1) as f64 * (2 * k + 1) as f64 / 9.0).sin() * 2.0 * 2f64.sqrt() / 3.0).collect(); + for (a, b) in buf.iter().zip(&r) { + assert!((*a as f64 - b).abs() <= 3.0, "k {k}: {buf:?} vs {r:?}"); + } + } + } + + #[test] + fn dc_only_shortcut_matches_full_transform() { + for tx in 0..4u8 { + let n = 4usize << tx; + for dc in [-5000, -37, -1, 1, 2, 57, 1234, 9999] { + let mut coefs = vec![0i32; n * n]; + coefs[0] = dc; + let mut a = vec![128u16; n * n]; + let mut b = vec![128u16; n * n]; + inverse_transform_add(&coefs, tx, DCT_DCT, false, 1, 1, 8, &mut a, n); + inverse_transform_add(&coefs, tx, DCT_DCT, false, 2, n, 8, &mut b, n); + assert_eq!(a, b, "tx {tx} dc {dc}"); + } + } + } + + #[test] + fn wht_round_trip_identity() { + // A single DC coefficient of 4 * v spreads v / 4 ... check invertibility on a simple case: + // input [4, 0, ..] (after the >> 2 pre-scaling a unit impulse). + let mut coefs = [0i32; 16]; + coefs[0] = 4 * 4; + let mut dst = [100u16; 16]; + inverse_transform_add(&coefs, 0, DCT_DCT, true, 1, 4, 8, &mut dst, 4); + let sum: i32 = dst.iter().map(|&v| v as i32 - 100).sum(); + assert_eq!(sum, 16); + } +} diff --git a/crates/vp9/src/transform_gen.rs b/crates/vp9/src/transform_gen.rs new file mode 100644 index 000000000..34a7e719d --- /dev/null +++ b/crates/vp9/src/transform_gen.rs @@ -0,0 +1,480 @@ +// Generated by tools/gen_transforms.py from the butterfly descriptions of the VP9 specification +// v0.6 section 8.7.1. Do not edit by hand. + +macro_rules! transforms_1d { + ($t:ty) => { + #[inline(always)] + fn r14(x: $t) -> $t { + (x + (1 << 13)) >> 14 + } + + #[inline(always)] + pub fn idct4(x: [$t; 4]) -> [$t; 4] { + let v0 = r14(x[0] * 11585 - x[2] * 11585); + let v1 = r14(x[0] * 11585 + x[2] * 11585); + let v2 = r14(x[1] * 6270 - x[3] * 15137); + let v3 = r14(x[1] * 15137 + x[3] * 6270); + let v4 = v1 + v3; + let v5 = v1 - v3; + let v6 = v0 + v2; + let v7 = v0 - v2; + [v4, v6, v7, v5] + } + + #[inline(always)] + pub fn idct8(x: [$t; 8]) -> [$t; 8] { + let v0 = r14(x[0] * 11585 - x[4] * 11585); + let v1 = r14(x[0] * 11585 + x[4] * 11585); + let v2 = r14(x[2] * 6270 - x[6] * 15137); + let v3 = r14(x[2] * 15137 + x[6] * 6270); + let v4 = v1 + v3; + let v5 = v1 - v3; + let v6 = v0 + v2; + let v7 = v0 - v2; + let v8 = r14(x[1] * 3196 - x[7] * 16069); + let v9 = r14(x[1] * 16069 + x[7] * 3196); + let v10 = r14(x[5] * 13623 - x[3] * 9102); + let v11 = r14(x[5] * 9102 + x[3] * 13623); + let v12 = v8 + v10; + let v13 = v8 - v10; + let v14 = v9 + v11; + let v15 = v9 - v11; + let v16 = r14(v15 * 11585 - v13 * 11585); + let v17 = r14(v15 * 11585 + v13 * 11585); + let v18 = v4 + v14; + let v19 = v4 - v14; + let v20 = v6 + v17; + let v21 = v6 - v17; + let v22 = v7 + v16; + let v23 = v7 - v16; + let v24 = v5 + v12; + let v25 = v5 - v12; + [v18, v20, v22, v24, v25, v23, v21, v19] + } + + #[inline(always)] + pub fn idct16(x: [$t; 16]) -> [$t; 16] { + let v0 = r14(x[0] * 11585 - x[8] * 11585); + let v1 = r14(x[0] * 11585 + x[8] * 11585); + let v2 = r14(x[4] * 6270 - x[12] * 15137); + let v3 = r14(x[4] * 15137 + x[12] * 6270); + let v4 = v1 + v3; + let v5 = v1 - v3; + let v6 = v0 + v2; + let v7 = v0 - v2; + let v8 = r14(x[2] * 3196 - x[14] * 16069); + let v9 = r14(x[2] * 16069 + x[14] * 3196); + let v10 = r14(x[10] * 13623 - x[6] * 9102); + let v11 = r14(x[10] * 9102 + x[6] * 13623); + let v12 = v8 + v10; + let v13 = v8 - v10; + let v14 = v9 + v11; + let v15 = v9 - v11; + let v16 = r14(v15 * 11585 - v13 * 11585); + let v17 = r14(v15 * 11585 + v13 * 11585); + let v18 = v4 + v14; + let v19 = v4 - v14; + let v20 = v6 + v17; + let v21 = v6 - v17; + let v22 = v7 + v16; + let v23 = v7 - v16; + let v24 = v5 + v12; + let v25 = v5 - v12; + let v26 = r14(x[1] * 1606 - x[15] * 16305); + let v27 = r14(x[1] * 16305 + x[15] * 1606); + let v28 = r14(x[9] * 12665 - x[7] * 10394); + let v29 = r14(x[9] * 10394 + x[7] * 12665); + let v30 = r14(x[5] * 7723 - x[11] * 14449); + let v31 = r14(x[5] * 14449 + x[11] * 7723); + let v32 = r14(x[13] * 15679 - x[3] * 4756); + let v33 = r14(x[13] * 4756 + x[3] * 15679); + let v34 = v26 + v28; + let v35 = v26 - v28; + let v36 = v32 + v30; + let v37 = v32 - v30; + let v38 = v33 + v31; + let v39 = v33 - v31; + let v40 = v27 + v29; + let v41 = v27 - v29; + let v42 = r14(v41 * 6270 - v35 * 15137); + let v43 = r14(v41 * 15137 + v35 * 6270); + let v44 = r14(-v37 * 15137 + v39 * 6270); + let v45 = r14(-v37 * 6270 - v39 * 15137); + let v46 = v34 + v36; + let v47 = v34 - v36; + let v48 = v40 + v38; + let v49 = v40 - v38; + let v50 = v42 + v45; + let v51 = v42 - v45; + let v52 = v43 + v44; + let v53 = v43 - v44; + let v54 = r14(v53 * 11585 - v51 * 11585); + let v55 = r14(v53 * 11585 + v51 * 11585); + let v56 = r14(v49 * 11585 - v47 * 11585); + let v57 = r14(v49 * 11585 + v47 * 11585); + let v58 = v18 + v48; + let v59 = v18 - v48; + let v60 = v20 + v52; + let v61 = v20 - v52; + let v62 = v22 + v55; + let v63 = v22 - v55; + let v64 = v24 + v57; + let v65 = v24 - v57; + let v66 = v25 + v56; + let v67 = v25 - v56; + let v68 = v23 + v54; + let v69 = v23 - v54; + let v70 = v21 + v50; + let v71 = v21 - v50; + let v72 = v19 + v46; + let v73 = v19 - v46; + [v58, v60, v62, v64, v66, v68, v70, v72, v73, v71, v69, v67, v65, v63, v61, v59] + } + + #[inline(always)] + pub fn idct32(x: [$t; 32]) -> [$t; 32] { + let v0 = r14(x[0] * 11585 - x[16] * 11585); + let v1 = r14(x[0] * 11585 + x[16] * 11585); + let v2 = r14(x[8] * 6270 - x[24] * 15137); + let v3 = r14(x[8] * 15137 + x[24] * 6270); + let v4 = v1 + v3; + let v5 = v1 - v3; + let v6 = v0 + v2; + let v7 = v0 - v2; + let v8 = r14(x[4] * 3196 - x[28] * 16069); + let v9 = r14(x[4] * 16069 + x[28] * 3196); + let v10 = r14(x[20] * 13623 - x[12] * 9102); + let v11 = r14(x[20] * 9102 + x[12] * 13623); + let v12 = v8 + v10; + let v13 = v8 - v10; + let v14 = v9 + v11; + let v15 = v9 - v11; + let v16 = r14(v15 * 11585 - v13 * 11585); + let v17 = r14(v15 * 11585 + v13 * 11585); + let v18 = v4 + v14; + let v19 = v4 - v14; + let v20 = v6 + v17; + let v21 = v6 - v17; + let v22 = v7 + v16; + let v23 = v7 - v16; + let v24 = v5 + v12; + let v25 = v5 - v12; + let v26 = r14(x[2] * 1606 - x[30] * 16305); + let v27 = r14(x[2] * 16305 + x[30] * 1606); + let v28 = r14(x[18] * 12665 - x[14] * 10394); + let v29 = r14(x[18] * 10394 + x[14] * 12665); + let v30 = r14(x[10] * 7723 - x[22] * 14449); + let v31 = r14(x[10] * 14449 + x[22] * 7723); + let v32 = r14(x[26] * 15679 - x[6] * 4756); + let v33 = r14(x[26] * 4756 + x[6] * 15679); + let v34 = v26 + v28; + let v35 = v26 - v28; + let v36 = v32 + v30; + let v37 = v32 - v30; + let v38 = v33 + v31; + let v39 = v33 - v31; + let v40 = v27 + v29; + let v41 = v27 - v29; + let v42 = r14(v41 * 6270 - v35 * 15137); + let v43 = r14(v41 * 15137 + v35 * 6270); + let v44 = r14(-v37 * 15137 + v39 * 6270); + let v45 = r14(-v37 * 6270 - v39 * 15137); + let v46 = v34 + v36; + let v47 = v34 - v36; + let v48 = v40 + v38; + let v49 = v40 - v38; + let v50 = v42 + v45; + let v51 = v42 - v45; + let v52 = v43 + v44; + let v53 = v43 - v44; + let v54 = r14(v53 * 11585 - v51 * 11585); + let v55 = r14(v53 * 11585 + v51 * 11585); + let v56 = r14(v49 * 11585 - v47 * 11585); + let v57 = r14(v49 * 11585 + v47 * 11585); + let v58 = v18 + v48; + let v59 = v18 - v48; + let v60 = v20 + v52; + let v61 = v20 - v52; + let v62 = v22 + v55; + let v63 = v22 - v55; + let v64 = v24 + v57; + let v65 = v24 - v57; + let v66 = v25 + v56; + let v67 = v25 - v56; + let v68 = v23 + v54; + let v69 = v23 - v54; + let v70 = v21 + v50; + let v71 = v21 - v50; + let v72 = v19 + v46; + let v73 = v19 - v46; + let v74 = r14(x[1] * 804 - x[31] * 16364); + let v75 = r14(x[1] * 16364 + x[31] * 804); + let v76 = r14(x[17] * 12140 - x[15] * 11003); + let v77 = r14(x[17] * 11003 + x[15] * 12140); + let v78 = r14(x[9] * 7005 - x[23] * 14811); + let v79 = r14(x[9] * 14811 + x[23] * 7005); + let v80 = r14(x[25] * 15426 - x[7] * 5520); + let v81 = r14(x[25] * 5520 + x[7] * 15426); + let v82 = r14(x[5] * 3981 - x[27] * 15893); + let v83 = r14(x[5] * 15893 + x[27] * 3981); + let v84 = r14(x[21] * 14053 - x[11] * 8423); + let v85 = r14(x[21] * 8423 + x[11] * 14053); + let v86 = r14(x[13] * 9760 - x[19] * 13160); + let v87 = r14(x[13] * 13160 + x[19] * 9760); + let v88 = r14(x[29] * 16207 - x[3] * 2404); + let v89 = r14(x[29] * 2404 + x[3] * 16207); + let v90 = v74 + v76; + let v91 = v74 - v76; + let v92 = v80 + v78; + let v93 = v80 - v78; + let v94 = v82 + v84; + let v95 = v82 - v84; + let v96 = v88 + v86; + let v97 = v88 - v86; + let v98 = v89 + v87; + let v99 = v89 - v87; + let v100 = v83 + v85; + let v101 = v83 - v85; + let v102 = v81 + v79; + let v103 = v81 - v79; + let v104 = v75 + v77; + let v105 = v75 - v77; + let v106 = r14(v105 * 3196 - v91 * 16069); + let v107 = r14(v105 * 16069 + v91 * 3196); + let v108 = r14(-v97 * 9102 + v99 * 13623); + let v109 = r14(-v97 * 13623 - v99 * 9102); + let v110 = r14(v101 * 13623 - v95 * 9102); + let v111 = r14(v101 * 9102 + v95 * 13623); + let v112 = r14(-v93 * 16069 + v103 * 3196); + let v113 = r14(-v93 * 3196 - v103 * 16069); + let v114 = v90 + v92; + let v115 = v90 - v92; + let v116 = v96 + v94; + let v117 = v96 - v94; + let v118 = v98 + v100; + let v119 = v98 - v100; + let v120 = v104 + v102; + let v121 = v104 - v102; + let v122 = v106 + v113; + let v123 = v106 - v113; + let v124 = v109 + v110; + let v125 = v109 - v110; + let v126 = v108 + v111; + let v127 = v108 - v111; + let v128 = v107 + v112; + let v129 = v107 - v112; + let v130 = r14(v129 * 6270 - v123 * 15137); + let v131 = r14(v129 * 15137 + v123 * 6270); + let v132 = r14(-v125 * 15137 + v127 * 6270); + let v133 = r14(-v125 * 6270 - v127 * 15137); + let v134 = r14(v121 * 6270 - v115 * 15137); + let v135 = r14(v121 * 15137 + v115 * 6270); + let v136 = r14(-v117 * 15137 + v119 * 6270); + let v137 = r14(-v117 * 6270 - v119 * 15137); + let v138 = v114 + v116; + let v139 = v114 - v116; + let v140 = v120 + v118; + let v141 = v120 - v118; + let v142 = v122 + v124; + let v143 = v122 - v124; + let v144 = v128 + v126; + let v145 = v128 - v126; + let v146 = v130 + v133; + let v147 = v130 - v133; + let v148 = v131 + v132; + let v149 = v131 - v132; + let v150 = v134 + v137; + let v151 = v134 - v137; + let v152 = v135 + v136; + let v153 = v135 - v136; + let v154 = r14(v153 * 11585 - v151 * 11585); + let v155 = r14(v153 * 11585 + v151 * 11585); + let v156 = r14(v149 * 11585 - v147 * 11585); + let v157 = r14(v149 * 11585 + v147 * 11585); + let v158 = r14(v145 * 11585 - v143 * 11585); + let v159 = r14(v145 * 11585 + v143 * 11585); + let v160 = r14(v141 * 11585 - v139 * 11585); + let v161 = r14(v141 * 11585 + v139 * 11585); + let v162 = v58 + v140; + let v163 = v58 - v140; + let v164 = v60 + v144; + let v165 = v60 - v144; + let v166 = v62 + v148; + let v167 = v62 - v148; + let v168 = v64 + v152; + let v169 = v64 - v152; + let v170 = v66 + v155; + let v171 = v66 - v155; + let v172 = v68 + v157; + let v173 = v68 - v157; + let v174 = v70 + v159; + let v175 = v70 - v159; + let v176 = v72 + v161; + let v177 = v72 - v161; + let v178 = v73 + v160; + let v179 = v73 - v160; + let v180 = v71 + v158; + let v181 = v71 - v158; + let v182 = v69 + v156; + let v183 = v69 - v156; + let v184 = v67 + v154; + let v185 = v67 - v154; + let v186 = v65 + v150; + let v187 = v65 - v150; + let v188 = v63 + v146; + let v189 = v63 - v146; + let v190 = v61 + v142; + let v191 = v61 - v142; + let v192 = v59 + v138; + let v193 = v59 - v138; + [v162, v164, v166, v168, v170, v172, v174, v176, v178, v180, v182, v184, v186, v188, v190, v192, v193, v191, v189, v187, v185, v183, v181, v179, v177, v175, v173, v171, v169, v167, v165, v163] + } + + #[inline(always)] + pub fn iadst8(x: [$t; 8]) -> [$t; 8] { + let v0 = x[7] * 1606 - x[0] * 16305; + let v1 = x[7] * 16305 + x[0] * 1606; + let v2 = x[5] * 7723 - x[2] * 14449; + let v3 = x[5] * 14449 + x[2] * 7723; + let v4 = x[3] * 12665 - x[4] * 10394; + let v5 = x[3] * 10394 + x[4] * 12665; + let v6 = x[1] * 15679 - x[6] * 4756; + let v7 = x[1] * 4756 + x[6] * 15679; + let v8 = r14(v1 + v5); + let v9 = r14(v1 - v5); + let v10 = r14(v0 + v4); + let v11 = r14(v0 - v4); + let v12 = r14(v3 + v7); + let v13 = r14(v3 - v7); + let v14 = r14(v2 + v6); + let v15 = r14(v2 - v6); + let v16 = v9 * 6270 - v11 * 15137; + let v17 = v9 * 15137 + v11 * 6270; + let v18 = v15 * 15137 - v13 * 6270; + let v19 = v15 * 6270 + v13 * 15137; + let v20 = r14(v17 + v18); + let v21 = r14(v17 - v18); + let v22 = r14(v16 + v19); + let v23 = r14(v16 - v19); + let v24 = v8 + v12; + let v25 = v8 - v12; + let v26 = v10 + v14; + let v27 = v10 - v14; + let v28 = r14(v25 * 11585 - v27 * 11585); + let v29 = r14(v25 * 11585 + v27 * 11585); + let v30 = r14(v21 * 11585 - v23 * 11585); + let v31 = r14(v21 * 11585 + v23 * 11585); + let v32 = -v20; + let v33 = -v29; + let v34 = -v30; + let v35 = -v26; + [v24, v32, v31, v33, v28, v34, v22, v35] + } + + #[inline(always)] + pub fn iadst16(x: [$t; 16]) -> [$t; 16] { + let v0 = x[15] * 804 - x[0] * 16364; + let v1 = x[15] * 16364 + x[0] * 804; + let v2 = x[13] * 3981 - x[2] * 15893; + let v3 = x[13] * 15893 + x[2] * 3981; + let v4 = x[11] * 7005 - x[4] * 14811; + let v5 = x[11] * 14811 + x[4] * 7005; + let v6 = x[9] * 9760 - x[6] * 13160; + let v7 = x[9] * 13160 + x[6] * 9760; + let v8 = x[7] * 12140 - x[8] * 11003; + let v9 = x[7] * 11003 + x[8] * 12140; + let v10 = x[5] * 14053 - x[10] * 8423; + let v11 = x[5] * 8423 + x[10] * 14053; + let v12 = x[3] * 15426 - x[12] * 5520; + let v13 = x[3] * 5520 + x[12] * 15426; + let v14 = x[1] * 16207 - x[14] * 2404; + let v15 = x[1] * 2404 + x[14] * 16207; + let v16 = r14(v1 + v9); + let v17 = r14(v1 - v9); + let v18 = r14(v0 + v8); + let v19 = r14(v0 - v8); + let v20 = r14(v3 + v11); + let v21 = r14(v3 - v11); + let v22 = r14(v2 + v10); + let v23 = r14(v2 - v10); + let v24 = r14(v5 + v13); + let v25 = r14(v5 - v13); + let v26 = r14(v4 + v12); + let v27 = r14(v4 - v12); + let v28 = r14(v7 + v15); + let v29 = r14(v7 - v15); + let v30 = r14(v6 + v14); + let v31 = r14(v6 - v14); + let v32 = v17 * 3196 - v19 * 16069; + let v33 = v17 * 16069 + v19 * 3196; + let v34 = v21 * 13623 - v23 * 9102; + let v35 = v21 * 9102 + v23 * 13623; + let v36 = v25 * 16069 + v27 * 3196; + let v37 = -v25 * 3196 + v27 * 16069; + let v38 = v29 * 9102 + v31 * 13623; + let v39 = -v29 * 13623 + v31 * 9102; + let v40 = r14(v33 + v37); + let v41 = r14(v33 - v37); + let v42 = r14(v32 + v36); + let v43 = r14(v32 - v36); + let v44 = r14(v35 + v39); + let v45 = r14(v35 - v39); + let v46 = r14(v34 + v38); + let v47 = r14(v34 - v38); + let v48 = v16 + v24; + let v49 = v16 - v24; + let v50 = v18 + v26; + let v51 = v18 - v26; + let v52 = v20 + v28; + let v53 = v20 - v28; + let v54 = v22 + v30; + let v55 = v22 - v30; + let v56 = v49 * 6270 - v51 * 15137; + let v57 = v49 * 15137 + v51 * 6270; + let v58 = v55 * 15137 - v53 * 6270; + let v59 = v55 * 6270 + v53 * 15137; + let v60 = v41 * 6270 - v43 * 15137; + let v61 = v41 * 15137 + v43 * 6270; + let v62 = v47 * 15137 - v45 * 6270; + let v63 = v47 * 6270 + v45 * 15137; + let v64 = r14(v57 + v58); + let v65 = r14(v57 - v58); + let v66 = r14(v61 + v62); + let v67 = r14(v61 - v62); + let v68 = r14(v56 + v59); + let v69 = r14(v56 - v59); + let v70 = r14(v60 + v63); + let v71 = r14(v60 - v63); + let v72 = v48 + v52; + let v73 = v48 - v52; + let v74 = v40 + v44; + let v75 = v40 - v44; + let v76 = v50 + v54; + let v77 = v50 - v54; + let v78 = v42 + v46; + let v79 = v42 - v46; + let v80 = r14(-v73 * 11585 - v77 * 11585); + let v81 = r14(v73 * 11585 - v77 * 11585); + let v82 = r14(v65 * 11585 + v69 * 11585); + let v83 = r14(-v65 * 11585 + v69 * 11585); + let v84 = r14(v75 * 11585 + v79 * 11585); + let v85 = r14(-v75 * 11585 + v79 * 11585); + let v86 = r14(-v67 * 11585 - v71 * 11585); + let v87 = r14(v67 * 11585 - v71 * 11585); + let v88 = -v74; + let v89 = -v70; + let v90 = -v64; + let v91 = -v76; + [v72, v88, v66, v90, v82, v86, v84, v80, v81, v85, v87, v83, v68, v89, v78, v91] + } + + }; +} + +pub(crate) mod narrow { + transforms_1d!(i32); +} + +pub(crate) mod wide { + transforms_1d!(i64); +} diff --git a/crates/vp9/tests/common/mod.rs b/crates/vp9/tests/common/mod.rs new file mode 100644 index 000000000..420541d17 --- /dev/null +++ b/crates/vp9/tests/common/mod.rs @@ -0,0 +1,303 @@ +//! Test fixture generation (ffmpeg + libvpx-vp9 as external encoder and oracle), a tiny IVF +//! reader, and comparison helpers. +#![allow(dead_code)] + +use filmcraft_vp9::{Decoder, Picture}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// One encoder configuration of the fixture matrix. +pub struct Fixture { + pub name: &'static str, + /// lavfi source (without size / rate). + pub source: &'static str, + pub width: u32, + pub height: u32, + pub frames: u32, + /// Extra video filter appended after the source. + pub filter: &'static str, + /// Pixel format of the encode (and of the reference decode). + pub pix_fmt: &'static str, + /// libvpx-vp9 arguments. + pub args: &'static [&'static str], +} + +const NOISE: &str = "noise=alls=10:allf=t+u"; + +macro_rules! fx { + ($name:expr, $src:expr, $w:expr, $h:expr, $n:expr, $filter:expr, $pf:expr, [$($a:expr),* $(,)?]) => { + Fixture { name: $name, source: $src, width: $w, height: $h, frames: $n, filter: $filter, pix_fmt: $pf, args: &[$($a),*] } + }; +} + +const P420: &str = "yuv420p"; +const P420_10: &str = "yuv420p10le"; + +pub const FIXTURES: &[Fixture] = &[ + fx!("intra_only_keyframes", "testsrc2", 352, 288, 3, NOISE, P420, ["-g", "1", "-b:v", "1M"]), + fx!("default_good", "testsrc2", 352, 288, 20, NOISE, P420, ["-b:v", "800k"]), + fx!("profile2_10bit", "testsrc2", 352, 288, 12, NOISE, P420_10, ["-b:v", "800k", "-profile:v", "2"]), + fx!("profile2_12bit", "mandelbrot", 176, 144, 8, NOISE, "yuv420p12le", ["-b:v", "500k", "-profile:v", "2"]), + fx!("profile1_444", "testsrc2", 176, 144, 8, NOISE, "yuv444p", ["-b:v", "500k", "-profile:v", "1"]), + fx!("profile1_422", "testsrc2", 176, 144, 8, NOISE, "yuv422p", ["-b:v", "500k", "-profile:v", "1"]), + fx!("profile1_440", "testsrc2", 176, 144, 8, NOISE, "yuv440p", ["-b:v", "500k", "-profile:v", "1"]), + fx!("profile3_444_10bit", "testsrc2", 176, 144, 6, NOISE, "yuv444p10le", ["-b:v", "500k", "-profile:v", "3"]), + fx!("lossless", "testsrc2", 176, 144, 5, NOISE, P420, ["-lossless", "1"]), + fx!("lossless_10bit", "mandelbrot", 176, 144, 4, NOISE, P420_10, ["-lossless", "1", "-profile:v", "2"]), + fx!("tiles_cols4", "testsrc2", 1280, 720, 6, NOISE, P420, ["-b:v", "2M", "-tile-columns", "2", "-speed", "4"]), + fx!("tiles_rows_cols", "testsrc2", 1280, 720, 6, NOISE, P420, ["-b:v", "2M", "-tile-columns", "2", "-tile-rows", "2", "-speed", "4"]), + fx!("altref_hidden", "testsrc2", 352, 288, 30, NOISE, P420, ["-b:v", "600k", "-auto-alt-ref", "1", "-lag-in-frames", "25"]), + fx!("frame_parallel", "testsrc2", 352, 288, 16, NOISE, P420, ["-b:v", "600k", "-frame-parallel", "1"]), + fx!("frame_parallel_off", "testsrc2", 352, 288, 16, NOISE, P420, ["-b:v", "600k", "-frame-parallel", "0"]), + fx!("error_resilient", "testsrc2", 352, 288, 16, NOISE, P420, ["-b:v", "600k", "-error-resilient", "1"]), + fx!("odd_size", "testsrc2", 347, 251, 10, NOISE, P420, ["-b:v", "600k"]), + fx!("tiny_odd", "testsrc2", 33, 17, 8, NOISE, P420, ["-b:v", "200k"]), + fx!("row_mt", "testsrc2", 640, 360, 10, NOISE, P420, ["-b:v", "1M", "-row-mt", "1", "-tile-columns", "1"]), + fx!("speed0", "mandelbrot", 176, 144, 8, NOISE, P420, ["-b:v", "300k", "-speed", "0"]), + fx!("speed2", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "2"]), + fx!("speed5", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "5", "-deadline", "realtime"]), + fx!("speed8", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "8", "-deadline", "realtime"]), + fx!("cq_low", "testsrc2", 352, 288, 8, NOISE, P420, ["-crf", "0", "-b:v", "0"]), + fx!("cq_high", "testsrc2", 352, 288, 12, NOISE, P420, ["-crf", "63", "-b:v", "0"]), + fx!("cq_10bit_low", "testsrc2", 176, 144, 6, NOISE, P420_10, ["-crf", "2", "-b:v", "0", "-profile:v", "2"]), + fx!("aq_segmentation", "testsrc2", 352, 288, 12, NOISE, P420, ["-b:v", "600k", "-aq-mode", "3"]), + fx!("aq_variance", "mandelbrot", 352, 288, 12, NOISE, P420, ["-b:v", "600k", "-aq-mode", "1"]), + fx!("sharpness", "testsrc2", 352, 288, 8, NOISE, P420, ["-b:v", "400k", "-sharpness", "5"]), + fx!("fade_intra_heavy", "testsrc2", 352, 288, 20, "fade=in:0:15", P420, ["-b:v", "400k", "-g", "8"]), + fx!("hd_1080p", "testsrc2", 1920, 1080, 3, NOISE, P420, ["-b:v", "4M", "-speed", "5", "-deadline", "realtime"]), + fx!("bench_1080p", "testsrc2", 1920, 1080, 60, "noise=alls=3:allf=t", P420, ["-b:v", "4M", "-speed", "4", "-tile-columns", "2", "-row-mt", "1"]), + fx!("bench_4k", "testsrc2", 3840, 2160, 20, "noise=alls=3:allf=t", P420, ["-b:v", "12M", "-speed", "6", "-tile-columns", "3", "-row-mt", "1"]), +]; + +pub fn fixture(name: &str) -> &'static Fixture { + FIXTURES.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("unknown fixture {name}")) +} + +pub fn ffmpeg() -> Option { + for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] { + if Path::new(p).exists() { + return Some(PathBuf::from(p)); + } + } + None +} + +pub fn fixtures_dir() -> PathBuf { + let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/vp9"); + std::fs::create_dir_all(&d).expect("create fixtures dir"); + d +} + +pub fn run(cmd: &mut Command) -> bool { + match cmd.output() { + Ok(o) if o.status.success() => true, + Ok(o) => { + eprintln!("command failed: {:?}\n{}", cmd, String::from_utf8_lossy(&o.stderr)); + false + } + Err(e) => { + eprintln!("failed to run {:?}: {e}", cmd); + false + } + } +} + +/// Bytes per sample and chroma subsampling of a pixel format. +pub fn format_info(pix_fmt: &str) -> (usize, u32, u32) { + let bps = if pix_fmt.contains("p10") || pix_fmt.contains("p12") { 2 } else { 1 }; + let (sx, sy) = if pix_fmt.starts_with("yuv420") { + (1, 1) + } else if pix_fmt.starts_with("yuv422") { + (1, 0) + } else if pix_fmt.starts_with("yuv440") { + (0, 1) + } else { + (0, 0) + }; + (bps, sx, sy) +} + +/// Reference decode of `ivf` with ffmpeg (native vp9 decoder, every frame, no fps conversion). +pub fn reference_decode(ivf: &Path, yuv: &Path, pix_fmt: &str) -> bool { + let Some(ff) = ffmpeg() else { return false }; + let tmp = yuv.with_extension(format!("tmp.{}.yuv", std::process::id())); + let mut c = Command::new(&ff); + c.args(["-hide_banner", "-loglevel", "error", "-y", "-c:v", "vp9", "-i"]).arg(ivf); + c.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt]).arg(&tmp); + if !run(&mut c) { + return false; + } + std::fs::rename(&tmp, yuv).is_ok() +} + +/// Generate (if needed) the fixture stream and its ffmpeg reference decode. +pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { + let Some(ff) = ffmpeg() else { + eprintln!("SKIP {}: ffmpeg not found", f.name); + return None; + }; + let dir = fixtures_dir(); + let ivf = dir.join(format!("{}.ivf", f.name)); + let yuv = dir.join(format!("{}.yuv", f.name)); + if !ivf.exists() { + let tmp = dir.join(format!("{}.tmp.{}.ivf", f.name, std::process::id())); + let mut vf = format!("{}=size={}x{}:rate=25,format={}", f.source, f.width, f.height, f.pix_fmt); + if !f.filter.is_empty() { + vf.push(','); + vf.push_str(f.filter); + } + let mut c = Command::new(&ff); + c.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", &vf]); + c.args(["-frames:v", &f.frames.to_string(), "-c:v", "libvpx-vp9", "-pix_fmt", f.pix_fmt]); + c.args(f.args); + c.args(["-f", "ivf"]).arg(&tmp); + if !run(&mut c) { + let _ = std::fs::remove_file(&tmp); + eprintln!("SKIP {}: libvpx-vp9 encode failed", f.name); + return None; + } + std::fs::rename(&tmp, &ivf).unwrap(); + } + if !yuv.exists() { + assert!(reference_decode(&ivf, &yuv, f.pix_fmt), "reference decode failed for {}", f.name); + } + Some((ivf, yuv)) +} + +/// Frames of an IVF file (32-byte file header, then 12-byte frame headers). +pub fn ivf_frames(data: &[u8]) -> Vec<&[u8]> { + let mut out = Vec::new(); + if data.len() < 32 || &data[..4] != b"DKIF" { + return out; + } + let hl = u16::from_le_bytes([data[6], data[7]]) as usize; + let mut p = hl.max(32); + while p + 12 <= data.len() { + let sz = u32::from_le_bytes(data[p..p + 4].try_into().unwrap()) as usize; + p += 12; + if p + sz > data.len() { + break; + } + out.push(&data[p..p + sz]); + p += sz; + } + out +} + +/// Write an IVF file. +pub fn write_ivf(path: &Path, w: u16, h: u16, frames: &[Vec]) { + let mut d = Vec::new(); + d.extend(b"DKIF"); + d.extend(0u16.to_le_bytes()); + d.extend(32u16.to_le_bytes()); + d.extend(b"VP90"); + d.extend(w.to_le_bytes()); + d.extend(h.to_le_bytes()); + d.extend(25u32.to_le_bytes()); + d.extend(1u32.to_le_bytes()); + d.extend((frames.len() as u32).to_le_bytes()); + d.extend(0u32.to_le_bytes()); + for (i, f) in frames.iter().enumerate() { + d.extend((f.len() as u32).to_le_bytes()); + d.extend((i as u64).to_le_bytes()); + d.extend(f); + } + std::fs::write(path, d).unwrap(); +} + +pub type DecodeFailure = (usize, filmcraft_vp9::Error, Vec); + +/// Decode an IVF file with `threads` workers (0 = default), pts = frame index. +pub fn decode_file_threads(path: &Path, threads: usize) -> Result, DecodeFailure> { + let data = std::fs::read(path).unwrap(); + let mut dec = if threads == 0 { Decoder::new() } else { Decoder::with_threads(threads) }; + let mut out = Vec::new(); + for (i, f) in ivf_frames(&data).into_iter().enumerate() { + match dec.decode(f, i as i64) { + Ok(p) => out.extend(p), + Err(e) => return Err((i, e, out)), + } + } + out.extend(dec.flush()); + Ok(out) +} + +/// Frame size in bytes of a raw picture. +pub fn raw_size(w: usize, h: usize, pix_fmt: &str) -> usize { + let (bps, sx, sy) = format_info(pix_fmt); + let (cw, ch) = ((w + sx as usize) >> sx, (h + sy as usize) >> sy); + (w * h + 2 * cw * ch) * bps +} + +/// Compare decoded pictures with a raw reference with frames of possibly varying sizes (sizes +/// are taken from the decoded pictures). Reports the first mismatch. +pub fn compare(pics: &[Picture], reference: &[u8], pix_fmt: &str) -> Result<(), String> { + let (bps, _, _) = format_info(pix_fmt); + let mut off = 0usize; + for (i, p) in pics.iter().enumerate() { + let (w, h) = (p.width as usize, p.height as usize); + let (cw, ch) = (p.chroma_width as usize, p.chroma_height as usize); + let fsize = raw_size(w, h, pix_fmt); + if off + fsize > reference.len() { + return Err(format!("decoder produced {} frames, reference has fewer (at frame {i})", pics.len())); + } + let f = &reference[off..off + fsize]; + off += fsize; + let sample = |buf: &[u8], k: usize| if bps == 2 { u16::from_le_bytes([buf[2 * k], buf[2 * k + 1]]) } else { buf[k] as u16 }; + let (ys, cs) = (w * h * bps, cw * ch * bps); + let planes = [("Y", &p.y, &f[..ys], w, h), ("U", &p.u, &f[ys..ys + cs], cw, ch), ("V", &p.v, &f[ys + cs..], cw, ch)]; + for (name, got, exp, pw, ph) in planes { + let mut first = None; + let mut count = 0usize; + let mut maxd = 0i32; + for y in 0..ph { + for x in 0..pw { + let (a, b) = (got.get(y * pw + x), sample(exp, y * pw + x)); + if a != b { + count += 1; + maxd = maxd.max((a as i32 - b as i32).abs()); + if first.is_none() { + first = Some((x, y, a, b)); + } + } + } + } + if let Some((x, y, a, b)) = first { + return Err(format!( + "frame {i} (pts {}, {w}x{h}) plane {name}: first mismatch at ({x},{y}) [SB64 of luma ~({},{})]: got {a} want {b}; {count} samples differ, max diff {maxd}", + p.pts, + x / 64, + y / 64 + )); + } + } + } + if off != reference.len() { + return Err(format!("decoder produced {} frames, reference has more data", pics.len())); + } + Ok(()) +} + +/// Per-frame MD5-like digest is not needed: comparison is sample exact. Full check of one +/// fixture: generate, decode (single- and multi-threaded), compare. Ok(false) when skipped. +pub fn check_fixture(name: &str) -> Result { + let f = fixture(name); + let Some((ivf, yuv)) = ensure(f) else { return Ok(false) }; + let reference = std::fs::read(&yuv).unwrap(); + check_files(name, &ivf, &reference, f.pix_fmt)?; + Ok(true) +} + +pub fn check_files(name: &str, ivf: &Path, reference: &[u8], pix_fmt: &str) -> Result<(), String> { + for threads in [1, 0] { + let pics = match decode_file_threads(ivf, threads) { + Ok(p) => p, + Err((fr, e, partial)) => { + let cmp = compare(&partial, reference, pix_fmt).err().unwrap_or_default(); + return Err(format!("{name} (threads {threads}): decode error at chunk {fr}: {e} (after {} pictures) {cmp}", partial.len())); + } + }; + compare(&pics, reference, pix_fmt).map_err(|e| format!("{name} (threads {threads}): {e}"))?; + } + Ok(()) +} diff --git a/crates/vp9/tests/conformance.rs b/crates/vp9/tests/conformance.rs new file mode 100644 index 000000000..2ad2c0f8a --- /dev/null +++ b/crates/vp9/tests/conformance.rs @@ -0,0 +1,152 @@ +//! Bit-exactness against ffmpeg's VP9 decoder on libvpx-vp9 fixtures (skipped when ffmpeg or +//! libvpx is absent). + +mod common; + +macro_rules! fixture_tests { + ($($name:ident),* $(,)?) => { + $( + #[test] + fn $name() { + match common::check_fixture(stringify!($name)) { + Ok(true) => {} + Ok(false) => eprintln!("skipped {}", stringify!($name)), + Err(e) => panic!("{e}"), + } + } + )* + }; +} + +fixture_tests!( + intra_only_keyframes, + default_good, + profile2_10bit, + profile2_12bit, + profile1_444, + profile1_422, + profile1_440, + profile3_444_10bit, + lossless, + lossless_10bit, + tiles_cols4, + tiles_rows_cols, + altref_hidden, + frame_parallel, + frame_parallel_off, + error_resilient, + odd_size, + tiny_odd, + row_mt, + speed0, + speed2, + speed5, + speed8, + cq_low, + cq_high, + cq_10bit_low, + aq_segmentation, + aq_variance, + sharpness, + fade_intra_heavy, + hd_1080p, +); + +/// Resolution changes mid-stream: libvpx's `resize_mode` (dynamic internal scaling with scaled +/// reference prediction), and concatenated streams of different sizes. +#[test] +fn resize_dynamic() { + let Some(ff) = common::ffmpeg() else { return }; + let dir = common::fixtures_dir(); + let ivf = dir.join("resize_dynamic.ivf"); + let yuv = dir.join("resize_dynamic.yuv"); + if !ivf.exists() { + // Encode with a spatial resampling switch: first 10 frames at 352x288, then the encoder + // is fed 176x144 content via a scale filter that changes size (ffmpeg reinitialises + // libvpx with a new size without a key frame when `-resize_mode` style scaling is + // unavailable, so emulate with libvpx's internal resize through the `resize-mode` knob + // of vpxenc-like options if present, otherwise concatenation below covers size changes). + let mut c = std::process::Command::new(&ff); + c.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=352x288:rate=25,noise=alls=10:allf=t+u"]); + c.args([ + "-frames:v", + "30", + "-c:v", + "libvpx-vp9", + "-b:v", + "60k", + "-minrate", + "60k", + "-maxrate", + "60k", + "-undershoot-pct", + "0", + "-lag-in-frames", + "0", + "-deadline", + "realtime", + "-speed", + "8", + ]); + c.args(["-vpx-options", "resize-mode=3"]); + c.args(["-f", "ivf"]).arg(&ivf); + if !common::run(&mut c) { + let mut c = std::process::Command::new(&ff); + c.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=352x288:rate=25,noise=alls=10:allf=t+u"]); + c.args(["-frames:v", "30", "-c:v", "libvpx-vp9", "-b:v", "40k", "-deadline", "realtime", "-speed", "8", "-lag-in-frames", "0"]); + c.args(["-f", "ivf"]).arg(&ivf); + assert!(common::run(&mut c)); + } + } + if !yuv.exists() { + assert!(common::reference_decode(&ivf, &yuv, "yuv420p")); + } + let reference = std::fs::read(&yuv).unwrap(); + common::check_files("resize_dynamic", &ivf, &reference, "yuv420p").unwrap(); +} + +/// Two streams of different sizes concatenated into one IVF (key frame at the size change). +#[test] +fn resize_concatenated() { + let a = common::fixture("default_good"); + let b = common::fixture("odd_size"); + let (Some((ia, ya)), Some((ib, yb))) = (common::ensure(a), common::ensure(b)) else { return }; + let da = std::fs::read(ia).unwrap(); + let db = std::fs::read(ib).unwrap(); + let mut frames: Vec> = common::ivf_frames(&da).into_iter().map(|f| f.to_vec()).collect(); + frames.extend(common::ivf_frames(&db).into_iter().map(|f| f.to_vec())); + let path = common::fixtures_dir().join("resize_concat.ivf"); + common::write_ivf(&path, 352, 288, &frames); + let mut reference = std::fs::read(ya).unwrap(); + reference.extend(std::fs::read(yb).unwrap()); + common::check_files("resize_concat", &path, &reference, "yuv420p").unwrap(); +} + +/// Print which coding tools each fixture exercises (run with `-- --ignored --nocapture`). +#[test] +#[ignore] +fn coverage_report() { + for f in common::FIXTURES { + let Some((ivf, _)) = common::ensure(f) else { continue }; + let data = std::fs::read(ivf).unwrap(); + let mut dec = filmcraft_vp9::Decoder::new(); + for fr in common::ivf_frames(&data) { + let _ = dec.decode(fr, 0); + } + println!("{:<22} {:?}", f.name, dec.stats()); + } +} + +/// The comparison must report a single flipped sample (guards against a vacuous harness). +#[test] +fn compare_detects_mismatch() { + let f = common::fixture("intra_only_keyframes"); + let Some((ivf, yuv)) = common::ensure(f) else { return }; + let pics = common::decode_file_threads(&ivf, 1).map_err(|e| e.1).unwrap(); + let mut reference = std::fs::read(yuv).unwrap(); + common::compare(&pics, &reference, f.pix_fmt).unwrap(); + let pos = reference.len() - 7; + reference[pos] ^= 1; + let err = common::compare(&pics, &reference, f.pix_fmt).unwrap_err(); + assert!(err.contains("plane V"), "{err}"); +} diff --git a/crates/vp9/tools/extract_tables.py b/crates/vp9/tools/extract_tables.py new file mode 100644 index 000000000..bdd59bb7b --- /dev/null +++ b/crates/vp9/tools/extract_tables.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Extract the VP9 constant tables from the text of the public VP9 Bitstream & Decoding Process +Specification (v0.6, WebM project / Google). + +Usage: + curl -L -o vp9.pdf 'https://storage.googleapis.com/downloads.webmproject.org/docs/vp9/vp9-bitstream-specification-v0.6-20160331-draft.pdf' + pdftotext -layout vp9.pdf vp9.txt + python3 extract_tables.py vp9.txt > ../src/spec_tables.rs + +Only the specification text is read. Every table is emitted flat (row-major, as printed) with its +element count checked against the declared dimensions. +""" +import re +import sys + +text = open(sys.argv[1], encoding="utf-8").read().replace("−", "-").replace("–", "-") +lines = [l for l in text.split("\n") if "Copyright" not in l and "VP9 Bitstream & Decoding Process Specification" not in l] +text = "\n".join(lines) + + +def table(name, count): + """Integers of the table `name[...] = { ... }` (the last definition in the text).""" + starts = [m.start() for m in re.finditer(r"\b%s\s*\[" % re.escape(name), text)] + for s in reversed(starts): + eq = text.find("=", s) + brace = text.find("{", s) + if eq < 0 or brace < 0 or eq > brace or text[s:eq].count("\n") > 2: + continue + depth = 0 + i = brace + while True: + ch = text[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + break + i += 1 + body = text[brace:i] + body = re.sub(r"//[^\n]*", "", body) + body = re.sub(r"/\*.*?\*/", "", body, flags=re.S) + vals = [int(v) for v in re.findall(r"-?\d+", body)] + assert len(vals) == count, (name, len(vals), count) + return vals + raise AssertionError("table not found: " + name) + + +def emit(rust_name, ty, vals, per_line=16): + print("pub const %s: [%s; %d] = [" % (rust_name, ty, len(vals))) + for k in range(0, len(vals), per_line): + print(" " + ", ".join(str(v) for v in vals[k:k + per_line]) + ",") + print("];") + print() + + +print("// Generated by tools/extract_tables.py from the VP9 Bitstream & Decoding Process Specification") +print("// v0.6 (section numbers refer to that document). Do not edit by hand.") +print() + +emit("INV_MAP_TABLE", "u8", table("inv_map_table", 255)) +emit("SUBPEL_FILTERS", "i16", table("subpel_filters", 4 * 16 * 8), 8) +emit("DC_QLOOKUP", "i32", table("dc_qlookup", 3 * 256)) +emit("AC_QLOOKUP", "i32", table("ac_qlookup", 3 * 256)) +emit("COS64_LOOKUP", "i32", table("cos64_lookup", 33)) +for n, c in [("4x4", 16), ("8x8", 64), ("16x16", 256)]: + for kind in ["default", "col", "row"]: + emit("%s_SCAN_%s" % (kind.upper(), n.upper()), "u16", table("%s_scan_%s" % (kind, n), c)) +emit("DEFAULT_SCAN_32X32", "u16", table("default_scan_32x32", 1024)) +emit("COEFBAND_4X4", "u8", table("coefband_4x4", 16)) +emit("COEFBAND_8X8PLUS", "u8", table("coefband_8x8plus", 1024)) +emit("ENERGY_CLASS", "u8", table("energy_class", 12)) +emit("PARETO_TABLE", "u8", table("pareto_table", 128 * 8), 8) +emit("MV_REF_BLOCKS", "i8", table("mv_ref_blocks", 13 * 8 * 2)) +emit("MODE_2_COUNTER", "u8", table("mode_2_counter", 14)) +emit("KF_PARTITION_PROBS", "u8", table("kf_partition_probs", 16 * 3), 3) +emit("KF_Y_MODE_PROBS", "u8", table("kf_y_mode_probs", 10 * 10 * 9), 9) +emit("KF_UV_MODE_PROBS", "u8", table("kf_uv_mode_probs", 10 * 9), 9) +emit("DEFAULT_PARTITION_PROBS", "u8", table("default_partition_probs", 16 * 3), 3) +emit("DEFAULT_Y_MODE_PROBS", "u8", table("default_y_mode_probs", 4 * 9), 9) +emit("DEFAULT_UV_MODE_PROBS", "u8", table("default_uv_mode_probs", 10 * 9), 9) +emit("DEFAULT_SKIP_PROB", "u8", table("default_skip_prob", 3)) +emit("DEFAULT_IS_INTER_PROB", "u8", table("default_is_inter_prob", 4)) +emit("DEFAULT_COMP_MODE_PROB", "u8", table("default_comp_mode_prob", 5)) +emit("DEFAULT_COMP_REF_PROB", "u8", table("default_comp_ref_prob", 5)) +emit("DEFAULT_SINGLE_REF_PROB", "u8", table("default_single_ref_prob", 10), 2) +emit("DEFAULT_MV_SIGN_PROB", "u8", table("default_mv_sign_prob", 2)) +emit("DEFAULT_MV_BITS_PROB", "u8", table("default_mv_bits_prob", 20), 10) +emit("DEFAULT_MV_CLASS0_BIT_PROB", "u8", table("default_mv_class0_bit_prob", 2)) +emit("DEFAULT_TX_PROBS", "u8", table("default_tx_probs", 4 * 2 * 3), 3) +emit("DEFAULT_INTER_MODE_PROBS", "u8", table("default_inter_mode_probs", 7 * 3), 3) +emit("DEFAULT_INTERP_FILTER_PROBS", "u8", table("default_interp_filter_probs", 8), 2) +emit("DEFAULT_MV_JOINT_PROBS", "u8", table("default_mv_joint_probs", 3)) +emit("DEFAULT_MV_CLASS_PROBS", "u8", table("default_mv_class_probs", 20), 10) +emit("DEFAULT_MV_CLASS0_FR_PROBS", "u8", table("default_mv_class0_fr_probs", 12), 3) +emit("DEFAULT_MV_CLASS0_HP_PROB", "u8", table("default_mv_class0_hp_prob", 2)) +emit("DEFAULT_MV_FR_PROBS", "u8", table("default_mv_fr_probs", 6), 3) +emit("DEFAULT_MV_HP_PROB", "u8", table("default_mv_hp_prob", 2)) +emit("DEFAULT_COEF_PROBS", "u8", table("default_coef_probs", 4 * 2 * 2 * 6 * 6 * 3), 3) diff --git a/crates/vp9/tools/gen_transforms.py b/crates/vp9/tools/gen_transforms.py new file mode 100644 index 000000000..ab62ce3e4 --- /dev/null +++ b/crates/vp9/tools/gen_transforms.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Generate straight-line 1D inverse transforms from the butterfly descriptions of the VP9 +specification (v0.6 section 8.7.1): inverse DCT (8.7.1.2 / 8.7.1.3, n = 2..5) and inverse ADST +(8.7.1.4 - 8.7.1.8, n = 3, 4). ADST4 and WHT are small enough to be written by hand in +src/transform.rs. + +The generator symbolically executes the ordered steps of the specification on an array T (and the +higher precision array S of the ADST) and emits one `let` per written element, with the cos64 / +sin64 constants folded in. + +Usage: python3 gen_transforms.py > ../src/transform_gen.rs +""" + +COS64 = [16384, 16364, 16305, 16207, 16069, 15893, 15679, 15426, 15137, 14811, 14449, 14053, 13623, + 13160, 12665, 12140, 11585, 11003, 10394, 9760, 9102, 8423, 7723, 7005, 6270, 5520, 4756, + 3981, 3196, 2404, 1606, 804, 0] + + +def cos64(angle): + a = angle & 127 + if a <= 32: + return COS64[a] + if a <= 64: + return -COS64[64 - a] + if a <= 96: + return -COS64[a - 64] + return COS64[128 - a] + + +def sin64(angle): + return cos64(angle - 32) + + +def brev(bits, x): + t = 0 + for i in range(bits): + t |= ((x >> i) & 1) << (bits - 1 - i) + return t + + +class Gen: + def __init__(self, n): + self.n0 = 1 << n + self.T = ["x[%d]" % i for i in range(self.n0)] + self.S = [None] * self.n0 + self.lines = [] + self.k = 0 + + def new(self, expr): + name = "v%d" % self.k + self.k += 1 + self.lines.append(" let %s = %s;" % (name, expr)) + return name + + @staticmethod + def lin(terms): + parts = [] + for var, c in terms: + if c == 0: + continue + if c == 1: + parts.append(("+", var)) + elif c == -1: + parts.append(("-", var)) + elif c < 0: + parts.append(("-", "%s * %d" % (var, -c))) + else: + parts.append(("+", "%s * %d" % (var, c))) + if not parts: + return "0" + s = parts[0][1] if parts[0][0] == "+" else "-" + parts[0][1] + for sign, p in parts[1:]: + s += " %s %s" % (sign, p) + return s + + def B(self, a, b, angle, flip): + ta, tb = self.T[a], self.T[b] + c, s = cos64(angle), sin64(angle) + x = self.new("r14(%s)" % self.lin([(ta, c), (tb, -s)])) + y = self.new("r14(%s)" % self.lin([(ta, s), (tb, c)])) + self.T[a], self.T[b] = (y, x) if flip else (x, y) + + def H(self, a, b, flip): + if flip: + a, b = b, a + x, y = self.T[a], self.T[b] + self.T[a] = self.new("%s + %s" % (x, y)) + self.T[b] = self.new("%s - %s" % (x, y)) + + def SB(self, a, b, angle, flip): + ta, tb = self.T[a], self.T[b] + c, s = cos64(angle), sin64(angle) + x = self.new(self.lin([(ta, c), (tb, -s)])) + y = self.new(self.lin([(ta, s), (tb, c)])) + self.S[a], self.S[b] = (y, x) if flip else (x, y) + + def SH(self, a, b): + sa, sb = self.S[a], self.S[b] + self.T[a] = self.new("r14(%s + %s)" % (sa, sb)) + self.T[b] = self.new("r14(%s - %s)" % (sa, sb)) + + +def idct_steps(g, n): + n0, n1, n2, n3 = 1 << n, 1 << (n - 1), 1 << (n - 2), (1 << (n - 3)) if n >= 3 else 0 + if n == 2: + g.B(0, 1, 16, 1) + else: + idct_steps(g, n - 1) + for i in range(n2): + g.B(n1 + i, n0 - 1 - i, 32 - brev(5, n1 + i), 0) + if n >= 3: + for i in range(n3): + for j in range(2): + g.H(n1 + 4 * i + 2 * j, n1 + 1 + 4 * i + 2 * j, j) + if n == 5: + for i in range(2): + for j in range(2): + g.B(n0 - n + 3 - n2 * j - 4 * i, n1 + n - 4 + n2 * j + 4 * i, 28 - 16 * i + 56 * j, 1) + for i in range(2): + for j in range(4): + g.H(n1 + n3 * j + i, n1 + n2 - 5 + n3 * j - i, j & 1) + if n >= 4: + for i in range(2 if n == 5 else 1): + for j in range(2): + g.B(n0 - n + 2 - i - n2 * j, n1 + n - 3 + i + n2 * j, 24 + 48 * j, 1) + for i in range(2 * n - 6): + for j in range(2): + g.H(n1 + n2 * j + i, n1 + n2 - 1 + n2 * j - i, j & 1) + if n >= 3: + for i in range(n3): + g.B(n0 - n3 - 1 - i, n1 + n3 + i, 16, 1) + for i in range(n1): + g.H(i, n0 - 1 - i, 0) + + +def idct(n): + g = Gen(n) + n0 = 1 << n + g.T = ["x[%d]" % brev(n, i) for i in range(n0)] # 8.7.1.2 permutation + idct_steps(g, n) + return g + + +def adst_in_perm(g, n): + n0, n1 = 1 << n, 1 << (n - 1) + c = list(g.T) + for i in range(n1): + g.T[2 * i] = c[n0 - 1 - 2 * i] + g.T[2 * i + 1] = c[2 * i] + + +def adst_out_perm(g, n): + c = list(g.T) + if n == 4: + for a in range(2): + for b in range(2): + for cc in range(2): + for d in range(2): + g.T[8 * a + 4 * b + 2 * cc + d] = c[8 * (d ^ cc) + 4 * (cc ^ b) + 2 * (b ^ a) + a] + else: + for a in range(2): + for b in range(2): + for cc in range(2): + g.T[4 * a + 2 * b + cc] = c[4 * (cc ^ b) + 2 * (b ^ a) + a] + + +def neg(g, i): + g.T[i] = g.new("-%s" % g.T[i]) + + +def adst8(): + g = Gen(3) + adst_in_perm(g, 3) + for i in range(4): + g.SB(2 * i, 1 + 2 * i, 30 - 8 * i, 1) + for i in range(4): + g.SH(i, 4 + i) + for i in range(2): + g.SB(4 + 3 * i, 5 + i, 24 - 16 * i, 1) + for i in range(2): + g.SH(4 + i, 6 + i) + for i in range(2): + g.H(i, 2 + i, 0) + for i in range(2): + g.B(2 + 4 * i, 3 + 4 * i, 16, 1) + adst_out_perm(g, 3) + for i in range(4): + neg(g, 1 + 2 * i) + return g + + +def adst16(): + g = Gen(4) + adst_in_perm(g, 4) + for i in range(8): + g.SB(2 * i, 1 + 2 * i, 31 - 4 * i, 1) + for i in range(8): + g.SH(i, 8 + i) + for i in range(4): + g.SB(8 + 2 * i, 9 + 2 * i, 28 - 16 * i, 1) + for i in range(4): + g.SH(8 + i, 12 + i) + for i in range(4): + g.H(i, 4 + i, 0) + for i in range(2): + for j in range(2): + g.SB(4 + 8 * i + 3 * j, 5 + 8 * i + j, 24 - 16 * j, 1) + for i in range(2): + for j in range(2): + g.SH(4 + 8 * j + i, 6 + 8 * j + i) + for i in range(2): + for j in range(2): + g.H(8 * j + i, 2 + 8 * j + i, 0) + for i in range(2): + for j in range(2): + g.B(2 + 4 * j + 8 * i, 3 + 4 * j + 8 * i, 48 + 64 * (i ^ j), 0) + adst_out_perm(g, 4) + for i in range(2): + for j in range(2): + neg(g, 1 + 12 * j + 2 * i) + return g + + +def emit(name, g): + n0 = g.n0 + print(" #[inline(always)]") + print(" pub fn %s(x: [$t; %d]) -> [$t; %d] {" % (name, n0, n0)) + for l in g.lines: + print(" " + l) + print(" [%s]" % ", ".join(g.T)) + print(" }") + print() + + +print("// Generated by tools/gen_transforms.py from the butterfly descriptions of the VP9 specification") +print("// v0.6 section 8.7.1. Do not edit by hand.") +print() +print("macro_rules! transforms_1d {") +print(" ($t:ty) => {") +print(" #[inline(always)]") +print(" fn r14(x: $t) -> $t {") +print(" (x + (1 << 13)) >> 14") +print(" }") +print() +for n in range(2, 6): + emit("idct%d" % (1 << n), idct(n)) +emit("iadst8", adst8()) +emit("iadst16", adst16()) +print(" };") +print("}") +print() +print("pub(crate) mod narrow {") +print(" transforms_1d!(i32);") +print("}") +print() +print("pub(crate) mod wide {") +print(" transforms_1d!(i64);") +print("}") diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 9adee93f5..faf06646a 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -21,6 +21,7 @@ const LAYERS: &[(&str, u8)] = &[ ("h264", 0), ("h264enc", 0), ("hevc", 0), + ("vp9", 0), ("prores", 0), ("mjpeg", 0), ("dnx", 0), From 804eb14fa9e4518f94ba3da5ee200fcbfa02fe2e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 22:37:46 -0400 Subject: [PATCH 02/24] M9.8: VP9 fixture matrix with backward adaptation / alt-ref / tile rows, synthetic scaled-reference, intra-only and show_existing streams, robustness tests Co-Authored-By: Claude Opus 5.5 --- crates/vp9/examples/vp9dec.rs | 38 ++ crates/vp9/src/boolcoder.rs | 10 +- crates/vp9/src/tile.rs | 3 - crates/vp9/src/transform.rs | 44 +- crates/vp9/src/transform_gen.rs | 862 ++++++++++++++--------------- crates/vp9/tests/common/mod.rs | 154 ++++-- crates/vp9/tests/conformance.rs | 66 +-- crates/vp9/tests/robustness.rs | 106 ++++ crates/vp9/tests/synthetic.rs | 357 ++++++++++++ crates/vp9/tools/gen_transforms.py | 33 +- 10 files changed, 1098 insertions(+), 575 deletions(-) create mode 100644 crates/vp9/examples/vp9dec.rs create mode 100644 crates/vp9/tests/robustness.rs create mode 100644 crates/vp9/tests/synthetic.rs diff --git a/crates/vp9/examples/vp9dec.rs b/crates/vp9/examples/vp9dec.rs new file mode 100644 index 000000000..ea4a7af56 --- /dev/null +++ b/crates/vp9/examples/vp9dec.rs @@ -0,0 +1,38 @@ +//! Decode an IVF file: `cargo run --release -p filmcraft-vp9 --example vp9dec -- in.ivf [out.yuv]`. +//! Writes raw planar output (8-bit or 16-bit little endian) and prints decoder statistics. + +use std::io::Write; + +fn main() { + let args: Vec = std::env::args().collect(); + let data = std::fs::read(&args[1]).expect("read input"); + let mut out = args.get(2).map(|p| std::io::BufWriter::new(std::fs::File::create(p).expect("create output"))); + let mut dec = filmcraft_vp9::Decoder::new(); + let hl = u16::from_le_bytes([data[6], data[7]]) as usize; + let mut p = hl; + let mut i = 0i64; + let t0 = std::time::Instant::now(); + let mut n = 0; + while p + 12 <= data.len() { + let sz = u32::from_le_bytes(data[p..p + 4].try_into().unwrap()) as usize; + p += 12; + let Some(frame) = data.get(p..p + sz) else { break }; + p += sz; + match dec.decode(frame, i) { + Ok(pics) => { + for pic in pics { + n += 1; + if let Some(o) = out.as_mut() { + for pl in [&pic.y, &pic.u, &pic.v] { + o.write_all(&pl.to_le_bytes()).unwrap(); + } + } + } + } + Err(e) => eprintln!("chunk {i}: {e}"), + } + i += 1; + } + eprintln!("{n} pictures in {:.3}s", t0.elapsed().as_secs_f64()); + eprintln!("{:#?}", dec.stats()); +} diff --git a/crates/vp9/src/boolcoder.rs b/crates/vp9/src/boolcoder.rs index 7980f2940..da8cb5967 100644 --- a/crates/vp9/src/boolcoder.rs +++ b/crates/vp9/src/boolcoder.rs @@ -13,15 +13,13 @@ pub struct BoolDecoder<'a> { value: u64, count: i32, range: u32, - /// Number of zero bytes appended after the end of the data (detects runaway reads). - overrun: u32, } impl<'a> BoolDecoder<'a> { /// init_bool (9.2.1): the marker bit is read and returned in `Err` position when non-zero is /// not treated as fatal (libvpx ignores it too), so this only returns the decoder. pub fn new(data: &'a [u8]) -> Self { - let mut d = BoolDecoder { data, pos: 0, value: 0, count: -8, range: 255, overrun: 0 }; + let mut d = BoolDecoder { data, pos: 0, value: 0, count: -8, range: 255 }; d.fill(); d.read_bool(128); d @@ -49,7 +47,6 @@ impl<'a> BoolDecoder<'a> { self.pos += 1; b } else { - self.overrun = self.overrun.saturating_add(1); 0 }; self.value |= (b as u64) << shift; @@ -101,11 +98,6 @@ impl<'a> BoolDecoder<'a> { } } } - - /// True when the decoder has consumed far more bits than the data holds (a damaged stream). - pub fn overrun(&self) -> bool { - self.overrun > 16 - } } #[cfg(test)] diff --git a/crates/vp9/src/tile.rs b/crates/vp9/src/tile.rs index 87a25ec14..bf117672a 100644 --- a/crates/vp9/src/tile.rs +++ b/crates/vp9/src/tile.rs @@ -197,9 +197,6 @@ impl<'a> TileDecoder<'a> { for c in (cs..ce).step_by(8) { self.decode_partition(&mut bd, r, c, BLOCK_64X64); } - if bd.overrun() { - return false; - } } true } diff --git a/crates/vp9/src/transform.rs b/crates/vp9/src/transform.rs index 83d590066..3792a81b6 100644 --- a/crates/vp9/src/transform.rs +++ b/crates/vp9/src/transform.rs @@ -15,21 +15,21 @@ macro_rules! adst4 { #[inline(always)] fn $name(x: [$t; 4]) -> [$t; 4] { let (s1_9, s2_9, s3_9, s4_9) = (SINPI_1_9 as $t, SINPI_2_9 as $t, SINPI_3_9 as $t, SINPI_4_9 as $t); - let s0 = s1_9 * x[0]; - let s1 = s2_9 * x[0]; - let s2 = s3_9 * x[1]; - let s3 = s4_9 * x[2]; - let s4 = s1_9 * x[2]; - let s5 = s2_9 * x[3]; - let s6 = s4_9 * x[3]; - let v = x[0] - x[2] + x[3]; - let s7 = s3_9 * v; - let x0 = s0 + s3 + s5; - let x1 = s1 - s4 - s6; + let s0 = s1_9.wrapping_mul(x[0]); + let s1 = s2_9.wrapping_mul(x[0]); + let s2 = s3_9.wrapping_mul(x[1]); + let s3 = s4_9.wrapping_mul(x[2]); + let s4 = s1_9.wrapping_mul(x[2]); + let s5 = s2_9.wrapping_mul(x[3]); + let s6 = s4_9.wrapping_mul(x[3]); + let v = x[0].wrapping_sub(x[2]).wrapping_add(x[3]); + let s7 = s3_9.wrapping_mul(v); + let x0 = s0.wrapping_add(s3).wrapping_add(s5); + let x1 = s1.wrapping_sub(s4).wrapping_sub(s6); let x2 = s7; let x3 = s2; - let r = |v: $t| (v + (1 << 13)) >> 14; - [r(x0 + x3), r(x1 + x3), r(x2), r(x0 + x1 - x3)] + let r = |v: $t| v.wrapping_add(1 << 13) >> 14; + [r(x0.wrapping_add(x3)), r(x1.wrapping_add(x3)), r(x2), r(x0.wrapping_add(x1).wrapping_sub(x3))] } }; } @@ -42,13 +42,13 @@ fn iwht4(x: [i32; 4], shift: u32) -> [i32; 4] { let mut c = x[1] >> shift; let mut d = x[2] >> shift; let mut b = x[3] >> shift; - a += c; - d -= b; - let e = (a - d) >> 1; - b = e - b; - c = e - c; - a -= b; - d += c; + a = a.wrapping_add(c); + d = d.wrapping_sub(b); + let e = a.wrapping_sub(d) >> 1; + b = e.wrapping_sub(b); + c = e.wrapping_sub(c); + a = a.wrapping_sub(b); + d = d.wrapping_add(c); [a, b, c, d] } @@ -144,7 +144,7 @@ pub fn inverse_transform_add(coefs: &[i32], tx_size: u8, tx_type: u8, lossless: let r = iwht4([t[j], t[4 + j], t[8 + j], t[12 + j]], 0); for i in 0..4 { let p = &mut dst[i * stride + j]; - *p = (*p as i32 + r[i]).clamp(0, max) as u16; + *p = (*p as i32).saturating_add(r[i]).clamp(0, max) as u16; } } return; @@ -188,7 +188,7 @@ pub fn inverse_transform_add(coefs: &[i32], tx_size: u8, tx_type: u8, lossless: tx1d_narrow(&mut col[..n], n, col_adst); for i in 0..n { let p = &mut dst[i * stride + j]; - *p = (*p as i32 + ((col[i] + round) >> shift)).clamp(0, max) as u16; + *p = (*p as i32 + (col[i].wrapping_add(round) >> shift).clamp(-(1 << 20), 1 << 20)).clamp(0, max) as u16; } } } else { diff --git a/crates/vp9/src/transform_gen.rs b/crates/vp9/src/transform_gen.rs index 34a7e719d..6d2136b48 100644 --- a/crates/vp9/src/transform_gen.rs +++ b/crates/vp9/src/transform_gen.rs @@ -5,466 +5,466 @@ macro_rules! transforms_1d { ($t:ty) => { #[inline(always)] fn r14(x: $t) -> $t { - (x + (1 << 13)) >> 14 + x.wrapping_add(1 << 13) >> 14 } #[inline(always)] pub fn idct4(x: [$t; 4]) -> [$t; 4] { - let v0 = r14(x[0] * 11585 - x[2] * 11585); - let v1 = r14(x[0] * 11585 + x[2] * 11585); - let v2 = r14(x[1] * 6270 - x[3] * 15137); - let v3 = r14(x[1] * 15137 + x[3] * 6270); - let v4 = v1 + v3; - let v5 = v1 - v3; - let v6 = v0 + v2; - let v7 = v0 - v2; + let v0 = r14(x[0].wrapping_mul(11585).wrapping_add(x[2].wrapping_mul(-11585))); + let v1 = r14(x[0].wrapping_mul(11585).wrapping_add(x[2].wrapping_mul(11585))); + let v2 = r14(x[1].wrapping_mul(6270).wrapping_add(x[3].wrapping_mul(-15137))); + let v3 = r14(x[1].wrapping_mul(15137).wrapping_add(x[3].wrapping_mul(6270))); + let v4 = v1.wrapping_add(v3); + let v5 = v1.wrapping_sub(v3); + let v6 = v0.wrapping_add(v2); + let v7 = v0.wrapping_sub(v2); [v4, v6, v7, v5] } #[inline(always)] pub fn idct8(x: [$t; 8]) -> [$t; 8] { - let v0 = r14(x[0] * 11585 - x[4] * 11585); - let v1 = r14(x[0] * 11585 + x[4] * 11585); - let v2 = r14(x[2] * 6270 - x[6] * 15137); - let v3 = r14(x[2] * 15137 + x[6] * 6270); - let v4 = v1 + v3; - let v5 = v1 - v3; - let v6 = v0 + v2; - let v7 = v0 - v2; - let v8 = r14(x[1] * 3196 - x[7] * 16069); - let v9 = r14(x[1] * 16069 + x[7] * 3196); - let v10 = r14(x[5] * 13623 - x[3] * 9102); - let v11 = r14(x[5] * 9102 + x[3] * 13623); - let v12 = v8 + v10; - let v13 = v8 - v10; - let v14 = v9 + v11; - let v15 = v9 - v11; - let v16 = r14(v15 * 11585 - v13 * 11585); - let v17 = r14(v15 * 11585 + v13 * 11585); - let v18 = v4 + v14; - let v19 = v4 - v14; - let v20 = v6 + v17; - let v21 = v6 - v17; - let v22 = v7 + v16; - let v23 = v7 - v16; - let v24 = v5 + v12; - let v25 = v5 - v12; + let v0 = r14(x[0].wrapping_mul(11585).wrapping_add(x[4].wrapping_mul(-11585))); + let v1 = r14(x[0].wrapping_mul(11585).wrapping_add(x[4].wrapping_mul(11585))); + let v2 = r14(x[2].wrapping_mul(6270).wrapping_add(x[6].wrapping_mul(-15137))); + let v3 = r14(x[2].wrapping_mul(15137).wrapping_add(x[6].wrapping_mul(6270))); + let v4 = v1.wrapping_add(v3); + let v5 = v1.wrapping_sub(v3); + let v6 = v0.wrapping_add(v2); + let v7 = v0.wrapping_sub(v2); + let v8 = r14(x[1].wrapping_mul(3196).wrapping_add(x[7].wrapping_mul(-16069))); + let v9 = r14(x[1].wrapping_mul(16069).wrapping_add(x[7].wrapping_mul(3196))); + let v10 = r14(x[5].wrapping_mul(13623).wrapping_add(x[3].wrapping_mul(-9102))); + let v11 = r14(x[5].wrapping_mul(9102).wrapping_add(x[3].wrapping_mul(13623))); + let v12 = v8.wrapping_add(v10); + let v13 = v8.wrapping_sub(v10); + let v14 = v9.wrapping_add(v11); + let v15 = v9.wrapping_sub(v11); + let v16 = r14(v15.wrapping_mul(11585).wrapping_add(v13.wrapping_mul(-11585))); + let v17 = r14(v15.wrapping_mul(11585).wrapping_add(v13.wrapping_mul(11585))); + let v18 = v4.wrapping_add(v14); + let v19 = v4.wrapping_sub(v14); + let v20 = v6.wrapping_add(v17); + let v21 = v6.wrapping_sub(v17); + let v22 = v7.wrapping_add(v16); + let v23 = v7.wrapping_sub(v16); + let v24 = v5.wrapping_add(v12); + let v25 = v5.wrapping_sub(v12); [v18, v20, v22, v24, v25, v23, v21, v19] } #[inline(always)] pub fn idct16(x: [$t; 16]) -> [$t; 16] { - let v0 = r14(x[0] * 11585 - x[8] * 11585); - let v1 = r14(x[0] * 11585 + x[8] * 11585); - let v2 = r14(x[4] * 6270 - x[12] * 15137); - let v3 = r14(x[4] * 15137 + x[12] * 6270); - let v4 = v1 + v3; - let v5 = v1 - v3; - let v6 = v0 + v2; - let v7 = v0 - v2; - let v8 = r14(x[2] * 3196 - x[14] * 16069); - let v9 = r14(x[2] * 16069 + x[14] * 3196); - let v10 = r14(x[10] * 13623 - x[6] * 9102); - let v11 = r14(x[10] * 9102 + x[6] * 13623); - let v12 = v8 + v10; - let v13 = v8 - v10; - let v14 = v9 + v11; - let v15 = v9 - v11; - let v16 = r14(v15 * 11585 - v13 * 11585); - let v17 = r14(v15 * 11585 + v13 * 11585); - let v18 = v4 + v14; - let v19 = v4 - v14; - let v20 = v6 + v17; - let v21 = v6 - v17; - let v22 = v7 + v16; - let v23 = v7 - v16; - let v24 = v5 + v12; - let v25 = v5 - v12; - let v26 = r14(x[1] * 1606 - x[15] * 16305); - let v27 = r14(x[1] * 16305 + x[15] * 1606); - let v28 = r14(x[9] * 12665 - x[7] * 10394); - let v29 = r14(x[9] * 10394 + x[7] * 12665); - let v30 = r14(x[5] * 7723 - x[11] * 14449); - let v31 = r14(x[5] * 14449 + x[11] * 7723); - let v32 = r14(x[13] * 15679 - x[3] * 4756); - let v33 = r14(x[13] * 4756 + x[3] * 15679); - let v34 = v26 + v28; - let v35 = v26 - v28; - let v36 = v32 + v30; - let v37 = v32 - v30; - let v38 = v33 + v31; - let v39 = v33 - v31; - let v40 = v27 + v29; - let v41 = v27 - v29; - let v42 = r14(v41 * 6270 - v35 * 15137); - let v43 = r14(v41 * 15137 + v35 * 6270); - let v44 = r14(-v37 * 15137 + v39 * 6270); - let v45 = r14(-v37 * 6270 - v39 * 15137); - let v46 = v34 + v36; - let v47 = v34 - v36; - let v48 = v40 + v38; - let v49 = v40 - v38; - let v50 = v42 + v45; - let v51 = v42 - v45; - let v52 = v43 + v44; - let v53 = v43 - v44; - let v54 = r14(v53 * 11585 - v51 * 11585); - let v55 = r14(v53 * 11585 + v51 * 11585); - let v56 = r14(v49 * 11585 - v47 * 11585); - let v57 = r14(v49 * 11585 + v47 * 11585); - let v58 = v18 + v48; - let v59 = v18 - v48; - let v60 = v20 + v52; - let v61 = v20 - v52; - let v62 = v22 + v55; - let v63 = v22 - v55; - let v64 = v24 + v57; - let v65 = v24 - v57; - let v66 = v25 + v56; - let v67 = v25 - v56; - let v68 = v23 + v54; - let v69 = v23 - v54; - let v70 = v21 + v50; - let v71 = v21 - v50; - let v72 = v19 + v46; - let v73 = v19 - v46; + let v0 = r14(x[0].wrapping_mul(11585).wrapping_add(x[8].wrapping_mul(-11585))); + let v1 = r14(x[0].wrapping_mul(11585).wrapping_add(x[8].wrapping_mul(11585))); + let v2 = r14(x[4].wrapping_mul(6270).wrapping_add(x[12].wrapping_mul(-15137))); + let v3 = r14(x[4].wrapping_mul(15137).wrapping_add(x[12].wrapping_mul(6270))); + let v4 = v1.wrapping_add(v3); + let v5 = v1.wrapping_sub(v3); + let v6 = v0.wrapping_add(v2); + let v7 = v0.wrapping_sub(v2); + let v8 = r14(x[2].wrapping_mul(3196).wrapping_add(x[14].wrapping_mul(-16069))); + let v9 = r14(x[2].wrapping_mul(16069).wrapping_add(x[14].wrapping_mul(3196))); + let v10 = r14(x[10].wrapping_mul(13623).wrapping_add(x[6].wrapping_mul(-9102))); + let v11 = r14(x[10].wrapping_mul(9102).wrapping_add(x[6].wrapping_mul(13623))); + let v12 = v8.wrapping_add(v10); + let v13 = v8.wrapping_sub(v10); + let v14 = v9.wrapping_add(v11); + let v15 = v9.wrapping_sub(v11); + let v16 = r14(v15.wrapping_mul(11585).wrapping_add(v13.wrapping_mul(-11585))); + let v17 = r14(v15.wrapping_mul(11585).wrapping_add(v13.wrapping_mul(11585))); + let v18 = v4.wrapping_add(v14); + let v19 = v4.wrapping_sub(v14); + let v20 = v6.wrapping_add(v17); + let v21 = v6.wrapping_sub(v17); + let v22 = v7.wrapping_add(v16); + let v23 = v7.wrapping_sub(v16); + let v24 = v5.wrapping_add(v12); + let v25 = v5.wrapping_sub(v12); + let v26 = r14(x[1].wrapping_mul(1606).wrapping_add(x[15].wrapping_mul(-16305))); + let v27 = r14(x[1].wrapping_mul(16305).wrapping_add(x[15].wrapping_mul(1606))); + let v28 = r14(x[9].wrapping_mul(12665).wrapping_add(x[7].wrapping_mul(-10394))); + let v29 = r14(x[9].wrapping_mul(10394).wrapping_add(x[7].wrapping_mul(12665))); + let v30 = r14(x[5].wrapping_mul(7723).wrapping_add(x[11].wrapping_mul(-14449))); + let v31 = r14(x[5].wrapping_mul(14449).wrapping_add(x[11].wrapping_mul(7723))); + let v32 = r14(x[13].wrapping_mul(15679).wrapping_add(x[3].wrapping_mul(-4756))); + let v33 = r14(x[13].wrapping_mul(4756).wrapping_add(x[3].wrapping_mul(15679))); + let v34 = v26.wrapping_add(v28); + let v35 = v26.wrapping_sub(v28); + let v36 = v32.wrapping_add(v30); + let v37 = v32.wrapping_sub(v30); + let v38 = v33.wrapping_add(v31); + let v39 = v33.wrapping_sub(v31); + let v40 = v27.wrapping_add(v29); + let v41 = v27.wrapping_sub(v29); + let v42 = r14(v41.wrapping_mul(6270).wrapping_add(v35.wrapping_mul(-15137))); + let v43 = r14(v41.wrapping_mul(15137).wrapping_add(v35.wrapping_mul(6270))); + let v44 = r14(v37.wrapping_mul(-15137).wrapping_add(v39.wrapping_mul(6270))); + let v45 = r14(v37.wrapping_mul(-6270).wrapping_add(v39.wrapping_mul(-15137))); + let v46 = v34.wrapping_add(v36); + let v47 = v34.wrapping_sub(v36); + let v48 = v40.wrapping_add(v38); + let v49 = v40.wrapping_sub(v38); + let v50 = v42.wrapping_add(v45); + let v51 = v42.wrapping_sub(v45); + let v52 = v43.wrapping_add(v44); + let v53 = v43.wrapping_sub(v44); + let v54 = r14(v53.wrapping_mul(11585).wrapping_add(v51.wrapping_mul(-11585))); + let v55 = r14(v53.wrapping_mul(11585).wrapping_add(v51.wrapping_mul(11585))); + let v56 = r14(v49.wrapping_mul(11585).wrapping_add(v47.wrapping_mul(-11585))); + let v57 = r14(v49.wrapping_mul(11585).wrapping_add(v47.wrapping_mul(11585))); + let v58 = v18.wrapping_add(v48); + let v59 = v18.wrapping_sub(v48); + let v60 = v20.wrapping_add(v52); + let v61 = v20.wrapping_sub(v52); + let v62 = v22.wrapping_add(v55); + let v63 = v22.wrapping_sub(v55); + let v64 = v24.wrapping_add(v57); + let v65 = v24.wrapping_sub(v57); + let v66 = v25.wrapping_add(v56); + let v67 = v25.wrapping_sub(v56); + let v68 = v23.wrapping_add(v54); + let v69 = v23.wrapping_sub(v54); + let v70 = v21.wrapping_add(v50); + let v71 = v21.wrapping_sub(v50); + let v72 = v19.wrapping_add(v46); + let v73 = v19.wrapping_sub(v46); [v58, v60, v62, v64, v66, v68, v70, v72, v73, v71, v69, v67, v65, v63, v61, v59] } #[inline(always)] pub fn idct32(x: [$t; 32]) -> [$t; 32] { - let v0 = r14(x[0] * 11585 - x[16] * 11585); - let v1 = r14(x[0] * 11585 + x[16] * 11585); - let v2 = r14(x[8] * 6270 - x[24] * 15137); - let v3 = r14(x[8] * 15137 + x[24] * 6270); - let v4 = v1 + v3; - let v5 = v1 - v3; - let v6 = v0 + v2; - let v7 = v0 - v2; - let v8 = r14(x[4] * 3196 - x[28] * 16069); - let v9 = r14(x[4] * 16069 + x[28] * 3196); - let v10 = r14(x[20] * 13623 - x[12] * 9102); - let v11 = r14(x[20] * 9102 + x[12] * 13623); - let v12 = v8 + v10; - let v13 = v8 - v10; - let v14 = v9 + v11; - let v15 = v9 - v11; - let v16 = r14(v15 * 11585 - v13 * 11585); - let v17 = r14(v15 * 11585 + v13 * 11585); - let v18 = v4 + v14; - let v19 = v4 - v14; - let v20 = v6 + v17; - let v21 = v6 - v17; - let v22 = v7 + v16; - let v23 = v7 - v16; - let v24 = v5 + v12; - let v25 = v5 - v12; - let v26 = r14(x[2] * 1606 - x[30] * 16305); - let v27 = r14(x[2] * 16305 + x[30] * 1606); - let v28 = r14(x[18] * 12665 - x[14] * 10394); - let v29 = r14(x[18] * 10394 + x[14] * 12665); - let v30 = r14(x[10] * 7723 - x[22] * 14449); - let v31 = r14(x[10] * 14449 + x[22] * 7723); - let v32 = r14(x[26] * 15679 - x[6] * 4756); - let v33 = r14(x[26] * 4756 + x[6] * 15679); - let v34 = v26 + v28; - let v35 = v26 - v28; - let v36 = v32 + v30; - let v37 = v32 - v30; - let v38 = v33 + v31; - let v39 = v33 - v31; - let v40 = v27 + v29; - let v41 = v27 - v29; - let v42 = r14(v41 * 6270 - v35 * 15137); - let v43 = r14(v41 * 15137 + v35 * 6270); - let v44 = r14(-v37 * 15137 + v39 * 6270); - let v45 = r14(-v37 * 6270 - v39 * 15137); - let v46 = v34 + v36; - let v47 = v34 - v36; - let v48 = v40 + v38; - let v49 = v40 - v38; - let v50 = v42 + v45; - let v51 = v42 - v45; - let v52 = v43 + v44; - let v53 = v43 - v44; - let v54 = r14(v53 * 11585 - v51 * 11585); - let v55 = r14(v53 * 11585 + v51 * 11585); - let v56 = r14(v49 * 11585 - v47 * 11585); - let v57 = r14(v49 * 11585 + v47 * 11585); - let v58 = v18 + v48; - let v59 = v18 - v48; - let v60 = v20 + v52; - let v61 = v20 - v52; - let v62 = v22 + v55; - let v63 = v22 - v55; - let v64 = v24 + v57; - let v65 = v24 - v57; - let v66 = v25 + v56; - let v67 = v25 - v56; - let v68 = v23 + v54; - let v69 = v23 - v54; - let v70 = v21 + v50; - let v71 = v21 - v50; - let v72 = v19 + v46; - let v73 = v19 - v46; - let v74 = r14(x[1] * 804 - x[31] * 16364); - let v75 = r14(x[1] * 16364 + x[31] * 804); - let v76 = r14(x[17] * 12140 - x[15] * 11003); - let v77 = r14(x[17] * 11003 + x[15] * 12140); - let v78 = r14(x[9] * 7005 - x[23] * 14811); - let v79 = r14(x[9] * 14811 + x[23] * 7005); - let v80 = r14(x[25] * 15426 - x[7] * 5520); - let v81 = r14(x[25] * 5520 + x[7] * 15426); - let v82 = r14(x[5] * 3981 - x[27] * 15893); - let v83 = r14(x[5] * 15893 + x[27] * 3981); - let v84 = r14(x[21] * 14053 - x[11] * 8423); - let v85 = r14(x[21] * 8423 + x[11] * 14053); - let v86 = r14(x[13] * 9760 - x[19] * 13160); - let v87 = r14(x[13] * 13160 + x[19] * 9760); - let v88 = r14(x[29] * 16207 - x[3] * 2404); - let v89 = r14(x[29] * 2404 + x[3] * 16207); - let v90 = v74 + v76; - let v91 = v74 - v76; - let v92 = v80 + v78; - let v93 = v80 - v78; - let v94 = v82 + v84; - let v95 = v82 - v84; - let v96 = v88 + v86; - let v97 = v88 - v86; - let v98 = v89 + v87; - let v99 = v89 - v87; - let v100 = v83 + v85; - let v101 = v83 - v85; - let v102 = v81 + v79; - let v103 = v81 - v79; - let v104 = v75 + v77; - let v105 = v75 - v77; - let v106 = r14(v105 * 3196 - v91 * 16069); - let v107 = r14(v105 * 16069 + v91 * 3196); - let v108 = r14(-v97 * 9102 + v99 * 13623); - let v109 = r14(-v97 * 13623 - v99 * 9102); - let v110 = r14(v101 * 13623 - v95 * 9102); - let v111 = r14(v101 * 9102 + v95 * 13623); - let v112 = r14(-v93 * 16069 + v103 * 3196); - let v113 = r14(-v93 * 3196 - v103 * 16069); - let v114 = v90 + v92; - let v115 = v90 - v92; - let v116 = v96 + v94; - let v117 = v96 - v94; - let v118 = v98 + v100; - let v119 = v98 - v100; - let v120 = v104 + v102; - let v121 = v104 - v102; - let v122 = v106 + v113; - let v123 = v106 - v113; - let v124 = v109 + v110; - let v125 = v109 - v110; - let v126 = v108 + v111; - let v127 = v108 - v111; - let v128 = v107 + v112; - let v129 = v107 - v112; - let v130 = r14(v129 * 6270 - v123 * 15137); - let v131 = r14(v129 * 15137 + v123 * 6270); - let v132 = r14(-v125 * 15137 + v127 * 6270); - let v133 = r14(-v125 * 6270 - v127 * 15137); - let v134 = r14(v121 * 6270 - v115 * 15137); - let v135 = r14(v121 * 15137 + v115 * 6270); - let v136 = r14(-v117 * 15137 + v119 * 6270); - let v137 = r14(-v117 * 6270 - v119 * 15137); - let v138 = v114 + v116; - let v139 = v114 - v116; - let v140 = v120 + v118; - let v141 = v120 - v118; - let v142 = v122 + v124; - let v143 = v122 - v124; - let v144 = v128 + v126; - let v145 = v128 - v126; - let v146 = v130 + v133; - let v147 = v130 - v133; - let v148 = v131 + v132; - let v149 = v131 - v132; - let v150 = v134 + v137; - let v151 = v134 - v137; - let v152 = v135 + v136; - let v153 = v135 - v136; - let v154 = r14(v153 * 11585 - v151 * 11585); - let v155 = r14(v153 * 11585 + v151 * 11585); - let v156 = r14(v149 * 11585 - v147 * 11585); - let v157 = r14(v149 * 11585 + v147 * 11585); - let v158 = r14(v145 * 11585 - v143 * 11585); - let v159 = r14(v145 * 11585 + v143 * 11585); - let v160 = r14(v141 * 11585 - v139 * 11585); - let v161 = r14(v141 * 11585 + v139 * 11585); - let v162 = v58 + v140; - let v163 = v58 - v140; - let v164 = v60 + v144; - let v165 = v60 - v144; - let v166 = v62 + v148; - let v167 = v62 - v148; - let v168 = v64 + v152; - let v169 = v64 - v152; - let v170 = v66 + v155; - let v171 = v66 - v155; - let v172 = v68 + v157; - let v173 = v68 - v157; - let v174 = v70 + v159; - let v175 = v70 - v159; - let v176 = v72 + v161; - let v177 = v72 - v161; - let v178 = v73 + v160; - let v179 = v73 - v160; - let v180 = v71 + v158; - let v181 = v71 - v158; - let v182 = v69 + v156; - let v183 = v69 - v156; - let v184 = v67 + v154; - let v185 = v67 - v154; - let v186 = v65 + v150; - let v187 = v65 - v150; - let v188 = v63 + v146; - let v189 = v63 - v146; - let v190 = v61 + v142; - let v191 = v61 - v142; - let v192 = v59 + v138; - let v193 = v59 - v138; + let v0 = r14(x[0].wrapping_mul(11585).wrapping_add(x[16].wrapping_mul(-11585))); + let v1 = r14(x[0].wrapping_mul(11585).wrapping_add(x[16].wrapping_mul(11585))); + let v2 = r14(x[8].wrapping_mul(6270).wrapping_add(x[24].wrapping_mul(-15137))); + let v3 = r14(x[8].wrapping_mul(15137).wrapping_add(x[24].wrapping_mul(6270))); + let v4 = v1.wrapping_add(v3); + let v5 = v1.wrapping_sub(v3); + let v6 = v0.wrapping_add(v2); + let v7 = v0.wrapping_sub(v2); + let v8 = r14(x[4].wrapping_mul(3196).wrapping_add(x[28].wrapping_mul(-16069))); + let v9 = r14(x[4].wrapping_mul(16069).wrapping_add(x[28].wrapping_mul(3196))); + let v10 = r14(x[20].wrapping_mul(13623).wrapping_add(x[12].wrapping_mul(-9102))); + let v11 = r14(x[20].wrapping_mul(9102).wrapping_add(x[12].wrapping_mul(13623))); + let v12 = v8.wrapping_add(v10); + let v13 = v8.wrapping_sub(v10); + let v14 = v9.wrapping_add(v11); + let v15 = v9.wrapping_sub(v11); + let v16 = r14(v15.wrapping_mul(11585).wrapping_add(v13.wrapping_mul(-11585))); + let v17 = r14(v15.wrapping_mul(11585).wrapping_add(v13.wrapping_mul(11585))); + let v18 = v4.wrapping_add(v14); + let v19 = v4.wrapping_sub(v14); + let v20 = v6.wrapping_add(v17); + let v21 = v6.wrapping_sub(v17); + let v22 = v7.wrapping_add(v16); + let v23 = v7.wrapping_sub(v16); + let v24 = v5.wrapping_add(v12); + let v25 = v5.wrapping_sub(v12); + let v26 = r14(x[2].wrapping_mul(1606).wrapping_add(x[30].wrapping_mul(-16305))); + let v27 = r14(x[2].wrapping_mul(16305).wrapping_add(x[30].wrapping_mul(1606))); + let v28 = r14(x[18].wrapping_mul(12665).wrapping_add(x[14].wrapping_mul(-10394))); + let v29 = r14(x[18].wrapping_mul(10394).wrapping_add(x[14].wrapping_mul(12665))); + let v30 = r14(x[10].wrapping_mul(7723).wrapping_add(x[22].wrapping_mul(-14449))); + let v31 = r14(x[10].wrapping_mul(14449).wrapping_add(x[22].wrapping_mul(7723))); + let v32 = r14(x[26].wrapping_mul(15679).wrapping_add(x[6].wrapping_mul(-4756))); + let v33 = r14(x[26].wrapping_mul(4756).wrapping_add(x[6].wrapping_mul(15679))); + let v34 = v26.wrapping_add(v28); + let v35 = v26.wrapping_sub(v28); + let v36 = v32.wrapping_add(v30); + let v37 = v32.wrapping_sub(v30); + let v38 = v33.wrapping_add(v31); + let v39 = v33.wrapping_sub(v31); + let v40 = v27.wrapping_add(v29); + let v41 = v27.wrapping_sub(v29); + let v42 = r14(v41.wrapping_mul(6270).wrapping_add(v35.wrapping_mul(-15137))); + let v43 = r14(v41.wrapping_mul(15137).wrapping_add(v35.wrapping_mul(6270))); + let v44 = r14(v37.wrapping_mul(-15137).wrapping_add(v39.wrapping_mul(6270))); + let v45 = r14(v37.wrapping_mul(-6270).wrapping_add(v39.wrapping_mul(-15137))); + let v46 = v34.wrapping_add(v36); + let v47 = v34.wrapping_sub(v36); + let v48 = v40.wrapping_add(v38); + let v49 = v40.wrapping_sub(v38); + let v50 = v42.wrapping_add(v45); + let v51 = v42.wrapping_sub(v45); + let v52 = v43.wrapping_add(v44); + let v53 = v43.wrapping_sub(v44); + let v54 = r14(v53.wrapping_mul(11585).wrapping_add(v51.wrapping_mul(-11585))); + let v55 = r14(v53.wrapping_mul(11585).wrapping_add(v51.wrapping_mul(11585))); + let v56 = r14(v49.wrapping_mul(11585).wrapping_add(v47.wrapping_mul(-11585))); + let v57 = r14(v49.wrapping_mul(11585).wrapping_add(v47.wrapping_mul(11585))); + let v58 = v18.wrapping_add(v48); + let v59 = v18.wrapping_sub(v48); + let v60 = v20.wrapping_add(v52); + let v61 = v20.wrapping_sub(v52); + let v62 = v22.wrapping_add(v55); + let v63 = v22.wrapping_sub(v55); + let v64 = v24.wrapping_add(v57); + let v65 = v24.wrapping_sub(v57); + let v66 = v25.wrapping_add(v56); + let v67 = v25.wrapping_sub(v56); + let v68 = v23.wrapping_add(v54); + let v69 = v23.wrapping_sub(v54); + let v70 = v21.wrapping_add(v50); + let v71 = v21.wrapping_sub(v50); + let v72 = v19.wrapping_add(v46); + let v73 = v19.wrapping_sub(v46); + let v74 = r14(x[1].wrapping_mul(804).wrapping_add(x[31].wrapping_mul(-16364))); + let v75 = r14(x[1].wrapping_mul(16364).wrapping_add(x[31].wrapping_mul(804))); + let v76 = r14(x[17].wrapping_mul(12140).wrapping_add(x[15].wrapping_mul(-11003))); + let v77 = r14(x[17].wrapping_mul(11003).wrapping_add(x[15].wrapping_mul(12140))); + let v78 = r14(x[9].wrapping_mul(7005).wrapping_add(x[23].wrapping_mul(-14811))); + let v79 = r14(x[9].wrapping_mul(14811).wrapping_add(x[23].wrapping_mul(7005))); + let v80 = r14(x[25].wrapping_mul(15426).wrapping_add(x[7].wrapping_mul(-5520))); + let v81 = r14(x[25].wrapping_mul(5520).wrapping_add(x[7].wrapping_mul(15426))); + let v82 = r14(x[5].wrapping_mul(3981).wrapping_add(x[27].wrapping_mul(-15893))); + let v83 = r14(x[5].wrapping_mul(15893).wrapping_add(x[27].wrapping_mul(3981))); + let v84 = r14(x[21].wrapping_mul(14053).wrapping_add(x[11].wrapping_mul(-8423))); + let v85 = r14(x[21].wrapping_mul(8423).wrapping_add(x[11].wrapping_mul(14053))); + let v86 = r14(x[13].wrapping_mul(9760).wrapping_add(x[19].wrapping_mul(-13160))); + let v87 = r14(x[13].wrapping_mul(13160).wrapping_add(x[19].wrapping_mul(9760))); + let v88 = r14(x[29].wrapping_mul(16207).wrapping_add(x[3].wrapping_mul(-2404))); + let v89 = r14(x[29].wrapping_mul(2404).wrapping_add(x[3].wrapping_mul(16207))); + let v90 = v74.wrapping_add(v76); + let v91 = v74.wrapping_sub(v76); + let v92 = v80.wrapping_add(v78); + let v93 = v80.wrapping_sub(v78); + let v94 = v82.wrapping_add(v84); + let v95 = v82.wrapping_sub(v84); + let v96 = v88.wrapping_add(v86); + let v97 = v88.wrapping_sub(v86); + let v98 = v89.wrapping_add(v87); + let v99 = v89.wrapping_sub(v87); + let v100 = v83.wrapping_add(v85); + let v101 = v83.wrapping_sub(v85); + let v102 = v81.wrapping_add(v79); + let v103 = v81.wrapping_sub(v79); + let v104 = v75.wrapping_add(v77); + let v105 = v75.wrapping_sub(v77); + let v106 = r14(v105.wrapping_mul(3196).wrapping_add(v91.wrapping_mul(-16069))); + let v107 = r14(v105.wrapping_mul(16069).wrapping_add(v91.wrapping_mul(3196))); + let v108 = r14(v97.wrapping_mul(-9102).wrapping_add(v99.wrapping_mul(13623))); + let v109 = r14(v97.wrapping_mul(-13623).wrapping_add(v99.wrapping_mul(-9102))); + let v110 = r14(v101.wrapping_mul(13623).wrapping_add(v95.wrapping_mul(-9102))); + let v111 = r14(v101.wrapping_mul(9102).wrapping_add(v95.wrapping_mul(13623))); + let v112 = r14(v93.wrapping_mul(-16069).wrapping_add(v103.wrapping_mul(3196))); + let v113 = r14(v93.wrapping_mul(-3196).wrapping_add(v103.wrapping_mul(-16069))); + let v114 = v90.wrapping_add(v92); + let v115 = v90.wrapping_sub(v92); + let v116 = v96.wrapping_add(v94); + let v117 = v96.wrapping_sub(v94); + let v118 = v98.wrapping_add(v100); + let v119 = v98.wrapping_sub(v100); + let v120 = v104.wrapping_add(v102); + let v121 = v104.wrapping_sub(v102); + let v122 = v106.wrapping_add(v113); + let v123 = v106.wrapping_sub(v113); + let v124 = v109.wrapping_add(v110); + let v125 = v109.wrapping_sub(v110); + let v126 = v108.wrapping_add(v111); + let v127 = v108.wrapping_sub(v111); + let v128 = v107.wrapping_add(v112); + let v129 = v107.wrapping_sub(v112); + let v130 = r14(v129.wrapping_mul(6270).wrapping_add(v123.wrapping_mul(-15137))); + let v131 = r14(v129.wrapping_mul(15137).wrapping_add(v123.wrapping_mul(6270))); + let v132 = r14(v125.wrapping_mul(-15137).wrapping_add(v127.wrapping_mul(6270))); + let v133 = r14(v125.wrapping_mul(-6270).wrapping_add(v127.wrapping_mul(-15137))); + let v134 = r14(v121.wrapping_mul(6270).wrapping_add(v115.wrapping_mul(-15137))); + let v135 = r14(v121.wrapping_mul(15137).wrapping_add(v115.wrapping_mul(6270))); + let v136 = r14(v117.wrapping_mul(-15137).wrapping_add(v119.wrapping_mul(6270))); + let v137 = r14(v117.wrapping_mul(-6270).wrapping_add(v119.wrapping_mul(-15137))); + let v138 = v114.wrapping_add(v116); + let v139 = v114.wrapping_sub(v116); + let v140 = v120.wrapping_add(v118); + let v141 = v120.wrapping_sub(v118); + let v142 = v122.wrapping_add(v124); + let v143 = v122.wrapping_sub(v124); + let v144 = v128.wrapping_add(v126); + let v145 = v128.wrapping_sub(v126); + let v146 = v130.wrapping_add(v133); + let v147 = v130.wrapping_sub(v133); + let v148 = v131.wrapping_add(v132); + let v149 = v131.wrapping_sub(v132); + let v150 = v134.wrapping_add(v137); + let v151 = v134.wrapping_sub(v137); + let v152 = v135.wrapping_add(v136); + let v153 = v135.wrapping_sub(v136); + let v154 = r14(v153.wrapping_mul(11585).wrapping_add(v151.wrapping_mul(-11585))); + let v155 = r14(v153.wrapping_mul(11585).wrapping_add(v151.wrapping_mul(11585))); + let v156 = r14(v149.wrapping_mul(11585).wrapping_add(v147.wrapping_mul(-11585))); + let v157 = r14(v149.wrapping_mul(11585).wrapping_add(v147.wrapping_mul(11585))); + let v158 = r14(v145.wrapping_mul(11585).wrapping_add(v143.wrapping_mul(-11585))); + let v159 = r14(v145.wrapping_mul(11585).wrapping_add(v143.wrapping_mul(11585))); + let v160 = r14(v141.wrapping_mul(11585).wrapping_add(v139.wrapping_mul(-11585))); + let v161 = r14(v141.wrapping_mul(11585).wrapping_add(v139.wrapping_mul(11585))); + let v162 = v58.wrapping_add(v140); + let v163 = v58.wrapping_sub(v140); + let v164 = v60.wrapping_add(v144); + let v165 = v60.wrapping_sub(v144); + let v166 = v62.wrapping_add(v148); + let v167 = v62.wrapping_sub(v148); + let v168 = v64.wrapping_add(v152); + let v169 = v64.wrapping_sub(v152); + let v170 = v66.wrapping_add(v155); + let v171 = v66.wrapping_sub(v155); + let v172 = v68.wrapping_add(v157); + let v173 = v68.wrapping_sub(v157); + let v174 = v70.wrapping_add(v159); + let v175 = v70.wrapping_sub(v159); + let v176 = v72.wrapping_add(v161); + let v177 = v72.wrapping_sub(v161); + let v178 = v73.wrapping_add(v160); + let v179 = v73.wrapping_sub(v160); + let v180 = v71.wrapping_add(v158); + let v181 = v71.wrapping_sub(v158); + let v182 = v69.wrapping_add(v156); + let v183 = v69.wrapping_sub(v156); + let v184 = v67.wrapping_add(v154); + let v185 = v67.wrapping_sub(v154); + let v186 = v65.wrapping_add(v150); + let v187 = v65.wrapping_sub(v150); + let v188 = v63.wrapping_add(v146); + let v189 = v63.wrapping_sub(v146); + let v190 = v61.wrapping_add(v142); + let v191 = v61.wrapping_sub(v142); + let v192 = v59.wrapping_add(v138); + let v193 = v59.wrapping_sub(v138); [v162, v164, v166, v168, v170, v172, v174, v176, v178, v180, v182, v184, v186, v188, v190, v192, v193, v191, v189, v187, v185, v183, v181, v179, v177, v175, v173, v171, v169, v167, v165, v163] } #[inline(always)] pub fn iadst8(x: [$t; 8]) -> [$t; 8] { - let v0 = x[7] * 1606 - x[0] * 16305; - let v1 = x[7] * 16305 + x[0] * 1606; - let v2 = x[5] * 7723 - x[2] * 14449; - let v3 = x[5] * 14449 + x[2] * 7723; - let v4 = x[3] * 12665 - x[4] * 10394; - let v5 = x[3] * 10394 + x[4] * 12665; - let v6 = x[1] * 15679 - x[6] * 4756; - let v7 = x[1] * 4756 + x[6] * 15679; - let v8 = r14(v1 + v5); - let v9 = r14(v1 - v5); - let v10 = r14(v0 + v4); - let v11 = r14(v0 - v4); - let v12 = r14(v3 + v7); - let v13 = r14(v3 - v7); - let v14 = r14(v2 + v6); - let v15 = r14(v2 - v6); - let v16 = v9 * 6270 - v11 * 15137; - let v17 = v9 * 15137 + v11 * 6270; - let v18 = v15 * 15137 - v13 * 6270; - let v19 = v15 * 6270 + v13 * 15137; - let v20 = r14(v17 + v18); - let v21 = r14(v17 - v18); - let v22 = r14(v16 + v19); - let v23 = r14(v16 - v19); - let v24 = v8 + v12; - let v25 = v8 - v12; - let v26 = v10 + v14; - let v27 = v10 - v14; - let v28 = r14(v25 * 11585 - v27 * 11585); - let v29 = r14(v25 * 11585 + v27 * 11585); - let v30 = r14(v21 * 11585 - v23 * 11585); - let v31 = r14(v21 * 11585 + v23 * 11585); - let v32 = -v20; - let v33 = -v29; - let v34 = -v30; - let v35 = -v26; + let v0 = x[7].wrapping_mul(1606).wrapping_add(x[0].wrapping_mul(-16305)); + let v1 = x[7].wrapping_mul(16305).wrapping_add(x[0].wrapping_mul(1606)); + let v2 = x[5].wrapping_mul(7723).wrapping_add(x[2].wrapping_mul(-14449)); + let v3 = x[5].wrapping_mul(14449).wrapping_add(x[2].wrapping_mul(7723)); + let v4 = x[3].wrapping_mul(12665).wrapping_add(x[4].wrapping_mul(-10394)); + let v5 = x[3].wrapping_mul(10394).wrapping_add(x[4].wrapping_mul(12665)); + let v6 = x[1].wrapping_mul(15679).wrapping_add(x[6].wrapping_mul(-4756)); + let v7 = x[1].wrapping_mul(4756).wrapping_add(x[6].wrapping_mul(15679)); + let v8 = r14(v1.wrapping_add(v5)); + let v9 = r14(v1.wrapping_sub(v5)); + let v10 = r14(v0.wrapping_add(v4)); + let v11 = r14(v0.wrapping_sub(v4)); + let v12 = r14(v3.wrapping_add(v7)); + let v13 = r14(v3.wrapping_sub(v7)); + let v14 = r14(v2.wrapping_add(v6)); + let v15 = r14(v2.wrapping_sub(v6)); + let v16 = v9.wrapping_mul(6270).wrapping_add(v11.wrapping_mul(-15137)); + let v17 = v9.wrapping_mul(15137).wrapping_add(v11.wrapping_mul(6270)); + let v18 = v15.wrapping_mul(15137).wrapping_add(v13.wrapping_mul(-6270)); + let v19 = v15.wrapping_mul(6270).wrapping_add(v13.wrapping_mul(15137)); + let v20 = r14(v17.wrapping_add(v18)); + let v21 = r14(v17.wrapping_sub(v18)); + let v22 = r14(v16.wrapping_add(v19)); + let v23 = r14(v16.wrapping_sub(v19)); + let v24 = v8.wrapping_add(v12); + let v25 = v8.wrapping_sub(v12); + let v26 = v10.wrapping_add(v14); + let v27 = v10.wrapping_sub(v14); + let v28 = r14(v25.wrapping_mul(11585).wrapping_add(v27.wrapping_mul(-11585))); + let v29 = r14(v25.wrapping_mul(11585).wrapping_add(v27.wrapping_mul(11585))); + let v30 = r14(v21.wrapping_mul(11585).wrapping_add(v23.wrapping_mul(-11585))); + let v31 = r14(v21.wrapping_mul(11585).wrapping_add(v23.wrapping_mul(11585))); + let v32 = v20.wrapping_neg(); + let v33 = v29.wrapping_neg(); + let v34 = v30.wrapping_neg(); + let v35 = v26.wrapping_neg(); [v24, v32, v31, v33, v28, v34, v22, v35] } #[inline(always)] pub fn iadst16(x: [$t; 16]) -> [$t; 16] { - let v0 = x[15] * 804 - x[0] * 16364; - let v1 = x[15] * 16364 + x[0] * 804; - let v2 = x[13] * 3981 - x[2] * 15893; - let v3 = x[13] * 15893 + x[2] * 3981; - let v4 = x[11] * 7005 - x[4] * 14811; - let v5 = x[11] * 14811 + x[4] * 7005; - let v6 = x[9] * 9760 - x[6] * 13160; - let v7 = x[9] * 13160 + x[6] * 9760; - let v8 = x[7] * 12140 - x[8] * 11003; - let v9 = x[7] * 11003 + x[8] * 12140; - let v10 = x[5] * 14053 - x[10] * 8423; - let v11 = x[5] * 8423 + x[10] * 14053; - let v12 = x[3] * 15426 - x[12] * 5520; - let v13 = x[3] * 5520 + x[12] * 15426; - let v14 = x[1] * 16207 - x[14] * 2404; - let v15 = x[1] * 2404 + x[14] * 16207; - let v16 = r14(v1 + v9); - let v17 = r14(v1 - v9); - let v18 = r14(v0 + v8); - let v19 = r14(v0 - v8); - let v20 = r14(v3 + v11); - let v21 = r14(v3 - v11); - let v22 = r14(v2 + v10); - let v23 = r14(v2 - v10); - let v24 = r14(v5 + v13); - let v25 = r14(v5 - v13); - let v26 = r14(v4 + v12); - let v27 = r14(v4 - v12); - let v28 = r14(v7 + v15); - let v29 = r14(v7 - v15); - let v30 = r14(v6 + v14); - let v31 = r14(v6 - v14); - let v32 = v17 * 3196 - v19 * 16069; - let v33 = v17 * 16069 + v19 * 3196; - let v34 = v21 * 13623 - v23 * 9102; - let v35 = v21 * 9102 + v23 * 13623; - let v36 = v25 * 16069 + v27 * 3196; - let v37 = -v25 * 3196 + v27 * 16069; - let v38 = v29 * 9102 + v31 * 13623; - let v39 = -v29 * 13623 + v31 * 9102; - let v40 = r14(v33 + v37); - let v41 = r14(v33 - v37); - let v42 = r14(v32 + v36); - let v43 = r14(v32 - v36); - let v44 = r14(v35 + v39); - let v45 = r14(v35 - v39); - let v46 = r14(v34 + v38); - let v47 = r14(v34 - v38); - let v48 = v16 + v24; - let v49 = v16 - v24; - let v50 = v18 + v26; - let v51 = v18 - v26; - let v52 = v20 + v28; - let v53 = v20 - v28; - let v54 = v22 + v30; - let v55 = v22 - v30; - let v56 = v49 * 6270 - v51 * 15137; - let v57 = v49 * 15137 + v51 * 6270; - let v58 = v55 * 15137 - v53 * 6270; - let v59 = v55 * 6270 + v53 * 15137; - let v60 = v41 * 6270 - v43 * 15137; - let v61 = v41 * 15137 + v43 * 6270; - let v62 = v47 * 15137 - v45 * 6270; - let v63 = v47 * 6270 + v45 * 15137; - let v64 = r14(v57 + v58); - let v65 = r14(v57 - v58); - let v66 = r14(v61 + v62); - let v67 = r14(v61 - v62); - let v68 = r14(v56 + v59); - let v69 = r14(v56 - v59); - let v70 = r14(v60 + v63); - let v71 = r14(v60 - v63); - let v72 = v48 + v52; - let v73 = v48 - v52; - let v74 = v40 + v44; - let v75 = v40 - v44; - let v76 = v50 + v54; - let v77 = v50 - v54; - let v78 = v42 + v46; - let v79 = v42 - v46; - let v80 = r14(-v73 * 11585 - v77 * 11585); - let v81 = r14(v73 * 11585 - v77 * 11585); - let v82 = r14(v65 * 11585 + v69 * 11585); - let v83 = r14(-v65 * 11585 + v69 * 11585); - let v84 = r14(v75 * 11585 + v79 * 11585); - let v85 = r14(-v75 * 11585 + v79 * 11585); - let v86 = r14(-v67 * 11585 - v71 * 11585); - let v87 = r14(v67 * 11585 - v71 * 11585); - let v88 = -v74; - let v89 = -v70; - let v90 = -v64; - let v91 = -v76; + let v0 = x[15].wrapping_mul(804).wrapping_add(x[0].wrapping_mul(-16364)); + let v1 = x[15].wrapping_mul(16364).wrapping_add(x[0].wrapping_mul(804)); + let v2 = x[13].wrapping_mul(3981).wrapping_add(x[2].wrapping_mul(-15893)); + let v3 = x[13].wrapping_mul(15893).wrapping_add(x[2].wrapping_mul(3981)); + let v4 = x[11].wrapping_mul(7005).wrapping_add(x[4].wrapping_mul(-14811)); + let v5 = x[11].wrapping_mul(14811).wrapping_add(x[4].wrapping_mul(7005)); + let v6 = x[9].wrapping_mul(9760).wrapping_add(x[6].wrapping_mul(-13160)); + let v7 = x[9].wrapping_mul(13160).wrapping_add(x[6].wrapping_mul(9760)); + let v8 = x[7].wrapping_mul(12140).wrapping_add(x[8].wrapping_mul(-11003)); + let v9 = x[7].wrapping_mul(11003).wrapping_add(x[8].wrapping_mul(12140)); + let v10 = x[5].wrapping_mul(14053).wrapping_add(x[10].wrapping_mul(-8423)); + let v11 = x[5].wrapping_mul(8423).wrapping_add(x[10].wrapping_mul(14053)); + let v12 = x[3].wrapping_mul(15426).wrapping_add(x[12].wrapping_mul(-5520)); + let v13 = x[3].wrapping_mul(5520).wrapping_add(x[12].wrapping_mul(15426)); + let v14 = x[1].wrapping_mul(16207).wrapping_add(x[14].wrapping_mul(-2404)); + let v15 = x[1].wrapping_mul(2404).wrapping_add(x[14].wrapping_mul(16207)); + let v16 = r14(v1.wrapping_add(v9)); + let v17 = r14(v1.wrapping_sub(v9)); + let v18 = r14(v0.wrapping_add(v8)); + let v19 = r14(v0.wrapping_sub(v8)); + let v20 = r14(v3.wrapping_add(v11)); + let v21 = r14(v3.wrapping_sub(v11)); + let v22 = r14(v2.wrapping_add(v10)); + let v23 = r14(v2.wrapping_sub(v10)); + let v24 = r14(v5.wrapping_add(v13)); + let v25 = r14(v5.wrapping_sub(v13)); + let v26 = r14(v4.wrapping_add(v12)); + let v27 = r14(v4.wrapping_sub(v12)); + let v28 = r14(v7.wrapping_add(v15)); + let v29 = r14(v7.wrapping_sub(v15)); + let v30 = r14(v6.wrapping_add(v14)); + let v31 = r14(v6.wrapping_sub(v14)); + let v32 = v17.wrapping_mul(3196).wrapping_add(v19.wrapping_mul(-16069)); + let v33 = v17.wrapping_mul(16069).wrapping_add(v19.wrapping_mul(3196)); + let v34 = v21.wrapping_mul(13623).wrapping_add(v23.wrapping_mul(-9102)); + let v35 = v21.wrapping_mul(9102).wrapping_add(v23.wrapping_mul(13623)); + let v36 = v25.wrapping_mul(16069).wrapping_add(v27.wrapping_mul(3196)); + let v37 = v25.wrapping_mul(-3196).wrapping_add(v27.wrapping_mul(16069)); + let v38 = v29.wrapping_mul(9102).wrapping_add(v31.wrapping_mul(13623)); + let v39 = v29.wrapping_mul(-13623).wrapping_add(v31.wrapping_mul(9102)); + let v40 = r14(v33.wrapping_add(v37)); + let v41 = r14(v33.wrapping_sub(v37)); + let v42 = r14(v32.wrapping_add(v36)); + let v43 = r14(v32.wrapping_sub(v36)); + let v44 = r14(v35.wrapping_add(v39)); + let v45 = r14(v35.wrapping_sub(v39)); + let v46 = r14(v34.wrapping_add(v38)); + let v47 = r14(v34.wrapping_sub(v38)); + let v48 = v16.wrapping_add(v24); + let v49 = v16.wrapping_sub(v24); + let v50 = v18.wrapping_add(v26); + let v51 = v18.wrapping_sub(v26); + let v52 = v20.wrapping_add(v28); + let v53 = v20.wrapping_sub(v28); + let v54 = v22.wrapping_add(v30); + let v55 = v22.wrapping_sub(v30); + let v56 = v49.wrapping_mul(6270).wrapping_add(v51.wrapping_mul(-15137)); + let v57 = v49.wrapping_mul(15137).wrapping_add(v51.wrapping_mul(6270)); + let v58 = v55.wrapping_mul(15137).wrapping_add(v53.wrapping_mul(-6270)); + let v59 = v55.wrapping_mul(6270).wrapping_add(v53.wrapping_mul(15137)); + let v60 = v41.wrapping_mul(6270).wrapping_add(v43.wrapping_mul(-15137)); + let v61 = v41.wrapping_mul(15137).wrapping_add(v43.wrapping_mul(6270)); + let v62 = v47.wrapping_mul(15137).wrapping_add(v45.wrapping_mul(-6270)); + let v63 = v47.wrapping_mul(6270).wrapping_add(v45.wrapping_mul(15137)); + let v64 = r14(v57.wrapping_add(v58)); + let v65 = r14(v57.wrapping_sub(v58)); + let v66 = r14(v61.wrapping_add(v62)); + let v67 = r14(v61.wrapping_sub(v62)); + let v68 = r14(v56.wrapping_add(v59)); + let v69 = r14(v56.wrapping_sub(v59)); + let v70 = r14(v60.wrapping_add(v63)); + let v71 = r14(v60.wrapping_sub(v63)); + let v72 = v48.wrapping_add(v52); + let v73 = v48.wrapping_sub(v52); + let v74 = v40.wrapping_add(v44); + let v75 = v40.wrapping_sub(v44); + let v76 = v50.wrapping_add(v54); + let v77 = v50.wrapping_sub(v54); + let v78 = v42.wrapping_add(v46); + let v79 = v42.wrapping_sub(v46); + let v80 = r14(v73.wrapping_mul(-11585).wrapping_add(v77.wrapping_mul(-11585))); + let v81 = r14(v73.wrapping_mul(11585).wrapping_add(v77.wrapping_mul(-11585))); + let v82 = r14(v65.wrapping_mul(11585).wrapping_add(v69.wrapping_mul(11585))); + let v83 = r14(v65.wrapping_mul(-11585).wrapping_add(v69.wrapping_mul(11585))); + let v84 = r14(v75.wrapping_mul(11585).wrapping_add(v79.wrapping_mul(11585))); + let v85 = r14(v75.wrapping_mul(-11585).wrapping_add(v79.wrapping_mul(11585))); + let v86 = r14(v67.wrapping_mul(-11585).wrapping_add(v71.wrapping_mul(-11585))); + let v87 = r14(v67.wrapping_mul(11585).wrapping_add(v71.wrapping_mul(-11585))); + let v88 = v74.wrapping_neg(); + let v89 = v70.wrapping_neg(); + let v90 = v64.wrapping_neg(); + let v91 = v76.wrapping_neg(); [v72, v88, v66, v90, v82, v86, v84, v80, v81, v85, v87, v83, v68, v89, v78, v91] } diff --git a/crates/vp9/tests/common/mod.rs b/crates/vp9/tests/common/mod.rs index 420541d17..1eed03afc 100644 --- a/crates/vp9/tests/common/mod.rs +++ b/crates/vp9/tests/common/mod.rs @@ -35,38 +35,103 @@ const P420_10: &str = "yuv420p10le"; pub const FIXTURES: &[Fixture] = &[ fx!("intra_only_keyframes", "testsrc2", 352, 288, 3, NOISE, P420, ["-g", "1", "-b:v", "1M"]), - fx!("default_good", "testsrc2", 352, 288, 20, NOISE, P420, ["-b:v", "800k"]), - fx!("profile2_10bit", "testsrc2", 352, 288, 12, NOISE, P420_10, ["-b:v", "800k", "-profile:v", "2"]), - fx!("profile2_12bit", "mandelbrot", 176, 144, 8, NOISE, "yuv420p12le", ["-b:v", "500k", "-profile:v", "2"]), - fx!("profile1_444", "testsrc2", 176, 144, 8, NOISE, "yuv444p", ["-b:v", "500k", "-profile:v", "1"]), - fx!("profile1_422", "testsrc2", 176, 144, 8, NOISE, "yuv422p", ["-b:v", "500k", "-profile:v", "1"]), - fx!("profile1_440", "testsrc2", 176, 144, 8, NOISE, "yuv440p", ["-b:v", "500k", "-profile:v", "1"]), - fx!("profile3_444_10bit", "testsrc2", 176, 144, 6, NOISE, "yuv444p10le", ["-b:v", "500k", "-profile:v", "3"]), - fx!("lossless", "testsrc2", 176, 144, 5, NOISE, P420, ["-lossless", "1"]), - fx!("lossless_10bit", "mandelbrot", 176, 144, 4, NOISE, P420_10, ["-lossless", "1", "-profile:v", "2"]), - fx!("tiles_cols4", "testsrc2", 1280, 720, 6, NOISE, P420, ["-b:v", "2M", "-tile-columns", "2", "-speed", "4"]), - fx!("tiles_rows_cols", "testsrc2", 1280, 720, 6, NOISE, P420, ["-b:v", "2M", "-tile-columns", "2", "-tile-rows", "2", "-speed", "4"]), - fx!("altref_hidden", "testsrc2", 352, 288, 30, NOISE, P420, ["-b:v", "600k", "-auto-alt-ref", "1", "-lag-in-frames", "25"]), + fx!("default_good", "testsrc2", 352, 288, 20, NOISE, P420, ["-b:v", "800k", "-frame-parallel", "0"]), + fx!("profile2_10bit", "testsrc2", 352, 288, 12, NOISE, P420_10, ["-b:v", "800k", "-profile:v", "2", "-frame-parallel", "0"]), + fx!("profile2_12bit", "mandelbrot", 176, 144, 8, NOISE, "yuv420p12le", ["-b:v", "500k", "-profile:v", "2", "-frame-parallel", "0"]), + fx!("profile1_444", "testsrc2", 176, 144, 8, NOISE, "yuv444p", ["-b:v", "500k", "-profile:v", "1", "-frame-parallel", "0"]), + fx!("profile1_422", "testsrc2", 176, 144, 8, NOISE, "yuv422p", ["-b:v", "500k", "-profile:v", "1", "-frame-parallel", "0"]), + fx!("profile1_440", "testsrc2", 176, 144, 8, NOISE, "yuv440p", ["-b:v", "500k", "-profile:v", "1", "-frame-parallel", "0"]), + fx!("profile3_444_10bit", "testsrc2", 176, 144, 6, NOISE, "yuv444p10le", ["-b:v", "500k", "-profile:v", "3", "-frame-parallel", "0"]), + fx!("profile3_422_12bit", "mandelbrot", 176, 144, 6, NOISE, "yuv422p12le", ["-b:v", "500k", "-profile:v", "3", "-frame-parallel", "0"]), + fx!("lossless", "testsrc2", 176, 144, 5, NOISE, P420, ["-lossless", "1", "-frame-parallel", "0"]), + fx!("lossless_10bit", "mandelbrot", 176, 144, 4, NOISE, P420_10, ["-lossless", "1", "-profile:v", "2", "-frame-parallel", "0"]), + fx!("tiles_cols4", "testsrc2", 1280, 720, 6, NOISE, P420, ["-b:v", "2M", "-tile-columns", "2", "-speed", "4", "-frame-parallel", "0"]), + fx!( + "tiles_rows_cols", + "testsrc2", + 1280, + 720, + 6, + NOISE, + P420, + ["-b:v", "2M", "-tile-columns", "1", "-tile-rows", "2", "-speed", "4", "-frame-parallel", "0"] + ), + fx!("tiles_rows4", "testsrc2", 640, 480, 5, NOISE, P420, ["-b:v", "1M", "-tile-columns", "0", "-tile-rows", "2", "-frame-parallel", "0"]), + fx!( + "altref_hidden", + "testsrc2", + 352, + 288, + 30, + NOISE, + P420, + ["2pass", "-b:v", "600k", "-auto-alt-ref", "1", "-lag-in-frames", "25", "-frame-parallel", "0"] + ), + fx!( + "altref_10bit", + "mandelbrot", + 352, + 288, + 24, + NOISE, + P420_10, + ["2pass", "-b:v", "600k", "-profile:v", "2", "-auto-alt-ref", "1", "-lag-in-frames", "16", "-frame-parallel", "0"] + ), + fx!( + "altref_tiles", + "testsrc2", + 1280, + 720, + 12, + NOISE, + P420, + ["2pass", "-b:v", "2M", "-auto-alt-ref", "1", "-lag-in-frames", "10", "-tile-columns", "2", "-speed", "2", "-frame-parallel", "0"] + ), fx!("frame_parallel", "testsrc2", 352, 288, 16, NOISE, P420, ["-b:v", "600k", "-frame-parallel", "1"]), - fx!("frame_parallel_off", "testsrc2", 352, 288, 16, NOISE, P420, ["-b:v", "600k", "-frame-parallel", "0"]), fx!("error_resilient", "testsrc2", 352, 288, 16, NOISE, P420, ["-b:v", "600k", "-error-resilient", "1"]), - fx!("odd_size", "testsrc2", 347, 251, 10, NOISE, P420, ["-b:v", "600k"]), - fx!("tiny_odd", "testsrc2", 33, 17, 8, NOISE, P420, ["-b:v", "200k"]), - fx!("row_mt", "testsrc2", 640, 360, 10, NOISE, P420, ["-b:v", "1M", "-row-mt", "1", "-tile-columns", "1"]), - fx!("speed0", "mandelbrot", 176, 144, 8, NOISE, P420, ["-b:v", "300k", "-speed", "0"]), - fx!("speed2", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "2"]), - fx!("speed5", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "5", "-deadline", "realtime"]), - fx!("speed8", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "8", "-deadline", "realtime"]), - fx!("cq_low", "testsrc2", 352, 288, 8, NOISE, P420, ["-crf", "0", "-b:v", "0"]), - fx!("cq_high", "testsrc2", 352, 288, 12, NOISE, P420, ["-crf", "63", "-b:v", "0"]), - fx!("cq_10bit_low", "testsrc2", 176, 144, 6, NOISE, P420_10, ["-crf", "2", "-b:v", "0", "-profile:v", "2"]), - fx!("aq_segmentation", "testsrc2", 352, 288, 12, NOISE, P420, ["-b:v", "600k", "-aq-mode", "3"]), - fx!("aq_variance", "mandelbrot", 352, 288, 12, NOISE, P420, ["-b:v", "600k", "-aq-mode", "1"]), - fx!("sharpness", "testsrc2", 352, 288, 8, NOISE, P420, ["-b:v", "400k", "-sharpness", "5"]), - fx!("fade_intra_heavy", "testsrc2", 352, 288, 20, "fade=in:0:15", P420, ["-b:v", "400k", "-g", "8"]), + fx!("odd_size", "testsrc2", 347, 251, 10, NOISE, P420, ["-b:v", "600k", "-frame-parallel", "0"]), + fx!("tiny_odd", "testsrc2", 33, 17, 8, NOISE, P420, ["-b:v", "200k", "-frame-parallel", "0"]), + fx!("row_mt", "testsrc2", 640, 360, 10, NOISE, P420, ["-b:v", "1M", "-row-mt", "1", "-tile-columns", "1", "-frame-parallel", "0"]), + fx!("speed0", "mandelbrot", 176, 144, 8, NOISE, P420, ["2pass", "-b:v", "300k", "-speed", "0", "-frame-parallel", "0"]), + fx!("speed1", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "1", "-frame-parallel", "0"]), + fx!("speed2", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "2", "-frame-parallel", "0"]), + fx!("speed3", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "3", "-frame-parallel", "0"]), + fx!("speed4", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "4", "-frame-parallel", "0"]), + fx!("speed5", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "5", "-deadline", "realtime", "-frame-parallel", "0"]), + fx!("speed6", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "6", "-deadline", "realtime", "-frame-parallel", "0"]), + fx!("speed7", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "7", "-deadline", "realtime", "-frame-parallel", "0"]), + fx!("speed8", "testsrc2", 352, 288, 10, NOISE, P420, ["-b:v", "600k", "-speed", "8", "-deadline", "realtime", "-frame-parallel", "0"]), + fx!("cq_low", "testsrc2", 352, 288, 8, NOISE, P420, ["-crf", "0", "-b:v", "0", "-frame-parallel", "0"]), + fx!("cq_q4", "testsrc2", 352, 288, 8, NOISE, P420, ["-crf", "4", "-b:v", "0", "-frame-parallel", "0"]), + fx!("cq_high", "testsrc2", 352, 288, 12, NOISE, P420, ["-crf", "63", "-b:v", "0", "-frame-parallel", "0"]), + fx!("cq_10bit_low", "testsrc2", 176, 144, 6, NOISE, P420_10, ["-crf", "2", "-b:v", "0", "-profile:v", "2", "-frame-parallel", "0"]), + fx!("cq_12bit_high", "testsrc2", 176, 144, 6, NOISE, "yuv420p12le", ["-crf", "60", "-b:v", "0", "-profile:v", "2", "-frame-parallel", "0"]), + fx!("aq_segmentation", "testsrc2", 352, 288, 12, NOISE, P420, ["-b:v", "600k", "-aq-mode", "3", "-frame-parallel", "0"]), + fx!("aq_variance", "mandelbrot", 352, 288, 12, NOISE, P420, ["-b:v", "600k", "-aq-mode", "1", "-frame-parallel", "0"]), + fx!("aq_complexity", "testsrc2", 352, 288, 12, NOISE, P420, ["2pass", "-b:v", "600k", "-aq-mode", "2", "-frame-parallel", "0"]), + fx!("sharpness", "testsrc2", 352, 288, 8, NOISE, P420, ["-b:v", "400k", "-sharpness", "5", "-frame-parallel", "0"]), + fx!("fade_intra_heavy", "testsrc2", 352, 288, 20, "fade=in:0:15", P420, ["-b:v", "400k", "-g", "8", "-frame-parallel", "0"]), fx!("hd_1080p", "testsrc2", 1920, 1080, 3, NOISE, P420, ["-b:v", "4M", "-speed", "5", "-deadline", "realtime"]), - fx!("bench_1080p", "testsrc2", 1920, 1080, 60, "noise=alls=3:allf=t", P420, ["-b:v", "4M", "-speed", "4", "-tile-columns", "2", "-row-mt", "1"]), - fx!("bench_4k", "testsrc2", 3840, 2160, 20, "noise=alls=3:allf=t", P420, ["-b:v", "12M", "-speed", "6", "-tile-columns", "3", "-row-mt", "1"]), + fx!( + "bench_1080p", + "testsrc2", + 1920, + 1080, + 60, + "noise=alls=3:allf=t", + P420, + ["-b:v", "4M", "-speed", "4", "-tile-columns", "2", "-row-mt", "1", "-frame-parallel", "0"] + ), + fx!( + "bench_4k", + "testsrc2", + 3840, + 2160, + 20, + "noise=alls=3:allf=t", + P420, + ["-b:v", "12M", "-speed", "6", "-tile-columns", "3", "-row-mt", "1", "-frame-parallel", "0"] + ), ]; pub fn fixture(name: &str) -> &'static Fixture { @@ -123,7 +188,7 @@ pub fn reference_decode(ivf: &Path, yuv: &Path, pix_fmt: &str) -> bool { let tmp = yuv.with_extension(format!("tmp.{}.yuv", std::process::id())); let mut c = Command::new(&ff); c.args(["-hide_banner", "-loglevel", "error", "-y", "-c:v", "vp9", "-i"]).arg(ivf); - c.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt]).arg(&tmp); + c.args(["-fps_mode", "passthrough", "-noautoscale", "-f", "rawvideo", "-pix_fmt", pix_fmt]).arg(&tmp); if !run(&mut c) { return false; } @@ -146,16 +211,35 @@ pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { vf.push(','); vf.push_str(f.filter); } - let mut c = Command::new(&ff); - c.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", &vf]); - c.args(["-frames:v", &f.frames.to_string(), "-c:v", "libvpx-vp9", "-pix_fmt", f.pix_fmt]); - c.args(f.args); - c.args(["-f", "ivf"]).arg(&tmp); + let two_pass = f.args.first() == Some(&"2pass"); + let args: Vec<&str> = f.args.iter().copied().filter(|a| *a != "2pass").collect(); + let log = dir.join(format!("{}.{}.passlog", f.name, std::process::id())); + let enc = |pass: Option, out: &Path| { + let mut c = Command::new(&ff); + c.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", &vf]); + c.args(["-frames:v", &f.frames.to_string(), "-c:v", "libvpx-vp9", "-pix_fmt", f.pix_fmt]); + c.args(&args); + if let Some(p) = pass { + c.args(["-pass", &p.to_string(), "-passlogfile"]).arg(&log); + } + if pass == Some(1) { + c.args(["-f", "null", "-"]); + } else { + c.args(["-f", "ivf"]).arg(out); + } + c + }; + if two_pass && !run(&mut enc(Some(1), &tmp)) { + eprintln!("SKIP {}: first pass failed", f.name); + return None; + } + let mut c = enc(if two_pass { Some(2) } else { None }, &tmp); if !run(&mut c) { let _ = std::fs::remove_file(&tmp); eprintln!("SKIP {}: libvpx-vp9 encode failed", f.name); return None; } + let _ = std::fs::remove_file(format!("{}-0.log", log.display())); std::fs::rename(&tmp, &ivf).unwrap(); } if !yuv.exists() { diff --git a/crates/vp9/tests/conformance.rs b/crates/vp9/tests/conformance.rs index 2ad2c0f8a..1528b6469 100644 --- a/crates/vp9/tests/conformance.rs +++ b/crates/vp9/tests/conformance.rs @@ -27,84 +27,42 @@ fixture_tests!( profile1_422, profile1_440, profile3_444_10bit, + profile3_422_12bit, lossless, lossless_10bit, tiles_cols4, tiles_rows_cols, + tiles_rows4, altref_hidden, + altref_10bit, + altref_tiles, frame_parallel, - frame_parallel_off, error_resilient, odd_size, tiny_odd, row_mt, speed0, + speed1, speed2, + speed3, + speed4, speed5, + speed6, + speed7, speed8, cq_low, + cq_q4, cq_high, cq_10bit_low, + cq_12bit_high, aq_segmentation, aq_variance, + aq_complexity, sharpness, fade_intra_heavy, hd_1080p, ); -/// Resolution changes mid-stream: libvpx's `resize_mode` (dynamic internal scaling with scaled -/// reference prediction), and concatenated streams of different sizes. -#[test] -fn resize_dynamic() { - let Some(ff) = common::ffmpeg() else { return }; - let dir = common::fixtures_dir(); - let ivf = dir.join("resize_dynamic.ivf"); - let yuv = dir.join("resize_dynamic.yuv"); - if !ivf.exists() { - // Encode with a spatial resampling switch: first 10 frames at 352x288, then the encoder - // is fed 176x144 content via a scale filter that changes size (ffmpeg reinitialises - // libvpx with a new size without a key frame when `-resize_mode` style scaling is - // unavailable, so emulate with libvpx's internal resize through the `resize-mode` knob - // of vpxenc-like options if present, otherwise concatenation below covers size changes). - let mut c = std::process::Command::new(&ff); - c.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=352x288:rate=25,noise=alls=10:allf=t+u"]); - c.args([ - "-frames:v", - "30", - "-c:v", - "libvpx-vp9", - "-b:v", - "60k", - "-minrate", - "60k", - "-maxrate", - "60k", - "-undershoot-pct", - "0", - "-lag-in-frames", - "0", - "-deadline", - "realtime", - "-speed", - "8", - ]); - c.args(["-vpx-options", "resize-mode=3"]); - c.args(["-f", "ivf"]).arg(&ivf); - if !common::run(&mut c) { - let mut c = std::process::Command::new(&ff); - c.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=352x288:rate=25,noise=alls=10:allf=t+u"]); - c.args(["-frames:v", "30", "-c:v", "libvpx-vp9", "-b:v", "40k", "-deadline", "realtime", "-speed", "8", "-lag-in-frames", "0"]); - c.args(["-f", "ivf"]).arg(&ivf); - assert!(common::run(&mut c)); - } - } - if !yuv.exists() { - assert!(common::reference_decode(&ivf, &yuv, "yuv420p")); - } - let reference = std::fs::read(&yuv).unwrap(); - common::check_files("resize_dynamic", &ivf, &reference, "yuv420p").unwrap(); -} - /// Two streams of different sizes concatenated into one IVF (key frame at the size change). #[test] fn resize_concatenated() { diff --git a/crates/vp9/tests/robustness.rs b/crates/vp9/tests/robustness.rs new file mode 100644 index 000000000..be63331d9 --- /dev/null +++ b/crates/vp9/tests/robustness.rs @@ -0,0 +1,106 @@ +//! Damaged, truncated and random input must never panic or hang (errors are fine). + +mod common; + +use filmcraft_vp9::Decoder; +use std::time::{Duration, Instant}; + +fn xorshift(seed: &mut u64) -> u64 { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + *seed +} + +fn decode_all(frames: &[Vec], threads: usize) { + let mut dec = Decoder::with_threads(threads); + for (i, f) in frames.iter().enumerate() { + let _ = dec.decode(f, i as i64); + } + let _ = dec.flush(); +} + +#[test] +fn corrupted_streams_do_not_panic() { + for name in ["default_good", "altref_hidden", "profile2_10bit", "tiles_cols4", "aq_segmentation", "lossless", "profile1_444", "tiles_rows4"] { + let f = common::fixture(name); + let Some((ivf, _)) = common::ensure(f) else { return }; + let data = std::fs::read(ivf).unwrap(); + let frames: Vec> = common::ivf_frames(&data).into_iter().map(|f| f.to_vec()).collect(); + let mut seed = 0x1234_5678_9abc_def0u64 ^ name.len() as u64; + for round in 0..10 { + let t0 = Instant::now(); + let mut d = frames.clone(); + for _ in 0..(1 + round * 4) { + let fi = (xorshift(&mut seed) as usize) % d.len(); + let fr = &mut d[fi]; + if fr.is_empty() { + continue; + } + let pos = (xorshift(&mut seed) as usize) % fr.len(); + match round % 3 { + 0 => fr[pos] ^= 1 << (xorshift(&mut seed) % 8), + 1 => fr[pos] = xorshift(&mut seed) as u8, + _ => fr.truncate(pos), + } + } + for threads in [1, 4] { + decode_all(&d, threads); + } + assert!(t0.elapsed() < Duration::from_secs(60), "{name} round {round} too slow"); + } + } +} + +#[test] +fn truncated_frames_do_not_panic() { + let f = common::fixture("default_good"); + let Some((ivf, _)) = common::ensure(f) else { return }; + let data = std::fs::read(ivf).unwrap(); + let frames: Vec> = common::ivf_frames(&data).into_iter().map(|f| f.to_vec()).collect(); + for cut in [1usize, 2, 3, 5, 8, 13, 21, 40, 100, 400] { + let d: Vec> = frames.iter().map(|f| f[..cut.min(f.len())].to_vec()).collect(); + decode_all(&d, 1); + decode_all(&d, 3); + } +} + +#[test] +fn garbage_input_is_rejected_gracefully() { + let mut seed = 42u64; + for len in [0usize, 1, 2, 5, 30, 100, 5000] { + for k in 0..40 { + let mut g: Vec = (0..len).map(|_| xorshift(&mut seed) as u8).collect(); + if len > 10 && k % 2 == 0 { + // A plausible key frame start: frame marker, profile 0, key frame, sync code. + g[0] = 0x82; + g[1] = 0x49; + g[2] = 0x83; + g[3] = 0x42; + } + let mut dec = Decoder::with_threads(1 + k % 3); + let _ = dec.decode(&g, 0); + let _ = dec.decode(&g, 1); + } + } + // Inter frame / show_existing without references. + let mut dec = Decoder::new(); + assert!(dec.decode(&[0x88], 0).is_err()); + assert!(dec.decode(&[0x86, 0, 0, 0, 0], 0).is_err()); +} + +/// Huge declared frame sizes must not allocate absurd amounts or panic. +#[test] +fn oversized_frame_is_rejected() { + // Key frame header: marker 2, profile 0, show_existing 0, key, show, no error res, + // sync, color (4 bits), width-1 = 0xffff, height-1 = 0xffff. + let bits = "10".to_string() + "00" + "0" + "0" + "1" + "0" + "010010011000001101000010" + "0000" + &"1".repeat(32); + let mut v = vec![0u8; bits.len().div_ceil(8) + 8]; + for (i, c) in bits.chars().enumerate() { + if c == '1' { + v[i / 8] |= 0x80 >> (i % 8); + } + } + let mut dec = Decoder::new(); + assert!(dec.decode(&v, 0).is_err()); +} diff --git a/crates/vp9/tests/synthetic.rs b/crates/vp9/tests/synthetic.rs new file mode 100644 index 000000000..a01c7a1b8 --- /dev/null +++ b/crates/vp9/tests/synthetic.rs @@ -0,0 +1,357 @@ +//! Streams libvpx (as driven by ffmpeg) does not produce, built by rewriting the uncompressed +//! headers of libvpx-vp9 output, all checked against ffmpeg's decoder: +//! - reference scaling: inter frames of a stream encoded at another size are spliced after a key +//! frame, with their frame size coded explicitly, so they predict from differently sized +//! references (down- and up-scaling); +//! - intra-only frames (a key frame rewritten as a hidden intra-only frame) shown with +//! show_existing_frame; +//! - show_existing_frame of hidden alt-ref frames and of arbitrary slots. + +mod common; + +use std::path::Path; + +struct Bits<'a> { + d: &'a [u8], + pos: usize, +} + +impl Bits<'_> { + fn f(&mut self, n: usize) -> u32 { + let mut v = 0; + for _ in 0..n { + v = (v << 1) | ((self.d[self.pos / 8] >> (7 - self.pos % 8)) & 1) as u32; + self.pos += 1; + } + v + } +} + +#[derive(Default)] +struct W { + bits: Vec, +} + +impl W { + fn put(&mut self, v: u32, n: usize) { + for i in (0..n).rev() { + self.bits.push((v >> i) & 1 == 1); + } + } + fn copy(&mut self, d: &[u8], from: usize, to: usize) { + let mut b = Bits { d, pos: from }; + for _ in from..to { + self.bits.push(b.f(1) == 1); + } + } + fn bytes(&self) -> Vec { + let mut out = vec![0u8; self.bits.len().div_ceil(8)]; + for (i, &b) in self.bits.iter().enumerate() { + if b { + out[i / 8] |= 0x80 >> (i % 8); + } + } + out + } +} + +/// Parse the header fields from refresh_frame_context to header_size_in_bytes (profile 0 +/// layouts); returns the bit position after header_size_in_bytes. +fn parse_tail(b: &mut Bits, error_res: bool, width: u32) -> usize { + if !error_res { + b.f(2); + } + b.f(2); // frame_context_idx + b.f(6); + b.f(3); + if b.f(1) == 1 && b.f(1) == 1 { + for _ in 0..6 { + if b.f(1) == 1 { + b.f(7); + } + } + } + b.f(8); + for _ in 0..3 { + if b.f(1) == 1 { + b.f(5); + } + } + if b.f(1) == 1 { + if b.f(1) == 1 { + for _ in 0..7 { + if b.f(1) == 1 { + b.f(8); + } + } + if b.f(1) == 1 { + for _ in 0..3 { + if b.f(1) == 1 { + b.f(8); + } + } + } + } + if b.f(1) == 1 { + b.f(1); + for _ in 0..8 { + for (bits, signed) in [(8, true), (6, true), (2, false), (0, false)] { + if b.f(1) == 1 { + b.f(bits); + if signed { + b.f(1); + } + } + } + } + } + } + let sb_cols = (width.div_ceil(8)).div_ceil(8); + let mut min_log2 = 0; + while (64 << min_log2) < sb_cols { + min_log2 += 1; + } + let mut max_log2 = 1; + while (sb_cols >> max_log2) >= 4 { + max_log2 += 1; + } + max_log2 -= 1; + let mut l = min_log2; + while l < max_log2 { + if b.f(1) == 1 { + l += 1; + } else { + break; + } + } + if b.f(1) == 1 { + b.f(1); + } + b.f(16); + b.pos +} + +/// Rewrite a profile 0 inter frame so that its frame size (`w` x `h`) is coded explicitly. +fn explicit_size(frame: &[u8], w: u32, h: u32) -> Vec { + let mut b = Bits { d: frame, pos: 0 }; + assert_eq!(b.f(2), 2); + assert_eq!(b.f(2), 0, "profile 0 only"); + assert_eq!(b.f(1), 0, "show_existing"); + assert_eq!(b.f(1), 1, "inter frame expected"); + let show = b.f(1) == 1; + let err = b.f(1) == 1; + if !show { + assert_eq!(b.f(1), 0, "intra-only"); + } + if !err { + b.f(2); + } + b.f(8 + 12); + let found_at = b.pos; + let mut found = false; + for _ in 0..3 { + if b.f(1) == 1 { + found = true; + break; + } + } + if !found { + b.f(32); + } + let after_size = b.pos; + // render_size, allow_high_precision_mv, interpolation filter + if b.f(1) == 1 { + b.f(32); + } + b.f(1); + if b.f(1) == 0 { + b.f(2); + } + let end = parse_tail(&mut b, err, w); + let mut o = W::default(); + o.copy(frame, 0, found_at); + o.put(0, 3); + o.put(w - 1, 16); + o.put(h - 1, 16); + o.copy(frame, after_size, end); + let mut out = o.bytes(); + out.extend_from_slice(&frame[end.div_ceil(8)..]); + out +} + +/// Rewrite a profile 0 key frame as a hidden intra-only frame refreshing `refresh` slots. +fn key_to_intra_only(frame: &[u8], refresh: u8, reset: u32) -> Vec { + let mut b = Bits { d: frame, pos: 0 }; + assert_eq!(b.f(2), 2); + assert_eq!(b.f(2), 0); + assert_eq!(b.f(1), 0); + assert_eq!(b.f(1), 0, "key frame expected"); + b.f(1); // show_frame + let err = b.f(1) == 1; + assert_eq!(b.f(24), 0x498342); + b.f(4); // color_space, color_range + let size_at = b.pos; + let w = b.f(16) + 1; + b.f(16); + if b.f(1) == 1 { + b.f(32); + } + let after_render = b.pos; + let end = parse_tail(&mut b, err, w); + let mut o = W::default(); + o.put(2, 2); + o.put(0, 2); + o.put(0, 1); + o.put(1, 1); // non-key + o.put(0, 1); // hidden + o.put(err as u32, 1); + o.put(1, 1); // intra_only + if !err { + o.put(reset, 2); + } + o.put(0x498342, 24); + o.put(refresh as u32, 8); + o.copy(frame, size_at, after_render); + o.copy(frame, after_render, end); + let mut out = o.bytes(); + out.extend_from_slice(&frame[end.div_ceil(8)..]); + out +} + +fn reference(ivf: &Path, pix_fmt: &str) -> Vec { + let yuv = ivf.with_extension("yuv"); + let _ = std::fs::remove_file(&yuv); + assert!(common::reference_decode(ivf, &yuv, pix_fmt), "ffmpeg failed on {}", ivf.display()); + let r = std::fs::read(&yuv).unwrap(); + let _ = std::fs::remove_file(&yuv); + r +} + +fn encode(name: &str, w: u32, h: u32, frames: u32, extra: &[&str]) -> Option>> { + let ff = common::ffmpeg()?; + let path = common::fixtures_dir().join(format!("{name}.ivf")); + if !path.exists() { + let mut c = std::process::Command::new(ff); + c.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i"]); + c.arg(format!("testsrc2=size={w}x{h}:rate=25,noise=alls=8:allf=t+u")); + c.args(["-frames:v", &frames.to_string(), "-c:v", "libvpx-vp9", "-b:v", "500k", "-frame-parallel", "0"]); + c.args(extra); + c.args(["-f", "ivf"]).arg(&path); + if !common::run(&mut c) { + return None; + } + } + let d = std::fs::read(&path).unwrap(); + Some(common::ivf_frames(&d).into_iter().map(|f| f.to_vec()).collect()) +} + +fn check(name: &str, frames: &[Vec]) { + let path = common::fixtures_dir().join(format!("{name}.ivf")); + common::write_ivf(&path, 0, 0, frames); + let r = reference(&path, "yuv420p"); + common::check_files(name, &path, &r, "yuv420p").unwrap(); + let _ = std::fs::remove_file(path); +} + +/// Splice the inter frames of a `w2` x `h2` stream after frames of a 352x288 stream. +fn scaled_splice(name: &str, w2: u32, h2: u32, interp: &[&str]) -> Option>> { + let a = encode("syn_base_352", 352, 288, 6, &[])?; + let mut args = vec!["-error-resilient", "1"]; + args.extend_from_slice(interp); + let b = encode(&format!("syn_er_{w2}x{h2}{}", interp.join("")), w2, h2, 10, &args)?; + let mut frames: Vec> = a[..4].to_vec(); + for f in &b[1..] { + frames.push(explicit_size(f, w2, h2)); + } + let _ = name; + Some(frames) +} + +#[test] +fn scaled_reference_downscale() { + let Some(f) = scaled_splice("down", 256, 208, &[]) else { return }; + let mut dec = filmcraft_vp9::Decoder::with_threads(1); + for c in &f { + let _ = dec.decode(c, 0); + } + assert!(dec.stats().scaled_ref_blocks > 0, "{:?}", dec.stats()); + check("syn_scaled_down", &f); +} + +#[test] +fn splice_same_size_control() { + let Some(f) = scaled_splice("same", 352, 288, &[]) else { return }; + check("syn_same", &f); +} + +#[test] +fn scaled_reference_upscale() { + let Some(f) = scaled_splice("up", 480, 400, &[]) else { return }; + check("syn_scaled_up", &f); +} + +#[test] +fn scaled_reference_odd_sizes() { + let Some(f) = scaled_splice("odd", 301, 199, &[]) else { return }; + check("syn_scaled_odd", &f); +} + +fn build_superframe(frames: &[Vec]) -> Vec { + if frames.len() == 1 { + return frames[0].clone(); + } + let mut out = Vec::new(); + for f in frames { + out.extend_from_slice(f); + } + let marker = 0xc0 | (3 << 3) | (frames.len() as u8 - 1); + out.push(marker); + for f in frames { + out.extend((f.len() as u32).to_le_bytes()); + } + out.push(marker); + out +} + +#[test] +fn intra_only_frames() { + // Error resilient source: the frame after the intra-only frame does not use the previous + // frame's motion vectors either way, so its symbols parse as the encoder intended. + let Some(src) = encode("syn_er_g8", 352, 288, 20, &["-g", "8", "-error-resilient", "1"]) else { return }; + let keys: Vec = src.iter().enumerate().filter(|(_, f)| f[0] & 0x04 == 0 && f[0] & 0x08 == 0).map(|(i, _)| i).collect(); + assert!(keys.len() >= 2, "need a second key frame: {keys:?}"); + for (reset, refresh, show_slot) in [(3u32, 0xffu8, 0u8), (2, 0x05, 2), (0, 0x10, 4)] { + let mut frames = Vec::new(); + for (i, f) in src.iter().enumerate() { + if i == keys[1] { + frames.push(key_to_intra_only(f, refresh, reset)); + frames.push(vec![0x88 | show_slot]); + } else { + frames.push(f.clone()); + } + } + let mut dec = filmcraft_vp9::Decoder::with_threads(1); + for c in &frames { + dec.decode(c, 0).unwrap(); + } + let s = dec.stats(); + assert!(s.intra_only == 1 && s.show_existing == 1, "{s:?}"); + check(&format!("syn_intra_only_{reset}"), &frames); + } +} + +#[test] +fn show_existing_frames() { + let Some(src) = encode("syn_er_352", 352, 288, 6, &["-error-resilient", "1"]) else { return }; + let mut frames = Vec::new(); + for (i, f) in src.iter().enumerate() { + frames.push(f.clone()); + if i >= 1 { + frames.push(vec![0x88 | (i as u8 % 8)]); + } + } + // A superframe holding a frame and a show_existing_frame. + let sf = build_superframe(&[src[2].clone(), vec![0x88]]); + frames.push(sf); + check("syn_show_existing", &frames); +} diff --git a/crates/vp9/tools/gen_transforms.py b/crates/vp9/tools/gen_transforms.py index ab62ce3e4..2e016d4bc 100644 --- a/crates/vp9/tools/gen_transforms.py +++ b/crates/vp9/tools/gen_transforms.py @@ -54,24 +54,15 @@ class Gen: @staticmethod def lin(terms): - parts = [] + # Wrapping arithmetic: conformant streams never overflow (8.7.1.1), damaged ones must not + # panic in debug builds. + s = None for var, c in terms: if c == 0: continue - if c == 1: - parts.append(("+", var)) - elif c == -1: - parts.append(("-", var)) - elif c < 0: - parts.append(("-", "%s * %d" % (var, -c))) - else: - parts.append(("+", "%s * %d" % (var, c))) - if not parts: - return "0" - s = parts[0][1] if parts[0][0] == "+" else "-" + parts[0][1] - for sign, p in parts[1:]: - s += " %s %s" % (sign, p) - return s + t = "%s.wrapping_mul(%d)" % (var, c) + s = t if s is None else "%s.wrapping_add(%s)" % (s, t) + return s if s is not None else "0" def B(self, a, b, angle, flip): ta, tb = self.T[a], self.T[b] @@ -84,8 +75,8 @@ class Gen: if flip: a, b = b, a x, y = self.T[a], self.T[b] - self.T[a] = self.new("%s + %s" % (x, y)) - self.T[b] = self.new("%s - %s" % (x, y)) + self.T[a] = self.new("%s.wrapping_add(%s)" % (x, y)) + self.T[b] = self.new("%s.wrapping_sub(%s)" % (x, y)) def SB(self, a, b, angle, flip): ta, tb = self.T[a], self.T[b] @@ -96,8 +87,8 @@ class Gen: def SH(self, a, b): sa, sb = self.S[a], self.S[b] - self.T[a] = self.new("r14(%s + %s)" % (sa, sb)) - self.T[b] = self.new("r14(%s - %s)" % (sa, sb)) + self.T[a] = self.new("r14(%s.wrapping_add(%s))" % (sa, sb)) + self.T[b] = self.new("r14(%s.wrapping_sub(%s))" % (sa, sb)) def idct_steps(g, n): @@ -165,7 +156,7 @@ def adst_out_perm(g, n): def neg(g, i): - g.T[i] = g.new("-%s" % g.T[i]) + g.T[i] = g.new("%s.wrapping_neg()" % g.T[i]) def adst8(): @@ -239,7 +230,7 @@ print("macro_rules! transforms_1d {") print(" ($t:ty) => {") print(" #[inline(always)]") print(" fn r14(x: $t) -> $t {") -print(" (x + (1 << 13)) >> 14") +print(" x.wrapping_add(1 << 13) >> 14") print(" }") print() for n in range(2, 6): From 5e9d7d904ec8a71a242c1cc0041c3942d4642b4d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 22:46:13 -0400 Subject: [PATCH 03/24] M9.8: VP9 loop filter speedup (gathered samples, sliding wide filter), perf test, vp9dec example, README Co-Authored-By: Claude Opus 5.5 --- crates/vp9/README.md | 148 ++++++++++++++++++++++++++++++++++ crates/vp9/examples/vp9dec.rs | 3 +- crates/vp9/src/loopfilter.rs | 100 ++++++++++------------- crates/vp9/tests/perf.rs | 47 +++++++++++ 4 files changed, 241 insertions(+), 57 deletions(-) create mode 100644 crates/vp9/README.md create mode 100644 crates/vp9/tests/perf.rs diff --git a/crates/vp9/README.md b/crates/vp9/README.md new file mode 100644 index 000000000..b307893e6 --- /dev/null +++ b/crates/vp9/README.md @@ -0,0 +1,148 @@ +# filmcraft-vp9 + +Clean-room, pure-Rust (no `unsafe`) VP9 decoder, implemented from the public *VP9 Bitstream & +Decoding Process Specification* v0.6 (WebM project). It is an L0 crate: it depends only on +`filmcraft-bitstream`, `thiserror`, and optionally `rayon` (default feature `threads`), and builds +for `wasm32-unknown-unknown` with and without `threads`. + +The constant tables (probability defaults, scans, coefficient bands, quantizer lookups, Pareto +table, interpolation filters, `mv_ref_blocks`...) were extracted from the specification text by +[`tools/extract_tables.py`](tools/extract_tables.py) into `src/spec_tables.rs`. The 1D inverse +DCT / ADST butterflies are generated by [`tools/gen_transforms.py`](tools/gen_transforms.py), +which symbolically executes the step lists of spec section 8.7.1 into `src/transform_gen.rs`. +No code was taken or ported from libvpx, FFmpeg or any other decoder; ffmpeg (with libvpx-vp9) +is used only as an external fixture generator and test oracle. + +## Status + +| Area | Status | +|---|---| +| Profiles 0 / 2 (8, 10, 12-bit 4:2:0) | done, bit-exact | +| Profiles 1 / 3 (4:2:2, 4:4:0, 4:4:4; 8 / 10 / 12-bit) | done, bit-exact | +| Uncompressed + compressed headers, superframe index (Annex B) | done | +| Boolean decoder, forward updates (`diff_update_prob`, MV probs), backward adaptation (8.4) | done | +| Segmentation (tree / temporal prediction, all four features), loop filter (levels, sharpness, ref / mode deltas, segment levels) | done | +| All 10 intra modes incl. edge rules, sub-8x8 blocks | done | +| Inter: 8-tap regular / smooth / sharp + bilinear, compound prediction, MV prediction (incl. previous-frame MVs, sub-8x8 append), reference scaling (8.5.2.3) | done | +| DCT / ADST 4..32, WHT (lossless) | done | +| Tiles (columns and rows); tile columns decode in parallel | done | +| Hidden frames, show_existing_frame, intra-only frames, resolution changes, error resilient / frame parallel modes | done | +| Loop filter in parallel (superblock wavefront) | done | +| Frame-level threading (overlapping consecutive frames) | not yet | + +## API + +```rust +use filmcraft_vp9::{Decoder, Plane}; + +let mut dec = Decoder::new(); // or Decoder::with_threads(n) +for (pts, chunk) in chunks { // one IVF / WebM block / MP4 sample each (frame or superframe) + for pic in dec.decode(chunk, pts)? { + // pic.width / pic.height (frame size), pic.render_width / render_height (render_size), + // pic.bit_depth, pic.subsampling_x / subsampling_y, pic.chroma_width / chroma_height, + // pic.y / pic.u / pic.v: Plane::U8(Vec) for 8-bit, Plane::U16(Vec) above, + // pic.y_stride / pic.uv_stride, pic.pts, pic.key, pic.intra_only, + // pic.color { color_space (0..7 as coded), full_range } + } +} +let _ = dec.flush(); // VP9 has no reordering: always empty, kept for API symmetry +let stats = dec.stats(); // what the stream exercised (frame kinds, tiles, compound, scaling...) +``` + +A superframe produces every shown frame it contains (normally one); hidden frames produce none. + +### Architecture + +The calling thread parses the uncompressed and compressed headers and manages the eight reference +slots, the four probability contexts, segmentation map and previous-frame motion vectors. Each tile +column is decoded by one `TileDecoder` (rayon pool) into a private strip of samples and mode info +(tile rows of a column are decoded in order — they share above contexts); strips are then +assembled, the loop filter runs as a superblock wavefront (superblock (r, c) waits for (r-1, c+1); +each superblock filters a private copy of its area plus 8 samples above / left and writes it +back), counts are merged for backward adaptation and the frame is stored. Samples are `u16` +internally for every bit depth. + +Modules: `header` (6.2 / 6.3, superframes), `boolcoder` (9.2), `probs` (frame contexts, counts, +8.4), `tile` (partition / mode info / MV prediction), `tile::recon` (residual, tokens, intra / +inter prediction calls, reconstruction), `intra`, `inter`, `transform`, `loopfilter`, `decoder`. + +## Tests + +`cargo test --release -p filmcraft-vp9`. Fixtures are generated on first use into +`target/fixtures/vp9/` with ffmpeg + libvpx-vp9 (tests print a message and skip when ffmpeg or +libvpx is unavailable). Every frame is compared sample-exactly with `ffmpeg -c:v vp9 -f rawvideo` +output, single-threaded and with the default thread pool; the first mismatch reports frame, plane, +position and superblock. + +- Unit tests: boolean decoder against a literal model of 9.2, tables / trees, inverse DCT / ADST + against floating-point bases, DC-only shortcut vs. full transform, scaled MC path vs. unscaled, + superframe index, `inv_remap_prob`, probability merging. +- Conformance (`tests/conformance.rs`): the fixture matrix below plus a stream concatenating two + sizes. `compare_detects_mismatch` guards the harness. `coverage_report` (ignored) prints the + decoder statistics for each fixture. +- Synthetic (`tests/synthetic.rs`): headers of libvpx output are rewritten to produce what ffmpeg's + libvpx wrapper cannot: inter frames of a stream encoded at another size spliced after a 352x288 + key frame with explicit frame sizes, i.e. **scaled reference prediction** (downscale 256x208, + upscale 480x400, odd 301x199; a same-size splice is the control); a key frame rewritten as a + hidden **intra-only frame** (reset_frame_context 3 / 2 / 0, various refresh masks) shown with + **show_existing_frame**; show_existing_frame of every slot, also inside a superframe. +- Robustness (`tests/robustness.rs`): bit flips, byte replacement and truncation of real streams, + truncated frames, random data and oversized frames never panic (debug and release) or hang, + single- and multi-threaded. + +### Fixture matrix (all bit-exact, 1 thread and thread pool) + +Backward adaptation is on (`-frame-parallel 0`) unless noted. + +| fixture | content | configuration | +|---|---|---| +| intra_only_keyframes | 352x288 | `-g 1` (key frames only, frame parallel) | +| default_good | 352x288, 20 frames | 800 kb/s | +| profile2_10bit / profile2_12bit | 352x288 / 176x144 | profile 2, 10 / 12-bit | +| profile1_444 / _422 / _440 | 176x144 | profile 1, 4:4:4 / 4:2:2 / 4:4:0 | +| profile3_444_10bit / profile3_422_12bit | 176x144 | profile 3 | +| lossless / lossless_10bit | 176x144 | `-lossless 1` (WHT) | +| tiles_cols4 | 1280x720 | 4 tile columns | +| tiles_rows_cols / tiles_rows4 | 1280x720 / 640x480 | tile columns + rows / 4 tile rows | +| altref_hidden / altref_10bit / altref_tiles | 352x288 / 720p | 2-pass, `-auto-alt-ref 1`: hidden frames, superframes, compound prediction | +| frame_parallel | 352x288 | `-frame-parallel 1` (no backward adaptation) | +| error_resilient | 352x288 | `-error-resilient 1` | +| odd_size / tiny_odd | 347x251 / 33x17 | odd sizes, partial superblocks | +| row_mt | 640x360 | `-row-mt 1`, 2 tile columns | +| speed0 .. speed8 | 176x144 / 352x288 | every speed (0 two-pass, 5..8 realtime) | +| cq_low / cq_q4 / cq_high / cq_10bit_low / cq_12bit_high | | `-crf 0 / 4 / 63 / 2 / 60` | +| aq_segmentation / aq_variance / aq_complexity | 352x288 | `-aq-mode 3 / 1 / 2` (segmentation, temporal segment-id prediction) | +| sharpness | 352x288 | `-sharpness 5` | +| fade_intra_heavy | 352x288 | fade-in, key frame every 8 | +| hd_1080p | 1920x1080 | realtime speed 5 | +| resize_concatenated | 352x288 then 347x251 | size change at a key frame | + +## Performance + +`cargo test --release -p filmcraft-vp9 --test perf -- --ignored --nocapture` (bit-exactness is +verified first). Apple M4 Pro (14 cores). The machine was heavily shared during measurement (load +average 130-170), so wall-clock figures are far below what an idle machine gives; single-thread +CPU time is the more meaningful number. + +| stream | 1 thread (CPU time) | 1 thread (wall, loaded) | 14 threads (wall, loaded) | +|---|---|---|---| +| 1080p 8-bit, libvpx speed 4, 4 MB/s, 4 tile columns, 60 frames | ~47 fps | ~11-17 fps | ~26-35 fps | +| 2160p 8-bit, libvpx speed 6, 12 MB/s, 8 tile columns, 20 frames | ~13 fps | ~7-13 fps | ~15-21 fps | + +No SIMD-specific code yet; interpolation, transforms and loop filter are plain loops (the loop +filter is still the largest cost). `cargo run --release -p filmcraft-vp9 --example vp9dec -- +in.ivf [out.yuv]` decodes an IVF file (`VP9_THREADS=n` selects the thread count) and prints the +statistics. + +## Known gaps + +- No frame-level threading: parallelism is tile columns + loop filter wavefront, so streams with + a single tile column (narrower than 512 pixels or encoded without tile columns) decode mostly on + one thread. +- Scaled references combined with compound prediction are not covered by a fixture (both are + verified separately; libvpx through ffmpeg cannot change size mid-stream and the synthetic + splice cannot keep the compound streams' probability contexts intact). +- Frames larger than 16384x16384 are rejected (`Error::Invalid`) to bound memory. +- Damaged streams are decoded as far as the data allows (the boolean decoder reads zeros past the + end, as specified); there is no concealment beyond that, and inter frames whose reference slots + are empty return `Error::MissingReference`. diff --git a/crates/vp9/examples/vp9dec.rs b/crates/vp9/examples/vp9dec.rs index ea4a7af56..376d7f1b1 100644 --- a/crates/vp9/examples/vp9dec.rs +++ b/crates/vp9/examples/vp9dec.rs @@ -7,7 +7,8 @@ fn main() { let args: Vec = std::env::args().collect(); let data = std::fs::read(&args[1]).expect("read input"); let mut out = args.get(2).map(|p| std::io::BufWriter::new(std::fs::File::create(p).expect("create output"))); - let mut dec = filmcraft_vp9::Decoder::new(); + let threads = std::env::var("VP9_THREADS").ok().and_then(|s| s.parse().ok()).unwrap_or(0); + let mut dec = if threads > 0 { filmcraft_vp9::Decoder::with_threads(threads) } else { filmcraft_vp9::Decoder::new() }; let hl = u16::from_le_bytes([data[6], data[7]]) as usize; let mut p = hl; let mut i = 0i64; diff --git a/crates/vp9/src/loopfilter.rs b/crates/vp9/src/loopfilter.rs index 918d6e774..a3de046f5 100644 --- a/crates/vp9/src/loopfilter.rs +++ b/crates/vp9/src/loopfilter.rs @@ -148,20 +148,22 @@ fn filter_sb_plane(v: &mut PlaneView, mi: &MiGrid, f: &LfFrame, plane: usize, pa } /// Sample filtering process (8.8.5) at `pos` (the q0 sample), samples across the edge `step` -/// apart. +/// apart. The samples are gathered into `v` (`v[8 + k]` = sample at offset k) and only modified +/// samples are written back. #[allow(clippy::too_many_arguments)] #[inline] fn filter_sample(d: &mut [u16], pos: isize, step: isize, limit: i32, blimit: i32, thresh: i32, filter_size: u8, bit_depth: u8) { - let at = |k: isize| (pos + k * step) as usize; - let s = |d: &[u16], k: isize| d[at(k)] as i32; - let q0 = s(d, 0); - let q1 = s(d, 1); - let q2 = s(d, 2); - let q3 = s(d, 3); - let p0 = s(d, -1); - let p1 = s(d, -2); - let p2 = s(d, -3); - let p3 = s(d, -4); + let reach: usize = if filter_size == TX_16X16 { 8 } else { 4 }; + let step = step as usize; + let start = pos as usize - reach * step; + let mut v = [0i32; 16]; + { + let span = &d[start..start + (2 * reach - 1) * step + 1]; + for k in 0..2 * reach { + v[8 - reach + k] = span[k * step] as i32; + } + } + let (p3, p2, p1, p0, q0, q1, q2, q3) = (v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11]); let shift = bit_depth as u32 - 8; // Filter mask process (8.8.5.1). let limit_bd = limit << shift; @@ -186,71 +188,57 @@ fn filter_sample(d: &mut [u16], pos: isize, step: isize, limit: i32, blimit: i32 && (q2 - q0).abs() <= one && (p3 - p0).abs() <= one && (q3 - q0).abs() <= one; - if filter_size == TX_4X4 || !flat { - narrow_filter(d, pos, step, hev, bit_depth, p1, p0, q0, q1); - return; - } - let flat2 = filter_size >= TX_16X16 && { - let mut ok = true; - for k in 4..8 { - if (s(d, -(k + 1)) - p0).abs() > one || (s(d, k) - q0).abs() > one { - ok = false; - break; - } - } - ok - }; - if filter_size == TX_8X8 || !flat2 { - wide_filter(d, pos, step, 3); + let (lo, hi) = if filter_size == TX_4X4 || !flat { + narrow_filter(&mut v, hev, bit_depth); + (6, 10) } else { - wide_filter(d, pos, step, 4); + let flat2 = filter_size >= TX_16X16 && (0..4).all(|k| (v[3 - k] - p0).abs() <= one && (v[12 + k] - q0).abs() <= one); + if flat2 { + wide_filter(&mut v, 4); + (1, 15) + } else { + wide_filter(&mut v, 3); + (5, 11) + } + }; + for k in lo..hi { + d[(pos + (k as isize - 8) * step as isize) as usize] = v[k] as u16; } } -/// Narrow filter process (8.8.5.2). -#[allow(clippy::too_many_arguments)] +/// Narrow filter process (8.8.5.2) on gathered samples. #[inline] -fn narrow_filter(d: &mut [u16], pos: isize, step: isize, hev: bool, bit_depth: u8, p1: i32, p0: i32, q0: i32, q1: i32) { +fn narrow_filter(v: &mut [i32; 16], hev: bool, bit_depth: u8) { let shift = bit_depth as u32 - 8; let lo = -(1 << (bit_depth - 1)); let hi = (1 << (bit_depth - 1)) - 1; - let c = |v: i32| v.clamp(lo, hi); + let c = |x: i32| x.clamp(lo, hi); let off = 0x80 << shift; - let (ps1, ps0, qs0, qs1) = (p1 - off, p0 - off, q0 - off, q1 - off); + let (ps1, ps0, qs0, qs1) = (v[6] - off, v[7] - off, v[8] - off, v[9] - off); let mut filter = if hev { c(ps1 - qs1) } else { 0 }; filter = c(filter + 3 * (qs0 - ps0)); let filter1 = c(filter + 4) >> 3; let filter2 = c(filter + 3) >> 3; - let at = |k: isize| (pos + k * step) as usize; - d[at(0)] = (c(qs0 - filter1) + off) as u16; - d[at(-1)] = (c(ps0 + filter2) + off) as u16; + v[8] = c(qs0 - filter1) + off; + v[7] = c(ps0 + filter2) + off; if !hev { let f = (filter1 + 1) >> 1; - d[at(1)] = (c(qs1 - f) + off) as u16; - d[at(-2)] = (c(ps1 + f) + off) as u16; + v[9] = c(qs1 - f) + off; + v[6] = c(ps1 + f) + off; } } -/// Wide filter process (8.8.5.3) with 2^log2 taps. +/// Wide filter process (8.8.5.3) with 2^log2 taps on gathered samples; the sum over j of the +/// specification is computed as a sliding window over the clamped indices (identical results). #[inline] -fn wide_filter(d: &mut [u16], pos: isize, step: isize, log2: u32) { +fn wide_filter(v: &mut [i32; 16], log2: u32) { let n = (1isize << (log2 - 1)) - 1; - let at = |k: isize| (pos + k * step) as usize; - let mut src = [0i32; 16]; - // src[k + 8] = sample at offset k, k in -8..8. - for k in -(n + 1)..=n { - src[(k + 8) as usize] = d[at(k)] as i32; - } - let mut f = [0i32; 16]; + let src = *v; + let s = |k: isize| src[(k.clamp(-(n + 1), n) + 8) as usize]; + let mut sum: i32 = (-n..=n).map(|j| s(-n + j)).sum(); + let round = 1i32 << (log2 - 1); for i in -n..n { - let mut t = src[(i + 8) as usize]; - for j in -n..=n { - let p = (i + j).clamp(-(n + 1), n); - t += src[(p + 8) as usize]; - } - f[(i + 8) as usize] = (t + (1 << (log2 - 1))) >> log2; - } - for i in -n..n { - d[at(i)] = f[(i + 8) as usize] as u16; + v[(i + 8) as usize] = (sum + s(i) + round) >> log2; + sum += s(i + 1 + n) - s(i - n); } } diff --git a/crates/vp9/tests/perf.rs b/crates/vp9/tests/perf.rs new file mode 100644 index 000000000..a705ad465 --- /dev/null +++ b/crates/vp9/tests/perf.rs @@ -0,0 +1,47 @@ +//! Decode speed (ignored by default; run with +//! `cargo test --release -p filmcraft-vp9 --test perf -- --ignored --nocapture`). + +mod common; + +use filmcraft_vp9::Decoder; +use std::time::Instant; + +fn bench(frames: &[&[u8]], threads: usize, iters: usize) -> (usize, f64) { + let mut best = f64::MAX; + let mut n = 0; + for _ in 0..iters { + let mut dec = Decoder::with_threads(threads); + let t0 = Instant::now(); + n = 0; + for f in frames { + n += dec.decode(f, 0).expect("decode").len(); + } + best = best.min(t0.elapsed().as_secs_f64()); + } + (n, best) +} + +fn run(name: &str, label: &str) { + let f = common::fixture(name); + let Some((ivf, _)) = common::ensure(f) else { return }; + common::check_fixture(name).unwrap(); + let data = std::fs::read(ivf).unwrap(); + let frames = common::ivf_frames(&data); + let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); + for threads in [1, cores.min(16)] { + let (n, secs) = bench(&frames, threads, 3); + println!("{label} ({} KB): {threads} thread(s): {n} frames in {secs:.3}s = {:.1} fps", data.len() / 1024, n as f64 / secs); + } +} + +#[test] +#[ignore] +fn perf_1080p() { + run("bench_1080p", "1080p 8-bit, 4 tile columns"); +} + +#[test] +#[ignore] +fn perf_4k() { + run("bench_4k", "2160p 8-bit, 8 tile columns"); +} From 8780d5ffa26ae1fac7bf34c17b7b6365d56b41c7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 00:10:59 -0400 Subject: [PATCH 04/24] T1: shared ffmpeg/ffprobe discovery in filmcraft-testkit New dev-dependency-only crate crates/testkit (L0, no FilmCraft deps): FILMCRAFT_FFMPEG/FILMCRAFT_FFPROBE, then PATH (incl. .exe), then known dirs; SKIPPED lines and FILMCRAFT_REQUIRE_ORACLES=1 to make skips fail; require_ffmpeg!/require_ffprobe!/require_oracles!; fixtures_dir() under /target/fixtures with per-thread temp names and atomic generation. Oracle helpers in aac, opus, h264, hevc, h264enc, isobmff, matroska, prores and export migrated (codecs left on its local lookup). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 18 +++ Cargo.toml | 1 + crates/aac/Cargo.toml | 3 + crates/aac/tests/common/mod.rs | 12 +- crates/export/Cargo.toml | 1 + crates/export/src/tests.rs | 2 +- crates/h264/Cargo.toml | 1 + crates/h264/src/synth_tests.rs | 15 +-- crates/h264/tests/common/mod.rs | 23 ++-- crates/h264enc/Cargo.toml | 3 + crates/h264enc/tests/common/mod.rs | 22 +--- crates/hevc/Cargo.toml | 1 + crates/hevc/src/synth_tests.rs | 13 +-- crates/hevc/tests/common/mod.rs | 29 ++--- crates/isobmff/Cargo.toml | 1 + crates/isobmff/tests/common/mod.rs | 37 ++---- crates/matroska/Cargo.toml | 1 + crates/matroska/tests/common/mod.rs | 28 +---- crates/opus/Cargo.toml | 3 + crates/opus/tests/common/mod.rs | 14 +-- crates/prores/Cargo.toml | 3 + crates/prores/tests/common/mod.rs | 19 +--- crates/testkit/Cargo.toml | 16 +++ crates/testkit/src/fixtures.rs | 102 +++++++++++++++++ crates/testkit/src/lib.rs | 59 ++++++++++ crates/testkit/src/oracle.rs | 170 ++++++++++++++++++++++++++++ docs/architecture.md | 5 +- docs/testing.md | 20 +++- xtask/src/main.rs | 3 +- 29 files changed, 462 insertions(+), 163 deletions(-) create mode 100644 crates/testkit/Cargo.toml create mode 100644 crates/testkit/src/fixtures.rs create mode 100644 crates/testkit/src/lib.rs create mode 100644 crates/testkit/src/oracle.rs diff --git a/Cargo.lock b/Cargo.lock index f1044aa2e..64ec283db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1304,6 +1304,7 @@ name = "filmcraft-aac" version = "0.1.0" dependencies = [ "filmcraft-bitstream", + "filmcraft-testkit", "rayon", "thiserror 2.0.21", ] @@ -1423,6 +1424,7 @@ dependencies = [ "filmcraft-project", "filmcraft-prores", "filmcraft-render", + "filmcraft-testkit", "filmcraft-time", "image", "rayon", @@ -1469,6 +1471,7 @@ name = "filmcraft-h264" version = "0.1.0" dependencies = [ "filmcraft-bitstream", + "filmcraft-testkit", "rayon", "thiserror 2.0.21", ] @@ -1478,6 +1481,7 @@ name = "filmcraft-h264enc" version = "0.1.0" dependencies = [ "filmcraft-bitstream", + "filmcraft-testkit", "rayon", "thiserror 2.0.21", ] @@ -1487,6 +1491,7 @@ name = "filmcraft-hevc" version = "0.1.0" dependencies = [ "filmcraft-bitstream", + "filmcraft-testkit", "rayon", "thiserror 2.0.21", ] @@ -1512,6 +1517,7 @@ name = "filmcraft-isobmff" version = "0.1.0" dependencies = [ "filmcraft-bitstream", + "filmcraft-testkit", "proptest", "serde_json", "thiserror 2.0.21", @@ -1521,6 +1527,7 @@ dependencies = [ name = "filmcraft-matroska" version = "0.1.0" dependencies = [ + "filmcraft-testkit", "serde_json", ] @@ -1540,6 +1547,9 @@ dependencies = [ [[package]] name = "filmcraft-opus" version = "0.1.0" +dependencies = [ + "filmcraft-testkit", +] [[package]] name = "filmcraft-project" @@ -1560,6 +1570,7 @@ name = "filmcraft-prores" version = "0.1.0" dependencies = [ "filmcraft-bitstream", + "filmcraft-testkit", "rayon", "thiserror 2.0.21", ] @@ -1579,6 +1590,13 @@ dependencies = [ "rayon", ] +[[package]] +name = "filmcraft-testkit" +version = "0.1.0" +dependencies = [ + "png", +] + [[package]] name = "filmcraft-time" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 589027ad7..f6fa00288 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ filmcraft-export = { path = "crates/export" } filmcraft-engine = { path = "crates/engine" } filmcraft-ui-egui = { path = "crates/ui-egui" } filmcraft-automation = { path = "crates/automation" } +filmcraft-testkit = { path = "crates/testkit" } # foundation thiserror = "2" diff --git a/crates/aac/Cargo.toml b/crates/aac/Cargo.toml index 1f1203da8..1bc264263 100644 --- a/crates/aac/Cargo.toml +++ b/crates/aac/Cargo.toml @@ -17,3 +17,6 @@ rayon = ["dep:rayon"] filmcraft-bitstream = { workspace = true } thiserror = { workspace = true } rayon = { workspace = true, optional = true } + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/aac/tests/common/mod.rs b/crates/aac/tests/common/mod.rs index 7ffc04c20..eb0551728 100644 --- a/crates/aac/tests/common/mod.rs +++ b/crates/aac/tests/common/mod.rs @@ -5,19 +5,13 @@ use std::process::Command; use filmcraft_aac::{Decoder, Encoder, EncoderConfig, EncoderStats, split_adts}; +/// ffmpeg (see `filmcraft_testkit::oracle`); `None` after printing `SKIPPED`. pub fn ffmpeg() -> Option { - for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] { - if Path::new(p).exists() { - return Some(PathBuf::from(p)); - } - } - None + filmcraft_testkit::ffmpeg_or_skip("aac oracle") } pub fn fixtures() -> PathBuf { - let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/aac"); - std::fs::create_dir_all(&d).unwrap(); - d + filmcraft_testkit::fixtures_dir("aac") } pub struct Rng(pub u64); diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index 998b0829e..23e6e0d32 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -26,4 +26,5 @@ rayon = { workspace = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif"] } [dev-dependencies] +filmcraft-testkit = { workspace = true } filmcraft-codecs = { workspace = true } diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 7747a75b8..8772f473a 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -65,7 +65,7 @@ fn mjpeg_mov_roundtrip() { let a = src.audio(0, 24_000, 48_000).unwrap(); let pk = a.peaks()[0]; assert!((pk - 0.5).abs() < 0.05, "tone at -6 dB: {pk}"); - if let Some(ffprobe) = ["/opt/homebrew/bin/ffprobe", "/usr/bin/ffprobe"].into_iter().find(|p| std::path::Path::new(p).exists()) { + if let Some(ffprobe) = filmcraft_testkit::ffprobe_or_skip("export mjpeg frame count") { let out = std::process::Command::new(ffprobe) .args(["-v", "error", "-count_frames", "-select_streams", "v:0", "-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", &path]) .output() diff --git a/crates/h264/Cargo.toml b/crates/h264/Cargo.toml index c33f682c2..f2a1eb8f3 100644 --- a/crates/h264/Cargo.toml +++ b/crates/h264/Cargo.toml @@ -19,3 +19,4 @@ thiserror = { workspace = true } rayon = { workspace = true, optional = true } [dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/h264/src/synth_tests.rs b/crates/h264/src/synth_tests.rs index dacde84ea..69c887390 100644 --- a/crates/h264/src/synth_tests.rs +++ b/crates/h264/src/synth_tests.rs @@ -437,17 +437,12 @@ type Planes3 = (Vec, Vec, Vec); /// Validate the hand-built stream and its expected output with ffmpeg when available. fn cross_check_ffmpeg(stream: &[u8], expected: &[Planes3], name: &str) { - let ff = std::path::Path::new("/opt/homebrew/bin/ffmpeg"); - if !ff.exists() { - eprintln!("ffmpeg not found; skipping oracle check of {name}"); - return; - } - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/h264"); - std::fs::create_dir_all(&dir).unwrap(); - let src = dir.join(format!("{name}.{}.h264", std::process::id())); - let dst = dir.join(format!("{name}.{}.yuv", std::process::id())); + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip(name) else { return }; + let dir = filmcraft_testkit::fixtures_dir("h264"); + let src = filmcraft_testkit::temp_path(&dir.join(format!("{name}.h264"))); + let dst = filmcraft_testkit::temp_path(&dir.join(format!("{name}.yuv"))); std::fs::write(&src, stream).unwrap(); - let ok = std::process::Command::new(ff) + let ok = std::process::Command::new(&ff) .args(["-hide_banner", "-loglevel", "error", "-y", "-i"]) .arg(&src) .args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "yuv420p"]) diff --git a/crates/h264/tests/common/mod.rs b/crates/h264/tests/common/mod.rs index dc7263000..78466ff50 100644 --- a/crates/h264/tests/common/mod.rs +++ b/crates/h264/tests/common/mod.rs @@ -307,19 +307,13 @@ pub fn fixture(name: &str) -> &'static Fixture { FIXTURES.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("unknown fixture {name}")) } +/// ffmpeg (see `filmcraft_testkit::oracle`). pub fn ffmpeg() -> Option { - for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] { - if Path::new(p).exists() { - return Some(PathBuf::from(p)); - } - } - None + filmcraft_testkit::ffmpeg() } pub fn fixtures_dir() -> PathBuf { - let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/h264"); - std::fs::create_dir_all(&d).expect("create fixtures dir"); - d + filmcraft_testkit::fixtures_dir("h264") } fn run(cmd: &mut Command) -> bool { @@ -339,15 +333,12 @@ fn run(cmd: &mut Command) -> bool { /// Generate (if needed) the fixture stream and its ffmpeg reference decode. /// Returns None (with a message) when ffmpeg is unavailable. pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { - let Some(ff) = ffmpeg() else { - eprintln!("SKIP {}: ffmpeg not found", f.name); - return None; - }; + let ff = filmcraft_testkit::ffmpeg_or_skip(f.name)?; let dir = fixtures_dir(); let h264 = dir.join(format!("{}.h264", f.name)); let yuv = dir.join(format!("{}.yuv", f.name)); if !h264.exists() { - let tmp = dir.join(format!("{}.tmp.{}.h264", f.name, std::process::id())); + let tmp = filmcraft_testkit::temp_path(&h264); let mut vf = format!("{}=size={}x{}:rate=25,format=yuv420p", f.source, f.width, f.height); if !f.filter.is_empty() { vf.push(','); @@ -364,13 +355,13 @@ pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { if !run(&mut c) { // Platform encoders (VideoToolbox) are optional; libx264 fixtures must generate. assert!(f.args.contains(&"-c:v"), "fixture generation failed for {}", f.name); - eprintln!("SKIP {}: encoder unavailable", f.name); + eprintln!("SKIPPED ({}): encoder unavailable", f.name); return None; } std::fs::rename(&tmp, &h264).unwrap(); } if !yuv.exists() { - let tmp = dir.join(format!("{}.tmp.{}.yuv", f.name, std::process::id())); + let tmp = filmcraft_testkit::temp_path(&yuv); let mut c = Command::new(&ff); c.args(["-hide_banner", "-loglevel", "error", "-y", "-i"]).arg(&h264); c.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "yuv420p"]).arg(&tmp); diff --git a/crates/h264enc/Cargo.toml b/crates/h264enc/Cargo.toml index 988eed7fd..8385ba913 100644 --- a/crates/h264enc/Cargo.toml +++ b/crates/h264enc/Cargo.toml @@ -17,3 +17,6 @@ threads = ["dep:rayon"] filmcraft-bitstream = { workspace = true } thiserror = { workspace = true } rayon = { workspace = true, optional = true } + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/h264enc/tests/common/mod.rs b/crates/h264enc/tests/common/mod.rs index 18ddc360e..409175fe0 100644 --- a/crates/h264enc/tests/common/mod.rs +++ b/crates/h264enc/tests/common/mod.rs @@ -95,29 +95,19 @@ pub fn synth(w: usize, h: usize, t: usize) -> Yuv { Yuv { w, h, y, u, v } } +/// ffmpeg (see `filmcraft_testkit::oracle`); `None` after printing `SKIPPED`. pub fn ffmpeg() -> Option { - for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] { - if Path::new(p).exists() { - return Some(p.into()); - } - } - None + filmcraft_testkit::ffmpeg_or_skip("h264enc oracle") } +/// ffprobe (see `filmcraft_testkit::oracle`); `None` after printing `SKIPPED`. pub fn ffprobe() -> Option { - for p in ["/opt/homebrew/bin/ffprobe", "/usr/local/bin/ffprobe", "/usr/bin/ffprobe"] { - if Path::new(p).exists() { - return Some(p.into()); - } - } - None + filmcraft_testkit::ffprobe_or_skip("h264enc oracle") } +/// Per-test output directory under `/target/fixtures/h264enc/`. pub fn out_dir(name: &str) -> PathBuf { - let base = std::env::var("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target")); - let d = base.join("fixtures").join("h264enc").join(name); - std::fs::create_dir_all(&d).unwrap(); - d + filmcraft_testkit::fixtures_dir(&format!("h264enc/{name}")) } /// Decode an Annex-B file with ffmpeg; returns (stderr, raw yuv420p bytes). diff --git a/crates/hevc/Cargo.toml b/crates/hevc/Cargo.toml index 7e963a21c..b401435cd 100644 --- a/crates/hevc/Cargo.toml +++ b/crates/hevc/Cargo.toml @@ -19,3 +19,4 @@ thiserror = { workspace = true } rayon = { workspace = true, optional = true } [dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/hevc/src/synth_tests.rs b/crates/hevc/src/synth_tests.rs index 291a2ad05..44b8a828a 100644 --- a/crates/hevc/src/synth_tests.rs +++ b/crates/hevc/src/synth_tests.rs @@ -546,14 +546,10 @@ fn as_u8(p: &crate::Plane) -> &[u8] { /// Compare with ffmpeg's decode of the same stream (skipped when ffmpeg is absent). fn check_ffmpeg(name: &str, stream: &[u8], pics: &[crate::Picture]) { - let Some(ff) = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].iter().find(|p| std::path::Path::new(p).exists()) else { - eprintln!("SKIP ffmpeg check for {name}"); - return; - }; - let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/hevc"); - std::fs::create_dir_all(&dir).unwrap(); - let src = dir.join(format!("synth_{name}.hevc")); - let yuv = dir.join(format!("synth_{name}.yuv")); + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip(name) else { return }; + let dir = filmcraft_testkit::fixtures_dir("hevc"); + let src = filmcraft_testkit::temp_path(&dir.join(format!("synth_{name}.hevc"))); + let yuv = filmcraft_testkit::temp_path(&dir.join(format!("synth_{name}.yuv"))); std::fs::write(&src, stream).unwrap(); let st = std::process::Command::new(ff) .args(["-hide_banner", "-loglevel", "error", "-y", "-i"]) @@ -577,6 +573,7 @@ fn check_ffmpeg(name: &str, stream: &[u8], pics: &[crate::Picture]) { } } let _ = std::fs::remove_file(&yuv); + let _ = std::fs::remove_file(&src); } fn run(name: &str, tiles: Tiles, pcm_lf_disabled: bool, segments: Vec) { diff --git a/crates/hevc/tests/common/mod.rs b/crates/hevc/tests/common/mod.rs index 2da4dabed..56d368294 100644 --- a/crates/hevc/tests/common/mod.rs +++ b/crates/hevc/tests/common/mod.rs @@ -85,19 +85,13 @@ pub fn fixture(name: &str) -> &'static Fixture { FIXTURES.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("unknown fixture {name}")) } +/// ffmpeg (see `filmcraft_testkit::oracle`). pub fn ffmpeg() -> Option { - for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] { - if Path::new(p).exists() { - return Some(PathBuf::from(p)); - } - } - None + filmcraft_testkit::ffmpeg() } pub fn fixtures_dir() -> PathBuf { - let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/hevc"); - std::fs::create_dir_all(&d).expect("create fixtures dir"); - d + filmcraft_testkit::fixtures_dir("hevc") } fn run(cmd: &mut Command) -> bool { @@ -121,15 +115,12 @@ pub fn pix_fmt(bd: u32) -> &'static str { /// Generate (if needed) the fixture stream and its ffmpeg reference decode. /// Returns None (with a message) when ffmpeg or the encoder is unavailable. pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { - let Some(ff) = ffmpeg() else { - eprintln!("SKIP {}: ffmpeg not found", f.name); - return None; - }; + let ff = filmcraft_testkit::ffmpeg_or_skip(f.name)?; let dir = fixtures_dir(); let hevc = dir.join(format!("{}.hevc", f.name)); let yuv = dir.join(format!("{}.yuv", f.name)); if !hevc.exists() { - let tmp = dir.join(format!("{}.tmp.{}.hevc", f.name, unique())); + let tmp = filmcraft_testkit::temp_path(&hevc); let mut vf = format!("{}=size={}x{}:rate=25,format={}", f.source, f.width, f.height, pix_fmt(f.bit_depth)); if !f.filter.is_empty() { vf.push(','); @@ -148,13 +139,13 @@ pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { // Platform encoders (VideoToolbox) are optional; libx265 fixtures must generate. assert!(f.args.contains(&"-c:v"), "fixture generation failed for {}", f.name); let _ = std::fs::remove_file(&tmp); - eprintln!("SKIP {}: encoder unavailable", f.name); + eprintln!("SKIPPED ({}): encoder unavailable", f.name); return None; } std::fs::rename(&tmp, &hevc).unwrap(); } if !yuv.exists() { - let tmp = dir.join(format!("{}.tmp.{}.yuv", f.name, unique())); + let tmp = filmcraft_testkit::temp_path(&yuv); let mut c = Command::new(&ff); c.args(["-hide_banner", "-loglevel", "error", "-y", "-i"]).arg(&hevc); c.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt(f.bit_depth)]).arg(&tmp); @@ -316,9 +307,3 @@ pub fn check_pts(pics: &[Picture]) -> Result<(), String> { } Ok(()) } - -/// Unique temp-file suffix: process id plus a per-process counter (tests run on parallel threads). -fn unique() -> String { - static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); - format!("{}-{}", std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) -} diff --git a/crates/isobmff/Cargo.toml b/crates/isobmff/Cargo.toml index 620051ad1..c9b848fa0 100644 --- a/crates/isobmff/Cargo.toml +++ b/crates/isobmff/Cargo.toml @@ -14,5 +14,6 @@ filmcraft-bitstream = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +filmcraft-testkit = { workspace = true } proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/isobmff/tests/common/mod.rs b/crates/isobmff/tests/common/mod.rs index 209f148e0..895c217d4 100644 --- a/crates/isobmff/tests/common/mod.rs +++ b/crates/isobmff/tests/common/mod.rs @@ -6,37 +6,27 @@ use serde_json::Value; use std::path::{Path, PathBuf}; use std::process::Command; +/// Find a tool silently (see `filmcraft_testkit::oracle`). pub fn tool(name: &str) -> Option { - for dir in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] { - let p = Path::new(dir).join(name); - if p.exists() { - return Some(p); - } + match name { + "ffmpeg" => filmcraft_testkit::ffmpeg(), + "ffprobe" => filmcraft_testkit::ffprobe(), + _ => filmcraft_testkit::oracle::find_tool(name), } - let ok = Command::new(name).arg("-version").output().map(|o| o.status.success()).unwrap_or(false); - ok.then(|| PathBuf::from(name)) } +/// ffmpeg, or `None` after printing `SKIPPED` (a failure with `FILMCRAFT_REQUIRE_ORACLES=1`). pub fn ffmpeg() -> Option { - let f = tool("ffmpeg"); - if f.is_none() { - eprintln!("ffmpeg not found; skipping oracle test"); - } - f + filmcraft_testkit::ffmpeg_or_skip("isobmff oracle") } +/// ffprobe, or `None` after printing `SKIPPED` (a failure with `FILMCRAFT_REQUIRE_ORACLES=1`). pub fn ffprobe() -> Option { - let f = tool("ffprobe"); - if f.is_none() { - eprintln!("ffprobe not found; skipping oracle test"); - } - f + filmcraft_testkit::ffprobe_or_skip("isobmff oracle") } pub fn fixture_dir() -> PathBuf { - let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/isobmff"); - std::fs::create_dir_all(&d).unwrap(); - d + filmcraft_testkit::fixtures_dir("isobmff") } pub fn out_dir() -> PathBuf { @@ -120,10 +110,7 @@ pub fn fixture_specs() -> Vec<(&'static str, Vec<&'static str>)> { } fn run_ffmpeg(ff: &Path, args: &[&str], out: &Path) -> bool { - static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); - let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let ext = out.extension().unwrap().to_str().unwrap(); - let tmp = out.with_extension(format!("tmp{}_{n}.{ext}", std::process::id())); + let tmp = filmcraft_testkit::temp_path(out); let st = Command::new(ff).args(["-v", "error", "-y", "-nostdin"]).args(args).arg(&tmp).output(); match st { Ok(o) if o.status.success() => std::fs::rename(&tmp, out).is_ok(), @@ -139,7 +126,7 @@ fn run_ffmpeg(ff: &Path, args: &[&str], out: &Path) -> bool { /// Path to a fixture, generating it with ffmpeg if needed. `None` if ffmpeg is missing or the /// encoder isn't available. pub fn fixture(name: &str) -> Option { - let ff = tool("ffmpeg")?; + let ff = ffmpeg()?; let path = fixture_dir().join(name); if path.exists() { return Some(path); diff --git a/crates/matroska/Cargo.toml b/crates/matroska/Cargo.toml index 9b74dad0b..9f64162fb 100644 --- a/crates/matroska/Cargo.toml +++ b/crates/matroska/Cargo.toml @@ -12,4 +12,5 @@ workspace = true [dependencies] [dev-dependencies] +filmcraft-testkit = { workspace = true } serde_json = { workspace = true } diff --git a/crates/matroska/tests/common/mod.rs b/crates/matroska/tests/common/mod.rs index 2e0fd8722..1ef7830f4 100644 --- a/crates/matroska/tests/common/mod.rs +++ b/crates/matroska/tests/common/mod.rs @@ -6,32 +6,13 @@ use serde_json::Value; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -pub fn tool(name: &str) -> Option { - for dir in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] { - let p = Path::new(dir).join(name); - if p.exists() { - return Some(p); - } - } - let ok = Command::new(name).arg("-version").output().map(|o| o.status.success()).unwrap_or(false); - ok.then(|| PathBuf::from(name)) -} - -/// (ffmpeg, ffprobe), or `None` (test skipped) if either is missing. +/// (ffmpeg, ffprobe), or `None` after printing `SKIPPED` (see `filmcraft_testkit::oracle`). pub fn tools() -> Option<(PathBuf, PathBuf)> { - match (tool("ffmpeg"), tool("ffprobe")) { - (Some(a), Some(b)) => Some((a, b)), - _ => { - eprintln!("ffmpeg/ffprobe not found; skipping oracle test"); - None - } - } + Some((filmcraft_testkit::ffmpeg_or_skip("matroska oracle")?, filmcraft_testkit::ffprobe_or_skip("matroska oracle")?)) } pub fn fixture_dir() -> PathBuf { - let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/matroska"); - std::fs::create_dir_all(&d).unwrap(); - d + filmcraft_testkit::fixtures_dir("matroska") } const V: &[&str] = &["-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25"]; @@ -93,8 +74,7 @@ pub fn fixture(ffmpeg: &Path, name: &str) -> Option { return Some(out); } write_subs(&dir); - let tid = format!("{:?}", std::thread::current().id()).replace(|c: char| !c.is_ascii_alphanumeric(), ""); - let tmp = dir.join(format!("{name}.{}.{tid}.tmp", std::process::id())); + let tmp = filmcraft_testkit::temp_path(&out); let ok = match spec(name) { Some(args) => { let mut c = Command::new(ffmpeg); diff --git a/crates/opus/Cargo.toml b/crates/opus/Cargo.toml index 463a94081..1ae2c8258 100644 --- a/crates/opus/Cargo.toml +++ b/crates/opus/Cargo.toml @@ -10,3 +10,6 @@ rust-version.workspace = true workspace = true [dependencies] + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/opus/tests/common/mod.rs b/crates/opus/tests/common/mod.rs index 712edd620..6c6ec2754 100644 --- a/crates/opus/tests/common/mod.rs +++ b/crates/opus/tests/common/mod.rs @@ -1,6 +1,6 @@ #![allow(dead_code)] -use std::path::{Path, PathBuf}; +use std::path::PathBuf; /// Plain SNR (dB) of `test` against `reference` over the common length. pub fn snr_db(reference: &[f32], test: &[f32]) -> f64 { @@ -138,19 +138,13 @@ pub fn opus_quality(reference: &[f32], test: &[f32], channels: usize, rate: u32) 100.0 * (1.0 - 0.5 * (1.0 + err).ln() / 1.13f64.ln()) } +/// ffmpeg (see `filmcraft_testkit::oracle`); `None` after printing `SKIPPED`. pub fn ffmpeg() -> Option { - for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] { - if Path::new(p).exists() { - return Some(PathBuf::from(p)); - } - } - None + filmcraft_testkit::ffmpeg_or_skip("opus oracle") } pub fn fixtures() -> PathBuf { - let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/opus/oracle"); - std::fs::create_dir_all(&d).unwrap(); - d + filmcraft_testkit::fixtures_dir("opus/oracle") } /// Minimal Ogg demuxer: returns (packets, last granule position). diff --git a/crates/prores/Cargo.toml b/crates/prores/Cargo.toml index aa3f2f117..67c2002d1 100644 --- a/crates/prores/Cargo.toml +++ b/crates/prores/Cargo.toml @@ -17,3 +17,6 @@ threads = ["dep:rayon"] filmcraft-bitstream = { workspace = true } thiserror = { workspace = true } rayon = { workspace = true, optional = true } + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/prores/tests/common/mod.rs b/crates/prores/tests/common/mod.rs index d567dac6a..9b3192aa0 100644 --- a/crates/prores/tests/common/mod.rs +++ b/crates/prores/tests/common/mod.rs @@ -6,24 +6,13 @@ use filmcraft_prores::{ChromaFormat, Frame}; use std::path::{Path, PathBuf}; use std::process::Command; +/// ffmpeg, or `None` after printing `SKIPPED` (see `filmcraft_testkit::oracle`). pub fn ffmpeg() -> Option { - for dir in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] { - let p = Path::new(dir).join("ffmpeg"); - if p.exists() { - return Some(p); - } - } - let ok = Command::new("ffmpeg").arg("-version").output().map(|o| o.status.success()).unwrap_or(false); - if !ok { - eprintln!("ffmpeg not found; skipping oracle test"); - } - ok.then(|| PathBuf::from("ffmpeg")) + filmcraft_testkit::ffmpeg_or_skip("prores oracle") } pub fn fixture_dir() -> PathBuf { - let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/prores"); - std::fs::create_dir_all(&d).unwrap(); - d + filmcraft_testkit::fixtures_dir("prores") } fn run(ff: &Path, args: &[&str]) { @@ -51,7 +40,7 @@ impl Spec { pub fn make(ff: &Path, spec: &Spec) -> PathBuf { let path = fixture_dir().join(format!("{}.mov", spec.name)); if !path.exists() { - let tmp = fixture_dir().join(format!("{}.tmp.mov", spec.name)); + let tmp = filmcraft_testkit::temp_path(&path); let frames = spec.frames.to_string(); let mut args = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str()]; args.extend(spec.enc.iter().copied()); diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml new file mode 100644 index 000000000..eca8a6c87 --- /dev/null +++ b/crates/testkit/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "filmcraft-testkit" +description = "Test-only helpers: ffmpeg/ffprobe oracle discovery, fixture directories, golden-image comparison" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +[lints] +workspace = true + +# Used only as a dev-dependency. No FilmCraft dependencies, so any crate (including L0 codecs) can +# use it without creating dependency cycles. +[dependencies] +png = "0.18" diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs new file mode 100644 index 000000000..f6c2f781a --- /dev/null +++ b/crates/testkit/src/fixtures.rs @@ -0,0 +1,102 @@ +//! Fixture locations and race-free generation. +//! +//! Fixtures live in `/target/fixtures//` whatever `CARGO_TARGET_DIR` is, so +//! parallel agents with private target dirs share one generated set. `FILMCRAFT_FIXTURES_DIR` +//! overrides the root. Media is never committed. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// The workspace root (the directory holding the top-level `Cargo.toml`). +pub fn workspace_root() -> PathBuf { + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + let root = here.parent().and_then(Path::parent).unwrap_or(here); + root.canonicalize().unwrap_or_else(|_| root.to_path_buf()) +} + +/// Root of all generated fixtures: `$FILMCRAFT_FIXTURES_DIR` or `/target/fixtures`. +pub fn fixtures_root() -> PathBuf { + match std::env::var_os("FILMCRAFT_FIXTURES_DIR").filter(|v| !v.is_empty()) { + Some(d) => PathBuf::from(d), + None => workspace_root().join("target").join("fixtures"), + } +} + +/// `/` (created). `sub` may contain `/` (e.g. `"opus/oracle"`). +pub fn fixtures_dir(sub: &str) -> PathBuf { + let mut d = fixtures_root(); + for part in sub.split('/').filter(|p| !p.is_empty()) { + d.push(part); + } + std::fs::create_dir_all(&d).unwrap_or_else(|e| panic!("create {}: {e}", d.display())); + d +} + +/// A temporary sibling of `out` that no other thread or process will pick: the stem gets +/// `.tmp---` and the extension is kept (ffmpeg chooses the muxer from it). +pub fn temp_path(out: &Path) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let tid = format!("{:?}", std::thread::current().id()).replace(|c: char| !c.is_ascii_alphanumeric(), ""); + let n = N.fetch_add(1, Ordering::Relaxed); + let stem = out.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + let name = match out.extension() { + Some(ext) => format!("{stem}.tmp-{}-{tid}-{n}.{}", std::process::id(), ext.to_string_lossy()), + None => format!("{stem}.tmp-{}-{tid}-{n}", std::process::id()), + }; + out.with_file_name(name) +} + +/// Return `out` if it already exists (non-empty); otherwise run `make(tmp)` on a [`temp_path`] +/// and atomically rename the result into place. Concurrent generators race harmlessly. +/// `None` when `make` reports failure (the temporary file is removed). +pub fn generate(out: &Path, make: impl FnOnce(&Path) -> bool) -> Option { + if std::fs::metadata(out).is_ok_and(|m| m.len() > 0) { + return Some(out.to_path_buf()); + } + if let Some(dir) = out.parent() { + let _ = std::fs::create_dir_all(dir); + } + let tmp = temp_path(out); + if !make(&tmp) || !tmp.exists() { + let _ = std::fs::remove_file(&tmp); + return None; + } + std::fs::rename(&tmp, out).ok()?; + Some(out.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn root_is_the_workspace() { + assert!(workspace_root().join("Cargo.toml").exists()); + assert!(workspace_root().join("crates").join("testkit").exists()); + } + + #[test] + fn temp_paths_are_unique_and_keep_the_extension() { + let out = Path::new("/x/clip.mp4"); + let a = temp_path(out); + let b = temp_path(out); + assert_ne!(a, b); + assert_eq!(a.extension().unwrap(), "mp4"); + let other = std::thread::spawn(move || temp_path(Path::new("/x/clip.mp4"))).join().unwrap(); + assert_ne!(a, other); + } + + #[test] + fn generate_is_cached_and_atomic() { + let dir = std::env::temp_dir().join(format!("filmcraft-testkit-gen-{}", std::process::id())); + let out = dir.join("f.bin"); + let _ = std::fs::remove_file(&out); + assert!(generate(&out, |_| false).is_none()); + assert!(!out.exists()); + let p = generate(&out, |t| std::fs::write(t, b"abc").is_ok()).unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"abc"); + // cached: the generator is not called again + let p2 = generate(&out, |_| panic!("regenerated")).unwrap(); + assert_eq!(p, p2); + } +} diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs new file mode 100644 index 000000000..f617e9221 --- /dev/null +++ b/crates/testkit/src/lib.rs @@ -0,0 +1,59 @@ +//! Test-only helpers shared by every FilmCraft crate (used as a dev-dependency only). +//! +//! - [`oracle`]: find `ffmpeg` / `ffprobe` (env vars, `PATH`, well-known dirs) and skip — or, with +//! `FILMCRAFT_REQUIRE_ORACLES=1`, fail — when they are missing ([`require_ffmpeg!`] & co.). +//! - [`fixtures`]: the shared fixture directory (`/target/fixtures/`) and +//! collision-free temporary names for concurrent generators. +//! +//! ffmpeg/ffprobe are external test oracles and fixture generators only; they are never linked +//! or shipped (AGENTS.md §2). + +pub mod fixtures; +pub mod oracle; + +pub use fixtures::{fixtures_dir, temp_path, workspace_root}; +pub use oracle::{ffmpeg, ffmpeg_or_skip, ffprobe, ffprobe_or_skip, oracles_required, skip}; + +/// `let ff = require_ffmpeg!();` — the ffmpeg path, or return from the test after printing +/// `SKIPPED: … ffmpeg not found` (a hard failure with `FILMCRAFT_REQUIRE_ORACLES=1`). +/// `require_ffmpeg!(value)` returns `value` instead of `()`. +#[macro_export] +macro_rules! require_ffmpeg { + () => { + match $crate::ffmpeg_or_skip(concat!(module_path!(), ":", line!())) { + Some(p) => p, + None => return, + } + }; + ($ret:expr) => { + match $crate::ffmpeg_or_skip(concat!(module_path!(), ":", line!())) { + Some(p) => p, + None => return $ret, + } + }; +} + +/// Like [`require_ffmpeg!`] for ffprobe. +#[macro_export] +macro_rules! require_ffprobe { + () => { + match $crate::ffprobe_or_skip(concat!(module_path!(), ":", line!())) { + Some(p) => p, + None => return, + } + }; + ($ret:expr) => { + match $crate::ffprobe_or_skip(concat!(module_path!(), ":", line!())) { + Some(p) => p, + None => return $ret, + } + }; +} + +/// `let (ffmpeg, ffprobe) = require_oracles!();` — both tools or skip. +#[macro_export] +macro_rules! require_oracles { + () => { + ($crate::require_ffmpeg!(), $crate::require_ffprobe!()) + }; +} diff --git a/crates/testkit/src/oracle.rs b/crates/testkit/src/oracle.rs new file mode 100644 index 000000000..8dbdbd3f7 --- /dev/null +++ b/crates/testkit/src/oracle.rs @@ -0,0 +1,170 @@ +//! Discovery of the external oracle tools (ffmpeg, ffprobe). +//! +//! Search order for a tool `T` (`ffmpeg` or `ffprobe`): +//! 1. `FILMCRAFT_FFMPEG` / `FILMCRAFT_FFPROBE`: an explicit path (a set-but-wrong path is an error, +//! not a silent skip); +//! 2. every directory on `PATH` (`T`, and `T.exe` on Windows); +//! 3. well-known install locations (`/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, +//! `C:\ffmpeg\bin`, …). +//! +//! When a tool is missing, tests print `SKIPPED: …` and pass — unless +//! `FILMCRAFT_REQUIRE_ORACLES=1` is set (CI), which turns every skip into a failure. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// Environment variable that makes missing oracles a hard failure. +pub const REQUIRE_ENV: &str = "FILMCRAFT_REQUIRE_ORACLES"; + +const KNOWN_DIRS: &[&str] = &[ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/opt/local/bin", + "/snap/bin", + r"C:\ffmpeg\bin", + r"C:\Program Files\ffmpeg\bin", + r"C:\ProgramData\chocolatey\bin", +]; + +fn exe_names(name: &str) -> Vec { + if cfg!(windows) { vec![format!("{name}.exe"), name.to_string()] } else { vec![name.to_string(), format!("{name}.exe")] } +} + +fn is_file(p: &Path) -> bool { + p.is_file() +} + +/// Find `name` given an explicit override, a `PATH`-style list and extra directories. +/// (Pure function of its inputs; [`find_tool`] supplies the process environment.) +pub fn find_tool_in(name: &str, explicit: Option<&std::ffi::OsStr>, path: Option<&std::ffi::OsStr>, extra: &[&str]) -> Result, String> { + if let Some(e) = explicit.filter(|e| !e.is_empty()) { + let p = PathBuf::from(e); + return if is_file(&p) { Ok(Some(p)) } else { Err(format!("{} is set but `{}` is not a file", env_var(name), p.display())) }; + } + let names = exe_names(name); + if let Some(path) = path { + for dir in std::env::split_paths(path) { + for n in &names { + let p = dir.join(n); + if is_file(&p) { + return Ok(Some(p)); + } + } + } + } + for dir in extra { + for n in &names { + let p = Path::new(dir).join(n); + if is_file(&p) { + return Ok(Some(p)); + } + } + } + Ok(None) +} + +fn env_var(name: &str) -> String { + format!("FILMCRAFT_{}", name.to_ascii_uppercase()) +} + +/// Find a tool using the process environment (uncached). +pub fn find_tool(name: &str) -> Option { + let explicit = std::env::var_os(env_var(name)); + let path = std::env::var_os("PATH"); + match find_tool_in(name, explicit.as_deref(), path.as_deref(), KNOWN_DIRS) { + Ok(p) => p, + Err(e) => panic!("{e}"), + } +} + +/// Path of ffmpeg, if available (cached for the process). +pub fn ffmpeg() -> Option { + static F: OnceLock> = OnceLock::new(); + F.get_or_init(|| find_tool("ffmpeg")).clone() +} + +/// Path of ffprobe, if available (cached for the process). +pub fn ffprobe() -> Option { + static F: OnceLock> = OnceLock::new(); + F.get_or_init(|| find_tool("ffprobe")).clone() +} + +/// Whether `FILMCRAFT_REQUIRE_ORACLES` is set to a true value. +pub fn oracles_required() -> bool { + std::env::var(REQUIRE_ENV).is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) +} + +/// Report a skipped test: prints `SKIPPED (): `. With +/// `FILMCRAFT_REQUIRE_ORACLES=1` it panics instead, so CI cannot pass by skipping. +pub fn skip(context: &str, reason: &str) { + if oracles_required() { + panic!("{context}: {reason}, and {REQUIRE_ENV}=1 forbids skipping oracle tests"); + } + eprintln!("SKIPPED ({context}): {reason}"); +} + +fn tool_or_skip(name: &str, found: Option, context: &str) -> Option { + if found.is_none() { + skip(context, &format!("{name} not found (set {} or put {name} on PATH)", env_var(name))); + } + found +} + +/// ffmpeg, or `None` after reporting a skip (see [`skip`]). +pub fn ffmpeg_or_skip(context: &str) -> Option { + tool_or_skip("ffmpeg", ffmpeg(), context) +} + +/// ffprobe, or `None` after reporting a skip (see [`skip`]). +pub fn ffprobe_or_skip(context: &str) -> Option { + tool_or_skip("ffprobe", ffprobe(), context) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + + fn tmpdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("filmcraft-testkit-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn explicit_path_wins_and_bad_path_errors() { + let d = tmpdir("explicit"); + let f = d.join("my-ffmpeg"); + std::fs::write(&f, b"").unwrap(); + let got = find_tool_in("ffmpeg", Some(f.as_os_str()), None, &[]).unwrap(); + assert_eq!(got, Some(f.clone())); + let missing = d.join("nope"); + assert!(find_tool_in("ffmpeg", Some(missing.as_os_str()), None, &[]).is_err()); + // empty value = unset + assert_eq!(find_tool_in("ffmpeg", Some(OsString::new().as_os_str()), None, &[]).unwrap(), None); + } + + #[test] + fn path_search_finds_plain_and_exe_names() { + let a = tmpdir("path-a"); + let b = tmpdir("path-b"); + std::fs::write(b.join("ffprobe.exe"), b"").unwrap(); + let path = std::env::join_paths([&a, &b]).unwrap(); + let got = find_tool_in("ffprobe", None, Some(&path), &[]).unwrap(); + assert_eq!(got, Some(b.join("ffprobe.exe"))); + std::fs::write(a.join("ffprobe"), b"").unwrap(); + let got = find_tool_in("ffprobe", None, Some(&path), &[]).unwrap(); + assert!(got.is_some_and(|p| p.starts_with(&a)), "earlier PATH entry wins"); + } + + #[test] + fn known_dirs_are_the_fallback() { + let d = tmpdir("known"); + std::fs::write(d.join("ffmpeg"), b"").unwrap(); + let ds = d.to_string_lossy().to_string(); + let got = find_tool_in("ffmpeg", None, Some(OsString::new().as_os_str()), &[ds.as_str()]).unwrap(); + assert_eq!(got, Some(d.join("ffmpeg"))); + assert_eq!(find_tool_in("ffmpeg-does-not-exist", None, None, &[ds.as_str()]).unwrap(), None); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index f8c6b731c..5a488be55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,7 +26,7 @@ Design principles: L3 render · gpu · export L2 edit · codecs · interchange L1 frame · media · project · audio-dsp - L0 foundation: time · geom · color · bitstream + L0 foundation: time · geom · color · bitstream · testkit (dev-dependency only) codecs/containers: isobmff · matroska · h264 · h264enc · hevc · prores · aac ``` @@ -45,6 +45,7 @@ and `filmcraft-cli`. | `hevc` | L0 | H.265 Main/Main 10 decoder | | `prores` | L0 | ProRes decoder and encoder | | `aac` | L0 | AAC-LC decoder and encoder | +| `testkit` | L0 | test-only helpers, used only as a dev-dependency: ffmpeg/ffprobe discovery, fixture dirs, golden images ([testing.md](testing.md)) | | `frame` | L1 | `VideoFrame` (planar YUV / RGBA8 / linear RGBA f32, colour metadata), `AudioBuffer` | | `media` | L1 | `MediaSource` trait, probing/openers, frame cache, generators, stills, WAV | | `project` | L1 | document model, effect definitions, keyframes | @@ -72,7 +73,7 @@ are exempt): | Every crate has a layer | A new crate fails the check until it is added to `LAYERS`. | | Only downward edges | A crate may not depend on a crate in a higher layer. | | Same-layer edges are listed | From L1 up, a same-layer edge must be in `SAME_LAYER`: `media→frame`, `project→media`, `project→frame`, `gpu→render`, `export→render`, `cli→filmcraft`, plus a few reserved for planned crates. | -| L0 codecs stay standalone | L0 crates other than `time`, `geom`, `color`, `bitstream` may depend on no workspace crate except `filmcraft-bitstream`. External crates such as `thiserror` and `rayon` are allowed. | +| L0 codecs stay standalone | L0 crates other than `time`, `geom`, `color`, `bitstream`, `testkit` may depend on no workspace crate except `filmcraft-bitstream`. External crates such as `thiserror` and `rayon` are allowed. | | No UI/OS crates below L5 | `egui`, `eframe`, `egui-wgpu`, `winit`, `rfd`, `cpal`, `muda` are allowed only in L5 and L6. | `cargo xtask wasm` runs `cargo check --target wasm32-unknown-unknown` on every L0–L4 crate, so diff --git a/docs/testing.md b/docs/testing.md index dc1327df3..c2300a155 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -27,10 +27,22 @@ Commit `*.proptest-regressions` files so failing cases are re-run. ffmpeg and ffprobe are **external processes** used to generate fixtures and to check results. They are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). -- Each crate's `tests/common/mod.rs` finds the tools in `/opt/homebrew/bin`, `/usr/local/bin` or - `/usr/bin`. If they are missing, the test prints a message and returns (it passes). -- Fixtures are generated on first use into `/target/fixtures//` and reused afterwards. - Delete that directory to regenerate them. Never commit media. +- The tools are found by `filmcraft-testkit` (a dev-dependency-only crate, `crates/testkit`), in + this order: `FILMCRAFT_FFMPEG` / `FILMCRAFT_FFPROBE` (explicit paths; a wrong path is an error), + then every directory on `PATH` (`ffmpeg` and `ffmpeg.exe`), then well-known install directories + (`/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `C:\ffmpeg\bin`, …). +- If a tool is missing the test prints `SKIPPED (): ffmpeg not found …` and passes. Set + **`FILMCRAFT_REQUIRE_ORACLES=1`** (CI) to make every such skip a failure. In new tests use + `let ff = filmcraft_testkit::require_ffmpeg!();` (also `require_ffprobe!`, `require_oracles!`). +- Fixtures are generated on first use into `/target/fixtures//` + (`filmcraft_testkit::fixtures_dir`; independent of `CARGO_TARGET_DIR`, so agents with private + target dirs share them; `FILMCRAFT_FIXTURES_DIR` overrides the root) and reused afterwards. + Generators write to a per-process/thread temporary name (`testkit::temp_path`) and rename it into + place, so concurrent tests never see half-written files. Delete the directory to regenerate. + Never commit media. +- Every crate's `tests/common/mod.rs` delegates discovery and fixture paths to testkit, except + `crates/codecs/src/tests.rs`, which still has its own lookup (the crate was being edited + concurrently; migrate it when convenient). - Fixture sources are synthetic: `testsrc2`, `mandelbrot`, SMPTE bars, noise and fades, sine tones. H.264 and HEVC fixtures need ffmpeg built with libx264 and libx265. VideoToolbox fixtures are generated only on macOS. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index cec79fadb..85c085b28 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -13,6 +13,7 @@ use serde_json::Value; /// (crate name without the `filmcraft-` prefix, layer). const LAYERS: &[(&str, u8)] = &[ + ("testkit", 0), ("time", 0), ("geom", 0), ("color", 0), @@ -55,7 +56,7 @@ const LAYERS: &[(&str, u8)] = &[ ]; /// L0 crates that are not codecs/containers (no `bitstream`-only restriction). -const L0_FOUNDATION: &[&str] = &["time", "geom", "color", "bitstream"]; +const L0_FOUNDATION: &[&str] = &["time", "geom", "color", "bitstream", "testkit"]; /// Allowed same-layer edges (from, to). const SAME_LAYER: &[(&str, &str)] = &[ From 3704002f89580a0bbf956bd11036d0cc7c7e3eef Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 00:20:59 -0400 Subject: [PATCH 05/24] T2: cargo xtask fixtures pre-generates the oracle fixture matrix Runs each crate's ignored generate_fixtures test (h264, hevc, isobmff, matroska, prores) - the same generators the oracle tests use - and prints made/cached/skipped per fixture plus a summary. Optional crate filter: cargo xtask fixtures hevc prores. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/h264/tests/conformance.rs | 11 +++++++ crates/hevc/tests/conformance.rs | 11 +++++++ crates/isobmff/tests/oracle_demux.rs | 10 ++++++ crates/matroska/tests/common/mod.rs | 16 ++++++++++ crates/matroska/tests/oracle.rs | 11 +++++++ crates/prores/tests/oracle_decode.rs | 11 +++++++ crates/testkit/src/fixtures.rs | 34 ++++++++++++++++++++ docs/testing.md | 8 +++++ xtask/Cargo.toml | 1 + xtask/src/main.rs | 47 +++++++++++++++++++++++++++- 11 files changed, 160 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 64ec283db..c0c0ca828 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5626,6 +5626,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" name = "xtask" version = "0.1.0" dependencies = [ + "filmcraft-testkit", "serde_json", ] diff --git a/crates/h264/tests/conformance.rs b/crates/h264/tests/conformance.rs index dc4ddaea5..a2654c9cd 100644 --- a/crates/h264/tests/conformance.rs +++ b/crates/h264/tests/conformance.rs @@ -95,3 +95,14 @@ fn coverage_report() { ); } } + +/// Pre-generate every fixture and its reference decode (`cargo xtask fixtures`). +#[test] +#[ignore] +fn generate_fixtures() { + let dir = common::fixtures_dir(); + for f in common::FIXTURES { + let outs = [dir.join(format!("{}.h264", f.name)), dir.join(format!("{}.yuv", f.name))]; + filmcraft_testkit::fixtures::generate_and_report(&format!("h264/{}", f.name), &outs, || common::ensure(f)); + } +} diff --git a/crates/hevc/tests/conformance.rs b/crates/hevc/tests/conformance.rs index 0dfd0d8cd..a6109ddfe 100644 --- a/crates/hevc/tests/conformance.rs +++ b/crates/hevc/tests/conformance.rs @@ -91,3 +91,14 @@ fn compare_detects_mismatch() { let err = common::compare(&pics, &reference, f.width as usize, f.height as usize, f.bit_depth).unwrap_err(); assert!(err.contains("plane V"), "{err}"); } + +/// Pre-generate every fixture and its reference decode (`cargo xtask fixtures`). +#[test] +#[ignore] +fn generate_fixtures() { + let dir = common::fixtures_dir(); + for f in common::FIXTURES { + let outs = [dir.join(format!("{}.hevc", f.name)), dir.join(format!("{}.yuv", f.name))]; + filmcraft_testkit::fixtures::generate_and_report(&format!("hevc/{}", f.name), &outs, || common::ensure(f)); + } +} diff --git a/crates/isobmff/tests/oracle_demux.rs b/crates/isobmff/tests/oracle_demux.rs index ee621ee7d..45ffe3b58 100644 --- a/crates/isobmff/tests/oracle_demux.rs +++ b/crates/isobmff/tests/oracle_demux.rs @@ -271,3 +271,13 @@ fn specific_properties() { let v = &f.tracks[f.track_of_kind(TrackKind::Video).unwrap()]; assert!(v.references.iter().any(|(k, ids)| k.to_string() == "tmcd" && ids == &vec![t.id])); } + +/// Pre-generate every fixture (`cargo xtask fixtures`). +#[test] +#[ignore] +fn generate_fixtures() { + for name in all_fixture_names() { + let out = [fixture_dir().join(name)]; + filmcraft_testkit::fixtures::generate_and_report(&format!("isobmff/{name}"), &out, || fixture(name)); + } +} diff --git a/crates/matroska/tests/common/mod.rs b/crates/matroska/tests/common/mod.rs index 1ef7830f4..ad82697dd 100644 --- a/crates/matroska/tests/common/mod.rs +++ b/crates/matroska/tests/common/mod.rs @@ -25,6 +25,22 @@ fn write_subs(dir: &Path) { std::fs::write(dir.join("in.vtt"), vtt).unwrap(); } +/// Every fixture `spec`/`fixture` knows (for `cargo xtask fixtures`). +pub const ALL_FIXTURES: &[&str] = &[ + "h264_aac.mkv", + "vp9_opus.webm", + "flac.mkv", + "vorbis.mkv", + "subs.mkv", + "webvtt.webm", + "cues_front.mkv", + "hevc_hdr.mkv", + "prores_pcm.mkv", + "mjpeg_ac3.mkv", + "av1.mkv", + "live.mkv", +]; + /// Fixture name → ffmpeg arguments (after `-y -v error`, before the output path). /// `None` for fixtures written through a pipe (no Cues, unknown sizes). pub fn spec(name: &str) -> Option> { diff --git a/crates/matroska/tests/oracle.rs b/crates/matroska/tests/oracle.rs index bc278ac87..5eaae0a03 100644 --- a/crates/matroska/tests/oracle.rs +++ b/crates/matroska/tests/oracle.rs @@ -360,3 +360,14 @@ fn not_matroska() { assert!(matches!(open(&ebml[..]), Err(Error::NotMatroska(_)))); let _ = Value::Null; } + +/// Pre-generate every fixture (`cargo xtask fixtures`). +#[test] +#[ignore] +fn generate_fixtures() { + let Some((ffmpeg, _)) = tools() else { return }; + for name in ALL_FIXTURES { + let out = [fixture_dir().join(name)]; + filmcraft_testkit::fixtures::generate_and_report(&format!("matroska/{name}"), &out, || fixture(&ffmpeg, name)); + } +} diff --git a/crates/prores/tests/oracle_decode.rs b/crates/prores/tests/oracle_decode.rs index 23387807b..1b93eda97 100644 --- a/crates/prores/tests/oracle_decode.rs +++ b/crates/prores/tests/oracle_decode.rs @@ -140,3 +140,14 @@ fn color_metadata_is_reported() { let f = decode_frame(&packets(&ff, &mov)[0]).unwrap(); assert_eq!((f.color.primaries, f.color.transfer, f.color.matrix), (9, 16, 9)); } + +/// Pre-generate every decode fixture (`cargo xtask fixtures`). +#[test] +#[ignore] +fn generate_fixtures() { + let Some(ff) = ffmpeg() else { return }; + for spec in specs() { + let out = [fixture_dir().join(format!("{}.mov", spec.name))]; + filmcraft_testkit::fixtures::generate_and_report(&format!("prores/{}", spec.name), &out, || Some(make(&ff, &spec))); + } +} diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs index f6c2f781a..62e9a1449 100644 --- a/crates/testkit/src/fixtures.rs +++ b/crates/testkit/src/fixtures.rs @@ -65,6 +65,40 @@ pub fn generate(out: &Path, make: impl FnOnce(&Path) -> bool) -> Option Some(out.to_path_buf()) } +/// Status of one fixture for `cargo xtask fixtures`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + Made, + Cached, + Skipped, +} + +/// Print a `FIXTURE ` line (parsed by `cargo xtask fixtures`). +pub fn report(name: &str, status: Status) { + let s = match status { + Status::Made => "made", + Status::Cached => "cached", + Status::Skipped => "skipped", + }; + println!("FIXTURE {s} {name}"); +} + +/// Run a fixture generator and [`report`] it: `cached` when every path in `outputs` existed +/// before, `made` when `generate` succeeded, `skipped` otherwise. +pub fn generate_and_report(name: &str, outputs: &[PathBuf], generate: impl FnOnce() -> Option) -> Option { + let existed = !outputs.is_empty() && outputs.iter().all(|p| std::fs::metadata(p).is_ok_and(|m| m.len() > 0)); + let r = generate(); + report( + name, + match (&r, existed) { + (None, _) => Status::Skipped, + (Some(_), true) => Status::Cached, + (Some(_), false) => Status::Made, + }, + ); + r +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/testing.md b/docs/testing.md index c2300a155..6153af5e5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -5,6 +5,8 @@ cargo test --workspace # everything; oracle tests skip cargo test -p filmcraft-h264 # one crate cargo test --release -p filmcraft-hevc # codec tests are much faster in release cargo xtask ci # all gates (fmt, clippy, tests, layers, assets, wasm) +cargo xtask fixtures # pre-generate the ffmpeg fixture matrix +FILMCRAFT_REQUIRE_ORACLES=1 cargo test --workspace # CI: missing ffmpeg fails instead of skipping ``` ## 1. Kinds of tests @@ -43,6 +45,12 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). - Every crate's `tests/common/mod.rs` delegates discovery and fixture paths to testkit, except `crates/codecs/src/tests.rs`, which still has its own lookup (the crate was being edited concurrently; migrate it when convenient). +- `cargo xtask fixtures [crate…]` pre-generates the whole fixture matrix up front (useful before a + parallel test run or on a fresh machine). It runs each crate's ignored `generate_fixtures` test — + the same generators the oracle tests call — for `h264`, `hevc`, `isobmff`, `matroska` and + `prores`, and prints one `made` / `cached` / `skipped` line per fixture plus a summary. The + `aac`, `opus` and `h264enc` oracles generate small per-test signals on demand and are not part of + the matrix. - Fixture sources are synthetic: `testsrc2`, `mandelbrot`, SMPTE bars, noise and fades, sine tones. H.264 and HEVC fixtures need ffmpeg built with libx264 and libx265. VideoToolbox fixtures are generated only on macOS. diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index df7dfa877..78230cad7 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,3 +7,4 @@ publish = false [dependencies] serde_json = { workspace = true } +filmcraft-testkit = { workspace = true } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 85c085b28..03e265913 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -5,6 +5,9 @@ //! - `wasm`: `cargo check --target wasm32-unknown-unknown` for every crate in L0–L4. //! - `assets`: every asset file (image, icon, font, LUT, audio, video…) has a complete //! `.attribution` sidecar and an entry in `ATTRIBUTION.md` (AGENTS.md §1). +//! - `fixtures [crate…]`: pre-generate the ffmpeg fixture matrix of the oracle tests (runs each +//! crate's ignored `generate_fixtures` test, i.e. the same generators the tests use) and print +//! what was made, reused or skipped. //! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm. use std::process::{Command, ExitCode}; @@ -237,6 +240,47 @@ fn wasm() -> Result<(), String> { Ok(()) } +/// (crate, test target) whose ignored `generate_fixtures` test builds that crate's fixture matrix. +const FIXTURE_GENERATORS: &[(&str, &str)] = + &[("h264", "conformance"), ("hevc", "conformance"), ("isobmff", "oracle_demux"), ("matroska", "oracle"), ("prores", "oracle_decode")]; + +fn fixtures(only: &[String]) -> Result<(), String> { + let unknown: Vec<&String> = only.iter().filter(|o| !FIXTURE_GENERATORS.iter().any(|(c, _)| *c == short(o))).collect(); + if !unknown.is_empty() { + let known: Vec<&str> = FIXTURE_GENERATORS.iter().map(|(c, _)| *c).collect(); + return Err(format!("no fixture generator for {unknown:?} (known: {})", known.join(", "))); + } + let Some(ff) = filmcraft_testkit::ffmpeg() else { + return Err("ffmpeg not found: set FILMCRAFT_FFMPEG=/path/to/ffmpeg or put ffmpeg on PATH".into()); + }; + println!("ffmpeg: {}", ff.display()); + println!("fixtures: {}", filmcraft_testkit::fixtures::fixtures_root().display()); + let (mut made, mut cached, mut skipped) = (0, 0, 0); + let mut failed = Vec::new(); + for (krate, target) in FIXTURE_GENERATORS.iter().filter(|(c, _)| only.is_empty() || only.iter().any(|o| short(o) == *c)) { + let pkg = format!("filmcraft-{krate}"); + println!("\n== {pkg} ({target})"); + let mut cmd = Command::new(env!("CARGO")); + cmd.args(["test", "--release", "-p", &pkg, "--test", target, "--", "--ignored", "--exact", "generate_fixtures", "--nocapture"]); + cmd.stderr(std::process::Stdio::inherit()); + let out = cmd.output().map_err(|e| e.to_string())?; + for line in String::from_utf8_lossy(&out.stdout).lines() { + let Some(rest) = line.strip_prefix("FIXTURE ") else { continue }; + match rest.split_whitespace().next() { + Some("made") => made += 1, + Some("cached") => cached += 1, + _ => skipped += 1, + } + println!(" {rest}"); + } + if !out.status.success() { + failed.push(pkg); + } + } + println!("\nfixtures: {made} made, {cached} already present, {skipped} skipped"); + if failed.is_empty() { Ok(()) } else { Err(format!("fixture generation failed in {}", failed.join(", "))) } +} + fn ci() -> Result<(), String> { let cargo = env!("CARGO"); run(Command::new(cargo).args(["fmt", "--check"]))?; @@ -253,8 +297,9 @@ fn main() -> ExitCode { "layers" => layers(), "wasm" => wasm(), "assets" => assets(), + "fixtures" => fixtures(&std::env::args().skip(2).collect::>()), "ci" => ci(), - _ => Err("usage: cargo xtask ".into()), + _ => Err("usage: cargo xtask ".into()), }; match r { Ok(()) => ExitCode::SUCCESS, From 0d1f21d393ef3b889b8092e71a3ecb34847ac916 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 00:21:03 -0400 Subject: [PATCH 06/24] =?UTF-8?q?M9.8:=20VP9=20decode=20speedup=20?= =?UTF-8?q?=E2=80=94=20lane-parallel=20loop=20filter=20(i16/i32=20lanes),?= =?UTF-8?q?=20banded=20parallel=20loop=20filter,=20vectorizable=20MC=20fil?= =?UTF-8?q?ters,=20per-size=20transform=20buffers,=20buffer=20pools,=20par?= =?UTF-8?q?allel=20strip=20assembly=20and=20picture=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1080p 8-bit bench: 55 -> 98 fps single-thread CPU time; 45 -> 115 fps wall with 14 threads (best of 4 on a heavily loaded machine). Still bit-exact against the ffmpeg oracle on the whole fixture matrix (1 thread and thread pool). Fixture generation uses per-call unique temporary names (concurrent tests generating the same fixture raced on rename). Co-Authored-By: Claude Opus 5.5 --- crates/vp9/README.md | 45 +++-- crates/vp9/examples/vp9dec.rs | 34 ++-- crates/vp9/src/decoder.rs | 239 ++++++++++++++++------- crates/vp9/src/frame.rs | 53 ++++- crates/vp9/src/inter.rs | 137 ++++++++----- crates/vp9/src/loopfilter.rs | 342 ++++++++++++++++++++++----------- crates/vp9/src/tile.rs | 11 +- crates/vp9/src/tile/recon.rs | 2 +- crates/vp9/src/transform.rs | 113 ++++++----- crates/vp9/tests/common/mod.rs | 20 +- 10 files changed, 691 insertions(+), 305 deletions(-) diff --git a/crates/vp9/README.md b/crates/vp9/README.md index b307893e6..67c444694 100644 --- a/crates/vp9/README.md +++ b/crates/vp9/README.md @@ -120,25 +120,42 @@ Backward adaptation is on (`-frame-parallel 0`) unless noted. ## Performance `cargo test --release -p filmcraft-vp9 --test perf -- --ignored --nocapture` (bit-exactness is -verified first). Apple M4 Pro (14 cores). The machine was heavily shared during measurement (load -average 130-170), so wall-clock figures are far below what an idle machine gives; single-thread -CPU time is the more meaningful number. +verified first), or `VP9_THREADS=n VP9_LOOPS=k cargo run --release -p filmcraft-vp9 --example +vp9dec -- in.ivf` (decodes the file k times and prints the statistics). -| stream | 1 thread (CPU time) | 1 thread (wall, loaded) | 14 threads (wall, loaded) | -|---|---|---|---| -| 1080p 8-bit, libvpx speed 4, 4 MB/s, 4 tile columns, 60 frames | ~47 fps | ~11-17 fps | ~26-35 fps | -| 2160p 8-bit, libvpx speed 6, 12 MB/s, 8 tile columns, 20 frames | ~13 fps | ~7-13 fps | ~15-21 fps | +Apple M4 Pro (14 cores), 1080p 8-bit `bench_1080p` fixture (libvpx speed 4, 4 MB/s, 4 tile +columns, 60 frames). The machine was shared with other builds during measurement (load average +140-210), so wall-clock numbers are the best of several runs and still far below what an idle +machine gives; single-thread CPU time is the stable figure. -No SIMD-specific code yet; interpolation, transforms and loop filter are plain loops (the loop -filter is still the largest cost). `cargo run --release -p filmcraft-vp9 --example vp9dec -- -in.ivf [out.yuv]` decodes an IVF file (`VP9_THREADS=n` selects the thread count) and prints the -statistics. +| | before (5e9d7d9) | now | +|---|---|---| +| 1 thread, CPU time | 55 fps | 98 fps | +| 14 threads, wall clock (best of 4, loaded machine) | 45 fps | 115 fps | + +What made the difference: + +- Loop filter: each group of 8 samples along an edge is filtered as 8 lanes at once (16-bit lanes + for 8 / 10-bit, 32-bit for 12-bit); the mask / flat / flat2 decisions are lane-wise and every + filter result is selected per lane, so the arithmetic vectorizes. Per-group decisions use masks + instead of divisions and skip groups with no edge before looking up levels. The parallel loop + filter (superblock wavefront) locks per superblock-row band only while copying, instead of + holding one frame-wide lock. +- Motion compensation: the 8-tap filters run tap-outer over fixed-width rows (4..64) and no longer + zero a 10 KB edge window per block; the 2D inverse transform buffers are sized per transform. +- Memory: strip / frame planes and mode-info grids are recycled through a pool (no per-frame + multi-megabyte allocations and page faults); assembling tile strips into the frame and the + cropping / 8-bit narrowing of output pictures run in parallel chunks of rows. + +The loop filter is still the largest single cost (~30% single-threaded), followed by inter +prediction and coefficient decoding. ## Known gaps -- No frame-level threading: parallelism is tile columns + loop filter wavefront, so streams with - a single tile column (narrower than 512 pixels or encoded without tile columns) decode mostly on - one thread. +- No frame-level threading: parallelism is tile columns + loop filter wavefront + row-parallel + copies, so streams with a single tile column (narrower than 512 pixels or encoded without tile + columns) decode mostly on one thread, and 1080p (at most 4 tile columns) uses 4 threads for + coefficient decoding and prediction. - Scaled references combined with compound prediction are not covered by a fixture (both are verified separately; libvpx through ffmpeg cannot change size mid-stream and the synthetic splice cannot keep the compound streams' probability contexts intact). diff --git a/crates/vp9/examples/vp9dec.rs b/crates/vp9/examples/vp9dec.rs index 376d7f1b1..cf44408b5 100644 --- a/crates/vp9/examples/vp9dec.rs +++ b/crates/vp9/examples/vp9dec.rs @@ -9,30 +9,34 @@ fn main() { let mut out = args.get(2).map(|p| std::io::BufWriter::new(std::fs::File::create(p).expect("create output"))); let threads = std::env::var("VP9_THREADS").ok().and_then(|s| s.parse().ok()).unwrap_or(0); let mut dec = if threads > 0 { filmcraft_vp9::Decoder::with_threads(threads) } else { filmcraft_vp9::Decoder::new() }; + // VP9_LOOPS=n decodes the file n times (for profiling; the stream starts with a key frame). + let loops = std::env::var("VP9_LOOPS").ok().and_then(|s| s.parse().ok()).unwrap_or(1usize); let hl = u16::from_le_bytes([data[6], data[7]]) as usize; - let mut p = hl; let mut i = 0i64; let t0 = std::time::Instant::now(); let mut n = 0; - while p + 12 <= data.len() { - let sz = u32::from_le_bytes(data[p..p + 4].try_into().unwrap()) as usize; - p += 12; - let Some(frame) = data.get(p..p + sz) else { break }; - p += sz; - match dec.decode(frame, i) { - Ok(pics) => { - for pic in pics { - n += 1; - if let Some(o) = out.as_mut() { - for pl in [&pic.y, &pic.u, &pic.v] { - o.write_all(&pl.to_le_bytes()).unwrap(); + for _ in 0..loops { + let mut p = hl; + while p + 12 <= data.len() { + let sz = u32::from_le_bytes(data[p..p + 4].try_into().unwrap()) as usize; + p += 12; + let Some(frame) = data.get(p..p + sz) else { break }; + p += sz; + match dec.decode(frame, i) { + Ok(pics) => { + for pic in pics { + n += 1; + if let Some(o) = out.as_mut() { + for pl in [&pic.y, &pic.u, &pic.v] { + o.write_all(&pl.to_le_bytes()).unwrap(); + } } } } + Err(e) => eprintln!("chunk {i}: {e}"), } - Err(e) => eprintln!("chunk {i}: {e}"), + i += 1; } - i += 1; } eprintln!("{n} pictures in {:.3}s", t0.elapsed().as_secs_f64()); eprintln!("{:#?}", dec.stats()); diff --git a/crates/vp9/src/decoder.rs b/crates/vp9/src/decoder.rs index d6541eed5..3de847525 100644 --- a/crates/vp9/src/decoder.rs +++ b/crates/vp9/src/decoder.rs @@ -3,7 +3,7 @@ //! adaptation and reference frame management. use crate::error::{Error, Result, ensure}; -use crate::frame::{Frame, MiGrid, MiInfo, Plane as FPlane, plane_geometry}; +use crate::frame::{Frame, MiGrid, MiInfo, Plane as FPlane, Pools, plane_geometry}; use crate::header::{FrameHeader, HeaderState, KEY_FRAME, RefInfo, parse_compressed, parse_uncompressed, split_superframe}; use crate::loopfilter::{LfFrame, PlaneView, filter_superblock}; use crate::probs::{Counts, adapt_coef_probs, adapt_noncoef_probs}; @@ -52,6 +52,7 @@ pub struct Decoder { last_show_frame: bool, stats: DecodeStats, threads: usize, + bufs: Pools, #[cfg(feature = "threads")] pool: Option, } @@ -102,6 +103,7 @@ impl Decoder { last_show_frame: false, stats: DecodeStats::default(), threads: threads.max(1), + bufs: Pools::default(), #[cfg(feature = "threads")] pool, } @@ -125,7 +127,7 @@ impl Decoder { continue; } if let Some(frame) = self.decode_frame(f)? { - out.push(make_picture(&frame, pts)); + out.push(make_picture(&frame, pts, self.par())); } } Ok(out) @@ -236,7 +238,8 @@ impl Decoder { } let counting = !h.error_resilient_mode && !h.frame_parallel_decoding_mode; let prev_mi = if use_prev { self.prev_mi.clone() } else { None }; - let shared = FrameShared { h: &h, fc: &fc, seg: &seg, prev_seg_ids: &self.prev_seg_ids, prev_mi: prev_mi.as_deref(), refs, seg_q, counting }; + let shared = + FrameShared { h: &h, fc: &fc, seg: &seg, prev_seg_ids: &self.prev_seg_ids, prev_mi: prev_mi.as_deref(), refs, seg_q, counting, pools: &self.bufs }; let col_bounds: Vec<(usize, usize)> = (0..tile_cols).map(|i| (tile_offset(i, mi_cols, h.tile_cols_log2), tile_offset(i + 1, mi_cols, h.tile_cols_log2))).collect(); let row_bounds: Vec<(usize, usize)> = @@ -272,9 +275,13 @@ impl Decoder { self.stats.inter_blocks += s.inter_blocks; strips.push(s); } + // Release the references before the slots are refreshed, so that evicted frames can be + // recycled. + drop(ref_frames); + drop(prev_mi); // Assemble the frame and mode info. let geo = plane_geometry(h.width, h.height, h.color.subsampling_x, h.color.subsampling_y); - let (planes, mi, seg_ids) = assemble(strips, &geo, mi_rows, mi_cols); + let (planes, mi, seg_ids) = assemble(strips, &geo, mi_rows, mi_cols, &self.bufs, self.par()); let mut frame = Frame { planes, width: h.width, @@ -315,12 +322,25 @@ impl Decoder { self.prev_seg_ids = seg_ids; } let frame = Arc::new(frame); + let mut evicted = Vec::new(); for i in 0..8 { if (h.refresh_frame_flags >> i) & 1 == 1 { - self.slots[i] = Some(frame.clone()); + evicted.extend(self.slots[i].replace(frame.clone())); } } - self.prev_mi = Some(Arc::new(mi)); + // Recycle the planes of frames no longer referenced anywhere. + for f in evicted { + if let Ok(f) = Arc::try_unwrap(f) { + for p in f.planes { + self.bufs.samples.put(p.data); + } + } + } + if let Some(old) = self.prev_mi.replace(Arc::new(mi)) + && let Ok(old) = Arc::try_unwrap(old) + { + self.bufs.mi.put(old.mi); + } if h.show_frame { self.stats.shown += 1; Ok(Some(frame)) @@ -354,6 +374,17 @@ impl Decoder { s.profiles[h.profile as usize] += 1; } + fn par(&self) -> Par<'_> { + #[cfg(feature = "threads")] + { + Par(self.pool.as_ref()) + } + #[cfg(not(feature = "threads"))] + { + Par(std::marker::PhantomData) + } + } + /// Run `f(0..n)`, in parallel when a pool is available. fn run_parallel(&self, n: usize, f: &(dyn Fn(usize) -> T + Sync)) -> Vec { #[cfg(feature = "threads")] @@ -392,20 +423,53 @@ impl Decoder { } /// Loop filter with a superblock wavefront: superblock (r, c) runs once (r - 1, c + 1) is done. -/// Each superblock copies the samples it may touch (its area plus 8 samples above / left) out of -/// the shared frame, filters them locally and writes them back. +/// The planes are split into superblock-row bands, each behind its own lock. Each superblock +/// copies the samples it may touch (its area plus 8 samples above / left, i.e. the bottom rows +/// of band r - 1 and its own band) out, filters them locally and writes them back; the locks are +/// only held while copying, and only neighbouring rows ever contend. #[cfg(feature = "threads")] fn lf_parallel(pool: &rayon::ThreadPool, threads: usize, frame: &mut Frame, mi: &MiGrid, lf: &LfFrame, sb_rows: usize, sb_cols: usize) { use rayon::prelude::*; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Condvar, Mutex}; let strides = [frame.planes[0].stride, frame.planes[1].stride, frame.planes[2].stride]; - let heights = [frame.planes[0].data.len() / strides[0], frame.planes[1].data.len() / strides[1], frame.planes[2].data.len() / strides[2]]; - let planes = Mutex::new([std::mem::take(&mut frame.planes[0].data), std::mem::take(&mut frame.planes[1].data), std::mem::take(&mut frame.planes[2].data)]); + let sub = [(0usize, 0usize), (lf.ss_x, lf.ss_y), (lf.ss_x, lf.ss_y)]; + let band_h = [64usize, 64 >> lf.ss_y, 64 >> lf.ss_y]; + // bands[r][p]: rows [r * band_h[p], (r + 1) * band_h[p]) of plane p. + let bands: Vec> = { + let [a, b, c] = &mut frame.planes; + let mut ia = a.data.chunks_mut(band_h[0] * strides[0]); + let mut ib = b.data.chunks_mut(band_h[1] * strides[1]); + let mut ic = c.data.chunks_mut(band_h[2] * strides[2]); + (0..sb_rows).map(|_| Mutex::new([ia.next().unwrap_or_default(), ib.next().unwrap_or_default(), ic.next().unwrap_or_default()])).collect() + }; let progress = Mutex::new(vec![0usize; sb_rows]); let cv = Condvar::new(); let next = AtomicUsize::new(0); - let sub = [(0usize, 0usize), (lf.ss_x, lf.ss_y), (lf.ss_x, lf.ss_y)]; + // Copy rows [ry, ry + rh) x [rx, rx + rw) of plane p between the bands and `buf` (row-major, + // stride rw); `to_buf` selects the direction. + let xfer = |r: usize, p: usize, rx: usize, ry: usize, rw: usize, rh: usize, buf: &mut [u16], to_buf: bool| { + let bh = band_h[p]; + let mut yy = 0; + while yy < rh { + let y = ry + yy; + let br = y / bh; + let n = ((br + 1) * bh - y).min(rh - yy); + debug_assert!(br + 1 >= r && br <= r); + let mut g = bands[br].lock().expect("lf band"); + let band = &mut g[p]; + for k in 0..n { + let s = (y + k - br * bh) * strides[p] + rx; + let b = (yy + k) * rw; + if to_buf { + buf[b..b + rw].copy_from_slice(&band[s..s + rw]); + } else { + band[s..s + rw].copy_from_slice(&buf[b..b + rw]); + } + } + yy += n; + } + }; pool.install(|| { (0..threads.min(sb_rows)).into_par_iter().for_each(|_| { let mut bufs: [Vec; 3] = [vec![0; 72 * 72], vec![0; 72 * 72], vec![0; 72 * 72]]; @@ -424,20 +488,14 @@ fn lf_parallel(pool: &rayon::ThreadPool, threads: usize, frame: &mut Frame, mi: } // Region of each plane: [x0 - 8, x0 + size) x [y0 - 8, y0 + size). let mut regions = [(0usize, 0usize, 0usize, 0usize); 3]; - { - let pl = planes.lock().expect("lf planes"); - for p in 0..3 { - let (sx, sy) = sub[p]; - let (x0, y0) = ((c * 64) >> sx, (r * 64) >> sy); - let (rx, ry) = (x0.saturating_sub(8), y0.saturating_sub(8)); - let rw = (x0 + (64 >> sx)).min(strides[p]) - rx; - let rh = (y0 + (64 >> sy)).min(heights[p]) - ry; - regions[p] = (rx, ry, rw, rh); - for yy in 0..rh { - let s = (ry + yy) * strides[p] + rx; - bufs[p][yy * rw..yy * rw + rw].copy_from_slice(&pl[p][s..s + rw]); - } - } + for p in 0..3 { + let (sx, sy) = sub[p]; + let (x0, y0) = ((c * 64) >> sx, (r * 64) >> sy); + let (rx, ry) = (x0.saturating_sub(8), y0.saturating_sub(8)); + let rw = (x0 + (64 >> sx)).min(strides[p]) - rx; + let rh = (y0 + band_h[p]) - ry; + regions[p] = (rx, ry, rw, rh); + xfer(r, p, rx, ry, rw, rh, &mut bufs[p], true); } { let [b0, b1, b2] = &mut bufs; @@ -448,15 +506,9 @@ fn lf_parallel(pool: &rayon::ThreadPool, threads: usize, frame: &mut Frame, mi: ]; filter_superblock(&mut views, mi, lf, r * 8, c * 8); } - { - let mut pl = planes.lock().expect("lf planes"); - for p in 0..3 { - let (rx, ry, rw, rh) = regions[p]; - for yy in 0..rh { - let s = (ry + yy) * strides[p] + rx; - pl[p][s..s + rw].copy_from_slice(&bufs[p][yy * rw..yy * rw + rw]); - } - } + for p in 0..3 { + let (rx, ry, rw, rh) = regions[p]; + xfer(r, p, rx, ry, rw, rh, &mut bufs[p], false); } let mut p = progress.lock().expect("lf progress"); p[r] = c + 1; @@ -465,56 +517,113 @@ fn lf_parallel(pool: &rayon::ThreadPool, threads: usize, frame: &mut Frame, mi: } }); }); - let [a, b, c] = planes.into_inner().expect("lf planes"); - frame.planes[0].data = a; - frame.planes[1].data = b; - frame.planes[2].data = c; } +/// Optional thread pool for data-parallel copies. +#[derive(Clone, Copy)] +struct Par<'a>(#[cfg(feature = "threads")] Option<&'a rayon::ThreadPool>, #[cfg(not(feature = "threads"))] std::marker::PhantomData<&'a ()>); + +impl Par<'_> { + /// `f(i, chunk)` for the consecutive `chunk_len`-element chunks of `data`. + fn chunks(self, data: &mut [T], chunk_len: usize, f: impl Fn(usize, &mut [T]) + Sync) { + let chunk_len = chunk_len.max(1); + #[cfg(feature = "threads")] + if let Some(pool) = self.0 + && data.len() > chunk_len + { + use rayon::prelude::*; + pool.install(|| data.par_chunks_mut(chunk_len).enumerate().for_each(|(i, c)| f(i, c))); + return; + } + for (i, c) in data.chunks_mut(chunk_len).enumerate() { + f(i, c); + } + } +} + +/// Rows per chunk of the parallel frame copies. +const COPY_ROWS: usize = 64; + /// Build full-frame planes, mode info and segment ids from the tile column strips. -fn assemble(mut strips: Vec, geo: &[(usize, usize, usize, usize); 3], mi_rows: usize, mi_cols: usize) -> ([FPlane; 3], MiGrid, Vec) { +fn assemble( + mut strips: Vec, + geo: &[(usize, usize, usize, usize); 3], + mi_rows: usize, + mi_cols: usize, + pools: &Pools, + par: Par, +) -> ([FPlane; 3], MiGrid, Vec) { if strips.len() == 1 { let s = strips.pop().expect("one strip"); let [a, b, c] = s.planes; let mk = |d: Vec, g: (usize, usize, usize, usize)| FPlane { data: d, stride: g.0, width: g.2, height: g.3 }; return ([mk(a, geo[0]), mk(b, geo[1]), mk(c, geo[2])], MiGrid { cols: mi_cols, rows: mi_rows, mi: s.mi }, s.seg_ids); } - let mut planes = [ - FPlane::new(geo[0].0, geo[0].1, geo[0].2, geo[0].3), - FPlane::new(geo[1].0, geo[1].1, geo[1].2, geo[1].3), - FPlane::new(geo[2].0, geo[2].1, geo[2].2, geo[2].3), - ]; - let mut mi = vec![MiInfo::default(); mi_rows * mi_cols]; + let mk = |g: (usize, usize, usize, usize)| FPlane { data: pools.samples.take(g.0 * g.1, 0), stride: g.0, width: g.2, height: g.3 }; + let mut planes = [mk(geo[0]), mk(geo[1]), mk(geo[2])]; + let mut mi = pools.mi.take(mi_rows * mi_cols, MiInfo::default()); let mut seg = vec![0u8; mi_rows * mi_cols]; - for s in &strips { - for p in 0..3 { - let ss = s.strides[p]; - let fs = planes[p].stride; - let x0 = s.x_off[p]; - let w = ss.min(fs - x0); - let rows = s.planes[p].len() / ss; - for y in 0..rows { - planes[p].data[y * fs + x0..y * fs + x0 + w].copy_from_slice(&s.planes[p][y * ss..y * ss + w]); + for (p, plane) in planes.iter_mut().enumerate() { + let fs = plane.stride; + // Each chunk of destination rows gathers its part of every strip. + par.chunks(&mut plane.data, COPY_ROWS * fs, |ci, dst| { + let y0 = ci * COPY_ROWS; + for s in &strips { + let ss = s.strides[p]; + let x0 = s.x_off[p]; + let w = ss.min(fs - x0); + for (k, row) in dst.chunks_mut(fs).enumerate() { + let y = y0 + k; + if let Some(src) = s.planes[p].get(y * ss..y * ss + w) { + row[x0..x0 + w].copy_from_slice(src); + } + } } - } + }); + } + for s in &strips { let mw = s.mi_col_end - s.mi_col_start; for r in 0..mi_rows { mi[r * mi_cols + s.mi_col_start..r * mi_cols + s.mi_col_end].copy_from_slice(&s.mi[r * mw..r * mw + mw]); seg[r * mi_cols + s.mi_col_start..r * mi_cols + s.mi_col_end].copy_from_slice(&s.seg_ids[r * mw..r * mw + mw]); } } + for s in strips { + for p in s.planes { + pools.samples.put(p); + } + pools.mi.put(s.mi); + } (planes, MiGrid { cols: mi_cols, rows: mi_rows, mi }, seg) } -fn make_picture(f: &Frame, pts: i64) -> Picture { - let crop = |p: &FPlane| -> Vec { - let mut v = Vec::with_capacity(p.width * p.height); - for y in 0..p.height { - v.extend_from_slice(&p.row(y)[..p.width]); +fn make_picture(f: &Frame, pts: i64, par: Par) -> Picture { + // Crop (and narrow 8-bit samples), in parallel chunks of rows. + let conv = |p: &FPlane| -> Plane { + let w = p.width; + if w == 0 || p.height == 0 { + return if f.bit_depth == 8 { Plane::U8(Vec::new()) } else { Plane::U16(Vec::new()) }; + } + if f.bit_depth == 8 { + let mut out = vec![0u8; w * p.height]; + par.chunks(&mut out, COPY_ROWS * w, |ci, dst| { + for (k, row) in dst.chunks_mut(w).enumerate() { + for (d, &s) in row.iter_mut().zip(&p.row(ci * COPY_ROWS + k)[..w]) { + *d = s as u8; + } + } + }); + Plane::U8(out) + } else { + let mut out = vec![0u16; w * p.height]; + par.chunks(&mut out, COPY_ROWS * w, |ci, dst| { + for (k, row) in dst.chunks_mut(w).enumerate() { + row.copy_from_slice(&p.row(ci * COPY_ROWS + k)[..w]); + } + }); + Plane::U16(out) } - v }; - let conv = |v: Vec| if f.bit_depth == 8 { Plane::U8(v.into_iter().map(|s| s as u8).collect()) } else { Plane::U16(v) }; let [y, u, v] = &f.planes; Picture { width: f.width, @@ -524,9 +633,9 @@ fn make_picture(f: &Frame, pts: i64) -> Picture { bit_depth: f.bit_depth as u32, subsampling_x: f.ss_x, subsampling_y: f.ss_y, - y: conv(crop(y)), - u: conv(crop(u)), - v: conv(crop(v)), + y: conv(y), + u: conv(u), + v: conv(v), y_stride: y.width, uv_stride: u.width, pts, diff --git a/crates/vp9/src/frame.rs b/crates/vp9/src/frame.rs index a6ed51b26..3b2a6861e 100644 --- a/crates/vp9/src/frame.rs +++ b/crates/vp9/src/frame.rs @@ -74,10 +74,6 @@ pub struct Plane { } impl Plane { - pub fn new(alloc_w: usize, alloc_h: usize, width: usize, height: usize) -> Plane { - Plane { data: vec![0; alloc_w * alloc_h], stride: alloc_w, width, height } - } - #[inline] pub fn row(&self, y: usize) -> &[u16] { &self.data[y * self.stride..(y + 1) * self.stride] @@ -111,3 +107,52 @@ pub fn plane_geometry(width: u32, height: u32, ss_x: bool, ss_y: bool) -> [(usiz let ch = (height as usize + sy) >> sy; [(aw, ah, width as usize, height as usize), (aw >> sx, ah >> sy, cw, ch), (aw >> sx, ah >> sy, cw, ch)] } + +/// Recycled buffers (strip / frame planes, mode info), so that steady-state decoding does not +/// allocate (and page-fault) megabytes per frame. +pub struct Pool { + bufs: std::sync::Mutex>>, +} + +impl Default for Pool { + fn default() -> Self { + Pool { bufs: Default::default() } + } +} + +impl Pool { + const MAX: usize = 32; + + /// A buffer of `len` copies of `fill` (recycled memory when available). + pub fn take(&self, len: usize, fill: T) -> Vec { + let found = { + let mut b = self.bufs.lock().expect("buffer pool"); + b.iter().position(|v| v.capacity() >= len).map(|i| b.swap_remove(i)) + }; + match found { + Some(mut v) => { + v.resize(len, fill); + v + } + None => vec![fill; len], + } + } + + pub fn put(&self, mut v: Vec) { + if v.capacity() == 0 { + return; + } + v.clear(); + let mut b = self.bufs.lock().expect("buffer pool"); + if b.len() < Self::MAX { + b.push(v); + } + } +} + +/// The decoder's buffer pools. +#[derive(Default)] +pub struct Pools { + pub samples: Pool, + pub mi: Pool, +} diff --git a/crates/vp9/src/inter.rs b/crates/vp9/src/inter.rs index 5b3a7266b..0598c5d87 100644 --- a/crates/vp9/src/inter.rs +++ b/crates/vp9/src/inter.rs @@ -20,11 +20,25 @@ pub struct RefPlane<'a> { /// Predict a `w` x `h` block whose top-left sample is at (`x`, `y`) in 1/16 sample units of the /// reference, stepping `step_x` / `step_y` (16 = unscaled). Output samples go to `out` (stride -/// `w`). `tmp` must hold at least `(h * step_y / 16 + 8) * w` entries. +/// `w`). `tmp` must hold at least `(h * step_y / 16 + 8) * w` entries, `win` at least +/// `(w + 7) * (h + 7)` (edge-clamped source window). #[allow(clippy::too_many_arguments)] -pub fn predict(r: &RefPlane, x: i32, y: i32, step_x: i32, step_y: i32, w: usize, h: usize, filter: u8, bit_depth: u8, out: &mut [u16], tmp: &mut [u16]) { +pub fn predict( + r: &RefPlane, + x: i32, + y: i32, + step_x: i32, + step_y: i32, + w: usize, + h: usize, + filter: u8, + bit_depth: u8, + out: &mut [u16], + tmp: &mut [u16], + win: &mut [u16], +) { if step_x == 16 && step_y == 16 { - predict_unscaled(r, x, y, w, h, filter, bit_depth, out, tmp); + predict_unscaled(r, x, y, w, h, filter, bit_depth, out, tmp, win); } else { predict_scaled(r, x, y, step_x, step_y, w, h, filter, bit_depth, out, tmp); } @@ -50,17 +64,16 @@ fn fetch_window(r: &RefPlane, x0: i32, y0: i32, w: usize, h: usize, win: &mut [u } #[allow(clippy::too_many_arguments)] -fn predict_unscaled(r: &RefPlane, x: i32, y: i32, w: usize, h: usize, filter: u8, bit_depth: u8, out: &mut [u16], tmp: &mut [u16]) { +fn predict_unscaled(r: &RefPlane, x: i32, y: i32, w: usize, h: usize, filter: u8, bit_depth: u8, out: &mut [u16], tmp: &mut [u16], win: &mut [u16]) { let (x0, y0) = (x >> 4, y >> 4); let (fx, fy) = ((x & 15) as usize, (y & 15) as usize); let max = (1i32 << bit_depth) - 1; let inside = x0 - 3 >= 0 && y0 - 3 >= 0 && x0 + w as i32 + 4 <= r.last_x && y0 + h as i32 + 4 <= r.last_y; // Source view with origin at (x0 - 3, y0 - 3). - let mut win = [0u16; 71 * 71]; let (src, ss, so): (&[u16], usize, usize) = if inside { (r.data, r.stride, (y0 - 3) as usize * r.stride + (x0 - 3) as usize) } else { - let ws = fetch_window(r, x0, y0, w, h, &mut win); + let ws = fetch_window(r, x0, y0, w, h, win); (&win[..], ws, 0) }; match (fx, fy) { @@ -73,61 +86,97 @@ fn predict_unscaled(r: &RefPlane, x: i32, y: i32, w: usize, h: usize, filter: u8 (_, 0) => { let f = taps(filter, fx); for i in 0..h { - let s = &src[so + (i + 3) * ss..]; - let o = &mut out[i * w..i * w + w]; - for (j, d) in o.iter_mut().enumerate() { - let p = &s[j..j + 8]; - let mut acc = 0i32; - for t in 0..8 { - acc += f[t] as i32 * p[t] as i32; - } - *d = ((acc + 64) >> 7).clamp(0, max) as u16; - } + filter_h(&src[so + (i + 3) * ss..], f, max, &mut out[i * w..i * w + w]); } } (0, _) => { let f = taps(filter, fy); for i in 0..h { - let o = &mut out[i * w..i * w + w]; - for (j, d) in o.iter_mut().enumerate() { - let base = so + i * ss + j + 3; - let mut acc = 0i32; - for t in 0..8 { - acc += f[t] as i32 * src[base + t * ss] as i32; - } - *d = ((acc + 64) >> 7).clamp(0, max) as u16; - } + filter_v(&src[so + i * ss + 3..], ss, f, max, &mut out[i * w..i * w + w]); } } _ => { let fh = taps(filter, fx); let fv = taps(filter, fy); for i in 0..h + 7 { - let s = &src[so + i * ss..]; - let o = &mut tmp[i * w..i * w + w]; - for (j, d) in o.iter_mut().enumerate() { - let p = &s[j..j + 8]; - let mut acc = 0i32; - for t in 0..8 { - acc += fh[t] as i32 * p[t] as i32; - } - *d = ((acc + 64) >> 7).clamp(0, max) as u16; - } + filter_h(&src[so + i * ss..], fh, max, &mut tmp[i * w..i * w + w]); } for i in 0..h { - let o = &mut out[i * w..i * w + w]; - for (j, d) in o.iter_mut().enumerate() { - let mut acc = 0i32; - for t in 0..8 { - acc += fv[t] as i32 * tmp[(i + t) * w + j] as i32; - } - *d = ((acc + 64) >> 7).clamp(0, max) as u16; - } + filter_v(&tmp[i * w..], w, fv, max, &mut out[i * w..i * w + w]); } } } } +/// Horizontal 8-tap filter of one row: `o[j]` from `s[j..j + 8]`. The taps are the outer loop +/// so that the row vectorizes (same sums, same order of the exact integer additions). +#[inline(always)] +fn filter_h(s: &[u16], f: &[i16], max: i32, o: &mut [u16]) { + match o.len() { + 4 => filter_h_n::<4>(s, f, max, o), + 8 => filter_h_n::<8>(s, f, max, o), + 16 => filter_h_n::<16>(s, f, max, o), + 32 => filter_h_n::<32>(s, f, max, o), + 64 => filter_h_n::<64>(s, f, max, o), + _ => { + for (j, d) in o.iter_mut().enumerate() { + let acc: i32 = (0..8).map(|t| f[t] as i32 * s[j + t] as i32).sum(); + *d = ((acc + 64) >> 7).clamp(0, max) as u16; + } + } + } +} + +#[inline(always)] +fn filter_h_n(s: &[u16], f: &[i16], max: i32, o: &mut [u16]) { + let o: &mut [u16; W] = (&mut o[..W]).try_into().expect("row"); + let mut acc = [64i32; W]; + for t in 0..8 { + let c = f[t] as i32; + let p: &[u16; W] = s[t..t + W].try_into().expect("row"); + for j in 0..W { + acc[j] += c * p[j] as i32; + } + } + for j in 0..W { + o[j] = (acc[j] >> 7).clamp(0, max) as u16; + } +} + +/// Vertical 8-tap filter of one row: `o[j]` from `s[j + t * ss]`, t = 0..8. +#[inline(always)] +fn filter_v(s: &[u16], ss: usize, f: &[i16], max: i32, o: &mut [u16]) { + match o.len() { + 4 => filter_v_n::<4>(s, ss, f, max, o), + 8 => filter_v_n::<8>(s, ss, f, max, o), + 16 => filter_v_n::<16>(s, ss, f, max, o), + 32 => filter_v_n::<32>(s, ss, f, max, o), + 64 => filter_v_n::<64>(s, ss, f, max, o), + _ => { + for (j, d) in o.iter_mut().enumerate() { + let acc: i32 = (0..8).map(|t| f[t] as i32 * s[j + t * ss] as i32).sum(); + *d = ((acc + 64) >> 7).clamp(0, max) as u16; + } + } + } +} + +#[inline(always)] +fn filter_v_n(s: &[u16], ss: usize, f: &[i16], max: i32, o: &mut [u16]) { + let o: &mut [u16; W] = (&mut o[..W]).try_into().expect("row"); + let mut acc = [64i32; W]; + for t in 0..8 { + let c = f[t] as i32; + let p: &[u16; W] = s[t * ss..t * ss + W].try_into().expect("row"); + for j in 0..W { + acc[j] += c * p[j] as i32; + } + } + for j in 0..W { + o[j] = (acc[j] >> 7).clamp(0, max) as u16; + } +} + /// General process of 8.5.2.4 (any step). #[allow(clippy::too_many_arguments)] fn predict_scaled(r: &RefPlane, x: i32, y: i32, step_x: i32, step_y: i32, w: usize, h: usize, filter: u8, bit_depth: u8, out: &mut [u16], tmp: &mut [u16]) { @@ -175,7 +224,7 @@ mod tests { for &(bw, bh) in &[(4usize, 4usize), (8, 8), (16, 8)] { let mut a = vec![0u16; bw * bh]; let mut b = vec![0u16; bw * bh]; - predict_unscaled(&r, x, y, bw, bh, filter, 8, &mut a, &mut tmp); + predict_unscaled(&r, x, y, bw, bh, filter, 8, &mut a, &mut tmp, &mut [0u16; 71 * 71]); predict_scaled(&r, x, y, 16, 16, bw, bh, filter, 8, &mut b, &mut tmp); assert_eq!(a, b, "filter {filter} pos ({x},{y}) {bw}x{bh}"); } diff --git a/crates/vp9/src/loopfilter.rs b/crates/vp9/src/loopfilter.rs index a3de046f5..21ad99b4f 100644 --- a/crates/vp9/src/loopfilter.rs +++ b/crates/vp9/src/loopfilter.rs @@ -109,15 +109,20 @@ fn filter_sb_plane(v: &mut PlaneView, mi: &MiGrid, f: &LfFrame, plane: usize, pa m.tx_size }; let sb_size = if sub == 0 { m.sb_size } else { m.sb_size.max(BLOCK_16X16) } as usize; - let skip = m.skip; let is_intra = m.ref_frame[0] <= INTRA_FRAME; - let is_block_edge = if pass == 0 { x % (8 * NUM_8X8_WIDE[sb_size] as usize) == 0 } else { y % (8 * NUM_8X8_HIGH[sb_size] as usize) == 0 }; - let is32 = edge % 8 == 0; + // Block sizes are powers of two, so the modulo is a mask. + let is_block_edge = if pass == 0 { x & (8 * NUM_8X8_WIDE[sb_size] as usize - 1) == 0 } else { y & (8 * NUM_8X8_HIGH[sb_size] as usize - 1) == 0 }; + let tx_ok = edge & ((1 << tx_sz) - 1) == 0 && (is_intra || !m.skip); + if !is_block_edge && !tx_ok { + i += 8; + continue; + } let lvl = f.level(m); if lvl == 0 { i += 8; continue; } + let is32 = edge % 8 == 0; let base_size = if tx_sz == TX_4X4 && is32 { TX_8X8 } else { tx_sz.min(TX_16X16) }; let filter_size = if base_size == TX_16X16 && ((pass == 0 && sub_x == 1 && (x >> 3) == mi_cols - 1) || (pass == 1 && sub_y == 1 && (y >> 3) == mi_rows - 1)) { @@ -126,119 +131,238 @@ fn filter_sb_plane(v: &mut PlaneView, mi: &MiGrid, f: &LfFrame, plane: usize, pa base_size }; let (limit, blimit, thresh) = f.limits[lvl as usize]; - let tx_edge_normal = edge % (1 << tx_sz) == 0; - for k in i..(i + 8).min(edge_len) { - let (sx, sy) = if pass == 0 { (x, row * 8 + (k << sub_y)) } else { (col * 8 + (k << sub_x), y) }; - if sx >= 8 * mi_cols || sy >= 8 * mi_rows { - continue; + // Lanes of the group (the 8 samples along the edge) inside the MI grid. + let n = if pass == 0 { (8 * mi_rows - y).div_ceil(1 << sub_y) } else { (8 * mi_cols - x).div_ceil(1 << sub_x) }.min(LANES).min(edge_len - i); + // A transform edge is not filtered in the last odd chroma column (8.8.2). + let odd_last = pass == 1 && sub_x == 1 && mi_cols & 1 == 1 && edge & 1 == 1; + let mut apply = [false; LANES]; + for (l, a) in apply.iter_mut().enumerate().take(n) { + let is_tx_edge = tx_ok && !(odd_last && x + (l << sub_x) + 8 >= mi_cols * 8); + *a = is_block_edge || is_tx_edge; + } + if apply.iter().any(|&a| a) { + let px = x >> sub_x; + let py = y >> sub_y; + let pos = (py - v.oy) * v.stride + px - v.ox; + let along = if pass == 0 { v.stride } else { 1 }; + let lim = Limits { apply, limit, blimit, thresh, filter_size, bit_depth: f.bit_depth }; + let (d, st) = (&mut *v.data, across as usize); + match (f.bit_depth <= 10, filter_size == TX_16X16, pass == 1) { + (true, false, false) => lanes16::filter_group::<4, false>(d, pos, along, st, &lim), + (true, false, true) => lanes16::filter_group::<4, true>(d, pos, along, st, &lim), + (true, true, false) => lanes16::filter_group::<8, false>(d, pos, along, st, &lim), + (true, true, true) => lanes16::filter_group::<8, true>(d, pos, along, st, &lim), + (false, false, false) => lanes32::filter_group::<4, false>(d, pos, along, st, &lim), + (false, false, true) => lanes32::filter_group::<4, true>(d, pos, along, st, &lim), + (false, true, false) => lanes32::filter_group::<8, false>(d, pos, along, st, &lim), + (false, true, true) => lanes32::filter_group::<8, true>(d, pos, along, st, &lim), } - let is_tx_edge = if pass == 1 && sub_x == 1 && mi_cols & 1 == 1 && edge & 1 == 1 && sx + 8 >= mi_cols * 8 { false } else { tx_edge_normal }; - let apply = is_block_edge || (is_tx_edge && (is_intra || !skip)); - if !apply { - continue; - } - let px = sx >> sub_x; - let py = sy >> sub_y; - let pos = ((py - v.oy) * v.stride + px - v.ox) as isize; - filter_sample(v.data, pos, across, limit, blimit, thresh, filter_size, f.bit_depth); } i += 8; } } } -/// Sample filtering process (8.8.5) at `pos` (the q0 sample), samples across the edge `step` -/// apart. The samples are gathered into `v` (`v[8 + k]` = sample at offset k) and only modified -/// samples are written back. -#[allow(clippy::too_many_arguments)] -#[inline] -fn filter_sample(d: &mut [u16], pos: isize, step: isize, limit: i32, blimit: i32, thresh: i32, filter_size: u8, bit_depth: u8) { - let reach: usize = if filter_size == TX_16X16 { 8 } else { 4 }; - let step = step as usize; - let start = pos as usize - reach * step; - let mut v = [0i32; 16]; - { - let span = &d[start..start + (2 * reach - 1) * step + 1]; - for k in 0..2 * reach { - v[8 - reach + k] = span[k * step] as i32; - } - } - let (p3, p2, p1, p0, q0, q1, q2, q3) = (v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11]); - let shift = bit_depth as u32 - 8; - // Filter mask process (8.8.5.1). - let limit_bd = limit << shift; - let blimit_bd = blimit << shift; - if (p3 - p2).abs() > limit_bd - || (p2 - p1).abs() > limit_bd - || (p1 - p0).abs() > limit_bd - || (q1 - q0).abs() > limit_bd - || (q2 - q1).abs() > limit_bd - || (q3 - q2).abs() > limit_bd - || (p0 - q0).abs() * 2 + (p1 - q1).abs() / 2 > blimit_bd - { - return; - } - let thresh_bd = thresh << shift; - let hev = (p1 - p0).abs() > thresh_bd || (q1 - q0).abs() > thresh_bd; - let one = 1 << shift; - let flat = filter_size >= TX_8X8 - && (p1 - p0).abs() <= one - && (q1 - q0).abs() <= one - && (p2 - p0).abs() <= one - && (q2 - q0).abs() <= one - && (p3 - p0).abs() <= one - && (q3 - q0).abs() <= one; - let (lo, hi) = if filter_size == TX_4X4 || !flat { - narrow_filter(&mut v, hev, bit_depth); - (6, 10) - } else { - let flat2 = filter_size >= TX_16X16 && (0..4).all(|k| (v[3 - k] - p0).abs() <= one && (v[12 + k] - q0).abs() <= one); - if flat2 { - wide_filter(&mut v, 4); - (1, 15) - } else { - wide_filter(&mut v, 3); - (5, 11) +/// Samples along an edge filtered together. +const LANES: usize = 8; + +/// Per-group filter parameters. +struct Limits { + apply: [bool; LANES], + limit: i32, + blimit: i32, + thresh: i32, + filter_size: u8, + bit_depth: u8, +} + +/// The group filter, instantiated for 16-bit lanes (bit depths 8 and 10: every intermediate, +/// including the 16-tap sums of at most 16 * 1023 + 8, fits) and 32-bit lanes (12-bit). +macro_rules! group_filter { + ($m:ident, $t:ty) => { + mod $m { + use super::{LANES, Limits}; + use crate::tables::*; + type T = $t; + + /// Sample filtering process (8.8.5) for the up to 8 lanes of a group whose `apply` flag is set. + /// Lane `l` has its q0 sample at `pos + l * along`; samples across the edge are `step` apart + /// (`ROWS`: `along` is 1, i.e. a horizontal edge). `R` samples on each side are read (8 for + /// 16x16 filters, else 4). Every lane is computed with every filter and the result chosen per + /// lane (8.8.5.1 decides which), so the arithmetic is branch-free and vectorizes; unmodified + /// samples are written back unchanged. + #[inline(always)] + pub(super) fn filter_group(d: &mut [u16], pos: usize, along: usize, step: usize, g: &Limits) { + let Limits { apply, limit, blimit, thresh, filter_size, bit_depth } = *g; + let (limit, blimit, thresh) = (limit as T, blimit as T, thresh as T); + // v[8 + k][l] = sample at offset k across the edge in lane l. + let mut v = [[0 as T; LANES]; 16]; + let start = pos - R * step; + if ROWS { + for k in 0..2 * R { + let row: &[u16; LANES] = d[start + k * step..start + k * step + LANES].try_into().expect("lanes"); + v[8 - R + k] = std::array::from_fn(|l| row[l] as T); + } + } else { + for l in 0..LANES { + let s = start + l * along; + let line: &[u16] = &d[s..s + 2 * R]; + for k in 0..2 * R { + v[8 - R + k][l] = line[k] as T; + } + } + } + let shift = bit_depth as u32 - 8; + let limit_bd = limit << shift; + let blimit_bd = blimit << shift; + let thresh_bd = thresh << shift; + let one = 1 << shift; + let (p3, p2, p1, p0, q0, q1, q2, q3) = (&v[4], &v[5], &v[6], &v[7], &v[8], &v[9], &v[10], &v[11]); + // Filter mask process (8.8.5.1), with non-short-circuit `&` so that it vectorizes. + let mut mask = [false; LANES]; + let mut hev = [false; LANES]; + let mut flat = [false; LANES]; + let mut flat2 = [false; LANES]; + let can_flat = filter_size >= TX_8X8; + let can_flat2 = R == 8 && filter_size >= TX_16X16; + for l in 0..LANES { + let d_p1p0 = (p1[l] - p0[l]).abs(); + let d_q1q0 = (q1[l] - q0[l]).abs(); + mask[l] = apply[l] + & ((p3[l] - p2[l]).abs() <= limit_bd) + & ((p2[l] - p1[l]).abs() <= limit_bd) + & (d_p1p0 <= limit_bd) + & (d_q1q0 <= limit_bd) + & ((q2[l] - q1[l]).abs() <= limit_bd) + & ((q3[l] - q2[l]).abs() <= limit_bd) + & ((p0[l] - q0[l]).abs() * 2 + (p1[l] - q1[l]).abs() / 2 <= blimit_bd); + hev[l] = (d_p1p0 > thresh_bd) | (d_q1q0 > thresh_bd); + flat[l] = can_flat + & mask[l] + & (d_p1p0 <= one) + & (d_q1q0 <= one) + & ((p2[l] - p0[l]).abs() <= one) + & ((q2[l] - q0[l]).abs() <= one) + & ((p3[l] - p0[l]).abs() <= one) + & ((q3[l] - q0[l]).abs() <= one); + } + if !mask.iter().fold(false, |a, &m| a | m) { + return; + } + if can_flat2 { + for l in 0..LANES { + flat2[l] = flat[l] + & ((v[0][l] - p0[l]).abs() <= one) + & ((v[1][l] - p0[l]).abs() <= one) + & ((v[2][l] - p0[l]).abs() <= one) + & ((v[3][l] - p0[l]).abs() <= one) + & ((v[12][l] - q0[l]).abs() <= one) + & ((v[13][l] - q0[l]).abs() <= one) + & ((v[14][l] - q0[l]).abs() <= one) + & ((v[15][l] - q0[l]).abs() <= one); + } + } + let any_flat = flat.iter().fold(false, |a, &f| a | f); + let any_flat2 = flat2.iter().fold(false, |a, &f| a | f); + // Results per position across the edge; start from the unfiltered samples. + let mut out = v; + // Narrow filter (8.8.5.2) for lanes that are masked but not flat. + { + let lo = -(1 << (bit_depth - 1)) as T; + let hi = ((1 << (bit_depth - 1)) - 1) as T; + let c = |x: T| x.clamp(lo, hi); + let off: T = 0x80 << shift; + for l in 0..LANES { + let (ps1, ps0, qs0, qs1) = (p1[l] - off, p0[l] - off, q0[l] - off, q1[l] - off); + let filter = c(if hev[l] { c(ps1 - qs1) } else { 0 } + 3 * (qs0 - ps0)); + let filter1 = c(filter + 4) >> 3; + let filter2 = c(filter + 3) >> 3; + let f = (filter1 + 1) >> 1; + let narrow = mask[l] && !flat[l]; + let outer = narrow && !hev[l]; + out[8][l] = if narrow { c(qs0 - filter1) + off } else { out[8][l] }; + out[7][l] = if narrow { c(ps0 + filter2) + off } else { out[7][l] }; + out[9][l] = if outer { c(qs1 - f) + off } else { out[9][l] }; + out[6][l] = if outer { c(ps1 + f) + off } else { out[6][l] }; + } + } + // Wide filter (8.8.5.3) with 8 taps for flat lanes (p2..q2) ... + if any_flat { + let w8 = wide_filter::<3>(&v); + for k in 5..11 { + for l in 0..LANES { + if flat[l] && !flat2[l] { + out[k][l] = w8[k][l]; + } + } + } + } + // ... and 16 taps for flat2 lanes (p6..q6). + if any_flat2 { + let w16 = wide_filter::<4>(&v); + for k in 1..15 { + for l in 0..LANES { + if flat2[l] { + out[k][l] = w16[k][l]; + } + } + } + } + let (lo, hi) = if any_flat2 { + (1, 15) + } else if any_flat { + (5, 11) + } else { + (6, 10) + }; + if ROWS { + for k in lo..hi { + let o = start + (k + R - 8) * step; // k >= 8 - R + let row: &mut [u16; LANES] = (&mut d[o..o + LANES]).try_into().expect("lanes"); + for l in 0..LANES { + row[l] = out[k][l] as u16; + } + } + } else { + // Whole lines: unmodified samples are rewritten with their own value. + for l in 0..LANES { + let s = start + l * along; + let line: &mut [u16] = &mut d[s..s + 2 * R]; + for k in 0..2 * R { + line[k] = out[8 - R + k][l] as u16; + } + } + } + } + + /// Wide filter process (8.8.5.3) with 2^LOG2 taps on gathered lanes (positions 8 - 2^(LOG2-1) + /// .. 8 + 2^(LOG2-1) - 2 are produced); the sum over j of the specification is computed as a + /// sliding window over the clamped indices (identical results). + #[inline(always)] + fn wide_filter(v: &[[T; LANES]; 16]) -> [[T; LANES]; 16] { + let n = (1isize << (LOG2 - 1)) - 1; + let s = |k: isize| &v[(k.clamp(-(n + 1), n) + 8) as usize]; + let mut out = [[0 as T; LANES]; 16]; + let mut sum = [0 as T; LANES]; + for j in -n..=n { + let a = s(-n + j); + for l in 0..LANES { + sum[l] += a[l]; + } + } + let round: T = 1 << (LOG2 - 1); + for i in -n..n { + let (cur, add, sub) = (s(i), s(i + 1 + n), s(i - n)); + let o = &mut out[(i + 8) as usize]; + for l in 0..LANES { + o[l] = (sum[l] + cur[l] + round) >> LOG2; + sum[l] += add[l] - sub[l]; + } + } + out + } } }; - for k in lo..hi { - d[(pos + (k as isize - 8) * step as isize) as usize] = v[k] as u16; - } -} - -/// Narrow filter process (8.8.5.2) on gathered samples. -#[inline] -fn narrow_filter(v: &mut [i32; 16], hev: bool, bit_depth: u8) { - let shift = bit_depth as u32 - 8; - let lo = -(1 << (bit_depth - 1)); - let hi = (1 << (bit_depth - 1)) - 1; - let c = |x: i32| x.clamp(lo, hi); - let off = 0x80 << shift; - let (ps1, ps0, qs0, qs1) = (v[6] - off, v[7] - off, v[8] - off, v[9] - off); - let mut filter = if hev { c(ps1 - qs1) } else { 0 }; - filter = c(filter + 3 * (qs0 - ps0)); - let filter1 = c(filter + 4) >> 3; - let filter2 = c(filter + 3) >> 3; - v[8] = c(qs0 - filter1) + off; - v[7] = c(ps0 + filter2) + off; - if !hev { - let f = (filter1 + 1) >> 1; - v[9] = c(qs1 - f) + off; - v[6] = c(ps1 + f) + off; - } -} - -/// Wide filter process (8.8.5.3) with 2^log2 taps on gathered samples; the sum over j of the -/// specification is computed as a sliding window over the clamped indices (identical results). -#[inline] -fn wide_filter(v: &mut [i32; 16], log2: u32) { - let n = (1isize << (log2 - 1)) - 1; - let src = *v; - let s = |k: isize| src[(k.clamp(-(n + 1), n) + 8) as usize]; - let mut sum: i32 = (-n..=n).map(|j| s(-n + j)).sum(); - let round = 1i32 << (log2 - 1); - for i in -n..n { - v[(i + 8) as usize] = (sum + s(i) + round) >> log2; - sum += s(i + 1 + n) - s(i - n); - } } +group_filter!(lanes16, i16); +group_filter!(lanes32, i32); diff --git a/crates/vp9/src/tile.rs b/crates/vp9/src/tile.rs index bf117672a..0c2ca02b5 100644 --- a/crates/vp9/src/tile.rs +++ b/crates/vp9/src/tile.rs @@ -34,6 +34,7 @@ pub struct FrameShared<'a> { pub seg_q: [[[i32; 2]; 2]; 8], /// Whether syntax elements need to be counted (backward adaptation enabled). pub counting: bool, + pub pools: &'a crate::frame::Pools, } /// Output of one tile column: samples, mode info and segment ids of its columns. @@ -112,6 +113,7 @@ pub struct TileDecoder<'a> { pred: Vec, pred2: Vec, mc_tmp: Vec, + mc_win: Vec, /// First error met while decoding (e.g. a block referencing an unusable reference frame). pub error: Option, } @@ -126,12 +128,13 @@ impl<'a> TileDecoder<'a> { let strip_w = (mi_col_end - mi_col_start).div_ceil(8) * 64; let strip_h = sb_rows * 64; let x0 = mi_col_start * 8; - let planes = [vec![0u16; strip_w * strip_h], vec![0u16; (strip_w >> ss_x) * (strip_h >> ss_y)], vec![0u16; (strip_w >> ss_x) * (strip_h >> ss_y)]]; + let uv_len = (strip_w >> ss_x) * (strip_h >> ss_y); + let planes = [s.pools.samples.take(strip_w * strip_h, 0), s.pools.samples.take(uv_len, 0), s.pools.samples.take(uv_len, 0)]; let mi_w = mi_col_end - mi_col_start; let strip = Strip { mi_col_start, mi_col_end, - mi: vec![MiInfo::default(); mi_w * mi_rows], + mi: s.pools.mi.take(mi_w * mi_rows, MiInfo::default()), seg_ids: vec![0; mi_w * mi_rows], planes, strides: [strip_w, strip_w >> ss_x, strip_w >> ss_x], @@ -170,7 +173,9 @@ impl<'a> TileDecoder<'a> { token_cache: vec![0; 1024], pred: vec![0; 64 * 64], pred2: vec![0; 64 * 64], - mc_tmp: vec![0; 64 * (64 * 5 + 16)], + // Scaled prediction reads up to 5x the block height (8.5.2.3), unscaled h + 7 rows. + mc_tmp: vec![0; if s.refs.iter().flatten().any(|r| r.x_scale != 1 << 14 || r.y_scale != 1 << 14) { 64 * (64 * 5 + 16) } else { 64 * 71 }], + mc_win: vec![0; 71 * 71], error: None, } } diff --git a/crates/vp9/src/tile/recon.rs b/crates/vp9/src/tile/recon.rs index dc52b99f5..4ab72118b 100644 --- a/crates/vp9/src/tile/recon.rs +++ b/crates/vp9/src/tile/recon.rs @@ -317,7 +317,7 @@ impl<'a> TileDecoder<'a> { last_y: ((f.height as i32 + sy as i32) >> sy) - 1, }; let out = if rl == 0 { &mut self.pred } else { &mut self.pred2 }; - inter::predict(&rp, start_x, start_y, step_x, step_y, w, h, b.interp_filter, self.bit_depth, out, &mut self.mc_tmp); + inter::predict(&rp, start_x, start_y, step_x, step_y, w, h, b.interp_filter, self.bit_depth, out, &mut self.mc_tmp, &mut self.mc_win); } let (stride, x_off) = (self.strip.strides[plane], self.strip.x_off[plane]); let dst = &mut self.strip.planes[plane]; diff --git a/crates/vp9/src/transform.rs b/crates/vp9/src/transform.rs index 3792a81b6..f0daf1fd5 100644 --- a/crates/vp9/src/transform.rs +++ b/crates/vp9/src/transform.rs @@ -167,54 +167,77 @@ pub fn inverse_transform_add(coefs: &[i32], tx_size: u8, tx_type: u8, lossless: } let row_adst = matches!(tx_type, DCT_ADST | ADST_ADST); let col_adst = matches!(tx_type, ADST_DCT | ADST_ADST); - if bit_depth == 8 { - let lim = 1 << 24; - let mut t = [0i32; 1024]; - for i in 0..rows.min(n) { - let row = &mut t[i * n..i * n + n]; - for (d, s) in row.iter_mut().zip(&coefs[i * n..i * n + n]) { - *d = sat(*s, lim); - } - tx1d_narrow(row, n, row_adst); - for v in row.iter_mut() { - *v = sat(*v, lim); - } + let p = TxParams { row_adst, col_adst, rows, shift, round, max }; + match (tx_size, bit_depth == 8) { + (0, true) => itx_narrow::<4>(coefs, &p, dst, stride), + (1, true) => itx_narrow::<8>(coefs, &p, dst, stride), + (2, true) => itx_narrow::<16>(coefs, &p, dst, stride), + (_, true) => itx_narrow::<32>(coefs, &p, dst, stride), + (0, false) => itx_wide::<4>(coefs, &p, dst, stride), + (1, false) => itx_wide::<8>(coefs, &p, dst, stride), + (2, false) => itx_wide::<16>(coefs, &p, dst, stride), + (_, false) => itx_wide::<32>(coefs, &p, dst, stride), + } +} + +struct TxParams { + row_adst: bool, + col_adst: bool, + rows: usize, + shift: u32, + round: i32, + max: i32, +} + +/// 2D inverse transform of size N with 32-bit intermediates (8-bit streams). +fn itx_narrow(coefs: &[i32], p: &TxParams, dst: &mut [u16], stride: usize) { + let lim = 1 << 24; + let mut t = [[0i32; N]; N]; + for (i, row) in t.iter_mut().enumerate().take(p.rows.min(N)) { + for (d, s) in row.iter_mut().zip(&coefs[i * N..i * N + N]) { + *d = sat(*s, lim); } - let mut col = [0i32; 32]; - for j in 0..n { - for i in 0..n { - col[i] = t[i * n + j]; - } - tx1d_narrow(&mut col[..n], n, col_adst); - for i in 0..n { - let p = &mut dst[i * stride + j]; - *p = (*p as i32 + (col[i].wrapping_add(round) >> shift).clamp(-(1 << 20), 1 << 20)).clamp(0, max) as u16; - } + tx1d_narrow(row, N, p.row_adst); + for v in row.iter_mut() { + *v = sat(*v, lim); } - } else { - let lim = 1i64 << 40; - let mut t = [0i64; 1024]; - for i in 0..rows.min(n) { - let row = &mut t[i * n..i * n + n]; - for (d, s) in row.iter_mut().zip(&coefs[i * n..i * n + n]) { - *d = *s as i64; - } - tx1d_wide(row, n, row_adst); - for v in row.iter_mut() { - *v = (*v).clamp(-lim, lim); - } + } + let mut col = [0i32; N]; + for j in 0..N { + for i in 0..N { + col[i] = t[i][j]; } - let mut col = [0i64; 32]; - for j in 0..n { - for i in 0..n { - col[i] = t[i * n + j]; - } - tx1d_wide(&mut col[..n], n, col_adst); - for i in 0..n { - let p = &mut dst[i * stride + j]; - let r = ((col[i].clamp(-lim, lim) + round as i64) >> shift).clamp(-(1 << 30), 1 << 30) as i32; - *p = (*p as i32 + r).clamp(0, max) as u16; - } + tx1d_narrow(&mut col, N, p.col_adst); + for i in 0..N { + let d = &mut dst[i * stride + j]; + *d = (*d as i32 + (col[i].wrapping_add(p.round) >> p.shift).clamp(-(1 << 20), 1 << 20)).clamp(0, p.max) as u16; + } + } +} + +/// 2D inverse transform of size N with 64-bit intermediates (high bit depth). +fn itx_wide(coefs: &[i32], p: &TxParams, dst: &mut [u16], stride: usize) { + let lim = 1i64 << 40; + let mut t = [[0i64; N]; N]; + for (i, row) in t.iter_mut().enumerate().take(p.rows.min(N)) { + for (d, s) in row.iter_mut().zip(&coefs[i * N..i * N + N]) { + *d = *s as i64; + } + tx1d_wide(row, N, p.row_adst); + for v in row.iter_mut() { + *v = (*v).clamp(-lim, lim); + } + } + let mut col = [0i64; N]; + for j in 0..N { + for i in 0..N { + col[i] = t[i][j]; + } + tx1d_wide(&mut col, N, p.col_adst); + for i in 0..N { + let d = &mut dst[i * stride + j]; + let r = ((col[i].clamp(-lim, lim) + p.round as i64) >> p.shift).clamp(-(1 << 30), 1 << 30) as i32; + *d = (*d as i32 + r).clamp(0, p.max) as u16; } } } diff --git a/crates/vp9/tests/common/mod.rs b/crates/vp9/tests/common/mod.rs index 1eed03afc..fad515e9d 100644 --- a/crates/vp9/tests/common/mod.rs +++ b/crates/vp9/tests/common/mod.rs @@ -153,6 +153,14 @@ pub fn fixtures_dir() -> PathBuf { d } +/// Unique suffix for temporary files: tests run on several threads of one process and may +/// generate the same fixture concurrently. +pub fn unique() -> String { + static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + format!("{}-{n}", std::process::id()) +} + pub fn run(cmd: &mut Command) -> bool { match cmd.output() { Ok(o) if o.status.success() => true, @@ -185,14 +193,14 @@ pub fn format_info(pix_fmt: &str) -> (usize, u32, u32) { /// Reference decode of `ivf` with ffmpeg (native vp9 decoder, every frame, no fps conversion). pub fn reference_decode(ivf: &Path, yuv: &Path, pix_fmt: &str) -> bool { let Some(ff) = ffmpeg() else { return false }; - let tmp = yuv.with_extension(format!("tmp.{}.yuv", std::process::id())); + let tmp = yuv.with_extension(format!("tmp.{}.yuv", unique())); let mut c = Command::new(&ff); c.args(["-hide_banner", "-loglevel", "error", "-y", "-c:v", "vp9", "-i"]).arg(ivf); c.args(["-fps_mode", "passthrough", "-noautoscale", "-f", "rawvideo", "-pix_fmt", pix_fmt]).arg(&tmp); if !run(&mut c) { return false; } - std::fs::rename(&tmp, yuv).is_ok() + std::fs::rename(&tmp, yuv).is_ok() || yuv.exists() } /// Generate (if needed) the fixture stream and its ffmpeg reference decode. @@ -205,7 +213,7 @@ pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { let ivf = dir.join(format!("{}.ivf", f.name)); let yuv = dir.join(format!("{}.yuv", f.name)); if !ivf.exists() { - let tmp = dir.join(format!("{}.tmp.{}.ivf", f.name, std::process::id())); + let tmp = dir.join(format!("{}.tmp.{}.ivf", f.name, unique())); let mut vf = format!("{}=size={}x{}:rate=25,format={}", f.source, f.width, f.height, f.pix_fmt); if !f.filter.is_empty() { vf.push(','); @@ -213,7 +221,7 @@ pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { } let two_pass = f.args.first() == Some(&"2pass"); let args: Vec<&str> = f.args.iter().copied().filter(|a| *a != "2pass").collect(); - let log = dir.join(format!("{}.{}.passlog", f.name, std::process::id())); + let log = dir.join(format!("{}.{}.passlog", f.name, unique())); let enc = |pass: Option, out: &Path| { let mut c = Command::new(&ff); c.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", &vf]); @@ -240,7 +248,9 @@ pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> { return None; } let _ = std::fs::remove_file(format!("{}-0.log", log.display())); - std::fs::rename(&tmp, &ivf).unwrap(); + if std::fs::rename(&tmp, &ivf).is_err() && !ivf.exists() { + panic!("could not store fixture {}", ivf.display()); + } } if !yuv.exists() { assert!(reference_decode(&ivf, &yuv, f.pix_fmt), "reference decode failed for {}", f.name); From 7655447233d2f068134e9df9ac71d42da652dd66 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 00:26:14 -0400 Subject: [PATCH 07/24] T3: golden-image tests for the renderer + GPU parity; fix stale GPU uploads crates/golden (test-only, L3): nine procedural 320x180 scenes (transform and opacity, blend modes, Gaussian Blur, Lumetri basic, Crop, Cross Dissolve / Dip to Black / Wipe mid-transition, timecode/clip-name text) rendered on the CPU and compared with committed goldens (PSNR >= 45 dB, max <= 12, p99 <= 2); FILMCRAFT_BLESS=1 regenerates them with attribution sidecars. testkit::golden adds PNG I/O, diff metrics and failure images. The same scenes are composited on the GPU and compared with the CPU. GPU parity exposed a real bug: GpuCompositor cached uploads by pixel-buffer address without owning the buffer, so a new frame allocated at a freed frame's address was drawn with a stale texture (whole wrong frames, PSNR ~12 dB). The cache now keeps the buffer alive; regression test upload_cache_keeps_buffers_alive. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 18 + Cargo.lock | 15 + crates/golden/Cargo.toml | 26 ++ crates/golden/goldens/blend_modes.png | Bin 0 -> 37912 bytes .../goldens/blend_modes.png.attribution | 8 + crates/golden/goldens/crop.png | Bin 0 -> 7677 bytes crates/golden/goldens/crop.png.attribution | 8 + crates/golden/goldens/gaussian_blur.png | Bin 0 -> 7937 bytes .../goldens/gaussian_blur.png.attribution | 8 + crates/golden/goldens/lumetri_basic.png | Bin 0 -> 25016 bytes .../goldens/lumetri_basic.png.attribution | 8 + crates/golden/goldens/text_burnin.png | Bin 0 -> 4528 bytes .../goldens/text_burnin.png.attribution | 8 + crates/golden/goldens/transform_opacity.png | Bin 0 -> 26637 bytes .../goldens/transform_opacity.png.attribution | 8 + .../goldens/transition_cross_dissolve.png | Bin 0 -> 29914 bytes .../transition_cross_dissolve.png.attribution | 8 + .../goldens/transition_dip_to_black.png | Bin 0 -> 17162 bytes .../transition_dip_to_black.png.attribution | 8 + crates/golden/goldens/transition_wipe.png | Bin 0 -> 26976 bytes .../goldens/transition_wipe.png.attribution | 8 + crates/golden/src/lib.rs | 8 + crates/golden/tests/golden.rs | 345 ++++++++++++++++++ crates/gpu/src/lib.rs | 11 +- crates/gpu/src/tests.rs | 18 + crates/testkit/src/golden.rs | 344 +++++++++++++++++ crates/testkit/src/lib.rs | 3 + docs/architecture.md | 3 +- docs/testing.md | 23 +- xtask/src/main.rs | 1 + 30 files changed, 879 insertions(+), 8 deletions(-) create mode 100644 crates/golden/Cargo.toml create mode 100644 crates/golden/goldens/blend_modes.png create mode 100644 crates/golden/goldens/blend_modes.png.attribution create mode 100644 crates/golden/goldens/crop.png create mode 100644 crates/golden/goldens/crop.png.attribution create mode 100644 crates/golden/goldens/gaussian_blur.png create mode 100644 crates/golden/goldens/gaussian_blur.png.attribution create mode 100644 crates/golden/goldens/lumetri_basic.png create mode 100644 crates/golden/goldens/lumetri_basic.png.attribution create mode 100644 crates/golden/goldens/text_burnin.png create mode 100644 crates/golden/goldens/text_burnin.png.attribution create mode 100644 crates/golden/goldens/transform_opacity.png create mode 100644 crates/golden/goldens/transform_opacity.png.attribution create mode 100644 crates/golden/goldens/transition_cross_dissolve.png create mode 100644 crates/golden/goldens/transition_cross_dissolve.png.attribution create mode 100644 crates/golden/goldens/transition_dip_to_black.png create mode 100644 crates/golden/goldens/transition_dip_to_black.png.attribution create mode 100644 crates/golden/goldens/transition_wipe.png create mode 100644 crates/golden/goldens/transition_wipe.png.attribution create mode 100644 crates/golden/src/lib.rs create mode 100644 crates/golden/tests/golden.rs create mode 100644 crates/testkit/src/golden.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index abe06308d..394012f27 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -23,6 +23,24 @@ iconography, images or other Adobe assets; open licences only; every asset attri | `docs/images/filmcraft-effects.png` | FilmCraft contributors | Original work: FilmCraft screenshot crop; NASA Earth Views (US Government work, public domain) | MIT OR Apache-2.0; footage public domain | | `docs/images/filmcraft-timeline.png` | FilmCraft contributors | Original work: FilmCraft UI crop; thumbnails from Night of the Living Dead (1968) and Carnival of Souls (1962), CC0 Chopin waveform | MIT OR Apache-2.0; film frames public domain | +## Golden test images + +Reference frames for the renderer golden tests (`crates/golden`), rendered by FilmCraft itself from +procedural demo footage (`crates/media/src/generators.rs`); no external media. Regenerate with +`FILMCRAFT_BLESS=1 cargo test -p filmcraft-golden`. + +| Asset | Author | Source | Licence | +|---|---|---|---| +| `crates/golden/goldens/blend_modes.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `blend_modes`) from procedurally generated media | MIT OR Apache-2.0 | +| `crates/golden/goldens/crop.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `crop`) from procedurally generated media | MIT OR Apache-2.0 | +| `crates/golden/goldens/gaussian_blur.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `gaussian_blur`) from procedurally generated media | MIT OR Apache-2.0 | +| `crates/golden/goldens/lumetri_basic.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `lumetri_basic`) from procedurally generated media | MIT OR Apache-2.0 | +| `crates/golden/goldens/text_burnin.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `text_burnin`) from procedurally generated media | MIT OR Apache-2.0 | +| `crates/golden/goldens/transform_opacity.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transform_opacity`) from procedurally generated media | MIT OR Apache-2.0 | +| `crates/golden/goldens/transition_cross_dissolve.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transition_cross_dissolve`) from procedurally generated media | MIT OR Apache-2.0 | +| `crates/golden/goldens/transition_dip_to_black.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transition_dip_to_black`) from procedurally generated media | MIT OR Apache-2.0 | +| `crates/golden/goldens/transition_wipe.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transition_wipe`) from procedurally generated media | MIT OR Apache-2.0 | + ## Third-party media shown in screenshots The README screenshots show frames from these works. The media files themselves are not in the diff --git a/Cargo.lock b/Cargo.lock index c0c0ca828..9bc39411d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1451,6 +1451,21 @@ dependencies = [ "serde", ] +[[package]] +name = "filmcraft-golden" +version = "0.1.0" +dependencies = [ + "filmcraft-geom", + "filmcraft-gpu", + "filmcraft-media", + "filmcraft-project", + "filmcraft-render", + "filmcraft-testkit", + "filmcraft-time", + "pollster 0.4.0", + "wgpu", +] + [[package]] name = "filmcraft-gpu" version = "0.1.0" diff --git a/crates/golden/Cargo.toml b/crates/golden/Cargo.toml new file mode 100644 index 000000000..8ed401cb5 --- /dev/null +++ b/crates/golden/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "filmcraft-golden" +description = "Golden-image tests for the CPU renderer/compositor and GPU-vs-CPU parity (test-only crate)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +[lints] +workspace = true + +# Test-only: the library is empty and everything is a dev-dependency, so the crate adds no edges +# to the layer graph. Goldens live in `goldens/` (see docs/testing.md §3). +[dependencies] + +[dev-dependencies] +filmcraft-testkit = { workspace = true } +filmcraft-time = { workspace = true } +filmcraft-geom = { workspace = true } +filmcraft-media = { workspace = true } +filmcraft-project = { workspace = true } +filmcraft-render = { workspace = true } +filmcraft-gpu = { workspace = true } +pollster = "0.4" +wgpu = { version = "30", features = ["wgsl", "metal", "vulkan", "dx12", "gles"] } diff --git a/crates/golden/goldens/blend_modes.png b/crates/golden/goldens/blend_modes.png new file mode 100644 index 0000000000000000000000000000000000000000..97ec12057ddc28d7b5f62a90dc99eb0dbfd4be4a GIT binary patch literal 37912 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 literal 0 HcmV?d00001 diff --git a/crates/golden/goldens/gaussian_blur.png.attribution b/crates/golden/goldens/gaussian_blur.png.attribution new file mode 100644 index 000000000..1e0c7bf7c --- /dev/null +++ b/crates/golden/goldens/gaussian_blur.png.attribution @@ -0,0 +1,8 @@ +asset: gaussian_blur.png +title: Gaussian Blur effect +author: FilmCraft contributors +source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media) +license: MIT OR Apache-2.0 +license-file: +added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1) +notes: Golden reference image generated by crates/golden/tests/golden.rs (gaussian_blur). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork. diff --git a/crates/golden/goldens/lumetri_basic.png b/crates/golden/goldens/lumetri_basic.png new file mode 100644 index 0000000000000000000000000000000000000000..7ae95cb90d0c78610517ba8581a822edf80077a3 GIT binary patch literal 25016 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

j3Q= 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 literal 0 HcmV?d00001 diff --git a/crates/golden/goldens/transform_opacity.png.attribution b/crates/golden/goldens/transform_opacity.png.attribution new file mode 100644 index 000000000..7d0e12b74 --- /dev/null +++ b/crates/golden/goldens/transform_opacity.png.attribution @@ -0,0 +1,8 @@ +asset: transform_opacity.png +title: Motion transform and opacity +author: FilmCraft contributors +source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media) +license: MIT OR Apache-2.0 +license-file: +added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1) +notes: Golden reference image generated by crates/golden/tests/golden.rs (transform_opacity). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork. diff --git a/crates/golden/goldens/transition_cross_dissolve.png b/crates/golden/goldens/transition_cross_dissolve.png new file mode 100644 index 0000000000000000000000000000000000000000..2ef34ac41d41d6fe1f894af98a494832afb37d83 GIT binary patch literal 29914 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 z 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 RSU~^)002ovPDHLkV1jc7UEBZw literal 0 HcmV?d00001 diff --git a/crates/golden/goldens/transition_dip_to_black.png.attribution b/crates/golden/goldens/transition_dip_to_black.png.attribution new file mode 100644 index 000000000..57ff8d19a --- /dev/null +++ b/crates/golden/goldens/transition_dip_to_black.png.attribution @@ -0,0 +1,8 @@ +asset: transition_dip_to_black.png +title: Dip to Black at 25% +author: FilmCraft contributors +source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media) +license: MIT OR Apache-2.0 +license-file: +added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1) +notes: Golden reference image generated by crates/golden/tests/golden.rs (transition_dip_to_black). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork. diff --git a/crates/golden/goldens/transition_wipe.png b/crates/golden/goldens/transition_wipe.png new file mode 100644 index 0000000000000000000000000000000000000000..2548e9ce91a3b232778e9ca391be880b8f47b900 GIT binary patch literal 26976 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 DoK&71 literal 0 HcmV?d00001 diff --git a/crates/golden/goldens/transition_wipe.png.attribution b/crates/golden/goldens/transition_wipe.png.attribution new file mode 100644 index 000000000..8ea1f4dff --- /dev/null +++ b/crates/golden/goldens/transition_wipe.png.attribution @@ -0,0 +1,8 @@ +asset: transition_wipe.png +title: Wipe at 50% +author: FilmCraft contributors +source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media) +license: MIT OR Apache-2.0 +license-file: +added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1) +notes: Golden reference image generated by crates/golden/tests/golden.rs (transition_wipe). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork. diff --git a/crates/golden/src/lib.rs b/crates/golden/src/lib.rs new file mode 100644 index 000000000..71c8cad0e --- /dev/null +++ b/crates/golden/src/lib.rs @@ -0,0 +1,8 @@ +//! Golden-image tests for FilmCraft's renderer (test-only crate; the library is empty). +//! +//! `tests/golden.rs` builds small procedural projects (no media files), renders one frame of each +//! through the CPU reference compositor (`filmcraft-render`) and compares it with a committed PNG +//! in `goldens/` using `filmcraft_testkit::golden`. The same scenes are composited on the GPU +//! (`filmcraft-gpu`) when an adapter is available and compared with the CPU result. +//! +//! Regenerate references with `FILMCRAFT_BLESS=1 cargo test -p filmcraft-golden`. diff --git a/crates/golden/tests/golden.rs b/crates/golden/tests/golden.rs new file mode 100644 index 000000000..ddc3da30b --- /dev/null +++ b/crates/golden/tests/golden.rs @@ -0,0 +1,345 @@ +//! Golden-image tests: procedural projects rendered through the CPU compositor and compared with +//! committed reference PNGs (`goldens/*.png`, made by this test with `FILMCRAFT_BLESS=1`), plus +//! GPU-vs-CPU parity on the same scenes when a GPU adapter is available. +//! +//! Tolerances (8-bit sRGB levels, see `filmcraft_testkit::golden`): +//! - CPU vs golden: `Tolerance::RENDER` — PSNR ≥ 45 dB, max abs ≤ 12, 99th percentile ≤ 2. +//! - GPU vs CPU: 99th percentile of the per-pixel max channel difference ≤ 6 and mean < 1.5 +//! (antialiased edges and half-float textures differ slightly; the CPU is the reference). + +use std::path::PathBuf; +use std::sync::Arc; + +use filmcraft_geom::Vec2; +use filmcraft_media::generators::GeneratorSource; +use filmcraft_media::{DemoScene, Generator, MediaSource, SharedSource}; +use filmcraft_project::{ + ClipId, EffectInstance, ItemId, ItemKind, Label, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackItem, TrackKind, Transition, + TransitionId, find_effect, +}; +use filmcraft_render::{RenderOptions, SourceMap, render_sequence}; +use filmcraft_testkit::golden::{Rgba8, Tolerance, assert_golden, diff}; +use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange}; + +const W: u32 = 320; +const H: u32 = 180; +const RATE: FrameRate = FrameRate::FPS_24; + +/// A procedurally generated project and the frame to render. +struct Scene { + project: Project, + seq: ItemId, + sources: SourceMap, + frame: i64, +} + +struct Builder { + p: Project, + map: SourceMap, + seq: ItemId, + next_transition: u64, +} + +impl Builder { + fn new(video_tracks: usize) -> Self { + let mut p = Project::new("golden"); + let seq = p.new_sequence("golden", SequenceSettings { width: W, height: H, frame_rate: RATE, ..Default::default() }, video_tracks, 0, None); + Builder { p, map: SourceMap::default(), seq, next_transition: 1 } + } + + /// A generated media item (640×360, 24 fps, 10 s). + fn media(&mut self, g: Generator) -> ItemId { + let src = GeneratorSource::new(g, 640, 360, RATE, Tick(10 * TICKS_PER_SECOND)); + let info = src.info().clone(); + let id = self.p.add_item( + &info.name.clone(), + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::Generator(src.generator.clone()), + info, + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + }), + None, + ); + self.map.0.insert(id, Arc::new(src) as SharedSource); + id + } + + fn demo(&mut self, scene: DemoScene) -> ItemId { + self.media(Generator::Demo(scene)) + } + + /// Place `item` on video track `track` (frames), scaled to the frame size. + fn place(&mut self, track: usize, item: ItemId, start: i64, dur: i64) -> ClipId { + let mut ti = self.p.make_track_item(item, TrackKind::Video, RATE.tick_of(start), TimeRange::new(Tick::ZERO, RATE.tick_of(dur)), RATE).unwrap(); + ti.scale_to_frame = true; + let id = ti.id; + let s = self.p.sequence_mut(self.seq).unwrap(); + s.video_tracks[track].items.push(ti); + s.video_tracks[track].sort(); + id + } + + fn clip(&mut self, id: ClipId) -> &mut TrackItem { + self.p.sequence_mut(self.seq).unwrap().find_item_mut(id).unwrap().1 + } + + /// Set intrinsic-effect parameters (`motion`, `opacity`). + fn fixed(&mut self, id: ClipId, effect: &str, params: &[(&str, ParamValue)]) { + let e = self.clip(id).effect_mut(effect).unwrap(); + set(e, params); + } + + /// Append a standard effect with parameters. + fn effect(&mut self, id: ClipId, effect: &str, params: &[(&str, ParamValue)]) { + let mut e = find_effect(effect).unwrap_or_else(|| panic!("no effect {effect}")).instance(); + set(&mut e, params); + self.clip(id).effects.push(e); + } + + /// A transition of `dur` frames centred on the cut at `cut` between `a` and `b` on `track`. + fn transition(&mut self, track: usize, effect: &str, a: ClipId, b: ClipId, cut: i64, dur: i64) { + let id = TransitionId(self.next_transition); + self.next_transition += 1; + let tr = Transition { + id, + effect: find_effect(effect).unwrap_or_else(|| panic!("no transition {effect}")).instance(), + start: RATE.tick_of(cut - dur / 2), + duration: RATE.tick_of(dur), + from: Some(a), + to: Some(b), + align: Default::default(), + reverse: false, + }; + self.p.sequence_mut(self.seq).unwrap().video_tracks[track].transitions.push(tr); + } + + fn at(self, frame: i64) -> Scene { + Scene { project: self.p, seq: self.seq, sources: self.map, frame } + } +} + +fn set(e: &mut EffectInstance, params: &[(&str, ParamValue)]) { + let id = e.effect.clone(); + for (k, v) in params { + e.param_mut(k).unwrap_or_else(|| panic!("{id}: no param {k}")).value = v.clone(); + } +} + +fn fl(v: f64) -> ParamValue { + ParamValue::Float(v) +} + +fn pt(x: f64, y: f64) -> ParamValue { + ParamValue::Vec2(Vec2::new(x, y)) +} + +fn blend(name: &str) -> ParamValue { + ParamValue::Choice(filmcraft_project::effect::BLEND_MODES.iter().position(|b| *b == name).unwrap_or_else(|| panic!("blend {name}")) as u32) +} + +// ---- scenes ---- + +/// Motion (scale, rotation, position) and 70 % opacity over a full-frame background. +fn transform_opacity() -> Scene { + let mut b = Builder::new(2); + let bg = b.demo(DemoScene::OceanSunset); + let fg = b.demo(DemoScene::Aurora); + b.place(0, bg, 0, 48); + let top = b.place(1, fg, 0, 48); + b.fixed(top, "motion", &[("scale", fl(45.0)), ("rotation", fl(20.0)), ("position", pt(215.0, 70.0))]); + b.fixed(top, "opacity", &[("opacity", fl(70.0))]); + b.at(12) +} + +/// Four quadrant layers over a background in Multiply, Screen, Overlay and Difference. +fn blend_modes() -> Scene { + let mut b = Builder::new(5); + let bg = b.demo(DemoScene::OceanSunset); + let fg = b.demo(DemoScene::Dunes); + b.place(0, bg, 0, 48); + for (i, (mode, x, y)) in [("Multiply", 80.0, 45.0), ("Screen", 240.0, 45.0), ("Overlay", 80.0, 135.0), ("Difference", 240.0, 135.0)].into_iter().enumerate() + { + let c = b.place(i + 1, fg, 0, 48); + b.fixed(c, "motion", &[("scale", fl(50.0)), ("position", pt(x, y))]); + b.fixed(c, "opacity", &[("blend", blend(mode))]); + } + b.at(12) +} + +/// Gaussian Blur on colour bars (sharp edges show the kernel). +fn gaussian_blur() -> Scene { + let mut b = Builder::new(1); + let bars = b.media(Generator::BarsAndTone); + let c = b.place(0, bars, 0, 48); + b.effect(c, "gaussian_blur", &[("blurriness", fl(12.0))]); + b.at(12) +} + +/// Lumetri Color basic correction: temperature, exposure, contrast, highlights/shadows, saturation. +fn lumetri_basic() -> Scene { + let mut b = Builder::new(1); + let bg = b.demo(DemoScene::OceanSunset); + let c = b.place(0, bg, 0, 48); + b.effect( + c, + "lumetri", + &[ + ("temperature", fl(25.0)), + ("exposure", fl(0.6)), + ("contrast", fl(30.0)), + ("highlights", fl(-30.0)), + ("shadows", fl(25.0)), + ("saturation", fl(135.0)), + ], + ); + b.at(12) +} + +/// Crop (left/top/right/bottom) of a top layer revealing the background. +fn crop() -> Scene { + let mut b = Builder::new(2); + let bg = b.demo(DemoScene::OceanSunset); + let bars = b.media(Generator::BarsAndTone); + b.place(0, bg, 0, 48); + let c = b.place(1, bars, 0, 48); + b.effect(c, "crop", &[("left", fl(15.0)), ("top", fl(10.0)), ("right", fl(25.0)), ("bottom", fl(20.0))]); + b.at(12) +} + +/// Two clips with a 12-frame transition centred on the cut at frame 24, rendered at `frame`. +fn transition(effect: &str, frame: i64) -> Scene { + let mut b = Builder::new(1); + let a = b.demo(DemoScene::OceanSunset); + let c = b.demo(DemoScene::Aurora); + let ca = b.place(0, a, 0, 24); + let cb = b.place(0, c, 24, 24); + b.transition(0, effect, ca, cb, 24, 12); + b.at(frame) +} + +/// Timecode and Clip Name burn-ins (FilmCraft's built-in bitmap text). +fn text_burnin() -> Scene { + let mut b = Builder::new(1); + let bg = b.demo(DemoScene::CityNight); + let c = b.place(0, bg, 0, 48); + b.effect(c, "timecode", &[("position", pt(160.0, 40.0)), ("size", fl(20.0))]); + b.effect(c, "clip_name", &[("position", pt(160.0, 145.0)), ("size", fl(12.0))]); + b.at(30) +} + +/// (name, title, scene). +fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> { + vec![ + ("transform_opacity", "Motion transform and opacity", transform_opacity), + ("blend_modes", "Blend modes (Multiply, Screen, Overlay, Difference)", blend_modes), + ("gaussian_blur", "Gaussian Blur effect", gaussian_blur), + ("lumetri_basic", "Lumetri Color basic correction", lumetri_basic), + ("crop", "Crop effect", crop), + ("transition_cross_dissolve", "Cross Dissolve at 50%", || transition("cross_dissolve", 24)), + ("transition_dip_to_black", "Dip to Black at 25%", || transition("dip_to_black", 21)), + ("transition_wipe", "Wipe at 50%", || transition("wipe", 24)), + ("text_burnin", "Timecode and Clip Name burn-in text", text_burnin), + ] +} + +fn render_cpu(s: &Scene) -> Rgba8 { + let img = render_sequence(&s.project, s.seq, RATE.tick_of(s.frame), RenderOptions::default(), &s.sources); + Rgba8::new(img.w as u32, img.h as u32, img.over_black_rgba8()) +} + +fn golden_path(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("goldens").join(format!("{name}.png")) +} + +fn check(name: &str) { + let (_, title, make) = scenes().into_iter().find(|(n, _, _)| *n == name).unwrap(); + let img = render_cpu(&make()); + assert_eq!((img.w, img.h), (W, H)); + let d = assert_golden(&golden_path(name), &img, Tolerance::RENDER, title, &format!("crates/golden/tests/golden.rs ({name})")); + eprintln!("{name}: {d}"); +} + +macro_rules! goldens { + ($($name:ident),* $(,)?) => { + mod cpu { + $( + #[test] + fn $name() { + super::check(stringify!($name)); + } + )* + } + + #[test] + fn every_scene_has_a_test() { + let tested = [$(stringify!($name)),*]; + for (n, _, _) in scenes() { + assert!(tested.contains(&n), "scene {n} has no golden test"); + } + } + }; +} + +goldens!(transform_opacity, blend_modes, gaussian_blur, lumetri_basic, crop, transition_cross_dissolve, transition_dip_to_black, transition_wipe, text_burnin); + +/// Sanity: the scenes are not trivially empty or identical to each other. +#[test] +fn scenes_are_distinct() { + let imgs: Vec<(&str, Rgba8)> = scenes().into_iter().map(|(n, _, f)| (n, render_cpu(&f()))).collect(); + for (n, img) in &imgs { + let lit = img.px.chunks_exact(4).filter(|p| p[0] as u32 + p[1] as u32 + p[2] as u32 > 30).count(); + assert!(lit > img.px.len() / 4 / 4, "{n}: mostly black"); + } + for i in 0..imgs.len() { + for j in i + 1..imgs.len() { + let d = diff(&imgs[i].1, &imgs[j].1).unwrap(); + assert!(d.psnr < 30.0, "{} and {} are near-identical ({d})", imgs[i].0, imgs[j].0); + } + } +} + +// ---- GPU parity ---- + +fn device() -> Option<(wgpu::Device, wgpu::Queue)> { + let instance = wgpu::Instance::default(); + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?; + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok() +} + +#[test] +fn gpu_matches_cpu_on_golden_scenes() { + let Some((dev, q)) = device() else { + eprintln!("SKIPPED (gpu parity): no GPU adapter"); + return; + }; + let mut c = filmcraft_gpu::GpuCompositor::new(&dev, &q); + let mut failures = Vec::new(); + for (name, _, make) in scenes() { + let s = make(); + let t = RATE.tick_of(s.frame); + let cpu = render_cpu(&s); + let plan = filmcraft_render::plan::plan_frame(&s.project, s.seq, t, RenderOptions::default(), &s.sources); + let kind = if matches!(plan, filmcraft_render::plan::FramePlan::Layers { .. }) { "layers" } else { "cpu image" }; + c.composite(&plan); + let (gw, gh, mut px) = c.read_output().expect("GPU readback"); + px.chunks_exact_mut(4).for_each(|p| p[3] = 255); + let gpu = Rgba8::new(gw, gh, px); + let d = diff(&cpu, &gpu).unwrap(); + // mean over RGB only (alpha is equal by construction) + let mean_rgb = d.mean_abs * 4.0 / 3.0; + eprintln!("{name} ({kind}): GPU vs CPU {d}"); + if d.p99 > 6 || mean_rgb >= 1.5 { + let dir = filmcraft_testkit::golden::failures_dir(); + let _ = filmcraft_testkit::golden::write_png(&dir.join(format!("{name}.gpu.png")), &gpu); + let _ = filmcraft_testkit::golden::write_png(&dir.join(format!("{name}.cpu.png")), &cpu); + failures.push(format!("{name} ({kind}): {d}; images in {}", dir.display())); + } + } + assert!(failures.is_empty(), "GPU differs from the CPU reference:\n{}", failures.join("\n")); +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 83e46f795..790861692 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -27,6 +27,9 @@ struct Uploaded { code_scale: f32, chroma: (u32, u32), last_used: u64, + /// The uploaded pixel buffers, kept alive while cached: the cache key is the buffer address, + /// and a freed buffer's address can be reused by a different frame (stale texture). + _pixels: PixelData, } pub struct GpuCompositor { @@ -245,12 +248,12 @@ impl GpuCompositor { let up = match &f.data { PixelData::Rgba8(d) => { let v = self.plane_texture(w, h, wgpu::TextureFormat::Rgba8UnormSrgb, d, 4); - Uploaded { views: [v, dummy(), dummy()], kind: 0, code_scale: 1.0, chroma: (w, h), last_used: self.clock } + Uploaded { views: [v, dummy(), dummy()], kind: 0, code_scale: 1.0, chroma: (w, h), last_used: self.clock, _pixels: f.data.clone() } } PixelData::RgbaF32(d) => { let half: Vec = d.iter().flat_map(|v| f32_to_f16(*v).to_le_bytes()).collect(); let v = self.plane_texture(w, h, wgpu::TextureFormat::Rgba16Float, &half, 8); - Uploaded { views: [v, dummy(), dummy()], kind: 1, code_scale: 1.0, chroma: (w, h), last_used: self.clock } + Uploaded { views: [v, dummy(), dummy()], kind: 1, code_scale: 1.0, chroma: (w, h), last_used: self.clock, _pixels: f.data.clone() } } PixelData::Yuv8 { planes, chroma, .. } => { let (sx, sy) = chroma.shifts(); @@ -258,7 +261,7 @@ impl GpuCompositor { let y = self.plane_texture(w, h, wgpu::TextureFormat::R8Unorm, &planes[0], 1); let u = self.plane_texture(cw, ch, wgpu::TextureFormat::R8Unorm, &planes[1], 1); let v = self.plane_texture(cw, ch, wgpu::TextureFormat::R8Unorm, &planes[2], 1); - Uploaded { views: [y, u, v], kind: 2, code_scale: 255.0, chroma: (cw, ch), last_used: self.clock } + Uploaded { views: [y, u, v], kind: 2, code_scale: 255.0, chroma: (cw, ch), last_used: self.clock, _pixels: f.data.clone() } } PixelData::Yuv16 { planes, chroma, bits, .. } => { let (sx, sy) = chroma.shifts(); @@ -270,7 +273,7 @@ impl GpuCompositor { let u = self.plane_texture(cw, ch, wgpu::TextureFormat::R16Float, &pu, 2); let v = self.plane_texture(cw, ch, wgpu::TextureFormat::R16Float, &pv, 2); let _ = Chroma::C420; - Uploaded { views: [y, u, v], kind: 2, code_scale: scale, chroma: (cw, ch), last_used: self.clock } + Uploaded { views: [y, u, v], kind: 2, code_scale: scale, chroma: (cw, ch), last_used: self.clock, _pixels: f.data.clone() } } }; self.uploads.insert(key, up); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 9aacbde97..f787d7056 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -77,3 +77,21 @@ fn half_float_conversion() { assert!((d - v).abs() <= v.abs() * 1e-3 + 1e-6, "{v} → {d}"); } } + +/// The upload cache is keyed by pixel-buffer address, so it must keep the buffer alive: otherwise +/// a new frame allocated at a freed frame's address is drawn with the stale texture (seen as +/// whole frames from a previous composite in `crates/golden` GPU parity). +#[test] +fn upload_cache_keeps_buffers_alive() { + let Some((dev, q)) = device() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let mut c = GpuCompositor::new(&dev, &q); + let px = Arc::new(vec![200u8; 16 * 8 * 4]); + let frame = Arc::new(VideoFrame { width: 16, height: 8, data: PixelData::Rgba8(px.clone()), ..(*yuv_frame(16, 8)).clone() }); + let plan = FramePlan::Layers { width: 16, height: 8, layers: vec![PlanLayer { frame, matrix: Affine::IDENTITY, opacity: 1.0 }] }; + c.composite(&plan); + drop(plan); + assert!(Arc::strong_count(&px) > 1, "cached upload must own its pixel buffer"); +} diff --git a/crates/testkit/src/golden.rs b/crates/testkit/src/golden.rs new file mode 100644 index 000000000..d79fbad02 --- /dev/null +++ b/crates/testkit/src/golden.rs @@ -0,0 +1,344 @@ +//! Golden-image testing: RGBA8 images, PNG I/O, difference metrics and reference checks. +//! +//! A golden test renders an image, then calls [`assert_golden`] with the path of a committed +//! reference PNG and a [`Tolerance`]. On mismatch the actual image and an amplified difference +//! image are written to `/target/golden-failures/` for inspection. +//! +//! `FILMCRAFT_BLESS=1` (re)writes the reference instead of comparing, plus its `.attribution` +//! sidecar when missing (AGENTS.md §1). References must be produced by FilmCraft itself (original +//! work), be small (< [`MAX_GOLDEN_BYTES`]) and be listed in `ATTRIBUTION.md`. + +use std::io::{BufReader, Cursor}; +use std::path::{Path, PathBuf}; + +/// Size limit for a committed golden PNG. +pub const MAX_GOLDEN_BYTES: u64 = 50 * 1024; + +/// An 8-bit RGBA image, row-major, no padding. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Rgba8 { + pub w: u32, + pub h: u32, + pub px: Vec, +} + +impl Rgba8 { + pub fn new(w: u32, h: u32, px: Vec) -> Self { + assert_eq!(px.len(), w as usize * h as usize * 4, "pixel buffer size"); + Self { w, h, px } + } + pub fn get(&self, x: u32, y: u32) -> [u8; 4] { + let i = (y as usize * self.w as usize + x as usize) * 4; + [self.px[i], self.px[i + 1], self.px[i + 2], self.px[i + 3]] + } + fn opaque(&self) -> bool { + self.px.chunks_exact(4).all(|p| p[3] == 255) + } +} + +/// Encode as PNG (RGB when fully opaque, else RGBA), maximum compression. +pub fn encode_png(img: &Rgba8) -> Result, String> { + let mut out = Vec::new(); + { + let mut enc = png::Encoder::new(&mut out, img.w, img.h); + let opaque = img.opaque(); + enc.set_color(if opaque { png::ColorType::Rgb } else { png::ColorType::Rgba }); + enc.set_depth(png::BitDepth::Eight); + enc.set_compression(png::Compression::High); + let mut w = enc.write_header().map_err(|e| e.to_string())?; + if opaque { + let rgb: Vec = img.px.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); + w.write_image_data(&rgb).map_err(|e| e.to_string())?; + } else { + w.write_image_data(&img.px).map_err(|e| e.to_string())?; + } + w.finish().map_err(|e| e.to_string())?; + } + Ok(out) +} + +/// Decode a PNG (8-bit gray/RGB/RGBA, palette expanded) to RGBA8. +pub fn decode_png(bytes: &[u8]) -> Result { + let mut dec = png::Decoder::new(BufReader::new(Cursor::new(bytes))); + dec.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); + let mut r = dec.read_info().map_err(|e| e.to_string())?; + let mut buf = vec![0; r.output_buffer_size().ok_or("PNG too large")?]; + let info = r.next_frame(&mut buf).map_err(|e| e.to_string())?; + let (w, h) = (info.width, info.height); + let n = w as usize * h as usize; + let px = match info.color_type { + png::ColorType::Rgba => buf[..n * 4].to_vec(), + png::ColorType::Rgb => buf[..n * 3].chunks_exact(3).flat_map(|p| [p[0], p[1], p[2], 255]).collect(), + png::ColorType::GrayscaleAlpha => buf[..n * 2].chunks_exact(2).flat_map(|p| [p[0], p[0], p[0], p[1]]).collect(), + png::ColorType::Grayscale => buf[..n].iter().flat_map(|&g| [g, g, g, 255]).collect(), + png::ColorType::Indexed => return Err("unexpanded palette PNG".into()), + }; + Ok(Rgba8 { w, h, px }) +} + +pub fn read_png(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + decode_png(&bytes).map_err(|e| format!("{}: {e}", path.display())) +} + +pub fn write_png(path: &Path, img: &Rgba8) -> Result { + let bytes = encode_png(img)?; + if let Some(d) = path.parent() { + std::fs::create_dir_all(d).map_err(|e| e.to_string())?; + } + std::fs::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(bytes.len() as u64) +} + +/// Per-pixel difference statistics over all four channels (8-bit levels). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Diff { + /// Largest absolute channel difference. + pub max_abs: u8, + /// Mean absolute channel difference. + pub mean_abs: f64, + /// 99th percentile of the per-pixel maximum channel difference. + pub p99: u8, + /// PSNR in dB over all channels (`f64::INFINITY` when identical). + pub psnr: f64, + /// Pixels with any channel different. + pub differing: usize, +} + +impl std::fmt::Display for Diff { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "PSNR {:.2} dB, max {}, p99 {}, mean {:.3}, {} pixels differ", self.psnr, self.max_abs, self.p99, self.mean_abs, self.differing) + } +} + +/// Compare two images of equal size. +pub fn diff(a: &Rgba8, b: &Rgba8) -> Result { + if (a.w, a.h) != (b.w, b.h) { + return Err(format!("size {}x{} vs {}x{}", a.w, a.h, b.w, b.h)); + } + let mut hist = [0usize; 256]; + let (mut sum, mut sq, mut max, mut differing) = (0u64, 0u64, 0u8, 0usize); + for (p, q) in a.px.chunks_exact(4).zip(b.px.chunks_exact(4)) { + let mut m = 0u8; + for k in 0..4 { + let d = p[k].abs_diff(q[k]); + sum += d as u64; + sq += d as u64 * d as u64; + m = m.max(d); + } + hist[m as usize] += 1; + max = max.max(m); + differing += (m > 0) as usize; + } + let pixels = (a.w as usize * a.h as usize).max(1); + let target = (pixels * 99).div_ceil(100); + let mut acc = 0; + let mut p99 = 0u8; + for (v, n) in hist.iter().enumerate() { + acc += n; + if acc >= target { + p99 = v as u8; + break; + } + } + let n = (pixels * 4) as f64; + let mse = sq as f64 / n; + let psnr = if mse == 0.0 { f64::INFINITY } else { 10.0 * (255.0f64 * 255.0 / mse).log10() }; + Ok(Diff { max_abs: max, mean_abs: sum as f64 / n, p99, psnr, differing }) +} + +/// Acceptance thresholds; every set limit must hold. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Tolerance { + pub min_psnr: f64, + pub max_abs: u8, + pub p99: u8, +} + +impl Tolerance { + /// Bit-exact. + pub const EXACT: Tolerance = Tolerance { min_psnr: f64::INFINITY, max_abs: 0, p99: 0 }; + /// Default for CPU renders compared with CPU-made references: absorbs float differences + /// between platforms (FMA, SIMD widths, thread scheduling of reductions) but not real changes. + pub const RENDER: Tolerance = Tolerance { min_psnr: 45.0, max_abs: 12, p99: 2 }; + + pub fn accepts(&self, d: &Diff) -> bool { + d.psnr >= self.min_psnr && d.max_abs <= self.max_abs && d.p99 <= self.p99 + } +} + +/// Whether `FILMCRAFT_BLESS` asks to regenerate references. +pub fn blessing() -> bool { + std::env::var("FILMCRAFT_BLESS").is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) +} + +/// Where failing comparisons write `.actual.png` / `.diff.png`. +pub fn failures_dir() -> PathBuf { + crate::workspace_root().join("target").join("golden-failures") +} + +/// Difference image: |a − b| per channel ×8, opaque. +pub fn diff_image(a: &Rgba8, b: &Rgba8) -> Rgba8 { + let px = + a.px.chunks_exact(4) + .zip(b.px.chunks_exact(4)) + .flat_map(|(p, q)| { + let d = |k: usize| (p[k].abs_diff(q[k]) as u32 * 8).min(255) as u8; + let al = d(3); + [d(0).max(al), d(1).max(al), d(2).max(al), 255] + }) + .collect(); + Rgba8 { w: a.w, h: a.h, px } +} + +/// Today's date (UTC) as YYYY-MM-DD, for attribution sidecars. +fn today() -> String { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + // civil-from-days (Howard Hinnant) + let z = (secs / 86_400) as i64 + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + (m <= 2) as i64; + format!("{y:04}-{m:02}-{d:02}") +} + +/// The attribution sidecar text for a generated golden image. +pub fn sidecar_text(file_name: &str, title: &str, generated_by: &str) -> String { + format!( + "asset: {file_name}\n\ + title: {title}\n\ + author: FilmCraft contributors\n\ + source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media)\n\ + license: MIT OR Apache-2.0\n\ + license-file:\n\ + added: {} by FilmCraft contributors (FILMCRAFT_BLESS=1)\n\ + notes: Golden reference image generated by {generated_by}. Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork.\n", + today() + ) +} + +/// The `ATTRIBUTION.md` table row for a golden image at workspace-relative `rel`. +pub fn attribution_row(rel: &str, generated_by: &str) -> String { + format!("| `{rel}` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft ({generated_by}) | MIT OR Apache-2.0 |") +} + +/// Compare `actual` with the golden PNG at `golden` (or bless it). `title` and `generated_by` +/// fill the attribution sidecar written on bless. Returns the difference on success. +pub fn check_golden(golden: &Path, actual: &Rgba8, tol: Tolerance, title: &str, generated_by: &str) -> Result { + let name = golden.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + if blessing() { + let size = write_png(golden, actual)?; + if size > MAX_GOLDEN_BYTES { + return Err(format!("{}: {size} bytes exceeds the {MAX_GOLDEN_BYTES}-byte golden limit; render smaller", golden.display())); + } + let side = PathBuf::from(format!("{}.attribution", golden.display())); + if !side.exists() { + let file = golden.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + std::fs::write(&side, sidecar_text(&file, title, generated_by)).map_err(|e| e.to_string())?; + } + let root = crate::workspace_root(); + let rel = golden.strip_prefix(&root).unwrap_or(golden).to_string_lossy().replace('\\', "/"); + let index = std::fs::read_to_string(root.join("ATTRIBUTION.md")).unwrap_or_default(); + if !index.contains(&format!("`{rel}`")) { + eprintln!("golden {rel}: add this row to ATTRIBUTION.md:\n{}", attribution_row(&rel, generated_by)); + } + eprintln!("blessed {rel} ({size} bytes)"); + return Ok(Diff { max_abs: 0, mean_abs: 0.0, p99: 0, psnr: f64::INFINITY, differing: 0 }); + } + let expected = read_png(golden).map_err(|e| format!("{e}\n(missing or unreadable golden; generate it with FILMCRAFT_BLESS=1)"))?; + let d = diff(&expected, actual).map_err(|e| format!("{}: {e}", golden.display()))?; + if tol.accepts(&d) { + return Ok(d); + } + let dir = failures_dir(); + let a = dir.join(format!("{name}.actual.png")); + let _ = write_png(&a, actual); + let _ = write_png(&dir.join(format!("{name}.diff.png")), &diff_image(&expected, actual)); + Err(format!( + "{}: {d} (tolerance: PSNR ≥ {} dB, max ≤ {}, p99 ≤ {}); actual and diff written to {}", + golden.display(), + tol.min_psnr, + tol.max_abs, + tol.p99, + dir.display() + )) +} + +/// [`check_golden`] that panics on failure. +pub fn assert_golden(golden: &Path, actual: &Rgba8, tol: Tolerance, title: &str, generated_by: &str) -> Diff { + match check_golden(golden, actual, tol, title, generated_by) { + Ok(d) => d, + Err(e) => panic!("golden mismatch: {e}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ramp(w: u32, h: u32, alpha: u8) -> Rgba8 { + let px = (0..w * h).flat_map(|i| [(i % w * 255 / w) as u8, (i / w * 255 / h) as u8, 77, alpha]).collect(); + Rgba8::new(w, h, px) + } + + #[test] + fn png_round_trip_rgb_and_rgba() { + for a in [255, 128] { + let img = ramp(37, 21, a); + let png = encode_png(&img).unwrap(); + assert_eq!(decode_png(&png).unwrap(), img); + } + } + + #[test] + fn diff_metrics() { + let a = ramp(10, 10, 255); + assert_eq!(diff(&a, &a).unwrap().psnr, f64::INFINITY); + let mut b = a.clone(); + b.px[0] = b.px[0].wrapping_add(10); // one channel of one pixel + let d = diff(&a, &b).unwrap(); + assert_eq!((d.max_abs, d.p99, d.differing), (10, 0, 1)); + assert!((d.mean_abs - 10.0 / 400.0).abs() < 1e-12); + let mse: f64 = 100.0 / 400.0; + assert!((d.psnr - 10.0 * (255.0f64 * 255.0 / mse).log10()).abs() < 1e-9); + assert!(Tolerance::RENDER.accepts(&d)); + assert!(!Tolerance::EXACT.accepts(&d)); + assert!(diff(&a, &ramp(10, 11, 255)).is_err()); + } + + #[test] + fn check_golden_accepts_within_tolerance_and_reports_failures() { + if blessing() { + return; + } + let dir = std::env::temp_dir().join(format!("filmcraft-testkit-golden-{}", std::process::id())); + let path = dir.join("ramp.png"); + let img = ramp(32, 16, 255); + write_png(&path, &img).unwrap(); + assert!(check_golden(&path, &img, Tolerance::EXACT, "t", "unit test").is_ok()); + let mut near = img.clone(); + near.px[5] += 1; + assert!(check_golden(&path, &near, Tolerance::RENDER, "t", "unit test").is_ok()); + let mut far = img.clone(); + far.px.iter_mut().step_by(4).for_each(|v| *v = 255 - *v); + let e = check_golden(&path, &far, Tolerance::RENDER, "t", "unit test").unwrap_err(); + assert!(e.contains("PSNR"), "{e}"); + assert!(check_golden(&dir.join("missing.png"), &img, Tolerance::RENDER, "t", "unit test").unwrap_err().contains("FILMCRAFT_BLESS")); + } + + #[test] + fn date_and_sidecar_fields() { + let t = today(); + assert_eq!(t.len(), 10); + assert!(t.starts_with("20")); + let s = sidecar_text("x.png", "X", "test y"); + for f in ["asset:", "title:", "author:", "source:", "license:", "added:"] { + assert!(s.lines().any(|l| l.starts_with(f) && l.len() > f.len() + 2), "{f}"); + } + } +} diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index f617e9221..4abcd3a36 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -4,11 +4,14 @@ //! `FILMCRAFT_REQUIRE_ORACLES=1`, fail — when they are missing ([`require_ffmpeg!`] & co.). //! - [`fixtures`]: the shared fixture directory (`/target/fixtures/`) and //! collision-free temporary names for concurrent generators. +//! - [`golden`]: RGBA8 images, PNG I/O, image-difference metrics and golden-file checks +//! (`FILMCRAFT_BLESS=1` regenerates references). //! //! ffmpeg/ffprobe are external test oracles and fixture generators only; they are never linked //! or shipped (AGENTS.md §2). pub mod fixtures; +pub mod golden; pub mod oracle; pub use fixtures::{fixtures_dir, temp_path, workspace_root}; diff --git a/docs/architecture.md b/docs/architecture.md index 5a488be55..c225ff120 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,7 +23,7 @@ Design principles: L6 apps/filmcraft · apps/filmcraft-cli L5 ui-egui · automation L4 engine - L3 render · gpu · export + L3 render · gpu · export · golden (test-only) L2 edit · codecs · interchange L1 frame · media · project · audio-dsp L0 foundation: time · geom · color · bitstream · testkit (dev-dependency only) @@ -55,6 +55,7 @@ and `filmcraft-cli`. | `interchange` | L2 | EDL, FCP7 XML, FCPXML and OTIO import/export (no file I/O) | | `render` | L3 | sequence evaluation, CPU compositor, video effects, transitions, audio mix | | `gpu` | L3 | wgpu compositor (WGSL) | +| `golden` | L3 | test-only: golden-image tests of the CPU renderer and GPU-vs-CPU parity; empty library, dev-dependencies only | | `export` | L3 | render → encode → mux pipeline, progress/cancel | | `engine` | L4 | `Session`, command registry, undo history, media pool, jobs, interchange glue | | `ui-egui` | L5 | the egui frontend: docking, panels, timeline, monitors, playback, control-channel handlers | diff --git a/docs/testing.md b/docs/testing.md index 6153af5e5..56fca2664 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -82,9 +82,26 @@ Each codec README has the full fixture matrix and the measured results. difference of ≤ 6 and a mean of < 1.5 (8-bit levels). It skips when no GPU adapter is available. - Effects implemented only on the CPU need no GPU test: the plan pre-renders those layers on the CPU. -- Golden-image files (rendered PNGs compared against committed references) are not used yet. If you - add them, generate the references from FilmCraft itself, give each one an attribution sidecar, - and document the tolerance (PSNR in dB, or max/percentile error). +- **Golden images** (`crates/golden`, a test-only crate): `tests/golden.rs` builds small procedural + projects (demo-generator footage, bars, no media files) at 320×180 and renders one frame of each + through the CPU compositor: Motion transform + opacity, four blend modes (Multiply, Screen, + Overlay, Difference), Gaussian Blur, Lumetri basic correction, Crop, Cross Dissolve at 50 %, Dip + to Black at 25 %, Wipe at 50 %, and Timecode / Clip Name burn-in text. Each frame is compared + with `crates/golden/goldens/.png` by `filmcraft_testkit::golden`: + **PSNR ≥ 45 dB, max abs ≤ 12, 99th-percentile per-pixel max channel difference ≤ 2** (8-bit + sRGB levels; `Tolerance::RENDER`). On failure the actual frame and a ×8 difference image go to + `/target/golden-failures/`. `scenes_are_distinct` guards against blank or duplicate + scenes. +- **Blessing:** `FILMCRAFT_BLESS=1 cargo test -p filmcraft-golden` rewrites the references (and + writes a `.attribution` sidecar for any new one; it prints the `ATTRIBUTION.md` row to add). + References are original work rendered by FilmCraft, must stay under 50 KB (enforced on bless), + and every one needs its sidecar and index row (`cargo xtask assets`). Look at a re-blessed PNG + before committing it. +- The same scenes run through `plan_frame` + `GpuCompositor` when a GPU adapter exists + (`gpu_matches_cpu_on_golden_scenes`, same p99 ≤ 6 / mean < 1.5 criterion; skipped otherwise). + This found a real bug: the GPU upload cache was keyed by pixel-buffer address without keeping + the buffer alive, so a new frame allocated at a freed frame's address was drawn with the stale + texture (fixed; regression test `upload_cache_keeps_buffers_alive`). ## 4. UI: control channel and screenshots diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 03e265913..ee24ea1e5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -46,6 +46,7 @@ const LAYERS: &[(&str, u8)] = &[ ("interchange", 2), ("render", 3), ("gpu", 3), + ("golden", 3), ("scopes", 3), ("playback", 3), ("export", 3), From 8eb8f5ff68ba21f807ce1e7a04a9e09485f9cd51 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 00:32:25 -0400 Subject: [PATCH 08/24] T4: BS.1770 loudness oracle test against ffmpeg ebur128 Seven generated signals (sine, pink noise at 48/96 kHz, speech-like at 48/44.1 kHz, gated stereo with silence and sub-gate passages, fs/4 inter-sample peak) written as float WAV and measured by LoudnessMeter and ffmpeg ebur128 (metadata, 3 decimals per 100 ms). Tolerances: M/S +-0.1 LU every 100 ms, I +-0.1 LU, LRA +-0.5 LU, true peak +-0.2 dB. Measured: M/S <= 0.0005, I <= 0.007, LRA <= 0.04 LU, TP <= 0.045 dB. On the fs/4 tone ffmpeg's true peak is +0.6 dB above the analytic value, so that case is checked against the analytic peak (ours -0.053). testkit gains a float WAV writer. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 + crates/audio-dsp/Cargo.toml | 3 + crates/audio-dsp/README.md | 3 + crates/audio-dsp/tests/loudness_oracle.rs | 285 ++++++++++++++++++++++ crates/testkit/src/lib.rs | 1 + crates/testkit/src/wav.rs | 47 ++++ docs/testing.md | 1 + 7 files changed, 343 insertions(+) create mode 100644 crates/audio-dsp/tests/loudness_oracle.rs create mode 100644 crates/testkit/src/wav.rs diff --git a/Cargo.lock b/Cargo.lock index 9bc39411d..a2d7c1539 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1312,6 +1312,9 @@ dependencies = [ [[package]] name = "filmcraft-audio-dsp" version = "0.1.0" +dependencies = [ + "filmcraft-testkit", +] [[package]] name = "filmcraft-automation" diff --git a/crates/audio-dsp/Cargo.toml b/crates/audio-dsp/Cargo.toml index bc1a0483b..aef31dba1 100644 --- a/crates/audio-dsp/Cargo.toml +++ b/crates/audio-dsp/Cargo.toml @@ -11,3 +11,6 @@ workspace = true # Intentionally dependency-free (layer L1): planar `&mut [f32]` slices in, planar slices out. [dependencies] + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/audio-dsp/README.md b/crates/audio-dsp/README.md index 99c85a0fd..e1ae13a6c 100644 --- a/crates/audio-dsp/README.md +++ b/crates/audio-dsp/README.md @@ -24,6 +24,9 @@ Householder FDN, phase vocoder). - Gated integrated loudness (−70 LUFS / −10 LU) and LRA (−70 / −20 LU, 10th–95th percentile) use a 0.01 LU histogram with exact energy sums: fixed memory for programmes of any length. - True peak: 4× (2× at 88.2/96 kHz) polyphase Kaiser-sinc interpolation. +- Oracle-tested against ffmpeg's `ebur128` filter (`tests/loudness_oracle.rs`, skipped without ffmpeg): + M/S within 0.0005 LU, I within 0.007 LU, LRA within 0.04 LU and true peak within 0.045 dB (0.053 dB of the analytic value on an fs/4 tone) on + sine, pink-noise, speech-like and gated stereo signals at 44.1/48/96 kHz. - `normalize_gain_db(measured, target)` and `normalize_gain_db_peak_limited(...)` for Normalize / Essential Sound loudness auto-match. diff --git a/crates/audio-dsp/tests/loudness_oracle.rs b/crates/audio-dsp/tests/loudness_oracle.rs new file mode 100644 index 000000000..1a3b82765 --- /dev/null +++ b/crates/audio-dsp/tests/loudness_oracle.rs @@ -0,0 +1,285 @@ +//! Oracle test: BS.1770-4 / EBU R128 loudness against ffmpeg's `ebur128` filter. +//! +//! Each signal is generated here, written as a 32-bit float WAV and measured twice: by +//! `LoudnessMeter` and by `ffmpeg -af ebur128=peak=true:metadata=1,ametadata=print` (three decimals +//! per 100 ms frame). ffmpeg is an external oracle only (never linked). +//! +//! Tolerances (EBU Tech 3341 allows ±0.1 LU for M/S/I and +0.2/−0.4 dB for true peak; Tech 3342 +//! allows ±1 LU for LRA): +//! - momentary (every 100 ms once 400 ms have been seen) and short-term (once 3 s have been seen): +//! ±0.1 LU wherever ffmpeg reads above −70 LUFS; +//! - integrated: ±0.1 LU; +//! - loudness range: ±0.5 LU; +//! - true peak: ±0.2 dB (both are 4× oversampling estimates with different interpolators). + +use filmcraft_audio_dsp::loudness::LoudnessMeter; +use std::f64::consts::PI; +use std::path::Path; +use std::process::Command; + +const TOL_MS: f64 = 0.1; +const TOL_I: f64 = 0.1; +const TOL_LRA: f64 = 0.5; +const TOL_TP_DB: f64 = 0.2; + +/// ffmpeg's per-100 ms frame values. +#[derive(Clone, Copy, Debug, Default)] +struct Frame { + m: f64, + s: f64, + i: f64, + lra: f64, + true_peak: f64, +} + +fn ffmpeg_ebur128(ff: &Path, wav: &Path) -> Vec { + let out = Command::new(ff) + .args(["-hide_banner", "-nostats", "-v", "error", "-i"]) + .arg(wav) + .args(["-af", "ebur128=peak=true:metadata=1,ametadata=mode=print:file=-", "-f", "null", "-"]) + .output() + .expect("run ffmpeg"); + assert!(out.status.success(), "ffmpeg ebur128 failed: {}", String::from_utf8_lossy(&out.stderr)); + let mut frames = Vec::new(); + let mut cur: Option = None; + for line in String::from_utf8_lossy(&out.stdout).lines() { + if line.starts_with("frame:") { + frames.extend(cur.take()); + cur = Some(Frame::default()); + continue; + } + let (Some(f), Some((k, v))) = (cur.as_mut(), line.strip_prefix("lavfi.r128.").and_then(|l| l.split_once('='))) else { continue }; + let v: f64 = v.trim().parse().unwrap_or(f64::NAN); + match k { + "M" => f.m = v, + "S" => f.s = v, + "I" => f.i = v, + "LRA" => f.lra = v, + "true_peak" => f.true_peak = v, + _ => {} + } + } + frames.extend(cur); + frames +} + +/// Our measurements after each 100 ms block: (M, S) per block, plus the final meter. +fn ours(chans: &[Vec], sr: u32) -> (Vec<(f64, f64)>, LoudnessMeter) { + let mut m = LoudnessMeter::new(sr as f64, chans.len()); + let block = sr as usize / 10; + let n = chans[0].len(); + let mut per = Vec::new(); + let mut pos = 0; + while pos < n { + let end = (pos + block).min(n); + let slices: Vec<&[f32]> = chans.iter().map(|c| &c[pos..end]).collect(); + m.process(&slices); + per.push((m.momentary(), m.short_term())); + pos = end; + } + (per, m) +} + +struct Report { + max_dm: f64, + max_ds: f64, + i: (f64, f64), + lra: (f64, f64), + tp: (f64, f64), + /// True-peak reference used (analytic value when known, else ffmpeg). + tp_ref: f64, +} + +/// Measure `chans` with both meters and check the tolerances. `tp_truth`: the analytic true peak +/// (dBTP) when known; it then replaces ffmpeg as the true-peak reference. +fn compare(ff: &Path, name: &'static str, chans: &[Vec], sr: u32, tp_truth: Option) -> Report { + let dir = filmcraft_testkit::fixtures_dir("audio-dsp/loudness"); + let wav = dir.join(format!("{name}_{sr}.wav")); + let tmp = filmcraft_testkit::temp_path(&wav); + let refs: Vec<&[f32]> = chans.iter().map(|c| c.as_slice()).collect(); + filmcraft_testkit::wav::write_f32(&tmp, &refs, sr).unwrap(); + let frames = ffmpeg_ebur128(ff, &tmp); + let _ = std::fs::remove_file(&tmp); + let (per, meter) = ours(chans, sr); + assert!(frames.len() + 1 >= per.len() && !frames.is_empty(), "{name}: ffmpeg gave {} frames, we have {} blocks", frames.len(), per.len()); + let (mut max_dm, mut max_ds, mut n_m, mut n_s) = (0f64, 0f64, 0, 0); + for (k, (f, (m, s))) in frames.iter().zip(&per).enumerate() { + let full_blocks = k + 1; + if full_blocks >= 4 && f.m > -70.0 { + let d = (f.m - m).abs(); + assert!(d <= TOL_MS, "{name}: momentary at {:.1} s: ffmpeg {:.3}, ours {m:.3}", full_blocks as f64 / 10.0, f.m); + max_dm = max_dm.max(d); + n_m += 1; + } + if full_blocks >= 30 && f.s > -70.0 { + let d = (f.s - s).abs(); + assert!(d <= TOL_MS, "{name}: short-term at {:.1} s: ffmpeg {:.3}, ours {s:.3}", full_blocks as f64 / 10.0, f.s); + max_ds = max_ds.max(d); + n_s += 1; + } + } + let last = frames.last().unwrap(); + let ff_tp_db = 20.0 * last.true_peak.log10(); + let r = Report { + tp_ref: tp_truth.unwrap_or(ff_tp_db), + max_dm, + max_ds, + i: (meter.integrated(), last.i), + lra: (meter.loudness_range(), last.lra), + tp: (meter.true_peak_dbtp(), ff_tp_db), + }; + eprintln!( + "{name} @ {sr} Hz: I ours {:.3} / ffmpeg {:.3} LUFS; LRA {:.2} / {:.2} LU; TP {:.3} / {:.3} dBTP; max |ΔM| {:.4} over {n_m}, max |ΔS| {:.4} over {n_s}", + r.i.0, r.i.1, r.lra.0, r.lra.1, r.tp.0, r.tp.1, r.max_dm, r.max_ds + ); + assert!(n_m > 0, "{name}: no momentary values compared"); + assert!((r.i.0 - r.i.1).abs() <= TOL_I, "{name}: integrated ours {:.3} vs ffmpeg {:.3}", r.i.0, r.i.1); + assert!((r.lra.0 - r.lra.1).abs() <= TOL_LRA, "{name}: LRA ours {:.3} vs ffmpeg {:.3}", r.lra.0, r.lra.1); + match tp_truth { + Some(t) => { + eprintln!("{name}: analytic true peak {t:.3} dBTP: ours off by {:+.3} dB, ffmpeg off by {:+.3} dB", r.tp.0 - t, r.tp.1 - t); + assert!((r.tp.0 - t).abs() <= TOL_TP_DB, "{name}: true peak ours {:.3} vs analytic {t:.3} dBTP", r.tp.0); + } + None => assert!((r.tp.0 - r.tp.1).abs() <= TOL_TP_DB, "{name}: true peak ours {:.3} vs ffmpeg {:.3} dBTP", r.tp.0, r.tp.1), + } + r +} + +// ---- signals ---- + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0 + } +} + +fn db(x: f64) -> f64 { + 10f64.powf(x / 20.0) +} + +fn sine(freq: f64, amp: f64, sr: u32, secs: f64, phase: f64) -> Vec { + (0..(secs * sr as f64) as usize).map(|i| (amp * (2.0 * PI * freq * i as f64 / sr as f64 + phase).sin()) as f32).collect() +} + +/// Pink noise (Paul Kellet's refined filter on white noise), scaled to `amp` RMS-ish. +fn pink(sr: u32, secs: f64, amp: f64, seed: u64) -> Vec { + let mut r = Rng(seed); + let (mut b0, mut b1, mut b2, mut b3, mut b4, mut b5, mut b6) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + (0..(secs * sr as f64) as usize) + .map(|_| { + let w = r.next(); + b0 = 0.99886 * b0 + w * 0.0555179; + b1 = 0.99332 * b1 + w * 0.0750759; + b2 = 0.96900 * b2 + w * 0.1538520; + b3 = 0.86650 * b3 + w * 0.3104856; + b4 = 0.55000 * b4 + w * 0.5329522; + b5 = -0.7616 * b5 - w * 0.0168980; + let y = b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362; + b6 = w * 0.115926; + (y * 0.11 * amp) as f32 + }) + .collect() +} + +/// Speech-like: syllable bursts (80–300 ms) of a pitched, formant-ish signal with pauses +/// (50–600 ms) and phrase-level level changes, so gating and the M/S windows are exercised. +fn speech(sr: u32, secs: f64, seed: u64) -> Vec { + let mut r = Rng(seed); + let n = (secs * sr as f64) as usize; + let mut out = vec![0f32; n]; + let mut pos = 0usize; + let mut phrase_gain = 1.0; + while pos < n { + if r.next() > 0.7 { + phrase_gain = db(-12.0 * (r.next() + 1.0) / 2.0); + } + let syl = ((0.08 + 0.22 * (r.next() + 1.0) / 2.0) * sr as f64) as usize; + let f0 = 110.0 + 60.0 * (r.next() + 1.0) / 2.0; + let (f1, f2) = (500.0 + 400.0 * r.next(), 1500.0 + 700.0 * r.next()); + for i in 0..syl.min(n - pos) { + let t = i as f64 / sr as f64; + let env = (PI * i as f64 / syl as f64).sin().powi(2); + let mut v = 0.0; + for h in 1..=20 { + let f = f0 * h as f64; + if f > sr as f64 / 2.2 { + break; + } + let formant = (-((f - f1) / 200.0).powi(2)).exp() + 0.5 * (-((f - f2) / 300.0).powi(2)).exp() + 0.05; + v += formant * (2.0 * PI * f * t).sin() / h as f64; + } + out[pos + i] = (0.3 * phrase_gain * env * v + 0.003 * r.next()) as f32; + } + pos += syl; + let gap = ((0.05 + 0.55 * (r.next() + 1.0) / 2.0) * sr as f64) as usize; + pos += gap; + } + out +} + +fn concat(parts: &[Vec]) -> Vec { + parts.concat() +} + +#[test] +fn loudness_matches_ffmpeg_ebur128() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let sr = 48_000; + let mut reports = Vec::new(); + + // 1. Steady 997 Hz sine, −20 dBFS, both channels (dual mono adds +3 dB). + let s = sine(997.0, db(-20.0), sr, 10.0, 0.0); + reports.push(compare(&ff, "sine_997", &[s.clone(), s], sr, Some(-20.0))); + + // 2. Independent pink noise per channel. + reports.push(compare(&ff, "pink_noise", &[pink(sr, 15.0, 0.5, 1), pink(sr, 15.0, 0.5, 2)], sr, None)); + + // 3. Speech-like bursts and pauses (exercises the relative gate and LRA). + let sp = speech(sr, 25.0, 7); + let sp2: Vec = sp.iter().map(|v| v * 0.7).collect(); + reports.push(compare(&ff, "speech_like", &[sp, sp2], sr, None)); + + // 4. Stereo with silence and quiet passages: loud tone, digital silence, a −30 dBFS tone on L + // only, a −50 dBFS passage (below the relative gate) and a −80 dBFS passage (below the + // absolute gate). + let l = concat(&[ + sine(440.0, db(-18.0), sr, 5.0, 0.0), + vec![0.0; 3 * sr as usize], + sine(1000.0, db(-30.0), sr, 5.0, 0.0), + pink(sr, 4.0, db(-50.0), 3), + pink(sr, 4.0, db(-80.0), 4), + ]); + let r = concat(&[ + sine(440.0, db(-18.0), sr, 5.0, 0.3), + vec![0.0; 3 * sr as usize], + vec![0.0; 5 * sr as usize], + pink(sr, 4.0, db(-50.0), 5), + pink(sr, 4.0, db(-80.0), 6), + ]); + reports.push(compare(&ff, "gated_stereo", &[l, r], sr, None)); + + // 5. Inter-sample peaks: fs/4 at 45° phase, 0 dBFS true peak but −3 dBFS sample peak. + let x = sine(sr as f64 / 4.0, 0.9, sr, 5.0, PI / 4.0); + reports.push(compare(&ff, "intersample_peak", &[x.clone(), x], sr, Some(20.0 * 0.9f64.log10()))); + + // 6. Mono speech-like at 44.1 kHz (K-weighting derived for another rate; true peak at 4×). + let sr2 = 44_100; + reports.push(compare(&ff, "speech_mono_44k", &[speech(sr2, 20.0, 11)], sr2, None)); + + // 7. Pink noise at 96 kHz (2× true-peak oversampling). + reports.push(compare(&ff, "pink_noise_96k", &[pink(96_000, 8.0, 0.4, 21), pink(96_000, 8.0, 0.4, 22)], 96_000, None)); + + let worst = |f: fn(&Report) -> f64| reports.iter().map(f).fold(0f64, f64::max); + eprintln!( + "worst: |ΔM| {:.3}, |ΔS| {:.3}, |ΔI| {:.3}, |ΔLRA| {:.3}, |ΔTP| {:.3} dB", + worst(|r| r.max_dm), + worst(|r| r.max_ds), + worst(|r| (r.i.0 - r.i.1).abs()), + worst(|r| (r.lra.0 - r.lra.1).abs()), + worst(|r| (r.tp.0 - r.tp_ref).abs()), + ); +} diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 4abcd3a36..867df7b3b 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -13,6 +13,7 @@ pub mod fixtures; pub mod golden; pub mod oracle; +pub mod wav; pub use fixtures::{fixtures_dir, temp_path, workspace_root}; pub use oracle::{ffmpeg, ffmpeg_or_skip, ffprobe, ffprobe_or_skip, oracles_required, skip}; diff --git a/crates/testkit/src/wav.rs b/crates/testkit/src/wav.rs new file mode 100644 index 000000000..72d3d29eb --- /dev/null +++ b/crates/testkit/src/wav.rs @@ -0,0 +1,47 @@ +//! Minimal WAV writer (IEEE float, interleaved) for handing generated signals to ffmpeg. + +use std::path::Path; + +/// Encode planar `f32` channels (equal lengths) as a 32-bit float WAV file. +pub fn encode_f32(channels: &[&[f32]], sample_rate: u32) -> Vec { + let nch = channels.len().max(1) as u16; + let frames = channels.first().map_or(0, |c| c.len()); + assert!(channels.iter().all(|c| c.len() == frames), "channels must have equal lengths"); + let data_len = (frames * nch as usize * 4) as u32; + let mut v = Vec::with_capacity(44 + data_len as usize); + v.extend_from_slice(b"RIFF"); + v.extend_from_slice(&(36 + data_len).to_le_bytes()); + v.extend_from_slice(b"WAVEfmt "); + v.extend_from_slice(&16u32.to_le_bytes()); + v.extend_from_slice(&3u16.to_le_bytes()); // WAVE_FORMAT_IEEE_FLOAT + v.extend_from_slice(&nch.to_le_bytes()); + v.extend_from_slice(&sample_rate.to_le_bytes()); + v.extend_from_slice(&(sample_rate * nch as u32 * 4).to_le_bytes()); + v.extend_from_slice(&(nch * 4).to_le_bytes()); + v.extend_from_slice(&32u16.to_le_bytes()); + v.extend_from_slice(b"data"); + v.extend_from_slice(&data_len.to_le_bytes()); + for i in 0..frames { + for c in channels { + v.extend_from_slice(&c[i].to_le_bytes()); + } + } + v +} + +/// Write planar `f32` channels to `path` as a float WAV. +pub fn write_f32(path: &Path, channels: &[&[f32]], sample_rate: u32) -> std::io::Result<()> { + std::fs::write(path, encode_f32(channels, sample_rate)) +} + +#[cfg(test)] +mod tests { + #[test] + fn header_sizes() { + let a = [0.5f32, -0.5]; + let w = super::encode_f32(&[&a, &a], 48000); + assert_eq!(w.len(), 44 + 16); + assert_eq!(&w[0..4], b"RIFF"); + assert_eq!(u32::from_le_bytes(w[40..44].try_into().unwrap()), 16); + } +} diff --git a/docs/testing.md b/docs/testing.md index 56fca2664..db1180214 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -68,6 +68,7 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). | `aac` encode | ffmpeg decodes our stream | no errors, no clipping, CBR within ±5% of target; SNR reported | | `isobmff` | `ffprobe -show_packets` on ffmpeg-made MP4/MOV | packet offsets, sizes, pts/dts, durations, key flags and stream parameters equal; remuxed files decode with `ffmpeg -v error` silent | | `matroska` | `ffprobe -show_packets` | every packet (stream, size, key flag, pts, duration) equal; seeks land on the latest keyframe ≤ target | +| `audio-dsp` loudness | `tests/loudness_oracle.rs`: signals generated in Rust (997 Hz sine, pink noise at 48/96 kHz, speech-like bursts at 48/44.1 kHz, stereo with silence and sub-gate passages, an fs/4 inter-sample-peak tone) written as float WAV and measured with `ffmpeg -af ebur128=peak=true:metadata=1` | momentary and short-term every 100 ms **±0.1 LU**, integrated **±0.1 LU**, LRA **±0.5 LU**, true peak **±0.2 dB** (against the analytic value when one exists). Measured: ΔM/ΔS ≤ 0.0005 LU, ΔI ≤ 0.007 LU, ΔLRA ≤ 0.04 LU, ΔTP ≤ 0.045 dB against ffmpeg. On the fs/4 tone ffmpeg's own true peak is +0.6 dB high (−0.32 vs the analytic −0.92 dBTP); ours is −0.05 dB | | `export` | our own demuxer/decoder reads the file back; ffprobe counts frames when present | expected size, duration, colour, audio level; exact frame count | Each codec README has the full fixture matrix and the measured results. From 7dcdef697498b05f322512e7d47adc24c3f14713 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 00:39:14 -0400 Subject: [PATCH 09/24] T5: headless scripted UI tests (egui_kittest + control channel) crates/ui-egui/tests/scripted.rs runs the real FilmcraftApp under egui_kittest's eframe harness (no window/GPU) and drives it through ControlRequests, stepping frames until each reply arrives; synthetic ui.click input is fed through the app's raw_input_hook. Tests: demo project + widget registry, razor/undo/redo with timeline locate, source insert, apply Gaussian Blur and see it in Effect Controls, playback toggle/stop, click a tool button by automation id, error replies. docs/testing.md documents coverage and limits. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 53 +++++++- crates/ui-egui/Cargo.toml | 3 + crates/ui-egui/tests/scripted.rs | 212 +++++++++++++++++++++++++++++++ docs/testing.md | 30 ++++- 4 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/tests/scripted.rs diff --git a/Cargo.lock b/Cargo.lock index a2d7c1539..bd2aecbe8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1103,6 +1103,20 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "eframe", + "egui", + "kittest", + "log", + "serde", + "toml 1.1.6+spec-1.1.0", +] + [[package]] name = "either" version = "1.18.0" @@ -1629,6 +1643,7 @@ version = "0.1.0" dependencies = [ "eframe", "egui", + "egui_kittest", "filmcraft-audio-dsp", "filmcraft-color", "filmcraft-edit", @@ -2459,6 +2474,16 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -3956,6 +3981,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "2.0.1" @@ -4333,7 +4367,7 @@ dependencies = [ "cfg-expr", "heck 0.5.0", "pkg-config", - "toml", + "toml 0.8.2", "version-compare", ] @@ -4466,11 +4500,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.3", "toml_edit 0.20.2", ] +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.6.3" @@ -4508,7 +4555,7 @@ checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ "indexmap", "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.3", "winnow 0.5.40", ] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 540fc7bbb..a42a66f5f 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -27,3 +27,6 @@ serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } rayon = { workspace = true } + +[dev-dependencies] +egui_kittest = { workspace = true, features = ["eframe"] } diff --git a/crates/ui-egui/tests/scripted.rs b/crates/ui-egui/tests/scripted.rs new file mode 100644 index 000000000..2053f2363 --- /dev/null +++ b/crates/ui-egui/tests/scripted.rs @@ -0,0 +1,212 @@ +//! Scripted UI tests: the real `FilmcraftApp` runs headless under `egui_kittest` (no window, no +//! GPU) and is driven through the same JSON control channel that agents use over TCP/MCP +//! (`docs/control-protocol.md`). Each test opens the demo project, sends control requests +//! (`engine.execute`, `ui.menu.invoke`, `ui.click`, `ui.inspect`, …) between frames and asserts on +//! the replies and on `sequence.inspect` JSON. +//! +//! The harness steps egui frames itself: synthetic input queued by `ui.click`/`ui.key` is moved +//! into the next frame's `RawInput` through the app's own `raw_input_hook`, exactly as eframe does. +//! Screenshots (`ui.screenshot`) need a real viewport and are not covered here. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + /// The app with the demo project open, after its first frames (theme and fonts installed). + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d + } + + /// Run `n` frames, feeding queued synthetic input like eframe's `raw_input_hook`. + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + /// Send a control request and run frames until it is answered. + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + + /// `call` that must succeed; returns `result`. + fn ok(&mut self, method: &str, params: Value) -> Value { + let v = self.call(method, params.clone()); + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + v["result"].clone() + } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + fn sequence(&mut self) -> Value { + self.exec("sequence.inspect", json!({})) + } + + fn inspect(&mut self) -> Value { + self.ok("ui.inspect", json!({})) + } + + fn element_ids(&mut self, prefix: &str) -> Vec { + let v = self.ok("ui.elements", json!({"prefix": prefix})); + v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect() + } +} + +/// Clips on a video track in `sequence.inspect` JSON. +fn track_clips(seq: &Value, track: usize) -> Vec { + seq["video"][track]["items"].as_array().cloned().unwrap_or_default() +} + +#[test] +fn demo_project_opens_headless_and_registers_widgets() { + let mut d = Driver::demo(); + let ui = d.inspect(); + assert!(ui["activeSequence"].is_u64(), "demo sequence is active: {ui}"); + assert!(ui["elements"].as_u64().unwrap() > 50, "widgets registered: {}", ui["elements"]); + let panels = d.element_ids("panel."); + for p in ["panel.Timeline", "panel.Program", "panel.Project", "panel.tab.Timeline"] { + assert!(panels.iter().any(|x| x == p), "{p} missing from {panels:?}"); + } + let tools = d.element_ids("tools."); + assert!(tools.len() >= 5, "tool buttons: {tools:?}"); + let seq = d.sequence(); + assert_eq!(track_clips(&seq, 0).len(), 6, "demo V1 clips: {seq}"); +} + +#[test] +fn razor_then_undo_through_the_control_channel() { + let mut d = Driver::demo(); + let before = track_clips(&d.sequence(), 0).len(); + d.exec("playhead.set", json!({"seconds": 2.0})); + let r = d.exec("timeline.razor", json!({"track": "V1"})); + assert_eq!(r["cuts"], json!(1), "{r}"); + let after = track_clips(&d.sequence(), 0); + assert_eq!(after.len(), before + 1); + // the timeline shows the new clip on the next frame + d.frames(2); + let new_clip = after[1]["clip"].as_u64().unwrap(); + let at = d.ok("ui.timeline.locate", json!({"clip": new_clip})); + assert!(at["x"].as_f64().is_some(), "{at}"); + // Undo from the menu path (UI command dispatch), then Redo by shortcut-free command id. + d.ok("ui.menu.invoke", json!({"id": "edit.undo"})); + assert_eq!(track_clips(&d.sequence(), 0).len(), before); + d.exec("edit.redo", json!({})); + assert_eq!(track_clips(&d.sequence(), 0).len(), before + 1); +} + +#[test] +fn insert_from_source_ripples_the_sequence() { + let mut d = Driver::demo(); + let project = d.exec("project.inspect", json!({})); + let item = first_media_item(&project).unwrap_or_else(|| panic!("no media item in {project}")); + let dur0 = d.sequence()["duration"].as_i64().unwrap(); + d.exec("source.open", json!({"item": item})); + let rate = &d.sequence()["settings"]["frame_rate"]; + let (num, den) = (rate["num"].as_i64().unwrap(), rate["den"].as_i64().unwrap()); + let tick = |f: i64| f * 254_016_000_000 * den / num; + d.exec("project.setMarks", json!({"item": item, "in": tick(24), "out": tick(47)})); + d.exec("playhead.set", json!({"frame": 0})); + d.exec("source.insert", json!({})); + let dur1 = d.sequence()["duration"].as_i64().unwrap(); + assert!(dur1 > dur0, "insert lengthens the sequence: {dur0} → {dur1}"); + d.frames(2); +} + +/// First movie item id anywhere in `project.inspect` JSON. +fn first_media_item(v: &Value) -> Option { + match v { + Value::Object(m) => { + if let Some(id) = m.get("item").and_then(Value::as_u64) + && m.get("type").and_then(Value::as_str).is_some_and(|t| t == "Movie") + { + return Some(id); + } + m.values().find_map(first_media_item) + } + Value::Array(a) => a.iter().find_map(first_media_item), + _ => None, + } +} + +#[test] +fn apply_effect_appears_in_effect_controls() { + let mut d = Driver::demo(); + let clip = track_clips(&d.sequence(), 0)[0]["clip"].as_u64().unwrap(); + d.exec("timeline.select", json!({"clips": [clip]})); + d.exec("effects.apply", json!({"effect": "Gaussian Blur"})); + let seq = d.sequence(); + let fx: Vec = track_clips(&seq, 0)[0]["effects"].as_array().unwrap().iter().filter_map(|e| e["effect"].as_str().map(str::to_string)).collect(); + assert!(fx.iter().any(|e| e == "gaussian_blur"), "{fx:?}"); + d.ok("ui.panel.show", json!({"panel": "Effect Controls"})); + d.frames(3); + let ids = d.element_ids("effectControls.effect."); + assert!(ids.iter().any(|i| i == "effectControls.effect.gaussian_blur"), "{ids:?}"); + d.ok("ui.menu.invoke", json!({"id": "edit.undo"})); + d.frames(2); + assert!(!d.element_ids("effectControls.effect.").iter().any(|i| i == "effectControls.effect.gaussian_blur")); +} + +#[test] +fn playback_toggle_and_stop() { + let mut d = Driver::demo(); + assert_eq!(d.inspect()["playback"]["playing"], json!(false)); + let r = d.ok("ui.menu.invoke", json!({"id": "playback.toggle"})); + assert_eq!(r["playing"], json!(true), "{r}"); + d.frames(5); + assert_eq!(d.inspect()["playback"]["playing"], json!(true)); + let r = d.ok("ui.playback", json!({"action": "stop"})); + assert_eq!(r["playing"], json!(false), "{r}"); + assert_eq!(d.inspect()["playback"]["playing"], json!(false)); +} + +#[test] +fn clicking_a_tool_button_by_automation_id() { + let mut d = Driver::demo(); + assert_ne!(d.inspect()["ui"]["tool"], json!("Razor")); + d.ok("ui.click", json!({"id": "tools.Razor"})); + d.frames(2); + let ui = d.inspect(); + assert_eq!(ui["ui"]["tool"], json!("Razor"), "{}", ui["ui"]["tool"]); + d.ok("ui.set", json!({"tool": "selection"})); + assert_eq!(d.inspect()["ui"]["tool"], json!("Selection")); +} + +#[test] +fn unknown_methods_and_commands_fail_cleanly() { + let mut d = Driver::demo(); + let v = d.call("ui.nope", json!({})); + assert_eq!(v["ok"], json!(false)); + let v = d.call("engine.execute", json!({"command": "no.such.command"})); + assert_eq!(v["ok"], json!(false)); +} diff --git a/docs/testing.md b/docs/testing.md index db1180214..8d4304535 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -16,6 +16,8 @@ FILMCRAFT_REQUIRE_ORACLES=1 cargo test --workspace # CI: missing ffmpeg fails | Unit | `src/` `#[cfg(test)]` modules, `src/tests.rs` | CABAC engines, VLC tables (prefix-freeness, Kraft sums), colour matrices, frame cache | | Property (`proptest`) | `time`, `bitstream`, `edit`, `isobmff/tests/roundtrip.rs`, `interchange/tests/*` | tick↔frame round trips for every rate, DF timecode, edit invariants (no overlaps, durations conserved), mux→demux round trips, interchange export→import | | Synthetic streams | `h264/src/synth_tests.rs`, `hevc/src/synth_tests.rs` | hand-built bitstreams for features the reference encoders don't emit; expected output known exactly | +| Scripted UI | `crates/ui-egui/tests/scripted.rs` | headless app (egui_kittest) driven over the control channel: razor/undo, insert, apply effect, playback, click by automation id (§4) | +| Golden images | `crates/golden/tests/golden.rs` | CPU renders vs committed PNGs; GPU vs CPU on the same scenes (§3) | | Engine / command | `crates/engine/src/tests.rs` | run commands on the demo project, assert the sequence, undo/redo, disabled cases | | Render | `crates/render/src/tests.rs` | compositing, opacity, Motion, cross dissolve midpoint, ½-res vs full, GPU plan vs reference, audio mix, audio-effect continuity | | Oracle | `crates/*/tests/*oracle*.rs`, `conformance.rs` | compare with ffmpeg/ffprobe (§2) | @@ -106,7 +108,33 @@ Each codec README has the full fixture matrix and the measured results. ## 4. UI: control channel and screenshots -There is no automated UI test suite yet. UI work is checked by driving the real app: +### Scripted UI tests (headless) + +`crates/ui-egui/tests/scripted.rs` runs the real `FilmcraftApp` under +[`egui_kittest`](https://docs.rs/egui_kittest) (`build_eframe`): no window, no GPU, no OS event loop. +A small `Driver` opens the demo project, sends requests through the same control channel agents use +(`ControlRequest` → `control::handle`, exactly as the TCP server does) and steps egui frames until +each reply arrives. Synthetic input queued by `ui.click` / `ui.drag` / `ui.key` is moved into the +next frame by the app's own `raw_input_hook`, so clicks by automation id work headless. Covered: + +| Test | Asserts | +|---|---| +| `demo_project_opens_headless_and_registers_widgets` | active sequence, > 50 registered widgets, panel and tool ids, 6 clips on V1 (`sequence.inspect`) | +| `razor_then_undo_through_the_control_channel` | `timeline.razor` adds a clip, the timeline can locate it on screen, `edit.undo` via `ui.menu.invoke`, redo | +| `insert_from_source_ripples_the_sequence` | `source.open` + marks + `source.insert` lengthens the sequence | +| `apply_effect_appears_in_effect_controls` | `effects.apply` adds Gaussian Blur in the model and `effectControls.effect.gaussian_blur` appears in the panel; undo removes it | +| `playback_toggle_and_stop` | `playback.toggle` / `ui.playback stop` and `ui.inspect` playback state | +| `clicking_a_tool_button_by_automation_id` | `ui.click {id: "tools.Razor"}` changes the tool (real egui input path) | +| `unknown_methods_and_commands_fail_cleanly` | errors come back as `{"ok": false}` | + +Not covered headless: `ui.screenshot` (needs a real viewport), the wgpu monitor path (the harness +runs the CPU texture path), audio output, and wall-clock playback advance (kittest frames do not +advance real time). Use `Driver` for new UI regressions: `d.exec(command, params)`, +`d.ok(method, params)`, `d.frames(n)`. + +### Interactive checks + +For visual work, drive the real app: ```sh cargo run --release -p filmcraft -- --control 9876 From 9db963e0e677de0c6817985dc49fb4d3929ef202 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 00:39:44 -0400 Subject: [PATCH 10/24] =?UTF-8?q?M9.8:=20VP9=20wired=20into=20codecs=20?= =?UTF-8?q?=E2=80=94=20WebM/MKV=20V=5FVP9=20and=20MP4=20vp09=20(+vpcC)=20i?= =?UTF-8?q?mport,=208/10/12-bit=204:2:0/4:2:2/4:4:4=20(4:4:0=20expanded,?= =?UTF-8?q?=20RGB=20to=20RGBA)=20output,=20key-frame-checked=20GOP=20rando?= =?UTF-8?q?m=20access,=20Decoder::reset=20/=20is=5Fkeyframe,=20oracle=20se?= =?UTF-8?q?ek=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + ROADMAP.md | 3 +- crates/codecs/Cargo.toml | 1 + crates/codecs/src/gop.rs | 11 ++- crates/codecs/src/lib.rs | 4 +- crates/codecs/src/mkv.rs | 42 +++++++- crates/codecs/src/mp4.rs | 23 ++++- crates/codecs/src/tests.rs | 163 ++++++++++++++++++++++++++++++++ crates/codecs/src/video.rs | 141 +++++++++++++++++++++++++++ crates/vp9/README.md | 10 ++ crates/vp9/src/decoder.rs | 14 ++- crates/vp9/src/lib.rs | 42 ++++++++ crates/vp9/tests/conformance.rs | 31 ++++++ 13 files changed, 472 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96706d541..33417898d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1363,6 +1363,7 @@ dependencies = [ "filmcraft-media", "filmcraft-prores", "filmcraft-time", + "filmcraft-vp9", "image", "log", "symphonia", diff --git a/ROADMAP.md b/ROADMAP.md index 245a1e2d3..a2a78bd53 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,14 +37,13 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining | M6 | Export | ✅ | Own H.264 encoder (High/Main/Baseline, B-frames, VBR/CBR/2-pass) → MP4 + own AAC; ProRes, MJPEG, PNG, GIF, WAV; background jobs | Preset library, queue UI, smart render | 6–8 | | M7 | Audio | 🟡 | Mixer basics, peak meters, BS.1770 loudness meters (M/S/I LUFS, true peak; matches ffmpeg), DSP crate, 12 clip audio effects running on it (EQ, filters, dynamics, limiter, delay, reverb, DeNoise, DeHum, pitch) with keyframes | Effects UI polish (EQ curve), remaining effects (multiband, convolution reverb), track effects/mixers with automation, Essential Sound, audio transitions, 5.1 | 6–10 | | M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette; basic scopes | LUT import UI, colour match, colour management + HDR (PQ/HLG, log, tone mapping) | 8–12 | -| M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), Matroska/WebM import (H.264/HEVC/ProRes/MJPEG + AAC/FLAC/MP3/Vorbis/PCM) | VP9 and Opus (in progress), AV1 (rav1d), DNxHR, MXF, hardware decode | 12–20 | +| M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), VP9 decoder (profiles 0–3, 8/10/12-bit, bit-exact on 50+ fixtures; WebM/MKV `V_VP9` and MP4 `vp09` import with key-frame-checked seeking), Matroska/WebM import (H.264/HEVC/VP9/ProRes/MJPEG + AAC/FLAC/MP3/Vorbis/PCM) | VP9 frame threading, Opus (in progress), AV1 (rav1d), DNxHR, MXF, hardware decode | 12–20 | | M10 | Graphics & captions | ⬜ | — | Text engine, Type tool, Essential Graphics, captions (SRT/VTT/SCC), speech-to-text | 20–30 | | M11 | Interchange & project management | 🟡 | `.fcproj` save/open | Autosave/recovery, FCP7 XML, FCPXML, EDL, OTIO, relink, project manager, proxies | 15 | | M12–M16 | Web (WASM), platform, long tail | 🟡 | L0–L4 crates compile to wasm32 | Web app shell (file access, WebCodecs, audio), ~850 remaining commands and dialogs, performance hardening | 40–65 | ## Running now -- VP9 decoder (`crates/vp9`), from the public VP9 bitstream spec - Opus decoder (`crates/opus`), from RFC 6716/8251 - Interchange (`crates/interchange`): CMX 3600 EDL, FCP7 XML, FCPXML, OTIO import and export diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 9b98df6a1..c9189eb67 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -19,6 +19,7 @@ filmcraft-matroska = { workspace = true } filmcraft-bitstream = { workspace = true } filmcraft-h264 = { workspace = true } filmcraft-hevc = { workspace = true } +filmcraft-vp9 = { workspace = true } filmcraft-prores = { workspace = true } filmcraft-aac = { workspace = true } thiserror = { workspace = true } diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index 13e2add2f..deafcf257 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -80,10 +80,19 @@ impl GopCache { st.decoder = Some(s.make_decoder()?); st.next = usize::MAX; } - let key = s.sync_before(i); + let mut key = s.sync_before(i); // Continue the running decoder when the wanted sample is ahead within this GOP run. let continuing = st.next != usize::MAX && st.next > key && st.next <= i + 16 && st.next <= n; if !continuing { + // The container's sync flags may be wrong for the codec (an MP4 without `stss` marks + // every sample): step back to a sample the decoder can start from. + while key > 0 { + let data = s.read(key)?; + if st.decoder.as_ref().expect("decoder").is_random_access(&data) != Some(false) { + break; + } + key = s.sync_before(key - 1); + } if let Some(d) = st.decoder.as_mut() { d.reset(); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 5881cf179..e5f8c48c1 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -1,6 +1,6 @@ //! The container + codec hub. //! -//! - [`VideoDecoder`]: the trait every video codec implements (our own H.264/ProRes/…, MJPEG, and +//! - [`VideoDecoder`]: the trait every video codec implements (our own H.264/HEVC/VP9/ProRes/…, MJPEG, and //! OS hardware decoders registered by the platform layer). Factories are tried in registration //! order, so a hardware decoder can take precedence over the pure-Rust one. //! - [`Mp4Source`]: a [`MediaSource`](filmcraft_media::MediaSource) over MP4/MOV using @@ -46,7 +46,7 @@ impl From for filmcraft_media::MediaError { fn factories() -> &'static RwLock> { static F: std::sync::OnceLock>> = std::sync::OnceLock::new(); - F.get_or_init(|| RwLock::new(vec![video::h264_factory, video::hevc_factory, video::prores_factory, video::mjpeg_factory])) + F.get_or_init(|| RwLock::new(vec![video::h264_factory, video::hevc_factory, video::vp9_factory, video::prores_factory, video::mjpeg_factory])) } /// Register a video decoder factory (tried before previously registered ones). diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index 90abae2b8..a5aacdafa 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -1,7 +1,7 @@ //! Matroska/WebM media source (`filmcraft-matroska` demux, GOP-aware video via [`crate::gop`]). //! //! Video codecs are mapped onto ISO-BMFF sample entries so the same decoder factories serve both -//! containers (H.264, HEVC, ProRes, MJPEG; VP9/AV1 once their decoders land). Audio: AAC via our +//! containers (H.264, HEVC, VP9, ProRes, MJPEG; AV1 once its decoder lands). Audio: AAC via our //! decoder, PCM directly, MP3/FLAC/Vorbis via the bootstrap decoders. use std::collections::HashMap; @@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex}; use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer}; use filmcraft_frame::{AudioBuffer, VideoFrame}; -use filmcraft_isobmff::{AvcConfig, FourCc, HevcConfig, PcmConfig, SampleEntry}; +use filmcraft_isobmff::{AvcConfig, CodecConfig, FourCc, HevcConfig, PcmConfig, SampleEntry, VpcConfig}; use filmcraft_matroska::{Codec, MkvFile, TrackKind}; use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo}; use filmcraft_time::{FrameRate, Tick}; @@ -59,8 +59,42 @@ fn from_tick(t: &filmcraft_matroska::Track, time: Tick) -> i64 { time.to_rational_floor(n, d) } -fn sample_entry(c: &Codec, w: u16, h: u16) -> Option { +/// VP9 configuration from the Matroska `CodecPrivate` feature list (ID / length / value triples: +/// 1 profile, 2 level, 3 bit depth, 4 chroma subsampling) and the track's `Colour`. +fn vp9_config(private: &[u8], v: Option<&filmcraft_matroska::VideoInfo>) -> VpcConfig { + let mut c = VpcConfig { bit_depth: 8, colour_primaries: 2, transfer_characteristics: 2, matrix_coefficients: 2, ..Default::default() }; + let mut p = 0; + while p + 2 <= private.len() { + let (id, len) = (private[p], private[p + 1] as usize); + let Some(val) = private.get(p + 2..p + 2 + len) else { break }; + let x = val.first().copied().unwrap_or(0); + match id { + 1 => c.profile = x, + 2 => c.level = x, + 3 => c.bit_depth = x, + 4 => c.chroma_subsampling = x, + _ => {} + } + p += 2 + len; + } + if let Some(col) = v.and_then(|v| v.colour.as_ref()) { + if let Some(t) = col.transfer_characteristics { + c.transfer_characteristics = t as u8; + } + if let Some(pr) = col.primaries { + c.colour_primaries = pr as u8; + } + if let Some(m) = col.matrix_coefficients { + c.matrix_coefficients = m as u8; + } + c.full_range = col.full_range(); + } + c +} + +fn sample_entry(c: &Codec, v: Option<&filmcraft_matroska::VideoInfo>, w: u16, h: u16) -> Option { Some(match c { + Codec::Vp9 { private } => SampleEntry::video(FourCc(*b"vp09"), CodecConfig::Vp9(vp9_config(private, v)), w, h), Codec::Avc { avcc } => SampleEntry::avc(AvcConfig::parse(avcc).ok()?, w, h), Codec::Hevc { hvcc } => SampleEntry::hevc(HevcConfig::parse(hvcc).ok()?, w, h), Codec::ProRes { fourcc } => SampleEntry::prores(FourCc(fourcc.unwrap_or(*b"apcn")), w, h), @@ -144,7 +178,7 @@ impl MkvSource { if explicit { explicit_color = Some(color); } - ventry = sample_entry(&t.codec, w as u16, h as u16); + ventry = sample_entry(&t.codec, t.video.as_ref(), w as u16, h as u16); let secs = file.duration_ns().unwrap_or(0) as f64 / 1e9; let bitrate = (secs > 0.0).then(|| (t.samples.iter().map(|s| s.size as u64).sum::() as f64 * 8.0 / secs) as u64); VideoStreamInfo { diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index 4497bc2f2..5f37b6c33 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -40,9 +40,17 @@ pub fn sniff(b: &[u8]) -> bool { fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorInfo { let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited }; - if let Some(v) = &entry.video - && let Some(col) = &v.color - { + // VP9 carries its colour description in vpcC (used when there is no colr box). + let vpc = match &entry.codec { + CodecConfig::Vp9(v) => Some(filmcraft_isobmff::ColorInfo::Nclx { + primaries: v.colour_primaries as u16, + transfer: v.transfer_characteristics as u16, + matrix: v.matrix_coefficients as u16, + full_range: v.full_range, + }), + _ => None, + }; + if let Some(col) = entry.video.as_ref().and_then(|v| v.color.as_ref()).or(vpc.as_ref()) { let (p, t, m, full) = match col { filmcraft_isobmff::ColorInfo::Nclx { primaries, transfer, matrix, full_range } => (*primaries, *transfer, *matrix, *full_range), filmcraft_isobmff::ColorInfo::Nclc { primaries, transfer, matrix } => (*primaries, *transfer, *matrix, false), @@ -339,6 +347,7 @@ fn codec_label(c: &CodecConfig) -> String { } ), CodecConfig::Hevc(_) => "HEVC".into(), + CodecConfig::Vp9(c) => format!("VP9 (Profile {})", c.profile), CodecConfig::ProRes { fourcc } => match &fourcc.0 { b"apco" => "Apple ProRes 422 Proxy".into(), b"apcs" => "Apple ProRes 422 LT".into(), @@ -368,6 +377,14 @@ fn pixfmt_label(c: &CodecConfig) -> String { 244 => "YUV 4:4:4".into(), _ => "YUV 4:2:0 8-bit".into(), }, + CodecConfig::Vp9(c) => { + let sub = match c.chroma_subsampling { + 2 => "4:2:2", + 3 => "4:4:4", + _ => "4:2:0", + }; + format!("YUV {sub} {}-bit", c.bit_depth.max(8)) + } CodecConfig::ProRes { fourcc } if fourcc.0[2] == b'4' => "YUVA 4:4:4 12-bit".into(), CodecConfig::ProRes { .. } => "YUV 4:2:2 10-bit".into(), CodecConfig::Jpeg { .. } => "YUV 4:2:x 8-bit".into(), diff --git a/crates/codecs/src/tests.rs b/crates/codecs/src/tests.rs index 1adfdb3c9..ff3fa9ded 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -178,3 +178,166 @@ fn matroska_hevc_flac() { fn matroska_prores_pcm() { check_mkv("blue_prores_pcm.mkv", &["-c:v", "prores_ks", "-profile:v", "2"], &["-c:a", "pcm_s16le"]); } + +/// Path of a fixture under `target/fixtures/codecs/`, generated with ffmpeg when missing +/// (`None` when ffmpeg or the encoder is unavailable). +fn fixture_path(name: &str, args: &[&str]) -> Option { + fixture(name, args)?; + Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/codecs").join(name)) +} + +/// ffmpeg's decode of `src` as raw planar video (the oracle). +fn reference_yuv(src: &std::path::Path, pix_fmt: &str) -> Option> { + let name = format!("{}.{pix_fmt}.yuv", src.file_name()?.to_str()?); + let path = src.to_str()?; + let b = fixture(&name, &["-i", path, "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt])?; + Some(b.to_vec()) +} + +/// Planes of a decoded YUV frame as little-endian bytes (ffmpeg rawvideo layout). +fn yuv_bytes(f: &filmcraft_frame::VideoFrame) -> Vec { + match &f.data { + filmcraft_frame::PixelData::Yuv8 { planes, .. } => planes.iter().flat_map(|p| p.iter().copied()).collect(), + filmcraft_frame::PixelData::Yuv16 { planes, .. } => planes.iter().flat_map(|p| p.iter().flat_map(|s| s.to_le_bytes())).collect(), + _ => panic!("not a YUV frame: {}", f.format_label()), + } +} + +/// Random access into a VP9 file: frames requested out of order (seeks back and forth across key +/// frames, then sequential playback) are sample-exact against ffmpeg's decode. +fn check_vp9_seeks(name: &str, container: &str, pix_fmt: &str, extra: &[&str], frames: usize) { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/codecs"); + let log = dir.join(format!("{name}.passlog")).to_string_lossy().into_owned(); + let mut args = vec!["-f", "lavfi", "-i", "testsrc2=s=352x288:r=25,noise=alls=8:allf=t", "-frames:v"]; + let n = frames.to_string(); + args.push(&n); + args.extend_from_slice(&["-c:v", "libvpx-vp9", "-pix_fmt", pix_fmt, "-g", "25", "-b:v", "600k"]); + args.extend_from_slice(extra); + if extra.contains(&"-auto-alt-ref") { + // libvpx only uses alternate reference frames in two-pass mode: run the first pass. + let Some(ff) = ffmpeg() else { return }; + if !dir.join(name).exists() { + let _ = std::fs::create_dir_all(&dir); + let ok = Command::new(ff).args(["-y", "-v", "error"]).args(&args).args(["-pass", "1", "-passlogfile"]).arg(&log).args(["-f", "null", "-"]).status(); + if !ok.is_ok_and(|s| s.success()) { + eprintln!("first pass for {name} failed; skipping"); + return; + } + } + args.extend_from_slice(&["-pass", "2", "-passlogfile"]); + args.push(&log); + } + let Some(path) = fixture_path(name, &args) else { return }; + let Some(reference) = reference_yuv(&path, pix_fmt) else { return }; + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); + let src = crate::open_bytes(name, bytes).unwrap(); + let info = src.info().clone(); + assert_eq!(info.container, container); + let v = info.video.as_ref().unwrap(); + assert!(v.codec.contains("VP9"), "{}", v.codec); + assert_eq!((v.width, v.height), (352, 288)); + let frame_len = reference.len() / frames; + let rate = info.frame_rate(); + let order: Vec = [frames - 3, 3, 40, 26, 24, frames - 1, 0, 12].into_iter().chain(30..45).filter(|&k| k < frames).collect(); + for k in order { + let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap(); + assert_eq!((f.width, f.height), (352, 288)); + let got = yuv_bytes(&f); + assert!(got == reference[k * frame_len..(k + 1) * frame_len], "{name}: frame {k} differs from ffmpeg"); + } +} + +#[test] +fn webm_vp9_seeks_bit_exact() { + check_vp9_seeks("noise_vp9.webm", "WebM", "yuv420p", &["-deadline", "realtime", "-speed", "8"], 60); +} + +#[test] +fn webm_vp9_altref_superframes_seek_bit_exact() { + // Good-quality encode with alternate reference frames: hidden frames packed into superframes. + check_vp9_seeks("noise_vp9_altref.webm", "WebM", "yuv420p", &["-speed", "4", "-auto-alt-ref", "1", "-lag-in-frames", "16"], 60); +} + +#[test] +fn mp4_vp9_10bit_seeks_bit_exact() { + check_vp9_seeks("noise_vp9_10bit.mp4", "MPEG-4", "yuv420p10le", &["-profile:v", "2", "-deadline", "realtime", "-speed", "8"], 50); +} + +#[test] +fn mkv_vp9_444_12bit_seeks_bit_exact() { + check_vp9_seeks("noise_vp9_444_12bit.mkv", "Matroska", "yuv444p12le", &["-profile:v", "3", "-deadline", "realtime", "-speed", "8"], 30); +} + +#[test] +fn mkv_vp9_422_seeks_bit_exact() { + check_vp9_seeks("noise_vp9_422.mkv", "Matroska", "yuv422p", &["-profile:v", "1", "-deadline", "realtime", "-speed", "8"], 30); +} + +/// Every sample flagged as sync (as in an MP4 without `stss`): the GOP cache must still start +/// decoding at a real VP9 key frame. +#[test] +fn vp9_random_access_ignores_bogus_sync_flags() { + use crate::gop::{GopCache, VideoSamples}; + let Some(path) = fixture_path( + "noise_vp9_g20.ivf", + &["-f", "lavfi", "-i", "testsrc2=s=176x144:r=25", "-frames:v", "50", "-c:v", "libvpx-vp9", "-g", "20", "-deadline", "realtime", "-speed", "8"], + ) else { + return; + }; + let Some(reference) = reference_yuv(&path, "yuv420p") else { return }; + let data = std::fs::read(&path).unwrap(); + let mut chunks = Vec::new(); + let mut p = 32; + while p + 12 <= data.len() { + let sz = u32::from_le_bytes(data[p..p + 4].try_into().unwrap()) as usize; + chunks.push(data[p + 12..p + 12 + sz].to_vec()); + p += 12 + sz; + } + struct AllSync(Vec>); + impl VideoSamples for AllSync { + fn count(&self) -> usize { + self.0.len() + } + fn pts(&self, i: usize) -> i64 { + i as i64 + } + fn sync_before(&self, i: usize) -> usize { + i + } + fn sample_at(&self, t: i64) -> Option { + (t >= 0 && (t as usize) < self.0.len()).then_some(t as usize) + } + fn read(&self, i: usize) -> crate::Result> { + Ok(self.0[i].clone()) + } + fn make_decoder(&self) -> crate::Result> { + Ok(Box::new(crate::video::Vp9Decoder::new(None))) + } + } + let s = AllSync(chunks); + let cache = GopCache::new(None); + let frame_len = 176 * 144 * 3 / 2; + for k in [37usize, 5, 49, 21, 20, 19] { + let f = cache.frame(&s, k as i64).unwrap(); + assert!(yuv_bytes(&f) == reference[k * frame_len..(k + 1) * frame_len], "frame {k} differs from ffmpeg"); + } +} + +/// VP9 RGB (profile 1, colour space sRGB; planes G, B, R) decodes to RGBA in the right order. +#[test] +fn mkv_vp9_rgb() { + let Some(b) = fixture( + "orange_vp9_gbrp.mkv", + &["-f", "lavfi", "-i", "color=c=0xe08020:s=128x96:r=25:d=0.4", "-c:v", "libvpx-vp9", "-pix_fmt", "gbrp", "-deadline", "realtime", "-lossless", "1"], + ) else { + return; + }; + let src = crate::open_bytes("orange_vp9_gbrp.mkv", b).unwrap(); + let f = src.video_frame(FrameRequest::full(Tick(TICKS_PER_SECOND / 5))).unwrap(); + let px = f.to_rgba8(); + let c = &px[(48 * 128 + 64) * 4..][..3]; + // (the lavfi colour source is converted to RGB by ffmpeg, which rounds by a level) + for (got, want) in c.iter().zip([0xe0u8, 0x80, 0x20]) { + assert!((*got as i32 - want as i32).abs() <= 2, "{c:?}"); + } +} diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs index 76ee775cf..7f62a3c51 100644 --- a/crates/codecs/src/video.rs +++ b/crates/codecs/src/video.rs @@ -20,6 +20,12 @@ pub trait VideoDecoder: Send { /// Forget all state (called after seeking to a sync sample). fn reset(&mut self); fn name(&self) -> &str; + /// Whether decoding can start at `sample` (`None`: unknown, trust the container's sync flags). + /// Containers may flag samples as sync that the codec cannot start from (an MP4 without + /// `stss` marks every sample), so codecs that can tell say so. + fn is_random_access(&self, _sample: &[u8]) -> Option { + None + } } /// A factory returns `None` when it does not handle the entry. @@ -212,6 +218,141 @@ pub fn hevc_factory(e: &SampleEntry) -> Option>> { } } +/// Our pure-Rust VP9 decoder (profiles 0-3, 8/10/12-bit; tile columns and loop filter decode in +/// parallel). +pub struct Vp9Decoder { + dec: filmcraft_vp9::Decoder, + /// Container colour (vpcC / Matroska `Colour`): transfer and primaries are not in the VP9 + /// bitstream. + transfer: Option, + primaries: Option, +} + +/// Colour primaries from an ISO/IEC 23091-2 code. +pub(crate) fn primaries_from_code(c: u8) -> Option { + use filmcraft_color::Primaries; + match c { + 1 => Some(Primaries::Bt709), + 5 => Some(Primaries::Bt601_625), + 6 => Some(Primaries::Bt601_525), + 9 => Some(Primaries::Bt2020), + 12 => Some(Primaries::P3D65), + _ => None, + } +} + +impl Vp9Decoder { + pub fn new(cfg: Option<&filmcraft_isobmff::VpcConfig>) -> Self { + Self { + dec: filmcraft_vp9::Decoder::new(), + transfer: cfg.and_then(|c| filmcraft_color::Transfer::from_code(c.transfer_characteristics)), + primaries: cfg.and_then(|c| primaries_from_code(c.colour_primaries)), + } + } + + fn convert(&self, p: filmcraft_vp9::Picture) -> DecodedFrame { + use filmcraft_color::{Matrix, Primaries, Range}; + use filmcraft_frame::{Chroma, PixelData}; + use filmcraft_vp9::Plane; + use std::sync::Arc; + let (w, h) = (p.width as usize, p.height as usize); + let (cw, ch) = (p.chroma_width as usize, p.chroma_height as usize); + let mut color = filmcraft_color::ColorInfo { matrix: filmcraft_frame::default_matrix(p.width, p.height), ..filmcraft_color::ColorInfo::REC709 }; + // color_space (7.2.2): 1 BT.601, 2 BT.709, 3 SMPTE-170, 4 SMPTE-240, 5 BT.2020, 7 sRGB. + match p.color.color_space { + 1 | 3 => color.matrix = Matrix::Bt601, + 2 | 4 => color.matrix = Matrix::Bt709, + 5 => { + color.matrix = Matrix::Bt2020Ncl; + color.primaries = Primaries::Bt2020; + } + _ => {} + } + if let Some(t) = self.transfer { + color.transfer = t; + } + if let Some(pr) = self.primaries { + color.primaries = pr; + } + if p.color.full_range { + color.range = Range::Full; + } + let pts = p.pts; + if p.color.color_space == 7 { + // RGB (profiles 1 / 3, 4:4:4): the planes carry G, B, R. + let shift = p.bit_depth - 8; + let mut rgba = Vec::with_capacity(w * h * 4); + for i in 0..w * h { + rgba.extend_from_slice(&[(p.v.get(i) >> shift) as u8, (p.y.get(i) >> shift) as u8, (p.u.get(i) >> shift) as u8, 255]); + } + let mut frame = VideoFrame::rgba8(p.width, p.height, rgba); + frame.color = filmcraft_color::ColorInfo { range: Range::Full, transfer: filmcraft_color::Transfer::Srgb, ..color }; + return DecodedFrame { pts, frame }; + } + // 4:4:0 has no frame format of its own: chroma rows are repeated to 4:4:4. + let (chroma, rows_440) = match (p.subsampling_x, p.subsampling_y) { + (true, true) => (Chroma::C420, false), + (true, false) => (Chroma::C422, false), + (false, false) => (Chroma::C444, false), + (false, true) => (Chroma::C444, true), + }; + fn expand(v: &[T], cw: usize, ch: usize, h: usize, rows_440: bool) -> Vec { + if !rows_440 { + return v[..cw * ch].to_vec(); + } + let mut out = Vec::with_capacity(cw * h); + for y in 0..h { + out.extend_from_slice(&v[(y >> 1) * cw..(y >> 1) * cw + cw]); + } + out + } + let data = match (&p.y, &p.u, &p.v) { + (Plane::U8(y), Plane::U8(u), Plane::U8(v)) => PixelData::Yuv8 { + planes: [Arc::new(y[..w * h].to_vec()), Arc::new(expand(u, cw, ch, h, rows_440)), Arc::new(expand(v, cw, ch, h, rows_440))], + chroma, + alpha: None, + }, + (Plane::U16(y), Plane::U16(u), Plane::U16(v)) => PixelData::Yuv16 { + planes: [Arc::new(y[..w * h].to_vec()), Arc::new(expand(u, cw, ch, h, rows_440)), Arc::new(expand(v, cw, ch, h, rows_440))], + chroma, + bits: p.bit_depth, + alpha: None, + }, + _ => unreachable!("VP9 planes share one sample type"), + }; + // render_size (the intended display size) is not applied: the container's display + // dimensions / pixel aspect describe the presentation. + let par = (1, 1); + DecodedFrame { pts, frame: VideoFrame { width: p.width, height: p.height, data, color, par, pts: filmcraft_time::Tick::ZERO } } + } +} + +impl VideoDecoder for Vp9Decoder { + fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + let pics = self.dec.decode(sample, pts).map_err(|e| CodecError::Decode(e.to_string()))?; + Ok(pics.into_iter().map(|p| self.convert(p)).collect()) + } + fn flush(&mut self) -> Vec { + self.dec.flush().into_iter().map(|p| self.convert(p)).collect() + } + fn reset(&mut self) { + self.dec.reset(); + } + fn name(&self) -> &str { + "FilmCraft VP9" + } + fn is_random_access(&self, sample: &[u8]) -> Option { + Some(filmcraft_vp9::is_keyframe(sample)) + } +} + +pub fn vp9_factory(e: &SampleEntry) -> Option>> { + match &e.codec { + CodecConfig::Vp9(c) => Some(Ok(Box::new(Vp9Decoder::new(Some(c))) as Box)), + _ => None, + } +} + /// Our ProRes decoder (every frame is intra; slices decode in parallel). pub struct ProResDecoder; diff --git a/crates/vp9/README.md b/crates/vp9/README.md index 67c444694..379156eda 100644 --- a/crates/vp9/README.md +++ b/crates/vp9/README.md @@ -46,9 +46,19 @@ for (pts, chunk) in chunks { // one IVF / WebM block / MP4 sample each (frame o } } let _ = dec.flush(); // VP9 has no reordering: always empty, kept for API symmetry +dec.reset(); // forget references / contexts (seek), keeps the thread pool let stats = dec.stats(); // what the stream exercised (frame kinds, tiles, compound, scaling...) + +// Random access points: does a container sample start with a key frame (and its parameters)? +let key: bool = filmcraft_vp9::is_keyframe(chunk); +let info: Option = filmcraft_vp9::keyframe_info(chunk); ``` +`filmcraft-codecs` wires the decoder into Matroska / WebM (`V_VP9`, profile / bit depth from +`CodecPrivate`, colour from `Colour`) and MP4 (`vp09` + `vpcC`) import; its GOP cache asks +`is_keyframe` before starting a decode at a container sync sample, so wrongly flagged samples +(e.g. an MP4 without `stss`) still seek correctly. + A superframe produces every shown frame it contains (normally one); hidden frames produce none. ### Architecture diff --git a/crates/vp9/src/decoder.rs b/crates/vp9/src/decoder.rs index 3de847525..117903e63 100644 --- a/crates/vp9/src/decoder.rs +++ b/crates/vp9/src/decoder.rs @@ -133,12 +133,22 @@ impl Decoder { Ok(out) } - /// End of stream. VP9 has no reordering delay, so this returns no pictures; it resets the - /// reference state so that a new stream can follow. + /// End of stream. VP9 has no reordering delay, so this always returns no pictures. pub fn flush(&mut self) -> Vec { Vec::new() } + /// Forget all decoding state (references, probability contexts, segmentation), e.g. before + /// decoding from another key frame after a seek. The thread pool and statistics are kept. + pub fn reset(&mut self) { + self.st = HeaderState::default(); + self.slots = Default::default(); + self.prev_mi = None; + self.prev_seg_ids = Vec::new(); + self.last_size = None; + self.last_show_frame = false; + } + fn decode_frame(&mut self, data: &[u8]) -> Result>> { let refs: [Option; 8] = std::array::from_fn(|i| self.slots[i].as_ref().map(|f| RefInfo { width: f.width, height: f.height })); let mut h = parse_uncompressed(data, &mut self.st, &refs)?; diff --git a/crates/vp9/src/lib.rs b/crates/vp9/src/lib.rs index a6fe0ff5d..3bdfd2be5 100644 --- a/crates/vp9/src/lib.rs +++ b/crates/vp9/src/lib.rs @@ -39,6 +39,48 @@ mod transform; mod transform_gen; pub use decoder::{DecodeStats, Decoder}; + +/// Stream parameters read from a key frame's uncompressed header (see [`keyframe_info`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KeyframeInfo { + pub profile: u8, + pub width: u32, + pub height: u32, + pub render_width: u32, + pub render_height: u32, + pub bit_depth: u32, + pub subsampling_x: bool, + pub subsampling_y: bool, + pub color: ColorInfo, +} + +/// If the first frame of `chunk` (one container sample: a frame or a superframe) is a key frame, +/// i.e. a random access point, its stream parameters. Intra-only frames and show_existing_frame +/// are not random access points. +pub fn keyframe_info(chunk: &[u8]) -> Option { + let first = header::split_superframe(chunk).into_iter().find(|f| !f.is_empty())?; + let mut st = header::HeaderState::default(); + let h = header::parse_uncompressed(first, &mut st, &[None; 8]).ok()?; + if h.show_existing_frame || h.frame_type != header::KEY_FRAME { + return None; + } + Some(KeyframeInfo { + profile: h.profile, + width: h.width, + height: h.height, + render_width: h.render_width, + render_height: h.render_height, + bit_depth: h.color.bit_depth as u32, + subsampling_x: h.color.subsampling_x, + subsampling_y: h.color.subsampling_y, + color: ColorInfo { color_space: h.color.color_space, full_range: h.color.color_range }, + }) +} + +/// Whether `chunk` starts with a key frame (decoding can start there). +pub fn is_keyframe(chunk: &[u8]) -> bool { + keyframe_info(chunk).is_some() +} pub use error::{Error, Result}; /// Colour information from the uncompressed header. diff --git a/crates/vp9/tests/conformance.rs b/crates/vp9/tests/conformance.rs index 1528b6469..dd90b10d2 100644 --- a/crates/vp9/tests/conformance.rs +++ b/crates/vp9/tests/conformance.rs @@ -108,3 +108,34 @@ fn compare_detects_mismatch() { let err = common::compare(&pics, &reference, f.pix_fmt).unwrap_err(); assert!(err.contains("plane V"), "{err}"); } + +/// Key frame detection (random access points) and decoder reset: decoding again from the +/// second key frame after `reset` gives the same pictures as the uninterrupted decode. +#[test] +fn keyframes_and_reset() { + let f = common::fixture("fade_intra_heavy"); + let Some((ivf, _)) = common::ensure(f) else { return }; + let data = std::fs::read(ivf).unwrap(); + let chunks = common::ivf_frames(&data); + let keys: Vec = (0..chunks.len()).filter(|&i| filmcraft_vp9::is_keyframe(chunks[i])).collect(); + assert_eq!(keys.first(), Some(&0)); + assert!(keys.len() >= 2, "key frames {keys:?}"); + let info = filmcraft_vp9::keyframe_info(chunks[0]).unwrap(); + assert_eq!((info.width, info.height, info.bit_depth), (f.width, f.height, 8)); + assert!(info.subsampling_x && info.subsampling_y); + let mut dec = filmcraft_vp9::Decoder::with_threads(1); + let mut all = Vec::new(); + for (i, c) in chunks.iter().enumerate() { + all.extend(dec.decode(c, i as i64).unwrap()); + } + // Garbage in between, then a reset and a restart at the second key frame. + let _ = dec.decode(&[0x82, 0x49, 0x83, 0x42, 0x00], 0); + dec.reset(); + let k = keys[1]; + for (i, c) in chunks.iter().enumerate().skip(k) { + for p in dec.decode(c, i as i64).unwrap() { + let want = all.iter().find(|q| q.pts == p.pts).unwrap(); + assert!(p.y == want.y && p.u == want.u && p.v == want.v, "picture {} differs after reset", p.pts); + } + } +} From 0c2b49bdc7f6ae8520690c463634f471f617fcfb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 00:53:46 -0400 Subject: [PATCH 11/24] rust-version 1.95 (egui/eframe 0.36 and egui_kittest require it; 1.90 was never buildable) Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 2 +- docs/contributing.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 261d88b0c..a718dffde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/*", "xtask"] version = "0.1.0" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" repository = "https://github.com/storytold/filmcraft" [workspace.dependencies] diff --git a/docs/contributing.md b/docs/contributing.md index b8a9868d6..f7abfee38 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -8,7 +8,7 @@ of the crate you will change. | | All platforms | |---|---| -| Rust | stable ≥ 1.90 (edition 2024), via [rustup](https://rustup.rs) | +| Rust | stable ≥ 1.95 (edition 2024; egui 0.36 requires it), via [rustup](https://rustup.rs) | | wasm target | `rustup target add wasm32-unknown-unknown` (for `cargo xtask wasm`) | | Components | `rustup component add rustfmt clippy` | | ffmpeg + ffprobe (optional) | test oracle and fixture generator only; tests skip without it | From 10a9ae76d14d52468e963cae5d7ac42d40ac6df5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 01:04:44 -0400 Subject: [PATCH 12/24] =?UTF-8?q?M9.8:=20VP9=20README=20=E2=80=94=20benchm?= =?UTF-8?q?ark=20numbers=20(alternated=20before/after=20runs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/vp9/README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/vp9/README.md b/crates/vp9/README.md index 379156eda..665b42bb9 100644 --- a/crates/vp9/README.md +++ b/crates/vp9/README.md @@ -134,14 +134,15 @@ verified first), or `VP9_THREADS=n VP9_LOOPS=k cargo run --release -p filmcraft- vp9dec -- in.ivf` (decodes the file k times and prints the statistics). Apple M4 Pro (14 cores), 1080p 8-bit `bench_1080p` fixture (libvpx speed 4, 4 MB/s, 4 tile -columns, 60 frames). The machine was shared with other builds during measurement (load average -140-210), so wall-clock numbers are the best of several runs and still far below what an idle -machine gives; single-thread CPU time is the stable figure. +columns), 120 frames per run, before / after binaries alternated. The machine was shared with +other builds (load average 80-110 during the multi-thread runs, up to 200 earlier), so +wall-clock numbers are the best of 3 runs and still well below what an idle machine gives; +single-thread CPU time is the stable figure. | | before (5e9d7d9) | now | |---|---|---| -| 1 thread, CPU time | 55 fps | 98 fps | -| 14 threads, wall clock (best of 4, loaded machine) | 45 fps | 115 fps | +| 1 thread, CPU time | 52-55 fps | 98-102 fps | +| 14 threads, wall clock (best of 3, load ~90) | 82-89 fps | 143-145 fps | What made the difference: From 012e15425d4ee75a00f9a193f77e9e4b81024fa8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 01:12:31 -0400 Subject: [PATCH 13/24] =?UTF-8?q?ROADMAP:=20crash=20recovery=20day=20?= =?UTF-8?q?=E2=80=94=20VP9,=20Opus,=20captions,=20previews,=20autosave/rec?= =?UTF-8?q?overy,=20test=20infra=20merged;=20~40%=20parity,=20724=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 332c3f5b8..a2798e00e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ Progress toward feature parity with Adobe Premiere Pro, with estimates. Updated as milestones land. -**Last updated:** 2026-09-30 (midday) · **Overall parity:** ~32% · **Code:** ~71k lines of Rust, 400+ tests +**Last updated:** 2026-10-01 · **Overall parity:** ~40% · **Code:** ~106k lines of Rust in 30 crates, 724 tests ## Estimate to parity @@ -44,10 +44,14 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining ## Running now -- Interchange (`crates/interchange`): CMX 3600 EDL, FCP7 XML, FCPXML, OTIO import and export +- Playback: fix a UI hang after preview renders, then a load-robust playback benchmark and profiling (target: 3 × 1080p streams with 0 dropped frames) +- Text engine (shaping, layout), Type tool, graphics clips and Essential Graphics (M10.1–2) +- Audio Track Mixer: track effects, sends, submixes, automation modes (M7.2) ## Log +- **2026-10-01:** recovered from a machine crash with no lost work. Merged: VP9 decoder (profiles 0–3, bit-exact, WebM/MKV/MP4), Opus decoder (RFC 8251 range-exact; WebM/MKV/MP4), captions (SRT/VTT/SCC, burn-in), render bar + render previews, project schema versioning + atomic saves + auto-save + crash-recovery journal, test infrastructure (golden images, loudness oracle vs ffmpeg, headless scripted UI tests; fixed a GPU stale-texture bug), public contributor docs and licence files. + - **2026-09-30 (evening):** Opus decoder (RFC 6716/8251, range-exact on every conformance vector, ~80–110× realtime 48 kHz stereo) wired into WebM/MKV and MP4 import. - **2026-09-30 (afternoon):** Matroska/WebM import; LUFS meters; clip audio effects on the DSP crate. - **2026-09-30 (midday):** HEVC decoder, Matroska/WebM demuxer and audio DSP merged; HEVC import wired; asset rules (AGENTS.md, ATTRIBUTION.md, `cargo xtask assets`); README with hero screenshot and the Craft family. From b141c406e9fa066129bd6e9e43ca434fca0955db Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 01:22:30 -0400 Subject: [PATCH 14/24] M10.1: text engine (crates/text): font database with bundled OFL fonts and system-font discovery, harfrust shaping, bidi, line breaking, paragraph layout, glyph cache and rasteriser; captions and Timecode/Clip Name burn-ins use it - New L1 crate filmcraft-text: fonts (bundled Inter Regular/Medium/SemiBold/Bold/Italic, JetBrains Mono, Noto Serif; DirectorySource scans system font folders reading only name/OS2 tables; family/style resolution with synthetic bold/italic; per-char fallback), layout (harfrust kerning/ligatures, unicode-bidi, unicode-linebreak, alignment incl. justify, leading, tracking, baseline shift, all/small caps, underline, carets/hit-test/selection, cached), raster (moved from captions; active-edge list, shapes, emboldening), render (outline + sub-pixel glyph-mask caches; transformed-outline path for rotation/skew), mask (distance-transform strokes, blur). - Captions burn-in now shapes and kerns via the engine (font.rs/raster.rs removed). - Timecode / Clip Name effects draw real text (full clip names, not seven-segment digits); text_burnin golden re-blessed. - UI takes its font bytes from the text crate (no duplicate copies in the binary). - New fonts: Inter Bold, Inter Italic (OFL-1.1, rsms/inter v4.1), Noto Serif Regular (OFL-1.1, notofonts) with sidecars, ATTRIBUTION.md rows and licence text. - Perf: 3-line 96 px title at 1080p ~1.6 ms cold / 0.15 ms warm (release). Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 3 + Cargo.lock | 27 +- Cargo.toml | 1 + assets/fonts/Inter-Bold.ttf | Bin 0 -> 420428 bytes assets/fonts/Inter-Bold.ttf.attribution | 8 + assets/fonts/Inter-Italic.ttf | Bin 0 -> 417388 bytes assets/fonts/Inter-Italic.ttf.attribution | 8 + assets/fonts/Inter-Medium.ttf.attribution | 2 +- assets/fonts/Inter-Regular.ttf.attribution | 2 +- assets/fonts/Inter-SemiBold.ttf.attribution | 2 +- .../JetBrainsMono-Regular.ttf.attribution | 2 +- assets/fonts/NotoSerif-Regular.ttf | Bin 0 -> 482540 bytes .../fonts/NotoSerif-Regular.ttf.attribution | 8 + assets/fonts/OFL-NotoSerif.txt | 93 +++ crates/captions/Cargo.toml | 3 +- crates/captions/README.md | 11 +- crates/captions/src/burn.rs | 44 +- crates/captions/src/font.rs | 137 ---- crates/captions/src/lib.rs | 6 +- crates/captions/src/raster.rs | 228 ------ crates/golden/goldens/text_burnin.png | Bin 4528 -> 8304 bytes crates/golden/tests/golden.rs | 2 +- crates/render/Cargo.toml | 1 + crates/render/src/effects.rs | 32 +- crates/render/src/graphics.rs | 72 ++ crates/render/src/lib.rs | 1 + crates/text/Cargo.toml | 21 + crates/text/README.md | 30 + crates/text/src/fonts.rs | 475 ++++++++++++ crates/text/src/layout.rs | 723 ++++++++++++++++++ crates/text/src/lib.rs | 52 ++ crates/text/src/mask.rs | 213 ++++++ crates/text/src/raster.rs | 484 ++++++++++++ crates/text/src/render.rs | 302 ++++++++ crates/text/src/sfnt.rs | 140 ++++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/theme.rs | 8 +- docs/architecture.md | 7 +- docs/captions.md | 5 +- xtask/src/main.rs | 2 +- 40 files changed, 2707 insertions(+), 449 deletions(-) create mode 100644 assets/fonts/Inter-Bold.ttf create mode 100644 assets/fonts/Inter-Bold.ttf.attribution create mode 100644 assets/fonts/Inter-Italic.ttf create mode 100644 assets/fonts/Inter-Italic.ttf.attribution create mode 100644 assets/fonts/NotoSerif-Regular.ttf create mode 100644 assets/fonts/NotoSerif-Regular.ttf.attribution create mode 100644 assets/fonts/OFL-NotoSerif.txt delete mode 100644 crates/captions/src/font.rs delete mode 100644 crates/captions/src/raster.rs create mode 100644 crates/render/src/graphics.rs create mode 100644 crates/text/Cargo.toml create mode 100644 crates/text/README.md create mode 100644 crates/text/src/fonts.rs create mode 100644 crates/text/src/layout.rs create mode 100644 crates/text/src/lib.rs create mode 100644 crates/text/src/mask.rs create mode 100644 crates/text/src/raster.rs create mode 100644 crates/text/src/render.rs create mode 100644 crates/text/src/sfnt.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 394012f27..148f352a9 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -12,6 +12,9 @@ iconography, images or other Adobe assets; open licences only; every asset attri | `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | | `assets/fonts/Inter-Medium.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | | `assets/fonts/Inter-SemiBold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | +| `assets/fonts/Inter-Bold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | +| `assets/fonts/Inter-Italic.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | +| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) | | `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) | | `docs/images/filmcraft-hero.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from public-domain films (Night of the Living Dead 1968, Carnival of Souls 1962), CC0 Chopin (Musopen) | MIT OR Apache-2.0; film frames public domain | | `docs/images/filmcraft-color.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Charade (1963, public domain) | MIT OR Apache-2.0; film frames public domain | diff --git a/Cargo.lock b/Cargo.lock index c551bf47b..86481ba91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1360,9 +1360,9 @@ version = "0.1.0" dependencies = [ "filmcraft-color", "filmcraft-project", + "filmcraft-text", "filmcraft-time", "proptest", - "skrifa", "thiserror 2.0.21", ] @@ -1645,6 +1645,7 @@ dependencies = [ "filmcraft-geom", "filmcraft-media", "filmcraft-project", + "filmcraft-text", "filmcraft-time", "image", "rayon", @@ -1659,6 +1660,17 @@ dependencies = [ "png", ] +[[package]] +name = "filmcraft-text" +version = "0.1.0" +dependencies = [ + "filmcraft-color", + "harfrust", + "skrifa", + "unicode-bidi", + "unicode-linebreak", +] + [[package]] name = "filmcraft-time" version = "0.1.0" @@ -1684,6 +1696,7 @@ dependencies = [ "filmcraft-media", "filmcraft-project", "filmcraft-render", + "filmcraft-text", "filmcraft-time", "log", "rayon", @@ -4669,6 +4682,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -4681,6 +4700,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.13.3" diff --git a/Cargo.toml b/Cargo.toml index a718dffde..f96a87db7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ filmcraft-codecs = { path = "crates/codecs" } filmcraft-project = { path = "crates/project" } filmcraft-edit = { path = "crates/edit" } filmcraft-interchange = { path = "crates/interchange" } +filmcraft-text = { path = "crates/text" } filmcraft-captions = { path = "crates/captions" } filmcraft-render = { path = "crates/render" } filmcraft-gpu = { path = "crates/gpu" } diff --git a/assets/fonts/Inter-Bold.ttf b/assets/fonts/Inter-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9fb9b751e5b2441054f6eef670da7b3f53a3505a GIT binary patch literal 420428 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 45vPrwCj8&%8F=IDq-RRM%sJ98 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Ç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 literal 0 HcmV?d00001 diff --git a/assets/fonts/Inter-Italic.ttf.attribution b/assets/fonts/Inter-Italic.ttf.attribution new file mode 100644 index 000000000..e346bc98d --- /dev/null +++ b/assets/fonts/Inter-Italic.ttf.attribution @@ -0,0 +1,8 @@ +asset: Inter-Italic.ttf +title: Inter Italic (text engine / graphics) +author: The Inter Project Authors (Rasmus Andersson et al.) +source: https://github.com/rsms/inter (release v4.1, extras/ttf) +license: OFL-1.1 +license-file: assets/fonts/OFL-Inter.txt +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: Unmodified font file, bundled via include_bytes! in the text engine (crates/text/src/fonts.rs) for graphics text, titles and burn-ins. diff --git a/assets/fonts/Inter-Medium.ttf.attribution b/assets/fonts/Inter-Medium.ttf.attribution index 20dc0cf41..707fc56c8 100644 --- a/assets/fonts/Inter-Medium.ttf.attribution +++ b/assets/fonts/Inter-Medium.ttf.attribution @@ -5,4 +5,4 @@ source: https://github.com/rsms/inter license: OFL-1.1 license-file: assets/fonts/OFL-Inter.txt added: 2026-09-30 by Claude (agent) for Brandon Thomas -notes: Unmodified font file, embedded in the UI via include_bytes! (crates/ui-egui/src/theme.rs). +notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs). diff --git a/assets/fonts/Inter-Regular.ttf.attribution b/assets/fonts/Inter-Regular.ttf.attribution index c4f496fbc..052b01130 100644 --- a/assets/fonts/Inter-Regular.ttf.attribution +++ b/assets/fonts/Inter-Regular.ttf.attribution @@ -5,4 +5,4 @@ source: https://github.com/rsms/inter license: OFL-1.1 license-file: assets/fonts/OFL-Inter.txt added: 2026-09-30 by Claude (agent) for Brandon Thomas -notes: Unmodified font file, embedded in the UI via include_bytes! (crates/ui-egui/src/theme.rs). +notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs). diff --git a/assets/fonts/Inter-SemiBold.ttf.attribution b/assets/fonts/Inter-SemiBold.ttf.attribution index 771822f07..e010cad7e 100644 --- a/assets/fonts/Inter-SemiBold.ttf.attribution +++ b/assets/fonts/Inter-SemiBold.ttf.attribution @@ -5,4 +5,4 @@ source: https://github.com/rsms/inter license: OFL-1.1 license-file: assets/fonts/OFL-Inter.txt added: 2026-09-30 by Claude (agent) for Brandon Thomas -notes: Unmodified font file, embedded via include_bytes! in the UI (crates/ui-egui/src/theme.rs) and as the caption burn-in font (crates/captions/src/font.rs). +notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs; caption burn-in, graphics), which also supplies it to the UI (crates/ui-egui/src/theme.rs). diff --git a/assets/fonts/JetBrainsMono-Regular.ttf.attribution b/assets/fonts/JetBrainsMono-Regular.ttf.attribution index ab720f6cf..f185af97b 100644 --- a/assets/fonts/JetBrainsMono-Regular.ttf.attribution +++ b/assets/fonts/JetBrainsMono-Regular.ttf.attribution @@ -5,4 +5,4 @@ source: https://github.com/JetBrains/JetBrainsMono license: OFL-1.1 license-file: assets/fonts/OFL-JetBrainsMono.txt added: 2026-09-30 by Claude (agent) for Brandon Thomas -notes: Unmodified font file, embedded in the UI via include_bytes! (crates/ui-egui/src/theme.rs). +notes: Unmodified font file, embedded via include_bytes! in the text engine (crates/text/src/fonts.rs), which also supplies it to the UI (crates/ui-egui/src/theme.rs). diff --git a/assets/fonts/NotoSerif-Regular.ttf b/assets/fonts/NotoSerif-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9fe4b29864584227cfb601a37011b51da779d378 GIT binary patch literal 482540 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

QAj6E0iqKGEvUz zL&;t~wW<=@$Lcx$812I`v(pJ?*UT6-@WNTI_p{n!GA5niL8<&4*7{NPVPdiPDt)2^ z$(!-|*bIYW>zVWt8(N{Q%gK(Vw6qu-ClWoSwow{A1|^_--Z3Zv-5y#y8#)DdztKjN z(g&`HuCoSQK^7;<=ZfXzIYu`Q9r->sPF5eSZ(;FYn)SJz?GMn-3Ht*AIKPwu%E_iS zitJ+~gC^`4*ZfjmD5C}%3r82z2NO7woq4$q#-^yR=>@x6ZaMmj-EFsti9CPvbLRLp zyV(2wH=w+s6yxcv=z@kznSVMXr8S;8BsyG3Yi(+pPHf%2eLA)EV&8f3+}87amrBX4 zm;x~be=1nbLhU%3xSUOxo`sU$K{*Qp(Z8NMRhw!lv34h{H>!8U+El}cwL7;>*QQ!Z ztR2@v{T}kC>DE~|S*+hfL@_@4_@H0XnPU4?j74htPo%9= zXYW}|7pO@_uW)ZqV7=0Er1wry(@Sn-saMt&njXUQkYA0q{=NYc(t1LY`;ka4wPdNyoH6D!+|U zDb}awHiM$l~nPotxvh^xiRL-DIo z(sgQj&0+dNk93{q;Phpln3;+5B(g#I6f{@J#92(0%4VB8^M~TGLNU-Hx@mIxE?cz| zNn`Cc_ffZox({c&Ou2@%@$!27F}oRehb9izcH@~kaEiwL9*39F=#2h3b_Dk~)RNBRoJpG@qu*=Bq#%_KgCkxbvhpw~~=N~g`s z|6ZAqpPZ~!3$6O5{N(hK;*6i3@)*3_r>Q|FJ9+k}O~eey+$&1gEFMv66~!L@l&O>F zv#)WtSj~7EG)atGvT^CKPqz{HKFGI|@>Rrx6>&_7!Fz$lvsuy?-6Q|U8-AZojnEfp zp(p7r0cjwm|Kr`i=@EWc7N$cBm-*uZnb`HeyBLVofd7Q^*lIWn-_mI5hfG?W z7!Dc`-*9s_er5dXCs)UROcyO8MlRg^q!J#K%kQ7n$d@Gh9S(nT3D?Ar_LUV_ECpqK ztg)TsO6;6)eiQS_7D;!qrte|FFf8Hca3-Myb@elACkolVqeO+D^RxL!w|ByLfX>GW ziDcA)&Ui)(jT~~}x;9(HRIc?Jr3m5SaJf0+RLIRG7AY1eF>??94t-CN!yPZW_1fum z7Iw*=w#eD@iyYQWQ>IYN>x&EWz4*bXrau(%qncceHQOAvv9G2GO(vqVb2a8yNhzCN zQLgw5+5fIB&0MVvR_gutv+qI`dOyF~(kEtxsp8*=w2_AeFN79v@F80csq7tgx1yj( z!;hw4otKiO#=H2)MIGfcSS<~G$%T_@CLTRm6OW;0IZ_2jsphZoBPhA_4L_QEITEnd zX{W`rQ{!L7RfH3YH?dlJWZ)}Sdr9C7rc_o z=;o9YZ*dP_lW9spIadZmaGiWqE=JtD;&pP7@a(5ScInJD%!!|UA#rxUceSHzd0V*90w zONp;XiJjB;T|f+St|Ve;xI)gt6#@;y^NT=?3eo@;YNBzkl`=>33rrJ`<_8+BjG2no zB<`O{5DR^LP5&Wo}zo)ggV7o9=qU7}LQq(-WliY{y~2;jzLLDY zx$?HvMS;v-W>@ZQ;1XTvKg-pNPdv-vo9WY3L9MN2n*2m?6&fCX9bLOoxb4rf z@{^_VS6Dgq$ZV|qRH^(WR^EgiK{vG{U>~cXt*Fq-`>a&lwdOnqE+J1DeIj&ZrObjw zESdRwys#{oa1Sg(pW}lJN{?DZz(=N7{}t}{Z5*i|(#s=jFU-!*&Qhjkr>HZFtv;_> zrt&r8>Vik7vH9G-8fE18g%@4eHFiVngZgflDmBTY(lyWV&hpk34^(!oDO9Pu45BG-X~#q`bbXJvPTxg3AX#Od9Z>EW4MccSTEZOHQtE zfo78+KKbjC+$Chj>vZy$a&=a(IX_dWNmqMe<;gP%4O9qtBQ_QS<4F+_LOxOIxSUUuP?hi66h&uSzldRXLVH? z>-*Z3Ty(s%Zc$B2Zc)@-(_<+`;!a>zq0QhbcGx>dYP;4%!(3=iQAJ-_cE*~Kh15!B zf{IJcn8A2Jl-*Tite|sgk4W1xrVKj?N?cP%u@E&;rEz@Akk=YA7Khd@E$ea>mh#>} zx!-27wPvNt?RjZ#wL8>U7w*eeC|!d}ccaT4vZN%XH@KT-n@V8LRg~4Z9D$V7bWVwx z%i+=$I2iZ)owg*cDyPT*Is=nF?qQ*T-k(Sk6##o?3kS(e{wZmCDp;UT!I&nol54k^ zG9A8-5wEJSzom12o-;Ydgz5?s>#)M4P_Q&iY^3wkX=95 zYq&>KWghxW!18p#nwyoU&MvlCeCbJfp)yNPHzzyHx3K_v>!K_dTIgTzCs!&YUdh zk^}WVPFSAI>GRduDz(a=+||jQ8QbCrr6@e9^u}xHAU=o}mO&o9V8ZSk z1)z`UmgsUR6yc=hipwnlzuA))sV~lnuFo}V^m?_|pI_|B>6|0ub7HSN<__lvTwJQr zQ2&%SFVkaJ75gqmZ!L55Hpc4QUW`MfD~xjH*qMwInS41o0aF4>EhvaL z1S4UIB~#i}QnH#B0fWl+IUP}xusnrJ*Q&MZtYTA%HHn8`>+q-Xa;megjGd(qSRLgC zZe?tDr>4)ST>yU6$p_ zE2~szWpZixsRje5Qkpxd^3tQysOg zs*a9EbM;|v!G-fqYddGp=n2(9d+ce>Sunq6!wFlrZ!V?xZvB|2-=Pe*OqTbr7(J-5!@3}Y8OCIY3Gm$Vy?1C9xU0ij2M4of})C6@X zZ|8Lhx+fH$n?FvgAZ99I{lME~CXr|%N~&1Aa1R|px#bkMdF&MF{Ev;bnq*xH;!qhr zlh>hCSPH@|1+K8ZAlFlpWvm=j1_mN;lsd4%Ln@n{e|D9b8jfoz>+r~p*2eMbs*NM` z0%`yvaTO*jpE|=y(vCz&g3X)$-3h7Q>oIFY1$OE+wXDfR-Co| z!?o%xO|m{!t#RggLb`d|4|9t-+@DH??Po6xf6vX1?TY}#9l|Q^eLLX z2(i&knU4DBOh@n1kQ2DB&~sh#lh-Pq zKTbaWxI28b&x~wvfA8FR3jT?S`jm#{wUu37OhLo)vWj&>RVkl>7`jn@fJs37K{ASA z2(!o=+Z_;*oSq1#4yI&5yk|*QuUtL}56d;DSy^0?VGbHyew89UEh)o}r~6XUWXUB3 z!8&ib-eGo@JF>Tn0W%CAk%VY^ONznB!7ZeQcr*>I_hV< zH(h(mL!jjkv2w-?R(>fIBg%0~Lfe6S^0S2Vh%0?7xdGqHxx1L+L_~@I8r~p$cgb6) zE0ex(ACE0D<}oo&xUeeaYg~l$zriBA0I!-ouqoJ!S9^whbxSJBMk};wfy(}d{u=Qx zkazj5;l~~e<5lJ_vvnn7oeiUbKyPndQwNcXQnWxShLD$Nqe)hnrrM#f3yddDoDOlXMU=?H<%-}jOEr?40QV_*0 zh5KkypA;-fd|g?bbFPE>uH1Ftq&$5aVk6w-5yYvbrhnQ4zC6#m6qCo7iEdJK`$YZtr$mIW8*`-bSth543vpd>8}j89P3F z%9*h*kzn+4TG|k|uerX>Xl$#;`n1T?lLS-enXBqMbxz!U&Gp?JUfi%`D^iYl=b;Z@ zuXDA9LTyfHXE0HCl|?%0j>X0q8I;pu8i`IEL$K(SLp&+7t0}(`reg0e;4^U7HRA&VT+5 zSDKeCUp2@&>S7O~jFq)$U2G4^bp{?;*mCf4?ZEKH`t7p-ZscM2-74J)9(oo$q|r@LVl^@7nLhD#Mt!(t*oWx}O>(MS zH4h}o?-(+fY!NQdmee);C>@=MByq$7!9R@Fm1v)98*3vJzRvSbPro9%35f_HRUnFu z4?$l1TXJ)MgGTP|Z*bVkUztp6?(a|jqXtpix&gUt7Ja4C!?Oo=vgZCj{Q@tMtXf$#96Md88!(9G|Has1ynek*r zDKKg(5-7)k!{99sk5gs{D0EqHh5!*IdAd3Ib7ZkRMWPcM^%ZfFS4L zQEE7rnV{V$3qtle^_^#Js9=srau`((zj`F}ZPJRimdY<)?dkKpVzpG-FBn%u&G-cl ztHt^=d~o@5@n*58p*vJn{_%0}6AP4&f4tlk>TC!F0)q3?kw`FDR9X`B-Scgp3HZW2 zzF=UeIbs9EzE3V<`@NQq`By{43kO?Agnb~ZbsN$aXY zR;`Jq7{O|{tleE#=Xdrc1}3J*hlaW)S_c!I7EkQx{{2^mhCX{4uq4*8_ID>Hk-~c@%LF-{Qa7XKY(}& zAR?|eA4JnU%FiQxI*cRNQ_s#X*>Gx%6%7Fvbma%jqS301rnf zKOGsl<`cx{<`dBF!;@bB?stEMXA(cZB~=H0-};9@+IWH^8FGM>P6`hEqZsB_t}j#Yo;4?Thpj0^x%D z?j5{&AN$|IO`8tlNBgzM4nMH|D~}&~XhYKjAG+(oPkiXkhvij~VqNm_nY@mr9iO>R zzrx|BJ=TWDoOMD%wob9LlJY(rE+1*35edV}Ub=8w`>swlB4K>_%t~rpsaWprlvt5b zlV&{Q>y=P)rro~KD4GucCqI}ziiRfK<%!c+%3MNMwhP0A#69p;wq*J$$}oVGYr?T3 z!196BgRhLr+X)~NplMy|sWl=GmL;Hb$^0Sdq>*&uOYVqhbnAi*y24+h;aQ1bDeEXa zP+}}9GH9o@eWkA(tS;l{ZP6C3yG~o6k}9ob!cUwiuW2YV{mxeJbk^H`X)8m6OIIX0 z-ZScgEf72NmJ}3!hSSCtHo%eTwK#=VSr95q?QnBDKXuxOz8w6x5mHEwgy6u@S>moQ z-gw)}mA7pyu7`2r!uyh$Tc@UOohiZlQ(A53Y;*JM!~9pLwk%}uYP*iDU3GD<##0^& zm3uUu7t=ef*AV)c@N{I$vazj^$kwrCTOy!6Wf|N848kdAG9evzEIMLNxd|L|8JTJn zGNvF3G^Un1{6F-NzOF*)@V|bG()=>$xb2I^KsoF1Ut&yk`2RDy8$z~ccliHa^2gKx zC8NVXvtxPcKDT3KRYGQ)VxVN&v5hA2D`b(JmJ{>2KaetDcRUAsgto*y%N3>y@InX6 zFLKV3)jta#$~{-*(k}2TxNW|k6CQ=6bni{<+ox!?v1P&4F8+o|Q#|>ZGTZE~+qty5 zeyp!dN7G!;TP0ZrTbc_PRUshAtzG5QU!~e*z6q}wP2_gIQ|l-Y)CI2KI|@7y=!!)P zP++#Bku4@agtH8I0^>}disn+#$r!W$(v3B)=@)|qgH>K7GQ3NUm-6)a@Nja-HueG# z5n=fJ+J^v-gUkK zo4sT4<`FM9E~NcmM)YJ~d{#sqsX`-giN$oomxsEjJP0huF9P9T_%kp_e8BXiTQ zBO|&Zn|=wZ9AxoIQ(CfCzMxyPJy_>+bTzKqrb_;=P>?^nD&B7P#E$H}>(1fpQ&!$b z?ry&RMqhUgS$NMpcIc7Kv@S#pzK&(^=5-@jkBa}`VPh3&Ub&OAV~Mw>o$*If@jc-LK@ zf}QzqV0<5lz$WT&t6n|}Nq1c3z3S2EL=VPkf*)XOstcT9>lHm?H=YpG+?WAS)zQsY zRs?(7g$-RzGb>lXF#mS>=CM87zzIn`d9&v5*M98v>x7*`WN5|kYT$*;Z&cL-1myq7 z=39;yBC-{e-OBLA!I_H>uPO z$`Q*PT2UMCn(SC6soGl_>HrD#4ycqw^s>Mv;MNSz^eF2P3twPbh41JUm*3LWUx{v^ zj>#1hn~+tb-!*#d$k8iSc6P3u>KN31^pnqjrM#fDTo-G#YYXEW2ciS-yXLAJ8xHQ< zf8Kff_Z^hIT>-}fR;2zSlAq4>+?wJO^YU{!yAnx0>CEogzQ&%>Z9BFMwnrM;2FJSF z`Z_S(h&S;c>Q1y2s`L9=h9-r&hG?ihIUZ_j4RdiQJkJj%*i%yuqnv=oZRUVv@|hzZ zN%%-qqj7n5MLc%wCa2I+ZwWnF7jY*~;sp>`dS+Z4!ne7|enanw3||)q#j>-@1Pl7Y zp($l)cU5ggO_f3TgM^*CAyQgcpLn%7Qdqj`^aGmOr(X+R`GFxfj~^v(er;J@M@QYV z*Mz6<|0!o2;Y~2kyfGMDf$$@GUa@WxT!*i|Y~QY<$p2jC1IT#B2zPK?`9}2HqZBg-U|8A111V zEcGWpT;(_BV?`cBcb@{n>exPG`7;bvT-8FX(+livH|(kmzWcu;t2`ceM`x_Ft!;8L zjYo z=cRZd`?ALowDSqy2W2%lQus7}b9Z|ZkTuKuj_m;E_cJvt6P7Go3ehNZMI1w+D>jGM zV)mH;mDATd;?Rfw$J-THaaCnQm81(L{BG0kUIMbv=@MF84lSpayG94Lu8+)o3B=ME zeN~KFfQNipYC@Ue%i>fzBdpA(-Z2j2c7sPFj(D9EN%l8YYedmmWfguyJId^>kj_Z1 z(0v8v+JGa!1Rd~?9H^+R@PKKclat?exd0FhwhQl0((GvnK1q+K!}QOP2Z}eFdN5N7 zz?BEgOttp}On{yUVf5l-k9|#fc=EJ_aw*jpBVSaRYK&4y>v9QHSf4y3+?ECbU2bP{ zX1`7l$#gloAjf2+7-n#cyxY<^=J&vUieu~ndYi^EmtB_0FehbMMrz4~&9eCDit3|R zTzlx)<<}ls(bKbHd2g@w{U=Y}bkoU`?{7GE=+H$M9XfPOUdjP1Wf7;Avpf2HGG|Q& zUL2U?^H(yVR@hrOs>ogs4kaf5FPlt}(YHsbZADO)PU;~Sp}mP-hSZKXR(SYyhzBts zW1?s8zQfykCTTzh@lSlTr)PAcz5OjR=fa2^ZHUCLyY=?#8v>Y+G3#^`VlKsj0|)l( zIk4vtRtv-l-vJ<*elLp&%VD&1`7Brl_IGsc>kqF=)S(M~u%l{hBDTD|pfEU@K7OfT zFt}r8-vPfXUtdD=mzdux{0m5R%yBByD`{tUe3e%ffGY>xwn9xLU-+N=h^Da8Gl+E+ zFkZaS8}mQ;q(A0O-Yheo`b<7z18fU0Yov*SNh$A_WYogm@2!5gmgY#8z4-YObo+iw zYA+Zqsw^os6bxfDz;`566@AJXZ+j=;Ru`f&w%0D4Ql)LexuI!b%wzAy89Q$mv|I$2Okm=zD|nwhllzYH(s%3E){0*2z3uYUh6SHW<6 zX4CRjpPgN{a{b%b^%oW1eB#8(!abnrj|xwoIB|2~McA5xRd}KBqMbXB6&4=bx$~kz zAX^PHk%SVmK$%#eVGfGdPmhB;D=QHanu?et8G*~PH%lBRk8F8j*Fv-E(#JT=Y)_4G2*C6>3Xu<4MZK(d>GFrv?ThH2_m*T2y*MXC(}XACLKdHMuSoL} z3}?orn*c*D7gyHwnaS2!?@ZE0BF*VhMF`FZhEr!w`{xAbNwG)U6vd>3S9Nu66>0p)+M&Bi?y^y*DxH#V=)6*?s4YW8~DSv3RT%&9^7|26wl0?Ne;d9lK(OwzM|doaQ=L z^RaDPuj~Y|$lLZd% zTB>vz)l1=bCiEG}5nj2j%+verl`CUy?IDNj=90di);>F@GDrE*yKFV(g5^6= z*zhoWNgKb(@@>8Yb2ndi(Pd=xI$pjKKSElH(tdQ<8i+yC6(pX<4l-O`A6$ymj^ zWU$F}tVza(4b(X;)zPRyX_T=->03mGW>#aPz7ew|q<%AV?K! z0=5&yOysn~G;4yUZSeRs-!oYTg}2=!e(RKa`A|-y!n0vjZ2S5&s{*WpazUlUfIW># zZ4%ln^ICR{vQrX&Jh258&C%|bj*6C8e9*mfYGTUksjWxubZdR{U{(6KE2qwkq@9CvExt4rg@mk(XiNt%0iZO!kyQ?RHa|R>2VY$FWD*LDvRClGQl*4W$!{_Fo6b{3aeCzX;pf^_r5uU;+45z!hK%9UZs6{nX|!`hQ2h5 zg3KCQgL4_MMg^()(lE4pMyw8%cJLD#HEET76q_IL2lbMwDL?tgw3^h4fZg*1FG@uk zEB5>+#FJ#Dq;m^DD~Ji>gac()ilYQnnTsPBh~jOui<^+3Nw3LS?N74aFF&U!*lliNnVwu1jyEU6=l{*mZ=p&m@HV(F;$a zVr}aDt7hN!U~)!ya8-NzQC3~wB0QP*viL2UG{Z$9>eNj}mKA%QtA5$~*$PL6S9r26 z)U&TL{4}4jEAk%>?I!8&q=9kwmt9RMG0CiS)qHE$@KFdu1D-(oKB0-5{f(N0$xjK@ z-zqLEsCoF|$}RSXANCcL7a!d9+z0Cp+ovo4{O8JP`{D2hpW6)*E8js-vcHE&$owiu zA{?EwZ~biL!w=UK+0<%VQRwe6DrbvQ}?q9RfKEl^Mnmilm>|>+esmSh02r9rcBsy~%qw2CH1bpxZ6x%a?@~`9;z# zeD8;^yr?86Nu z@(p}SO<&r48hrEaGyg(oG3v9Y%7dyzwZ4kJ86ykx^8UhM!RegvG(!e03SU|-xP;rq z-4qZoFik1zb|ogr?R`0^R<{1j)fJe?s@exnv(eSqW~-16R|UduXN4rVtfls6D|MAo zx2dJ;7JI4nyMByVzOqtBqm~eQ5sLr^Om(issJWGVgsx;xl1ahL3W^4Wq0u&Oq*KhF zQEq53a@+5IciU|Z?rJEuQH(x7Sn7#dcSG{k=+?*p0#kT~GVBnIf6Q@;iQ^IQ?-m_$ zXvPC+4j4=2rvN^JFz84^!BBR@tLZ5ZcHM9yza(qUp}pwQd({Qf%{R!S4kKo3Z5bPN z7_&B5EiqelTa`LqWL?!C@l{2tMlyySjyNKvHHJuiKTSI)i@ZNPdfFUVE`XQaF=Zh%ejP~sH%m>bhK;e2GLwA5%l$3m zEs2tvg9oeJ<>rzSKe=C&_jhnx`<@<_t2DTBcJ<2Sr<0!@oPNx+toL{mACjw~m|qY9 z@N)4bvZ}#4P!$QFnU^@S0stzCBInBpEregJP#_`4E$DPRI3x%|Bbuh&)8m`!Lcy57 zsY)+>{|CaO4`~8}jg4*P`NdWJO%n&Y3WcHw4Q-69zGl2N>~=&3f`ilb{8IP!mKuwy zv{WCk4_!V5hKGYDep~z=sMFIHOgJtmP_QvDKTr zhIX7b#~7-*Gk+JpB>p0=ihM4D1HnN}9BLuec?-Fu99(qvk-*bjyb1n+32RfOz0uM? z+R;}(+2{>;>I(hEgO_#md0bv|(e$d#g{2xad@c|S%U7>iKD=+4?V6_krmm?q!q%$& zGfiE6F<{IN%kPI^H9GfeO=To0j!0(rO^@85(@VN!KDx+~q9&!_w$BP(`2XMWrZ-7B zrD9bks@4iBh22j7Sa=3phqtF6mb-RdmyM^#mo0<2ViOKY)aWHZ&HIIPd1Www#ZvJJ z+6D1APM!#FtPKf#NPMhGtK8v|O_H1#5p!voaL86v{s)8Bo_t*}*|o;kj72tEk^U9Dt$3MUYs&@6q_fP- zSahZ{#$p>JGYCsv9N@fM1X)ap1aFEhqM4oN%V7()aVkvJ$XX}Hgh7Gk)Li@s@zoc$ z*Du>xKX%bT@<7Fky3uL3$K1a9yMo6s-V_}%>sMScGO}l&s{gVzpD}wonvLEruh4b9 z(dRbf;d*HFGCq;)cYNa5B>+Mr)(Q`@iDdQoTn|0$%eyF_e}0+#`9o=+lfnv0{PQa_ zK5t`xPoIYh_~)zVK8KvhLx-gm;_GZ;E@Kb0pB*5REo8 z2aq3k=0DGP^19A^mGa~?*a>_pUoW*TXFbfnr*sbnF3T`l?l?xv8QH*J&J&(E#%+Ni zIE}qD$0QM%UTnx*!YLS%@6;wRU+&SvWqw0}UIoBw zF`IA~x?jPYgAgHGSdK=r{ineIJXvc2M7|fk(A;IURn_}^wUMwZq;r)2Q*x`MV)Knf zC3UV9)9Z!H%xL@Ia!14QV5m4>^NW4uCB+x33%h%|sMb7&rEI|iv1$WKKpD51q^lqN zv{O>mNJ8z$@c98p)nyII>!2cNbmD%g1p}bi$a~pENrk}3+u-6YB{6Ui!;B+8lYifs zm}sqa$DRF&-erxRXkx0{gq8zNSG3F>h(#-0`2K-tu&zwsT3Z>bYL0jtN-N@##vwsd zgf$CcZyI1g+K}l{3&UkY-mbiZc}Mb& z!BxLD?}ofv;QW0EfDK14kITpwioD&Sr}4+M z-6>3iSF~E+DSKAIbkuH_?xD=j@%y$FEC1d)?_b?%e$0 zYeh@^-7U@kIe>q?Lqo!>#^@|A(4W*76uTPi$+z?cB_##=i;mcl@^B>ie|=7;&x^C` zM!T`Xrb`w#OYwN}0$YXAZWE5s7l}WbrC*zcFVZ8D&t5C8+}wQU=4Slcd}s4!yh#4~ zTKs!^Wncf~WPjgEb5-EZvO*JTR6kG?ESF5$vi~a6nno*a1sGiEm@xOWw)W7^O%|KY zwEe>C@Xus%IqZiXrLXx;eD=;eXXE@Im?(H=2O8|tv~CCWXjB)9>PDVQAe+#QN~%Xb zHB*pP4|;F>HQ6kj626r*M$0BA8rQY1o9c<$YpNZFhI)*^z^FZ+?_S(+D%pJ9X!T@I zeV?bmMLk`d7FMD+-N8a&2=Y zwru#KlO3za_gq(8<#D@O4&58rHmx#wVjg55qs}?p;ML~yI%j|Dz{l<@Db{swYVBVA ziBE}5VqxKd$99UBhlgXuk=YdybQD4?B2WB?_&o%Gu3>=dV9H&Nh?I*CdKlo4Mx>17 zBK(v|gl&o8)mW9knr%>~2w*5F(uMZ3FoZ%}d)1^;1TMa*J)qYsCAU9p>~ZRo`;0oO z(T$ar7^R|WD$&*D7e%NXw`otUTqEw=)PS1XwotIWY2W5$`yIg@P1Cd9XhVFjueb~q zy3K8-+7?g&vQN5}wPS3+VyGP>A0|jf!(Bu(%&+6uZ)D-0JRBUPjm8lNFdLGnNu}UO z;=}#W+~@#4FiSK|fbym@KjAqP={r5$279Q!x3@mh(-Sef-Ddo})fkO7(ofWEw^=MU zyBXg^Ev)mv9n?2kK{De3Cj6M}W@fX7Ilt*&ue48Ff5Pvvn<^Z3M^${lhpBjnW@Zk~ zwXGiM)mE8GOAHkzN42wlrFHdTWJ)$abmv_Uu+G)I)M3APp}dbs(JlD=jK{NX)xFys z?$pCAbHlZ$Idv?S{^W_ujy3N;*>nWGsi{3Rv<7P1hp zx+3rB7&?Ta{fxg6zgd2x+*pCckj7Il`6Zz}&=sk1l^SdXCY`UrR@dFs;`@NH!Ewyh z(QNW|cq>CzmoL^7+cq+iZ?akoANkB5ZW*zZX&q%XmHoHB{;)7G{QmxBr(WLEeADgS zE$vp(UOP51^~5)hxwahGR-L>9u;HKh57A92v#f6lmy!U9f=!JOh77dy<^$B!RP!%q zydi8V*OwcE&Z>s`*1)u-zI@iUEbMQuwpO~VHfOQ7dm`ARbC+-1@%V{RtxIq9Z-3(Y zuPwW#cJQ}%g$`d7cGYTJ4$M7jzT%4gHH}Bweu>>l2@pqMx3eP{?)$T<3$k#s$}?b6 zQPkc9`OW+_d9Qf%I}i-0srKmAVnl@rOeI2~ygK@nIwGp|p6a@8R%fiI;^H`^*nhMT zM)}1aVq9~$=3=$4F+=M-m?lg-Z+jwQ4aCdfc_Xjm2CpK-_9PvsO^?@yGRsZ;h6kOHY zzua6h->5+W3W= zZsjT9LMJEO)te90%N<%g)61K;Z1THwR!Z*@8G}Csma3s_Re5x<+;$RHAd_^+z(m5F zN5c}HXaX<7Inc41!U|v6uSef1vRS1+X`PmQ^{VLR=tQB_s*T)ZDzgHlNh|0%MYzUmf>W6{;&s7`b>o-EZ@s>=eH{wv&{9I-k=A*ssf zy!_pFkGf5+{E!gxg_jxYXYf=otjdDLvj*2P>oQp@b5ga^fpY;=ke!T^D7l7TP!J=x zn&dS*ZI|Rk(Tjw=waK5-!IdS2E;(>S>?L$8gf}4dH^hwT_`@cY+JV1#cHlwT6A4Dt z9N3iCfVP_)e2sGcMQrx@UGvcU5yE=QC;qHpSx$Y&&b|82Ij{{XT>EYs%E54w0Vq$< zxt5&o4WAvIG))pj6_vY@5~IKtMGd*<$v1hN0uMS=MkJ~ww@=qxRMVgMni&30*HDeN zS?6<01?@w=l15!U$~>jyzW{ziy{@s?Kh!SV{Q2aEL+<)V+!2dV2+!evt&!?S>fOlR z4m}oizd<|O9R7InIWG6LKD=_k~1Xic|?>k-7*VINEr?$LuDLb%5XLU zP)aZHnuZe}6aMgE>qv#y=&Y1X4GCANr^xEDyY14?RgP-A$5QOkRW~;XE7TuM{wU;% zZj8HCpN~dAFL~k{(ao$R5ZsEC<`2=fwnjdnb-1<$10_QA5t)gp)9%VV#0zpU@wwnu z<_4@(Vd?~%?4_7C!lTbG3F8HfLh4d&@>SySM+pDv%&j>K5r|l2j$+2_Fa}ZzDr*>c<6!E)XGTKT?46z98tzt=K>FVenZ}bgC+IvI&0h_OBRO9TbDA6}t zOko##YU}bNcJ0Jid?>7w48ER*zNR-kG=jyVcgpz^b^XFVwiQuD`1*v1xN-ED(#uC)&FWb^45jyF#U1 zLoIbpeLF924*FdFo^D^T+V%Bvi@B_>wmxD|shbnAhUS)lzojkK;cP6lx0=$I8?b8o z8XE?5>XNqj;Hn~3waxCTtn^n_RKfr*1rIl3r{)V6LJZ>22+rJ44u?E?8z1Nt(Y4@Hj>gsl@1LofL^EZmsZIzDZrnnQIiJLQ;WDA!Um!7!w z))Pj(q0*52;{CU=7saKw-4D2^f}2|26(P`tvMh3WlxPXTFb7jbg+_*nUjt0c?ZcoV zd$F}d)iXHQr8YbD&S<24q)Rfp@Twl4=%g+!xqvs`{Qi@IuTVRSetFG@ZWUR@KM3J8Y8RK#PdE-^Ruoc;@*iIYWfwdiE$ zhIl68WFDlwLk+^kXzWs2UE+~!n@gS*MXsuMO7O&i!g8G?73#{&Wo7P`28XL!W6-&( zjOWh?UsRs@Wpi7T-qX@K+1|1;?8H-x^({TgkAV}}Qps@>x8kLyXq|W~&lX|ez(o_c z$=8?TH*rpx>bq#4Ow*omBq&E;NWL)6Nq5B6l$?V`?-WF2i(zZQwFCs2d@&yt>=LTO zXSZL|+k4H9&DRVJTr<0KYHG*M)vJZC53Fr2`(lZM@}WBlMzw}ANn_UbBd`B^lEbP0 zP-R2=y1v2;?n=cE-e_9iB79Cs^*1MCdPkQtmFvH8bYr6tt4Fv*WdOg)-Olnh3a=1C zFuwGw;*X?!cr?f(#TsFNki=m-1a=8|6#4l)j0AqbZuQglp#Otp!VUJC{Ohj0_PYEU zyRAlZ)eSdXrK#Cm;I>-a1#3n|)@VEyD|^*h>8g1C19$vah09`fR($1-+n+bPDlae_ z3}*AkF1!3*v&CpMTkbu6*+&6U;>;-&`YnHt5cSv{sx1e0?^HSLl}^>{{$1PE&NXT@ zda>uXMWb!`4ztyvZjZ&9D=JHGxa|1#r8c9{UV8nd$8RXLRqQsEl^OL1XJ!uQjb&w~ zvhy}>q!P+DxMBCIROsXAhQ)!AQN#nX#YUP)!!a|rgEf*H2XHd0z&ZA~^_ZBRXsl?A z>C5OZNUHOX@z#$iKZSdJqf|Z+ zzj8(Xik0_Y67-h#1-d;m2OIyjd*w>$mZLU%x7}u}D7mcAtS>Gqg&PhL+^s(?2^Z{s zV5=$U=xS(6{x@bNZx`x?)~1FoXVAFyf!)bl1_!aiMo6RINC(*pVNn^DvO+Klje$2A zdA=(6L0%#g={Qf57s)a&vB>W&vc|6Anc8PtCI@Z7ps(og`r7>Px#BJ+ zv|(gTA};=G!9{*(>OM40m1sA$|pm z=mMFt7J=^DON`Z=Nne8b90E7u76tm#_b)k!BS6aN#Q_8|f{-yRcoCn|!w@l9T}!B0 zKDL)&KwNR}O@pJi?%lN`=B;Q9v(6=sT7wGq^_#G*kIX!D@W6wc5R&k@+Wnur8{JE^ z1^*4t23jU@MlXB~0?$Q{R17Rxc&9iP8xpm`g0HA=cYFKJZaKK%Yjq7zhHKPs{2J9sA1~Hv!D-OKSw~jWU znMsd)jXL99b;di4uuHph>7QkO=gm@YZ`YuzQPoi852}+pGTw@hrM(TQ-n}N{t#o(B z+jozry$!0f{x-QK^V4Tr`v(&>#&X}&E&YSdH3q#;`JFtae5CwNezj$Q->m%ptnv|1 z`FQdfah0?cV;S@;_a%(TKv&){LnRG1(s?OozTmk>S}tBdS8-50$e7C&Auy%4`N zQW!lFeU(1fRyG;lJsH{K!!V3PF<~eEdVPKgS~1>?J85gj@@psh4chZLm{@AlI@^$%T2LLVb=dUzFOw zZxepiz zUp&E%_|F19Sym__NtGtjJ6@^)@VUzrmn#*8sX6d(n9InXEAQ5JuUz|r#4Ehh(+5s} zA}v2Vqu!g~w$$^AZ$J(AMU2CZu~2Jg*)<0YF#iCiNf9%t0JM+Aes@0lXm+gmYtg&z zihe3*botsPez_c2-kd9{e8UZyA!P+{Y8eQxBO|83&$uAH^5@dSNUSkm+@n_leAX*2 zjAZwCxMmD*fs<=QX&|zM$L^QkjX*T z*ffnb8jtjDw1zUDK(CNx$t>JbW%){_T_R{48}lA|D_HLg!`gujoq{BqKkN9{!Ni<@OAfB zN8DeH+=NBoi+w08rhpi%0@+dF&1T4-rV{~qV`Q8hD64_?pu}{{KU+IC6Rh)_nj_Uc zaaVNts%k6B0;`kX#40n^SQC%z-PjhH-1&~P!S3<8TdJ)cdnYG%wwR=sDf*5LP7QPU z;{TZM8oqR9^Oa*&gaB{=)h;5y$l>H^F+^JC?Qw!#miP z2fA&5FAu`>PwBUQ*B|d!=SR;9B~)&`4BzZY)RCYzBII=&Y%l= zKZCdlwW|XneqHEre#Rdt4f#Tk1jC|fptGZ`+N|=xpN2O@6Vlh~Y8riEyY!~YQR{2;ltzvIjz*8MGJ1UDyJXW(m<_m>^I-AuRsI<9_kuLmgliL`*qqWUepsn?81@7+V z0nXdC4#!rGyN$q|t~TKF0e3FD&24Y=_?n7~w3iU_DuP#Ni;5vb8trZb$ACSbFHoxk z_BIFHh)hr^qEB?fK<0r4WO0HJzb_MV~%+3`_X-K1in%xxDePLHMlgLZK6 zI<~=&E}}iL;kB7T(8K2q?eDA|t#fp>xO)%uI_jc7t1y(5NNOB8#hsGB*@nKNeWBqE z83E6>anD#=(^y@R+ECLK9o`sGnG)XGCafZG#wYv-Dja)|K~088<|^hg;5b9f1vs$) z+7i6$E)VR!sz7gdwf9=b5{b4%S`c&Hmg%h&v0T61Q9lQ>CGJ#tBBq|Ek?ypBrMGpc zQ;tsdY|@w#J_f1}ap-Sgqr*I0Xrs&N<;w1*y}Q`<6+fhGUl+RX&1 z8CD6$J{eYvW1l=cIz98#nJE|7WOONKIBgxJxuv2x;g)ngqqWAF605PJ4Rj;rpy2)! z*OxELA@U$AO9TGTRN6Ej`2_3w2|57&*`* z|0wv7;m0P;&|hTOQk-)sbV#X8Zulxxn4MjCA8ekZL>b>eM2OEv3-~rrMIT&XBRnEg zLsn_DC$N@HFwU_jve&}wbN$&g3YacK@e?I@DeBc%FSG^Xpapoz)7Tp}MoXP+?+_`J zn)Ku^7f=C=8UequX8=D^UL+B;fJ3CfPf&+Y@8k$wPjI>jPHZz#Zv9GN>tW4!u{5 zFP*m&*!^R6w#_NB(5Oc87tP5u*CNiZo{Mud+$12wkfiE1|?h9L~Fz#)@P z121zYM~gXRa#eQ@wn{lY;;(Z#XEKH;rABGnDLw&AF5;p6f+J0y_nL;+A7wde(q`b2 z>vlOSNKd1}`_gEI?`e%R&+W8V3?uFd@tI`Pz18e#!9ncdeS9kKjX|xc1m%hcz*rzs)~lneK!vd zzJFg&tVmN)Z%2af!J-OnL-XF%EhB-l;Q_tZTv=UGq-}a6=qstR*Ls3Mvr(yU71>^GrBIXxpX|Swc~=>@k)Nu5MYp!hcX!A?GrnSQ(h%IW| z;r0FftGC&zoCZx%@cm93Mn#${3+>L*vLZS?HkTHas`Is!E-VOD+XvUr%&fhmzeq0? zYmJsYmFwDEZQh!;>L@j}Y=sX)L4I^Q1lF^ye+Btjv3`c@e2VoTX=ANo^gF6o9&F8u z`?DcKb(@CMy%bBrRlN0{9a(1pN>ZZYmL%Yod>yDnBZ@4*81acK=<(^N0$>92NH|!f zDX^+mZqGUKPWTO_Qh`G^QrM^YQ87B&-3!6? zCX#Zm5&loc3BP%nKXt^t^91w(Yvhn80w(NxSrxJ18o|NS8dxr;8Q~TN33fMWP_n2r z(p=E(KA`&Mo4;7*s6?8B*QPJdAJbb43f=l1Z9#|QZ^fpX%7<^hnNR-N5RD1F)DPWI zqO%vh&(&czb-9k{UBzMaLa$5yBoUA=QW2!ZGKun8T>dwO;#}S3zn^@))-DiVs{!(I za2XD86~3MWnV3n28GfKbTVT;_hONo1Gfr;aX%)7RS>0ZJT-{M>?jK@J0smZB6Lws2 zMMcm|Jp|3g=pm>;O-%&#K9`xA^a!{pfMKZ2}1VYc^$-|#UNk2rG&soL?0QBFw#sBt1J#A3(!{2(Y8bY%w#S83K%;5CbV>1mJUyI=@1uM zqSg6=2_S@=94W}0zbqvu287GW%pgNCXb~71OduZB3d1^^lX-C+g40WD(Jy{#e0VRJ zHa#N^Us62Eu0(9Q3Ea<2=}cw&30-->xPE(j`^i^ghA{&E?Cq3j+&m)2(c7HQ&%E79S82nGL}b8 zyi{(V&re9IqCmr}_)jsPnvcL{(;|)LZCIXus+}RT8FRgv$z~?q)5xN3IMk|^%}hFT zG0^D@C@8$K_oiXh__|uFL09Cj$sfON!;WLk$#DL`tzA3n3;ewkM+C=cf6r7Q!pAwgiG>w*PihV1*(T+r?T6^nNO?5tZ zQISRGHXXU};$!vaZLhV-t?;T ztZ4RnPb$cHX?R^@nfIX!b0f&tTx{JuBW_4T5or`^U(l3-4!J2Z(H24zGV)Cdrp_yI zyW9H~#8tc*xdqD^x?UPy6*V}n+L#+>mPB!RY(_xN}5l6sH-0l)c);9UqJt>TD8#xda~*`8=c&_EUljc8B=bFL7Rt-3t2dqYdEb3>Kt2V7^RLa*~eO-(&Up=#vf z4f07MB@!<~gb6jIwA0X10y~VIjIbS*>%dcqp5+cbDTj+|xu06o8?0{bcG#lS88sCI zFPEz7Ds1zE;AU4@-Rxv6JTfgGH~P14=v@;tsWdlocRLl5ofidk`%5Z2ae8{7#}2ZQ ziStc}G%}d6Pf6{7>aQP@&R-6UW@>(ew`V=3+C)O5dQY3{jpsMlcLQ^k=}p z!oF!bDAlb!9g2lJ#TRw2Ty^R!&EiYmWVs=7yLcZ6{8z3QL1374U}5;SNTJj(ISQ9A zTx{;Ftc`iB6@8tbT`UZD`&+7ftYLUdd3&L-+|~^T8`Ko_kJ^-ir#zKfR(nQ74@7L~($8ewPrJrPm~%OOEYyaLEl zrXZwhilO+>nUeDqv&`q^^T8N_r4i8O)GHmc-noLHUU7H{X5n z6|h8u+lid8m!&G7_}k{+`_hIDeogEk*1)jAvOm3qFuYiC`fBm=(`0($@qsfD=AS?X zv&W~Z&gITL^F$4GpS9{wn-4Kx_d=gy9QW52{o z2WY5I0qI`3Gl6d})qL~2sf{JOJ8vIc><4k3-%V{SSs#PEeQ(NlW03SI*pN3ceU7^% zv*^bAhuM3waR=G6QIXE{J1<*60g3Qi_UsW{&li2?kpUkuuDYOQ&5|2xGP%a>DJt!Peu~%K18TT$-Zc&?-rhvzguMA&1c!r-QVMPf0L(Z z{2eJ8R5dIc9I`=jT3{v+K;@JH__JwKp{~osM zK4hjUce4^KLUUeBM)M@Qo0mWV%`^syi1IE>*|dVK3hK(lZ9B$D+(CiA4j;|{j)EJN zQJ%Gn*JgbiMOn$+*|k_7qDrjf$L_!Fw)^prx$3KOYK)7A5QZ7AQW;d*1C&~;3SMY+ zBTB52Z={!2wNhbK^56US>^=a@q}D2;{{v&!Rher=GcC4OhDTz+4vDj?z}WR0>^d>v z^CH9JR|$`=v8#MwI8o_Y`fkwV1MItbyIT}C3slS=Jv`(Vi9qxcqFah^~}v#&!e#*M>JmP+Zw zx29q^DgR&jRtjPX8U$-7%uK$CoE{jqSIZ}y>?DuJwnJt_syKT~`{m_5mG@xg6hZm( zV!q7FX_>!a%QT;9XUj|lBhqp^l~l`ASeMPqNga{IDHxF}kF=r16D%#Zlv%5Qf_WLG zGeaxvwu(}WNeEKEZ2-V9g4hGs;%NXVNt6o#kTciRoA!8fI4kQk>){1h{q2yBO8&@_ z0>>~svk*Z6To{CNQRsrjBgtQ=@EgkK!2J1K2zs%2@=qL=bnw%|(7>Y_OX-H}MHwK& zBMqcUMYtA@CojGficZTDY8DDFV=1L`OZj$MTzZih^SqVJMW(To0&K{eK@|kd<)r|@ zd^OzH&lMd9kKmL9@RCO`pBN9Fdi?qY0{b_8_u2cf3N|%XISK$B{v3TdS6u&_(KB>8 zb84&_5YsfE!4$;Qf%R+zSODZXqzjLooXL&%HJ|w82?ldA3*kcKvRq;P!;vRA)M4yW--ei?*{|dTOnT>7-;S zS0G+|XluuA)=D+`47E}f)_ZAWWEzsKPR|3epW;<5dRF)Bcye&R6(XQRM zu4}gWB_7vEuI?Nj?mUlzH#Moviu9a5K0h;JoNrt$4r&VG8RI@|3qJFtMHY`z?%Ri5;slTZ*HwsPOe~hq0!+fIS~WCCmIv5oDy!9)7leV5@(#nHl6s9-Ls!W^10DA)Dj3- zj25e*s!CsC)M+BsrKpa{+I8A>n}j!3ed#lOGsE8MN`zm^JGvV#KDOOvFJWn6SvwE3 zAwX$RStsKa0E&YJI$)Fi2M#DILP(o&K#4T-I7Zw&JA1X(VJr_c`R3p(&=->d^&=Qk zekL6F&eN`$5e7Cs+21+rOhIX1If0iB;+AgY%w!~mxmsHEO2J?92zZhU?O9zu?EyI2)Zx64B3 zbars>+8{ehorlX-D1#qjEArmYY5q3LvGFlYBkxO@-rxq|s=arNPuzRt;Q7t|%I?I$ zL+4-8*;^S!uA53__o_4_7iu>?NL95XR8`wuD?D)b-5;-C5#^_;)7yhU4r!rnaQdP3 zxa%a#Af7=Z%@)?yMqm}-VK|lw~j^wwz5B|%yzZ5d#Co!E2gd- z?QgI+8ip=7eBRN4gu5JB6*Ya!Xe5RaPx}K{hBiD{CRZNIeAW=YzI%2HKQ_l zWhR>)gg2Bk^Q19Ti^)%Y8Vms6c*#!{K0Z5}oko*ITwx$}M*ciKvzRL-{paw=mNOJ# z_#au+gPw@{v+|EQ^vb%B3>&T$Y5O874warSw2xdgY~6vQ`=;4yv=6|^oM%eBSXH%@$S|>o3}x2 z)LEKp02Bpyo`SgLy)hy264bUu);eQu8 z2D@5%4SMgmcI{xIw?wULZ|Iz8^>)PCTKs{Q=7w0J-tQ{5RfMTe4jJNT2ugbStY;xP z{0NE?E^;nXIcIDC6QXyqwvJiv|pP5K=5dQaBj!v>5WFvQB zF6NyXO>e1MbU?g`IqET7pKGbA!F8l2Aud8T?vOG#-7C$z4K*=qF0&R`S9$eGo# zudvT-{#935Wr3zbFD)+&dyU2dhus!vjGkD?qVHI&{yWrUW*|b5~`mF zuc)c%ivVh{s8}F10qPD)`J^#1^P!fQ7EJIFnJKf5?JRDoqY;EoWCk&q#$^0$7VxhA zb%&3vDh>pF=x9|Nor-RpVZ-1HOo6zebEIcYulVph*dJaoH93y?@HL4ldo*)CyuH@i zwXA2Qr0Q(nL@jX`=D&~r-43h*-Vdt!<=*9Lhym&dYVzRl92D?KOC`V$%4)Rh3jK8k zU-F$Ye~or?k;h-^wp~b8%_W6sa^x=rX5DDu`%7YaZ!E#i1FHRH{ zBTA(dW(yji24;&aNbWzrWZDSVF1E0Vvs3ESLJK?nq4!QPBrNbYWhTvgrPwzOb^gM2 zMV_+iG9M#X0OV@5SeqUG@0^`n+lu|ZVy(HwQ1Tw=MY$^l>GWD;Ip<2ROBY_&in9~z z%L^{+T&bnPAs`g`FeMFu9kay*WZQe+Yu~fGLS^OxT~!svk(gAWa#C-348Iavn~0Ysce~}vVor*kX3>X zAn=69fYQ9@{rL@bBw&q%^hTqmP8A87BO!yqr18BMN`E@i3A4>o-$nnw=e<(-$Q+fI zxYTo{^06f@`RtUI@)uk3QmL$1HLS5b&f;)5HHJp<52B-6rgcMy76bU)CycGdafxi> zD4O`b<(&Aehrj)v`S)WmMHOtZpds*AAKM^M{gK&MvWURrJu6 zgPm?$XZ!FH6>EVs&fxFL!{ILoFIx&3@Sm%}^Vi(Vc;DFt^dGZVu~b^7?4ERL%xTg0 zM2*#ouBkj%Vw_0nng17R2)W@uS852Foat)4jS$V)Lb@Y^(U!bX zy0Y|z<6J59sccno>|D3a`D|sQlog3>lfwYWSlPJ}?d`=Dcy`iB;zH{?X9|)Elqr~7 zFJ!E9sT6!6r!Lrku4K$meGi{22}$L-QCG=HNOAcr?Vho;b0y*NvoCQ8gv`)%$_kfC z#FU1+85)dhxaE0|=Fo7zOKG^}vndUSzxKhqA4E!#kAhcB^KD^Xf8xw2Qij~P4z0ni z)0|!26Karo{WxUE?ejC2Q^_J!N>&i_q1-hZODt4oxy%2i;t14b{F7pD{$NGRL0fXXke8xj{pRAURXnU(W)p?!4m{ z?#q?Up38lwOr#uX?Sx|>Zcw^L!LhJ1Z;z7b0UW;~<2YYfiNK1)?jDl3chBM~ipI|E zZ!CuBCsS%Vhfja)f>>Y7mCuQN6^rtkWsfjU=Zk0c;1?uN47V*;;AdQUd z!8v3v;>Vtis~=Ky@O*ci760Vq??t`v1yHS_WWID%F9P&g`2EsfdQEWtZI z8-chx%pHXs>N2-8ya<6lnNvm1cY+rtQicJL!}nc;K);o^%gYFq+q9XtptBI@!X>U} zK_Z>av0Z>bil%0sENN=;WUrC+e7eRaPnI+`JlWM*dRrJ}dK-z`xs_vvgiD>IHkxkB z(u#+10>aZh*zcT5T28`~6S+e$9L(4wjZ8lS{Idhl$OG+{WIcN?OU;v>!t-X4G3v=5;U@7Iim7TrOxdG3}K$`P-)&i?|+Xyy8Kp72} z1H%$;6fyJQ!Hn^6a<(c%b2X*t=SK7#le2v56g25SU+<>`J zaVj~pm76d$?Ds6zz{1%1?P8|Dow-o8u+$T$lPxRJqVu=I80q``?Lt|6NaXEQ41 z(EzHEsXAs(P^AT#6JLnY z$g8AYJd{8icgsc+zP%Ji6;dL>NoYpLjHlZb zBTU>OT5x@xe0>joi@5(E;&Y7pNS0ZU_KMSyGE`wzDFbLJ-HB$-uFA4!%I3|W{Womc zD{>J#=Qvt2XD`GAT3EAyaR(zZvu<+nCq)x^`XsYuQ)y?iWy|N#R%Ag~oH>^k<>YJ_ zZ7#xQ&!q?;_)eIk{>3GV)>jekhH6uRwUO+$fWeZ%29ivb(W;=e8q;D&t#-%(Q*?5ObXQ zH?}Xe@A6Stk6quzn1&aJ(e|ZsFS`HF#2&oe7u&x0{sj=rveH>^DkDul!R2guj6YBilUs;m_}WrOd7aJz|CJ>T z@tV&U@4mg130^V{z!98g;E4T z`T5btv~u5+`zf%Bjq>|3v4MBc{q(0rk&kWIdjyplG@?Np^{;>!PPNMQR&>?ZH(zzr zO(%Nx?bx|<$L<|_VL)gB(8rJsN>h6?S_!b~5Hlrrkg*_J3#^xTHK@s;)Ekr+M<~nQ zR&6OLD6O#BCq{p9;>zLpz;yCV^}61nHT_%H4E3&K^@e{!$iZ4>DK4$C_BO2D*gvXQ z6|bNCig0Vs$XFuzzsXPbO|Dp${2`DR0AMKFK_TFy3~8v?SO95C;yoWz4RhwKI76L_ z@W-fHU1~Fbej&Vx7Jzzpb(OR5Btsn4zVsa2D^IlGx|a%o=zX<$kHmgSu>499NNp1)4GmlBrE;g{kwZEYLl4a-MYk3Tp%0$wS= zvc2FHi(Da{(%cKLv;VvcsgOrm*oAw#I*B1VQNANwiA8-OwVtNkm(zNVo`qyKC?|t*@08Vb@OF zI(zRu{%%i|tGcb`3!gpw@J2j`((C_A+90=$RGbtun2dk5h9wiRYAQax0;pqWksP7I z-t9Ng?5c)QYejtx)p45j)~dNVR;-_8{j8jAU#cq&_6L-5&TVr8t(0b#0Ei6heqb0; zDWs&I1t8c8Nn9;*?fO*uH8y09n4#$CyCwE4iR65XaWU>a0lag+MKV0$6T}9@!N3=(9JYAl>un(c8{5 zA3JvR*gBF38JqA+kDmAOb&dDkdDllz-vfU07xJvV{7JW)x^AxGd8sL^kDTA)Z)ee8uknFPu>eSlZDjtLCO%XnthBBsO=@L3X$$z z?TO8u)oq^H$(9vCUsY#QxZc>3h)vWF9qMT4t@5`!dRLB#ZXvOEdC%5Pt);Fx*fmiV zHCMS}!$G&*Utd!*wyAr?evIUcuc~WX*{q}*kS~_gb z;`SB89pZ<9|0inE>*lY~=H`-LA~h9aEA zls!#0H2bY>(JK!ezQNIKk_&Qzv3;_D(3h5{?MX#I-2L(Ve7Vu#(`GYvMIMmwNgyFa zI+F9LJeiaVzOV-{bdx2%fGo5{(EOB%E6%P+FQ;uX7;g*ve~59UY}Biy{>Vqwsy_4I zFs{>3`VF6_f81&}n5_27w|WL*Cq{&yJ5is+M!tShm@cvuL_?txw=21E!{w2ospdei zcDlK7hqtyG_@ipZI&33&SOgrQg_IDp2dR=kAr=*elXfI+PM_ifjzV}^`QpwUJHjok z&1l7){3{y;MLI+s+A=v^+udCoajTx%u3fkF(cY1DV?W?!s!T_|T<*8eY})-Fox|f} zzlR!2-nV@-U_wb69VpOg2Eml-AppSiG}F=qdKC@zD90mR`mzGAc3_#nI+j6DgjVNA z)LMKI+8+oroc(hY;4@{bv~{I||HFrsuVk+vb6$_iE#FT?DIJ1f!;X4E{62Zvb5d2Y zuk{9Hjt43OfJco6Q+ruu5OUC@RG-7SrfmNc>8P)C)j0Gji&^i~-~Dq(+%6WydTXl< zPcH{Bbkrh`zDWKZ3b3`@=d#ySI8DY!lP~&%p#r1FCaC%!;jonhwlVQERrV^dF?ylU zMb?MZEiRg1a);QBAY?KDj0Xt)GK4hEYidnSEkHq}@KQMkOyY_a~rJ>Va@t}^CU z>1x~j9@CC7YP}An@YP-08eG;YW60jv{X3LGI&Q4->q<1CP&7R8jX)@rZ}3-tG_e7M z2Lhp(gfJMcQUP+VDMI7i9Iq0PW7{?ef-i5|MtHDB{vG)?;@JyfiZHir8w7wuGOr1h1< zF10xr_Zjq+Izc5FtVLJu7DZcK`A|zoRfV;**iq>Z#rpq0+TH`cv8&1-_j{5hTef7$ zYF2Mnv$|wSw&h;Ny-f48%uJ?_r(|X_$;>1(sie%LH&TE=NC+it3WSgZ2yGJxup4?v zSXc-@b_pyj!!9KY8Tt1;_q``ewufXtzyCj2};Vz|I=`fXQQt2Em`H%pnT5Gz9R>8${>oUX;soU4w z_?Yz4m9*sgof5s`?~@I3%j&-4Ny}PQc;h&CKafR2bJsO1H*h4j=*^LAx3zs;jN1wn z(tEUjYT1!4T$Jhp@Gv=8?@r>@bnqp(GF*kn2j~MQ@aZ+~7#i`ZpXGMr|K#BrE}roY zWX>i$D_8KlFyXl$ai16FJ@;|G_uO+Ec2lLI9ka}#QjuEUme>nvM$51q=?*#}O3Ce) zds|Xnv8-8QGx+iQ3Vq+1J$FLTEaMo5>ip^xJdtL6&Z zt{X&8`c=mwmf7BpjkI~^{Gp*%XL34rc=HB!{oT#|J);|*z3|iuhY&C8?8y#c*Up^) z!Z^F3ZU4HvCN{TFYkB~Qo1$esK$)B3s~ls{Ko{;Q7D&AkOFj;qSyJ@)I|DwqPyO?` z6GsoAoD%Jp1ob@O=%;TS< zn4eV1YeCfkDlwNBi@5YfR-R(vra}hAQjPLASDwfmE9jGJVt z$biLd^Xd50-9s(r8&`vD%KCSDQllIf9cVsdO}OO6_mOElctRZvyfI7SRFV?rXx&$` zqtW`;L!lOK-_+F1VDURRbGS^J`-|8ifDsL@n-XZG%ravxeY8w8Boh;7=8PbLONw^?fH65CV*?CX`)f$NGAgf-cNrU-3A^jZK+gX!GFkj2y5 zJ(X{pZ1FZawd&SiHu-!_c9;9?&@8{Nu0P+|-9LIRmr2THQH4sDu?D;@zuoC&EVe*i z9V(nA1V9tV_^eofGB<@46G;;Y*aD_xDb7v|O*BTeI+HT~p7*dR7phPjRYSAfRH#AM z*cv$5UVM@!-LcSwD9mG0I=&ZzCSje6hnF-w=$+D1OC8S{p*{+q-{F9Rxnn$QM!C<3 zwc0|5eLf=iV8WKZEW6;~$KV!hWu&sbRYIe zYnn)P=OpWULR=c~iMd3d8SG3!J!FbjS>txXBhwM@pBY_JaUL=mRV`J1_=aGz z{|&v@T&K~yM{iMWdU&q?Sjg#8X&PEuhW}yZ&5w?q-m>x^hFe-1G%An7f1-cx;Z0Z^ z#bs`VM1s}6RQJWggtiup`QgNBWL#+52CxdI9wZ~-RU_^0!eGSh&Upeu8|PHpws9X3 zh5uwnd)w9%*LDr}G)IE7!-Ma=^~j)jNyGclmKfHnKpyAw7)z?Hu$|Bx>4;{GbU(uq zAF)pxwuQ4%zgwrRHfoGcr}>mXKTEvA{jOEHm2tOGYgeeXO_`QoG0!mFU!mm>2zr8f zu?M)^i!D+Z(SS@N^Z5eI=UZ=cD9+aAY83q%S9P6HS+I4YL!VCX^*VH?t;xdr{@SlF zA1`$dO4c~E4>zx|`1%c>oZjHEI_kZJWbww)3o-_52(gR+j*pG-qX!}AC3-_)wb3WT zA*Fol=I3vD?4j4iYXN@z(OVw-%-e!bBgADz!#Q01os>ztB5Cq59e)^RN=?yC39oxbL?KC802r8@Ne#53WL{I>8*iLa*J=7+)DVys&M zTtNKS$o#JA0x4wyIaa{5QFIEBu`6?-xA;)J%(wtC8L%2+v)uIXm6P{e^Jnhi>J|>| z8NT?l(KVYB(}~eNVD#BNs>qhfUAuX%CVYQw*myQP-rqA|sc?pAx!+?&&>xqd4)j8^ zVLHslg*q=9zUt^}+;xf+Uv2R{^OE`H$Vk*XkgM12J)7P=b@-T)lcyfk`0RfOKQTDz zHh4`MOOO;*BbfH5F%dL>Gt3sqkk!gWOI7U1zUGpnzo$O`i&p? zRm@v0Z>b*rnl)`oghm4sy@OM>K*z+^^}(5zggI&ba*MmuO}n^g3#lsw9R*?wu*D<2&m7#`_=PuyG0Jtgq=Ly^J#U6~`QsUBy`#Ol@SS8x0Q zxH|F1pQLt8ckLmh({zsk>2U2~Iwrhv!=#{B`Dnh1>9FM2vulz~ZuPNjdwcenEx=tQ z?DIC)rh`XSj#eLBI@fg<3VnAwoGmvr_-r?=UblgqyiJ(sTFA(fk|6fkB(6(6sw!kA zk(9`RzqNVR=`p6G$-FBaj5aQa%<3j5`dF-1tw>w_zCv?6q19oh~4I7dk&DP;#!c?gQ*|CH#2! z22~z9Zq?ZD*aPe#v<1OKf5Jn0`Z1s-vl@?q@QnCR_xl&{&nW$m<3}$X<>bY;@uye> z-`F$%KUMz*N>WhuAKt^oAwMU31b;zW_^*rq5&kNZ?JKDSIs=eN$iyjPncHW+@~@!T@&tZ#d6clfz+x#C}Ztd;NLRIUX+* zK4NjXo0{A%i}d&H-~HW;4!M}oF(86Wi97EAvcSpCpWE@sQh@JUKD$F!vkbuJ?mAzB zhlF2wJrrmYgxU!9m#OMF{l5PJUB)|Mo->?x5%(^?1N^_9cM<14@Gka2hfXYEI{RWpuUXoT|Hs`J zR|Ue_Xh*uK-5$lQ>vgv++LQC0fU6Dhm{#qMngUw2HWoKrBW;QF_g!y|u(HYj-OAo^ zgXHh9uJare{$Ji{UGHkQ7EVoh{WX*|!EN)u=S|$D*7mr1e_hyk)uIi$;XH@tSYvBe zr?OAE)c;?cns>E~cifct`4(~ic@E8Y&%cPjvk%pf6C~9n>CizPXa63veYX|IKU~Ui zi-C9UtFWxJ_{%%=EUFe8NlEgep9)F--XciI!5VniZHum2x|nC*vAe=~1j+xhP0{O2 z2F9!MOIP%6x8et!_fGL76mNLvE%;!e@Ck`CovV8X)C=vRYGeZHb5*@d!TjGM`^p7y z*S#wwFF0+to)@h9Ab3QwUBA*L)VGb#F& z&PqLud)NVF@C(@|reE#dJCQQ@T;51x%a-O|jVA4JIxKdz!EDf(Hf&P6YV^36;K=$3 zuC2H6%1x@8O>DQP-KMs)lS75h+S;(&88tMnPS5TzsI>u?(deo( z*Bjd-W7}n%%3-#s*9=xCx`w%*clHhT_7C)S7FWjoiLk996mleN*cD3Xd6wzBNK|0j z@pnaiSIcXdzB`7`q3@0`y&fFxW%e|JN9^gZvZovI`Izhr*xM=b^SP=oVsr#|D(cJ* zbBCEuj(AE%o!MayaboFtx^#i*>cKkpJkd}UlJ@bv@H^7wS7Dw2H?fmoo8joU%=ZGD zDF~hY5H;W@9}GSZ!B_EH!77-b?9i{XPsxI!yQl>VODR}bc$z+^j5_IaW?8YB$)X~Q zhAb*&6|<=Dxq!c?01?9rJ+|+pIq@P7jUWF3@ z(Z$}wBU>K6dadpf7FTK>@}=))6c;taqU5 z{S1Bry3A6mThCv^cBTb%RKuPpOHGN-;TyvE!t+ws24&qwZWntV+yKu@-5Tig25Dc= zLx6S|?pHR>diFdeB>Wp2A65YS9JZeb|B#Hv7oOjU>`j4E=y@7nczz>N!}0w5_n_g8 zvhkViSht>gjh>(X96ry`_+&NG_}rJJ@$o#3FFe1oO0XW`(#P`{UwnRJ)xGTbMb;+p z@Sp@wnYBsyff)e!h39E}@ITDEkvm%UJi$+tPM^Cac=&r6{O}etJoK{Xt$^ochM%?K z=jG4S_`>rWxmNbPhdoc@3(s$?`V$*}k^KueA7#&1+P?%p%5DL_@H~yr>}9}tdD-(c zKH19ze+GZKs=oOPc@OBFs9kW~Q2WN%O3GT;NY47 zI0C!BQ{WW71ET{wa7V40@rGlo2EhV~9|ljL2r%6xa&g;WY{#l|w?HyNBIf6x;eP~{ zmq2D0h_xEF9zwd56d{J^DqGe|na+YhRF>!ZRg1x2NKEc-d4pObt2T`m7PYQ7Wj*A=D6+}seirF*x0C# z7}VgebiURCjlr^%RY9KT(9eu~hzz!%KyZRcudJwAF@5>a+KGReI0N)IO7GbvoB&F$}r;)~p^bLc?J@5(Zh@hiid#xDaR*=g5|N^d`DrRuCXy0 zPb74WbpwT_bf9~@RC}ws?D9?fjE33P!Wy+{Bpn^_`v;=w5tDj#r?aP3W18D|`DLv; z(A9kF)-5|gZ_V>-`Bn%%FcR!EW~W=3xl*8o6B1Rq@75^{etYpg?szHEQasJ6hK5@| zX-zAG{`wGqn8JGDb9bgYL){p-W&Q&0J8)ZD&NDDBq(v;^NL@9JTjac@P)qTrAH5~{ z5o?5v%00_MEyb}*LwTGU`~xxcAlM6>fjBi&Rr8GP2x5f_-kBJ}=T8W16Q`-X{m&t8 z9s3*#MMe9cL!1fYRf}v{GTkD}*$9iwXTcv|nK;hEJHJ?J;asv@5%RM3+3%Jw=B^e< zNVE-X0(HN$Sti*1<&<>CQs9m)sysfAv9+Yu67D)dm=V@2a1%k`5J;dVV&^z#y!qe+kY&$pX%Zl9>X)O>vEkk_8??l*|+WQ1X{RnG%i# z+m!`K7H>O@!t>JN&le0p{c@66pDSwuoQ}YCXT3>V*Lhp4ARR?Sr_J9Ud*7=5iyblM`oKcF9{|D zu}qG3D!ee7`gX-y4G1?*OTO{Nuh zdLgRu2LBS&304|iEhIL-Lp~iU>z0KBB|6+L0CJujyE;oc+fEh=xkP`*$qVniAs=+M1ZysIIBHwCz(oi49y>O-@6g3_`!Bxuz|lQm z9aZyBq4WJ)RZhr@7GgIkQY`cOj2YPzLYgkAm85wDahE8*fL224xjBfqdEQ6O>;)%R zb2>lya9PLtLx(!-HCy@{%+#;`g8~12%Au`+TIS%|cKc6@gQY(8#jnh8p3jFyyy#N@ z-2VN4d5Id=gCyJMzs3&&G?w6KmK3x!0VbdnFv&~=t)LSjF;^+0#C?kVe!Xec8Wa_I zo%LC5ESmP$Yo2IMh^0hh$rk;ZV9S$6+mWpuli{Z>$1NW6P)3t+y94G#w1kG?UO3Hvj#sIqpW!75Y} zwVMpFsNJ=>UV{>&Z~wr2<)Qs2GyG$psgEKt#vE!W9;p^9iexWyuMb|(#Jyh@qACkG zCP;&!+zv5Gh$75o;Y4GvL{?c4W-&Yl&u}?E~3<*M-xDrs^8At;yKm_Me^ERJ)-r*^GRM*6~cp;&vI$ zNL?Hp%>`_pbX&|92;=sRj+W7Bd41gKN%>o39U;o3Zh`jr7pS&Ez74BjELWK&xCENz zZf=`!>jJr!0Ou&vaTC3cCfr17~{c@$NCr@I*{VDD^*!}scR!^j^#i^%;|NjV3LV|KwdA`KUmoOoSkIgIO ziDu1$6`A#@c1n4G+%#TMO8(BBR~|oh!>%o(NbCL-w>k9+U;s4Vl>Vmf+#!42>=9D8DnQQ|}bazwif{7z%GQMZ3pZT-hDpbTazu%T0`wb%V#%#-Wr;BMpK z*n6q?Iv0ctA-+hNDjwh{MpzPgV@BfDNSs^Dry)jwGBa4XQfV^hZ%lO$T(VE231q@O zLtlxRU9F>y%KE@mr^6a|?+De_=DXf7Hu>(CC4%4nvHjYux7@~k;%Vc>o8vFnNVg$Z zf0I`|#ci6+&Y0exyFB$tBvJkeXHhIPQj%Ch?Zk2nl6ZApZKTsJ&;3LRS1PCT|zNEh;$3-j12OQ06YA_f!3Qkr$J=xq#%{0YJntD91lcO3VVk z5T#*xPCp^8Q~*$_e5X?(?7vz}=})?3no`ogh2{3+K)jOpFKkaKpP!QbU!jz-g}i=p z1HTN^z&X4a4kW8d`MScDP%H&e$XfHryvYLxKo{@RSF4@rXg(WBH#uA(lkEB%Qyx1O zemA3x9nQ2P;B$q-{@hF;aX;}(vGD{}pCLO)oHYnqhs>muLout=Oin@cYrz-v2^2V- zOfhE;f{D}wA0-Z7b=@9^)?r0equ-{hSE&9|RnzJCW4*E2+OYfDD-0KTeH3?<#;mEIQpzy*lB?5f>~{5Ihp6N-3(1(EszxDuxC9}= zV>AH1{9^c6`mi3?`Y`BN9`TiDE)AmSQg&9>Sz~!lU)wMHj?CfIS@vFbk&s0EbCq5z zQ<_!o_p)^2aAGr4HgUyLj_yQ)4my}9ew#9hxgVMHX`Q3nNiMhuI6a4r3$W@nLBtSx zh^+n^DPfE4rJ2}16NKDuDS8?V(#1zB)0-}kE` z+KpvX`b^oZTxeXe|43Qm3huFmDX%?q|C#d63&OPLO2xPf?K7mj6e;doX#-XOvCu%H ztVmL_`x-E(C--l^tc~sQkGa<{0rQFx zd6)P(GZ>jd3+oEsh$=3NDwBVLr4!@)n%325YaBk96)iKZVv7QuXLUzk@6NT}Ub~`R z@%}{Ci2Q+fOaW z<;er~94doKfH4A*+yoM$#1cTkHu*bU24u1_QaMkX0T`9GvRboZ17KU!YXA;r?&sJ@ zGpuMS4IBDBxJDlOp$L>7oET*DR$)3&uWTG`9U13~PaoR0|LDo4>dv;#l*$t~s2gRr z$c}^Y<0o#r<=Q*Aq1jE_X0RI-<1<<7VS}OMgQTuN9XJblA8OM1ZHKr?etbkwpr#84 zPad6X>#Y9wldr#h(G@Y`XviRaDT*l3_!EUe8fov zjl0Bjz`i9tgE?JXCl06fuA1H77HjQ~ZW(Rs=+0;H%XGS>%hRp8>-yDK42=$k`Zd++ zj%<89n(54^lXRhM+IQg=)HHIUV%0fOP~)T?FTYps{yW9rq83ZR5`Dy*L9z{{%^+mR;Zn|~iiV2Ys9X)*T5~X4;Y7XJfsB27G;DOw?y-*dB zq|ON9k9B7#(_}<_rCgs;EZ3vKoJu?WpU~Ld9Itjd71e`vMwQB-8kJY~D_^gtP7U`p zTiS;k3@W2ZZ#df#i*6m{j*`BhH`g@P-D{4UjV;FeYHZZ5LE~{1|7rT5y)c?~xD0*S z(3qpi#SDT!^XnLjL>(Oz7Ket8DyNPNhss-mH=`R(0TYOileWx;a?o((J5!=1?0;)<*b#)hT z)Ht=+K|iDkNO=X}!73Rw2_khr^f*|5HNX;w=5>QbCAn7U55Ig@u1wAQlc$+aw zQS^$6UTDXV>JcF> zzVFllP&*k6x$~Nnk~v3(o;MbciQ*SOGXuU1eulr&jxJagf{-MH^aZ&4AaIEWy_d~`6(5=jbDGeg#GP}m-{NJ*;nwFvaZcyp{- zjlM(b7it@8DG11$qs{7i!Fk2W6-tl6mG#QLL2}@MYP@siys{tC^gP11RopbD_fnRL zXduy95aefReh|Mft&E`@L}D=c8okN7-n=o^tkfuJ_I>*><=1NU`bL8XJNu(lTpxDu z{H-+ad(uC`gxpDo)nRThP-00voCW=u83rmt`eLQ&*AqiQkNh!FEuw1WpJAYq`hyYF zpyL^gAXwU>ATu$eglxII*iK0zWZD~$wGmzeI%(X>iCLdFWX-iiM3y&=-FR zw9;C*1FsDo>>at}tP>7LpDpVRt{r?L{D7;1Fj}TziRQ=>{!-SEMgUpZYFQ!bkv*ucc8(>IQ~Da`Ok!eP#4CFu(b1AL3NNB* z6RcsrsW=N5&@UpKrk)WJ-$8$be*uidg2dJm3^uLoy?_mO$9PIYUdm6AXiQ`R=YI<# z;D0LPDqth_!(bC$05&>v5s&6ML)h@nu$|kDHHZKXK~Nw8O%!WXttW9Li6x2rPK_FbvIHVk$r-i*1 z_F06e_#D`zAj}@cn6=L?`NoILJ{OXwH9;Tb;b%L=0bAVlAU`-mi zF$J!bviq)i;f2#_pF$o{6yI-3T5V~)K4Zi62GGcEIU`p*u)b>eMYaTnirfSG>IZ z)*G(D%lB`ZnAp_0X?$YiS?tB-%A2mg{~9Mze#xl@A#i65AWS`w5I0h zo;`<^AW398a-Rh;zzxim9I#*?)Y3?hE-YGwkc_}>xX;4Lx7fvJ517!w&Ca8bW}^5q z$bme3L9aU)_D2Ut9S@R+PnznjMeYcUEYe?z6>IpDg6mKm_n&a&1ec;}VNmW0jLQ1v z(PV6RE%!UDL_A!Oi=vO2>>}crr8O*QiXb`C^zWG%lJjLQOL1P#Bljj2&emE>%Ccl# zv|`Dd3m`lYmE<`^Fl)KAXGk;VHANc|VYM`ausXw7KAt9jsW>^yW(*N7+aYhsSxTrZ z9u;sY&4XM|iIbFZugY1(MJgwTmYHc`dEESx$v(KiG)RN`3>M}n=^m0>K-7sPHfF|( zW@lHAM$&Z-o5|ZKIDY?Q>+W3G=CUVUwt=qSO7x)R{y`YBge$TwM9&l{*8-AmwtG^I z9<$Y1myV3Bnw>@4HzIQGCtU+JSJLjXg>$>N7X5;n1VH&XBk09}CBgk!>Pxna3*E?+ zKv)V5bK63luI&boN#%`YJq?>jp=s1~rIOw2B*r&B(9+>m-0yO<7@95bzY0s@>^+fS z*DGK&6)@-pax-So9EVI3@>(;!2APP&Wf=8WGyaDoX68r%9o^jE$;P}YlgF^#)fw70 zSNwzAL)s2!Shub_nZijkN3VLnr5UZ!-1k@eI$8$CTbhU?oB!*Tu3)4Gq!^noaQ7kl z4EvKL9n%w;;(%Gij**07kyeP!34v!(Zw1xRmv^_t|6auTz=n~K$D%XWH-ulBT5;z) z#hRz4-NRv*(2*C;l4o&R?tytza!}?)&1OjxCJ5km(SC7g%50W$V}=y_YkJz_y|53$4i_-UF*8Xs#7 zz`VZXZ9h!?g(5ngu?C4%VIOO0#CwNki=UPnBycMi7>wAMng(S?MnyXY;sXL3TiUcp z-AWcmt2E}hZ%AVbf$t*{FVL6SprYn1gu6-05&Podm`Gu7pNd5|f0gvgV3DevOuKvznan*uhdNH`pFIfzW^@AHjokd%U-SBtsHaUm$0M4P$Y` zAI@|1xJg}CV{$8=@O!%^#=E@!ClqdzN~Ja)SDn5-z0zVZsp`?|Cv|Px-Xll$wp|Oa zPD8!QWU#DEUw;}iQ!K*DyT$wslG|)n0fz)bAjo((j<<&AGCOxz5TSdnr=z>y;)s}i zW?#f;Y~83jwEh1$k#kjiZhX4uq7`jZF@?j^zq_sX*ycPYLEaVfZ$XLl!{jwW`Uojj zjQ*6AR7Ged4wu(wxIfpqU4K<~%H@MdGyy*)c?8Z|Omtusv?v{%&*SHm?kXKtHjfFLZ`RQK_wLQZR0=`Vhd3pKU3YBO6u ztagz1%#q?ADsJH(24ox%ke1buD#_urOAR?08W?c+tP%^|vP_284y@1Wp4Zrg*xzWa z0bxE<-GCGUyk7|a@vozaP;p$0`F$D*V_JKUvxJ$Ch%JQR5@51!Ax^tETFL@@0?{$S zBbXYuLd4u;2*}2DT?ci|DxIdK z#Tt6@W)iZLB2?geuq{YJCF0Ij@JZ5{6dlR!cDU?WICSQ3z(n4=OA_pn=RiXTjOez0N#hf$t(bi~o$?Vld)lJyiFgox=cacs$?W;bGjpTO5K74;+|#Zh2$an`?*w8 z@S$`~_{wsrHpkY9xm15$noD&w_AO!m;Q~V*A?wQK5wsY=hRI3h6cYj@d{8_JI>>PM zF@Me4b+IkOed}V8=3H;M)$E(-B5JcFz7;!)RIR)>=Wu%pqrqN(dZf+%*MxGz~jOPUlDqzYIk3lR~4tug*O1r5rZ#7vtj|qatNJq>r>N1zDTeKY?0qjuiwXxNQ_#ei70ZQUOQgG)*$dKFNh|ctZR^hT zjP0JgY+F}4)xB-^K*wOug_5c|(A`;3R@W5T`qy#MTqKw)4rbu37cAnkIGND$O4vzp z<${M_R3wP~GL3?(AKwz~Z;iDbSi5>p>hR^`-JY%%1j#bFY`(ig#YM;C*$#EJrav?| zI&{VA>+iz#f?F>1rGp_fOiJfFv907GH~ubY|%pMhjAf9 z;~TbDG`c@uw5CC`wXCr=Yj(dRa>IPZ`~%nynCBH+!PX96JCorHCZyzkM_`8totY4{ zC5e+RQykTQhW=aNc)1n*IX?+PreER0`G6Xoopl7{8ymz3He!W3Sm}c zXfbU9{qF^3f*LW23Lo1XDNE};xz^!P8!W!3*Kbs<>uPFoB*F{6^|$Y73#B@_f5zoL*5`p?m|SBa^9ECcn8&+QPfVx5?%__#|Tj{M9Wj&xv*y;&0S0nD37DU zXI9E_CtH5DECg8DcwZg}mklPkjIY+Iiy_B`boFPembVSu{iPEMm*}YZgZ~q#TaLb)-_1qc|gl z=NE-LJ_;HF=!@305cepfFUog)QuY{DltP@Lmz95zRwacQmW7_H)U*6$t03GSIRUazY1rQ%ecp>DX@t2{{y6VQAB7l(o4;UmuDf162nR~rDRyQhBP_@ z^(GG4-ASX~Sdk>A3az6gvDN8yZrc!7KbUhy^CNzr0JcGA$T@_z#df26HeZo4))@*1 zDP_#*wfXxp&Zg!-m=X@LQ!xJSdb z;u&yfaBuE-BE4a2&}DNt)BQaKp#lB-g!cDp(Eb%T(S3|V1I|iAlIr=t|5ayoll5~A0R`V6ub0W?^@_G=zPes1m)BM6d3jj< zxJ75GnaX#`6})^hKV5pdy6&By=C9Ej{CU2@$U6Ok;8Hs%;7Ad{p z{`l&vtz>ny7VD)~I6e(9i+>2yiL!?(^K_ub3mPjFD$-IQDVFNcJ|6BxmayZpI*sW_ z|JdZzdD3=X?#%Uv$>%@2`g6-9?*I^)5`YxgL11ogR5VIK{dWNj7$=2WpP=OazXMTo zh&eRjdt51`6P*XHBA<`4hV4L7J?G+X=6}UB`UT%Vh|N%b0}hlbLJ%{5$fBsPHo4r! z>f^l3i5~EF6Mr!RYXP^3=UkQsJJ)a1SgzCh@@?I>*&8h14o0%ktE?I_orURsL*)c; zegf~h){<5$4F68k9t;GUtQvz^C(La#$%MI$GGT7*aYti=p|RN!2rUBvKmS(M?Nv8o z!FAMAy~2noUp`o+Z+nBu)PVmktM&!lM*fnKHvT~^E~B9T&i$7?(aTj^HP+(uGX*RR z<-uHt-$U*vVPOJ6iP8I#(I|zajJ(MWbnU8_sXdVuHcx}zVy|->8`&DZfMGjr!Kl8; zVv2Q|w+g6JaTdGUg3RQy znd|kc8t?7l)=Yy_Q#m2PD8AyvA+yRAU;quk;C@U_S1eZPg&7c`f(pc5qoRpfKg@qC ztkEn*s2ZNHHM$dBn&y~EsWEDL9Bn;=pTg{JQ_a(DZ6UWCYgXGPHk)i#oqkhwI1q|i zG`26=)OVh2Zn6Zha*lKHNlZ?y3czdyZcEOhB}_VBSxn&zDt)OMy`F!l#-Nw+E`?gg z%d|BE;l3K3j8_=kiirH?TD?KFx^=_m3?I_hTdk^wYK?NIDc{hLGClKYW46KBzO4}# z?pSq>zy9rSfJTOWI0R^5W|JZ)D?von91t2LGb#iH0^>eQf3NmLZKIz503WPQsPr;< zgEFFs@Hg^$eccXoxUXID$-y+IY*bsV_PY9g##W8a-*{<59VTwHzg_4uVP-4$!>ad! z3)cV-m?dL||I(_`$k)}Vye964o!7V4<*vwMx9}iGwS+ZQy0S-o(r;dse)A{kMd`=l z81Q4ZaZgqKg#Q-&n2?d|{gZf4P2Dj)fN}D>Z< z-EMoTEt_py^t`~|aqAPEl6&b{`VF6>M^p48Jg?{e4Hb7J@M*CkgDVLJbTT*k_9U9k zR=d^dZR=F?cU1ob$x0@GSGAF&6rhv8~*pylHYy6T1K%;as4d8PMKU2ka=TirxD!#8N*q{wG zWxNS<%Fs%;qbd1Lu2!6z=A~caYA=NDpn!POhLkOB&zOU3GJZv3Piz0Spa{&OUrQ59 z&>uD>eHofBXwLYOM!qvQ7ZHD{!lLl&5ckKb9Y7#rD|#eQh6>ytscSd+y7?_tzd^e# z1sb~%jV!z$U^Eh%Z?D<`O&`BBvtNFlen>;k@8!nkA7NiWe+Y5j`wImcVBT8woB7Z2 zO6n5&_7^HX!4z+Q0mQu$)W^ZW!!jK8!Hg_#{>1vHo?5@2yW{k^H&0VUYbAGw_(&BV zz_<7cV>!`%?q2Q&UMat#s;YRb;*%@s6Z|w<^&%L~1?crwg~T3++Ke$elbwDsyJH98 zqT*}Dh>WLZelY#wj%=3N2SuwM!mlrZ{KBvCHT%5}k6?)DhvTIqLx|?D;jULOa;apW; zfiy=YwWLol?#=8AV@)loDYYjd2No2Q;wk7P_^AW(w3gqEsEMQo2$~=QdCQ(*wxlzu zg0s#Q^!YuBbaEh{NH&>m4v!-c$s~q3i`(6#bH$uZCX3z>3WlQ|lh)w$SWV_8t)V55 zNMKfIDcmK%CXy)`z$KTZ=*KpjA@M}rfj%l-TF0Vzh%rhrMVX?>u3xoEV{52+ls9XX zH4#rut)YRxO`*3L1}@ZC>uWwH3#u9-aYbE-^fIk^+ujB(q>oZjugb;h8f)qlR;#9I z)$vA+#bQu4=yM%4##%k$#tA3`K&uA_K@_;l;)JOnTejhI;yJO}>_pueH-< zs%03z+%}Yr4@a#Ay)T;a#In|V?F|?oPAhH={}d$lF_W1h7nX8E#GSuP^rZ^A|Ay zJ__)(1fHHMWIR*-FBbpGYm2Y-mwij;7hmES81em!ALaJl(|-@X2Z!ON(dQ5}X_gu( zGggTHcDJaS1KOaY-DK{t2OEOEx_?KOv|g=CH#MboYJF>#uF(wf*KjY(>^OOtf^|+} z4D6xeh1^5@uQQpwt*yjZxF@-rn6yNyJDXO^waovLWQTb%2fovkXeQn*a^SPsohg;Q z!{h0&OAHv!ed))GnSTg8J5@Hozo&M!W_P3)ct7HgRc~Xt(##UyCkgUjVDJmGon17P z0Cwre_@M+j6IvlrXo(ZPu&@Gxhknf2s(!6 zi&g&&aahMxlQNA&R`&XKYjd-;>_7gnyE))=2AbW)HPS1FqOVu|ng0Z%Wn$UJ&?^sc zU;jmx?o|nKW#8eH7{BtsE8N!~&;EkG15y3C>KF7oLB@o#5B%bBCPON|!LLFOWV4U6 z-^?euf1m#$kuB2as0hRuN}?CTcOKob<57ku;d_k9zNc?*!REQeeB?Jef&H2?A1sNZ0yaoEu+#p&0}NuM)tf2n$ke~lf#AowxpIPXg@Ch0pgCFTm%X`Q247@ebS|;`{^kw-|&st{u)q7X-QBh!EUw0&3ulfTCw)p-wWh z6u6j|%yVY+D^ZZGbW+<3`hZ)p@{+ERYu3(e$gPh0HU{hJLc3cA_7wUqdNDj^&t-G5 z*o^IZ|F)pq9_{eu20g1(y&t;m!Dl8{6yn`3TO_cfJLziOF+6;*lfNQmj{9RRk@jCS zw{F^x+L<-!^{Wx@`!gV(;$N={%HC7;87x=9t$yVReEv81(%vU~A0NQySZvik81bLR z;?QT5e=iP<4i~^vq#SI7j92DZ!)lafJ)}Bxd_Qz?J+f=lic>e;cuRF!am$T2U#589 z_>TIGBgI{#8|!wCoqbbv=Gtpd*S_|e>hv|&-co&-d4 z-p=cRT$qa)8Pr3hQ38+P?<|!woi~>n{+;)MWx&bGe-;fbtdus514}L~hJ+j-qa76!w}t4rV3zy_j?C-dgZn#o z-8_04ZeaDDV#^9RGWD7do!of+=;#d_FaMARhpy%MtB+oC)m4`qy&98I%D4(Nry)-% zPAmBb7#+c45-l}m_x#uT9mg*@aBAxXn-j=}AIE=6^_eSgIdkTgD^F{FU3T#%nv(~@6oS=;HM*#lRHN$7NUA#B#M@H1HO_2*meL_spsQJM0wWp_h zg+AohAL&2XH!MUGQ}bxR%BPvq7a*X4s65Uh?GIws7+Op@75mg8r7O)HIpiDE+UzQa zye(|;dyVbAhC)Z6+aFE1omzil?1BiVzu`=sPNnLMTP)n}i*v&ntJWP0_($SRfu!E! zYIO9I?vAbDeK4A#RtZ}q_?M-c109k=$kZ1WNWC*CYX25pTt&Bo%Ub1u^vG(+Z0JzBj$5qyT3tG z+9SN!Hz_5Z#F$}TRx(%S6J!{$QPRb z8y0atjxum1#x-;KY9Y?-6H+pWnEghL3}`YNxL_!*j|>KjAB$wn9no65v$Gpo;09;P zZwR!W4fZtWt~*`0ansc0bD4p3O_O2$@W6+5-?%lp@8GUrb3`^GESKK~+VjEf#duy& z8AJ!(>YyLH!O~_y;LS~t<#hD9E`sA zQMIN1RQ$Sh=#JQJq2};Fu*cCGUG>OihaRO{zi_7`x0iRbv{Dv7XVV45|7V3(TVHaz z>pAWL%_B0E@(Io3o_&2gA6VBl`9uGXy-|%tos4B*jOSzdRhyJ?`O&Xmx8cbZZ0JAp z12~us|IxBvoT>>X^_sa1r#7fxJhMAp*xlDs@GE7jccuqIx_=oMX+joI4_c|U{@!8N zDC!au7maPW|K7pl)3YbKogwX-RrLyuvykCBcg(eKE5zn5xIiDYEQm+=2_POJDbP)* z>O1^fct0ulmLUEfM>s&Z4VfkTFmZ}3Y>CW7;8`^UoF34Xm<(h&=SZ&V-&0r!_ciC- zvVZ-Ve0V}gu~M~NwtXR@=bN3(UAS4yvcj%}aGa=60rZ7vB)Fvz)TP@Bn6kLg_5!4> zcx`ynK9|sjitRo??mtRBkem>zO=cwb$D)PZeq5%kgz$;G1t|1?Yg23 z2q4~q8xXPJhuDItPB+02@MZR%`sahe;(I&^pzwd$b>KztF_!b9402RU&fQX25gDz5 zMJ+O=*Z$>4KLGy)w=Mp{><;}~?#+PLEU~5HC%LuYEbPYZ7E_ov74PSEpZk-*qp0k1 z9y}_^a6#uv^jB9*oThjOcrs$jn3_VIIO>W|xzcD>=}~|6Gf#X4%+cFyXsiuedwP^l z#6O{|Z}xl7whn~0UAdaos{V#EA>XF>{gg)$Rn+#4bIIa=bL!%{{D4>CPa}o>oG8Ss zr0OCQAL&BukkcNJI)oHNBQlTk@W6_CkP)rXWbJ%*-{gVILAb5sdS3R;Z^(9YZ~A+J zo#W#JuCA*RFWj;Iy3w0%*>bWgBfadRejxC=mo+95w0QpH;{HT!Q>L~ZtXs(FOk~V}c59OzceU%cW>~YL zb8n>3mZX|rre~4E-Nkx|gdMU*wIS}gI5yNh-n&vJNAIHI|4eV+G>aubvhyq02C~L^ zqMiqQ3LYbmuq4L0NNXaMoqbc0ow){0B3XW>-Ew@F-L*7B^Nz%oPzeUQL#+*ohE(&S z6ivEF=6|?Xd6yt%&Znl4Qb0$E3s3LQwI5!+awg`}HoI00I{S0k9;3N=Tsd@V)vA*# z(gBOh9JL=i(7Qb!vNRYBVH-%*kCmFR(nb=K6%2~4(I@J$KAm6JPd21Hc=8_+~-croGx?X@(h5g0Vmz!MXdL!)R! z=lXc!uJX~JOMDjx3-EH^V>?p;t)$wq5lPoXqL^Q2cDtH%-0h%enay>@^UvSuv&&`a zY7i_p=hA_O%^uKj#Hqg-Ba`&_1d67RQLh*Fmvv}lG%89GVfV$&7H!_#tNZs-KJ2#H z3)fF))9Fw;vt@I>d#F3#uh;n}dC$6`d>;~G3(2nOc7JEOqfJbOjR#%z=)&owO_+Cc zeQXoZgJ)6dCAnDzc!*E8=D0qlugac=P`Il}Xv@cH!ixV0V&Xmu_J8g=n~%Ek`D|F0 z0m}+tY!d_lJM&Vm1vD;17QurJ4Y4%P|2u7y2bZj?;<*XV4#Z;zioY71bO}i3a~K0k zNCk;70cI2(sc=F_Zd82E%rHU0_G`@^k6HU4_;aVBL2Y>6p0*wMkP(-DXA3^VRc-i= zcI1FC=a(KbdTN!m;c&ol=@0DAaJ|F!bm9k4<~Hk|dq95obHd9X%S~?i2eXgJT^2@bw6fws?ry^9``FA@ZewvZ_gL{s zXCti()*au8xuGNp28B3dhFflG&bI^p?|*7E!NjP!8(4KWXnpLNUQYZH9KXGFJz7v3*j?I?^Rk&Q~!chqjg)y2f>8n2(opP)TM@9xK zI*k=}tNb}jU)%VyoyPQ4SrnYqh)z1m0tST%6@zppy$b!3{_31tXvluzh-eZ6R&&)s zet`QM(T~2|(g2W*fisAqP1BhVm@9P6(9Vtf=0sJKGY{-86n6HCeanJvuHl*Z3Y{)6 z0nuTo8sksFWnqNJ3c4ShA&luM%|(S_%52b<&3NA0`LB@ zTW_*=aDQk%d^kR8pE-JXU2E~vZthiI^YKfDvw@B_kMG3M;Z$>>_|Kk>@vhpsOUJt! zUvV?%%pY+}nY+pj7KRLVLGGfYlg+wV$|AT;-XZ0_*k!$*t1XY!O09`}DB z$)x*1h>;1tqs5n&K$Hmy2$ck>?!GYJ*B;7yTWvLU(PT7TSJRz|6mnznEn~UfTu)E7 zLm8Ot%1`U-2E8o_y)I{tbrs^SR6}pn(Ph@I&ZTA&iGl9Iz(Aou7F-)5(sUFW%1A-5 zNQ`ayi`IbjcM{oiHLI^2UUB7`{ijatr(Z#>400O!6+1p+)@X+0Z(RD#&iqBH6<4jM zpWw&D-mw|PTC58qQDnkQ&L|{Lgd}Sj>jF#p>8>r|3#P&zlPA~Lp6QCxK_-E_W;`~X zI_7K$|FwOf(0&$bAN+XS8<@dI(hI}n8FIm#1SU^@9D#9VeM-t<;Bb{Q^Bk$H5$&!- z@3(#^aIW8io3!q3^yJcwTvH;}m5zr!mIpMwg}aHF+=D5>sqgxT-KpXXuO`~^=mc0f zw})B7P-tm77!&4F^PdGsg3{z%{kM7(yR;E&V>;()+}#R6@LK_n@5T~6@$}t=KJ^1u zcUa|ceB>@+?&3XmT{!+KW~Lw?B0t;@vFruag`Fv1UQ!1HW9nFw**)xN;?Wa=`vQ~! z-s~Lln5-mp?sA*Mcia&+yNgG_DKVs)u}F3&Id)j;Pf)@m@qY5~K-dWjQgF^K`>|KQ zg|r#Uc6YhWjo-w}?aIk5oKuvdZ*;YGCpo|P?xz#$Fb`M|JP4CS5q@ZpG#Iu>Y^GoK z4iixg5I-PXW=l;)HjKUkAY!M>T-5aC8^)l*kdbd?7Z>;;^Aonf7WHZ+NTAwf)qQ;-8?MhcTY<3lgfd~b5%8%K=wU?ibS&u>v zD;dcW{VB<4m-$vGOa|@Gn}RZaq^6#iSF4m43~W=@%jMN|ie7~xW`5bEwbfiVeW`r^ zYVKPGsCG_8o$~kgn97tebKS)+n&O7Wu;GV>rdq&cvldry?%Z+7>jZ*_qC{9f3vUHLAD_ViOs)-Dn6#%2nqt_+LI+n zA`lIru&D?b<#Ek8b8aerF);0G@fzd)Y!|B69=_UR;klHYoBHhj-JRQNoH3KbX-T_V zd(+v#$JO?_%l)5ItR5_kcrcXpPX1gn5XiX+t4S<2f z&eK4=rBb7<_V-dN+J$uJ4uElgIf ziX%ChnJf#^j8!?13a%yFRLdjx!s9i;(r9vj9~O-yFY&i{AIT3a>~?|C|Nk$Pxhi;x z_$#ptgj^&$7@N%8aNu+lToF!yAWFz=Ar44_fUGhxI`M4?MBUUXr{DTOOP>mY=GQQn zReg~MtbXUJsYVzASk>0#bZirL?CF879}5cqkGlq*#uuAn)5)z&u)z)YH#lX>9D<^A zw42?)DYe-a1g2!O@}$6rn1lyL^-X7COm5GuDwMm9O-R1(Ai1;1IS{ z@QT4bT(8&H-;uc+?6v08nqal{lTC_9L&M!+m9Dy4Q-|c2MhCaEc&lD*u{dfp13j8L zr{(cETf%4z*@Y?jX-wHdz58g&zv~b~rk6N>9!iSJZT&)qo0EBEGK=G)2OqrBX_w2r z)y0q0m|Wbw&8lES?e&y?li)^VmN~1z@ge8a4uf^hqOnf(G^(2{kG}yiY{865+goD!Xiq*KY42r8KU@IW#jW{J zR3%eplb8HHh;C^g`fe~4k3Effb(r^qn3pccffrbqmu0dE3XCu}h>zIrd1fCP66Uv? zwy(B0mO^lw<0J#Y6|8rt6W&S?c((M<00ib-TDtJkr0BfBR{HgwhxgAlOyZyRg#{kk zz2g$s)(soB6z?R)id9{XArXBpA5dayMy!WU68V3`LAhZ$ZJ_fY+dx^hw;E$>tXvT3 zX=R&+v27UplrT2u2~x2LjwsFo>B@qLDls7LRPiGiNoIFm`QU>GofetQE9ZuVRd6mE z_G#yb7%eo|i?3TX#MO`o_agsYB7Lr0$6|&cv4eD_D+4A)^^rqIkBkiaEP>?e6{8za z3}xa!A#1d`hd9e+JGWgPaOlnU^!AM#w<)(_A5ao-KtodzlKuk=A$_8p+!zBjR_7cQcHawF9o`E0W{mVO-({Bpi}*`xq`$Y`DkS{@f~r>m0fk7H zKp@OnHcM&LW^uHiuusV3Vc^cv0XSelJ2d*X>Ey#AO;6oZl_{_Ljop>cW1jO z#g1_CZHxh(U@0Vm?~1f}+F>)3Ea1_>pcut3gYibA#&x?XWZ7~i;N_Ijgj0JsFTf32 z3JY_7;x?15R^8O(v~BxT(o$53ZidE<Rcj6XPmA!_|sg zbwAu;zY(TDWd`>naf)(7OD`E*Y>xzDkojgX;v=BTH;)TPkEZ!!oWIclVg$ne!j03P zAJbZZM^o{mkO<8qiBmz<05Eyi*8~qIW@|s_`%fnwp1Y6>QvN*W1)DBA`*y&QiZjf zk{!wR_EeU$42~pv)atY|<*N@P`8gG`d+JkOd#gs>o9I~S&bMaT+A`^O076852v}nt zhKj8@kB31dwa>1a@=W%I-9}TWkcsD`3tYFq(A*u^jYIv9$&PgDpT#+zz#M*(q?L1! z!m?a*wUR?aobJhwIiltbI+ua34aMx5^*OK=ZF`F_u}FYN-BD}JwM|Wy8ngMvQvl%g zL?Rh}7G$r+B1w?K+83vn__CCKK{}OjyT;`1v732_ZSEqON6wp^J#G`KUWn;W5>F=f zT>Pv+Z&dB`GFuhfzzU6w`NX8KslYo8ivO6L#o2iQg)m*owk#Gv^tXz$bpK!G-UGbR z;@TV6`%0E<$&%G&^=e6$Y)f+QW$pFaUaxo8-o`d>XDhI*9lzb-7p6&9UhZ(O)H-bC zPgdb>*IjPXYK#>V4U-kPU>Ykr#IkhN-A0?E;Lp|0$iV%R1fMCIYJXd_#gXn$F{ zV{~b&BU$70`W9!qx~)!eoxaSG^dvK>wv^z`V?Ge~kg!5pNWpi6J@Iq<(faCGrhY4mVyjn87I(QkKKJGR9+9xJAP6k|=8CA7e^r|)8-f+Bcpl{KlzW#p5Vj;mTiWsvVw*9b3S3HRlS7Q(; zC~DCq;WfJ{BDl12`DIK>5bjKOjurP{%nJ{Kk=KQ6lRsNoR^Hw*T~pZ;VcU_vCzu$~ z%>heI&BzkpqAs^ZH0Z3U#okl_Ja=Zh_5nnd0MR~}rSo*G^hS>WZ%Y0P4Gs2%D~Xge z?ag@Bq%rK4ycXfOQI(1J4-G8^h|*Y>;PzK%h!T_I;%Vk)y*IpK?V6SG!M^^!zJcDs z!tNA@rmJ%yG6y?5lEgC44h{9w@S+%UXpg~b@<4gX4y{$MtaZBkQQ^$!$N`UccbSh>ukqmX(iBX0|bTkTU62a3AuN1^1aIFIhOx zGSACrfz&7c@Rhw-_MgROkt^_dILvbdU@+|*=QsuVmyM?sfMo|{L0IXi3SIB2b2qHD zgq>nVJmt3yWN{ziA_E=hxAZapq|;Rn&r822d(`D|>)f8r;a2c)Oap=)Y-mm`IOHe^ zJUiih<0Pg-)SqcW3J@s#%Ua>|`5oe}a4pI=rUfSpT|Z=sPAnBkU4)GN(}f z=qeGE*ow(zQv<1%=6d0orJFX64~&fsu=cRX6`o`5F>>)`?0K|KH@DOctPtFrmi8~j z|0zjWIY7P-bB~c&#DOl|B4h}&w}=F(Cpc2TKup>eyOZN(#hE&Z!w}^c1p)XNmUzu| z#An8_`=Ii~Wc=0EgrO6-__+eNp|jz}1@jSlfF}UrvJce(`GSIE(xCVk4CL;})i+l9 z40e5B^=hxP(hf&!!Cl+@QgwyFRzDj3mh4-NPzczNYjKF7IA{H_lgo5{5>5eu6#VP+ zP3?4y{mE1!=oAm*&u-ukNN{`a^_cl9$#iSoIdq<9FmGTnz)b${$HWVnYQFexMEFT# z(IMflNI25;h$0;89=yX}<$EKQ>C9dhG~*KFukyCkIE61lUa*V%ys>1Aeg^Eqbh+9} zSy`VE{T`NW7QDhRrvcJoQmE|N z;haxlKLm$KgT_>43^@I1_$g)^JWju1s{DgGXOpbh5%o}co`mEXdcsU$xG;AZ@puC0|Ao^&cK zPG=p?z{p^>)e#QWwl8Z>M5H-ZZSYlCTxD8A+F^EvYhnpg?R7tSuYUxA>SGz3 zXzFYoSQ0Zm{W+v|ZmTS-Fd@m6Rw&n+>%);mrY=9@f*Ut1#*YfE!Z6+bT?v*U2*&#? z1RR?}LW(d!P|{v*Hu=IXlipJ43s(nRJ|`0A`r|=&pe?R1^IxRC;J|s!dbgvx@8J5> zf!d%)S5up;S-&Qj>vsbdVWC=BA-;`~_?4+(Ex@;-AlW#fq62NyCY)>r5|@9XXVtQ< zP-Aa%Yq#5xi1-7>nrLpIVI&buIXrP&syT~OI=Z-Hw8EK;#WS@In=O#4c9^TZ&d`#M zhJkQM2sL^mxkzQjKwBpz^A69PAv__B;3WkXI#PaE@@BvRaDjtL$l+2K_CouTX7ZG- z0bMxO+)*aBBs)jetSPJ1b~lEiCYSO0tIO6bZJoC2tz(P&Ml}D?mYrNOysP|bVe41* ztDD=GRMr3;t5jDDXNcPf+T3>)cLD-8ZjI1wl5P^jiF;H)UFE%U!dRi#!IIk(@_I~z zr;L1JiNRnjtI`=N-4<`e)H!k8cOUbcb!q@Tbkl#lb(hd=K>zzVBo?oquY z?1oZ=qz}e9_^Q+eb_mkN>F^#& zu;20j)j6KoFPtF^GOEe^`TuXv@%0~cj$N?5-iUYSX0rGo0~YDH(FrJuAfQtORr2^G zD#D2<@ps5j>^SNK%kni8wydtFeHb|x?$!qel3P#DKUv7lIMF@2$6M8~M0g_C-PTj4 z=k_6#&}j9hXJMYw67wEA+HhdIY!)`y8|#b;i&t!geYH6AChVx-Gy%(xVK&X8f`KbP zcwR01PRET;lBd<8JIlP92ceijRd3VF4!}mECKD+Ca)qUkajUX&!K|u3F4HmxVKHh? zU8yN!8Ms7OXR4v~lB=%1wEw{N9jBhUbK3#n5k;^)S22C1lHx-apddv`Szkc%TOLVN ztyUxqGZ>H|9P?LZ43on|rlb&xQY=e%Pc<-$FTh=ux`a}Q`C4P@{2;7jlz}b5$^Qz; z?n`Qsa?3Sw`mUTce~OrdW|cnDN?5;~&>ig)Sh zCfWjg=Ch1KDGGOG6JXAQt_E(5yw+=yY93tQXq{<|dp9xlxgArMzCX2Ycy9 z@1Zn8*#>KK)ndb0-I;6p&y2I|RaoCG6)9(WqH3GcBx%YJw#?%0nGL(~Uwo| zcP3mWXRrzd#CkZB)kex5>p0$midee2TI@dFn8Pye)@Iz9Rr>XO8JkXBTT$n%TVYy* zxxY}6GTp}JUOQN;PggD*fZ6}dZImxrBg)0 zOZhyTOmL}qT+cH!v-6^X#9Lu0m(k2%&^Gwh%~I-S3cg#dE$0X)JkJlpB_~c?mP=SN z$%7JO&AnNUfV?qn0Z0Xm_v}iLXT}+xD?;Z-2VLfzEc2KxzVdl4(D>=Ot ztxQViT0F-L(@Wva&j;o0DVgOOMG~J6nw&+M+NYZUU7Q%s@Vmkb@TchIf*ILc%i4)J z8Hj>f(OG83QCzphNezs{Al`)xX_)?8E5V+=MRDy1;Z6sFQnID$@x`1y{ff~*IdjQZWK5ip zGMFr0a}Mn@?g4n@6<&nh4!OX$kN`?>W0>bEF`Xku&Uj=zp`+h%Nl1Od;3(i8Y^8SH zk@OgZlwP>tV|r`h$e6?CgHovE~?`S9Z4h0uWo9lfW^eoL}=Q*dugLR8jPef zo2Fy^CR1ZL;CK6s_G;XtPuWlz)Z4wT#KBX>FS>kWBG{7K=4%W&+n3~$wYh96tu9}- zxU;9CqBaz$vs>d~#STxw*4Y+%takvc{hk^}-bnLliw#0@)<}x&U*@nU+G8OH8qSbqg$BO4arXIvU zJ-2wROf~8Ks?UVDK)$gt{`0F|dZqSGP2ne#G069yjK*OwLXsRL;iiPrT#{dYOQZ;v zFt2LyO;1ayC9eJ24e|P;XG$Ir?;JY!oP%u*M=6)Y@Fx!iwk$dKQ1hng>2>R-r#E5A zrkNYnpMhAjk|giaIKvdp*JYDI(AK18au^dLaj0=6BKlrX(Mlm`)l9l%9;OMMX`iQf zG=%pkNcV;P&mjv5C7vawN-;k{9$9MI-`HX-#AbCiuR3MK`cuTG$rpPXh|h$2T~w0aN=%0&zF7Ix{R4reJ1k&|}ay3PCd^Mg-$X%A(7 z^{dQLIYp6Lr3cI01j?O;Ulpjg` z+_GEKkSW`}xpUNs$1s4jX{=imG-798Pp3M6QqxUrQ8+#GG0qRqy*zXA@m#_`VA}$Y zBEGg|+qNzFRa-V}*a9rn!yG<^g)%otyt>O6!1j99zer`OWdyO9NNK5L8vhr`E`I6K zZPT0gZ6r(izaWp&wcIBDYWZho&-mJ9o44*HgZbTT_;*=Gf(9bX7#{qBtGno407(uo zD9NHku$n!Ius&O6t>9^u{9&zN(1!gytx|*!WWwv_)LOeoxDlX5I2wr8{3 z6H;?dSnwJUsWKJGRQRJuEnTC zsdr29?n1```4hN+6U!nuYgJ{n*&V8%l0+JPE+W{uhN7j%YlXIKh-L78#0k{V2V0WFv{gW7G7@0)AK}PmO@+`-`K~A=$Jf== zrZa=>eQH4&5uatU|IgwiZ~rJ+?Z5k(A_$N6%t~=jm^h9tE=a4AtZhM#FaE&Cv{oP) zl8w%+sUtvF0tiZEoG6Keg6D-?)$X50hIk5(vf_kmo08Q##z0nNG|ZoGM0%{!L>>wl zHwxz3_?>O*nYar7nl7OplZ7^o6XU{UY5Z9;lOup|1zm-=pO zB?8-(bS~Wx3l~MU)>&f}68=iuoHI08FUc;5Jm<3_6>&OZEY9n~ISGsj|Jrc|l)fp- z8PD&g!q*`lz&h{(Gv7L-_plFM0A2bNx4qhk;=G>CvI(PBF+-NLc=qh%aMq=5epu4IZT3}-Cua>7H(Ul@@p>j@yj4OGyD4Ymgtmo*h0==5g>q_7ks#-+5;$(Pf_~l9fgS zE?aX_XByNJ`%+`Rg^+Wi; zY#8T~$$nIgYY;dr>EXE%U9Z^IhMNQ9yWki)HIWjbS>~RH?==KNJO`nvTBor%D+);n zukOo!L*|@0;VU6WDwQp_8lI_H?K3sBRLgny@}Jl_Jb}F|lct1VHw>>nYFN?PXBApE zh*F@t(k;kQRuQZ7pMSoy)@97ABd$urqGp3B=u-cz7)BMymnBYVB1o_)9o%Ds1ou*Kezgn$1-d#R`f1*u@eL zCneCGKcSyiST92}e@{tjJ?ZYK0An$!9!uv3z=?<)*M$h#bRY}IppEo+giH!AaVhOD zW53K+iOG|YMoc0}Bish@+j20Fc+!|k=O%L-H~1pj5YI&$ZO=}c%N@xU8;b;@pO{=R zu2FZ#+7Q1wrHsog9_!G>SY!9ENGYFit*j48ho}6mJg1 zXmNod4G-I8t(Jlx;PeW?1Lqvtt9)*7+KrY9LrpN5bar*}2Z!q~K5=dFh2diaHj~X4 z99+4mvPybq5E#}~K?j9I$b1TIAHhH_H+Nz}+1@=AZAB0n>IHg8cAcz^0k{z+16g)ylA)HXy~IU80`e8E4-D#L_=F+wzWN(Xzy(7uIU)< z$qwM{f1qvgTItN5i7iZ$c{{_mS)qEI4ra}w*hC9E1Z2u6h%AJ_(h)g?z=3mGi;&VE zoJM?}-{kZx7LWW$blrvdr;8vG%!dxQZdkK?8>Zp{pgsq&(LmTV_kFrlh%7~`d!-;L z_ZH%&n2Kkym=^Ekr4(1I2U12Jih3n=z&=GW{1*WUDB`mr zyUV-NkgjR%=`V?Y(YZr!F!U5R=HvdvNb5YYFoc4^dTIXZeoCX|A z2a9v4NqS%=fRuHa;hLn^U2E4H8jHgp@g@Y=uus(s>vnBRB;BEOrmH1?!CQa>wn6+a zaAb-`$LLSNBA19WM^pa7HS27%`y@RoLaM@};-YTKt&EUi#hut|EDKI5o)Nj1dwzAD zOSu;irKMx@1fK?pDRQ7UJ^_VRl!f>s;-UoARoUEFTU%#ab6e*pZj$t5+di{wGFUe~ zp6annQ2NH;%8&OqkMy-QcGlj)lCM4{=7wt;me!f{g);=i5!G;!D z)&P|7hBp+J)U$wQo%29*m#DsF8 z30)^>i*2Ny=K^bK|I+(0(QTL_DH88GJIeHBHm7n-gQGQ`N)5DSqMCBe&iaVW?{08Z z3wC3*!Pb*fP9quc`;n5b(GK~mJ!myKeIZhW(rTx~w_ucw_;Kv$(@h+TAH@D4^$*?( z5cP-FuhdtN@g)3$b&=}HHgk>L-|1e}QQuKCrd&30dxg$kXHQ4`db2jHbDHZTf#~TY zUkvv3^!DKYeZVuab5`0{O9JSJk>eZt+YOcVwY7D%(Vm`2$P*4lnmf8X`)mA>^pN0= z+pEgqw-<(cb;MO2js#-PNL|dIs8H*Yp;R-*B_}99Y{AHkkiX9^pcNs8LAQ2iu&b-L zr70P3S=~Oz#*HCoHM4$w!yjnrsJ5D|)<7a2^}oSva#(7fXQ0}ymfh<3i!WDONgA|r zB;)Dp3p&(?Iy*<&$(Z$oZxhUV?)IJF_Ny>gBQJ^sDahLx!IPeMz?@p zz20EDrdCv#aB|;)n_(VLUKQ8Hy_V`)*koGFL7g|eY|YvUxL6>a&EIbNm%iSffxf=} z9)cF8as!q_FD1>bii}T83rEaACQV#-!(bK_;Im0dm5|uzUgEDkB8K8%XhJKazJ`b45=FhS zMKMpSjlLn?;}X6a^~?|8%=ruh+drGvR!$JLIvQ!nS{P1tX*-f;uPxv$Lh$6DBGvww z;2_(xhkeaypD$8ovj%`5Fg1p0{hJ8Q#4uGMEI@JrFmK7#!w2#-uL~x=EQgZt73_3g zU4&`5>S5LM=piKGdK zF>*I-a4N5~q;`L;@Vw>v*qPor=$ zhGShAU$R(7YA`{Z&mSTG_adGX_5Klgj+Q`gql zC)KB9lH!wMt?d2qbO-*sv%jx*fcnE8-HTHhV^3(Z=LShEq*;)aL1giOW2xhNrRG+< z5X;acaU(eAiVdHEgYu_v!d7fpy9$JCXIC!Qm7u?_!&1gHlPm5j@1@6Clcyj5STU&? z!M}trX$Jdxhsw%^dV2@UaQghJhgqH*yi^fCq&pf%)(=c{0||;zb#@KjQWID(>rF?- z<1pK*^xlUvskI-R{>GMa+w>JivjuqkDqy68DK)GqIhW>F1mG6GK}xpN2n31OifWGq zcD%^^hqcs9Ok%}lxe*5~!&*ElsvVc${ zxmiq*-^I=3Y*EkoE^(}@d>gs1#c^`J@M2%#ekZKO*i8B)jSI6trNh8=ci;fnVd-o@ zA4%Bu^sT0fN|Qr#u-Zw^JI?BZ8iz@%D>n)5djq*DCjwl;Nopd}J2ccAnLyH|3M6W( ztjY!M#en5-MA(G^5eA2`eI+WFkUxo|YoF>Fc9<$}Gnf%ID4DcZRbFDKa`X${hW1A- zdbe9|d$cB4xwDzJQWrR{HCtjQGxwz`WBQCRA2x;CwQMV+O8NV*>usGc5)}Gz&8?5$<>A)SLx4ofxQ4o|j z!O-XT5P_{sa3M-Atps;6+76=SxRm|dit+K4;}a7WF;mx&QBOBy;8q6zi*pYgIRE?u z2hRP+njKqL{Cd~=%9Y!t*y=0QcZzYseU^LWCDj8cw{mk~ltZ+kasjcGsQt{Sh~$WJ zd&7S5Tvl#HImKj`+N1n(sr`|X_Q!=KQu`w%?T-t%)S&+kJbDeO??XS3^i>JN>B0^g zA1U(QVe>+M4#XSFqkb6Wan;#Jc8p1k_0a4vIx9aR&2Ch@ zi>-#W$E+`AD zOInzC3#!&8U8$p%IG)-moOR>vpSkgd@?3VP-<-C_1O8AtWHTH+dgSOjdJcOV>a(XF zx&3Q~s3x0i>Cu)ol?UDafVjBn?oZrw=gHeK3(9GN)!#ZdXC;&X6ot0_MZQItk3N z5-=aewGc+jgeu}e$Sx)*ZVYnFB0Qx<)%wWaZgBpH`t&wQ{G^47lJ<8AIYezqS zR$qgDSbL2T9;pse|y;%EM=D7k>h>pMl-DHB%w{MNU~5D_^)9I&Rr z&$r*kzaxxzVd|8M1Rx;7y71IVMKLS)cj-NoPx<2Fl=n$SLCA?twwf4^*=oW)=*+n@ zWrk@#u_k;Qm_bRZXqd^T$#$@*WCxE3oc~#8no4%?hycqvv@e?(7WS|muOmz_98<9! zpZPP&*RmZSm&zZ&PYwBK3QHLQc0#y=*_+~M7YLqR+obrtge>z*F5ACb3+H@53S0Y# z^fUhtY?r)!_^B?CoulqxsJc2t0de`ylLGDFZ={{`;3p=j#w42}-i5_JS!;kb;cpT%*3aTODReyjSr zbR1t|o5R}U*gaG@j$>?dSbLOj=f`p8h^TCTT)13ne?(NaKQ56#67e{xgHi;WzhB(O z+8bv^g<97Ca$YXHz{+FPUcDOgr1lA6eCB;*t}A}0qVLrEP*2}c{p+fis9yR$j`=Ca zJ1&t{{mc=ePBGqb)$P)HVkPZm(yasOZcwy8EVqY7KgHX=eWBvcb%6&7VXiuzxwKwcn8+m)|-_Ke9c&lXn_~ib@J*6Q z4Y_~u=d6F$9{qn>?jP-`f7Tx57fJ1pl(auCoF}zEQqumout;hjD`|gNIA3ZXD`|fi zE|PS3DZ=_^+2IXg#dx`i%haq+D{Jkd@^SpecCw59n`P4TC`yfEn*IN;V%x7;He9o%=zbW;9# z$43Es8OgQ)`(9~59dtD$Rfc>I{YVQcv{r7zb029fb2j5WdH(Xxl6B0tDN1ZNK|??f zSS7cG55(=W6~g%q^Wkyn9pNLOFQo+m{hu?3d7&gE6yx7G8#8C!3Fac?d}}Y9bh;0G z;M#`^d=Gblc^6+`$8-z$=xZ+?U1<*UuKmx-;peQqz-}@0JlifY*%roRp=V&s^l?U{ zxsfgD6Q-o{*EkUcQEy>HowWz?{lcYl)e!eFt=(T<}}D$n8)`+K$;SmUwPaA+ZSn{W$T%^IovLaF_z@O$Kb zi<9*pWBqtvLO=Cb%`IXW<*YqcS&4eJ)c<|BBs+FNENJc`itiF}q+Kl_cp<6^H$VQ#oE6k6-7x2xAMY3U&6)qn z^GiH2=QTry_ocK4nlcdc040)CaweTj5@qrL%0QF;s9RAbZNSj%l<*@Tz9Tou8*w9E zz)I4`zyIfdW@m0uZ(&QrMMcS@7FgOx+n_fVT36qP?bAC8uIl8iA7#JTnZy|@oXG?z z56`JYP!u*~04k2FI{3?ky;`b@q zA6C82+E>l25T9lA64D;%_g>ZT`>$i3E|l*;9>}evxcY=TRTO`yng1XSO|}`fjp9Tu`nq201BOCz5v$E&*%{)PH2ssF!?X zlq5_ki$78$!A*rc|OTK9|&@d1oCld z(l9Dl5~ukl{b2zA?mRPoaseR!M4qYq6Ei+}>p~#Lslb2Yed6x-AG$`mIJ4HE8T@TXgK{{PNUez?fhePW1?6;Q6b%|9qDaD5 zS?>Z4*d``rte46;?Wvdq? ze`fucp}dFnzdW4&l-t*o4{b-C(dR^AB0l2g;w24qF0)0`*V4!`CZic~2)O!{k~pUT059%1@x zWMvl*sC_6ukIJ=gsJ_DbnN_ZHt8P}5r=|9B?HeH0I-ODVhxuxCZitvv`V&B(s=K44j=tPMUrM#$k&5|YeKXI`1ZaITy)%Eq*R$&J^@GZK_W1Pz+sBKrDB8!T z8QkbmOi>QFd{R+P`$+9E)4hDB{y1N)&W%e8?c;yXSF3&FY`%{t=d0!4VjurZwUj_l zd|LcI@H2_s$3DDH5U0{F@Y2&!s&hXpm8y4(ufyslDMa}!UuQcpO*?^aQ2WDTExuwq zF-_2>+8t7DQ2LfZdxKnirc?_b3sszdD@^S{EjWc)j4)VUf;xibQ*33k$^pw;6y*d< zY7bcM=FsBGOa4`%R_7k(wQtVC(MWZ{ww8oaPv>Bly-{RVjsUt zxM(B}JM(sFoz9KxI_jpVHxYGVwc@*9p-z}ORsPOZtmEGaQ=6#noA|Dto*Crv5{#nF z+)KMRyH@9}P}XAI$V5x$sIGMHU{lFTOZQIP&-Siv{=Gvj?VVIRd+$Vk%8)OEL*@07 zb0i*-zMXgP#7EiQ)h)DlrS-G+jw?OEn-CU_pTqxPr(#w)zIs?uj(uS5v42FDbVdO@ z6uZQ~D%9%SxV8{Y`};h#be29wV3=g{E&mp^ zKT*`usi1LbCmxijR)|qGKCePO=6j{Go>4U|!@6$Ojp(5OcW{KK7<9S?kYE`)I%9!f zJTKMKBU1_NSfB6lKIsWh1@2u!Wk~w?C4=ovs0D0=PPUd=k<#qk7=uIUn>r>JKXF35L@6LYD-?FESYO{tNY(w_jP$ z;GrkX77h<8@Es5qc`3wrCEWjo@Lf2p0!N&U@A&3l2<~!iDZZoj38|K0G|oy1^-+Ia zs>LauXV2cHJrfQK8PpP#XK@a7N&P%~dVo=Gl8iy?kIFk!EG3-&HwIBIXVH$4JcZV0 zOH>2Wmt0D%v5(l_Qkej)kIgNm*3Yxnl_k6ft$#(WIcKB38D|rwveJlnC_*WxnK-YY zV{_;i_5FQTUV6&W_jYPsT1u@SXRS-|j;I0f4((@^1J7{#VC5dg?Sggyw+q}#zJ%NC zJoVUto9C&gHPRk{1_AvO7!|jTIlUBGv6}<6`ppNmVz-Y3R`%Wpwc>rD6>cGOMx^sW z^P+XpZ6v{OBMY~~`sh|Nx1DOnqmcfw&gQ3BFZ}K^tCnsl#ai40=-blUN}O&hbK1!F z68Y9btsYafdW7{Px56xNdx_KiMA7Dsk~Z=!W_GJxCF=ov*lh;urrV67w^{9^Tg{wy zvm|m3Z=5pCm%0dx_-Ud$>?O1tfHLIrS+#^6REt$XIw$NPT2$IYp$(I>sSRZGh1zJ} zfTT|N14(_MHoPCS`Qw~6ggb1uz#Wp>2`3=6&+4hr3TqT9UgU2g2&okXP6;; zoK6?3ElTpl(Rl6Ea_u}4J#b>0VUF0RXf<21r&bIr6s_h;_`r${ycO61pU*s!K3eVJ ztwsy2=9Bp2B=HmOh!@P?4lp9ge^xutCXa$Q19ylwQ$GUSL?jwjXr(JDj7jR0D^a(ftb7g=cPBGb^ zt*M091L`CT6mg}kFtd_&b8w2ad6ez4 zk^|DKGy7&XXOwvw(V2ha*irb7#`;-dtd-4$u?nSF9ij;p`$PxgbIdZ9bYNr=`v}vU z@^Z3a<>lkJ8IUIeD=%;sE!Tiar81Y$3R7m%nQ$IO8fexBI6UWF0db@1CP>CwKpab$%Y(ViYQYmR+l*FYbNRCIul;LHbGbHX4B|VfAC(1q z`vm#3DIRm<<&o+tdzI7eLE^iO9%H~&@#W#dx$&SE)W_Ze266glkBezyX0T;^tw<2$@; z7^{;?iKg(<@qt1qgAgm#8E&4ZR9N*Sm9{{esb_c7YiNGh;aAu^W|d?1kFs(m`!gu> z5m$zaeG``wx5NGNBF!j>hh z_+41D(w2>jhOLf3z=7Ah{43Hg;^w<5EmmXbR;&CzC;tQhr9nVGR$*|&F%mbco??T7 z6!8IXIo(!QqeTUEQ{lM-UvaUcAU5Dg5u2YSb})iMV#Ta-wzW*$;8&@VdQvqJ;b-4r zBfiMCjg40*rwu}R@jF}-U~9@i-bm#D`S}dw53zCrIV%^YW-eeLzZD>Vk=o;8&fA~C z+JBOjqdhN2`-AKlq(E9;6u&2zgS3>hc}dU$^q@Zi^l&UxkE?x*kTbI)W>sVoVNc|G zsHZXRaXUfFt0Uq@TX!-V_gJjva($&afCy4EznwWp1=m(=yh>+jvN%$giGM$-L&e8Y zaX@?mPDrTG!IP`a)#lQ2*E2cknVO=%doL63==Dynq_EQ~C%wHVci_|C;nVNp zQ$6bwAL4Vwf4;ZhyMMp8|M+F%&wIT4_IY|vj-YWGjqP&dC^dEIFpuK{-UA1`1E0D~ z{BnH=;&23NWZ;kyu5O=FX^s%gkpqN-hZ zT(}?37-m%%UzEberJMC{;r>&WHUxb&PQP>drQ)x`E`3AVmEwmzv6J|Gsy*WSXS611L~6R12SejbMnU!oGae1#G?rUA8S0tnX0K_V)ETZEoP-Gm71XuR#V z+axH9S6+eMuf|Rs#!j$3Of=~%jxBF*xlFv|$tO>qSE&915Q_$Hb77yh96pQ&+kWr^ zz+aTUe*z5{))N+6)NKhJYv$)ugsU0Hu-pXc)g}&j^^D08QCRX$eHN~S2e@$IkRjRswV)?K?QWLTz z4eF$DZvFNZojZE#+_s=U+doxaIn>=hm1@joGG@bur5HDXaknVOCB$L=;%$lu$;@LM z<8m)x5U_i@vo=dCmd(inj}!U9xvF=4lvLF* z4BmyoSx#U+IGvo2oD+E00x5y1-zoIV(tomk*?}zV7}iso{&Uyd?4OjF><|)@_2Aw> zu0bC)7AYu;BKRo0)YRH)pKWr|_#Fg}q9=R%`Rx2e+6!mE{kq6Z)!j zgVvA~x46x&t-Gtt9*z;8>GAw$t3mpceI$&vo09PvbN98#2kH(?s;JaD{a_xRGW zR49@j@9iIR_*_-O8Jc>Hy4ru}GoQIU7;NkSw6F$oYU}+sQFNi~6VHs5$OL~Gc09?hkH0c@<2@XG4AQITwCRm&T_AhNT zlG8=o5Y5aM(W#v-TU-sUZ_{d@bdQd8owdf2H6uNd+mf|5H8s|l%o{YLQ|@v@!}OS7 z%i1D-=S_x+O}@yq{Vk7g)mVS$rSXq%Uv=qf!}>MqQ??pgBcZrEXUm715DTfw-5lMv zQ&VxK_>hqIShT`lb@{({Z3zoswO<6@Om3S)fGEUFxMi18l{g2c3s!>(ppUDGq9}92 z=V%0i3S}YU%Mtc$zjE>Db$fR0XsmHI)g7E3SQ7DQF41c$;HbDVo~g3gJXP9(W!1C> z{l*7Q-~Yg-{%j~7>`L5s^J%wvgs{&n)GoG0U0v>Q$P>&q+rO-C4Ds1xmOkXDxJkT? z47HUm?1f|rfHEh45=G#?jm023>Zpy3*eK{MZ&4E2yMEk0)!ON(uU@u>Qc@HmdLL|k zDASp4a`LF>a!~JB{Wq^Rlo$ORYlo6QhIikm^6v;YwhWBO!L!SVrHI#nXEy^gx>S8C z$n<>j!~@wM9^=DS2xUU>=BNrGAgX4`Xeqo!!qJq7y6_Fs)5MXs=OF%rBj%?szaD@8 zQYsj~;RJHFt?uYJ6kYk#RmYBX!{qWanc>Xs6x{N!^fwA_K|%h%1pj9+W8t|oemy)q z{JW#T;y7MRU&UN&;Jm(3o+q~!Z3>Ztl@vrC#|`m|<%uz)_-NF^tsXy`0dw3E&gH_E zzY9X$VC=A~HmE7*Cwd)3kS2v514>4sX@5TSYdY)?;t*iWDZ6Z~fNWAef zd}D>Z6B8?j^54my_$UU=Dh)2WDvcY<&OqjGA*!NoX`--1$fgT>OrVO()nvo}5W+(d zXIckpqUQgr(KV^tqGfGS@l%8Pkl!fST-{~r*1M)g*4~p-moIYtugxDeb>9E^ZR>ZO zy?NsxJLU;c8FI0xgbE%ZC@!vD~PYdhl&+8B8fBf#d z?}DNdGQooqWWQhDo+1j#2~F5ES&(eV13DL`iMTZ6kQ{+EJ)|1u-HQ3f;U;>{IkFk= z$Q3;3e^25D!^&^8wy}Z2S+7Krh{1Q*Sa>)YdlE-Dht6 zRiZXkZjXBZy6S2|sNN9HsvR?h88$gyOX!Z^hGmUy5@p=KE|;*SOpcmp+|_edc>OJrmXNC^K7LwvYT4d_a8qN* z+V}gq;l{8zy}Zp=i}dm4MMAa7sco~2_x4S$9&SqetW|f!>T9Dl_34u%ZN?DfH1CQ? z+E8)c=Z>xLtE;V{Ix*hhG}g3>lh>?YeqwL8U<`VZR4+3zy*0J+=%UP#3(ni0?Ea&F zN%c=hhQ_-VuZo|)Jn-AjhD=*~L$+N#Y`0feJIOQ$cdb8(-veZzMB*VhiYGRqOX#9D zDakDpN*H*wY8Ekz9R;T_JT!@nT*>D8L~YZNsg=3wPZ>?dCl)OpIK4h&*%EK`rF@G` zzIXW9TT-9&Ty^wocYj={xa-2{+d8(bS-$9;&+oc;Dzs*;*uVO4q^)7I4@SEhhT4-1 zVRATvy)xR+%}Rmdl!pTXn8Qw|53;9BgB*w<5_L6$CN;I{APz+x6tv_V{73jLZa-x2 zYZ26KgU;ipC3E5MSXlE60yv35jA z7WSQb<>2BgcQ)?d*P3v()@%H&j$BwHXf(ds?9T4LoU(V%`sKsr)A#J&dDoVT&UT?c z6N(2qlH$qhwc306>HC7yB`g-cc==sb;$GuDbfLy!`x0PBsdDhPgy|sE5F=SQ`-SsQ z;R!G;5N>50ran=M&D5j=d(qzzc>L>oCQg0jYyMB&&}0bujrrfZx;2{kU*Z*vj0G%p zZ=0*jbvBFbyhpz0z4K3*sf>`w{}4Yu9(3{m`e*I0wt<8NvSckSj21_mzJ+_-sD zV-1vG{o@dJ5-a=Oh%M`#Ua@>}+2&8}+jaMr@{V?OUqdMFZ%=4W-loZsjN{72G{eFd zCofu2A#Sg{2LQuXidJkZo)Dn6!^Sa5A3H1aCUzJQM1|9L;i}%@l=EX#t5%e~|Gs8q zVw>=TN87ZgpP|WapE|3$F=#Z#6Myw}EX^$H$YjzhGTG%h_4COjJpX5Eh38e@P%nZm z!-%(wG49x`c7je+L6O)_J`D=FP`>CfXE-KK!TF6%PIhBp@}YD#rF%`(Aw0PE!RgHp z?%Vg^^z?&!H(kAK;_8hXPAp$>V!~+m`|ZY;9$mST`~Fy~4j*!KZ1H8e&k%23xN zGzl!cLmSvP=Yi}D6puiH7(In1Dpd%N?u_hn~kETKtE7ZcHErW=ySmW_WW3|oM(dJxN*za;XgYiVBWmx>y z$yVXqNTp3M=HE1wMV1`ZclpUr)`l#unwZ~d_f}Rlr&CDeZnN2A z9-Gr|FgC^OJAohpD8{}aRwH_eswvULu%JjQiQ)4M08NgFrb!h2q3gc=?T{<{zQq+n zYObrJ)uC@S+!8X^)>Z}bpYT~~pK2jSa>;qUZMVb zs6RAseLc3I4*r)~HGgf0!paW8(?zWYZy8YKWmPHs%U94yb*YxXe|1{5PjwV;Dc2!T z!b7SjRbRupWNHAas?<&?JyVKza=R7>WL|BSvb+#+3Vr_HlrJnr0!+oFv*khO>M(} zkQbhzpBPN)`YtT%Ns}6c21QofxPj z2D+x&M^N%$h+1>}qsCUEi;nhROj*&>TGq(y$KYd%*?tYSmgvBeTSFOA;cO%%N z53gt^FlK^6lhC=6dBPAHrHzT`#heV1 z9;PJ79O9iNbZv-320^4nXLsRpa7BH`=#HH!gGtUYV=>2@l8sHii24e1xvw=_|9H76 z*4eMgw)A~J+t8eP{mxm?8)O=HXEdM9L1QH&fX%OFoLzh;(lD$Y-@$b{% zCZ^Rr1rkj`uo}eIFe#vS8DK+lhjeG;@vWqt=G>+9uE}|-`M&5ab9f^eKg(Xn@{CfhJGF3L*h#7G29v{=d72{n+;u}822*`K z&sm4bu$Wf)^P?lSE2Fh0qb`+ds$2F#GDUNWVNMZ1%E}-WElHjfwg~^bWGeqH;ph+f zMOOGx{z~D}*Up+wif7QRwf5u%({U0CeTq7|si;EPXmpmB+l@y@&NDf6dY56TInh~H6SA1g z9HwBM^``areR89?Pu8F0zh1vCme`aPKAT^j+Ze5#N($DtsfLil?9&>}Z38F&BxX;3 z2QU|);<;7~fL2KJh~i)ffnrsWUpGL9Hos8F%;X zD7TpKtEDPok2k0M!LZI|3Wn^bt+?vidg)Ne3bGm@`q*dl(DM|eV!_wquQTeV; zJL@_v*=P)S%gW2Eo735L#Njmsb$XwXGN8Ru=J#9g+EDKfI4w?#PA85vyjsxg>gG)r7NezYWDF5e~}SgP#{QTn9D< z(2;M;#Q7b7W(gD!(bKu5ia~`rL-*mQxz_wi@?WXZ3-vTIhR>LOnrJ&^^w+4H&bq zUZcLmWi9J-?AxbVU+Y`2wo`?y>euSiByg&m)Jg&Ty6B;(OM1E%Rl;fc8^0sJOtVir z@#(R-09E7UKj=mjbbPuh+7K1rq1SEtGCxM0(6_M4|22D6pS2ltz8#* z0}3fP5{^U*{SmIcvuS&iIMC94)PA()W45mo{AXr_YlTs2SXR^Ga=7S^aBcoJw7gTe z>N(pr@w4s6nK|E#S6DvtRoGUHsZ{Sjg9)xwZNjs^f9`{`T7fyUpZ0~DWbot*Px$Do zQT2>)gYb-m-@qA)VbCZ{=kFD!g^RnozQM3%zv`Q+Utn2KMYUlc2=?C;g_Gp3y&o1* zQk6}rLWdps7ga+3Tyd$YOs%79fdDegsIV@7ZsgFdYs$(Fez8tmIyjja=p4X{`#;2` z!lk^a1p(k(KsD&+*WPj{(wP32gXknNImlY(FA%3eIe$(r-WEwPXC%|rcxp#~Z+UIy zrjhntjpF$RTi7?$ZA0oyG~XgzB>sYZ!ZZLBTn>*M34AiptHUQtI`GMJ^vPh4%>b51 zE*=NeSHZrATpkeSXf-pYU%i=`3;sm4dEqvP(FPB|e_ObX&Rx&@dHI9dESuZUONIHs z1ecj3u7{yK88E(~y0b8&f0tTmbq}vs7fNS+3j?^E^zCbkZ|AmJL9MW!e^9HLUoht! zg3DzbF8|8l@~@l831}2hVaAU^2Dy?JzeEdRYA3aLZN5Hq?%P-&T?_W1b3aw=Vw*J%AdKX2)MfB+8-d>4p*2K1QJusrn- zJtgzor1EYgKS(<%l8ZJfJY13P4lNrRS`~-v!(#n%2|K!fbZ~on&#C?IWWpwHag<4Qp`qs>9 z%lWrYqLoIqTU-y5>(7C8xR5FhyHbqa2en`V;zUy;QE{b6pwu0{ZjR~KRV3{QfwK!7$_Vn3J3+^ zRIB}aG^vZ#+4BFbaI>gcGAK5b=W1$lc#ga4$-K~N(fK9%azj8$KOpO8tQiKnN2@Nr4cjr$L&5ZM#8WShWlG%ie=YaO`& zrNb>7vQ*)@Pe&IY60X?uQ5z-DMoMLru1F@U?bU8uC}c6(O}b3DN_a^Vah99cPa8}D zmj?N#YyCHT*{zphxlubSL*rE+fqy=xl z1#s?y5vP!ZJ+2+5OZw~uvzL!q3nmaqE5@}sipOG|NmQ)3+)_!m($CJHaNC&AEAnjr zfhFj^!BAN_r1#sc$>!pUeJfYElUUh&8MtY^#kAX$tosGCZsX?$dzJ(sS>yVwUWX0t zR1MC$K+{X7ti{3-NYkd9y{U?~Z&|A*+Y<5Hq^$}kuvLCsr)jHR@JEY^-~4AVX+$Yi=Ww%Lj7tW$Jd~gOq_k)s zwl`d^j%Ja8hF|C=R1+zxigpWC2PvJkTyGh`ksAzD8n|aBL0HC*owWN z2XvK$HJkv*W-7`=9h}#_8talq~K z@PLnz`Dx~}q>D3Yg%laKA$drtJajOiU{e$qoqABaddgHY)T8#sZ0iC&$*p^yat|bx z>9(cR9=fNtZtcnC^cNkz-5ax}e4$&lk-wy}R5YJG2O!;q9U}^^d0Q8_dDx3@Mw6n&ls9O?qQjsy)#>yfWtXMK!K) zQ_GUXP`2GF*3~|oil?fQiN^x|il1?v6r>Vy1xS)?jlzUJB1P;>!blHhx3m^mg`tRP zn_)pCuYs(sq!lF9j+$y$+@XF?9f%K%2qWGxy*BmTn%<;l)H9L3(31_RO{um-#Bu6C z(x*;U1cE|)(63LWJay-;78;V-DhG@xQw`R{bfU zFYcg{-WS%{;1<20oPc7aRDaKjKVVD`rt>lt2at(UM43eK%CQZ#^$AC7Cfwf=%q^P= zyImlg5Mo1(mvQRfK9`I9&J}8mwPf~e?98m#`CcIFi`GQi!|v`qD^~1mcc_1g5nnahkXW?*9dFvz-`o-3 zwtmf~s3=}R3j?~H$BBGlVKS3&)m^UEI6HQ&T(Q58(#yqXv>Gq=ZLPd8+K+|y4g8O3 zK$SNQ>A|!l7fEsR)WSlWCuJL9caef_wvdtJ!?sS!JtDIY{FHN(d|$b3@E=v5iES%j zsY#){n4t*O3O9woq0nU&vL0k#@lph;Xz){3+IoM~LEDNgxBcTM!|ci8yN6vL9cvY#NK~ zZOg4`QLLN=kB#+@W}d?G4X+)d$E7=ZI=pTozW81EEzQWCdClrGfALNU2-0IER8t%X z#9qML=1yczsvCf!UE{5F$8$ZsT?C3hm!`I1(CFxL6qD9q?2?Q&X*UB1E~BCg0D+_+ z*)Sj(c?O_D6R8*}myO3r1x zWRb{h4{8WcX_|l;Ar6U*n1nctpD;PFiLj_R`4=kl&|kC*EO$+H-O2AWa|5Y_KLaxC zFjI;W$}PpzA}tm?;c=9d5R?I>$!6m$6AF>2*itdt`|H-X;t}UbCmX++h*F1Y`?fu>E4zL)y|}Zb zrz@NAm9?f@RyVe7*S8(nbm*&>hFh&;`#v_gf5)a(QzPc`3TLc-{o;}R-L%e`=P8&j zjvN_Q$S(-4Su5qsqvbMsNZ`=w)Fu(uf7}WKlY6yq(Xw7o+WIq-4~wrhcb91n;7L^H zGHGU2XHA z#25~p{A*25v?I~zw&0m2jQQeR3Of<#%867bDw_lkZ3#f4middX9$U6-$d<8c+mr2^ zItF&M%>K9V_2!29#)|Svb1WVkY3msY= z`@m4=_MTja&Q|Xq*f_mZ*jO{QxMx*eGG<2p*uJi=UVgkNKmkK_iOJB{{BmDP@Ba^H z-vJ(1b>*)&MI(*6M$>!O6lt19quzV9WyzLYaW5EbV;f^!z?j|%34|<3NP%6FO(6tG zvLs7jQ#X(-DVq==*@T`1oDE?^7Crxe=ic|G$TG?P`96DQo*(a>d+xcX7nr{c^)mPO z3LnQYV?)KxBj#VC(KZSkswz1*V%;dIh-k*z)BZXi59@6|Th`QDJ3I4*zet!97J(rwmj`lQlU9@Up zF7yNOhnxv0B66Ppdvwud;!B1EsHX}=^=nAL?*?XIa$>9*<)ahYc z81=~WB;+M&+;XKtXg_*mCRQKryl|*vd*|xw`}%cy?@V;Gv0-HMKzwHG`W{_)V7lzk zw&q!vXCl!x8@08^dOtMU)zD_Hv1;QwvsDkl9iJGQpDVZ8BGyXKUJcS6?+3c{MJ@?p zn8VH`KpN>Gl%9ejRunN-Dm6lJ$B@6?VsThC;tz!MrkQtML-v5#LcB)Ni`STu`$Y*ceDwt{0=vc0&%O*kKx~@C6yRNu zU+%W3*~^a~&%OR7m)T@88Eu|RWkAxa8uSFn1ub<459fg*hDuCT71}CYtvTSB*Hicp znID${vLuq12a1Xk%t%E@m1TTLbPGU&LAPWkWxZ5xDJdOwPKXDCd8p+tI=fBx;?$QO zoBB<;-67?p*NgA^stbPRc&NruBOGMh6yLy06*Hnyczy9ha(epyZPDyk###k8W37F$ zuSlP?khAZoI*?4`guca543P8vXu6bgE9E#dg7eF8D;1~jFp7VY66cBQuc)24 zY-amSqq%`o;+PDm_S%CC7zXgK2KTft88-E288+g`@2EO~KG)iV$xA_`XPFn#tI` z49)~KlxK<*JPXTgDlxMqn1m-Ni$Y;AlU%4Snab`g5#0k-b?Q=M^}a0VPnc3Ghrcdw z@CRcp4TfLjwpdJuRxUy8FNfv=!Xwf?!*$&N_CrbD0ZQ_QzkRCc!{S#+T0tWsmo>ap zk`4+TjN_S0B|UMa3}Z$37v?bGzUqR~G^$J6v`IEgH*G3R8_cgtBmMBvL)*>FVIJ@@u)|TO`?3iUri=! zfZPOKdpnsuh!@1Se?T%hfSm{J!depcrl|P5U3@{#;UYutf= z5_+uLU=Z4hN3Y+u@wy33dz)hHrmeefLfp1v*Cw^_A@zn`7q1t@-HHvu`sCI-SFX5o zTXOFw78X9SSLna(s;h2Wyyd>zZ@&*riSjgSk)T3T3D9Il*5IMG;CM4VkzApqxO1t^ zmT3@Vl@&USaFEY@Yv1ZG!L03}=7b=O-u$%1hjH+ZwD_ySyiK>$5nVOeJ*zJ_RbYIf z@Waa1RC9~lpzRd}d6XeV%OF)AM#q}sQaw&Ji%+n$a!)R;oQg}Njb+tX#i+kyiUz5q zzz{p@Y+N{}IVr9D;KBTmgDD|U9UDt4Uzf1M$mfaWEWSW*K8F2{)8r0`mSm+F34D@U z513jC2GfeOAnXMplv^rojk*f8soESIov5_2aE}C0+#@BrgYy1Sp>X?+_A+f%Ae0(W z2!cU|Zb|F|uqg)=FLF1$6a#>dmmI9QFtdRSz4r%BNCUdD`B-J8*{U;`RJFE*r!nKL zsv93C#5ipg#|oE$BS-InXqVimQWAiut53A}vN$ z(1pR?m~RI74Cd=DLVfM3iW=p*qaA!QX6wZ(R2BPc_v%_a)scn;w_c*GXByqccE5Jh z$9M9nn49m}Qkn8b8zNRi%;x!tOjV#FK%=}I&@|A75~-g9jKRpsoI6N}?9mG$M^8+r zQpD2Rhcx+MsXdlwTEaG~*Y2sebv1pvx~}?F3Ak74H_sJ<)-|pZN=GgY*&9?wUnUZs z-7z38Dq})z7Esu1puAC76Fw@oBiB8$8i}5bkYTWhCik9F3&bq{P=Gv3v++FzaC zHoW(sH`8tZn5D_7EUERFf9B8VBNz(q}v&^4I&$-lEuwcai@v zh$2Yj7|XB9y$g8IxUo@C-B4wmj2eXMo;Y^1%r&9Bx~+S`NMSElCIWgfb$WPhW*_H%eYoiZ}%qc~%_ zC5F5%rIdUXY+K}D2dQkD%h#NcbF)>djvdXXj8(|bhPI|sOU0o-9PREDRKiX!`82A^w z!Y@hZ`Sd;OAJaM=QssR?+IWesVqRI|c9S~kuDsWnTs2HO52b?Vd@MY5e0@Sn9rvw> z3+Xa<3Hcao$;Dp|&IR*0BC`l2>CF9pq-HY}2Lg#af1HaGdt`h`I$~alHNxLnAv72b z0mIWz&+*C|7{yJj@`fz&6Nz+U$ZAlB!)k+7w>!PNdh`0$jV*PMUyt-JO!tXD&I2Yy zl*@o=&STG#Y|Q7<+#8z*3c%pPvcc$yOK==K2&iOGR0c~z_#R13b4<%XNF^)=Rrrq* z5ab-LR(t|G2uBoaLn8-+YtGSA1&#?xd-}I3Pu({w9UkLf%j`ar_&JE|Gf!Q+iAe)mOJv4&S<|3_y-d1b&HPRxu< z&UU7Kt@?7$U~0{t4P#@gH)tb<%2G{vN2+h$9M%nYw6t35w7S}mef{L{8iU%fdwhB; zqQf-Kpa^evA{SrwfRV9{Q%fkPFd9QNZMW=j((4bl3`U$*o5fzPICN-XUB~RYiI$eO zOenNQ8x1;wnYgEI*!OfyzkUD6$iUza-WA@CN4%+{Em&%^^g^IjkePOx=Y24C$bA?j z4H>%-Y6mXt-u%F_{yoJ{GKV)Fe0+(6Posr{Z`}ZQ@YUgMe^NFf^B#^&NgK&9rGw5H z7K$3ObF5t9t588kdymGyQef3m!N|@}Qt2=;~AP~Q%QYQ~Lbw!ePf2i5=>Z{uo>p>;+TT@|YeM7o;YicmD_I5x)mQn^;SZ`ekL}yDzj@W<)a1&ssTsT!t^tJ=q`_V)?UmPS)XQ8O zygePHO)^RwM>0hSjqdHd`atfb;%xp)6+daZpznR&ikcxNtH|BY9nXb1h*&RMuwoR* z#~#4_=T69~Fucp$c~7dgB5VyASgj--`6 zv!ERlw)r5o8MKq7Rhjn^i-WY})G8cUcg@HJ6E7&%{i$_bQ+%O&U^O)!+NhoT$nJd) zG`6;`ylvy`6(ddVTz?^;^>sWN$3Rl=+>$28+36AwD=8_$w}E4EwQLXy*%&IW0MTy< zMW9uDk-e&-9{Mgj0gZ}R_^YVMPFN;nBJg)JNvB8piQ+2*?cxpCPnxud$3G_QkbdlV zcGspdMUR-|teSap7yncE7yjzSXDNC}L6b|d!3fmoBr69;K^k65{tpLDx0O&wj3rpL zK{IjVR;icM?#=20mk7~yBXS3-E!V>ES)bf|=Zw_B>DJT9HK)hqUQ@7oz&OdgdJHcO z(mBCN%u{ZpH|y(+ejFB#U#8y&{b@DdMpB7{ZuRuN2lrjv95lBjuGp(kR6OwnI%OpH z^xpJotFTvmulnFV)z^S>PVT1Y$=VnY za_y_@YQczwEG=0{TGzk>|OME(XFNf|1ToShmtAbwxEQ+4r zxL){!M*;@}qme7sM4DTH9z<$TlCZ@M4H;*kCOH!Z0KuLkDXBq=7TM8L@gKqM8+a{n z+^LjmfdT2J=SxS174yBnB0tI$Ft-n&U13b>OSG)(lT;H-U7r*mxhv5e6Mr;0ncT$= z*gL{sL3#*VOED`(;Gn5MFc1Xt^1?FmF@UUtX(WX${-m}VIx%+>=^2e_%Iau=BvJg-vum)MSfTe2wSWOO!g!@oMGQ^a_gl_c@loVAywiv zNO6^Pmc#O_6xn6U?`rXkU0xPwv-$nrzVPtc?uk@jDAwK^?yt87+9teXZB1hhWon(TD>1o= z3g=po;6xIHH9Q8}CzY!X{0jOPV;VAJ$t!g&hBH7i;D9V)>%}Ld^>^<&sIu*z*>+8D z-*wxEF0)a&YqTQ5R z#}OQs1)&Rd0Szm91Q1kL&QpI8l0j*!QDPd3ySP7-Q3_{Y-j3iMKeja&^#6j|V2p}2 zoG}O%+0mMcd`buubpHZ@*8w7G{e?W#lt;S1SJCDb~I*&g^p6874=l^L3#`PgSZO8MpqruRrD;UtFUnC>b-Qe z3iswFH{Hct0$aRr7EEkZmTgl zsg;V(5qUQD737NE6smEGcRQ-6T*HVbr!XP4xCBLfkcDrbtuFdL`;GDq{Cg7TOs!Z1 zd{+WWUbVU!iJl&oO##mgBW=mL$YpjCG8ubSl18QxerpMq>)K;Gjvv~?@;E1ZV}rU< zr_&zL>a3~j6CD?qX${p?#@nyD`BtQEUc4n{Hpf!tQj0O+5(Zaneh5gYhi&z+!cFJ` zw*(zS5uoG6C|UXfG54N-=JN&Jqo$=mEbwzJxa1DR6;%>Yd-0<&tGdjgdIv!~+_WtT7-s7- zb4@RWb5H$v$FtL{G%uS4W3Z+#aJue^N;MWn7D#i z8w|)V(l?-hn8ssD1$pBoz6x2LB|E7Vlbl(V(p%ZPHqA_RHTZ3Th-=rbIJ)AQW1ot* zB;H%t6m(kc&O~4L4?Ei0e}OkG_W~YKC@?>HRDmt)d@m3lh^ZDf%Fi#4v7LZiU}F_Q zB4r>Ea_`9!668u9;1b+dAWJ44KVwWJ2+qC7j~u$_*pdG&)($0;LuJKXni%zvY%21!LQ+y`{Ca#Wvf%@4x}^^EQ{uMt`trWCFj4mq%Ii4x&&068ePa zcj@_I))I&oUF#b(jaBc=2iVNEpJ|M zt&z`8y3eaCsVxCk}VbFssYgsc9NVmMEy9d za2Ne(Vi1)75uI8$o@q=*k|w(?V9f1ktUBT;lBy~N zSy+dnV&2r6q)=U?xoLRRN@sR{t#(bPDQu60&a~7gI)ta`=zc4PXxkYc#KujspIGz}iYJ$#Z(;0kC}iNWPEC=NRf zr5BhhE&ob;VymKaY&_d8d=blX%jY=DdWc!jhHxoHT2ng5*(zP;8e@4?$lDUn=W**o z&XSTUgVi=Qgr$MOIg7rBr2+pswlwaBlUNgFzR*huxZz=^L3y~l8vqJUK)BvE3>a^) zEfAE7v;PSkeH{;C$j@+sJRkCHu>ImSgtfNz;@0|VtIlD)&116r1U)VLL0ePAW<#sx zS`_!3{nObYVGWjFOCog{UamoifoPHSjN+y@+C=5>P%&tY`rXxzYII{WHu$U&{E)gs z<>Mv%HJw@Ku5+e6?9ZHHkJoDT zdKh;BYwiMf(NjQBN3A58=;O?pX(V3#vR99t?oZc+sx^&~#L7g|d}hf{VW!*VDlSIJ z85&)N8`B1{tKhdDJKj+Ao-oATUF2um#Q8M@FFP1Ws8eh_l3vpkZO~MQ>e|Mqhu#~S ziuHN|j>@v);`(4CrA^jC0jz>dYXo1;o(P;PPA=TEk}w5!t>G?7c9ezT;&{{2#9=|H zZrCxmJ~rEYCN{lW*wQyVKlWpuv&{2`EBxMd8+M8xp@S}<*;6$pg51ecmuU(4sdPG) zN{1tX-WPNtcpoUF`RSFCGwZwA96IyQ#S3_wtR~aM;6zKNqER@rG<8lW5x*eutHIT1 z2sPe&EFr#^O`RiO`gfHIh6#?&j9l-Cjg+J;S;KlZi9|lE#X{A#qnU+7WJT)0+J@QY z=}a=!lAH{sW|O1CEhDwMmO1VAZS8Xztu{58T(ilV4zzam^|z--LZuOH!)SBgc%z}z z?2e)nEt1%(g-^+hkITs=xpHJLAyp2YEeccRHi(b@Ua2Z}`K!)InR2OBF>6S8Ve!|m z32k3RqFhBwml47kuRxxh33`=VinP-M8S{baM)`PTp4DaF!y;ZXBJ#520v1Y=+i?7N z_Qk(&nJaZwEJ;p=;et5=K}@z-@_<-;4;gZ0r6rYBQi_~Fj!V|?i9Yii(=(n0F_IO8 z!V~D)C^)EVquU^NZB&?~zKzZr>f0#nwO5m^fs`BpYBVN)tcC(e9ns!VcHq3E(7JP+ zDloWY$&l4YM<%Z)8qYb%3Qd;w5JdI^kdb#gh1S!|nsjHsJ>5Tl;NYtA`Vji0g`$a> z#Fj?=d}jSR-X~38pAI)oR+ZrMN0z?eD%=uJ*8aA0q-U;IxQ2abr)E}6sFWSPj7x5u zmb93rHu1h`wum{Fz+XarS-HS&awVcm3)WzFx$LB> zUD7bvbZ8%xw0-*yX|v!mH4W8<9z?|Z-w#;Emk6wUBY<`9BEv`03)q&N1M>QHEs4I4 zxQC1d<;N@3j@fu-wAMTyJRitYn;xBRTRZX$bYyW3JZxo)H{2LZbQMC4NlQdh?~7u% zJcw|B+5FHk=!wEE=fUv$b;=S~%_zE3{j8jIrE(F~=*|zU_zlxsvyip16s&nJ*HWr?V%b8I_1n8d%%J;Ts>&qaEr(#!m;9OqB= zt;tl^Fc8B_k1fz&>dc}4K5M*fO@FC}q5qqy`DOxgxs{0Du&m1M!n%?5sXD#$X))KB>##65!Uf2HHj>0Heu3 z(`aTbj~>YgTqa5Iya;@MV3$SY`v81igq{~>I245Iu<59YkPUlTE6s2P>_XV`8_8p^ zkvtsc*i%+vKQ>KVYyDqty3-?Kcm&jbNu^$Db&=Yib3bgSOvC)$7v1zAp7z%V!mQ?= z9GFL>PEyhzQ@?^4Xy$o2f34*&?zmjj+M+tLfA=xf>qA{ip-S03IMSsM-%@nH7yF;f zPsFai>Z+^%xEcS6oqKle+9P=aE>}d+C9S{{fHW2!=?3R!`UcaY4N~e_o+n@~T^@C& z`?`~wOHW#Y_3m0rar4XLo#YhgV%~sygKcuMXGPtKV?wa9InkV~t?Uy&APXFvKlmb) z#}yuA^bIoyV+GWq7?vHSTxeKx68@#ieaWHbg~CZ*oqVM@oyn7wwQI3QyhMNN?%i9d z5MQCV>MFUU?p3KA|42;uZ|ok5@vpi4j%yP^d#}zPE9dI5=5x8)d*;|BE|DQ=DA{1L z6J744lMfrylO$zb(*BJFqP)i(9O%r1mw{4-vEECWffd4T3Dy`g{|2l-S^_S02_Wi; z*zA-aXx0=04gKm8h{7@1n7}LmR4$N7#58J(icmMnbC6t2(wmUX7LpgG&>I9Ncv|;V zw5k@veRyJEJmV^_@V1};hT(j;eaoKVBO{)uPN3cg+U8YpYJOm)<_913tUI=1`y5Xa z9Jpk7Y|n&4Sj880?QItvZ!cC=TB;_nD!~%nHhbajT^Bu93Tcx8SfDMx~ES-7~c$Yu~~3My061yFW5jthd~ z95D5L?L3)qW^-~Wz;}E1npR?xI0(#^w zquV%K)p>bJa=;3NO+$6SwuB!<(|-NctEL9hnGu_&tYKAV!@>BF_hhh9U1qa9VAb{} zlS!XPU8}V;+E$ImJ3Xa$H;_luclJ)yLGeKa@0(4S%FiaeG^P{3|H*W_xx1-j#8O-y zUzOgn+cDJs(Ld`Ob=5(KN*UJNa#88F{^Rw%?XFlN;8N+ztPR$QflyCx^w5PRTRq0= zJFrQrv(G4c6ti&Y!QW|QF(#x9dHLKN9cyBYGePNtx-_g=@IS5{UO2w1%<1}T1xg?z z`%{L0=bE20`Geh+7LD3PO-hqb!~D_np%O=gWCzMBpzK|*aFvU{@vpJySFq-!K!JcV zwGA-zxND}Fenx9qJTveyd@s2gUW#3G#$xzo;;j2_wl-K2T#z$RV{i`kJk;Eq|?0m3~ zxM6fdst%YVe+0=hURlX4Kj!V`jx0o?p>qIKE-#2}yiea0cFw-?;)_Jw=$<08=4E=M z9BFE9S_gW)^NQvxOig~7u9s_!k_9nIsLhECBHQI4Tgo4n%8in*D3(IEoa9hE3-G>T z=xWS|Z8_<|eLD5nd7`~M+nF_j&h9eDC$VNJ4N=z56bfZ&pl)wvwZmKfD~$z}iSL1{ zW;@AN!Ewq7v2Vm3Z^#$zn0R+-0orw<^cx3aq>L)nP7^z6!kjXJgC z*lfoF>Wp{piGvu7^ls+TfCHWBLo&U<3$2s*vtHkxEjC=YZfb4U8cIfNZ9y`k z?7n~tMfmTSam#^`;ejEs>NRCaHXSj2wjVQtJQR4v^rS4q&t)OPDxsJ1q>o$T>&F|$ z+tR(=yv%rdSJURcfn9AIKDpyMU0#tfbr73#tjNi+~ zE}oCa8!`8!z!>K2EDe`GVu9X#YhJNAru#^l%;a0g48=-i+*9(ExJO}WG!Ftn~AIM-vyZ@paasK!XE)6xH`+- z&iQLVbAYiBD4`i__D@i3Nv{n#Yx3TXmqOk#&42NVs8z_m4KJ~sy;HRp7@y{7ARU5y zHe6-!7XB>oTgqLSB(6c6ZwW-ohhjQD+%D>-DR+Fb9l;pEg^$8lFcynX=}Mf_v57SS zyP*(bVWDqW8g)lsr7?F{hddWAlx*>C*!k+Y>rwV#JyZig^Mni5v)lq`sY@YOTm#4x zoegPppyBNs;N19Rh1xX{pI-1gmVxwK@XiGd0Sbjraq=8LJ(m$I{a&UpRDsdFL#}`- zDYsII5K>)VtD&*Z$Z0BUiWw8_=7ifO<78_vA?z>N#R=(D>1B$xO>G|~9zu@#JC()Y zT}|XsA-pgxp+NKI+M}E1>F^A+CFI4=w^y$ikLkRDl)rAnXf#kEzElwkc^g$i)%T~; zTWZ|qy1ZQ!e~@l%O|Jp@Rrp;0a7DcpDegg`6OR8|TSv7YHaoI}DA+QoSo#^zBU$pV zz;_MDKXDxHo`#%-wOI%dP{d-u+?9O>{^{Cib&WyOxU&#P8zm36mU*YEYILcd#0rJKAeGBa?rLuMfz9Gg%u_2f9zLnetCK^k*d~MRH+>6z(I2_L?;ngNR;5xi zu}rhCr~cB*J3N+Dy+7@43AYZ!hFm}QJZtM(*_<5od3qX}SGSs5h8kvf3HL^(8sqVJ z%->G^9w;eWt9TdT55%V7uVyM7Ro5U_0*XF%i0hewAoH0Ej{e;p%^OqGwf-ZAel zV`s1uBz7rE5HFKXw3#(sobfM~Ch?H>iY}~Ygu}zl{05yr5 zW^ASKF_vm}i`yF(PbbAqEPA9VRa|Jd*m_px!Me;I!kwPwMaqVqZw8%G4l@`5x%p|tT^ACTnaaV#RfyMRCo_Yf$;w6!^Llm1 z=8o`+riQ3D>1%c=w{2GpPsIjnY69b}z1uq!@5MG=*l1|*dhD?Q@9K%z)-8l?W7M8L zv;kohEj08>MFvA#M`-OP;VRbkZt5-Z!kr|nCHYK;%k}gAw;Atq+l&s!bS5b{fzusA9EU$DM zsv0Vl>ai_Kh1OxLt()y#y?t`9y`!`=Rg}gC<3-$5A>sWcq}YM&XQaGm(69V9W*sx)<%fw;D^Hmr z$_xYpLERp|Z|B^|7z#Y1sc37zLh-r%(a_$7`~kgXB~Le4jcttsqs;-UJK56U4TP*1 z@6=#3cg3fs3nuZZRV3X|P5Tevkl6%UN!4KnJ4Ryw6H-kbf*S?DnEu5R7b=8ubT<1H z&?6}1FXcR;75TDAk%I>hd8?;|6X$$;3Mc~@@R15{E7=gOETdk&l@c)(!45-a<0p13 zF>zMxY}%}CKe^@jw{P|~*R42s=lsFF*+xp+ck(hT6?jP+3+CQenQ_bh67DVm7vV;0 zwxgtyg`%=8ly2|ro$Shlv|p{TyUuj0HPe-~#c)Yf^p_6)$mOVcq&nE%;c2KTbyM@C zGt@lEU>6?Y?M~`yzvD@p4IR=%7iUbYK{9r0q|sC>wN6R`L~1qKM!j1k({kBp6Wao2 zow4|%EO59u%3zM}a-qI(1R-KMI$U9{w8n#uN@s0#Lv_z+&{0yA4fibx3Cr=|T1UBK zxZdqhshTyV=~hRlL&ygGn76xHcp2XA00qq`2l5>pIZ~n&+kkbcfe{^T7|C2l78sLy zS`Hj8bnLkJlvg9vOryX@`Eaz8hg9($A=NsIuHKScK%=KZyJ6=U;!*$DJF+mEi=_JW zwGF5V$b#KvrvXGz43t{X9em#Tu?)!aypu_3mP@b5`IeH^#I$Xm2f1$D=J}D1Mjz!t z{+UuD`PW-8G&vytI~1%x$33-VmK=$d@7hbLi;m>%fF;S(Bpu1V?)I2?1K1b4hkcU( zeF1|v(C_!}1(b9SDqr9^1%-eX{-W&40L5)?ruw?Zp@wndJA{V#|V>vE0IzS&FH(d>dba zC*ItJ72R{s`!A$?&!G~p_RBW) z;UQw?1cZG^Q;tctLH*1Qd95(oDjr$&aH-u;{wG_VNu_lBrwuK)3>Mv^@iGHurI&{d z9^n_F&0H4_S67zQl*~VYRI5dH<1D;A%l*n+DwH$$Cw3vZnO2 zW#KmA^D6)CKATr~Ci=SH;=es?v~QfOFlemdR|QOG;y@_f0QdmDYsy|NT7+jn$qGu& z05~)3RgkB2I=I|n%v~y1@Jb6)^JD9uKiVAv8LXJo{sLL zePNwnxZI-)4~-?8E0q<)y^|}1-c+U~`i%JdWcLwj)rbBq!k-lbMXs#LnUy8P4kWq8 zowP#xnuD#^U$bgMC{`T`uAW-Gwqs0RTpFIyj$bo>-N(CwHm|eEb>hhWD`HkvO_{!t z2%o=zi1`o}48qqFDK5SGMjgLe&47{aExP>Va`(P2-AjPKE%THc=sE^rcl-78r$-u29sa#Y9rvBm18HK2 z^uWBtVYWBfhcYUn2d`O!Ey;WIkt2Bc!_j;5pZ-krHDH3nOZd5*;fG^FdMT=aj^Qe= zH+mDR!u9+$$$jSYsW((-^terR!UK#}-8Ve-)O~Iz(Q3HXBOLeX!ngbEUcB3D^WDzh zk9Z@!|0Lg^-xJOO6|NFv;#Yr{cQtqK8^6olo4fjY-c@2({0T4RJ%M(OUwwJUa!+_A z?+L`j_`Pq|vU}mq04B0m-}9i5YftH&~7hcniB^2ntpi&aNGi2WITH`VF+slY1<>o>5y_+6A% z%mE?%@~f{$_*E}~CtcMjf|v-ByPGgfchk9eK&BwYmB7#v%L!}`r(7-?<-fS1!nr+) zKU?H6>gBT&*7@$a2SkQX@oG>zk^RGjP<}EAf}ATr z3O!{5ZA;)Fs$LEjr917WLpC{yoh*Q8YAPS5DWM3n$#JL~LVgB{&*R~sc|zh3^MDh2 zr#Q_6LxksB8TMZXs`$zC0BTsC`IME9DT$L1B!!p|ugA)z)8t`KzJopa)oW;{(AYpo zu-)cYf5)!Y;A%_Wy{}(Q_v&ypqoC8o?|q5g3(`at2<Y4a-?|l7&;AMbu0gGkeD5Ew!38{} zhwh~uEj`ntB<~Kl$;qjvjd!q}GPzT0nx6=F&j`Qa`yqNpW;_6b9>EuVFGu~=^k6JR zF^2~ymqWdtWp98;^XK)F_bw>;K%|on3iaG$)rG(9NZSv6($rv9n3~#sx@)>Trn(S6 z_e8A!em}Z|8bYCf^YTAC;^{K?=((ax05*S$Z#{e9iGyo=1##;an`?w5cNV){k z29C>L@bL+cU9F93h#)T`GOYE8D}v6BFGySV7Tfgu*!o|69ZP2$NiWb+43)Y{rjS^V zmQLp)S$dhUx}>^zO{vXQ=@EZ?&WnYAa!>iR`sQZ6xP4h5&UyvB4(lW!Xq{R{Aw*Ak z*M%uzT%=h;l&M1Cx#jieIj1;RA%9dJ9;T)wOk8-W%Ko?zaU1nd<42G96n=-W z#mDg~aYk4n5qdy)TKu42M_Kp48(u-@^B?(Uhv>vDz4`{LRIUf@0UYUG+7p~w0u!g1 zz(k!8FG@-O?Pef!5{2W{;STXpz(~**fFA4%<7lqXNPuS7uu&M0*5DdhNjf)&l(+zg z{||IdnUv~=xU9;LUO-@yW1_HmPRH7VnarWJbCK4V-?g>(b7# zzE1J4dGBDBe!(&3e2MFE$o`NleK{VUC&)X`^ExmN6Kh(E_wT&ws^iyPxK-_I@ldC3cy({`F zKX&xGkALij%l@Gn1xevot~h+jl_w9$$b&by9i7v%5rKT1XR#hnj7K6VyK80F@XBVt)g9?- zsJGU8>zorklk)(RznG586CC*O5SUA^zVU5-wVHsG?=8B#ce#6Cm+tlE-HU}5y~Y;m z1ncsHD2PXM$2~M@iPX{=)@YtmuW$v^#hJsFvkl?cP_NtV_IZ)VUp}|FA@y0k(cLuP zwDE#{Q|mWsQ#Mn5INdbR+aH`f)!ifRqLUEsoAS<%uT#y=teOXoNvZoY=p<(Xg9EX~ z6jJRNSF2B(jh+#|O{tD6h38apwbJGvb(_%C&Le!$YYE?ZXV~HuS8>58KeEsWUh-IG zhcbJC9)0A<(uZGm8Grmg)&E+4{;%ur=V}&z0d3EB*!H~f5}_QU1ZXAl)uPKc@T=au zt3+GH4CVT>e^IPu=YvPOx&K5`L>cmz=%zy3wnLDD?;4EmiJsm`pRxAoT9fxc$-)L9 zCP}@Y54E&)2r22;_op`iD*hgV^#`1kOav9bN>Yhm{axPG+`X?GbN7-|;`hF^?7gq# z-J5&DYkBvQRN_x~vx`5snlO;P`j$KUHf5u9Ra5r+q7N|Djzj!n_U=EJ=!D`w=0bti z2``{r=7gqO=mNkKapCimFKd+E!4hj-{T_Nl;+v=)sg6f*e5knqpqEOwDWq;vx(KL;YnL-)-huY8^HK7Rdm zayVtL(@yg1FBRRMcb#^PUw?%;sDvUluv?E6w@Vr(dbStfm@PDW1@7gnhniRlC`&T{mG)>L63{g?J$2j~*A{+s5#Rk^*J+n2)akW8-Y zEGFNF%T0!|H;qP}R^44$i>~~Y$r4SI{^@d^Rd2MqBZ@u?p~Y~(oe3vblh19$sGL&WC28({E<5E#GZ zz`a6XEWP^1%ekv$qw{-Tz5pvLszNVg5slc zTMO~3!VU`UMgOH#D^%pJ@O$YoZgTHC@u_D}*QjKVdXpus8ui*2)5F|s$i8YkM zC(wkHJrF7Dm+0ofH}ZMX&`KsnvK%#~s29J7Dvdw>@zb%83hkJLzes^}bL=Mx!5#NR zzCb;wYNAH08Ywu6W;~VYe%a?jU7|<>>x9eOa46;Rn2KgR-JR&(dbsJblY$wK26*qL z2j+&R5vzN!XIJ}{ee`%k%q&=ixs|hK@XEtAo}+N^tyugGKJgIzr}`4WZ!U;Z5#!A=Lzb_Y$APJG0VN^$!*~O= zEPin0R0Y~B*Hs)DIZXJ=%%N3NsN(O-Ex>+kxhy@jcq2VbP3 zsw@;rMjsS^Ej2|GieBR+@tXMj+bzT0-NP+!D>l9$J`1x`k94}9V%3<^ z>tf;ugo_i~hcv~{3aS^Jw@~YS0DJEWi*Q+ZM6}eq-g|cIKIMe?1EXGiLeLtWM7MzK zBk5NSG?elKPI`9PxM04T~V4Ia*fEyh)FG;+vtGjM;FKLJjJYd2STEh|y4 zlup|G_GG(wvvA}WQPyxycuu@&aei*7Cp4czV@Q8m%h|fzP zK}e-wef*=KxD=djdWF|e~OFmuhOeWz9r4A$sO z)n#?1N|n#$&-jeZ{qv`*dN1l}oo_AqjuFa?x4QC$UzXGq`#^y1M@m{3TB36Uq2{?w z!*gX@OkUtcqbe>hF}WL}zIMlC+3d)sIiR}@#kTh%OC9FC+9R2PfYM8ZSW}G@jT)9#! zT(a}ioAn`kSG-C5j`-}>twN*F+7$0{gz7ebdZ&2n;2;pJ#g?DO^kp`ZFy}47W=iR1 zRN5;g%}QPjZ_OcyMYGFnX$p<>&rWx5KHPlS$r^~|b!n1@PsaLNQZ7q4y?*lE93Jj^3c~~7&sOx<8hHjz!`isR^D`sA&J5npgSFK5d zAov%DAZka)3rl`*KWGt)R6ja4k^=bnE1F$(SLf|&ef}T?VO#{uD^fZ zzWdj${J^&F(^=vA4l%knFe#=vJB@GVo;E7`BB2%JpR8$6$ zKK!dr9`_Zfm;u(j6*EHSQa*W7`~#ajnKvhDPJDi*;!SxdRCq*q1Be^>zR;03H%%TA z_0k(7;q1&b2r}CBhq2*h@K%`Qgp8Dq-1>qHgVL?^mNq(mJ9Mq3=WExiX_=VDRTKpeh&rCxG8}u)uBcPtCe-Z9JBkpKxZf*lmP)St=0ObQ4 zlT52~_C|sRDJXm=3b2sdjh3Sf8;i50MrYL;@t4>nLm^`FwgLDF2ZIdt1!y8^O^v09 zt;!{kNE&6#F+k?|KMOlpW6F#nMZCUn;FA~(IV)7PIyn>3~3pPN+0(d6BefyR%VEgGDj z6k1MsGd)m_#yFE#cIkIc#kt2KdSft)`AD2F9zyzN>gn?FBzA32WZguh}Tr>cd##%`rb)y?r zK3K5DB!Nik1XPK9=RO(PGK=@;frx5;tcojm@+hv=SJeluS;wbfth+i;e|ohvjYx4= z`XcVO`280A5q>XykybG^FfheuWK0hXOlMakO9RmnsJGebk)kbq;sn5CQaq&`MvQ{u zRXLLh#DJc0q`ke5^|P~0xSx*x`K_80C)mj=G+2b9_}OBG02K#pk0K+41C*?AEj$3K z%iC(4A*Mv)-+67EP&{-&Jm7Klrv|43s|F`FbnWWyUDFWu8`811Fads>@+U5w%UNn99N*n=nDOa5YFqg3dSqKc- z)AHWsbRW_gkr9~3jr33@afBHS&_6PmVdGFWkL0YwI)kL46e!5lBL(FA-o(24kk{7Q z&^eoJ{#MtWq-N?Yq@Y{`^T0`v)EN>Npnb1riTO!; zdyY-YF6LvA^K`{?pebi-S9n*TWlQ^~WZvG?5SnN#&igeQk$-U0;n^B{^|&wih-{d4 zw%RRLZ|+xi9y}kNLEOJw@nwMDRW!utkdvXb-`R~P-VYZ*9(SfgK|Gt00oy>>&?Fla zU9q&)Y_gcGPOHOVbXW}H9fQ-2vcwR6P+<#KJ3>iR7`wW~IxaBYcA!zI*;!{c7(>+t zeH94{S%Of>i9P1_`D~7oVy`JuF9fo31W^#!XiSbs;eN(pdH4-P~o>qvC`7#-9nq%q2+yo36}uo-13KD$e`E8w8JxsQZh-Ci+@fw z6vv87@hs}Z{#&*!vVe5!-Kc6|u1c&?l8AYy?;OnlM~>F`rwslR>cro7bO`!21H)rt z9d{RUWg#fjPi1MSVcX#|kwBmks!>a0W0DLOX`J=qbF6VZZH3-y)|h*fqcd#9b8N+= zD={ukr$4vwf|weyQ6}UG5C7FY=uX>pHW?SGT*LaGdh6Y`N?&bl@9Q5nsCelLzAiR_Keb&3oqfX z(y`<~XY27L8qLR)2#Q_u(;-D{_4o-D zTheC08UP{t2@Q67c3?QvWVX1>m+tu53@I@)hgV)WQvt)_Ym@8KVT&u((jWUm*CU@G zGveM$n_e6tJrP8ra$@Zh*^~P>iA3&FB}ug<%A`ax!lxL`+$UZDn)w9L%zHo2qnWc2 zyYhdP&d}Mvmvq7*s)~=wL`06LED^B*o_Q*TEDH&Ff44gE_p1$mpOU~89X9u|3K0<_ z)Ey)kOH!I3V*(2ePm9%oV2!fITWf6Ad1WYe-ZVUXllX&&{z>r!Hr2OsY)ep1x z-oJLk{TJ`FqnaP_FLGmDYD6lUmiE zZB*@3IEpSTx&&~b6QVRuG2h{kjl$_AnJ-sd{%x7hMT%r(l5!nn>{*A%Mx~-+(CIFz zDW_#Tu5!nJ5wWY@Qre>Snz9Oy#TZNmA`!bzWwDs+EjD*utkcwF%ve>fR3~bjsvM1h zs7?8n${zVeJeg3d+ug6k8r16P=Z>f>p=X7n#4+p43UfWB6-O^WN~<@7PHM}_!9!Cv zx6S5+zy~@a*o! zj@83Yg{Xv40~tx_PI)dFL-&Xr^|jNlWnds}qBpeUTZZP@~6e-JTbe?t`fl*?iZiz zK3g<;LzhDT8-wo5x7@PVnVL9}*O}~hNftsKlgwGEDs}1pGSaH?DuU`j^D$7)DXqP( zw!SHlv%0WDV2Q%dKwh4Ded31R5nxm}vF@S%&S6K^=E@;k{FcXs5G8f<{~zHgg$tHu z6#eO#kYr4fohEKDz-gJYA*56%h;IXxc5~}NV-WloUwP`{&~QVz&z~MJsnn(B!8H0n zG>kYx@l-U@T~pf?iKY^vcvr2z^aEF2dz-l_T;F6t3Ut)f95#3@P4%I)S=j4!dwtRR znt=FizsKzj)Xc&bpe;=RZC;j#!y17~HRZC87dy%}lKVt766Fq8c`GaR953#KQP&bL zQMLR37z?XQr(Zs$c7>l6#w-(&=z@n+;M)$HT72}*iO!bk4+&$!CF3_A!rQ5S_8A4u zJAe`)^^#zMwV-)~YW>A~wr;p+_m+;vSO@+nJU85Y%Z)VBAiaP8{(}eiAJ_+WLeKgI zp5;D=b8(3zi(}5q{BYX#?CFlqwlstrTY~NGiK&*cx|-m$w&m!SUB{Y@jUKl>-XGe! zaPiJ&D(X-9@QyC{s-S^o<9eAqw>^7)N`IL;l&SjqYaYK!1^N2)H;SA6h_gl&p|IEY zsnFunx+;AI2{SDCeJYrtIpo{``P@%-Z%P%+%B#6eF1`)YrAlT|l)ey+(vS~w6F$Xx zo^V1uUGNP%;J}J*KvxJGP2rTh0kQ%q3C$%gU;V9kt@AJF8HEKnp>a#I#h2XCB#J_}o66zYSyI~0=!oQVTt!0-*IOmUER%W%9itiQz zRSqRK4WPnojx3`+7E|E)CD7ocph8vzXn1lKAff|GW-4twnlJ9ZY~}Qo)mGQL6;5YK z@s3@ZQny?4^J0tBdg;`fBbOvQEY^-B`&)Z*=iEWvZ|&F|y}7im>}y{xGifd8ROf2^ zUhSdPJ5M&nRwj~Djg3>u#L5_GC4AO`G$#{9JonaH%7n01xI%p4bF0?$x41`o&Gpt-5WfngELIOp<$B?k!(VA3Lm+^l_aBC zCLXucD{5TE>5-8UldJmoH7?WW=+v0mQG>6>%iDTQ&f1XKxMyHhX*8xV8$!2h995Rvsi{$D+=OacrMhF1n^s@Sldv&pnzyCDH@6!* zigF@#xx;8%)%#C3K0X>vc%7}8fjjP7SKoKg+dYVpa<1M`{g~ZE(i7o8#YpgtFZn$AD^N{T=!`*Er)*c@bXGjmEFBg5Qa%;e89;ygo7q9I8y z_6BA5fudb$@Kb*=Xf|~LlPOTATx_9B?H+5XCRQwLD{(e%$;>xljK!AuWJ_yeK3wW> z3N4Lb1^6O<*Xck;7G1j3pA3F6Y--$N9q8^DawMBuTAF|$2sfC2CNL{NEb-7hqj+h$ z1E^fq-j|7v!BBKVT5x#Xi5Ex@wSmEY-~*fccXebow2R-bn0bNzd9t_TG{Yv7D8^_d z&3XijuZRtpy4#ut+~Iyf5YIxmfZJ~*W>4LN2e6aO%gZSrki1_|tk8QZm@YneWasWn zitnKJ=d1R2s1ENOYHb-BY->Ry<6EvjcH$HXwVar{?V3~B#`%E62uMZ%Nnh4(%oOa)(yeq4C{fw|RI-#v$oF6}S1F4Lv=2z~0i#h(d& zO=D_rs<8yzF}ixC>aj%PG1bb|QIjcjT_gU&<;LrT#Dn7V5uYyth)6kV2Sj!T6C?Ee zFj*?Ymz_>#u$P64H?nYLt#ank9UKQCSIZ}VzXT8cLMPc1r`{xNgr}~4uK*(yuc<=z ztSK)jma`)=BzPsnb7W8ql~c%Kb>d(cyL18obV$OE%oE%RH;rSt``?IADK- zZ6&;$+MdeHL@6p}@68BX#9;)*N~+3y)qfF7;qU=N_MV+UnI83fkz-<64TRkDAlm%j z_0q2D* zf`{;CFXi)-LCl<7GLwPyLd|p?HwjpjlvxDD#TA3q?#dcHmZ(@rs7oxq9V2cs>K2(% z`qC+F5;m!2ZBy4#FD!!l+%b=MZI6PPaoohb{sk->filIf@pAGBu;Z6a6;hCp%#YHs z73W)dd4V!+vJoH}bASdNGpB%L+U4S8lFfFXI}*rg#@wz0EJTD?0G6dC7Uv0*c@Za* zau1B0AN=fN_Y2b;7ci6nf+w!|eB*-}IO=8XT-+;skl{jB%zMODNWFUk?134O?|X=QW3jW z(1axZrr6*2u7bc@=dqivJ$?MfYfr76m|TlLH3qxgfIpW@pV<*~+4ujm+37Ur|A96~ zV&iGR5iD&H{sH=2acK+X2OwlM0r>8eh%zr^MQs{L9Oq(I_<@W)n5@OcRo$+L!ROLl zplvQg#S#v}+E`U-W9C=QvC^tli=Wm+zWhq)iaUoq=J2s&VXea@-u%jVLq|u$_$$Jf z@B0O4fEx9F4&ASimM&@D`OU55VLwdS1$i=;uN1O+KDHumu$Q-Xw)8oNJHqYGovRzC z<9=^{G?CCZ2D)d%ht8N&^;SoD$K*(NyssKIK-FIXBKR(D%lPt=o$SR<43-PL>YlHtgo|0Jm&czfQ6P06B?^j#jR`oFuam?~QQ?7*09s0pW zOHRzLTeZ$^`JC*svX)hN&4yqq>#_=3$z|1TXfh@|N^hzo2$z*NCA+LXX>*0wYCS1q zPt!;@`h?~Nd=7PbTWa&%RjJ@ocU2^`J{$;F)dx4{xvQ#^fyVJDFa;G-c{fgNE(%7_&rzwiLzT$Jh zsqA%ls~C*k=ADU+MOLno9KAQR4_6uvNNUvyazPMZ{iW5keeF$rgc`-GssIm?jFyX6 zHM4-#q3J&PEq{JSYICU_oe>u3iH+i?rN)+69`y!$0FQ%DND@_a)+ofmU3bfbtO`?G zuvhGOd0(RN!z-_Jbo_o*e|Nvw2}+VxagjW1q?6Z{)~B`}SQ@T6w?*BzY$kPWcY8Km zMGO8vhpT>!a8=4uTPI^e#y9riiNbJ|Nux%`o-DeV6kQswGI1J^!d22aWL(;t$yS}R zvv}^Y6W#+^rVb3vtW5_@%;WTB?0vD_o4zo$HM8Wb*@BZRKNt+t&PV+>?$8&UEeMxd zNRmy8MYsx(hQ)VK`6U4Ne~%~0SrYlS1D!p-pTcDtbKA^@#?qh`v=(;LR_BZ@iri30l(?VdL1PjsKQo2TjcwcL>^zC# zv4`l(aj7e>@b&z8G4a?Yno5>v+>{mI@~{|$k4S&uno1Q*<3p`+77l=b8F>gD1%i5I z+KVt(2DZcD>#jO+>9tphuU&OqNU3_enocXfe5LAY?aepcbmNUT-E_0i`mZ&gU%k2H zU&X)vy;r})SD;bL2z zoo9n(?L+musSZ$f9f?8o+oY+d$W-+ch5cqg2fwfcukw`CNHT zO*#I2U-~>KDDa3{j2^!aV+Q+!0NQYsb7pDXE6 ziXTh=XZDrdYW$-@xnH_neslTINRM}DWMt6aIWm|?V3w0_aAai2*F7?nN(q1Mz<_0p zSniNN=PGM!D=KPhQTxb0`;{t{QamgF`_j*!Dgcv0aix6w($7H^{=*&1JmNq9A8GFa zAX!!AjrV<(bIv*ET-DWGU7d6HbePbSXL@pA5;KGuauN^$5m1S;1QAe?6$G>2hoZ8m zpaSX|R#$hyl_lt|h|0YFe)qnr?&_IAcmMx^nR@x2d+xa>-E+RnOfX-2UDr_#7F23zs&P*VunC;qL}?ClHH zf)ka#nm-)z)#wJf?tiuVY-c&O?p}Q&+Me|KgFzqWtrIOxp(U2?)o6(sbMMuT6YXH% z=1-rojW4@&PN&Y*+Jq2|ki){vQlfLyTBM2|!UP1rE={_k5;cThGR%J&VwT$DzoR-B zGK`8e`~!jkVR77{WnU8zV+xq)5-v4>srC681kkL@2PNu?f-5O1YH_Fl6W>PHKvKQK zUm%D1TfR8l!$LUlQCfb1CQ1d7jn)F3fe2U-KG{$jNa!M}0WdIoUxI|7ChRW&G=J+T zx1Pr2z}+>-rRWcG-YD>#^5px_N|gT<_d~QYdM5Xv0c?`;kY(vl*t$mGAYNph^l+$) zAB!~tit=c=iLMuf9rC(kH^1nqxH;eSp7+gNc11iab>+rOLp!I2B885a_0w|{rHwdH zK*hiEdL*OOC}>{O1N={s*VM6hRc>{sKWnMv{m$xK7iWz4|Fq_MbOMEikW40Ip-@4F z-aulLA3{Q_n>d4$1OJ5%vf|Hj{vDw%`x{xB=Hti9|AmKvXd5gu8jl2T6-}1F8_i*} z+Fp~&#DzjO8R|)p@gzqgi9U%!@|LD|(#JVQ>e_)7zAvW}nUg=WkM__MNFzks9()%2 z#tF=^N*ZG+n{o#r6 zL3a$M{Sdxt5pk##5u(PWBuH&ZYM+5kN`>7BL@3cOVh1%BJ#EtQOqRA&T`x`-hdmx!aE)y={H;LD%4*$2_z;wfz|P zQ}5WN+trz_UT=5xM+!YIy9WtWK(Iaa$PQ| zR0Oa>=CtUv)wMaX*b{OXjB0HzA6WB|o#ITFLw>(8Ay2M6Z*t|qGN?mgQhR+iu`HVO zsjPC7I+QmOwaf16?(rr2q7u8(67{6|OBTCUo83IU;{#)6uf}pnOzT;ah4*ov=ALic z&Bl)IwEzeakO>H3A=#|(6d*bi-UC^zhvr-f2}Eot&~1utbcboIjgH%T#3keoAS#u9 zLMtLaG9}DqozoQR;E1m~R~_+d4OH&jB=&}p{p6PDq`_2DXN_BAGGnwDjdav=3R8Po zX9_qK`tH>dQ+KCMg-kb_GEoFCktJ(AE~Q37h)q`;4yrHK>W591D{k1jCqYQP!&`GI z{IW--9tX1Q4f06b?X$?`5s!0n?Z%|9ud5gkt6e6OU9Rh^Seyz~tmLtdWEH_g)Gkun z%Iq+(6gm3HxOfjo)yD(88l2mz=$y9lr|{wdol17 z6`Y?l`I04%vtDmE852Q;W^5Yi<)|_ht|#R8=(7n$LBCI|6p2*Uh__faDplc9UabxW zxZjlzj6wz%qx9^-#g9uxxqo8Vc%EC5YCE5+VkcE3$;E$8(*RGhL| zm3FH(Q0}x_GKjkEiU0c+4j^qV}N15eX;U=3HE9 zBEo?`m_v{#jV+%*Ld`>Jkwhy3`B1z9?bx(cBx_(ELA5VfD>k_e`wgmf>8i(wQt%>= z710*D+Mv>%jQF3sna9R_k^__ThBi#Q+DMw zQ0d(ktySkWdUesh$w}Yvq+Y7&$3}*=fIA=&YfOrRYKcLv)XI{@SU)zx;fP4&%N5N> zZdnsTKj8+%CV4LloSHH`=6<81G*n?5?@;}nX-kdhN2VXh#B^#9V|{<$n(2XiHqVX! z^1!P7#RK!Xx%Qb_vD7y?^`qYt-S7o(IjZT2zM1<*rV=lJ!Y6mQE0NCLzMe|CKlLy2 zZ;sE?AOKncT)a2Wr&qg3}Q3&F6 zV_{clvM-^N$bFqN`Eo5*>oj%Ty(vEEb~@!KG@Of7SH_i*s53pbD(3O4Rhfd?6Y;n- z&Z^63Q|l#~fK+Y{$n`dfxe!vA3-x%wD$&Wkn=Vs$vqf{fN}@WyTSSNo`?0yrE3aRl zS-qhsjbu%7sXb&+gexQ13og6rP_Mq=j?9jv%H`B}Ev^b=RSLD#fXD`OeMDQatrmXgsqz4N?&eZtVUuLpzl zE;3hY+$*p=A6ObF!8h14P{3R)kD?V_u~{6ZD%SS$|~nt{tghq`^Rt#!o{TJhiqr`(Bz+hx|oQ+^32$M~?;KK@T=8^Ru8bElp00t!MZ-a{Tv9r)rIXw*QK@x( zQ{xVU-ZVHGo)rcR6+2#rKXK-i0R!q5m8*uV!qmY&6BNaCw4m6~X@ePvVW|q^0d4G# zO0SR2h>*=~GZc%dp>A)c=I*-YNdM*4Qhz@0ml$Qp9F`a*>OgWlP>Z@$&QdS0i9S3(MY&M;!e%8$!bTOSWHCpi-h{0>^tQEAK z`v>0ay7Ka4DX~s&ncBHOAC4NK@2aqX5e3(nZrj|ppGgZk!`b|Ar^J_qn4tXMCIh7i z1j=+Rp<}e8P3=-3(-;^l71@lkv>dczf!$C<-5BhObVs6V5(b|M&h~*-DP4Kfd3~9T zIFqRD9FO~<&APgWR;&U=B-PfCTm?Cv- zYI7ppX$g-fT;*^{X^J>Quuq+1DWyp%N_O=)D%&Pg3Zo)By`z%u@N2mGB}X@wlfLlY zgPR?t!3`dRJvx+;QDe$sw42C)yW(26J8yFh9leo%zXx6=tIO)v0!`atBt8uDO@@^? ztYje4js6!v$TjjELI&>PM_+%G8?6lTj|_4jIeCD44u7|uyqErkvWqz{+6pA!$<$rO zWPzVpu$5;5&xx5Yp)zGv~X?5 zbfRyc)0_&*JrhG#jl~&H_{9okrJF8GA#|@8>om>E3S3+;x_LRx z5lQ4e{`=qOg8`JCK`x2`eu~FZa9sonFEN7|MsgeXwCEg8N)=uxz(tsqBVZ!VHh+@_ z1Sw0Wl~iWHz_4`=5gjBOx%9|~$7k+5oZpgn&d#MKeLd?E>yx>)Z>>%wl^Z{C_Q6kV zQJXq~Cd0|+eHvAlQqFB%eb3BAi55boWNKO@RBRz;D3MlT1-$( zO6SUEyefczXs(hWzyzEW4w@KHN<)=dg`C)SBD(y-}K&FTe4BQj7ghCWa;+^CFvO=ca)z zHvW^OoBsyI#4kvQf5T9(S35N)KSfMZ4n&335^knBMyQsBvfCCCutZ8E6or%PaZZ6; z_$|C=oSZd^Z&WHL@2%h$LxfUT+;8f5%V3gKkkEGW1^$nZo*3kwtDHQ*-h&IYW8sfb;!*O1dz(d$(r!IVOPryivo&D6=CEBJGY_RKx1W0j(XGR#KM`7sEYgGu2cwt0}8@PDuqmIAs-lx zMaq?Wzk7o_a`X6myZ@cqF8X53j$-A5kf+a=l+x-rUu_Z$zOjCo`)mR3o+35($r| zZdB^?ez&7qN|&td+Xrn1qA=?8O0z{-o!*$MRE)m%v3MlbzM&#EhK=!ocqpUI%+vzp zMx|VXoz^&$wxOoXa{r7ik-%|Gg4+tN6Q=kxeVqywL4+@wf7J0p6%F4p?%L+dSC{&-*)Ls$UaRF2 z>H*JCPmF)sl}LqT%LIzcXKmlw)h{b})Q+HAT-!aLQ-(M0u86T1De;A!28Fjn7K{a? zRyWG?iIi)uULnYH@s;327cqJi^d)L(G3sL!M#Z#EE0Jl%@Dx(4;eV>2YhijaOH6Rv zLAMfBd&Jm)7VqMJkSU``sxH!}MOSu@m*N3iBCC-bli}+8+I6|!i}#20V!J=&vx>wv zpU)<-clP$U)7`<2Jzj$$-(EDTgJGXpZtUoFsvUiO&S2aTO~=%_Tsa?->gO&P?cNab z!b~+w?V}^cd_F7v~IPPe)v;8oqqj z+S@9b?r7O$-E#Xu_sC#TZr^xdf2{rZzJ7=-I?3+@uSg=uvEFo#(o)hMSoP zf~mxI-VQ9*AT|kWX(7WA_~NVXtVtuW>m9N26`j;guDg~x6$4kR2 zCSe0H$Swh7CK`Jvtne;@MvcwZW$YGEX4YIX^5lIZ=Z)U-vC(U{@7cJ1-}r2!`PItI z#zEG8_e}iBFCBYuL;FLw-gkFPvxpf2Lf!?=K^4r!k2X&Y6w^qc zEc}VP5*aE+wpJjdfC#pd)duk%0`EjPY=|_R@l?%Yt{AKB&c2nwft`KjA=SX6_djZl zt-62kh5`Pb+{${ul+TD2l1i{UYBwu3ZyLDjP=7T}+E2=NmuofdmXrH>b{r}CyP|~X zENd@Vk?qnjNU+->0u5}eas<>O7WYD}Hl`KHm~sj3A_5btMuQX*8&!^C0>iTX_Rz89 z>J!sr+itoi=F4WbW+bVNV%9lFt&k{3d`d+*T#f#ipnqxv=4(lTazYJ2<-%yBck5C&IP`ToybAdB>FDSNA${R5`~@a_k( zNug98%5j&Se8E34n4B{A*9k2#j%|FgByDHW9&I-2BqIohrp_?dE@y$pTNQh%byl^BGb-;JI-+p8%-mb8Bv`4V|??v zAW5ssUNZGtQ?;w00p~1Wew;*`9_X7w=mk)W+&59k6hf6E87(@=^dai>i7;XW1u$KS zKE-Zwp886qxa<9U9^G?}c!s+`b+Ah;u^XH=r>V|QU=YZp)~m$QoKI2fuZ!g+<#3m| zg47q@Lw;inRro(xJSwA3d9h61MIPseMI@5Mj<&e}ALf)T8VBo)0L)&@6soW@DxhvH zmUu3e0aSVzLcAy>V5yxhw8I$V+fR%MqXgrT}A!`ns4b5VUi6jhGEa84l?1%aObQ;jDMX z>S!u!@|1%5T)||rCi{}EE-zN>r1GFqBlD-6TF+E9wFKpScp%|PSv+$aBVt=N%EbyX zk;7-SnnY@)*(E9*LHW-u2UcgXg1hi3{vvKAxe?05NZTCa3Sd0Yr=Vmt)GJ^V$YU7A z-fMhl)VbR9QX0&K;D8OWtrqYti&;LS3HF)>fUkpt%cFC0{KvNO*%N?kEe!br@J&7Ie%ql zrf{do883D91$<@@b#CZS~1&QCpHL$k}R!(C( z?P#ZObe3+Vo$m-GbZTf(eoH6Ky*+SIsj;JWeAmXa_G3eB`};?x-7c$6F83vRl){pH z7t18AHMKY)4iB}BrP2bO+s%@cr$E8h?V;~cURAOfu|qRmt>@s`-Mw}bc6(of<@`c- zPZz>^X<&sNBKhaWn0pi3`+d_AFa@%qe{B84i!@lDgGpKAo&xuw!C!)8Y*yf4aAArw zz=U|bkgSnM)0|{gCPSr-s)bA^yM##*zhI~oRN3~cKXmgoh1fJY@v}`|S{o_)&0(88V0ib) z{sa5>AKXW?rV%hhK95x;=oLabS%rF{m^Z2kF7w?BDU($3WVoZdaL&(f+SIyH?0M&5 zt5~%4f!x<}%eIC68ZIHOh9!k@(;%QQO|}(+W|^ub5Yl_Ap47w^+7%8?ZaUD}ovIa9 zmwK@Apy4iYFqa97v8}FcIy*FF?Ww0b+H-bWwWnLTdL}oZlW2QW*`9!a3jW!YBe^o2 zEAPIvwzn^zNLAYN#cEwhKA`#yOtOuz6=;O-+t^%(Z8F_p;!AoVtE1gig_l&q#-^`= zHW;8_c4YDa@e@@6oXWsVE+~GY32Xy8bzhn0(_NhFV34fa@+{EBucct7^58VT2OMjF zwg|F;gt};zG94mDPz-C0aVgReX3RUPw2QzDQQv`@E&-$`i+1iG8IvE_RvNWRO~V^E zy6t8p)!E7wiP$=p>Z(ZyC#jE2*Z6lyr}C=zb6oC{qrlg{qrl3`r5S9SUko9|m z>yMt(nmpGz<*4tePe;`<<3wq61xKciY`?BznlG{4p2_3&!Ye8S+00IfMD=jh-%OwL zg!>U-q%n$IsU3&2=U;FvGrYLhvwX+QXF-aYh2N8hxppWtRHH_S1Ct;Pel4hRSl_eS zXy8D5)dmk0WDQkhe-xiVA$x@+I2wvg2gD|eO0SUfe~?P--9G1lOT{lF`;+d7N)~tO zR2iLrZK-cp(2%n_{NuA?vE|z0fYanf?1RDbWwYO=o+^l~^MFSI$@do^Gk}aYsxrhd zzJekPUKTLh;Mx+P5*A8n1X)lM7qPPK7Jm+IkYq7 zvMhl@P;sWnI1wDVaN$v#_jCqixNtek!5d9h<14TosK-H=)dew|F9no8odh;9aEswwRhP%X9qg1?fyW;dEISc^|GyGhz)TBvPBsr{ zMiKuzutc-BJt&%J9(;)QLBx>Tg+BN(`;c+jkc14ajVf9t<_VHE0wZDJ0@Yv#@cYu- zy#Zw^ZzcRhw^WrNwP=kN9voDXbL42rlA|G z^vjrrBUm487z~0Vin^8?l8@>*OQ)^bB_&%^6f~wQ!~s>0VSxca?wLa8#E7eXAXl|p zt)d%qwN%Lp)rS8sO)!-Xh(3l&x)Ztlv`HRZTV6fCexqV`xQ3|CgtDA2)jm2f8Xrph zEagd(4FjW@i4@g#&@a+~9<7A#R_q9E*?U)RYGv+--&+v+g6%Vdh?PO=o**|*>x+=K zDNsWLc&1=1K~1h>P?!7jopz^D^eR*yWJ}zykpyOotGApEb<{-fFf9QLTn)WQy4k!fU+>F}nF!y_FlVYp_y2O=3@R-OxGA{1KNa9`J8 zK%sYFH}GFXb0*(tI#L*WS7lPz#FQ7r{joaPG10qn<%_{UG8qn&4ipcA*iZKjSou41 zYr{&L+N(7N1vVhatuJvUc*x)&T#{SxJivtsO0mfV(quf3o?ER=iS?WKY(Xwlif?lm zr4q-W-(kYf@RE?a0piNHs?@hX*yr51x<2JL2W8H!t83#P6Wy6Qv+xu0C9V&AI>EBU z(SpzvZJTX%*MtCfWFhS01FlOUfB>bFMP#*}qYs&=?EHdHa1e77_sga@lBp5u~cTYwSsYDTArKSSalw_n#A%h0jPCE6e*kj?b?1;e-UC|L8 zbVX}jZXawGU#}xVoVqW{!`@hjFTUCA2)T(X1>3}7OM8sQ?)l*E5g0BSb)9sLW%-w^ z&iHO7h~7rEh9KAqQ``u(Q_rjlF=MHHvFZ^74hb=+i0{gpXJfCR1C5tg4lo_){LcD? z=N`ZK@MI67xB3X_CfB|q>8{obTyR&Rgk>Y9`<%TEy3h9Qv{+L498^l$fBvy!{F`ex z?SS8`xbPFvD&Q#z^{tDlZ;~p;i-J*s)<{{lutGo)MnzJCRYPXFdN+D%SB{q>wT?jO__;&H=*K3y2{FCkpH?Wo z=j)j4KKY44y`wIPZ`GN$UvPeQ+u4GWk`0s!yL|Ocp@S1QT<7cIY{!mmDUq4TZ&IU9 z{t4gN12a1|twm4T7yc+(%LN$k&eHN<%{o%=*&U#{p&D1AR5gI-4j;Vu__-I>&j|nJ zLp>6rlk^UbcXRxkqCWmy1~M0*N!8m=gSxzRe#=talY#h(Qb(6eB0YXCP1PFN=@jecNT!*jb=hS*KH46r zUA$rM{9x5q^4xG8&DZKEX6uUyTZ|-sBHDm)Pr>uk-Q>@wL$YkVLm&-+WWrotzJ=xK zfK8Md<2+mncU3J~K=^T`@&$j_WcPKG-57Ac@^m=8^-aNp6E}XqUE5%GW$VP2nQ)Qm z@KOFV=N?IoV6X#FGq74q{tROb>myZK(T53S8sATe6+0V6=eKOyuyga^eseVj%N|HQUPY12h7kGJ0@MIyA7T5M0C* zD+|}%UiZlD>TGp9meTP5hBc2+R!nryO>~y3YOg#HGti zh!WI*080)P-D#3oigA@EYC|bicT*nktQ5t4nW1s_$EZt!eHDI^Ae|Jae3d!W1a3rW zjv*1o4LdkPTUuWs=8MX7xnQs}RkWG4R;|usx7gefsz5HaA1JsyHnTqv_r((tgVAhD zMAAd!9tIAp#KM^YPK z5mH}j={pkXU{`ITGZ73Wr7~A<0SnllhgJJw?j)@y+HemrNx^oyY{fH_tIqmJB<^(j zU6NIEIUKqi39~X8am7eEU0IzW>-X%4#U1`suB*ad_FE7)9nD>s(U>w+%VaYYrVneN z0`|a63BeI#m{|*j_lZn>puQC}1V@o~jdIK0x2R32-Oeyi%qxdY_ zGnRdr*`my@vS1cbx@`G2#aD%1AZTRA4%*DCTX*bewxS)VznVc)lezQJ*uUj+x!Z-7 z`ZkVbM*7DDeOdGoP^F6IY+)5?(e4#B3u-TR3Zw{NXzR39cGk#5Ug|8!1iYGjuTl2n z@)v~^;!O*k-@1k z368EB8C)~{BIRPf1pjQ*n_QVsZOYP8G1P3Kpm#CQX}MqOS6%L9UF6X0UMr_wW;d)ei)dcv*E>G5HWczj;)(8wk?y-FRbjZFXbw3>`~UP7 z;-O%wFjVUrf8@A>Zsu?KoUWem8Za~Yz24JT=aEM4EH1}VdEMKFbO)si9{}_wuP3%Znd|9 z{*X#X*^aNCc$R_A_>aZgk-;GA8fflgJrL$iwfetAHCt=4m@{g%#^)IQyU3m?>YQ$~ z)ArUgiijiB^)QM49gKhJ;%FhDGkL8H>2Gt#f%Io-msd>W$)<%JXPpVAr@dB4>c zNL#`C&j<3mSzp@d#(-7A6s}WAycKjtN;E3ZmYe_r9twh|2^s{*{!BDAMt?hgRKCs|%#YiuMT4iu`(aG4DwsZ9NO98)ZqouPB>_2y~vvXhu@iM{oY_TYlq(!bczjiuSxbF7r zuI$4a+mR#t4(>-6)eHBM>&ZS=Vwuij)Up%k-n@Lda=ENjORPS>MRJ@Ic?YU>x0O5Q zk0qjhE4R&|bCUs=*6}`lsM^u2c{KkcQxi#239p2lG& zp`pGgQt1f6_ytmtNU9W#zD22M1$y1?3e2usyV7Gf##A*7-aiiZ_VxDm_Y7hzJPUUc zMcb=19Is(80ne-%u#`njbusA?G9FM}SLpJpe6dg@kSpc~tGT@0=JEtQ(Nrlny2tOg z8+;kB-Rdxzo6~QX3nAB#Rh= zNMeRwi^eULZBC%(ax|EX`yED6$Xyy*(Koodd;h@my5T(o{q+tcQ7ObyjkUX{e`ad+ z$l0?duoVaDTfD}72ihF$N)sC?z`VQw+bL&@HbmoTVE|)Td3VUIzV2?HRsK~>krx}k zJw2E#U&S32A-~l-B`9P)g*ZVsR9}aH35f|R9!`%}m4la$pdxRA} z792;zWvKCG5j>1lHfAsz*m5k?P`1_{p@by+^62axGqK22&-g!csd6x<7Rx(oGqLFE ze4He5g;<=-`(vXr;#j@rp{}m+QCDryspnL3W1?R74G)d>4h;?te>`71i;imBO=KOm zx`to2D)f5!%w5J8%`75{61Ft-vdAB3aK6VDS)ymKS|6(1e{qTFf$ zTm12C7g`k&UmK5B5$Dtx8KHxVdVX`w%p%Y=XicDs#)4TGqo6Zs^)7Q13%Xj$v-CK1 zTOu*1Q4RLRhT`jbz1ropdpsWLdW^}BmQJt1NHoBB4PYdffguY~ZEYC@RaBIqmOf)= zE;O!HnN-Bf`9lghZCs0#Gh{LNLvnh`8owKRI=fTc1!(xkat1T( z(1Bl~{J(GG{CBx(vzhHqXFQjQ@Akw}F~60Z>$7ru6(UoWI68WcMd1K4J3VpiDX`~A zJk_4Q$fI@g3!p%-QFM71yo~=Q+JQjDHWT9*j9)`p59MIJ*v2bAV)IJ5+TO_hU&iB=rM4maIitJ^zR z80pIg3viBcnrzK$m-{2-lOKm6h7ZGOKz_hJZ24DOMLYmU#n0Kf^;LR~f+Hx6@EjVp z{gs}h{v!D?JGcLyo(Ugg*oYvwK;8NY1R zn#;%9FF17Q`0+!BE&zT|+Kw2Y;AnvJR?LK+h^C!Z2T+pj+)7@Krfd`&!IS@jcLY?V zv0?(8E-^^P7Ac=#AjweeiQ-u1eW<<1*_r7s1dG)T<&F(y&-lR2FJ=5PaZ)mJZF;CD zkf_XdxuTJ(-&adU`d5_C$$E^B0tJzUY4R&{S=MGlI8VGA2ekMVF!52H8dH0Lyv$L9N-GVR27iKUx zA$-}6SeXsDLU%X*gD^aAQ}ibnHjp>i6V8P<*%PM@P;p_3{GLJQ#v0OkyhD{mIz&>% z585d0ZNs=J0IhWy?NwSx@4&iXK3DZ;gA=)I02} zFUBZj?ZArAt`+H(r*kdOrf9@#l5>t&sqsD{FxlYN*PjX+S>R_@C;ZJnI-?o$!NOC;*~Ar zDO@?XaV3to=IL8)=QXbA@zzE3Eeg{dv~@YkGr<>#P~Zed=#n1w zjk%GLIkF!fayZ5RIzRuF9ka7LFe5L|I+GrD$(jM^G3WR-qx%eC?N)Qx;N5k_z5>_x*UU2%}&trm7V0q4s+yCM#YY zdSCClb#D=UoG2cRxA7zn@h3f8w57Sm3Ya z-bXGFb#hYGgWzx+XzFe5lgPf&P@!aEjwD-%DTG+^jm8>F4^U8YiTjTodmzyhy=2Rl zi=zw2?#JQ9Tee&h?MXbqnb)0l);g5ssh|Ayp7r$=E9&d_0IJZ!&BTQc>i{@Bm{nN? zG$ulJn&mqdUHIXLo!h)|g;90n_TTP1>R#)7ga69~)w`dX$D0q~&5z+tZR1T5NYfId zYp36_YGa;l?uQ?a>TE|o^2e;4Tq&0l4&D;k-X6?;4-#Ya=xyKa@Ih~=PtNF|Cc&qcx zH#^^Io;QCn{U8olzQ0J&1WFjIRv_v6Lo*C+nA6Uk{6oSW$9}G{7@24spaeiWxhCfJ z#9|(I?Ay%)e7n?^guwa<5MX3NzU5n@N+eQ|SXx&bh};nhIq>%lTDOk;h+k-&bKBTu z+~QNy4?jHpiz5H^<`oc4sqH25H1|3d^}vWQ7h|v|S}jDXLfB+YvqCTsrL2!^x$m3t zDbzox2NRi)!<$NvCNo1mpR*E-W+QHIG(C2&)l({YUoz?k)W(q4Q*!zTa!~QCre3W% z84BjTzP?jxS+Wuo5o7O%{q(?}8MtS}fIdy>hslgW#d$!CJ@q$d>eB;CQNS8wom40`Wj zcIQ|!`GVZO^a}k3#K~S{Oj9j7N|_EAP7wS0L6d{(^at(|Zm@OgwUS!Hlfc+)>l7=s zP^hfsm~3!_!wzhF{GG-x?>^;%vvE$of?AYbZzSyhW%Jf8~EFdVS$hDkdZWd=hd`^!mxn8=?Ci6aC?Xx0iNzVNfZ8 z&cy&Xd8k0>bV`H5MJ+?riiXzfWM`wpix{}RaiO`5HMgSKPyR>EqvkLFq z2FrU5N$d3`SQ?{3Za8Dr`qG)@~8kbO_v&)dF2c0pr<_-^|R9Uo#Mo#ZJnU3eJ+ z7HmjKOb92;`!C?zn+Apt_Z`_R*!`x!`5%j$ zzb9n=u6RMSe}?Au4%86L7ifA+$@_Et8{c?K^jkh!G{B^2TZ zp*#!&j zPO6dFMJkP4DwoSSB3DTbsHr7VOT+{PHaUe{jbex#E^4%5Im)bSi3w$Y#1c*{Qxma5 zih|CZNNrMS>>3lQ4T!OTZdG|bGN~%!$;ow+P(rPgnj@&%XfehD_`EGWV%B8RDmAJ% z>5U?9ETUE!35j@2senZ5sOHpaPuvhri^V35K4>@f)YMXwMq#U%bK*cEqL6B|YN_8V za_Qm`g;_6Cu_r6o{<~VR{4Nk|VByaQ=y(arT#!(9C8eu!!gXxb zORtN`=UT4AchvTE==CznAlmMvB;R%`NSz ze(wm;fP?jb`Th~#YKk(L1EfWOf&WluWFtgITD9(FbFuaLJDO&|Gf5KVf0`r_BqZ}c zCDA1RGZJdtJo)jab)ae-aI;PA|Kzt*BuwA=XA;9Z*!zk^t*&6$;RsXHLHOnWx%v2V zkxe(ffz%I3M1OefzR(*dGpoCHmu|3GsGyhFgEt{ zPe#7`wT7hZ*w=Fzf8QA;C6d`~2dSi7l_yiDNJ z(ste^LJPRRWSLPzi0KhDsECdfO-u&AWp+wa=b%BrB$pUVN4=hKHX5ureZHYgax|Ut zIzriau#QBesh7OnnOvXGn@7ljS=(!}1_z6|0l%{Z6U3+<&>PWiJ0vCDQk6+c>eeV3 z{``$^)ZZdMFOr|WYX_6axBUc;IT@J5F|d~cl)L4U5epWrbF+00zNeggrXd#jM;ZrB z8-biIFq>lXbioPW+6gMDkgq^!ZwChxF&)^tXe2BHtd&u;9+Mw8lwAHF8V4lPJXj>m zMLmDh8*fb5?hcojmXH2c6EK-?P1j%(SEk_!gsqfdepDOih0E-=^tMW3Dz6SrmpdheB>->y<-)?&WtVs}j%lK&P zWNJPBP=i>Vr~J0qYA-kWG{1NRtfItP+s}y}rSsaVFlAWHG|(zFVSYXjwj6Q$vXdKL z@pb9+TD#5d%Ov~GqCOky9i%=!R1!p%0sKYOs0}eQ7c2IER2yW>MMzzJa-*7iXV2Ge z%P=<~^Y>*w`nA0nkcovqx4psrBO0gT;*{oFBTU~|0%aTRA*2U)&2pGd{-D`EaD>Fd z524tx zknaUijbtch5b*q6aP%Sr0o^xmMa%>Z7+!K zYLi1(bU@Rjxk%`HLu$%SJ$TOxAL{Mx_4}It;8!@K2O`tZ(2zg;?ZtnGo?SYmSBC&| zY@xU99nlSRmjD~Jy~|nDI-&JW&h*=km{yvGY4(a>x!~`aexYc^(kpVoVj=ime>ab1 znoksp!c(XG;)y%NR;S6RHU#`Wzd>y_nN4bg-*@`4$xN@8uv>QXd4Is5HkyQY&v4v$ z9>fxd!ni|Z1sjXAv<$Wog4&jJ<{#5%IpXs6kanNc515u`t_rutIY6C6`>?J>NY) zKSy`}xO;wnzI$q9WQr=2geWiEy6GbI^okWLiu}ilShzy=>oNX=&UyR=Fc-pa zel8bgi|I_}f^`dp|6{Ncz|wI-Zb?oBgE57QI*|E`z2tp@1DOls{-OuDRA#rR2N@Fp z>|)6qfb)TcK_Vr$ax&#EVZmr14LDZ;!CEGlFdvI|m|ldtek z0hf4&ttB;{5k{}XmXLO^)uRcXlK3vt#n!3t9TOc(L}3`%@)WLrl`c&^Nk2sDY*C6n z1T~SWZlU|p)-QK(*PtnW2tao(T+Ug+sbfI+&oSer_@x%29}8O4JrYCc#E;o^gizp{ zFSb1`GIMOR?TAn;lEZc^fkugE9Fn)o`_`0-b9*bq%l|sJemIdBUY{f0r=^~OdVRnn z&5KOAbUJ4eQI+^5e9j_#u8M^^8qS4klQ3fGmJPIW{7b~!{09GP;;rnRE0)&mtvtQ- zmF9QxwE+?*Qqc*f1kkw-Cy9mH6Ocee+CG`JB-Rb(vN~NhN8+YT-?q`sXAe1&{;`eS z-5ZAeu*lvZ@wUf>51_mc6Ydqt58$~l3NJRkLRR_5Hr2Z~jQG;*Lz~VXa$@pX+Wws& zi=7cKU2|(OFiS1RG?I77#}F?{MddL3{3cjfD(gys>56|lsW-~9LKY2mtTuI0KcgX`@5hw;4&ApZ1iU&Vmy zQQ!s{MdX)ofj)Pvtb!h+O16T|RUz2_NSmcR5wmsqT-#k0BTY zzf?ld2}aoltr`G&!WF^|v{w^}YDa&cE9_3D(@A&OwW`1S@=AcjIbZJmJ-w(eeNCjQ z5Gi`2m&X%yY8TPQq|in)+!S0HU7%(h<|q8{)B#5qp3qvTh%cw`2Vd@Qwzdjiy*Ap8 zul7YQ!&m#eFDnPQIPvGM>*+-kE761%O+cwxT)}1lul!~h>x}O3D(;6#OZ4QQqGk$_ zci}T_ziB)!Fq;Oa@Cr|Vb5+tBLA0++}D-Vt7et5cZdg6Zrnm*_hucI-SJ5A3?2m%fnBfA)r}KbHrj z0rcQPZV+-L3`m*o53;}}y^9nG-)N}Sy1Hw;f`H_RE}B_0J-1`cl(^)v2ZL;*vr`8?Tn$X?(ehwiyh! zw8`}~5y$_^J(okP9e!URRTEmp(1f_F$uC72>?MayE-)OZ-Ga!08fGc-{ODYOih%zR z0lTC>Fc|0xDf!n`y(hMA+O+k=)~l{!8?0(O=?1HFypz0Ad>ns@{KHGTtYWyi&v6fc zsnN7wrjmfuBNGbK4UJI34{a@;*jqkyes=4c`SSKvTc197&qnEq6XFg3aQNw`Z@o*& z%gA43cir|h(1h9^T#RxZs_F<4gly@b!pi7)(NETlH7;2ei@&C!>TJdzTFg^-;jzNm z+j~FsA=GB?*)%;oHv}O%l-r#9Po(+nUsu>%-<%sqx)(WnYJ6gX{}I|g2zI9D)?uQ7 z(+j1_SvTO5qb?G_6<Db3lRqO@^N()X zO3vc1JH8)Y6YIi5*zxQoE1uI`@oxn#s% z$y7&z zPTQk4N5r$UB5v;U9+yd%uq8b%Z=ftjWV=M% z?s2*3c7t9FvVn;VkRx4=BLbsBH!$B8x11GXbi(9S{xVe1oeBD|9kP_XitS~^zR)!G z@#AhU7EkV@`LvH>FOe^Np_~ixt1+fvW)9FhBEOJLUPAx?rTJ(KYp4~eFuf@57cmXp z5&sFU1%`T>PmYxpidCeIHu-|gQngjd=O;F0BxC-);Y3$YPr18C9-fF+)|OKJfmAgI z-{5Gm+hnlBhxpGK!Uky~OnKdf*7(GDWYAMjbtr55hK7SXeG1P^R+O?Gy-LYvYQw{r$FRS+!_eZgY<4hFNsRyy z4aAQ{Fy}GxKpp^JpcpED0+!-K6PphlDr&V_O>t~}Jc3OziW(hG2FokQiG4PA>((Q8 zTla1!WcwaX|4cZLSb5a)<6jf<>z~*z+py!-)GS(0qCoq%F>q*|*rb2KSjb6F-k`G5}o`rp#0 z4O_)xTf~s}NNrY;LYh}PT*@thy7Ra#Zua}=CrM8tSc|Ud>HxK(wix$yP-{l0O-Pt{ zpfwIC9}qCxUZpN6j4SmoqH7HKNV#dJHA!evA@JNtb7QgG=M=#j7QPHyw9$m(-qqoKgI%jVzsOcr4Rm!ROX<9yd+D6{jumA~X1Xx2GS(kW z^%R{?XEsM-No&@>BC=DbOs-FKc4R}ZJE!_4rugHIzDm5koQig1NTAFig!kGuKZc|l zfE;EFo5@l(BG`7Dr3=Vlp1Vn1iXhGO^LB?+?s7SwtaZucgNIOK()Cm+feP}`h{*1) zcl@r{Wj_Q2DHr}qdbt;vPH~DFP74ROis=1~b)576TKmggjbm=Exkz*JQ#2e)(mVy& zYOI@l31B0aMS@~uP`Fz_rqad|GQo@6r)W z8?Ye4mexcAY0`SQR5f6chb@3nT!KG>%wfrpyveC^D{SW5U3*P&x6&}sKx@@$k!e*) zsYjtmmmDKj(;=VfN0Pf>}`>uw08{l$)do$o>Py=Ev z2T&C>eqOqtIW1{~d<<(Dw3OSrww-v8!AT)R2nrTO8uD4-rY-kcU0tom&4gsieFKb` z1y0<0{DZaA)9Y5PT-AY)N&fE53BNCqz$PrR#%0oZ>cP9WH=ZG%JMF-J(XQPmPVC-w z5&v5jtk=J7?cCO_b8ENpPd4wI`kQDza_TP|{jVbOR}3uP)5IdVA6WbkkT0;0I*K7qV5U2#O(aNXz!=z*5EO#MBz=_P!gSgw+(ZyCF zgc6YnJAH_%qL2u0II&B}3tT&Y>tFeo$)mr|yfdjK|HjX~)k}7eUstRAQU38ykbf$W z>)!or584|>t9Xn>HOed&?H6a3S#KpMmuRP|qvh2AfwP z?`l)iS|n&tE6mCmGE_|sq8BP>S_ylkMXwXcnKE+5;jhnk%&trs0$yVzxuP=AZ?bPU zN(`kqBkgknQC}hAA}{yNMF$h^N-!8Sxr4D})|FPvRGsAFaF@ly>#q z>`(_^jwZ^cQzYA+U7k!~Z9)G1L$ zV$2!jgH8;S8>uh^5LsJ|zRd;s zyB5qb0_n13r}970xgccp(-eEL{vq5{5j0P&F;vjO!33EEm>^42Atok~x-AKxQ#v4c z29@@x$pyxL+HGh<;*4O3bR#vZQ%=r7tHdmofu$Mn&!o<8*^}Fi9^PULs+l9Z`@-mYml5_t^X{{} z!JtO1HXCNlM2L_&7>u8Eu{RJfsV$aS<2sYh6u4m_@;P{EARDGtc^;eYEWu)>)6F)m z!BR&SC^>M5n2f{{8>V6-lOh<1sN?{6d?i^dCKKiIH%-rQ`8* zDlvIoMIQ{3Lcp)92GfRjWtGAB?Aw8bqxre{q|G%=t2Veyjt=K3MuY02&Unb* z-Il9*7SJoO#SCIJ{tJsEBaIcO^iU!L8bc4XrDxiTy0RPMw3dlln%}S!Walj1w0Y>q zzy0l9ale{_zI^V_fBt_iKY#didU$Mk+J<>^mZ#oGeGo`m?kaw-`2OydGq?2sfARqD znUDlFfN-3zxKweS;*h8{Q+-MVaurdCA{l29`cyHI%@q{6Fb7P*qR{~*UZVa?ArQ`4jY*V+sWx1NydIB8+t|)r;-UJ2p-}`l2om_ zFuQdqX4l7)CacwG)f+L!nvew!Hhz#7fCmCB^xUaKdGG}uqg(7Mt z5(wMUE}}#oy)oCyiVnNcvtIQLYKy^c(Bb?JLbBm-#E1>L*omw(N9|WBNvOj%YiVY9 zK^Et!Sdwl}E~3>&a(n>!cZ}5)_E>L{d6uES)Ee_UnAl#fjO9&NhMan%F`M`p81|t$$cinlN9-RZ&II7D^cNyZ z%ixS0fB6DkYrwoWTg@)WZw|MQ{xr5~X62_r8DiT0-op5n>?IU%AGEaO%$fAhf91CmH?C^W(xFwev1nycpDhZjQ}^8e-z9X$$KR^nntHt}3(uMg$wkF&zL=E?k#s z%lP{`1}3tc@EhV%&0pO*5J|NaHqN>9mKm2C(mlacDlyzT?~*0OD{sk6OTebwn|$u8 zyN;~E{FKNRrH?y`80tlJ)*_RnFOcI^E*O;n^RYJvTZJ|Dtne2S4W;vT^`o+dUvQ0W6uNzy&tLc1Bo^wcSb*r?{LN zWi}uMUB->iMbw~HcKT$M9f$vUY@8~jBuQmR#o0XVywx^gwDY{3ie$Lu;Fs*%x0?4W zRbiFOL}fM8JqDX^t;fS@A}UB;1_x2PtlrhDy%skdnwWZn^FLh1;A)SB-y6NeZR1x5 zjILkcPSD^K1fMOv1DLJ=QMeC8AwtcZyp;Ei3d&5XbW)PQ`l?kTb^&SMKwPVgm*Jeu z-Mg+gL_=oce1;uHl%(WwqAA?nM-b`dB+Mmhwh(%#>nE5|~&Zi8+HbT88p(Er?l|dcehyK8c-rKE!GA$pA834Gi(NY}sQrwXAAc zbaiIh;%su}))|Y|wx+z{oL@QAQXX!B&&t!DY2Q{UtV($*g~-Tk-k1^@DT^2)=LzbOG3ueAtUM}X93Q1!D<7}94xg4hu_Hj{KRGx7eyEF3K z{BZE!)^dv{Ck~N61_X*lwB^s7^gDl!Nng1feSB5;E~oVi=b|*CJ>}Bkq$i|`zxv(2 zZ~g~)V{+mF@;_dodQ8Q1NOl=N?)#wsYvg=VM9sh3LusVwXaay8xI8lQem6Nx z474EGhGk#JISi`_0{0&66bo`4!he%(X%t0_2kR-;59_G}_*;6f;?h8m>uG}O1{4Eg7yJ{%dj)5G0?Qdq+%B2OnnBlzi+87FyX`1Dt& zy~{m;!R(m438hi4fh4d#DrrHwfbSdlS)C^E-0diO+}JZx*$bW-e3$dyhPJ=4fAUSS z-bTtGqNh~&&W8s-L4@6vlM}ni`GO4&*kh_@GcQ+t{ey$=l6#01b%%dH`FpYM20|>6 zu|(zQ3&X?LKE;R=`M%Khg@p6kr!M8@oP;W`63#rPpg2$T+%5`)n;tvx8}iEJ#7#FH zQ%oXBQn72EzM4EWiTVP?&=jG_6H>@>@B1vWUzR~v{*8;jgeku69oUNf+ z&?Irt^331(=-@9IG2Tepgx!A5*<4t}PI-Q={#ySBn( z{yc-_jW<$a+(2ZdDb!|u@zC(_j-&Zslh;t&?}hhXE1_Tm)n?tKe4F9too(M)%`Kgr zIB-)3O^6|vt8*f)RPf~YTE4>#oQ^6V{^a#eTC{;~iz81+MGqV*JpG^K^~s3`@9m(W zl5(9>q&UM!@oVkmCK5)CCE;C4ijSv6Y`onPU<|^aC?#qjFF4H;)Qu#_$sp`+$GGb; z-Ra2>QAl26G8L95<>x(h+q39;b$pU@n?`CktSUDSoe!EDH6V(ASa^Y6nY^E9TFO+-T)~g ze|e6g@O3C5S0_^<3hZ}=4*CAz2Z6iT&Aqp1&Env<_xl6m7p@&i;>-Bo%{YOrb8 zGDh5UXWJW$k&|!Tdi`-lMH(bk`t{4QZvgtB*1O4@`^v{w%P5diD*MFsxi`qI#0PL% zNY6#AFrB62B&~M`zfXRQex6BrWWzi*OIq(XNLpvm#Uhskco;T{-0|ag2j37StusB7 z&omUP<9A)sdUxQCDM{-Luh7$6T10V~O&w~Gv{3W!n#)RLP3HVflGd3Xfil1b+AGa> zqVk73hKJAnHlr`xk|rUzKARGyJbcG@MW*u0V+pD_KT@$rzw`}}Mchgjd{)7_O#RqR zkC1Cg0&~;$JS~V=peC81!eoNCdm#;6Gdb}ka%&icWY&nz)kT6|9R|z$VGnX=Q%R5$ z&LM~Cnx*wM(q5T?-}}AZ?=Ye0R`R8=un#4%8>M+Q*5~JF&+Xib73ktC!b{|iIAh>) zz4hfNbNh#1?R`olLFD4Fa8E-Q=)^O`l&A8}cY7ZuUm_vYTzZ(?8y22KX?ZyUo2VO9 z5`^DqCsz^3Fr@VjN_^!NIR$`B_2WkXfY&=jk_DOQ_ixfjY+7C8_?5EU`bOK)8@MQK z`a8v=4F&7yiPgiT=0k6E+`+Y8ijv>MB^4v&I%R~fNOIP#?Y|>0PfpxGwly+{`mkl; z`aAa*Zjy=6Gi?+?vhWYNOk65OtS3l7I24}h-BYyHyVP|9EuQEMJ#B{0Rd#~$% ziVy6J2-5+kPiyxzvp^Vu@0lL3zD zmf(}AzWpMm`;E4}To3B{i4cE|@>&@$&XE86b-Z}Kg0?*q@3u5T_WVtGTYS0nPlf@*!je>_*Z0EOvVYtr^0 zIgO0m*2|qRAI1I+2}Mw78VX5MS(mSVbMNr*sYiM_->sB6Z4#cP1?81nr&ZF_9J#UQ z==0>ysPRSNR|!IeWx3A!*cl>0P~3fA@7HAc>g6!m)KD$Tier~q`Te)_9Q`|adU9d~ zIZgPPSh3uP<7WKk2YQZ5!ba*}gqIS8uAl}QEKO*l@;`SD51;%VBOTBHUT>tD^1x(6 z1ndSUaDdcdvE@WERF{#jAxDS~U>d%Y7CepuG2!pdc1emK zzLT!*Sq+oJ6c|WrRQTQg)x4noVe?js|$@4)~7_460f~3e@G@k zcNe~jw#yPef&hsOMO*&H$jGH+Kj{LVm41vO@*1Swn<7BhJlOTDM1X#kBwY>VVO!|`QB@*|af?9#5fAoRprpIz84_u>BQ zQCOCQ5sN}eOc7&Hm8K=8!D`i9IRI4HQ(>Li5uLFDz6nzN0oo1g=@7sUuHv>+2^6} zSC0@qd2n*#$V24Bl<>(hH8UiyGx6_t9h{;e4aH3o_} z^zF6-lCacq>o-d`p=<-Wm6ms+^2ghUhj+a#k^wIIVIzgC&!I#hkKFc>C@RftEL9(k zOmENp?&DkxiXi!ybB-w|jzp&3kACUV-;#fsoVfnkVH|654L4VDhH?b^4Um-04!ibL z{zff3{;Qq>s;5hx(x+l^{84p1uJR$Qx9C^mS3H)|b3dJy;zJ=mHSK0!dpO+g<0BCs zewt+MF9;Q%0dQ3n)8&W>$(b*?2H-WI2Qf)r0m;P)SC)8ss_3wC!pbgsiGQ={#N=Qq z9GjRoZ_ePXt99B#o?uF+by{Yd5<&CzL*eCZt?5VxyMufk(>+h8Qlq-%^VJjo-o|Ze7jA2`Dil0FiO$DDK?M?wlz+%-%VLTc`LL zy+an&5EOS%IQc79<}_}r;zsPE(US2R*dcr#o#^dbO1245i8!sd$ZgzT`ugzT z-%(u<50wZ4!sG?Jxh41C4|F<+`>6%l)~rz? zz0a-dUMRi=P(MbVP%J`Pe=Vo3oyoxX7DoM}sD4qk8O)%NfjkjuW5&E|K+t2=nvprS85oxvixQJpD0b2;%64%@mBsJQ#C4`BD zf}yB)#wa&ahfO$Ur^g+Ng`*>#X1x4L&8Fx_>NqWCEXdS1R4b zU^E&;@k6yv#fI8jQa4#gH_WWecne*v9Q&AdGvN-*cxmQan<^EE#CY3}w||5g#~?)* zh(%Dv%wNu|%*6jFcA$2$VtefnMNSHf?eE@Fp_Px<`he6qy5E)UFRU`}9XkY0vo(>m zn!E`8?=HL=40xu~?>`A|DKRy@4QHX0_W;(Ke`! zu=r!pnF@7SB7iHyLet>44A@#7NaYi4jk-Gw#lq;P8g*G0@rA8V=O;on!$V{7N~Fi#8p?z%5r?hq(@IaIE3MT;+M}_sO4X&+qn1x< zJ&G_eRGO7g-|L4Cu~`W#o~}Jd5r`&3rCatAl<2#kCR+=s@`}#n$9LRe%4YLv6H0HW zjVTV*UZgT1hBL*jL`N)=4vV1=^AZUY$q|Pq$Ya|qQWAOaD^m&KJcPuvIPtqB9LZ!N z;g<4<$LWN*t#(Q+srXXu6>53c%9IG+>@qfbYYT>C+Lj6Rdib7T#+peOKf+lINaI%W zdP_F5v$I0s+*kn|;$Pbl-Yv$MXw9QmgOD#0rhfugz3ES$mBVOdk8}DbHnR zu7`1|CY#i;Kt2_-$!DquTJb|VLjCJbpOc-I&c2Z{64jjsdUj*&tKyW#+&7Z=Rg62d z2b7Q2eu(mnOoA1<)-6>sw>sfb_ERKipk-RNO5|fc`Y9cw^z21S#)_1nq4;>M z0dpBuplb{C;b{?X3m@NMYRLg_k>9R12c+wn+R>?@rw}9c3^_&kHn9l*{mbW`>sXB{ z8_`>wQZb%F=0j38&O=6)?J$j9GpYch1KhjpEE(D6<^L z@28PPM{C@To(=u8NOKUxWpnMvcx|~t@%LX~fKS!}I27S2%JCS#bo;g({KclqZFn@F z>qR~W?1w@N8AN#2mw|_;p>`~$0|bte5^rnRgrn7MO5|T?OS)p#U_N5zjnbM(y5d%@ z*VC&rvXx=fRXu$=L(GNMVbm#LGG?ws3)UXH*2I3kI&RSiyY6t%RaP{hH6(1D44=i9UUWA@SpYsWKy1I64 z_0x-1t-46z74C$vcp^j|HHK;h%Zk7h*a4A$8q6wl5izt}u^tR;a^$)wM@RKKd-;R>e$(}<*vK7balZxRC@p!&sQsTR0M*kM}wP1>6>z?)&ACS zpwnf@&#km9Z!>UPTz-==Y-mA7x&F zsAZ${0F1O5DhOz+iVBLv%wI8ghI}Qbp*X*-eY|9NDG)REYLmgTK4dib&E;?E!v^{- z-8K|gw6!lQ8N`>GVaT>5$}FY|_)#!&HvoJk3P)_QI28)xPeE9rFwUUr*k$PI{@TY> zQrA{R5KJAx5%Vq%Z`&4L>>ZXbK3zGz$LsAGTCrk?eotQslC&6AJc`k}6j7SzfC<7( zcA$SC57IpfjWQ->X^_a+c9yYRZ*R`kVh*&m_V%{60X1Erkjohg-7kGZ0xgwnwrHtT zEJvdSZ!pC3p`cg(#t80#&bD4@rTpJn#kNvyS!-~d9WUdAm72oK} zw|`~N>5sIvRiA%b%s{8f0QjtLKBraMNvw)z`A12@Qw}Z%B2~G zHq+|)Lnp6aS1ArUxKny&yZ!ETWMC9u;SB3CJ5_^r8JqhXgg4>xu5Vm1m1~>3Wc@O+ z+j!}NS{hyZh*Du52O-5O{PzSC`NOIYtFxyo2VbsKCwy-uxs z3(Iyk+PXoR_{_GLzC!hl#qh-##O777Q6Ntxv3ZBdZ#;kdu#@Gwc;>OwMB&A zD5J!se2GON9jnR+zez5i@U8bvtVrgD_7}_h26M_bR~gDorPDQ~%rqb(HMW9IXJ%g? z+YuJZ}4JnYu`{O#hL~rQ^x5D9Td&Vn(T(K zAdn zX$bqIm>FG`mDGg@T(fKv%u<7p#pWReIiG+a3{HfZl}pU zPuS>jBC)?&ZAS)GVQc+OknH2%rIQYjOBTLkYkT2yYi@3aOukgJ2}@u!q(Vf-$xnV- z*j)J3PV@+zocz7;E8%`TqpV-VF{*W<<3i4dBY+na7FeRfuOfbb~ltJjZgMU&r# z;I2@n@D40rqLmi5Jo=IrJQWL{+Tkc#ZoJ+E#i`p_-h_b-^&6a$l>Q2^Awn>5^z=<;&o`bOr$v zh_lFr&`3!PYYGKEibQu2lToiTJKB+%D(a+1aUlmZxAo+KE~hQ>Fm_zq)h!N>Ki8UV zYs=;63Gi2-v2&CMq1?8LBm$f78h4h_$tcUTqOr-SKW0$sW~;0ok5#pp5HuLb7YgMN zl1`&xZmY{0u^XH}aOmxG3<<5%UFqrV^5=7{IsBhT%ZIp~ij!3;$Qhj&W;G)llUd?G zmYpY)zyaoLYM2R>B18#vi;3>Y%H`QWNU@`o;60&qB@>8iiK@$+@Y;1wjmf4|J7Oj) z@9#8(xScmW*ji}yc_U_GNGDqhUc-53#*i#ur_|6RB~*H2*v5~E5{vOMoj#K-Ry#XNsx2OFYoXxCXOQm5U<&kf zcK3K@Mj#p2zRjJYI9GXyid0GwywuV!4_Qj%;HGwe%yHR0ps{+g+16Ad=(d^dPV3N+ z$7<(J0Xz>lTrk|4j7F0y;PW~TI1SX!`gK&l3bQy|uby=|K_`x<39FzAi`Uc;4O#$1 zf(l5%nrXUbt#_eygT|sIZqDP?s2eUlR?RZK)~q8gLZ38Tx~&@4zj3u$=LnIo_*KB{ zpN30kP`=YcJFKjo&UqB41Il{9*(btTr0tC;MumNSp?iCwds{pZjN!zP4emBKx3$`{ zqpN$TyS**nhX32QXt!%IahCy~&QU&$EP#bN6vWZW$>1cU0)-KwEFzbv2JT~stXV)8 z4#nd<(3>N~VeW9CY=k% zN4pL`_#nind$ADDP#$1nR5!(Jx)|##rs101uhz51NM*z+W+0(BC1#8%;4m12-eOr# zzwyDDHYcSl0lzge4Zb0c-10bm2Ow z3w>YQfq#TdopN^oGd->?pN&|b)vwP+Ge~3CK$qCS{Yjx!-B8;=aBvP3j-L_(46R6o zEBqQFG!o+;hMdOPPoRMS=hqMyT)UgoYWFh+N$@L5E`%!-vIe+7osy+9r7b9jYc>h7Xuu^)Vu2g@M z>eRAS4c?5Y74oPw2!%awyu0+L9r=&GDr3>xj?k;SQKBpwS}izBlK z2FANj7~;JAlEGXq1*tgOS?K5+8t9x?^#K0&)bxt=wO1f;+zohs4RY8T99Fc6Tqeq4 zG?DL=Xw)nW)#aWSWx40&&zEf=V($fX6}4~CBP7){1c!AyE$l7jzqU)QUUu7_;i10i znZ?osT_Tl)qz$?Q{zNW`@40okTD|L;*5TpBndyB)*s*&Zw~suD2o&Z-Zd_dD28g^Y znOq-|O|Sdp$O!oxKJZhyQ(=e{bB8s&dsy;ibEnGAY?OTombHsmfQN#B{)v$tq}5jR zr2Rt`GbhyJI8gXS6uwX_jP1*k&E!Q-(RQ+_Yg!=9og7*cH`sf7AZ{p5LfH>lS*kt5 zF`HnvDWvC+uE>XmavxuG7i=d#ZRqS3ued+Z*Uiy|Vm{C5^d>U^bG*!_-$P$6vsA`59U;V6X%2A4LEVgbE^Sb+R_coWW$< z=X!&=b;rAd72CMo zk0J>E#{Gr7GZl@bChyT`B&<`IZWT|8p-gaRQ8(h(xiyNSGN{mKetwYtUdat9K2(}v zdRQX7S^F5a^-(xvKSbPtM)fS{D7?9F3Ox0T~0pOVOC0lR!zP`SAumi?>ZmqQ^6^y4GW}VsRYJNva$q;Cq zsPSw2VC?S*#rpcVi(EbwcckLMRF4(!jKP=ijB*F-Bl=e-GgRo+KC9oB9$Qq_x~j`& z#hqa#$NlBZKoH$@d1#=|9Kh!p?k*Y&Cn{Pj2{O~KF7I#$h%x9csp9jh?`b6X<(G(!=5S0ee4$#(;1P3U?WO8=M$=lkzJhG zz8&Yp?BXmQj|E>Q$9B7!gKVyMo(s9pW^k-ot`;xOb?WW7y>__<7Z4oYK@S!{i=dFb_&2afMrw3RF{aD1x>O77LZDMyb4j>z!)WnT5~_l-o@>*w1J&o8)i7u6LY= z9vl&Mmuu1LHOj-(L`CPI$_R#>g_TL4Jmou7_G}V4H5#OkHyhkRvwFG4WQHFjpo*!D z8jVgBRfWk~4(I;ObtSk{!YZZG<2OD_g-(}7H`@@fn%(;OdQsSXx{!vLy%(L>2jB-9 zH8hGqaOcn{PPbdcLu59ina&c4wH!jCtR|JptvsZ(xbvxUIhAke@Vi?WsBf{jY%>At zR$n?9bme&$zd;-0me7p0^O;DRJiasw}R2 zvRqE)GX=jV(>UYOM4xLn+H6MqRG+xo0rY7*`s8DA1OKq)?O)K;v6YkBVpM(v3nh!0 zr3N<9gp0YP-bm_yq@qSfTH(-8aaTLw2nS6#-%{_eI0IpC#^}D&l_71?z9Ki^!erl0&1_<5}Gm1>FVHDVL`>YOBFvsu68VL=0Kwc zJ<5(Xnzh^cJYSfWZyPLl+Po)nmo8qK&fD$z^inE|Tm`~=rn1NaWT{+$H|P?BUoVkg zF-JL|>?s<113lcsJw=<@YRdRle;;pc1XcJ721{oaa-pQiOKY@o?aCCmnP7+sH`e8Q z$S_p5bD)7k*FFbGHmvMv36@%{yf;ENpU6eyZiM4yxUI$R9%ydTQnDHBojp0w6lWmT z?#`raCbbbnMK^gH+(BEEXVz`eeTZFHjJO5rBNKfX@M5Sx=UPeKZBnVmGNgX@^WU9Y znm4aBcV1!c+~PQi7stno(vQD?Se!SnI6kj{Q*q{&+0DYdxux-OrHPiJ1@X$?*EW>m zu5Wl>nB7n_caGQwtKn^WJw7hh)^IJ1&!1PCBQ`i*l$#ze&O=oV?KfP%DfNVQ2Ikd= zgcr$jgIw2nUHkWUoi`nk5nYoN{1fPv@C@k?{!RV>S?>n2UIJu=TyY!=lO!0K zzB2-((by{eU45Zyi_4daMpH(Y!ENPsE3GzHE49b8O=q~*p3k>)%AB3D8E2=C8Wipa z4LFSvn9t&BSdg$NXx9MUT~_tku)z%2tV*1kL#dbJd{&YDc8k+IgsM(QRrjDODAi32 zl9@)Q=%=*gV8@gf;k8ff`G7~WqGPD2Zj3~Y@ zFq&{%|B;DAS=$Wc`znwx2P;!Apvq`$v-yUnf*kULi`oPA2tX!7nn}>nxK>*?=PYmN zNvG47&zBC6Skf8S>oS??etqtFK2TI|W!9WyI_C8jI-IR7cB9f@3bs3f7ezC(w$;rz z%G!GL@oDstn#JKD`usk+#Ezj$v~QGA&>0e~O>3IE#}_&(Ck^uY+?7$EKZ4JN^^Og4 zb)%Bk3)3b#RJU%bSg*X^6lUyf^y&%pie|I_%sv5T%t+THC>>)18*1oPE=yDvU5r`h zATG_$TI%L63b2NLMbZ}<;*JH9&~&(N^`ad^96!vSTVQ)-J_HtzO~C;+FB|M6%O^8h zGTAsmokJFf-EP5Q7P?HKr^031{j!y9V_jNz$=I4U_r>LM@?Cx9&Sil=APp(@Y2 zv8|({%^%a_LxEtrMI`@v>AN*7*?V1cl=2<`(nO=rcc3O>aGWIIE~9d zTmeqF8w#yJ_IT7AgEXM>1WIadYj0l-KWSvG-=88QBI8z*SCCt3!dH$7#{IRaQfocky!z``kEg zDU1JcR#FZ7c@$qm3=7n73PT#i0?A8U&h>!yrETR7QV9mRHqH}{@TzvMs}N1=3^{Jy z;OM+~YbKp)nc44iLlsrl?gWaRr+f;lahd|Vf0(+)5Uh@65GN%eoskJTCXs9~(#!~| zJVqSuBCc3*$!gcdd4o0NMAkSP&O`U$^bQjaQ^z6XhL$YY1&C+4CEi>1IGmoAXaR@L z=AB`l+u7^&s+4w<$EJ|vlzI=K!x=49hqD-) zbT{h5qt@<&usGH0rLuh$vONR82yaksbYI`z*OdO z4W5CBHdEm}xnz5X8%fnOHZ6_`qoD)Wq3_qBjpFf;(iE#yg#gD#%qC4mEt?oK8A&VN zUH4CRTMeGgwy-18XX33$!xh5O_TZLNkh??Ybyh7ljSgC(BeK9|x2tWAX`Up7AgLh? zUonS`w0XWnVn97+fYVZZ>N6n-qJH}l-aJcrh?#JunQp{WsFqBdA3_y5IQ(4jDYz`M zyYGc5XWvU=b;GFcD2T+sG2oD#T(YlAtdwm$q1vltFlfZ3Z>eogU=Tb*=D6FP)Tzu# zc#xb{Zh_k$gg8d#yZvDv$_N|LIgnhUyxHdB%~R;*T`uBT#?SB!v7G4K_2^v7XLs&1 z`>`J)69kztc&Or@MAl(dF7N~*UJuehQ>2y}t~V;RCDS$0g(%`QTmAA9XDHX?Jvq*C zh-rNQ4u?(HB6FN{$Fwv`KoEKa^c4ZPT2y~$=s7(fE;x}e)1~uR&BCvxer6oLkjaKE zIJQ9%YaRzYP6a&vx#|~wcJ*~I99R8gsD3}H7cEg4gf)s%NQ18$ivni%lf!;X-|PtQ zi<)(9sa$6}-(z=UVhy%oqYiqb#iG{Pys=Oypze22bblTTdkt!a(@`Q9jCG@Wcs!<< zx=237wXTY1-8y%`>*r(fct<)KkEMARVgaH>ZmZjEH@d?vyTxvTw=EFzSiP+d3ycxw zAPn+o?-jJSK)HwR+@S42Hv+!^$8f{?f{gO`;nrm0k+{yOvuNC~AL=YxE4MWmzs#o5 zm?I_6Xi$^r{h|(2#(5|}D18vS-|!0+Q+Mg-xJtRLpY6Xw16Kj)S3_n;03UUc zqV5Ddq&$EC26T*#DzOwv=HU`mMKnegXVKIptyx*o3F=bo$F0~`5w*(psKZhdSD^_i z9aEA~Pr%RM&GXa{COc=R=pIVa`N?Q0xy7WPsY2rXo*tJ&vzyoqhOpOYF>sqy7Kf#M z*x=A<_HY4MP@*bbkP$)a@Tdo>k6L3&hOK(78akY!x;Z& zbdK7T>)Fz=$$|B=tX+1|OovHDoa#p|USu;VIVaG}_JrL=x^0iU%z8MrphHIpwMt`( zU>Ro9(+t8!@l}blu-#7GQ`U@nw%(#qTa2@m%2w;$W~bA5c?Wls)9mwwwAOF?%Wiiu z_@dUL4*7icuy7DvT~I4>(`&z^&Io>L-zy>^zB3hzobUREUyMawzl>Vlg&$!v+3&?o zC4WCx+*C5@gr_Er%+Q}ASTIa^HV%@a;sxXjn0lm$88I<yuUvk=YqW*Bjcfw zzO}1+HceZ3LTl1p%=He1MtfJBuz!n#hZqy|xkq|G?XRwkTW1u*sq9$W^np~F9aGn{ zuyr&aZ91iH^)|Js-4`pRT9th*^vKL)&4z5+-=*{qK9UTKfQbqL5>vY>4yOYUSb>P|KJP`ILXTE!P^3$=w)L!ZtVYHPQ(a65}Qw=6q(qqDQr zRalY;w&2W3=+aviJHcnY46mqC2@1l@eOM4;3=@Xmz#cfnP!8n+#7QERuB?*i+GE=) zJ*hw-l?-xQr%j(XU#r&Z!XAw#mvsfUm#faU96e%mIiyi)H$54PfS%nArH8FjSw9d} zcX*~L`+&VzT&!;SLb9Qr-rgGKL$Q2YYsBw%`y;WSKNN_7){DmRpwHt`ah#tIM)$d# zcE@8hJu^`HYiM`}Yq+^lP&2xeL_ReajKpmt&+|&9#ny)1&9qu({-5b^ns1@jehzX~ zlKC4#bapTR=J%BrBei!+HPXqdmMaO(?x35uTXY}6CYg0>Rc`NzUbjk{+C4|ZCD(XY zWix^-$4jEcx)cZZgJC=!AGbQ}X8%ByxHs7_jNuXB)dsLaSg{!^ zl+TNxpc3ac?v{e|FyT`48Y!f(DT)ttCX-&LgD=IhxolgQ55~a^eHNcR=yB&@5G4`s*pQC<=vYAGfMnF+Yi(LB}@!mJrsN(0kVN;2fPB3A1DNF?_6&SIubo5 z3K3t>Wo{2Od4r60I(!YL&go3f!T8m7I|BeZay|xrJ~|==9!Z`VHc1$|IBAkxDNT)L zlg&bOT7OVYNYMD9D9)TtEY4Xf9kgH|^abwy|dZycOcr9%6BGi``2&w?!1~&vqDXhYn4(citn9oF_`s z7ottd$Ug_r4J@`Uw~FHw7g^omW^!zGnBjP%Dgd{OnDNs@h32}$tyE%WKZoRosZs>P z0z3%#?gL`c7&xF7ld762l*h26zl8r22%^S^+q$z_-O=6cEVSi2I`VBu-NYnrItUsn zDy|SF8PKfiveYW#X+@$9`$aerwfMc0nC+XIP{JVh^A?Fv+yKq{s(&i5&Lsh=F|cl@ z*v=d^F(gABg>pWW#Cair6c2GThHL@cPLRBx076n+ozo`A)q&QE!J<<>SJ)F8)K}Tg zbDY057@cv9t2JF+Dmz;5z`XAU$Y3*)KqhgdvYbetAzKxCNksJUeV=nHRS@i9)Wy64 zXQ_8@rp;x!S;QnN68`@tui4#@*Q^HpKQFJPA{N8v%WD^d&eP6OQJ(RL1~idLZxgPU z-Dgnp{&jKUs0VqI!$jyjws1*McZ%3f_)_)G0|%t7DV6en@PWE-17-3oXBimj9WB z+{|;>L_a?@&@~djSVKMN;_b$TeBgk%kmc`s`$@(3&^`>s48ot~!EJ&Q-E*ofZUM-I7d=4^ig`^sf$38N(`e6#c?VjYN1Y0u@#1Nk4KEI9X1-dAog=` zPO~fGjt1Gc!)PK9gT(N{Gqq{_)7v(-W^sT|mhQE$f_i-igQm`1CTx{sP0L57IGeWP0H)$Ht@);0ux&u!43K^AUHRb+!N2qyJf zodJRwQ&2V`N_fqF139v{Zk!bj@g83!+f`0kf&m12CbBse=&3YD?14xy6GlXFI2wx* z*x-Y{hQLm{+ZXkQ3BqHXG%)-yTHl4%T{JoE6cKVKn0iWuJ!YJBJIlS@h4#u_Gki&% zKCAWxd+L_i#iGCAf<(()K=4MO?ZwIw=3kK3WR*Dur*u0ehPmmeD4UlGiIg2TPSaTo zqtU3rs@;zBrDL%fUfp)B)ll_^d^#vy21Bk}=Wq}M1*L&F9J;=&1Dw~;((ZNT9X5>V z0nVh@tGo`ASdzy?BQL1v%xkF%!}Fq8D97DuB~vl{_EgIR+S<)ohufFZs9;1$#zRgs zZ#H`hQN7vHYlG>OGkIV$bx*`|Mzhyq;q-Q6%s<*aHv}V>nfGK@Rc-!uOU32oqu6ih zL7&b8ST2L5t(ENy5XUSN=;l=<YbnvcB zZm_*WAExt87q;VRy-iiHi^yg#sBHRe?vy{_aC_bM#pugw%=tr@a~4w$!t)Qj`p0nZ zX(y?&Q-p*P(yXexeS2>q76`;*uzPRa%4E$cL7`Y|V((mS9y;(%bYOs{a%4zX{h~fp z|EcBB5zg3FG)Ea+==6VH95i(V`K`wU8>r}N`OGkA(NjeYf!BaK1Sut=T$TlnQylK9 z(z6F~kpO}rlCp}U0y7%zeqy&yv@9~%ms^R(<4I$G1(?F1)~3;7r?EY9_zAb&PRu$} z>BJK|EIKo1-)l`d9n>p!D%yIGk$>tHqX+{l3{tB>My0B6T}H-9*6C*8QQZbis#>ll zzQmc#db>XAoAuFLIC1QHvxzgryat=r;k25`CeCbiI<)ZKL=SMI7@ghZN`YIAIh|vW zI8!c@-Rw&{_5Bo;49;G?Gwn0)A4Ttm(7PX_ceCqCkSyCuYR^C4PeuSRHlP6Q@=K!8 znFfhmMtdDK&|VXLj5jv++Nq(vHgHCZ-lkU1dtB_ksAbZ)k#VPUoHM)QYzRr4saG6A zx89B+&|&H|C+@|TQ7i%lP6m%tieJu9Uk*LwQMLsFk`(ZWkzr~aWjBm`s!)upcCSu=;LMfKG!zHaO0vD0^*aoX5sOOLPR>buoTX0BVb zWX-I_>bt?$&IOUW69j=CU`B()M3Nv(K|ho<*X^St!E zB$r4pXpN^@I1}`O4iy$nRhh%Emno~Am8z-(+ah*8qwHwOcBoVx*_I9^LMsPJRPh2i z!f6y|(a=h;0R?A;X#>IOfCfXduG7L&RwupO=UjJjT|1^3&P92QwE z>gnnZaU9YiV#|^O+Yz}N(LNjvNF0K>Z8)$=oZDtIq|(WhQ7ZgkRvPS(I!&U@``{r* z8*e6hRotVqZQRa>dDZ*+P;1~zPdRf=v+zfCKUDIqdvU_H>eF1QNLG*&ve8@)7Kcdc zy&tzH9hO|Kty}n4VSg@?%e5O^iSRG~;%W25>2}04y1J0fMGJKkvF?fodskRMr=*p~ zq}4ASo85FGU%Wu!WxDA6S(VXcLhU3N>HPHAy^)PeXHH+S=~DoT`w#$IzGh_1t}?A! z@bY=E({bky&7M;g{w&-#JTZT+@E**B6l^WXN)&^ivfdNfGbx(~Ab&{#BTi;Qz!c&Z z3wD(lE|w3bSEq{?dQKSFx-Hccp0Q-l=JG60F{Z5aPw(r(ZfcvSYKj|@x&EGkettIV z;?rZx(#z(x&&ZoJp4BT)-{W%~&ht6qU%qm`gE*qlg=(+>-6a_zK@S;OP&~xe1Jr|sto0iA~tm)iv zH9D)OW58>&^>jPTfw5xm^g#D;E^Q8^R*s*$`LZhq=Z7Lo4KF4yaBlp{l?U$Y&if*%{^y=};LbTMfi`2b7xjNI*>pHL0}5y4jc!PrFe>c(CAd-igruD*Ca$80!fjX zih@nrbhnk87f+7lcHn#C$kzVq+CJf3WvuuIUjL5=Te)_+sf$_S7E6n<-7w8%^x%0)Nocb~BFG~p?3d{%jmLu*RUPZj%j7!B85c-u%WH$nzR z@ps?eef##dpSr0vpmdmB9Z@o4V&hlQp$t02p+jAYerZA>Z(`t6h(`GgrVK>=_%2D9 z$2#_d?845re4>=;j?9{9pJ}(J$Gf}N1$)k9eH&g_o@=Ll>z}qN6kJ2DXI=EP#}mPh z(Uucep1d^^vH19WsH3zI9o?`~?r6DKZIe4op?4uVo1yw9jr^B*5S=MZlgyFiCC`G% zHEaSI^5KA0`+{YduczAjhx-e0<^7@g?Wdl-ch7ZQ>d0a;Fn3^Z?wr9v!`dyMVj6`9 zr_WwB`(Ht7x)Px)uD$lEJ>C7CdrmuT*RIn}+XEfqB<_%65_vrIz$;2auu5a@ewD}F z(L>g8uet5zPP&aqiF6-#AQYAstHc09tck=V>Ct36ym9We>FJUWA^YHIZ(=LfHtH_{ z7pLH6Ti-QWS785wHD`DV&K2V`R`72w9UEI3YtN?XrU;Nbnio`zaa5?0NrE`>$F0Zt zlOz*!bBiV)BaaI2{Z051*Co77286qbXYOCf!X9Dir-#XWa(%TbgoQ67z3|tCy+ZHc zAP}Ovmgc5$Gq7ABJ_5#RQPQAYqO^%F=dxQO<4n*XKkD2tUAtvNbrt_uMniI3Jvya*r9@ceyk* zT_DJFzv5=P-Gbf!DZ7ug%I^P&aegz1&!tKgRhj7Ys4C4Ol#t)DP2Sk zx-EpukIxuS=Ynnia=}`Nr-e7cqql6Faf<8Wi*s>D*k^d1 zmB{7G`JR!=aO4z2S7#t>>YQ6{p=kj4)}GeUG2+a+$L9Duq78!)hg@v!KPrX$ukcGjYJyh>z^CwakM5A#<(P<+plAq0!kw=gW?AawUc;dyWUJ^CE);r%RmxBh$-KGL*ekM#Zn((|9N=Ph_YNb6Ue zAw54=dj1T1Zo~62T0hq!J%3nw{z<+5?r9eJ)4-t6u+Sj89vFBT^c_YRhr&XB20XAK_R(t+v4ZjUDgTqWGQiCGs}ilT>Yx zk+7&hp5L8wLOSS8>3yx-PkuK#){zV(oPMj_k7S~Xmf9Oc%l#S%p{2&eDo-;Fjm556 z{4IPqrT=?2LnF!vI`4BzA-KnF+7 zk|;KYAt#{QNri{1v#b<(hjJ~VYq$5FsyBu_E6*Y)I&d7blH4M!)A^mzs8XP>Cu%p6 zyYM>IysFv?kK9Am1ildknqj}o+NwLlQ@=945vI(u-(U3~Ig&K^H0mQqR2rW?dE|(H zL-5Fvw85uU9XX=b`VHwLM}nul;9nNprO9W5%lw&)Y8Sp(7Y?0X{q+blVw#rkI_NP*QUpmc&>wpvP4W~1qP$rE$F<5pAZXTA0o5tDT zs4t3-qTE*(LkWp-^W4Id#aK*u1Dpw35tk9}Le8)oXcHb-9MHb}As6zwUiD`WuVz$N%78`jqm32)AJk^N5La{BjF=@$8wj6kooKGZ6_ zO#l2*zJ=wMtv$hUN)xk$_BD&Uj68gJuYH94zHE z>eBG6X~n_0_m-5Mr5PioQt!06qn)MEc`^)#hlgjG7tfhF2Rh}-g@^gY6B85UdReKQ zz7YBM-vd6*29r~OZorqcd|c z5=_jv_~PumPgTkerE1A$a%;!>Whb#y$3<*`pC}6Jv{O?r@1;R24KS#R=v#00Fq#r!@Zjil5eBr+A`CE!YZQvfKcffp zu@aWxX)VQYS58nWwxB(mnn4tgy66xnyz|@C{DODX3uk}M zJKy7WDLT2IgWSu!j=_$&A`urwiJ#LoLQx5sC4P;hp&q1k!-)A9%&)O(`AOm6=HZzn zICJ>Jhj;7%%2;cE>NMx~;RHf2YO2hi7( zDs8e2RYPWJ+Ct`ox?WuhO!2(EmF1Bzxlnl9Gb_Jrk#TNyBe{ecU3?wF{;-d{7qRDX z2n&Ag*}U83Y5jmK6vl%773dU?PHpDs&O2;lqn)Z-4|pjS68#aCNantI%3G6K{C!wI*U)xSb2{|XY1aJW_|zr_7FW@!#) zo=plfKtThANtwckZo!3Fj~9&+q5*(vMTrEaE0F?hm_d?~>AGoB>sf3Jhs?yu_p4Mp ztZu_VG`Zd5Q?`Yd3@$inR?m!;)gXfrO7x7>@%ASJRXsrSD8j=;rhHm*Io zc~gVWyLNQ@`0mzWw$up|BeSaKOh@$I&fM;)-kjR3hWe_87q??TJO<>0(@YgR@?isl z>cAFJ?JP2>biwhk=%l!VA=K>n3-TCmbrH5ANrO# zW{hL-!qC9x4z|U>J+l#NbcqT*-9Z({F=Xld6FRzfEM2sqEXP(}aMovp{=IUHvm~6Bp6al>Jyqd~iDd*= z(Eg2o>^9Y5o7Y}vciRJ=KsbA1*Q1{7|H3eOXt^i*+f__N`3O(W0QE>@bKu`+A*y)H6)K%dcRe-oqopl(okkFTNww@`B7ePcbV6ISz|-1#cA={ zU7^6(_Qq*NRn1FRELpW`>9S=KxeRm5+^2F^aA)CgWu``V-GT+d!0rn4Q&$I4w_rhp zvW?E55#cr|JJ-;RECX$35oWF12aA@eAl+z_iargO5ARO2X-%yN4IH1dfj7C+QwG?{ zY`em`9WTvjst9gP#oecY7Oj+)#G$r`Qi;G@+=Gl|1z9lnA?#=JD!h(|wyj`nQ5mW4nt%f_u8UcJv% z+)M-p@I5`SkMuR;X3t}N4;v844rionp-+&(+9la?Ono@(<3L@PwM+?sV>(upi~rL| zc8jz?c2xa0f$T_MJs^2VhXg&c4VAWw!3im(5~daFQ~L&IgnmaK<}eW{XONIph^fa} zwU~NILM*0Ustvbx=ZYz*dQ822DYDUMq>9kSLh5L4p+I((jH)FmftIRn?YH8toYc}XsnV?sDEGHk>8-~qTjml?dgM*3Z{Y;8L8k2A5OrJidcfy=`^X4$R_5DVEhh9*v z%VI#0u-=9M#z7ZdJEnoY^3Gcr>|bdemfe$l-a)rfROObb!Mtti=%$1lD5_fO9#J~@ z5(>|ky1GiWl{Ns_c5^;<#tECrAUThT7X>b^5JZ;@hy*}}b$GzRN2+>yv%|i4V*f&5 zHEt@)9dx`Q3dNT4oc68+S+B^&!RFCpfGeg(e*@Nl{ne+R4^_exp_5B@*VbqTOZ5BT zir(Le0tpTe4qqvg5#ISGr<1!f_|Q&teE%r8R_)ognbr&<&}<4<8AHMP{|B0lObfwL7-|Bz-R z7~*Du&E|W`+#$a=l$R5%Z}$kYmWGHf&@GRr3!)E^!T@C!SB!-WM{{hqrVCX~V*CeE zmGns#Gq+nYTPT+$;-V={liTxJWFzH7vrow9PnOLVbwb6|85Lv-yI_`-Dx^vaZIQIJ zQrjK3*ecVqYUY@eJnW*`Uw{7%aJrG4>tnKs?MHPV&T{Jw^XFGy*;6^@^+ClT65GWe zh3tRxuY7$>C0ktkZYPg%KR_vDXNvHnh+>?bcLT*R_-T_`=Mvyq!U?E7htWf)2AP{~ zENhrGzP&s$IcN`zXkeV<0vAo3O2~MlsrQYdhO&Ua-J9VZS&(BMSydKkZVX1sI;t}M z(hnD(%RhAb;_6`WHJ)-uLyed15&Qn2@3UPT+Z`Gt@gwdLZ5O9Me>3_#DDJI5ipPVM zN=|=5Y2TGvn{W5Od%_JkYD!7Fp}&P_op+*jLXs1S1aH)8-I!48TCJs6EkJP!>X7ki ziFGa$M=tqixNL)`FozgGj5j zRIk+;uXP@vW^K(!Env?u0ayu=u`g=4+Nd2@m5#2qcZq3A~YluIe@)=pyh)nbI zsT{FpW$;uTv3-i}D>7xDn>$n|H#|2HS?Z02;F7KPuh!FNU+-ig}pi#0?uLev8} zPzZ^R68Punn=~u{+Kx!~m(+eizK2Lvl1-8vNt1cOY0G#a#qLhNZu~Wh+m`&Z%Yrx~ zWO-hjE3?bfXHac&sxch4`N=!IE~f=IBvNIg44@Epf%{1V;z|uD_ItJV`zj5Ij2rp@ zR0J;ARLLz@=x#Kdy#={AT@%WF_`|=$pBa2L`HOs?<3@C30p5SrI(ka$h%tv*`~87N z7dHBb;O>+Ms=(bAae1rC%XK|o8!&WK%}5wP+JU&L!wfSK_M5pKBf)Ld2`NxLY~u*U zo?>~$nqniPHxD1SWejIewZ39Wvxz;MI%dxu%O@3&E+Q6T+^mwZB|ORRm|0{=4u_L1 zMKd~wFDQ4Vhr`CqiUsX+CXxBUQTwLMsvntyQQ&LX&^V~p>2e1^@z-+evpSKxE}gqbL|FuMj;Ep7}jSOi$23>(q2 ze@1a9*`p*2;4Gg3rWq~mQlnTX3O;{CtQb>Rzjk8J+QxaK`09X{7mI{9k{g;vjh{HG zv0gpJpW!IB)X!|~T;5o-xO2gJcd;YGf66w?iV2hFES)rQzGYjKp%R4xjvq;c`w|v% z2ni&f`E(nT$wNOKz!RvY|7Pzp9>Kd`vv-GnI$-voRumgx^$T09 z5`D=Wgc&;rNSu99O2Ui-gNa7V*bU_P9zomw!4?Nj2!&wHWb7JUPB10?=VzM3PkJid zYcF#YIyuwyV@6F|8px6Yt!2eC#=%XjF5A4V(eJS2>B|k9w$EMR_M{*gMlfsBcmAxL z&Q(n<)tR}ThOl?sh*V$ZW2Kw)$;>d5IL80$fA?i*;|a2r9KrJMik_kv{^z;I(`+pp z#KZr)fA`NB%(($`=>Npu;2So_gECX77(Yc7t^au*=ch#3&G_s8=HElFGXI^Y2dpZ( z3a;yDY?b-{`8T6LqIx#?H_`Sm}~V|+sVbaZTgM#e7)0Bb|8k$t~mHCi99iVKg^BO(@-)(D&;YmjoB|(S%0n&_mv5I8u=y+}Ji`dhoZ{*mnyob9X{`C7I?I~6qW85>VZlJuNS2j zo3EhO!Ry7^)2Vhqy*PEmy&Cn{Oa%1~UN1_;gVl>s2Qzll0I1lmT*IbjQ5E<0#AZUm zQ^{G#p0evmO&VobUCh>Av;_KX)3}4#MB)^6X=D4ndWL(yKxYP{U{9OLD|rINEep_FlYqCO&}q{g5*Kud|X#PFnH zf{cBR)y26UDb>~|?P=0Cn#GwxT&1l~`qS^P!}`?s5}xW>1bZq;NqsHa2o9p@M0>m< z+9N2}_nhG=+{0bAjrFYSFig5=It=(q*GUFF2i+gn@IO%JVSIfy<3I2#Vg|+AV|>rr z!+NnsFU6{IKq-6s3idX*Gk1vc=-Vju3{lE>0&gGCDx;~chcUW*pm7}zif|gd*Koa7 zf6#M`?L_T4Dsg75KGXeJ{X2$wj{0XNJZEEN^}(t0wXqt$9;#Gww&nYuo%;sN(co$29*Qe^P-YBu{;JhX3rXiMg6VVQwB?dMyzR6 ztT)Krl6Y;6W3{JA-z1!KFpWxo`h9v#&oXN>PkSmQT^Vno|7ldG)}dhB%IY#XQ=)V0 z*X$i#vj(^7nl(oLgNCm`&oScX6P{yCT78D8i=y0`Jw!S9ZL>zB7%zh##xMxuZ)+**9?wOE8Q zW(^+_T0p}n71Akn(0b9l5tKW4y(k3_Rxd^wtc}YOVF#NZ+*(9O zN3BR0v%cSmTT!(4`pYq2A-NN*EP8oU?0t+v+iwhdKVA;#v0FUg{doU)pY0kM?zR2K zpmt*QfK%)qSuJh9agbV!-fS1iD1?>apuI+0LoMtk52&>_VQd5T8-sd{*TWc@q+}Gr z?(=~5;25jzkU=4~--x$@_2S@mDUAY}1`G;m8Z_QZuxIs`V-(P2wRRKB83ke*yryFg zdOuzc=%M)^@IJ$Q?0t>vfqPBI9Mn#{9@=3$0Y)QD$2?d)(1?wP(MZ!V4^}VM9_nFd zZ~%M^`%(A?>X?Jti`PSYY^T7er0JrA>M<&@_CO`3W5!D%{|;_7V^q-Pa7@zSy#%XT ze>p}0d8)OWSPm-0<+vv42E89I2lQBb4tPJ_Kd{QyAclQS(hX`SR&S7`J4h`yj|ND( zgVY-&=?2vT=Hp`b?L{S>UJ4hcUvV8i03S;)lq=oC9_|QH zAK92E8=Mui0AEQ;OXnenu#233AfW)CSG;G}u7<_U`-;abC38z(gTL`j`Ea-Xv9$M6 zb_Jf@mimoL<_d6g`#+7u;vc=VqGIWoF^gFw7V(?3%Dj%IW7`|WuhSyx@S`V8JhHRv zsBsgv4kxa%&^Ni^^}RQ2Tv-65qi;ZFZT{7Vzpr&QXbA`IGcNu6Tye~4)MDhvWc9+JXleGd!GmJ zj5Ykh;EO#+`%4m@pT^DN(r7;5z%6Cei~lAQwBNbNPJaqLhGQIUFzjKwqkCDsTrLUY zeH-sV`_g_796xXeI6lz&XZ1atLR#Rp%Ekd7U%YjEpZ?w~#K}kh7{O5edypdhY=qch zc+}e&_`4eAH?eYzD_V}dY`)eH+XJE>?K$d^>FhbHr{gzXzoEZA!*9I)!v6XUzuNO$ zZXWRQppIXI$VnPrKGHCtm!O9wiO=I0{cmG9iW!W7jf`<)BBlml7~ZP}P%4}IQk!Om z#7P$Sy$7%VXmh)5R*&bk=qI-U>53c z#w9>Z08kni0Jtbv!Ocg^_BLFYK!gm+uEgOEqYrsT*q2o^XBLs9qN15IxnsQ_c`v=x z3y$fV!<}Tflo~iGX$@X!!IMk51xU@2gC|Syq#I9i@#JlqV+bWJ={tdTBkmW=v7nXo zWf

d)E+5T5&AiG4eNRz1=n*(b#$m<3sRFi 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 z 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 DaH_h9 literal 0 HcmV?d00001 diff --git a/assets/fonts/NotoSerif-Regular.ttf.attribution b/assets/fonts/NotoSerif-Regular.ttf.attribution new file mode 100644 index 000000000..8d09443bb --- /dev/null +++ b/assets/fonts/NotoSerif-Regular.ttf.attribution @@ -0,0 +1,8 @@ +asset: NotoSerif-Regular.ttf +title: Noto Serif Regular (serif face of the text engine / graphics) +author: The Noto Project Authors +source: https://github.com/notofonts/notofonts.github.io (fonts/NotoSerif/unhinted/ttf) +license: OFL-1.1 +license-file: assets/fonts/OFL-NotoSerif.txt +added: 2026-10-01 by Claude (agent) for Brandon Thomas +notes: Unmodified font file, bundled via include_bytes! in the text engine (crates/text/src/fonts.rs) for graphics text, titles and burn-ins. diff --git a/assets/fonts/OFL-NotoSerif.txt b/assets/fonts/OFL-NotoSerif.txt new file mode 100644 index 000000000..6843f3187 --- /dev/null +++ b/assets/fonts/OFL-NotoSerif.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/captions/Cargo.toml b/crates/captions/Cargo.toml index 55ce943c3..dad44780c 100644 --- a/crates/captions/Cargo.toml +++ b/crates/captions/Cargo.toml @@ -13,9 +13,8 @@ workspace = true filmcraft-time = { workspace = true } filmcraft-color = { workspace = true } filmcraft-project = { workspace = true } +filmcraft-text = { workspace = true } thiserror = { workspace = true } -# Font tables, glyph outlines and metrics (MIT OR Apache-2.0). Rasterisation is our own. -skrifa = { version = "0.44", default-features = false, features = ["std"] } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/captions/README.md b/crates/captions/README.md index 919e057c9..b988d65fb 100644 --- a/crates/captions/README.md +++ b/crates/captions/README.md @@ -1,7 +1,7 @@ # filmcraft-captions Caption files and caption burn-in for FilmCraft (layer L2: depends on `filmcraft-time`, -`filmcraft-color`, `filmcraft-project` and `skrifa`). +`filmcraft-color`, `filmcraft-project` and `filmcraft-text`). The caption *model* (caption tracks in a sequence, captions, track style) lives in `filmcraft-project` (`caption.rs`) because it is part of the `.fcproj` document. This crate reads @@ -40,16 +40,13 @@ rasterises it to a small premultiplied linear-light RGBA overlay. `filmcraft-ren overlays of visible caption tracks over the finished frame (Program monitor and export burn-in). Text is set in **Inter SemiBold** (`assets/fonts/Inter-SemiBold.ttf`, SIL OFL 1.1, attributed in -`ATTRIBUTION.md`). `skrifa` (MIT OR Apache-2.0) provides glyph outlines and metrics; the -rasteriser (`raster.rs`, non-zero winding, 5 sub-scanlines with exact horizontal coverage) is our -own. Glyph masks are cached per character, size and quarter-pixel offset. There is no shaping or -kerning yet; that arrives with the M10.1 text engine. +`ATTRIBUTION.md`) by the `filmcraft-text` engine: shaped with kerning and ligatures, bidi-ordered, +and drawn from its sub-pixel positioned glyph cache. ## Tests - Unit tests per module (timestamps, decoding, sloppy SRT, WebVTT blocks and voices, CEA-608 - tables, PAC round trips, known SCC pop-on and roll-up streams, rasteriser coverage, glyphs, - layout). + tables, PAC round trips, known SCC pop-on and roll-up streams, layout). - `tests/roundtrip.rs`: property tests — SRT (also with BOM + CRLF), WebVTT (ids, speakers, settings, STYLE blocks) and SCC (drop-frame and non-drop, gaps and back-to-back captions) round-trip exactly; readers never panic on random bytes; SRT → VTT → SCC keeps text and frames. diff --git a/crates/captions/src/burn.rs b/crates/captions/src/burn.rs index 8fb7d75ca..5c43e5251 100644 --- a/crates/captions/src/burn.rs +++ b/crates/captions/src/burn.rs @@ -10,7 +10,7 @@ use filmcraft_color::srgb_to_linear; use filmcraft_project::{Caption, CaptionAlign, CaptionAnchor, CaptionStyle, CaptionTrack, Sequence}; use filmcraft_time::Tick; -use crate::font; +use filmcraft_text::{ParagraphStyle, TextStyle, layout, render}; /// A rendered caption: premultiplied linear-light RGBA f32 pixels at `(x, y)` in the frame. #[derive(Clone, Debug, PartialEq)] @@ -49,6 +49,15 @@ impl Overlay { } } +/// The caption face: Inter SemiBold at `px`. +fn caption_style(px: f32) -> TextStyle { + TextStyle { style: "SemiBold".into(), size: px, ..Default::default() } +} + +fn measure(text: &str, px: f32) -> f32 { + filmcraft_text::measure(text, &caption_style(px)) +} + fn lin(c: [u8; 4]) -> [f32; 4] { let a = c[3] as f32 / 255.0; [srgb_to_linear(c[0] as f32 / 255.0) * a, srgb_to_linear(c[1] as f32 / 255.0) * a, srgb_to_linear(c[2] as f32 / 255.0) * a, a] @@ -60,7 +69,7 @@ fn wrap(line: &str, px: f32, max_w: f32) -> Vec { let mut cur = String::new(); for word in line.split(' ') { let cand = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") }; - if !cur.is_empty() && font::measure(&cand, px) > max_w { + if !cur.is_empty() && measure(&cand, px) > max_w { out.push(std::mem::take(&mut cur)); cur = word.to_string(); } else { @@ -115,7 +124,8 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> if lines.is_empty() { return None; } - let (asc, desc) = font::metrics(px); + let vm = filmcraft_text::fonts::face(filmcraft_text::resolve("Inter", "SemiBold").face).metrics(px); + let (asc, desc) = (vm.ascent, vm.descent); let lh = px * style.line_spacing.max(0.8); let pad_x = (px * 0.3).round(); let block_h = lh * lines.len() as f32; @@ -131,7 +141,7 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> .max(0.0) .round(); let side = w as f32 * 0.05; - let widths: Vec = lines.iter().map(|l| font::measure(l, px)).collect(); + let widths: Vec = lines.iter().map(|l| measure(l, px)).collect(); let xs: Vec = widths .iter() .map(|&lw| match align { @@ -166,28 +176,10 @@ pub fn render_caption(c: &Caption, style: &CaptionStyle, w: usize, h: usize) -> } } let baseline = lt + (lh - (asc + desc)) / 2.0 + asc; - let mut pen = xs[i]; - for ch in line.chars() { - if let Some(g) = font::glyph(ch, px, pen.fract()) { - let gx = pen.floor() as i32 + g.left - ox; - let gy = baseline.round() as i32 + g.top - oy; - for y in 0..g.mask.h { - let ty = gy + y as i32; - if ty < 0 || ty >= oh as i32 { - continue; - } - for x in 0..g.mask.w { - let tx = gx + x as i32; - if tx < 0 || tx >= ow as i32 { - continue; - } - let j = ty as usize * ow + tx as usize; - cover[j] = (cover[j] + g.mask.get(x, y)).min(1.0); - } - } - } - pen += font::advance(ch, px); - } + let l = layout(line, &caption_style(px), &ParagraphStyle::default()); + let mut m = filmcraft_text::Mask { w: ow, h: oh, a: std::mem::take(&mut cover) }; + render::draw(&l, &render::at(xs[i], baseline.round()), &mut m, (ox, oy)); + cover = m.a; } let stroke = if outline > 0.0 { dilate(&cover, ow, oh, outline) } else { Vec::new() }; let (tc, bc, oc) = (lin(style.color), lin(style.background_color), lin(style.outline_color)); diff --git a/crates/captions/src/font.rs b/crates/captions/src/font.rs deleted file mode 100644 index 2aa1cf843..000000000 --- a/crates/captions/src/font.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! The caption font: Inter SemiBold (SIL OFL 1.1, `assets/fonts/Inter-SemiBold.ttf`, attributed in -//! ATTRIBUTION.md), read with `skrifa` and rasterised by [`crate::raster`]. Glyph masks are -//! cached by (char, size, sub-pixel offset). - -use std::collections::HashMap; -use std::sync::{Arc, Mutex, OnceLock}; - -use skrifa::instance::{LocationRef, Size}; -use skrifa::outline::{DrawSettings, OutlinePen}; -use skrifa::{FontRef, MetadataProvider}; - -use crate::raster::{Mask, Path, fill}; - -static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); - -fn font() -> Option> { - FontRef::new(INTER_SEMIBOLD).ok() -} - -/// Vertical metrics at a pixel size: (ascent, descent), both positive. -pub fn metrics(px: f32) -> (f32, f32) { - match font() { - Some(f) => { - let m = f.metrics(Size::new(px), LocationRef::default()); - (m.ascent, -m.descent) - } - None => (px * 0.8, px * 0.2), - } -} - -/// Horizontal advance of a character at a pixel size. -pub fn advance(c: char, px: f32) -> f32 { - let Some(f) = font() else { return px * 0.5 }; - let gid = f.charmap().map(c).or_else(|| f.charmap().map('?')); - gid.and_then(|g| f.glyph_metrics(Size::new(px), LocationRef::default()).advance_width(g)).unwrap_or(px * 0.5) -} - -/// Width of a single line of text. -pub fn measure(text: &str, px: f32) -> f32 { - text.chars().map(|c| advance(c, px)).sum() -} - -struct Pen { - p: Path, -} - -impl OutlinePen for Pen { - fn move_to(&mut self, x: f32, y: f32) { - self.p.move_to(x, -y); - } - fn line_to(&mut self, x: f32, y: f32) { - self.p.line_to(x, -y); - } - fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { - self.p.quad_to(cx0, -cy0, x, -y); - } - fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { - self.p.cubic_to(cx0, -cy0, cx1, -cy1, x, -y); - } - fn close(&mut self) { - self.p.close(); - } -} - -/// A rasterised glyph: coverage mask placed with its top-left at `(left, top)` relative to the -/// pen position on the baseline. -#[derive(Debug)] -pub struct Glyph { - pub mask: Mask, - pub left: i32, - pub top: i32, -} - -type Key = (char, u32, u8); - -fn cache() -> &'static Mutex>>> { - static C: OnceLock>>>> = OnceLock::new(); - C.get_or_init(Default::default) -} - -/// Rasterise `c` at `px` with the pen at fractional x offset `frac` (0..1, quantised to ¼ px). -pub fn glyph(c: char, px: f32, frac: f32) -> Option> { - let size_q = (px * 4.0).round().max(1.0) as u32; - let frac_q = ((frac.rem_euclid(1.0) * 4.0).round() as u8) % 4; - let key = (c, size_q, frac_q); - if let Some(g) = cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { - return g.clone(); - } - let g = render_glyph(c, size_q as f32 / 4.0, frac_q as f32 / 4.0).map(Arc::new); - let mut m = cache().lock().unwrap_or_else(|e| e.into_inner()); - if m.len() > 8192 { - m.clear(); - } - m.insert(key, g.clone()); - g -} - -fn render_glyph(c: char, px: f32, frac: f32) -> Option { - if c.is_whitespace() { - return None; - } - let f = font()?; - let gid = f.charmap().map(c).or_else(|| f.charmap().map('?'))?; - let outline = f.outline_glyphs().get(gid)?; - let mut pen = Pen { p: Path::default() }; - outline.draw(DrawSettings::unhinted(Size::new(px), LocationRef::default()), &mut pen).ok()?; - pen.p.close(); - let (x0, y0, x1, y1) = pen.p.bounds()?; - let left = (x0 + frac).floor() as i32; - let top = y0.floor() as i32; - let w = ((x1 + frac).ceil() as i32 - left).max(1) as usize; - let h = (y1.ceil() as i32 - top).max(1) as usize; - let mask = fill(&pen.p, w, h, frac - left as f32, -top as f32); - Some(Glyph { mask, left, top }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn inter_loads_and_draws() { - let (a, d) = metrics(100.0); - assert!(a > 80.0 && a < 110.0, "ascent {a}"); - assert!(d > 10.0 && d < 40.0, "descent {d}"); - assert!(advance('W', 100.0) > advance('i', 100.0)); - let g = glyph('H', 40.0, 0.0).unwrap(); - // the H has two stems: the middle row has ink on both sides and a gap between them - let y = g.mask.h / 4; - let row: Vec = (0..g.mask.w).map(|x| g.mask.get(x, y)).collect(); - assert!(row[1] > 0.9 && row[g.mask.w - 2] > 0.9, "{row:?}"); - assert!(row[g.mask.w / 2] < 0.1, "{row:?}"); - assert!(g.top < 0, "glyph rises above the baseline"); - assert!(glyph(' ', 40.0, 0.0).is_none()); - assert!(measure("Hello", 30.0) > 50.0); - } -} diff --git a/crates/captions/src/lib.rs b/crates/captions/src/lib.rs index 4c8b24b74..d10c98d12 100644 --- a/crates/captions/src/lib.rs +++ b/crates/captions/src/lib.rs @@ -10,13 +10,11 @@ //! (drop-frame or not) convert exactly; [`Document::snap_to_frames`] snaps to a sequence's frames. //! - **Model:** [`track_from_document`] / [`document_from_track`] convert to and from the project's //! [`CaptionTrack`]. -//! - **Burn-in:** [`burn`] lays a caption out with the track style and rasterises it with the -//! bundled Inter font (outlines via `skrifa`, coverage rasteriser in [`raster`]). +//! - **Burn-in:** [`burn`] lays a caption out with the track style and draws it with the +//! `filmcraft-text` engine (Inter SemiBold, shaped and kerned). pub mod burn; pub mod cea608; -pub mod font; -pub mod raster; pub mod scc; pub mod srt; pub mod vtt; diff --git a/crates/captions/src/raster.rs b/crates/captions/src/raster.rs deleted file mode 100644 index ff8dcc383..000000000 --- a/crates/captions/src/raster.rs +++ /dev/null @@ -1,228 +0,0 @@ -//! A small anti-aliased path rasteriser for glyph outlines. -//! -//! Curves are flattened to line segments; each pixel row is sampled at [`SUB`] sub-scanlines, and -//! on each sub-scanline the non-zero winding spans between edge crossings are accumulated with -//! exact horizontal coverage at the span ends. Original implementation for FilmCraft. - -/// Vertical sub-samples per pixel row. -pub const SUB: usize = 5; - -/// A flattened path: line segments `(x0, y0, x1, y1)` in pixel space, y down. -#[derive(Clone, Debug, Default)] -pub struct Path { - pub segs: Vec<[f32; 4]>, - start: (f32, f32), - cur: (f32, f32), - open: bool, -} - -impl Path { - pub fn move_to(&mut self, x: f32, y: f32) { - self.close(); - self.start = (x, y); - self.cur = (x, y); - self.open = true; - } - pub fn line_to(&mut self, x: f32, y: f32) { - if (x, y) != self.cur { - self.segs.push([self.cur.0, self.cur.1, x, y]); - } - self.cur = (x, y); - } - pub fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { - let (x0, y0) = self.cur; - let dd = ((x0 - 2.0 * cx + x).abs() + (y0 - 2.0 * cy + y).abs()).max(0.01); - let n = ((dd * 2.0).sqrt().ceil() as usize).clamp(1, 32); - for i in 1..=n { - let t = i as f32 / n as f32; - let u = 1.0 - t; - self.line_to(u * u * x0 + 2.0 * u * t * cx + t * t * x, u * u * y0 + 2.0 * u * t * cy + t * t * y); - } - } - pub fn cubic_to(&mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) { - let (x0, y0) = self.cur; - let dd = (x0 - 2.0 * c1x + c2x).abs() + (y0 - 2.0 * c1y + c2y).abs() + (c1x - 2.0 * c2x + x).abs() + (c1y - 2.0 * c2y + y).abs(); - let n = (((dd.max(0.01)) * 3.0).sqrt().ceil() as usize).clamp(1, 48); - for i in 1..=n { - let t = i as f32 / n as f32; - let u = 1.0 - t; - let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); - self.line_to(a * x0 + b * c1x + c * c2x + d * x, a * y0 + b * c1y + c * c2y + d * y); - } - } - pub fn close(&mut self) { - if self.open && self.cur != self.start { - self.segs.push([self.cur.0, self.cur.1, self.start.0, self.start.1]); - } - self.cur = self.start; - self.open = false; - } - /// Bounding box `(x0, y0, x1, y1)` of all segments. - pub fn bounds(&self) -> Option<(f32, f32, f32, f32)> { - let mut it = self.segs.iter(); - let f = it.next()?; - let mut b = (f[0].min(f[2]), f[1].min(f[3]), f[0].max(f[2]), f[1].max(f[3])); - for s in it { - b = (b.0.min(s[0]).min(s[2]), b.1.min(s[1]).min(s[3]), b.2.max(s[0]).max(s[2]), b.3.max(s[1]).max(s[3])); - } - Some(b) - } -} - -/// Coverage mask (0..1), row-major. -#[derive(Clone, Debug, PartialEq)] -pub struct Mask { - pub w: usize, - pub h: usize, - pub a: Vec, -} - -impl Mask { - pub fn new(w: usize, h: usize) -> Self { - Self { w, h, a: vec![0.0; w * h] } - } - pub fn get(&self, x: usize, y: usize) -> f32 { - self.a[y * self.w + x] - } -} - -/// Fill `path` (non-zero winding) into a `w`×`h` mask, offsetting the path by `(dx, dy)`. -pub fn fill(path: &Path, w: usize, h: usize, dx: f32, dy: f32) -> Mask { - let mut m = Mask::new(w, h); - if w == 0 || h == 0 { - return m; - } - // edges: (ytop, ybot, x at ytop, dx/dy, winding) - let mut edges: Vec<(f32, f32, f32, f32, i32)> = path - .segs - .iter() - .filter(|s| s[1] != s[3]) - .map(|s| { - let (x0, y0, x1, y1) = (s[0] + dx, s[1] + dy, s[2] + dx, s[3] + dy); - let (dir, xa, ya, xb, yb) = if y0 < y1 { (1, x0, y0, x1, y1) } else { (-1, x1, y1, x0, y0) }; - (ya, yb, xa, (xb - xa) / (yb - ya), dir) - }) - .collect(); - edges.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut xs: Vec<(f32, i32)> = Vec::new(); - let mut row = vec![0.0f32; w + 1]; - let weight = 1.0 / SUB as f32; - for y in 0..h { - row.iter_mut().for_each(|v| *v = 0.0); - let mut any = false; - for k in 0..SUB { - let sy = y as f32 + (k as f32 + 0.5) / SUB as f32; - xs.clear(); - for e in &edges { - if e.0 > sy { - break; // sorted by top - } - if sy < e.1 { - xs.push((e.2 + (sy - e.0) * e.3, e.4)); - } - } - if xs.len() < 2 { - continue; - } - xs.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut wind = 0; - let mut span_start = 0.0f32; - for &(x, d) in &xs { - let before = wind; - wind += d; - if before == 0 && wind != 0 { - span_start = x; - } else if before != 0 && wind == 0 { - add_span(&mut row, span_start, x, weight, w); - any = true; - } - } - } - if any { - let out = &mut m.a[y * w..(y + 1) * w]; - for (o, v) in out.iter_mut().zip(&row) { - *o = v.min(1.0); - } - } - } - m -} - -/// Add coverage `weight` over `[x0, x1)` with fractional ends. -fn add_span(row: &mut [f32], x0: f32, x1: f32, weight: f32, w: usize) { - let x0 = x0.clamp(0.0, w as f32); - let x1 = x1.clamp(0.0, w as f32); - if x1 <= x0 { - return; - } - let i0 = x0.floor() as usize; - let i1 = x1.floor() as usize; - if i0 == i1 { - row[i0] += (x1 - x0) * weight; - return; - } - row[i0] += (i0 as f32 + 1.0 - x0) * weight; - for v in &mut row[i0 + 1..i1] { - *v += weight; - } - if i1 < w { - row[i1] += (x1 - i1 as f32) * weight; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn rect(x0: f32, y0: f32, x1: f32, y1: f32) -> Path { - let mut p = Path::default(); - p.move_to(x0, y0); - p.line_to(x1, y0); - p.line_to(x1, y1); - p.line_to(x0, y1); - p.close(); - p - } - - #[test] - fn rectangle_coverage() { - let m = fill(&rect(1.5, 2.0, 4.0, 5.0), 6, 6, 0.0, 0.0); - assert!((m.get(1, 3) - 0.5).abs() < 1e-5); - assert!((m.get(2, 3) - 1.0).abs() < 1e-5); - assert_eq!(m.get(4, 3), 0.0); - assert_eq!(m.get(2, 1), 0.0); - assert_eq!(m.get(2, 5), 0.0); - let total: f32 = m.a.iter().sum(); - assert!((total - 2.5 * 3.0).abs() < 1e-3, "{total}"); - } - - #[test] - fn hole_with_opposite_winding() { - let mut p = rect(0.0, 0.0, 10.0, 10.0); - // inner square wound the other way - p.move_to(3.0, 3.0); - p.line_to(3.0, 7.0); - p.line_to(7.0, 7.0); - p.line_to(7.0, 3.0); - p.close(); - let m = fill(&p, 10, 10, 0.0, 0.0); - assert_eq!(m.get(5, 5), 0.0); - assert!((m.get(1, 5) - 1.0).abs() < 1e-5); - } - - #[test] - fn circle_area() { - let mut p = Path::default(); - let (cx, cy, r) = (10.0f32, 10.0f32, 6.0f32); - p.move_to(cx + r, cy); - for i in 1..=64 { - let a = i as f32 / 64.0 * std::f32::consts::TAU; - p.line_to(cx + r * a.cos(), cy + r * a.sin()); - } - p.close(); - let m = fill(&p, 20, 20, 0.0, 0.0); - let total: f32 = m.a.iter().sum(); - let want = std::f32::consts::PI * r * r; - assert!((total - want).abs() / want < 0.02, "{total} vs {want}"); - } -} diff --git a/crates/golden/goldens/text_burnin.png b/crates/golden/goldens/text_burnin.png index e447d3dd0d3a679fc32d714bfac0b8fcde5e4902..105c26abfc160025327d8030638d6a2fc82dc4af 100644 GIT binary patch literal 8304 zcmYLPWl$VJw_TPHEVx6Eut1RD5Fof)aCdhPnjp*K4k5TLiw1WIx;VilXmEFeJG}kA zSM}bXsi~>y?wLD%?m6e)iBwgV#lj%R00013@^Vt@005F0;=CRWh&bAZnacnGG<@<> z;+j5-$JwZUgnc(~@2Ub4{KQ<*f<34l(H&FB3KkuZHaFrt|Fr}=I^9o zTnUURF#e}kXh?-ef8{$2^O_Sd{XW&q$0i17x%^_q|rVEKlT129OcY6?i;%!LDJEW{v4V*P(n~i(Gg%XmZhY;XdkVS ziw*fWTW<9Ayf7zj9Uu(sa0+~?M&r{lh$W2o4ht;o#Gq`12J z^IIeMwKP<~o$LaaAHTmB`|k1LHv|H)ZS{ZnSC8CzQOfrjz3GX)M59<49UXl#*kS}z zEf5crLuS9Se9$4>Tq&pjmW`j&&hv1#0*6`|%Ih%u{Sq`&rsJ~G_O&mnL(u!EXGDyX zqjYX=x`@Z8b!??Qe6LqI`|oJBU~zrD$3pEkiw=X3|2<)e-ez_6)>iM*5{YKcX-6iP z6u)BNQ8*gt-;ZKFl^h}e&8u&J=EATJspS58KX_R$e~RVcR$XfI>`+K%mXmMs zJUsjRa+y)*W%O7Kz0MH!z21^vRb?`0qW3yk`Z`r0?{3WwQ&)aUH!jm2RnBgCI}eUg z_;qgr;$~sduk`GvK3$|VD!UdI zdthbxR+~9luIJ^-{-=DQnSu|Uh4*HEZ1Lwi^Vtz3SaK+e68dHVWre9vCeUDX2>rd8J@imv}staGoc! z+tWQ34o*BytEXj3JR!b6F$u|P%hw-sOoYV5MMXukeizxte{pn6!^=m&6Gs3iOBx_Q0WAgzlVGqWqk^N4r z^AgQZ%iJS6-#@`r8ZtAK#W#)3))lSQ`uY~hYEzpg9l7n-eqLT)7OOy=R_YP!mp@q% z+;3Kf``%DB*-dx2@7~yq>svmv#D#!las@$XR&n<-QOT%TGM^45?aoUP^SM)&wl z-*7a8_u}H>Maf zIEB>JQQPy7e-eR6RA^6sgni+jaGbw%59sRY9D8{_=zLSTpUU3AP^RC26-Vac!fw>k z_1!>IGX%quc>ClePQ?Az)|=Ja)4HNIl`Npb_wP%D;c>J%bO+<$B!rbJhJ$;Q{r<7s zij5C2CBr*`+#D@T*X<6VPI;X!HvFBQz6i!?p%Gqc^rQ%Q9o5_0TWKWP>~l6%bqLNU zAYnINEL2Plc)Hgv)!aW?;I2S`5s}|UGx>JM^t$Ic(HEMCKitBc{x%8-jQ8~Plxda( zVP{+jLG_*wW*mUX$nNeJ_}Y~_o){I2Ay^bed=BmR2W5NimwVqO!(~#FlAd?#x-{=6 zL15>lX1qv<0!J0CGdiEsyxZm8$kuah1CmE`=br9?XenuHALLyFmVj zx1);1&RxLDQC^=8a8YRAN!BAdHQLnU*E|AU_{bYQFlK% zkXN)qYL20xJ%la8swWI9IKMxwk+|eCNbUn%7vgZfC|(Rd;tThG0LKv0m`Udq`VWu7 z#bYO!sc(*QGFlEtps63WBO@G*nv9Kh)<#I);cuu`BCswC8y?8eVmhjjE=5psKHIxI zAo52SR#j6tDN!^17D_z{{FU|=06>->q}VbNcnu`|FERfwr@Mes!n;k7F^4~);V-~o z#DLdrpc+jKZ<`8CKmsL4=xYEO^8YIUE^~<9E5H^;%>VI!MB`6N0HE#P6?ZwtN<;AT zySj>2>b2AfUM?w=???a|lR>c?eN{|=DcH|X+DC=*GYn}rL{hjV%wf08;HLVs_GE$P z!!-7>3d8i`QkKwP`M(Vo3pHNCYYiZw2^@g#yr#zOL4|l}6yi~`@LWv3n-pZdzeYP^ zogyUfH-$Isk;5|IpKb~6$t!ELbdX4BV|^oGDLZ=tTxoSa884{#Hke$vpBH-N1Y&M% zAJRKR^1L0@=z6tGQFGn{bdfZKf4)m)r0^7wF)hLyo~6u`gtRu!Mb^YCN%YUlr|H5< zmiL4Ol*70(^!#_R007kT(z2J|{8N&Tc(>AQG4R06vrD|M7_8q0h@~a|AxAIaC&hoS zUbFVlG(n<^^IU#?OdpIl--1aLUT2(Dvv1UaJ`i@DUAL)NSzRNId&G)bQ%lGjwLR8yJ!HOb}fdWg{=q(SBbwy3LFPVU%^dOC|X>t{-%_&d-3 zZujj|uBGbX82YeBXL=FC>~Vu5#m_m1OdR>xN;0Nwpp<0s??FT({TMOv$r`qCxY)%! zoe9eR{DIytBnB=@m1r`sp%fRuB!B4;(_qf0qLmmF()?-0O{8Pl9ySeGj@Ola+ay-h zu3?7LMkx^=gy!;#vB`xkbE*1g`m92UTd&J66a}i8egmMTA<q67e#)hQ+~X8oTXQ`lm!rAba8v+6P*yER6ET(hE1kqi91F$V{V- zDbE7W<^1|pS{*A7Iam$R>Cx7RZ|1F zrm1|FB18$d3&&7jpSFR#CZp{!wR~4gUw@=VN8avxv$<+GNM2o>ncmQV>-F>r$X`lz zjfCA@KLlmM1c#D)1l@oa!)!;$!|>pBrrkwcH5Amfy`;Wls%23zch&Jzk*~fGkzt;` z?b}!0snFBwqj-UYAD4pZj4c9L*Q)(9Xu1hMRjt{T%u6OH*{pF$;QOnyFOl7!HJI&ZYt@L5;d+vOgzy*gBG0nSL$oTFSBuQ zsuiTo;;|Bgst$$1mM?chx99|MR#{^EC4x=b<+?R)`NYu617lU)X3vZ3FT;;JtyTBe z#}9oZCXa0l=>Gnl8n=(sJ9BR;WJ#<#hI97F9<7Nc00nLh|Ass&4%#kJyP8mn&3qvwdis^`cv<*e%Aim7dm(6Mp`(fAI|5C{TwEPbdAchZY0<oLygIqoZ@m zZ}HO(wYhe6D`kEJ-|JPawEGqp9^1_p@N)NTE6%lg9(HhhvYVw2MlHBM8^9z`4vzME z1U}{kgI2I5ls>$hfc20Nqx6$`Dv8xi7(@LvL6@T`tOnVFzUYNV^Bvyc#Y*i8{c@Fs zw*=fp5}|=B?R5xAsGz9meY_BWU?yzJZl+MOkLD8-Fv-<`GMIdNlgp~ir(d*Yzt?`Q zd(diW%6vP;#&g;o$63wm}mUlM=JIz}M#ZWCOhOWp;-Vt#$S4@*5y=eqC-UUnO11p{*^0kGK|$oa_P4iZ zo6XJgolXm_4y!w;IJE|ij_U}0gS}97!(m@FSrd#XL8G=K=-F%jO&7^+GaeV(&J|gdF}&Mj zr{m|Kp>aK1BK;#f>2s@gqq2%qCF*sQ_U!-3r1NoLfZ9{`zMnQ-p#j0nRh`e@dctBo z%mK`d9Jo$?7o&VCP+{LHnbBwD`@G}J7z=^;92AuBk!(INLe~4(d zmEOhZV1zOf1WEL%{#$QaCo#Kj;c4s2N`u2(92rGsdU}J`@;K@=cDoy>`z&pb~wCERmNBMaVHu;SypeV z$($cqfFu;RrkP!&A^B+^nX{k})8FFlQ;;8i#%GGY95h#Qiwv{NR$g|$K3eFc72QJs z0B&$a5ko1MQbCY_3D#Xi7*#UvqVX(&;Vqql^k7t38&Mx2n^~N_*;EC;+kfjpcZw7J z2+=$F)tWHr*F(+ICAd$!Ip1H}rnUQTZVU*E+E15%(eI~Z z<_y?QWHo3kcy7$32kQ<#3wL6X@*I_wY(ho8wwKkc!gEsEX8^WYlsM@8}0GasX$Tb?P+6?5qm_RyS)qrU{Aju6`1o;fXHIm-^V7|0l@7Swg#CCBTD!gbaB~V4@b8Ygo$;=t zN-3?`ES8|I?$9W9sCBfrqu ze-pCb+S9AEL7=oHH+O9^Uv_gKR`h0$-ntQyv-JtuDOuvqiu0(9&+yjo-VH zhkM62Fxul(TTLHWF(Z+8xV&5IUhqI`<7*NlI=rYpALkZ<^jwGjZYs za_7&Yx4}KFvp66m%#NY+J0ZgjJe=iVNhZ?+~(WzDmN+)00xi^OMkWyk(us@7j8 zQ_YEfAv;UbX?Kf(r)Yy}%*_bEdBCn8>NpL3&QX*)gRx~&=4X8vC^C(CZUp^VBK~0* zKdR$$+FRN$##-a4MKgrmJ7Cnd#u>Xucz5^xlzeFD9Q@xVl38@7u?yCD4fm39pkM9dv6_CpSZas9{8SDR^mTKbZ!i>TGkpMoN>ER?$c1Z!i~aXLP-r z?pk?C=B|EcPEu>fCxuCDnH6~+THPk`847lYMR}zgt*)}&HuO?K^9E%pZ0 z@Sdz+UyhIs7z>Yee%4+5S!`3pb8H#vV84DG{pG1u6cM1+*LLz(Ng_=GQL8nhfUth3 z`R#E@@KG0e*{fO^)+ZXZH)q|3&KwUUsUhOWXTau?v37Z1k1c4+{_eBRG3eu7DZS4v z`NjfU&?I)5u1%T*H`G7d(M(U?*8?cThu`6!IP*Z8?aC;Iv8TO7Y7~E(`GEGrRWF>) zkv+SK6NyXOLr|dOLLy`7!E#<*Z1{K98C&0RC!vILf6rk(|ve)Y?UVQZjYn6Xeo2&^?$8@7=2P z6br|j#>&9_AYtivo*lQnSB)BRToe!;-GqtjCS+jt1v4sN#bypAiEz2$y^fnKitnPh zni27naJ73d8^H&aN`b-Qq0N0X8}uymhJHMJ?!n)LsR#pIBWD89+blzC#ATUOdJkML zh6~By)F1~V0XR@)Om|-?bynm!UZ7oj2=@r?WT!4u?dhPW<^%$a_TO!3lXd=p{1{n> zqkSM}YosY1NT?Q8Q4r0h1;?u?{^$XAP?ct9H?rWHSEGpFYjKvLAu5=I@DG(<3*;%dZ) zZ+L_x*(!MQPRY;6s_#GdC85XEH=0@aH6!a#q^b&=vducBYMDAAjfazM32a({f2C{S zH0HLm0dFBjXp?hPeg4uc*OUy+-*15drf_*vBYyeE{Izz9$N_xnZsW$@X`DCC27m;7 zj?fggDa7bo7fmAEIOZ)kiMx$8KJGzpX+JUh=3Jn|YU~#N@K7ZhDwrt#pu0(6UD`a7 z3W7P&+>hBI?u(@CQ@;7W23K++20jH&d<{5?#}plbH@-T= z=Gagz=nR=JYZ5a-8roO9K0}Uv>Y#uGn34pv{|q*UvaVJeL|Q=0e(l8zqrLgIT@26j z=6VdYBz64S^(?|SX%hLNW_^%~h^0`Foi4^NMtAJIR0M#FDZo^ndy!NeZF1j(cM=IV z3Lagv>kT4l2dYa?9IOtrdK|q|=PL^OPlE#zP6jx{^Vg$&H;!Cax%vKE^?PT!l=c;8 zSd+wNrBKk*H#ObuB_iB*K?x5Y6Vq30sAs9&{c(xOU7I0mD4D+Ez9MxD`4}# z&1^R&yf|*X#iZ#Teuczx58FyrCz!7>>WSzhr;jEHq3NJN bO_^M)uF+W5%|&nY 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 O0000 Scene { b.at(frame) } -/// Timecode and Clip Name burn-ins (FilmCraft's built-in bitmap text). +/// Timecode and Clip Name burn-ins (text engine: JetBrains Mono / Inter). fn text_burnin() -> Scene { let mut b = Builder::new(1); let bg = b.demo(DemoScene::CityNight); diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 7c9b9c9e0..315bd48c4 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -18,6 +18,7 @@ filmcraft-media = { workspace = true } filmcraft-project = { workspace = true } filmcraft-audio-dsp = { workspace = true } filmcraft-captions = { workspace = true } +filmcraft-text = { workspace = true } rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/render/src/effects.rs b/crates/render/src/effects.rs index 2136fda7d..fe40bbd4d 100644 --- a/crates/render/src/effects.rs +++ b/crates/render/src/effects.rs @@ -716,40 +716,16 @@ pub fn apply(img: &mut Image, e: &EffectInstance, cx: &FxCtx) { }); } "timecode" | "clip_name" => { - let text = if e.effect == "timecode" { - cx.timecode.to_string() - } else { - cx.clip_name.chars().filter(|c| c.is_ascii_digit() || *c == ':' || *c == '-').collect() - }; + let (text, family) = if e.effect == "timecode" { (cx.timecode.to_string(), "JetBrains Mono") } else { (cx.clip_name.to_string(), "Inter") }; let pos = point(e, "position", cx, img); - let size = (f(e, "size", cx) / 100.0 * img.h as f32 * 0.5).max(6.0) as i32; - burn_text(img, &text, pos, size); + let px = (f(e, "size", cx) / 100.0 * img.h as f32 * 0.5).max(6.0); + let box_alpha = if e.effect == "timecode" { (f(e, "opacity", cx) / 100.0).clamp(0.0, 1.0) * 0.8 } else { 0.8 }; + crate::graphics::burn_text(img, &text, family, pos, px, box_alpha); } _ => {} } } -fn burn_text(img: &mut Image, text: &str, pos: Vec2, size: i32) { - let tw = filmcraft_media::digits::text_width(size, text); - let (w, h) = (img.w, img.h); - let mut buf = vec![0u8; w * h * 4]; - let x = pos.x as i32 - tw / 2; - let y = pos.y as i32 - size / 2; - filmcraft_media::digits::fill_rect(&mut buf, w, h, x - size / 4, y - size / 4, tw + size / 2, size + size / 2, [0, 0, 0, 200]); - filmcraft_media::digits::draw_text(&mut buf, w, h, x, y, size, text, [255, 255, 255, 255]); - img.px.par_chunks_mut(w * 4).zip(buf.par_chunks(w * 4)).for_each(|(d, s)| { - for (d, s) in d.chunks_exact_mut(4).zip(s.chunks_exact(4)) { - let a = s[3] as f32 / 255.0; - if a > 0.0 { - for k in 0..3 { - d[k] = srgb_to_linear(s[k] as f32 / 255.0) * a + d[k] * (1.0 - a); - } - d[3] = a + d[3] * (1.0 - a); - } - } - }); -} - /// Fill an opaque generated colour over the image at `op` opacity (Generate category). fn fill_over(img: &mut Image, op: f32, f: impl Fn(f32, f32) -> [f32; 3] + Sync) { let w = img.w; diff --git a/crates/render/src/graphics.rs b/crates/render/src/graphics.rs new file mode 100644 index 000000000..3664aa01f --- /dev/null +++ b/crates/render/src/graphics.rs @@ -0,0 +1,72 @@ +//! Text and vector graphics drawn with `filmcraft-text`: the Timecode / Clip Name burn-ins and +//! (see below) graphic clips' text and shape layers. + +use filmcraft_color::srgb_to_linear; +use filmcraft_geom::Vec2; +use filmcraft_text::{Align, Mask, ParagraphStyle, TextStyle, layout, render}; +use rayon::prelude::*; + +use crate::image::Image; + +/// sRGB-encoded straight colour (0..1) → premultiplied linear. +pub fn premul_linear(c: [f32; 4]) -> [f32; 4] { + let a = c[3].clamp(0.0, 1.0); + [srgb_to_linear(c[0]) * a, srgb_to_linear(c[1]) * a, srgb_to_linear(c[2]) * a, a] +} + +/// Composite `color` (premultiplied linear) through coverage `m` placed at (`x0`, `y0`), scaled by +/// `opacity`, over `img`. +pub fn paint_mask(img: &mut Image, m: &Mask, x0: i32, y0: i32, color: [f32; 4], opacity: f32) { + if m.w == 0 || m.h == 0 || color[3] <= 0.0 || opacity <= 0.0 { + return; + } + let w = img.w; + let ys = y0.max(0) as usize..((y0 + m.h as i32).min(img.h as i32)).max(0) as usize; + if ys.is_empty() { + return; + } + let xs = x0.max(0)..(x0 + m.w as i32).min(w as i32); + if xs.is_empty() { + return; + } + let rows = &mut img.px[ys.start * w * 4..ys.end * w * 4]; + rows.par_chunks_mut(w * 4).enumerate().for_each(|(ry, row)| { + let my = (ys.start + ry) as i32 - y0; + let mrow = &m.a[my as usize * m.w..(my as usize + 1) * m.w]; + for x in xs.clone() { + let cov = mrow[(x - x0) as usize] * opacity; + if cov <= 0.0 { + continue; + } + let p = &mut row[x as usize * 4..x as usize * 4 + 4]; + let k = 1.0 - color[3] * cov; + for c in 0..4 { + p[c] = color[c] * cov + p[c] * k; + } + } + }); +} + +/// Draw `text` centred on `pos` (layer pixels) at `px` pixels in white over a black box of +/// `box_alpha` (the Timecode and Clip Name effects). +pub fn burn_text(img: &mut Image, text: &str, family: &str, pos: Vec2, px: f32, box_alpha: f32) { + if text.is_empty() || pos.x.is_nan() { + return; + } + let st = TextStyle { family: family.into(), style: "Regular".into(), size: px, ..Default::default() }; + let l = layout(text, &st, &ParagraphStyle { align: Align::Center, ..Default::default() }); + let vm = filmcraft_text::fonts::face(filmcraft_text::resolve(family, "Regular").face).metrics(px); + // centre the cap height on pos + let (bx, by) = (pos.x as f32, pos.y as f32 + vm.cap_height / 2.0); + let pad = px * 0.25; + let b = l.bounds; + let boxp = filmcraft_text::Path::rect(bx + b[0] - pad, by - vm.cap_height - pad, bx + b[2] + pad, by + vm.descent * 0.6 + pad); + if let Some(bb) = boxp.bounds() { + let (x0, y0) = (bb.0.floor() as i32, bb.1.floor() as i32); + let m = + filmcraft_text::raster::fill(&boxp, (bb.2.ceil() as i32 - x0).max(1) as usize, (bb.3.ceil() as i32 - y0).max(1) as usize, -x0 as f32, -y0 as f32); + paint_mask(img, &m, x0, y0, [0.0, 0.0, 0.0, 1.0], box_alpha); + } + let (m, x0, y0) = render::rasterize(&l, &render::at(bx, by.round())); + paint_mask(img, &m, x0, y0, [1.0, 1.0, 1.0, 1.0], 1.0); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a723b6bc5..1cb409338 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -11,6 +11,7 @@ pub mod audio; pub mod audio_fx; pub mod blend; pub mod effects; +pub mod graphics; pub mod image; pub mod plan; pub mod preview; diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml new file mode 100644 index 000000000..d9f7f8e6e --- /dev/null +++ b/crates/text/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "filmcraft-text" +description = "FilmCraft text engine: font database (bundled + system), shaping, bidi, line breaking, paragraph layout and anti-aliased glyph/path rasterisation" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +filmcraft-color = { workspace = true } +# Font tables, glyph outlines and metrics (MIT OR Apache-2.0). Rasterisation is our own. +skrifa = { version = "0.44", default-features = false, features = ["std"] } +# OpenType shaping (HarfBuzz port on read-fonts, MIT); shares font parsing with skrifa. +harfrust = { version = "0.12", default-features = false, features = ["std"] } +# Unicode Bidirectional Algorithm (MIT OR Apache-2.0). +unicode-bidi = "0.3.18" +# UAX #14 line breaking (Apache-2.0). +unicode-linebreak = "0.1.5" diff --git a/crates/text/README.md b/crates/text/README.md new file mode 100644 index 000000000..b55c0dab8 --- /dev/null +++ b/crates/text/README.md @@ -0,0 +1,30 @@ +# filmcraft-text + +FilmCraft's text engine (layer L1: depends on `filmcraft-color`, `skrifa`, `harfrust`, +`unicode-bidi` and `unicode-linebreak`). It draws graphic-clip text and shapes, caption burn-in +and the Timecode / Clip Name effects. Pure Rust; it builds for `wasm32-unknown-unknown`. + +| Module | What | +|---|---| +| `fonts` | Font database. Bundled faces (always present, also on the web): Inter Regular / Medium / SemiBold / Bold / Italic, JetBrains Mono Regular, Noto Serif Regular — all SIL OFL 1.1, unmodified, in `assets/fonts/` with `.attribution` sidecars. More faces come from `FontSource`s: `DirectorySource::system()` scans the platform font folders on first need (only the `name` and `OS/2` tables are read, via `sfnt`); on wasm the scan finds nothing and hosts can call `add_font_data`. `resolve(family, style)` picks a face, synthesises bold/italic when the family lacks them, and falls back to Inter (flagged `missing`). `fallback_for` finds a face for characters the chosen face lacks. | +| `layout` | Paragraph layout: per-character font fallback → bidi levels (UAX #9) → items (runs of one face, direction and size) shaped with harfrust (OpenType kerning, ligatures, marks, complex scripts) → tracking → line breaking at UAX #14 opportunities (area text) → visual reordering per line → alignment (left / centre / right / justify), leading (added to the natural line height), baseline shift, all caps / synthesised small caps (78 %), underline. Every character boundary gets a caret stop (ligatures are split evenly), so editors can place carets, hit-test and draw selections. Layouts are cached by text + style (512 entries, LRU). | +| `raster` | Our anti-aliased rasteriser (non-zero winding, 5 sub-scanlines, exact horizontal coverage, active-edge list), contour paths with transforms, rounded rectangles, ellipses, polygons and outline emboldening. Coverage is linear, so compositing in linear light is correct. | +| `render` | Glyph outlines (cached per face + glyph) and glyph masks (cached per size in ⅛ px, ¼-px horizontal phase, synthetic style). Axis-aligned uniform transforms use the mask cache with sub-pixel x positioning; rotated / skewed / non-uniform transforms fill the transformed outlines of the whole run in one pass. Synthetic bold grows outlines by 2.5 % of the size; synthetic italic slants by 12°. | +| `mask` | Outer / centre / inner strokes from an exact Euclidean distance transform (Felzenszwalb–Huttenlocher) with sub-pixel refinement, a three-pass box blur (≈ Gaussian) for shadows, offsets. | + +## Coordinates + +Pixels, y down. Point text: the origin is the alignment point on the first baseline (left edge, +centre or right edge). Area text (`ParagraphStyle::width`): the origin is the box's top-left. + +## Performance + +A three-line, centred, 96 px bold title at 1080p (`perf::three_line_title_is_fast`), release build +on an M-series Mac: about 1.6 ms cold (shaping, outline extraction, glyph rasterisation) and +0.15 ms warm (layout cache + glyph cache). The test fails if the warm path exceeds 40 ms. + +## Not yet + +Per-character styling within one text layer (the whole layer has one style), vertical text, OpenType +feature toggles beyond kerning/ligatures, variable-font axes, hinting, colour (emoji) glyphs, and +hyphenation. Words longer than an area-text line overflow instead of breaking mid-word. diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs new file mode 100644 index 000000000..290a27e1d --- /dev/null +++ b/crates/text/src/fonts.rs @@ -0,0 +1,475 @@ +//! The font database: bundled fonts (always available, also on the web) plus fonts discovered by +//! [`FontSource`]s (system font folders on native platforms). +//! +//! Faces are addressed by [`FaceId`] (an index that never changes for the life of the process). +//! [`resolve`] maps a family + style name (as stored in projects) to a face and the synthetic +//! bold/italic needed when the family lacks that style. Unknown families fall back to Inter. + +use std::path::PathBuf; +use std::sync::{Arc, OnceLock, RwLock}; + +use skrifa::instance::{LocationRef, Size}; +use skrifa::{FontRef, MetadataProvider}; + +use crate::sfnt::{FaceNames, read_faces, read_faces_bytes}; + +/// Bundled fonts (SIL OFL 1.1; see `assets/fonts/*.attribution` and ATTRIBUTION.md). +pub static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); +pub static INTER_MEDIUM: &[u8] = include_bytes!("../../../assets/fonts/Inter-Medium.ttf"); +pub static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); +pub static INTER_BOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-Bold.ttf"); +pub static INTER_ITALIC: &[u8] = include_bytes!("../../../assets/fonts/Inter-Italic.ttf"); +pub static JETBRAINS_MONO_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"); +pub static NOTO_SERIF_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/NotoSerif-Regular.ttf"); + +/// The default family for new text. +pub const DEFAULT_FAMILY: &str = "Inter"; + +/// Index of a face in the database. +pub type FaceId = usize; + +/// Where a face's bytes come from. +#[derive(Clone, Debug)] +pub enum FaceData { + Static(&'static [u8]), + Shared(Arc>), + /// A file read on first use. + File(PathBuf), +} + +/// Description of a face offered by a [`FontSource`]. +#[derive(Clone, Debug)] +pub struct FaceInfo { + pub family: String, + pub style: String, + pub weight: u16, + pub italic: bool, + pub index: u32, + pub data: FaceData, + /// "bundled", "system", … + pub origin: &'static str, +} + +/// Something that can list fonts (system folders, a web font picker, a project's font folder…). +pub trait FontSource: Send + Sync { + fn faces(&self) -> Vec; +} + +/// Scans font folders on disk (reading only name tables). Native only; on the web there are no +/// folders and the scan finds nothing. +pub struct DirectorySource { + pub dirs: Vec, +} + +impl DirectorySource { + /// The platform's standard font folders. + pub fn system() -> Self { + let mut dirs: Vec = Vec::new(); + let home = std::env::var_os("HOME").map(PathBuf::from); + if cfg!(target_os = "macos") { + dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(PathBuf::from)); + if let Some(h) = &home { + dirs.push(h.join("Library/Fonts")); + } + } else if cfg!(windows) { + let win = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("C:\\Windows")); + dirs.push(win.join("Fonts")); + if let Some(l) = std::env::var_os("LOCALAPPDATA") { + dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + } + } else if cfg!(unix) && !cfg!(target_arch = "wasm32") { + dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(PathBuf::from)); + if let Some(h) = &home { + dirs.push(h.join(".local/share/fonts")); + dirs.push(h.join(".fonts")); + } + } + Self { dirs } + } +} + +fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + if depth < 4 { + walk(&p, depth + 1, out); + } + } else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| matches!(x.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")) { + out.push(p); + } + } +} + +impl FontSource for DirectorySource { + fn faces(&self) -> Vec { + let mut files = Vec::new(); + for d in &self.dirs { + walk(d, 0, &mut files); + } + files.sort(); + let mut out = Vec::new(); + for f in files { + let Ok(mut file) = std::fs::File::open(&f) else { continue }; + for n in read_faces(&mut file) { + out.push(info_from(n, FaceData::File(f.clone()), "system")); + } + } + out + } +} + +fn info_from(n: FaceNames, data: FaceData, origin: &'static str) -> FaceInfo { + FaceInfo { family: n.family, style: n.style, weight: n.weight, italic: n.italic, index: n.index, data, origin } +} + +/// A loaded (or loadable) face. +pub struct Face { + pub id: FaceId, + pub info: FaceInfo, + bytes: OnceLock>, + shaper: OnceLock, +} + +#[derive(Clone)] +enum FaceBytes { + Static(&'static [u8]), + Shared(Arc>), +} + +impl FaceBytes { + fn get(&self) -> &[u8] { + match self { + FaceBytes::Static(b) => b, + FaceBytes::Shared(b) => b, + } + } +} + +/// Vertical metrics at a pixel size (all positive distances, y down from the baseline for the +/// underline position). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct VMetrics { + pub ascent: f32, + pub descent: f32, + pub line_gap: f32, + pub cap_height: f32, + pub x_height: f32, + pub underline_pos: f32, + pub underline_thickness: f32, +} + +impl Face { + fn bytes(&self) -> Option<&[u8]> { + self.bytes + .get_or_init(|| match &self.info.data { + FaceData::Static(b) => Some(FaceBytes::Static(b)), + FaceData::Shared(b) => Some(FaceBytes::Shared(b.clone())), + FaceData::File(p) => std::fs::read(p).ok().map(|v| FaceBytes::Shared(Arc::new(v))), + }) + .as_ref() + .map(FaceBytes::get) + } + /// The parsed font (None if the file is missing or unreadable). + pub fn font(&self) -> Option> { + FontRef::from_index(self.bytes()?, self.info.index).ok() + } + pub(crate) fn shaper_data(&self) -> Option<&harfrust::ShaperData> { + let f = self.font()?; + Some(self.shaper.get_or_init(|| harfrust::ShaperData::new(&f))) + } + pub fn units_per_em(&self) -> f32 { + self.font().map(|f| f.metrics(Size::unscaled(), LocationRef::default()).units_per_em as f32).filter(|u| *u > 0.0).unwrap_or(1000.0) + } + /// Glyph for a character (None when the face does not cover it). + pub fn glyph(&self, c: char) -> Option { + self.font()?.charmap().map(c).map(|g| g.to_u32()).filter(|g| *g != 0) + } + pub fn has_char(&self, c: char) -> bool { + self.glyph(c).is_some() + } + pub fn metrics(&self, px: f32) -> VMetrics { + let Some(f) = self.font() else { + return VMetrics { + ascent: px * 0.8, + descent: px * 0.2, + cap_height: px * 0.7, + x_height: px * 0.5, + underline_pos: px * 0.1, + underline_thickness: px * 0.06, + ..Default::default() + }; + }; + let m = f.metrics(Size::new(px), LocationRef::default()); + VMetrics { + ascent: m.ascent, + descent: -m.descent, + line_gap: m.leading, + cap_height: m.cap_height.unwrap_or(m.ascent * 0.7), + x_height: m.x_height.unwrap_or(m.ascent * 0.5), + underline_pos: m.underline.map_or(px * 0.1, |u| -u.offset), + underline_thickness: m.underline.map_or(px * 0.06, |u| u.thickness.max(px * 0.03)), + } + } + /// Whether the face's GSUB offers an OpenType feature (e.g. `smcp`). + pub fn has_feature(&self, tag: &[u8; 4]) -> bool { + use skrifa::raw::TableProvider; + let Some(f) = self.font() else { return false }; + let Ok(gsub) = f.gsub() else { return false }; + let Ok(list) = gsub.feature_list() else { return false }; + list.feature_records().iter().any(|r| r.feature_tag().to_be_bytes() == *tag) + } +} + +struct Db { + faces: Vec>, + scanned: bool, +} + +fn db() -> &'static RwLock { + static DB: OnceLock> = OnceLock::new(); + DB.get_or_init(|| { + let mut db = Db { faces: Vec::new(), scanned: false }; + for b in [INTER_REGULAR, INTER_MEDIUM, INTER_SEMIBOLD, INTER_BOLD, INTER_ITALIC, JETBRAINS_MONO_REGULAR, NOTO_SERIF_REGULAR] { + for n in read_faces_bytes(b) { + push(&mut db, info_from(n, FaceData::Static(b), "bundled")); + } + } + RwLock::new(db) + }) +} + +fn push(db: &mut Db, info: FaceInfo) -> FaceId { + let id = db.faces.len(); + db.faces.push(Arc::new(Face { id, info, bytes: OnceLock::new(), shaper: OnceLock::new() })); + id +} + +/// Add faces from a source (skipping family+style pairs already present). Returns how many were +/// added. +pub fn add_source(src: &dyn FontSource) -> usize { + let faces = src.faces(); + let mut d = db().write().unwrap_or_else(|e| e.into_inner()); + let mut n = 0; + for f in faces { + let dup = d.faces.iter().any(|g| g.info.family.eq_ignore_ascii_case(&f.family) && g.info.style.eq_ignore_ascii_case(&f.style)); + if !dup { + push(&mut d, f); + n += 1; + } + } + n +} + +/// Register a font from memory (e.g. a font file the user picked on the web). Returns its faces. +pub fn add_font_data(data: Vec) -> Vec { + let data = Arc::new(data); + let names = read_faces_bytes(&data); + let mut d = db().write().unwrap_or_else(|e| e.into_inner()); + names.into_iter().map(|n| push(&mut d, info_from(n, FaceData::Shared(data.clone()), "user"))).collect() +} + +/// Scan the system font folders once (native only; a no-op on wasm). Returns the number of faces +/// added by this call. +pub fn scan_system() -> usize { + { + let mut d = db().write().unwrap_or_else(|e| e.into_inner()); + if d.scanned { + return 0; + } + d.scanned = true; + } + if cfg!(target_arch = "wasm32") { + return 0; + } + add_source(&DirectorySource::system()) +} + +pub fn system_scanned() -> bool { + db().read().unwrap_or_else(|e| e.into_inner()).scanned +} + +/// A face by id (panics never: unknown ids give the default face). +pub fn face(id: FaceId) -> Arc { + let d = db().read().unwrap_or_else(|e| e.into_inner()); + d.faces.get(id).or_else(|| d.faces.first()).cloned().expect("bundled fonts present") +} + +/// All faces (bundled first). +pub fn all_faces() -> Vec> { + db().read().unwrap_or_else(|e| e.into_inner()).faces.clone() +} + +/// Families with their style names, sorted by family; styles in weight order. +pub fn families() -> Vec<(String, Vec)> { + let mut m: std::collections::BTreeMap> = Default::default(); + for f in all_faces() { + let v = m.entry(f.info.family.clone()).or_default(); + if !v.iter().any(|(_, _, s)| s == &f.info.style) { + v.push((f.info.italic, f.info.weight, f.info.style.clone())); + } + } + m.into_iter() + .map(|(k, mut v)| { + v.sort(); + (k, v.into_iter().map(|(_, _, s)| s).collect()) + }) + .collect() +} + +/// The face chosen for a family/style request, plus synthetic emboldening / slant when the family +/// lacks a real bold / italic. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Resolved { + pub face: FaceId, + pub synth_bold: bool, + pub synth_italic: bool, + /// The requested family was not found (Premiere shows such fonts as missing). + pub missing: bool, +} + +fn style_wants(style: &str) -> (u16, bool) { + let s = style.to_ascii_lowercase().replace([' ', '-'], ""); + let italic = s.contains("italic") || s.contains("oblique"); + let w = if s.contains("thin") || s.contains("hairline") { + 100 + } else if s.contains("extralight") || s.contains("ultralight") { + 200 + } else if s.contains("light") { + 300 + } else if s.contains("medium") { + 500 + } else if s.contains("semibold") || s.contains("demibold") { + 600 + } else if s.contains("extrabold") || s.contains("ultrabold") { + 800 + } else if s.contains("black") || s.contains("heavy") { + 900 + } else if s.contains("bold") { + 700 + } else { + 400 + }; + (w, italic) +} + +/// Resolve a family + style name. Family matching is case-insensitive; a style that the family +/// lacks picks the nearest weight and synthesises bold (≥ 600 requested, ≤ 500 found) or italic. +pub fn resolve(family: &str, style: &str) -> Resolved { + if let Some(r) = resolve_in(family, style) { + return r; + } + if !system_scanned() && !family.is_empty() && !family.eq_ignore_ascii_case(DEFAULT_FAMILY) { + scan_system(); + if let Some(r) = resolve_in(family, style) { + return r; + } + } + let mut r = resolve_in(DEFAULT_FAMILY, style).expect("Inter is bundled"); + r.missing = !family.is_empty() && !family.eq_ignore_ascii_case(DEFAULT_FAMILY); + r +} + +fn resolve_in(family: &str, style: &str) -> Option { + let faces = all_faces(); + let fam: Vec<&Arc> = faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(family)).collect(); + if fam.is_empty() { + return None; + } + if let Some(f) = fam.iter().find(|f| f.info.style.eq_ignore_ascii_case(style)) { + return Some(Resolved { face: f.id, synth_bold: false, synth_italic: false, missing: false }); + } + let (w, it) = style_wants(style); + let best = fam + .iter() + .min_by_key(|f| { + let italic_pen = if f.info.italic == it { 0 } else { 1000 }; + italic_pen + (f.info.weight as i32 - w as i32).unsigned_abs() + }) + .expect("non-empty"); + Some(Resolved { face: best.id, synth_bold: w >= 600 && best.info.weight <= 500, synth_italic: it && !best.info.italic, missing: false }) +} + +/// A face that covers `c`, preferring `prefer`; bundled and already-registered faces are searched. +pub fn fallback_for(c: char, prefer: FaceId) -> FaceId { + let p = face(prefer); + if p.has_char(c) || c.is_control() { + return prefer; + } + let faces = all_faces(); + // prefer a face of the same italic-ness that is bundled (cheap), then anything loaded already + for f in faces.iter().filter(|f| f.info.origin == "bundled") { + if f.has_char(c) { + return f.id; + } + } + for f in faces.iter().filter(|f| f.info.origin != "bundled" && f.bytes.get().is_some()) { + if f.has_char(c) { + return f.id; + } + } + prefer +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_families_and_resolution() { + let fams = families(); + let inter = fams.iter().find(|(f, _)| f == "Inter").expect("Inter"); + for s in ["Regular", "Medium", "SemiBold", "Bold", "Italic"] { + assert!(inter.1.iter().any(|x| x == s), "{s} in {:?}", inter.1); + } + assert!(fams.iter().any(|(f, _)| f == "Noto Serif")); + assert!(fams.iter().any(|(f, _)| f == "JetBrains Mono")); + let b = resolve("inter", "bold"); + assert_eq!(face(b.face).info.style, "Bold"); + assert!(!b.synth_bold); + // Noto Serif has only Regular: Bold Italic is synthesised + let s = resolve("Noto Serif", "Bold Italic"); + assert_eq!(face(s.face).info.family, "Noto Serif"); + assert!(s.synth_bold && s.synth_italic); + // Inter Bold Italic: nearest is Italic (400) → synth bold + let bi = resolve("Inter", "Bold Italic"); + assert!(face(bi.face).info.italic && bi.synth_bold && !bi.synth_italic); + } + + #[test] + fn missing_family_falls_back_to_inter() { + let r = resolve("No Such Font Family 123", "Regular"); + assert_eq!(face(r.face).info.family, "Inter"); + assert!(r.missing); + } + + /// Native: the system scan reads name tables only and resolves families installed there. + #[test] + fn system_scan_finds_fonts() { + let t = std::time::Instant::now(); + scan_system(); // (another test may have triggered the scan already) + let n = all_faces().iter().filter(|f| f.info.origin == "system").count(); + eprintln!("system fonts: {n} faces in {:?}", t.elapsed()); + assert_eq!(scan_system(), 0, "scans once"); + if cfg!(target_os = "macos") { + assert!(n > 20, "{n}"); + let r = resolve("Helvetica", "Bold"); + assert_eq!(face(r.face).info.family, "Helvetica"); + let l = crate::layout("Helvetica", &crate::TextStyle { family: "Helvetica".into(), size: 30.0, ..Default::default() }, &Default::default()); + assert!(!l.missing_font && l.glyphs.iter().all(|g| g.id != 0)); + } + } + + #[test] + fn metrics_and_coverage() { + let f = face(resolve("Inter", "Regular").face); + let m = f.metrics(100.0); + assert!(m.ascent > 80.0 && m.ascent < 110.0, "{m:?}"); + assert!(m.descent > 10.0 && m.descent < 40.0); + assert!(m.cap_height > 60.0 && m.cap_height < 80.0); + assert!(f.has_char('A') && f.has_char('Ж')); + assert!(!f.has_char('\u{5d0}'), "Inter has no Hebrew"); + assert!(f.has_feature(b"liga") || f.has_feature(b"calt")); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs new file mode 100644 index 000000000..59c4941d8 --- /dev/null +++ b/crates/text/src/layout.rs @@ -0,0 +1,723 @@ +//! Paragraph layout: font fallback, bidi (UAX #9), shaping (OpenType via harfrust: kerning, +//! ligatures, mark positioning, complex scripts), line breaking (UAX #14), alignment, leading, +//! tracking, baseline shift, all caps / small caps, underline. Results are cached. +//! +//! Coordinates are pixels, y down. For **point text** (`ParagraphStyle::width == None`) the +//! origin is the alignment point on the first baseline: x = 0 is the left edge (left / justify), +//! the centre (centre) or the right edge (right). For **area text** (`width == Some(w)`) the +//! origin is the top-left of the text box and lines wrap at `w`. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::ops::Range; +use std::sync::{Arc, Mutex, OnceLock}; + +use harfrust::{Direction, Feature, Tag, UnicodeBuffer}; +use unicode_bidi::{BidiInfo, Level}; + +use crate::fonts::{self, FaceId, Resolved}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Caps { + #[default] + Normal, + All, + Small, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Align { + #[default] + Left, + Center, + Right, + Justify, +} + +/// Character formatting. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyle { + pub family: String, + pub style: String, + /// Font size in pixels. + pub size: f32, + /// Tracking in 1/1000 em (added after every cluster). + pub tracking: f32, + /// Metric kerning (`kern`). + pub kerning: bool, + /// Standard ligatures (`liga`, `clig`). + pub ligatures: bool, + /// Baseline shift in pixels (positive = up). + pub baseline_shift: f32, + pub faux_bold: bool, + pub faux_italic: bool, + pub caps: Caps, + pub underline: bool, +} + +impl Default for TextStyle { + fn default() -> Self { + Self { + family: fonts::DEFAULT_FAMILY.into(), + style: "Regular".into(), + size: 100.0, + tracking: 0.0, + kerning: true, + ligatures: true, + baseline_shift: 0.0, + faux_bold: false, + faux_italic: false, + caps: Caps::Normal, + underline: false, + } + } +} + +/// Paragraph formatting. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ParagraphStyle { + pub align: Align, + /// Extra line spacing in pixels added to the font's natural line height (Premiere's Leading). + pub leading: f32, + /// Wrap width (area text); None = point text. + pub width: Option, + /// Base direction: None = from the first strong character. + pub rtl: Option, +} + +fn hf(h: &mut impl Hasher, v: f32) { + v.to_bits().hash(h); +} + +impl Hash for TextStyle { + fn hash(&self, h: &mut H) { + self.family.hash(h); + self.style.hash(h); + hf(h, self.size); + hf(h, self.tracking); + hf(h, self.baseline_shift); + (self.kerning, self.ligatures, self.faux_bold, self.faux_italic, self.caps, self.underline).hash(h); + } +} + +impl Hash for ParagraphStyle { + fn hash(&self, h: &mut H) { + self.align.hash(h); + hf(h, self.leading); + self.width.map(f32::to_bits).hash(h); + self.rtl.hash(h); + } +} + +/// A positioned glyph. `(x, y)` is the glyph origin on its baseline. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Glyph { + pub face: FaceId, + pub id: u32, + pub x: f32, + pub y: f32, + pub size: f32, + /// Byte offset of the cluster in the source text. + pub cluster: usize, + pub synth_bold: bool, + pub synth_italic: bool, +} + +/// One laid-out line. +#[derive(Clone, Debug, PartialEq)] +pub struct Line { + /// Byte range in the source text (without the terminating newline). + pub range: Range, + pub baseline: f32, + /// Left edge and width of the line's content (trailing spaces excluded). + pub x: f32, + pub width: f32, + pub ascent: f32, + pub descent: f32, + pub glyphs: Range, + /// Caret stops `(byte offset, x)` for every character boundary in the line, by byte offset. + pub carets: Vec<(usize, f32)>, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Layout { + pub glyphs: Vec, + pub lines: Vec, + /// Underline rectangles `[x0, y0, x1, y1]`. + pub underlines: Vec<[f32; 4]>, + /// Logical bounds `[x0, y0, x1, y1]` (line boxes; area text spans the box width). + pub bounds: [f32; 4], + /// The face requested was missing (substituted). + pub missing_font: bool, + pub text_len: usize, +} + +impl Layout { + /// Line index for a caret at byte `pos`. + pub fn line_of(&self, pos: usize) -> usize { + let mut li = 0; + for (i, l) in self.lines.iter().enumerate() { + if l.range.start <= pos { + li = i; + } + } + li + } + /// Caret position `(x, baseline, line)` for byte offset `pos`. + pub fn caret(&self, pos: usize) -> (f32, f32, usize) { + let li = self.line_of(pos); + let Some(l) = self.lines.get(li) else { return (0.0, 0.0, 0) }; + let x = l.carets.iter().min_by_key(|(b, _)| b.abs_diff(pos)).map_or(l.x, |c| c.1); + (x, l.baseline, li) + } + /// Nearest caret byte offset to point `(x, y)`. + pub fn hit(&self, x: f32, y: f32) -> usize { + let Some(l) = self.lines.iter().min_by(|a, b| line_dist(a, y).total_cmp(&line_dist(b, y))) else { + return 0; + }; + l.carets.iter().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0) + } + /// Selection highlight rectangles `[x0, y0, x1, y1]` for the byte range `a..b`. + pub fn selection_rects(&self, a: usize, b: usize) -> Vec<[f32; 4]> { + let (a, b) = (a.min(b), a.max(b)); + let mut out = Vec::new(); + for l in &self.lines { + let s = a.max(l.range.start); + let e = b.min(l.range.end); + if s > e || (s == e && !(a < l.range.start && b > l.range.end)) { + continue; + } + let xs: Vec = l.carets.iter().filter(|(p, _)| *p >= s && *p <= e).map(|c| c.1).collect(); + if xs.len() < 2 && b <= l.range.end { + continue; + } + let x0 = xs.iter().copied().fold(f32::MAX, f32::min); + let mut x1 = xs.iter().copied().fold(f32::MIN, f32::max); + if b > l.range.end { + x1 += l.ascent * 0.25; // selected newline + } + out.push([x0.min(x1), l.baseline - l.ascent, x1, l.baseline + l.descent]); + } + out + } +} + +fn line_dist(l: &Line, y: f32) -> f32 { + let (t, b) = (l.baseline - l.ascent, l.baseline + l.descent); + if y < t { + t - y + } else if y > b { + y - b + } else { + 0.0 + } +} + +struct ShapedGlyph { + face: FaceId, + id: u32, + cluster: usize, + adv: f32, + dx: f32, + dy: f32, + size: f32, +} + +struct Item { + range: Range, + rtl: bool, + glyphs: Vec, +} + +fn upper_single(c: char) -> char { + let mut u = c.to_uppercase(); + match (u.next(), u.next()) { + (Some(x), None) => x, + _ => c, + } +} + +const SMALL_CAPS_SCALE: f32 = 0.78; + +fn shape_item(text: &str, chars: &[(usize, char, char)], rtl: bool, face: FaceId, size: f32, style: &TextStyle) -> Vec { + let f = fonts::face(face); + let (Some(font), Some(data)) = (f.font(), f.shaper_data()) else { return Vec::new() }; + let shaper = data.shaper(&font).build(); + let mut buf = UnicodeBuffer::new(); + for &(b, _, sc) in chars { + buf.add(if sc == '\t' { ' ' } else { sc }, b as u32); + } + buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); + buf.guess_segment_properties(); + let mut feats = Vec::new(); + if !style.kerning { + feats.push(Feature::new(Tag::new(b"kern"), 0, ..)); + } + if !style.ligatures { + feats.push(Feature::new(Tag::new(b"liga"), 0, ..)); + feats.push(Feature::new(Tag::new(b"clig"), 0, ..)); + } + let out = shaper.shape(buf, harfrust::ShapeOptions::new().features(&feats)); + let k = size / f.units_per_em(); + let _ = text; + out.glyph_infos() + .iter() + .zip(out.glyph_positions()) + .map(|(i, p)| ShapedGlyph { + face, + id: i.glyph_id, + cluster: i.cluster as usize, + adv: p.x_advance as f32 * k, + dx: p.x_offset as f32 * k, + dy: p.y_offset as f32 * k, + size, + }) + .collect() +} + +struct ParaLine { + range: Range, + glyphs: Vec, + carets: Vec<(usize, f32)>, + content: f32, + left_trim: f32, + ascent: f32, + descent: f32, +} + +/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string. +fn paragraph(text: &str, base: usize, style: &TextStyle, para: &ParagraphStyle, primary: Resolved) -> Vec { + let pm = fonts::face(primary.face).metrics(style.size); + if text.is_empty() { + return vec![ParaLine { + range: base..base, + glyphs: vec![], + carets: vec![(base, 0.0)], + content: 0.0, + left_trim: 0.0, + ascent: pm.ascent, + descent: pm.descent, + }]; + } + let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() }); + let bidi = BidiInfo::new(text, default_level); + let pinfo = &bidi.paragraphs[0]; + let base_rtl = pinfo.level.is_rtl(); + // per char: (byte, char, shaped char), face, level, small + let chars: Vec<(usize, char, char, FaceId, bool, bool)> = text + .char_indices() + .map(|(b, c)| { + let (sc, small) = match style.caps { + Caps::Normal => (c, false), + Caps::All => (upper_single(c), false), + Caps::Small if c.is_lowercase() => (upper_single(c), true), + Caps::Small => (c, false), + }; + let face = if sc.is_whitespace() { primary.face } else { fonts::fallback_for(sc, primary.face) }; + (b, c, sc, face, bidi.levels[b].is_rtl(), small) + }) + .collect(); + // items: runs of equal (face, rtl, small) + let mut items: Vec = Vec::new(); + let mut i = 0; + while i < chars.len() { + let (face, rtl, small) = (chars[i].3, chars[i].4, chars[i].5); + let mut j = i + 1; + while j < chars.len() && chars[j].3 == face && chars[j].4 == rtl && chars[j].5 == small { + j += 1; + } + let sub: Vec<(usize, char, char)> = chars[i..j].iter().map(|c| (c.0, c.1, c.2)).collect(); + let size = if small { style.size * SMALL_CAPS_SCALE } else { style.size }; + let mut glyphs = shape_item(text, &sub, rtl, face, size, style); + // tracking after each cluster + if style.tracking != 0.0 { + let t = style.tracking * style.size / 1000.0; + for k in 0..glyphs.len() { + if k + 1 == glyphs.len() || glyphs[k + 1].cluster != glyphs[k].cluster { + glyphs[k].adv += t; + } + } + } + let end = if j < chars.len() { chars[j].0 } else { text.len() }; + items.push(Item { range: chars[i].0..end, rtl, glyphs }); + i = j; + } + // advance per char byte (cluster start) + let mut char_adv = vec![0.0f32; text.len() + 1]; + for it in &items { + for g in &it.glyphs { + char_adv[g.cluster.min(text.len())] += g.adv; + } + } + let range_w = |r: Range| -> f32 { char_adv[r].iter().sum() }; + let trailing_ws = |r: &Range| -> (usize, f32) { + let mut end = r.end; + let mut w = 0.0; + for (b, c) in text[r.clone()].char_indices().rev() { + if !c.is_whitespace() { + break; + } + end = r.start + b; + w += char_adv[r.start + b]; + } + (end, w) + }; + // line breaking + let mut ranges: Vec> = Vec::new(); + match para.width { + None => ranges.push(0..text.len()), + Some(maxw) => { + let mut start = 0usize; + let mut last_ok: Option = None; + for (p, _) in unicode_linebreak::linebreaks(text) { + loop { + let r = start..p; + let (ce, _) = trailing_ws(&r); + let w = range_w(start..ce); + if w <= maxw || last_ok.is_none() { + last_ok = Some(p); + break; + } + let b = last_ok.take().expect("checked"); + ranges.push(start..b); + start = b; + } + } + ranges.push(start..text.len()); + ranges.retain(|r| !r.is_empty()); + if ranges.is_empty() { + ranges.push(0..text.len()); + } + } + } + let nlines = ranges.len(); + let mut out = Vec::new(); + for (li, r) in ranges.into_iter().enumerate() { + let (ce, tw) = trailing_ws(&r); + let content = range_w(r.start..ce); + // justification + let mut space_extra = 0.0; + if para.align == Align::Justify + && li + 1 < nlines + && let Some(maxw) = para.width + { + let spaces = text[r.start..ce].chars().filter(|c| *c == ' ').count(); + if spaces > 0 { + space_extra = ((maxw - content) / spaces as f32).max(0.0); + } + } + let (levels, runs) = bidi.visual_runs(pinfo, r.clone()); + let mut pen = 0.0f32; + let mut glyphs = Vec::new(); + let mut extents: HashMap = HashMap::new(); + for run in runs { + let rtl = levels[run.start].is_rtl(); + let mut idx: Vec = (0..items.len()).filter(|&k| items[k].range.start < run.end && items[k].range.end > run.start).collect(); + if rtl { + idx.reverse(); + } + for k in idx { + for g in items[k].glyphs.iter().filter(|g| run.contains(&g.cluster)) { + let x0 = pen; + glyphs.push(Glyph { + face: g.face, + id: g.id, + x: pen + g.dx, + y: -g.dy - style.baseline_shift, + size: g.size, + cluster: base + g.cluster, + synth_bold: primary.synth_bold || style.faux_bold, + synth_italic: primary.synth_italic || style.faux_italic, + }); + pen += g.adv; + if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce { + pen += space_extra; + } + let e = extents.entry(g.cluster).or_insert((x0, pen, items[k].rtl)); + e.0 = e.0.min(x0); + e.1 = e.1.max(pen); + } + } + } + // caret stops for each char boundary in the line + let mut carets = Vec::new(); + let mut cluster_starts: Vec = extents.keys().copied().collect(); + cluster_starts.sort_unstable(); + let char_bytes: Vec = text[r.clone()].char_indices().map(|(b, _)| r.start + b).collect(); + for (ci, &cs) in cluster_starts.iter().enumerate() { + let (x0, x1, rtl) = extents[&cs]; + let next = cluster_starts.get(ci + 1).copied().unwrap_or(r.end); + let members: Vec = char_bytes.iter().copied().filter(|b| *b >= cs && *b < next).collect(); + let n = members.len().max(1) as f32; + for (mi, b) in members.iter().enumerate() { + let f = mi as f32 / n; + let x = if rtl { x1 - (x1 - x0) * f } else { x0 + (x1 - x0) * f }; + carets.push((base + b, x)); + } + } + // end of line caret + let end_x = match chars.iter().rev().find(|c| c.0 < r.end && c.0 >= r.start) { + Some(c) => { + let cs = cluster_starts.iter().rev().find(|s| **s <= c.0).copied(); + match cs.and_then(|s| extents.get(&s)) { + Some(&(x0, x1, rtl)) => { + if rtl { + x0 + } else { + x1 + } + } + None => pen, + } + } + None => 0.0, + }; + carets.push((base + r.end, end_x)); + carets.sort_by_key(|c| c.0); + carets.dedup_by_key(|c| c.0); + let mut asc = pm.ascent; + let mut desc = pm.descent; + for g in &glyphs { + if g.face != primary.face { + let m = fonts::face(g.face).metrics(g.size); + asc = asc.max(m.ascent); + desc = desc.max(m.descent); + } + } + let content_w = if space_extra > 0.0 { para.width.unwrap_or(content) } else { content }; + out.push(ParaLine { + range: base + r.start..base + r.end, + glyphs, + carets, + content: content_w, + left_trim: if base_rtl { tw } else { 0.0 }, + ascent: asc, + descent: desc, + }); + } + out +} + +/// Lay out `text` (paragraphs separated by `\n`). +pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Layout { + let primary = fonts::resolve(&style.family, &style.style); + let pm = fonts::face(primary.face).metrics(style.size); + let line_h = (pm.ascent + pm.descent + pm.line_gap).max(style.size * 0.5) + para.leading; + let mut lay = Layout { missing_font: primary.missing, text_len: text.len(), ..Default::default() }; + let mut base = 0usize; + let mut plines = Vec::new(); + for p in text.split('\n') { + let p_clean = p.strip_suffix('\r').unwrap_or(p); + plines.extend(paragraph(p_clean, base, style, para, primary)); + base += p.len() + 1; + } + let first_baseline = if para.width.is_some() { pm.ascent } else { 0.0 }; + for (i, pl) in plines.into_iter().enumerate() { + let baseline = first_baseline + line_h * i as f32; + let shift = match (para.width, para.align) { + (None, Align::Left | Align::Justify) => 0.0, + (None, Align::Center) => -pl.content / 2.0, + (None, Align::Right) => -pl.content, + (Some(_), Align::Left | Align::Justify) => 0.0, + (Some(w), Align::Center) => (w - pl.content) / 2.0, + (Some(w), Align::Right) => w - pl.content, + } - pl.left_trim; + let g0 = lay.glyphs.len(); + lay.glyphs.extend(pl.glyphs.into_iter().map(|mut g| { + g.x += shift; + g.y += baseline; + g + })); + let x = shift + pl.left_trim; + if style.underline && pl.content > 0.0 { + lay.underlines.push([x, baseline + pm.underline_pos, x + pl.content, baseline + pm.underline_pos + pm.underline_thickness]); + } + lay.lines.push(Line { + range: pl.range, + baseline, + x, + width: pl.content, + ascent: pl.ascent, + descent: pl.descent, + glyphs: g0..lay.glyphs.len(), + carets: pl.carets.into_iter().map(|(b, cx)| (b, cx + shift)).collect(), + }); + } + let (mut x0, mut x1) = (f32::MAX, f32::MIN); + for l in &lay.lines { + x0 = x0.min(l.x); + x1 = x1.max(l.x + l.width); + } + if let Some(w) = para.width { + x0 = 0.0; + x1 = x1.max(w); + } + let first = &lay.lines[0]; + let last = lay.lines.last().expect("at least one line"); + lay.bounds = [x0, first.baseline - first.ascent, x1.max(x0), last.baseline + last.descent]; + lay +} + +fn cache() -> &'static Mutex)>> { + static C: OnceLock)>>> = OnceLock::new(); + C.get_or_init(Default::default) +} + +/// Lay out `text` (cached by text and styles). +pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc { + let mut h = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut h); + style.hash(&mut h); + para.hash(&mut h); + let key = h.finish(); + static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let now = CLOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if let Some(e) = cache().lock().unwrap_or_else(|e| e.into_inner()).get_mut(&key) { + e.0 = now; + return e.1.clone(); + } + let l = Arc::new(layout_uncached(text, style, para)); + let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); + if c.len() >= 512 { + // evict the oldest quarter + let mut ages: Vec = c.values().map(|v| v.0).collect(); + ages.sort_unstable(); + let cut = ages[ages.len() / 4]; + c.retain(|_, v| v.0 > cut); + } + c.insert(key, (now, l.clone())); + l +} + +/// Width of a single line of `text` (no wrapping). +pub fn measure(text: &str, style: &TextStyle) -> f32 { + let l = layout(text, style, &ParagraphStyle::default()); + l.lines.iter().map(|l| l.width).fold(0.0, f32::max) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn st(size: f32) -> TextStyle { + TextStyle { size, ..Default::default() } + } + + #[test] + fn kerning_and_ligatures_change_advances() { + let kern = measure("AVAVAV", &st(100.0)); + let nokern = measure("AVAVAV", &TextStyle { kerning: false, ..st(100.0) }); + assert!(kern < nokern - 5.0, "kerning tightens AV: {kern} vs {nokern}"); + // tracking adds 1/1000 em per cluster + let tracked = measure("AVAVAV", &TextStyle { tracking: 100.0, ..st(100.0) }); + assert!((tracked - kern - 6.0 * 10.0).abs() < 0.5, "{tracked} {kern}"); + } + + #[test] + fn point_text_alignment() { + let l = layout("Hello", &st(50.0), &ParagraphStyle { align: Align::Center, ..Default::default() }); + let line = &l.lines[0]; + assert!((line.x + line.width / 2.0).abs() < 0.01); + let r = layout("Hello", &st(50.0), &ParagraphStyle { align: Align::Right, ..Default::default() }); + assert!((r.lines[0].x + r.lines[0].width).abs() < 0.01); + assert_eq!(l.glyphs.len(), 5); + assert!(l.bounds[1] < -30.0 && l.bounds[3] > 5.0, "{:?}", l.bounds); + } + + #[test] + fn wraps_area_text_and_justifies() { + let p = ParagraphStyle { width: Some(300.0), align: Align::Justify, ..Default::default() }; + let l = layout("the quick brown fox jumps over the lazy dog again and again", &st(40.0), &p); + assert!(l.lines.len() >= 3, "{}", l.lines.len()); + for line in &l.lines[..l.lines.len() - 1] { + assert!((line.width - 300.0).abs() < 0.5, "justified to the box: {}", line.width); + } + // the lines tile the text + assert_eq!(l.lines[0].range.start, 0); + for w in l.lines.windows(2) { + assert_eq!(w[0].range.end, w[1].range.start); + assert!(w[1].baseline > w[0].baseline); + } + let left = layout("the quick brown fox jumps over the lazy dog", &st(40.0), &ParagraphStyle { width: Some(300.0), ..Default::default() }); + assert!(left.lines.iter().all(|l| l.width <= 300.0 + 0.01)); + } + + #[test] + fn newlines_make_paragraphs_and_leading_adds() { + let l = layout("one\ntwo\n\nfour", &st(30.0), &ParagraphStyle::default()); + assert_eq!(l.lines.len(), 4); + assert_eq!(l.lines[1].range, 4..7); + assert_eq!(l.lines[2].range, 8..8); + let gap = l.lines[1].baseline - l.lines[0].baseline; + let l2 = layout("one\ntwo", &st(30.0), &ParagraphStyle { leading: 10.0, ..Default::default() }); + assert!((l2.lines[1].baseline - l2.lines[0].baseline - gap - 10.0).abs() < 1e-3); + } + + #[test] + fn carets_and_hit_testing() { + let l = layout("abc\nde", &st(40.0), &ParagraphStyle::default()); + let (x0, _, li0) = l.caret(0); + let (x1, _, _) = l.caret(1); + let (x3, _, _) = l.caret(3); + assert!(x0.abs() < 1e-3 && x1 > x0 && x3 > x1); + assert_eq!(li0, 0); + let (_, b4, li4) = l.caret(4); + assert_eq!(li4, 1); + assert!(b4 > 0.0); + assert_eq!(l.hit(x1 + 1.0, 0.0), 1); + assert_eq!(l.hit(1000.0, b4), 6); + let rects = l.selection_rects(1, 5); + assert_eq!(rects.len(), 2); + } + + #[test] + fn bidi_reorders_rtl_runs() { + // Hebrew letters are laid out right-to-left: the first logical letter is rightmost. + let l = layout("ab \u{5d0}\u{5d1}\u{5d2} cd", &st(40.0), &ParagraphStyle::default()); + let x_of = |byte: usize| l.glyphs.iter().find(|g| g.cluster == byte).map(|g| g.x).unwrap(); + let alef = "ab ".len(); + let gimel = alef + 4; + assert!(x_of(alef) > x_of(gimel), "alef right of gimel"); + assert!(x_of(0) < x_of(gimel) && x_of(alef) < x_of("ab \u{5d0}\u{5d1}\u{5d2} ".len())); + } + + #[test] + fn caps_and_small_caps() { + let s = st(50.0); + let up = measure("HELLO", &s); + let all = measure("hello", &TextStyle { caps: Caps::All, ..s.clone() }); + assert!((up - all).abs() < 0.01); + let small = measure("hello", &TextStyle { caps: Caps::Small, ..s.clone() }); + assert!(small < up * 0.9 && small > up * 0.6, "{small} {up}"); + let l = layout("Hi", &TextStyle { baseline_shift: 10.0, underline: true, ..s }, &ParagraphStyle::default()); + assert!((l.glyphs[0].y + 10.0).abs() < 1e-3); + assert_eq!(l.underlines.len(), 1); + } + + #[test] + fn ligature_caret_interpolates() { + // Inter has an "fi"-like ligature only via calt in some versions; use "ffi" which most + // fonts ligate. Whatever shaping does, every char boundary has a caret. + let l = layout("office", &st(40.0), &ParagraphStyle::default()); + let stops: Vec = l.lines[0].carets.iter().map(|c| c.0).collect(); + assert_eq!(stops, vec![0, 1, 2, 3, 4, 5, 6]); + let xs: Vec = l.lines[0].carets.iter().map(|c| c.1).collect(); + assert!(xs.windows(2).all(|w| w[1] >= w[0]), "{xs:?}"); + } + + #[test] + fn fallback_font_for_missing_glyphs() { + // Inter lacks Devanagari/Hebrew; Noto Serif lacks Hebrew too → glyphs still produced (notdef) + let l = layout("A\u{3b1}", &TextStyle { family: "Noto Serif".into(), ..st(40.0) }, &ParagraphStyle::default()); + assert_eq!(l.glyphs.len(), 2); + assert!(l.glyphs.iter().all(|g| g.id != 0)); + } + + #[test] + fn empty_text_has_a_caret() { + let l = layout("", &st(40.0), &ParagraphStyle::default()); + assert_eq!(l.lines.len(), 1); + assert_eq!(l.caret(0).0, 0.0); + assert!(l.bounds[3] > l.bounds[1]); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs new file mode 100644 index 000000000..7fffd1084 --- /dev/null +++ b/crates/text/src/lib.rs @@ -0,0 +1,52 @@ +//! FilmCraft text engine (layer L1). +//! +//! - [`fonts`]: font database — bundled OFL fonts (Inter, JetBrains Mono, Noto Serif) plus faces +//! discovered by [`fonts::FontSource`]s (system font folders on native platforms; nothing on the +//! web unless the host registers font data), family/style resolution with synthetic bold/italic +//! and per-character fallback. +//! - [`layout`]: shaping (harfrust: kerning, ligatures, complex scripts), bidi, line breaking, +//! paragraph layout, carets and hit testing; cached. +//! - [`raster`]: our own anti-aliased path rasteriser (linear coverage, so compositing in linear +//! light is correct) and vector shapes. +//! - [`render`]: glyph outlines and the glyph-mask cache (sub-pixel positioned), drawing layouts +//! under any affine transform. +//! - [`mask`]: strokes (outer/centre/inner via a Euclidean distance transform), blur, offsets. + +pub mod fonts; +pub mod layout; +pub mod mask; +pub mod raster; +pub mod render; +pub mod sfnt; + +pub use fonts::{FaceId, Resolved, families, resolve}; +pub use layout::{Align, Caps, Glyph, Layout, Line, ParagraphStyle, TextStyle, layout, measure}; +pub use mask::StrokeKind; +pub use raster::{Mask, Path, Xform}; + +#[cfg(test)] +mod perf { + use super::*; + + /// A three-line 1080p title: layout + rasterisation must stay far below a frame (≈ 41 ms at + /// 24 fps). Cold = first time (shaping + glyph rasterisation); warm = cached. + #[test] + fn three_line_title_is_fast() { + let text = "FILMCRAFT PRESENTS\nA Night Drive Through the City\nDirected by Nobody in Particular"; + let st = TextStyle { size: 96.0, style: "Bold".into(), ..Default::default() }; + let ps = ParagraphStyle { align: Align::Center, ..Default::default() }; + let t0 = std::time::Instant::now(); + let l = layout::layout_uncached(text, &st, &ps); + let (m, _, _) = render::rasterize(&l, &render::at(960.0, 400.0)); + let cold = t0.elapsed(); + let _ = layout(text, &st, &ps); + let t1 = std::time::Instant::now(); + let l2 = layout(text, &st, &ps); + let (m2, _, _) = render::rasterize(&l2, &render::at(960.0, 400.0)); + let warm = t1.elapsed(); + assert_eq!(m.a.len(), m2.a.len()); + eprintln!("3-line 1080p title: cold {cold:?}, warm {warm:?} (mask {}x{})", m.w, m.h); + // generous bound for debug builds on busy CI machines + assert!(warm.as_millis() < 40, "warm {warm:?}"); + } +} diff --git a/crates/text/src/mask.rs b/crates/text/src/mask.rs new file mode 100644 index 000000000..33af4e494 --- /dev/null +++ b/crates/text/src/mask.rs @@ -0,0 +1,213 @@ +//! Coverage-mask operations used for text and shape appearance: strokes (outer / centre / inner) +//! from an exact Euclidean distance transform, Gaussian-like blur for shadows, offsets. + +use crate::raster::Mask; + +/// Where a stroke sits relative to the shape's edge (Premiere's stroke types). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum StrokeKind { + #[default] + Outer, + Center, + Inner, +} + +const INF: f32 = 1e20; + +/// 1-D squared distance transform (Felzenszwalb & Huttenlocher) of `f` into `d`. +fn edt_1d(f: &[f32], d: &mut [f32], v: &mut [usize], z: &mut [f32]) { + let n = f.len(); + if n == 0 { + return; + } + let mut k = 0usize; + v[0] = 0; + z[0] = -INF; + z[1] = INF; + for q in 1..n { + let fq = f[q] + (q * q) as f32; + let mut s; + loop { + let p = v[k]; + s = (fq - (f[p] + (p * p) as f32)) / (2.0 * (q as f32 - p as f32)); + if s <= z[k] && k > 0 { + k -= 1; + } else { + break; + } + } + if s <= z[k] { + // k == 0: q dominates everything so far + v[0] = q; + z[0] = -INF; + z[1] = INF; + continue; + } + k += 1; + v[k] = q; + z[k] = s; + z[k + 1] = INF; + } + k = 0; + for q in 0..n { + while z[k + 1] < q as f32 { + k += 1; + } + let p = v[k]; + let dq = q as f32 - p as f32; + d[q] = (dq * dq + f[p]).min(INF); + } +} + +/// Euclidean distance (in pixels, between pixel centres) from every pixel to the nearest pixel +/// where `seed` is true. +fn edt(w: usize, h: usize, seed: impl Fn(usize) -> bool) -> Vec { + let mut g = vec![INF; w * h]; + for (i, v) in g.iter_mut().enumerate() { + if seed(i) { + *v = 0.0; + } + } + let n = w.max(h); + let mut f = vec![0.0f32; n]; + let mut d = vec![0.0f32; n]; + let mut v = vec![0usize; n]; + let mut z = vec![0.0f32; n + 1]; + for x in 0..w { + for y in 0..h { + f[y] = g[y * w + x]; + } + edt_1d(&f[..h], &mut d[..h], &mut v[..h], &mut z[..h + 1]); + for y in 0..h { + g[y * w + x] = d[y]; + } + } + for y in 0..h { + f[..w].copy_from_slice(&g[y * w..y * w + w]); + edt_1d(&f[..w], &mut d[..w], &mut v[..w], &mut z[..w + 1]); + for x in 0..w { + g[y * w + x] = d[x].sqrt(); + } + } + g +} + +/// Approximate signed distance to the shape edge: negative inside, positive outside, with +/// sub-pixel refinement from the coverage values. +pub fn signed_distance(m: &Mask) -> Vec { + let (w, h) = (m.w, m.h); + let outside = edt(w, h, |i| m.a[i] >= 0.5); + let inside = edt(w, h, |i| m.a[i] < 0.5); + (0..w * h) + .map(|i| { + let c = m.a[i]; + if c > 0.0 && c < 1.0 { + 0.5 - c + } else if c >= 0.5 { + -(inside[i] - 0.5).max(0.0) + } else { + (outside[i] - 0.5).max(0.0) + } + }) + .collect() +} + +/// Stroke coverage of width `width` pixels around the shape in `m`. +pub fn stroke(m: &Mask, width: f32, kind: StrokeKind) -> Mask { + let sd = signed_distance(m); + let a = sd + .iter() + .zip(&m.a) + .map(|(&d, &c)| { + let v = match kind { + StrokeKind::Outer => (width + 0.5 - d).clamp(0.0, 1.0).max(c), + StrokeKind::Center => (width / 2.0 + 0.5 - d.abs()).clamp(0.0, 1.0), + StrokeKind::Inner => (width + 0.5 + d).clamp(0.0, 1.0).min(c), + }; + if kind == StrokeKind::Outer { v } else { v.min(1.0) } + }) + .collect(); + Mask { w: m.w, h: m.h, a } +} + +fn box_pass(src: &[f32], dst: &mut [f32], n: usize, stride: usize, count: usize, lanes: usize, lane_stride: usize, r: usize) { + let norm = 1.0 / (2 * r + 1) as f32; + for l in 0..lanes { + let base = l * lane_stride; + let at = |i: isize| -> f32 { if i < 0 || i as usize >= count { 0.0 } else { src[base + i as usize * stride] } }; + let mut acc = 0.0f32; + for i in -(r as isize)..=(r as isize) { + acc += at(i); + } + for i in 0..count { + dst[base + i * stride] = acc * norm; + acc += at(i as isize + r as isize + 1) - at(i as isize - r as isize); + } + } + let _ = n; +} + +/// Blur a mask with three box passes per axis (≈ Gaussian of standard deviation `sigma`). +pub fn blur(m: &Mask, sigma: f32) -> Mask { + if sigma < 0.3 || m.w == 0 || m.h == 0 { + return m.clone(); + } + // box radius for 3 passes ≈ sigma * sqrt(3) + let r = ((sigma * sigma + 1.0).sqrt().round() as usize).max(1); + let mut a = m.a.clone(); + let mut b = vec![0.0f32; a.len()]; + for _ in 0..3 { + box_pass(&a, &mut b, 0, 1, m.w, m.h, m.w, r); + std::mem::swap(&mut a, &mut b); + box_pass(&a, &mut b, 0, m.w, m.h, m.w, 1, r); + std::mem::swap(&mut a, &mut b); + } + Mask { w: m.w, h: m.h, a } +} + +/// Shift a mask by whole pixels (content moving out is dropped). +pub fn offset(m: &Mask, dx: i32, dy: i32) -> Mask { + let mut o = Mask::new(m.w, m.h); + o.add(m, dx, dy); + o +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::raster::{Path, fill}; + + #[test] + fn distance_transform_is_euclidean() { + let d = edt(9, 9, |i| i == 4 * 9 + 4); + assert_eq!(d[4 * 9 + 4], 0.0); + assert!((d[4 * 9 + 7] - 3.0).abs() < 1e-5); + assert!((d[0] - (32.0f32).sqrt()).abs() < 1e-4); + let none = edt(3, 3, |_| false); + assert!(none.iter().all(|&v| v > 1e5)); + } + + #[test] + fn strokes_sit_outside_centre_and_inside() { + let m = fill(&Path::rect(10.0, 10.0, 30.0, 30.0), 40, 40, 0.0, 0.0); + let outer = stroke(&m, 4.0, StrokeKind::Outer); + assert!(outer.get(7, 20) > 0.99, "outer covers 3px outside"); + assert!(outer.get(4, 20) < 0.01); + assert!(outer.get(20, 20) > 0.99, "outer includes the fill area"); + let inner = stroke(&m, 4.0, StrokeKind::Inner); + assert!(inner.get(12, 20) > 0.99 && inner.get(7, 20) < 0.01 && inner.get(20, 20) < 0.01); + let centre = stroke(&m, 4.0, StrokeKind::Center); + assert!(centre.get(9, 20) > 0.99 && centre.get(11, 20) > 0.99 && centre.get(20, 20) < 0.01 && centre.get(5, 20) < 0.01); + } + + #[test] + fn blur_keeps_mass() { + let m = fill(&Path::rect(20.0, 20.0, 30.0, 30.0), 50, 50, 0.0, 0.0); + let b = blur(&m, 3.0); + let (s0, s1): (f32, f32) = (m.a.iter().sum(), b.a.iter().sum()); + assert!((s0 - s1).abs() < 0.5, "{s0} {s1}"); + assert!(b.get(18, 25) > 0.05 && b.get(25, 25) < 1.0); + let o = offset(&m, 5, -5); + assert!(o.get(33, 18) > 0.9); + } +} diff --git a/crates/text/src/raster.rs b/crates/text/src/raster.rs new file mode 100644 index 000000000..33bf2f4dc --- /dev/null +++ b/crates/text/src/raster.rs @@ -0,0 +1,484 @@ +//! Anti-aliased path rasteriser for glyph outlines and vector shapes. +//! +//! Paths are flattened to closed polylines (contours). [`fill`] samples each pixel row at [`SUB`] +//! sub-scanlines with an active-edge list and accumulates the non-zero winding spans of each +//! sub-scanline with exact horizontal coverage at the span ends. Coverage is linear (fractional +//! area), so compositing it in linear light gives correct anti-aliasing. Original implementation +//! for FilmCraft (it started as the caption rasteriser). + +/// Vertical sub-samples per pixel row. +pub const SUB: usize = 5; + +/// A 2×3 affine transform `[a, b, c, d, e, f]`: (x, y) → (a x + c y + e, b x + d y + f). +pub type Xform = [f32; 6]; + +pub const IDENTITY: Xform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + +#[inline] +pub fn apply(m: &Xform, x: f32, y: f32) -> (f32, f32) { + (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]) +} + +/// `a ∘ b`: apply `b` first, then `a`. +pub fn compose(a: &Xform, b: &Xform) -> Xform { + [ + a[0] * b[0] + a[2] * b[1], + a[1] * b[0] + a[3] * b[1], + a[0] * b[2] + a[2] * b[3], + a[1] * b[2] + a[3] * b[3], + a[0] * b[4] + a[2] * b[5] + a[4], + a[1] * b[4] + a[3] * b[5] + a[5], + ] +} + +/// Average linear scale of a transform (for flattening tolerances and stroke widths). +pub fn xform_scale(m: &Xform) -> f32 { + (m[0] * m[3] - m[1] * m[2]).abs().sqrt() +} + +/// A flattened path: closed contours of points in pixel space, y down. +#[derive(Clone, Debug, Default)] +pub struct Path { + pub contours: Vec>, + cur: (f32, f32), + open: bool, + /// Flattening tolerance multiplier (curves are split more finely for larger values). + pub detail: f32, +} + +impl Path { + pub fn new() -> Self { + Self { detail: 1.0, ..Default::default() } + } + fn tol(&self) -> f32 { + if self.detail > 0.0 { self.detail } else { 1.0 } + } + pub fn move_to(&mut self, x: f32, y: f32) { + self.close(); + self.contours.push(vec![(x, y)]); + self.cur = (x, y); + self.open = true; + } + pub fn line_to(&mut self, x: f32, y: f32) { + if !self.open { + self.move_to(self.cur.0, self.cur.1); + } + if (x, y) != self.cur { + self.contours.last_mut().expect("open contour").push((x, y)); + } + self.cur = (x, y); + } + pub fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { + let (x0, y0) = self.cur; + let dd = ((x0 - 2.0 * cx + x).abs() + (y0 - 2.0 * cy + y).abs()).max(0.01) * self.tol(); + let n = ((dd * 2.0).sqrt().ceil() as usize).clamp(1, 64); + for i in 1..=n { + let t = i as f32 / n as f32; + let u = 1.0 - t; + self.line_to(u * u * x0 + 2.0 * u * t * cx + t * t * x, u * u * y0 + 2.0 * u * t * cy + t * t * y); + } + } + pub fn cubic_to(&mut self, c1x: f32, c1y: f32, c2x: f32, c2y: f32, x: f32, y: f32) { + let (x0, y0) = self.cur; + let dd = + ((x0 - 2.0 * c1x + c2x).abs() + (y0 - 2.0 * c1y + c2y).abs() + (c1x - 2.0 * c2x + x).abs() + (c1y - 2.0 * c2y + y).abs()).max(0.01) * self.tol(); + let n = ((dd * 3.0).sqrt().ceil() as usize).clamp(1, 96); + for i in 1..=n { + let t = i as f32 / n as f32; + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + self.line_to(a * x0 + b * c1x + c * c2x + d * x, a * y0 + b * c1y + c * c2y + d * y); + } + } + /// Close the current contour (contours are always filled as closed polygons). + pub fn close(&mut self) { + if self.open { + if let Some(c) = self.contours.last() + && let Some(&p) = c.first() + { + self.cur = p; + } + if self.contours.last().is_some_and(|c| c.len() < 2) { + self.contours.pop(); + } + } + self.open = false; + } + /// Append all contours of `o`. + pub fn extend(&mut self, o: &Path) { + self.close(); + self.contours.extend(o.contours.iter().cloned()); + } + pub fn is_empty(&self) -> bool { + self.contours.is_empty() + } + pub fn transform(&mut self, m: &Xform) { + for c in &mut self.contours { + for p in c.iter_mut() { + *p = apply(m, p.0, p.1); + } + } + } + pub fn transformed(&self, m: &Xform) -> Path { + let mut p = self.clone(); + p.transform(m); + p + } + /// Bounding box `(x0, y0, x1, y1)` of all points. + pub fn bounds(&self) -> Option<(f32, f32, f32, f32)> { + let mut it = self.contours.iter().flatten(); + let f = it.next()?; + let mut b = (f.0, f.1, f.0, f.1); + for p in it { + b = (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)); + } + Some(b) + } + /// Signed area (shoelace, y down: positive = clockwise on screen) of all contours. + pub fn signed_area(&self) -> f32 { + self.contours.iter().map(|c| contour_area(c)).sum() + } + /// Grow (d > 0) or shrink every contour by `d` pixels along its normals (faux bold). Holes + /// shrink because they wind the other way. + pub fn embolden(&mut self, d: f32) { + if d == 0.0 { + return; + } + let sign = if self.signed_area() >= 0.0 { 1.0 } else { -1.0 }; + for c in &mut self.contours { + let n = c.len(); + if n < 3 { + continue; + } + let src = c.clone(); + for i in 0..n { + let p0 = src[(i + n - 1) % n]; + let p1 = src[i]; + let p2 = src[(i + 1) % n]; + let n1 = normal(p0, p1, sign); + let n2 = normal(p1, p2, sign); + let (mx, my) = (n1.0 + n2.0, n1.1 + n2.1); + let ml = (mx * mx + my * my).sqrt(); + if ml < 1e-6 { + c[i] = (p1.0 + n1.0 * d, p1.1 + n1.1 * d); + continue; + } + let (mx, my) = (mx / ml, my / ml); + let cos = (mx * n1.0 + my * n1.1).max(0.35); + c[i] = (p1.0 + mx * d / cos, p1.1 + my * d / cos); + } + } + } + + // ---- shapes ---- + + pub fn rect(x0: f32, y0: f32, x1: f32, y1: f32) -> Path { + let mut p = Path::new(); + p.move_to(x0, y0); + p.line_to(x1, y0); + p.line_to(x1, y1); + p.line_to(x0, y1); + p.close(); + p + } + /// Rectangle with rounded corners of radius `r` (clamped to half the shorter side). + pub fn round_rect(x0: f32, y0: f32, x1: f32, y1: f32, r: f32) -> Path { + let r = r.min((x1 - x0).abs() / 2.0).min((y1 - y0).abs() / 2.0).max(0.0); + if r < 0.01 { + return Path::rect(x0, y0, x1, y1); + } + let k = 0.552_284_8 * r; + let mut p = Path::new(); + p.move_to(x0 + r, y0); + p.line_to(x1 - r, y0); + p.cubic_to(x1 - r + k, y0, x1, y0 + r - k, x1, y0 + r); + p.line_to(x1, y1 - r); + p.cubic_to(x1, y1 - r + k, x1 - r + k, y1, x1 - r, y1); + p.line_to(x0 + r, y1); + p.cubic_to(x0 + r - k, y1, x0, y1 - r + k, x0, y1 - r); + p.line_to(x0, y0 + r); + p.cubic_to(x0, y0 + r - k, x0 + r - k, y0, x0 + r, y0); + p.close(); + p + } + /// Ellipse centred at (cx, cy) with radii (rx, ry), wound clockwise on screen. + pub fn ellipse(cx: f32, cy: f32, rx: f32, ry: f32) -> Path { + let (kx, ky) = (0.552_284_8 * rx, 0.552_284_8 * ry); + let mut p = Path::new(); + p.move_to(cx + rx, cy); + p.cubic_to(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry); + p.cubic_to(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy); + p.cubic_to(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry); + p.cubic_to(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy); + p.close(); + p + } + /// Closed polygon through `pts`. + pub fn polygon(pts: &[(f32, f32)]) -> Path { + let mut p = Path::new(); + if let Some(&(x, y)) = pts.first() { + p.move_to(x, y); + for &(x, y) in &pts[1..] { + p.line_to(x, y); + } + p.close(); + } + p + } +} + +fn contour_area(c: &[(f32, f32)]) -> f32 { + let n = c.len(); + let mut a = 0.0; + for i in 0..n { + let (x0, y0) = c[i]; + let (x1, y1) = c[(i + 1) % n]; + a += x0 * y1 - x1 * y0; + } + a / 2.0 +} + +/// Outward unit normal of edge p0→p1 for an outline of orientation `sign`. +fn normal(p0: (f32, f32), p1: (f32, f32), sign: f32) -> (f32, f32) { + let (dx, dy) = (p1.0 - p0.0, p1.1 - p0.1); + let l = (dx * dx + dy * dy).sqrt(); + if l < 1e-9 { + return (0.0, 0.0); + } + (sign * dy / l, -sign * dx / l) +} + +/// Coverage mask (0..1), row-major. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Mask { + pub w: usize, + pub h: usize, + pub a: Vec, +} + +impl Mask { + pub fn new(w: usize, h: usize) -> Self { + Self { w, h, a: vec![0.0; w * h] } + } + pub fn get(&self, x: usize, y: usize) -> f32 { + self.a[y * self.w + x] + } + /// Add `src` placed at (`ox`, `oy`) (saturating at 1). + pub fn add(&mut self, src: &Mask, ox: i32, oy: i32) { + for y in 0..src.h { + let ty = oy + y as i32; + if ty < 0 || ty >= self.h as i32 { + continue; + } + let x0 = (-ox).max(0) as usize; + let x1 = src.w.min((self.w as i32 - ox).max(0) as usize); + if x0 >= x1 { + continue; + } + let srow = &src.a[y * src.w + x0..y * src.w + x1]; + let d0 = ty as usize * self.w + (ox + x0 as i32) as usize; + for (d, s) in self.a[d0..d0 + (x1 - x0)].iter_mut().zip(srow) { + *d = (*d + s).min(1.0); + } + } + } + /// Per-pixel maximum with `o` (same size). + pub fn max_with(&mut self, o: &Mask) { + for (a, b) in self.a.iter_mut().zip(&o.a) { + *a = a.max(*b); + } + } +} + +/// Fill `path` (non-zero winding) into a `w`×`h` mask, offsetting the path by `(dx, dy)`. +pub fn fill(path: &Path, w: usize, h: usize, dx: f32, dy: f32) -> Mask { + let mut m = Mask::new(w, h); + fill_into(path, &mut m, dx, dy); + m +} + +struct Edge { + ytop: f32, + ybot: f32, + x: f32, + slope: f32, + dir: i32, +} + +/// Fill `path` into `m`, adding coverage (saturating at 1) — the union of what was there and the +/// path for disjoint content. +pub fn fill_into(path: &Path, m: &mut Mask, dx: f32, dy: f32) { + let (w, h) = (m.w, m.h); + if w == 0 || h == 0 { + return; + } + let mut edges: Vec = Vec::new(); + for c in &path.contours { + let n = c.len(); + if n < 2 { + continue; + } + for i in 0..n { + let (x0, y0) = c[i]; + let (x1, y1) = c[(i + 1) % n]; + let (x0, y0, x1, y1) = (x0 + dx, y0 + dy, x1 + dx, y1 + dy); + if y0 == y1 || !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) { + continue; + } + let (dir, xa, ya, xb, yb) = if y0 < y1 { (1, x0, y0, x1, y1) } else { (-1, x1, y1, x0, y0) }; + if yb <= 0.0 || ya >= h as f32 { + continue; + } + edges.push(Edge { ytop: ya, ybot: yb, x: xa, slope: (xb - xa) / (yb - ya), dir }); + } + } + if edges.is_empty() { + return; + } + edges.sort_by(|a, b| a.ytop.total_cmp(&b.ytop)); + let ymin = edges[0].ytop.floor().max(0.0) as usize; + let ymax = (edges.iter().map(|e| e.ybot).fold(f32::MIN, f32::max).ceil().max(0.0) as usize).min(h); + let mut next = 0usize; + let mut active: Vec = Vec::new(); + let mut xs: Vec<(f32, i32)> = Vec::new(); + let mut row = vec![0.0f32; w + 1]; + let weight = 1.0 / SUB as f32; + for y in ymin..ymax { + let mut lo = usize::MAX; + let mut hi = 0usize; + for k in 0..SUB { + let sy = y as f32 + (k as f32 + 0.5) / SUB as f32; + while next < edges.len() && edges[next].ytop <= sy { + active.push(next); + next += 1; + } + active.retain(|&i| edges[i].ybot > sy); + xs.clear(); + for &i in &active { + let e = &edges[i]; + if e.ytop <= sy { + xs.push((e.x + (sy - e.ytop) * e.slope, e.dir)); + } + } + if xs.len() < 2 { + continue; + } + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut wind = 0; + let mut span_start = 0.0f32; + for &(x, d) in &xs { + let before = wind; + wind += d; + if before == 0 && wind != 0 { + span_start = x; + } else if before != 0 + && wind == 0 + && let Some((a, b)) = add_span(&mut row, span_start, x, weight, w) + { + lo = lo.min(a); + hi = hi.max(b); + } + } + } + if lo <= hi && lo < w { + let hi = hi.min(w - 1); + let out = &mut m.a[y * w..(y + 1) * w]; + for x in lo..=hi { + out[x] = (out[x] + row[x]).min(1.0); + row[x] = 0.0; + } + row[w] = 0.0; + } + } +} + +/// Add coverage `weight` over `[x0, x1)` with fractional ends; returns the touched pixel range. +fn add_span(row: &mut [f32], x0: f32, x1: f32, weight: f32, w: usize) -> Option<(usize, usize)> { + let x0 = x0.clamp(0.0, w as f32); + let x1 = x1.clamp(0.0, w as f32); + if x1 <= x0 { + return None; + } + let i0 = x0.floor() as usize; + let i1 = x1.floor() as usize; + if i0 == i1 { + row[i0] += (x1 - x0) * weight; + return Some((i0, i0)); + } + row[i0] += (i0 as f32 + 1.0 - x0) * weight; + for v in &mut row[i0 + 1..i1] { + *v += weight; + } + if i1 < w { + row[i1] += (x1 - i1 as f32) * weight; + } + Some((i0, i1.min(w - 1))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rectangle_coverage() { + let m = fill(&Path::rect(1.5, 2.0, 4.0, 5.0), 6, 6, 0.0, 0.0); + assert!((m.get(1, 3) - 0.5).abs() < 1e-5); + assert!((m.get(2, 3) - 1.0).abs() < 1e-5); + assert_eq!(m.get(4, 3), 0.0); + assert_eq!(m.get(2, 1), 0.0); + assert_eq!(m.get(2, 5), 0.0); + let total: f32 = m.a.iter().sum(); + assert!((total - 2.5 * 3.0).abs() < 1e-3, "{total}"); + } + + #[test] + fn hole_with_opposite_winding() { + let mut p = Path::rect(0.0, 0.0, 10.0, 10.0); + p.move_to(3.0, 3.0); + p.line_to(3.0, 7.0); + p.line_to(7.0, 7.0); + p.line_to(7.0, 3.0); + p.close(); + let m = fill(&p, 10, 10, 0.0, 0.0); + assert_eq!(m.get(5, 5), 0.0); + assert!((m.get(1, 5) - 1.0).abs() < 1e-5); + } + + #[test] + fn circle_area() { + let (cx, cy, r) = (10.0f32, 10.0f32, 6.0f32); + let m = fill(&Path::ellipse(cx, cy, r, r), 20, 20, 0.0, 0.0); + let total: f32 = m.a.iter().sum(); + let want = std::f32::consts::PI * r * r; + assert!((total - want).abs() / want < 0.02, "{total} vs {want}"); + } + + #[test] + fn embolden_grows_outline_and_shrinks_holes() { + let mut p = Path::rect(4.0, 4.0, 14.0, 14.0); + p.embolden(1.0); + let b = p.bounds().unwrap(); + assert!((b.0 - 3.0).abs() < 1e-4 && (b.2 - 15.0).abs() < 1e-4, "{b:?}"); + // reversed winding: still grows (orientation is taken from the whole outline) + let mut q = Path::polygon(&[(4.0, 4.0), (4.0, 14.0), (14.0, 14.0), (14.0, 4.0)]); + q.embolden(1.0); + assert!((q.bounds().unwrap().0 - 3.0).abs() < 1e-4); + } + + #[test] + fn transforms_compose() { + let t = compose(&[2.0, 0.0, 0.0, 2.0, 0.0, 0.0], &[1.0, 0.0, 0.0, 1.0, 3.0, 4.0]); + assert_eq!(apply(&t, 1.0, 1.0), (8.0, 10.0)); + let p = Path::rect(0.0, 0.0, 1.0, 1.0).transformed(&t); + assert_eq!(p.bounds(), Some((6.0, 8.0, 8.0, 10.0))); + assert!((xform_scale(&t) - 2.0).abs() < 1e-6); + } + + #[test] + fn round_rect_area() { + let m = fill(&Path::round_rect(0.0, 0.0, 20.0, 10.0, 3.0), 20, 10, 0.0, 0.0); + let total: f32 = m.a.iter().sum(); + let want = 200.0 - (4.0 - std::f32::consts::PI) * 9.0; + assert!((total - want).abs() < 1.0, "{total} {want}"); + } +} diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs new file mode 100644 index 000000000..07d889305 --- /dev/null +++ b/crates/text/src/render.rs @@ -0,0 +1,302 @@ +//! Drawing laid-out text into coverage masks. +//! +//! Two paths: +//! - **Glyph cache** (axis-aligned uniform scale, the common case for titles): each glyph is +//! rasterised once per (face, glyph, size, ¼-pixel horizontal phase, synthetic style) and the +//! cached masks are added at their pixel positions (sub-pixel positioning in x, whole pixels +//! in y). +//! - **Outlines** (rotation, skew, non-uniform scale): every glyph outline is transformed into +//! device space and the whole run is filled once. +//! +//! Synthetic bold grows outlines by 2.5 % of the size per side; synthetic italic slants by 12°. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use skrifa::instance::{LocationRef, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::{GlyphId, MetadataProvider}; + +use crate::fonts::{self, FaceId}; +use crate::layout::{Glyph, Layout}; +use crate::raster::{IDENTITY, Mask, Path, Xform, apply, compose, fill, fill_into, xform_scale}; + +/// Slant of synthetic italic (tan 12°). +pub const SYNTH_ITALIC_SLANT: f32 = 0.2126; +/// Synthetic bold outline offset as a fraction of the font size. +pub const SYNTH_BOLD: f32 = 0.025; + +#[derive(Clone, Copy, Debug)] +enum Cmd { + M(f32, f32), + L(f32, f32), + Q(f32, f32, f32, f32), + C(f32, f32, f32, f32, f32, f32), + Z, +} + +struct Rec(Vec); + +impl OutlinePen for Rec { + fn move_to(&mut self, x: f32, y: f32) { + self.0.push(Cmd::M(x, y)); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0.push(Cmd::L(x, y)); + } + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.0.push(Cmd::Q(cx0, cy0, x, y)); + } + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.0.push(Cmd::C(cx0, cy0, cx1, cy1, x, y)); + } + fn close(&mut self) { + self.0.push(Cmd::Z); + } +} + +/// Outline commands in font units (y up), cached per (face, glyph). +fn outline(face: FaceId, gid: u32) -> Option>> { + static C: OnceLock>>>>> = OnceLock::new(); + let c = C.get_or_init(Default::default); + if let Some(v) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&(face, gid)) { + return v.clone(); + } + let f = fonts::face(face); + let v = f.font().and_then(|font| { + let g = font.outline_glyphs().get(GlyphId::new(gid))?; + let mut rec = Rec(Vec::new()); + g.draw(DrawSettings::unhinted(Size::unscaled(), LocationRef::default()), &mut rec).ok()?; + Some(Arc::new(rec.0)) + }); + let mut m = c.lock().unwrap_or_else(|e| e.into_inner()); + if m.len() > 20_000 { + m.clear(); + } + m.insert((face, gid), v.clone()); + v +} + +/// The glyph's outline as a path: `m` maps glyph-local pixels (origin on the baseline, y down, +/// at the glyph's size) to the output. +pub fn glyph_path(g: &Glyph, m: &Xform) -> Path { + let mut p = Path::new(); + let Some(cmds) = outline(g.face, g.id) else { return p }; + let k = g.size / fonts::face(g.face).units_per_em(); + let slant = if g.synth_italic { SYNTH_ITALIC_SLANT } else { 0.0 }; + // font units (y up) → glyph px (y down), slanted + let local: Xform = [k, 0.0, slant * k, -k, 0.0, 0.0]; + let t = compose(m, &local); + p.detail = xform_scale(m).max(0.05); + let pt = |x: f32, y: f32| apply(&t, x, y); + for c in cmds.iter() { + match *c { + Cmd::M(x, y) => { + let (a, b) = pt(x, y); + p.move_to(a, b) + } + Cmd::L(x, y) => { + let (a, b) = pt(x, y); + p.line_to(a, b) + } + Cmd::Q(cx, cy, x, y) => { + let (c0, c1) = pt(cx, cy); + let (a, b) = pt(x, y); + p.quad_to(c0, c1, a, b) + } + Cmd::C(c0x, c0y, c1x, c1y, x, y) => { + let (p0, p1) = pt(c0x, c0y); + let (q0, q1) = pt(c1x, c1y); + let (a, b) = pt(x, y); + p.cubic_to(p0, p1, q0, q1, a, b) + } + Cmd::Z => p.close(), + } + } + p.close(); + if g.synth_bold { + p.embolden(g.size * SYNTH_BOLD * xform_scale(m)); + } + p +} + +/// A rasterised glyph: coverage placed with its top-left at `(left, top)` relative to the +/// pen's whole-pixel position. +#[derive(Debug)] +pub struct GlyphMask { + pub mask: Mask, + pub left: i32, + pub top: i32, +} + +type GKey = (FaceId, u32, u32, u8, bool, bool); + +fn glyph_cache() -> &'static Mutex>>> { + static C: OnceLock>>>> = OnceLock::new(); + C.get_or_init(Default::default) +} + +/// Rasterise glyph `g` at pixel size `px` with horizontal phase `frac` (0..1, quantised to ¼). +pub fn glyph_mask(g: &Glyph, px: f32, frac: f32) -> Option> { + let size_q = (px * 8.0).round().max(1.0) as u32; + let frac_q = ((frac.rem_euclid(1.0) * 4.0).round() as u8) % 4; + let key = (g.face, g.id, size_q, frac_q, g.synth_bold, g.synth_italic); + if let Some(v) = glyph_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return v.clone(); + } + let px = size_q as f32 / 8.0; + let fq = frac_q as f32 / 4.0; + let gg = Glyph { x: 0.0, y: 0.0, size: px, ..*g }; + let path = glyph_path(&gg, &[1.0, 0.0, 0.0, 1.0, fq, 0.0]); + let v = path.bounds().map(|(x0, y0, x1, y1)| { + let left = x0.floor() as i32; + let top = y0.floor() as i32; + let w = (x1.ceil() as i32 - left).max(1) as usize; + let h = (y1.ceil() as i32 - top).max(1) as usize; + Arc::new(GlyphMask { mask: fill(&path, w, h, -left as f32, -top as f32), left, top }) + }); + let mut m = glyph_cache().lock().unwrap_or_else(|e| e.into_inner()); + if m.len() > 16_384 { + m.clear(); + } + m.insert(key, v.clone()); + v +} + +/// Whether `m` is a uniform positive scale plus translation (the glyph-cache path applies). +pub fn is_axis_uniform(m: &Xform) -> bool { + m[1].abs() < 1e-6 && m[2].abs() < 1e-6 && m[0] > 0.0 && (m[0] - m[3]).abs() < 1e-4 * m[0].max(1.0) +} + +/// The whole layout (glyphs + underlines) as one device-space path. +pub fn layout_path(l: &Layout, m: &Xform) -> Path { + let mut p = Path::new(); + for g in &l.glyphs { + let gm = compose(m, &[1.0, 0.0, 0.0, 1.0, g.x, g.y]); + p.extend(&glyph_path(g, &gm)); + } + for u in &l.underlines { + p.extend(&Path::rect(u[0], u[1], u[2], u[3]).transformed(m)); + } + p +} + +/// Device-space bounding box of the layout's ink under `m` (logical bounds padded for overhangs). +pub fn device_bounds(l: &Layout, m: &Xform) -> [f32; 4] { + let b = l.bounds; + let pad = l.glyphs.iter().map(|g| g.size).fold(0.0f32, f32::max) * 0.35; + let pts = [(b[0] - pad, b[1] - pad), (b[2] + pad, b[1] - pad), (b[0] - pad, b[3] + pad), (b[2] + pad, b[3] + pad)]; + let mut o = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; + for (x, y) in pts { + let (a, c) = apply(m, x, y); + o = [o[0].min(a), o[1].min(c), o[2].max(a), o[3].max(c)]; + } + o +} + +/// Add the coverage of layout `l` under transform `m` into `out` (whose pixel (0,0) is device +/// pixel `origin`). +pub fn draw(l: &Layout, m: &Xform, out: &mut Mask, origin: (i32, i32)) { + let (ox, oy) = origin; + if is_axis_uniform(m) { + let s = m[0]; + for g in &l.glyphs { + let (x, y) = apply(m, g.x, g.y); + let px = g.size * s; + if px < 0.5 { + continue; + } + let xi = x.floor(); + if let Some(gm) = glyph_mask(g, px, x - xi) { + out.add(&gm.mask, xi as i32 + gm.left - ox, y.round() as i32 + gm.top - oy); + } + } + if !l.underlines.is_empty() { + let mut p = Path::new(); + for u in &l.underlines { + p.extend(&Path::rect(u[0], u[1], u[2], u[3]).transformed(m)); + } + fill_into(&p, out, -ox as f32, -oy as f32); + } + } else { + let p = layout_path(l, m); + fill_into(&p, out, -ox as f32, -oy as f32); + } +} + +/// Rasterise a layout into a tight mask; returns `(mask, x, y)` with the mask's device position. +pub fn rasterize(l: &Layout, m: &Xform) -> (Mask, i32, i32) { + let b = device_bounds(l, m); + let (x0, y0) = (b[0].floor() as i32 - 1, b[1].floor() as i32 - 1); + let (w, h) = ((b[2].ceil() as i32 + 1 - x0).max(1) as usize, (b[3].ceil() as i32 + 1 - y0).max(1) as usize); + let mut mask = Mask::new(w, h); + draw(l, m, &mut mask, (x0, y0)); + (mask, x0, y0) +} + +/// Translate-only transform helper. +pub fn at(x: f32, y: f32) -> Xform { + let mut t = IDENTITY; + t[4] = x; + t[5] = y; + t +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layout::{ParagraphStyle, TextStyle, layout}; + + fn ink(m: &Mask) -> f32 { + m.a.iter().sum() + } + + #[test] + fn h_has_two_stems() { + let l = layout("H", &TextStyle { size: 40.0, ..Default::default() }, &ParagraphStyle::default()); + let g = glyph_mask(&l.glyphs[0], 40.0, 0.0).unwrap(); + let y = g.mask.h / 4; + let row: Vec = (0..g.mask.w).map(|x| g.mask.get(x, y)).collect(); + assert!(row[1] > 0.9 && row[g.mask.w - 2] > 0.9, "{row:?}"); + assert!(row[g.mask.w / 2] < 0.1, "{row:?}"); + assert!(g.top < 0, "rises above the baseline"); + } + + #[test] + fn cached_and_outline_paths_agree() { + let l = layout("Title 42", &TextStyle { size: 48.0, ..Default::default() }, &ParagraphStyle::default()); + let fast = rasterize(&l, &at(20.25, 60.0)); + // a tiny rotation forces the outline path + let a = 1e-4f32; + let slow = rasterize(&l, &[a.cos(), a.sin(), -a.sin(), a.cos(), 20.25, 60.0]); + let (fi, si) = (ink(&fast.0), ink(&slow.0)); + assert!((fi - si).abs() / fi < 0.02, "{fi} vs {si}"); + } + + #[test] + fn synthetic_styles() { + let base = TextStyle { size: 60.0, ..Default::default() }; + let l = layout("Hello", &base, &ParagraphStyle::default()); + let b = layout("Hello", &TextStyle { faux_bold: true, ..base.clone() }, &ParagraphStyle::default()); + assert!(ink(&rasterize(&b, &IDENTITY).0) > ink(&rasterize(&l, &IDENTITY).0) * 1.1); + let i = layout("l", &TextStyle { faux_italic: true, ..base.clone() }, &ParagraphStyle::default()); + let (mi, _, _) = rasterize(&i, &IDENTITY); + // the top of an italic l is further right than its bottom + let row_centre = |y: usize| { + let r: Vec = (0..mi.w).map(|x| mi.get(x, y)).collect(); + let s: f32 = r.iter().sum(); + r.iter().enumerate().map(|(x, v)| x as f32 * v).sum::() / s.max(1e-6) + }; + let rows: Vec = (0..mi.h).filter(|&y| (0..mi.w).any(|x| mi.get(x, y) > 0.5)).collect(); + assert!(row_centre(rows[2]) > row_centre(rows[rows.len() - 3]) + 3.0); + } + + #[test] + fn rotated_text_draws() { + let l = layout("ROT", &TextStyle { size: 40.0, ..Default::default() }, &ParagraphStyle::default()); + let a = 30f32.to_radians(); + let (m, _, _) = rasterize(&l, &[a.cos(), a.sin(), -a.sin(), a.cos(), 0.0, 0.0]); + let (m0, _, _) = rasterize(&l, &IDENTITY); + assert!((ink(&m) - ink(&m0)).abs() / ink(&m0) < 0.03); + } +} diff --git a/crates/text/src/sfnt.rs b/crates/text/src/sfnt.rs new file mode 100644 index 000000000..bb00d515f --- /dev/null +++ b/crates/text/src/sfnt.rs @@ -0,0 +1,140 @@ +//! Minimal SFNT (TrueType / OpenType / collection) header reader for font discovery: reads only +//! the table directory, `name` and `OS/2` of each face, so scanning a system font folder does not +//! load whole files. + +use std::io::{Read, Seek, SeekFrom}; + +/// Names and style attributes of one face in a font file. +#[derive(Clone, Debug, PartialEq)] +pub struct FaceNames { + pub index: u32, + pub family: String, + pub style: String, + pub full_name: String, + pub weight: u16, + pub italic: bool, +} + +fn be16(b: &[u8], o: usize) -> Option { + Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?])) +} +fn be32(b: &[u8], o: usize) -> Option { + Some(u32::from_be_bytes([*b.get(o)?, *b.get(o + 1)?, *b.get(o + 2)?, *b.get(o + 3)?])) +} + +fn read_at(r: &mut R, off: u64, len: usize) -> Option> { + if len > 4 << 20 { + return None; + } + r.seek(SeekFrom::Start(off)).ok()?; + let mut v = vec![0u8; len]; + r.read_exact(&mut v).ok()?; + Some(v) +} + +/// Read the names of every face in a font file (a single font or a collection). +pub fn read_faces(r: &mut R) -> Vec { + let Some(head) = read_at(r, 0, 12) else { return Vec::new() }; + let offsets: Vec = if &head[0..4] == b"ttcf" { + let n = be32(&head, 8).unwrap_or(0).min(256) as usize; + let Some(t) = read_at(r, 12, n * 4) else { return Vec::new() }; + (0..n).filter_map(|i| be32(&t, i * 4)).collect() + } else { + vec![0] + }; + offsets.iter().enumerate().filter_map(|(i, &o)| read_face(r, o as u64, i as u32)).collect() +} + +/// Names from in-memory font data. +pub fn read_faces_bytes(data: &[u8]) -> Vec { + read_faces(&mut std::io::Cursor::new(data)) +} + +fn read_face(r: &mut R, off: u64, index: u32) -> Option { + let h = read_at(r, off, 12)?; + let tag = &h[0..4]; + if tag != [0, 1, 0, 0] && tag != b"OTTO" && tag != b"true" { + return None; + } + let n = be16(&h, 4)? as usize; + let dir = read_at(r, off + 12, n * 16)?; + let mut name = None; + let mut os2 = None; + for i in 0..n { + let rec = &dir[i * 16..i * 16 + 16]; + let (o, l) = (be32(rec, 8)? as u64, be32(rec, 12)? as usize); + match &rec[0..4] { + b"name" => name = Some((o, l)), + b"OS/2" => os2 = Some((o, l)), + _ => {} + } + } + let (no, nl) = name?; + let nb = read_at(r, no, nl)?; + let get = |id: u16| name_string(&nb, id); + let family = get(16).or_else(|| get(1))?; + let style = get(17).or_else(|| get(2)).unwrap_or_else(|| "Regular".into()); + let full_name = get(4).unwrap_or_else(|| format!("{family} {style}")); + let (mut weight, mut italic) = (400u16, false); + if let Some((oo, ol)) = os2 + && let Some(ob) = read_at(r, oo, ol.min(78)) + { + weight = be16(&ob, 4).unwrap_or(400); + italic = be16(&ob, 62).is_some_and(|fs| fs & 1 != 0); + } + let sl = style.to_ascii_lowercase(); + italic |= sl.contains("italic") || sl.contains("oblique"); + Some(FaceNames { index, family, style, full_name, weight, italic }) +} + +/// A name-table string by id, preferring Windows Unicode English, then any Unicode, then Mac Roman. +fn name_string(b: &[u8], id: u16) -> Option { + let count = be16(b, 2)? as usize; + let storage = be16(b, 4)? as usize; + let mut best: Option<(u8, String)> = None; + for i in 0..count { + let r = 6 + i * 12; + let (plat, enc, lang, nid, len, off) = + (be16(b, r)?, be16(b, r + 2)?, be16(b, r + 4)?, be16(b, r + 6)?, be16(b, r + 8)? as usize, be16(b, r + 10)? as usize); + if nid != id { + continue; + } + let raw = b.get(storage + off..storage + off + len)?; + let (rank, s) = match plat { + 3 if enc == 1 || enc == 10 => { + let u: Vec = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + (if lang == 0x409 { 0 } else { 1 }, String::from_utf16_lossy(&u)) + } + 0 => { + let u: Vec = raw.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + (2, String::from_utf16_lossy(&u)) + } + 1 if enc == 0 => (3, raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect()), + _ => continue, + }; + if best.as_ref().is_none_or(|(r0, _)| rank < *r0) { + best = Some((rank, s)); + } + } + best.map(|(_, s)| s).filter(|s| !s.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_bundled_names() { + let f = read_faces_bytes(crate::fonts::INTER_BOLD); + assert_eq!(f.len(), 1); + assert_eq!(f[0].family, "Inter"); + assert_eq!(f[0].style, "Bold"); + assert_eq!(f[0].weight, 700); + assert!(!f[0].italic); + let i = read_faces_bytes(crate::fonts::INTER_ITALIC); + assert!(i[0].italic, "{:?}", i[0]); + let n = read_faces_bytes(crate::fonts::NOTO_SERIF_REGULAR); + assert_eq!(n[0].family, "Noto Serif"); + assert!(read_faces_bytes(b"not a font").is_empty()); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index a42a66f5f..32f88ec17 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -18,6 +18,7 @@ filmcraft-media = { workspace = true } filmcraft-project = { workspace = true } filmcraft-edit = { workspace = true } filmcraft-render = { workspace = true } +filmcraft-text = { workspace = true } filmcraft-audio-dsp = { workspace = true } filmcraft-engine = { workspace = true } filmcraft-gpu = { workspace = true } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 5c548ac95..9f938841a 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -193,10 +193,10 @@ impl Tokens { /// Install fonts (Inter, Inter SemiBold, JetBrains Mono) and egui visuals. pub fn install(ctx: &egui::Context, t: &Tokens) { let mut fonts = FontDefinitions::default(); - fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); - fonts.font_data.insert("inter-medium".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Medium.ttf")))); - fonts.font_data.insert("inter-semibold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")))); - fonts.font_data.insert("jbmono".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")))); + fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR))); + fonts.font_data.insert("inter-medium".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_MEDIUM))); + fonts.font_data.insert("inter-semibold".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_SEMIBOLD))); + fonts.font_data.insert("jbmono".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::JETBRAINS_MONO_REGULAR))); fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "inter".into()); fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "jbmono".into()); fonts.families.insert(FontFamily::Name("semibold".into()), vec!["inter-semibold".into(), "inter".into()]); diff --git a/docs/architecture.md b/docs/architecture.md index c225ff120..73a01dd13 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,8 +24,8 @@ Design principles: L5 ui-egui · automation L4 engine L3 render · gpu · export · golden (test-only) - L2 edit · codecs · interchange - L1 frame · media · project · audio-dsp + L2 edit · codecs · interchange · captions + L1 frame · media · project · audio-dsp · text L0 foundation: time · geom · color · bitstream · testkit (dev-dependency only) codecs/containers: isobmff · matroska · h264 · h264enc · hevc · prores · aac ``` @@ -50,6 +50,7 @@ and `filmcraft-cli`. | `media` | L1 | `MediaSource` trait, probing/openers, frame cache, generators, stills, WAV | | `project` | L1 | document model, effect definitions, keyframes | | `audio-dsp` | L1 | loudness metering (BS.1770 / R128) and audio effects; no dependencies | +| `text` | L1 | text engine: font database (bundled OFL fonts + system fonts), shaping (harfrust), bidi, line breaking, paragraph layout, glyph/path rasteriser, strokes ([crates/text/README.md](../crates/text/README.md)) | | `edit` | L2 | pure edit algebra (insert, overwrite, razor, ripple, roll, slip, slide, rate stretch…) | | `codecs` | L2 | container + codec hub: MP4/MOV and MKV sources, GOP-aware seeking, decoder registry, audio decoding | | `interchange` | L2 | EDL, FCP7 XML, FCPXML and OTIO import/export (no file I/O) | @@ -257,7 +258,7 @@ Protocol reference: [control-protocol.md](control-protocol.md). Agent guide: [ag ## 8. Not built yet The layer table reserves names for crates that don't exist yet: `riff`, `mjpeg`, `dnx`, -`keyframe`, `effects`, `text`, `audio`, `captions`, `scopes`, `playback`, `format` and `platform`. +`keyframe`, `effects`, `audio`, `scopes`, `playback`, `format` and `platform`. Until they exist, that work lives elsewhere: keyframes and effect definitions in `project`, effects and the audio mix in `render`, scopes and playback in `ui-egui`, and OS integration (cpal, rfd, native menus) in `apps/filmcraft`. [ROADMAP.md](../ROADMAP.md) has the milestone status. diff --git a/docs/captions.md b/docs/captions.md index 0cac44d7a..b8c03f0a4 100644 --- a/docs/captions.md +++ b/docs/captions.md @@ -74,6 +74,5 @@ also splits captions. The Program monitor shows the visible caption tracks. On export, `file.exportMedia` `{"burnCaptions": true}` (Export mode: **Burn Captions Into Video**) draws them into every frame. -Captions are set in Inter SemiBold (OFL, bundled) with our own rasteriser; italics/bold markup is -not yet drawn differently, and text is not shaped (no kerning or complex scripts) until the M10.1 -text engine lands. Nested sequences do not burn in their own captions. +Captions are set in Inter SemiBold (OFL, bundled) by the text engine (`crates/text`: shaping, +kerning, bidi); italics/bold markup is not yet drawn differently. Nested sequences do not burn in their own captions. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ee24ea1e5..ad9cc29b2 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -36,11 +36,11 @@ const LAYERS: &[(&str, u8)] = &[ ("media", 1), ("project", 1), ("audio-dsp", 1), + ("text", 1), ("edit", 2), ("codecs", 2), ("keyframe", 2), ("effects", 2), - ("text", 2), ("audio", 2), ("captions", 2), ("interchange", 2), From 6b00314e96699fefdf811b2f50a66837c0eca201 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 01:29:49 -0400 Subject: [PATCH 15/24] M7.2: audio mixer graph, track automation and mixer commands - project: MixerStrip on every audio track, submix tracks and the Mix (automation mode Off/Read/Latch/Touch/Write, lanes for volume/pan/mute/ send levels, effect-parameter automation in the insert's own keyframes, sends pre/post fader, output routing, record arm, solo safe, input map); pre/post-fader flag on inserts; serde defaults (no schema migration). - render: mixer graph (tracks -> submixes -> Mix) in Premiere's order: pre-fader inserts, pre-fader sends, mute, fader, meter, post-fader inserts, post-fader sends, pan/balance. Per-sample automation, effect params on an absolute 64-sample grid (output independent of request cutting), route-level latency compensation, cached continuation state with pre-roll, solo/solo-safe logic, -3 dB constant-power pan law for mono tracks, balance for stereo. LiveMix carries held controls (Touch ramps back over the automatch time), meters and the newest project. - engine: mixer.* commands (setStrip, setValue, touch/release, recordStart/recordStop, submixes, inserts, sends, track keyframes, writeAutomation, inspect) and the automation recorder (gesture streams thinned into keyframes, one undo step per pass, Switch to Touch after Write); Preferences > Audio (automatch time, keyframe thinning). - tests: gain/pan law, block-boundary automation, solo/mute/solo-safe, sends, latency alignment, render-vs-playback identity, recorder modes, perf (24 tracks x EQ+Dynamics+Reverb + submix: 6.3x realtime, one core). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/autosave.rs | 42 ++ crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 8 + crates/engine/src/mixer.rs | 1026 ++++++++++++++++++++++++++++++ crates/engine/src/mixer_tests.rs | 282 ++++++++ crates/engine/src/previews.rs | 10 +- crates/interchange/src/common.rs | 3 + crates/project/src/effect.rs | 4 + crates/project/src/keyframe.rs | 34 + crates/project/src/lib.rs | 18 +- crates/project/src/mixer.rs | 548 ++++++++++++++++ crates/render/src/audio.rs | 139 ++-- crates/render/src/audio_fx.rs | 16 +- crates/render/src/lib.rs | 5 + crates/render/src/mixer.rs | 803 +++++++++++++++++++++++ crates/render/src/mixer_tests.rs | 434 +++++++++++++ crates/render/src/preview.rs | 6 +- 17 files changed, 3292 insertions(+), 87 deletions(-) create mode 100644 crates/engine/src/mixer.rs create mode 100644 crates/engine/src/mixer_tests.rs create mode 100644 crates/project/src/mixer.rs create mode 100644 crates/render/src/mixer.rs create mode 100644 crates/render/src/mixer_tests.rs diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 436841ab7..0cc02293a 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -69,6 +69,46 @@ impl AutoSavePrefs { #[serde(rename_all = "camelCase", default)] pub struct Preferences { pub auto_save: AutoSavePrefs, + pub audio: AudioPrefs, +} + +/// Preferences ▸ Audio (the mixer-automation part) and the Track Mixer panel-menu toggle. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AudioPrefs { + /// "Automatch Time" (s): how long Touch takes to return to the existing automation. + pub automatch_time: f64, + /// "Large Volume Adjustment" (dB) for the Increase/Decrease Clip Volume Many commands. + pub large_volume_adjustment: f64, + /// Automation Keyframe Optimization: "Linear keyframe thinning". + pub linear_keyframe_thinning: bool, + /// "Minimum time interval thinning". + pub minimum_time_interval_thinning: bool, + /// "Minimum time" (ms). + pub minimum_time_ms: u32, + /// Track Mixer ▸ "Switch to Touch after Write". + pub switch_to_touch_after_write: bool, +} + +impl Default for AudioPrefs { + fn default() -> Self { + Self { + automatch_time: 1.0, + large_volume_adjustment: 6.0, + linear_keyframe_thinning: true, + minimum_time_interval_thinning: false, + minimum_time_ms: 20, + switch_to_touch_after_write: true, + } + } +} + +impl AudioPrefs { + fn clamp(&mut self) { + self.automatch_time = if self.automatch_time.is_finite() { self.automatch_time.clamp(0.0, 30.0) } else { 1.0 }; + self.large_volume_adjustment = if self.large_volume_adjustment.is_finite() { self.large_volume_adjustment.clamp(0.0, 96.0) } else { 6.0 }; + self.minimum_time_ms = self.minimum_time_ms.clamp(1, 10_000); + } } impl Preferences { @@ -118,6 +158,7 @@ impl Preferences { *slot = value; let mut p: Preferences = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?; p.auto_save.clamp(); + p.audio.clamp(); *self = p; Ok(()) } @@ -125,6 +166,7 @@ impl Preferences { pub fn load(path: &Path) -> Self { let mut p: Preferences = fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); p.auto_save.clamp(); + p.audio.clamp(); p } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 687721f3b..300f0a21c 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1814,6 +1814,7 @@ fn build() -> Vec { )), ]; v.extend(crate::captions::commands()); + v.extend(crate::mixer::commands()); // Labels as individual commands (Edit ▸ Label ▸ ) for l in Label::ALL { let _ = l; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dfaba163b..f5346461c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod commands; pub mod demo; pub mod interchange; pub mod media_pool; +pub mod mixer; pub mod previews; pub mod trim; @@ -162,6 +163,8 @@ pub struct Session { pub loaded_schema: u32, /// Render preview files + render-bar segments (shared with the frontend's frame workers). pub previews: Arc, + /// Audio Track Mixer automation pass in progress. + pub mixrec: mixer::Recorder, } /// A background job with shared progress. @@ -221,6 +224,7 @@ impl Session { persistence: None, loaded_schema: filmcraft_format::SCHEMA_VERSION, previews: Arc::new(previews::PreviewStore::temp()), + mixrec: Default::default(), } } @@ -314,6 +318,8 @@ impl Session { (spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?; let r = (spec.run)(self, ¶ms); self.sync_persistence(); + // playback reads the newest snapshot (mixer moves, mutes… are heard while playing) + self.previews.live.publish_project(self.project.clone()); if r.is_ok() && spec.journal { self.journal.push((id.to_string(), params)); if self.journal.len() > 10_000 { @@ -488,6 +494,8 @@ mod autosave_tests; #[cfg(test)] mod file_tests; #[cfg(test)] +mod mixer_tests; +#[cfg(test)] mod previews_tests; #[cfg(test)] mod tests; diff --git a/crates/engine/src/mixer.rs b/crates/engine/src/mixer.rs new file mode 100644 index 000000000..78bd9fd74 --- /dev/null +++ b/crates/engine/src/mixer.rs @@ -0,0 +1,1026 @@ +//! Audio Track Mixer commands and the automation recorder. +//! +//! Strips are addressed by `strip` (or `track`): `"A1"` … (audio tracks), `"S1"` … (submixes), +//! `"Mix"` (the master), a track id, or a strip name. Lanes are `volume`, `pan`, `mute`, +//! `send..level` and `fx..` (see [`filmcraft_project::mixer`]). +//! +//! **Recording.** `mixer.recordStart` begins an automation pass at the playhead (the UI calls it +//! when playback starts). While it runs, `mixer.touch` / `mixer.release` are the fader gestures: +//! the audio follows the control immediately (through [`filmcraft_render::mixer::LiveMix`]) and the +//! gesture is recorded as a stream of (time, value) points. `mixer.recordStop` (playback stops) +//! thins every stream into keyframes and writes them as one undoable step, following the strip's +//! automation mode: +//! +//! | mode | records | after release | +//! |---|---|---| +//! | Off / Read | nothing (Read plays the automation) | the control returns to the automation | +//! | Latch | from the first touch | keeps writing the last value until playback stops | +//! | Touch | while touched | ramps back to the existing automation over the automatch time | +//! | Write | every control from the start of playback | keeps writing the last value until playback stops | +//! +//! With "Switch to Touch after Write" (on by default) a Write strip changes to Touch after the +//! pass. Without a recording pass, `mixer.touch` holds the control live and `mixer.release` commits +//! it once (a static value, or a keyframe at the playhead when the lane is automated). + +use serde_json::{Value, json}; + +use filmcraft_project::mixer::{ + self as pm, FADER_MAX_DB, FADER_MIN_DB, LANE_MUTE, LANE_PAN, LANE_VOLUME, MASTER_STRIP, lane_info, parse_fx_lane, parse_send_lane, thin_points, write_lane, +}; +use filmcraft_project::{AudioChannels, AutomationMode, InputMap, ItemId, Param, ParamValue, Sequence, Track, TrackId, TrackKind, TrackSend, find_effect}; +use filmcraft_time::{TICKS_PER_SECOND, Tick}; + +use crate::commands::{CommandSpec, always, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p}; +use crate::{EngineError, Result, Session}; + +type Run = fn(&mut Session, &Value) -> Result; +type Enabled = fn(&Session) -> std::result::Result<(), String>; + +// ------------------------------------------------------------------------------------- recorder + +/// A held value ends this long before the next move (one sample at 48 kHz). +const HOLD_EPS: Tick = Tick(TICKS_PER_SECOND / 48_000); + +/// One recorded control gesture. +#[derive(Clone, Debug)] +pub struct Gesture { + pub strip: TrackId, + pub lane: String, + pub mode: AutomationMode, + /// Recorded (time, value) points. + pub points: Vec<(Tick, f64)>, + pub touching: bool, + /// Touch: released at. + pub released: Option, + last: (Tick, f64), +} + +impl Gesture { + fn new(strip: TrackId, lane: &str, mode: AutomationMode, t: Tick, v: f64) -> Self { + Gesture { strip, lane: lane.to_string(), mode, points: vec![(t, v)], touching: true, released: None, last: (t, v) } + } + /// A new value at `t`. Repeats of the same value extend the hold instead of adding points, so a + /// fader held still records a flat line, not a ramp to the next move. + fn push(&mut self, t: Tick, v: f64) { + if t < self.last.0 { + return; + } + if v == self.last.1 { + self.last.0 = t; + return; + } + if self.points.last().is_some_and(|p| self.last.0 > p.0) { + self.points.push(self.last); + } + self.points.push((t, v)); + self.last = (t, v); + } + fn flush(&mut self) { + if self.points.last().is_some_and(|p| self.last.0 > p.0) { + self.points.push(self.last); + } + } + pub fn value(&self) -> f64 { + self.last.1 + } +} + +/// State of the current automation pass. +#[derive(Clone, Debug, Default)] +pub struct Recorder { + /// Sequence being recorded (None = no pass running). + pub seq: Option, + pub start: Tick, + pub gestures: Vec, +} + +impl Recorder { + pub fn active(&self) -> bool { + self.seq.is_some() + } + fn find(&mut self, strip: TrackId, lane: &str) -> Option<&mut Gesture> { + self.gestures.iter_mut().find(|g| g.strip == strip && g.lane == lane) + } +} + +/// Lanes a Write pass records on every strip. +fn write_lanes(strip: TrackId) -> &'static [&'static str] { + if strip == MASTER_STRIP { &[LANE_VOLUME] } else { &[LANE_VOLUME, LANE_PAN, LANE_MUTE] } +} + +/// The lane value as automation plays it (no live overrides). +fn underlying(tr: &Track, lane: &str, t: Tick) -> f64 { + let v = tr.lane_value(lane, t); + if lane_info(lane).is_some_and(|i| i.hold) { + // hold lanes: last keyframe at or before t + if let Some(p) = tr.lane(lane).filter(|p| p.is_animated() && tr.mixer.mode.reads()) { + let i = p.keyframes.partition_point(|k| k.time <= t); + return p.keyframes[i.saturating_sub(1)].value.as_f64().unwrap_or(v); + } + } + v +} + +fn clamp_lane(lane: &str, v: f64) -> f64 { + match lane_info(lane) { + Some(i) if i.hold => { + if v >= 0.5 { + 1.0 + } else { + 0.0 + } + } + Some(i) => v.clamp(i.min, i.max), + None => v, + } +} + +fn seq_of(s: &Session, id: ItemId) -> Result<&Sequence> { + s.project.sequence(id).ok_or(EngineError::NoSequence) +} + +/// Start an automation pass at `t` (playback start). +pub fn record_start(s: &mut Session, t: Tick) -> Result { + let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; + let seq = seq_of(s, seq_id)?; + let mut gestures = Vec::new(); + for id in seq.strip_ids() { + let Some(tr) = seq.strip(id) else { continue }; + if tr.mixer.mode != AutomationMode::Write { + continue; + } + for lane in write_lanes(id) { + let v = underlying(&tr, lane, t); + let mut g = Gesture::new(id, lane, AutomationMode::Write, t, v); + g.touching = false; + gestures.push(g); + } + } + s.previews.live.clear_all(); + for g in &gestures { + s.previews.live.hold(g.strip, &g.lane, g.value()); + } + let n = gestures.len(); + s.mixrec = Recorder { seq: Some(seq_id), start: t, gestures }; + Ok(json!({"recording": true, "writing": n})) +} + +/// A control moved to `v` at `t`. +pub fn touch(s: &mut Session, strip: TrackId, lane: &str, v: f64, t: Tick) -> Result { + let v = clamp_lane(lane, v); + let seq_id = s.mixrec.seq.or(s.state.active_sequence).ok_or(EngineError::NoSequence)?; + let mode = seq_of(s, seq_id)?.strip(strip).ok_or_else(|| bad("mixer.touch", "no such strip"))?.mixer.mode; + s.previews.live.hold(strip, lane, v); + if !s.mixrec.active() || !mode.writes() { + return Ok(json!({"recording": false, "value": v})); + } + match s.mixrec.find(strip, lane) { + Some(g) => { + if !g.touching { + if g.mode == AutomationMode::Touch { + // a new touch after a release: the old ramp is overwritten from here + g.released = None; + } else if t > g.last.0 { + // Write / Latch held the last value until this touch + g.last.0 = (t - HOLD_EPS).max(g.last.0); + } + } + g.touching = true; + g.push(t, v); + } + None => s.mixrec.gestures.push(Gesture::new(strip, lane, mode, t, v)), + } + Ok(json!({"recording": true, "value": v})) +} + +/// A control let go at `t`. +pub fn release(s: &mut Session, strip: TrackId, lane: &str, t: Tick) -> Result { + let automatch = Tick::from_seconds_f64(s.prefs.audio.automatch_time); + if s.mixrec.active() { + if let Some(g) = s.mixrec.find(strip, lane) { + g.touching = false; + g.push(t, g.value()); + if g.mode == AutomationMode::Touch { + g.released = Some(t); + s.previews.live.release(strip, lane, t, automatch); + } + // Latch / Write keep holding the last value until playback stops. + return Ok(json!({"recording": true})); + } + s.previews.live.clear(strip, lane); + return Ok(json!({"recording": false})); + } + // no pass: commit the held value once + let held = s.previews.live.get(strip, lane); + s.previews.live.clear(strip, lane); + match held { + Some(o) => { + set_value(s, strip, lane, o.value, t)?; + Ok(json!({"recording": false, "value": o.value})) + } + None => Ok(json!({"recording": false})), + } +} + +/// End the pass at `t`: write every gesture as keyframes (one undo step). +pub fn record_stop(s: &mut Session, t_stop: Tick) -> Result { + let rec = std::mem::take(&mut s.mixrec); + let Some(seq_id) = rec.seq.filter(|_| !rec.gestures.is_empty()) else { + s.previews.live.clear_all(); + return Ok(json!({"recording": false, "lanes": 0})); + }; + let automatch = Tick::from_seconds_f64(s.prefs.audio.automatch_time); + let prefs = s.prefs.audio.clone(); + let rate = seq_of(s, seq_id)?.settings.sample_rate.max(1) as i64; + let eps = Tick::from_units(1, rate).max(Tick(1)); + let min_interval = if prefs.minimum_time_interval_thinning { Tick(prefs.minimum_time_ms as i64 * TICKS_PER_SECOND / 1000) } else { Tick::ZERO }; + let mut written = Vec::new(); + let mut keyframes = 0usize; + let result = s.edit("Write Automation", |p, _| { + let seq = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?; + for mut g in rec.gestures { + if g.points.is_empty() { + continue; + } + let Some(tr) = seq.strip(g.strip).map(|c| c.into_owned()) else { continue }; + let hold = lane_info(&g.lane).is_some_and(|i| i.hold); + let held = g.value(); + let end = match g.mode { + AutomationMode::Touch => g.released.unwrap_or(t_stop).min(t_stop.max(g.points[0].0)), + _ => t_stop, + }; + g.push(end.max(g.last.0), held); + g.flush(); + let mut pts = g.points.clone(); + if g.mode == AutomationMode::Touch && automatch.0 > 0 && !hold { + let back = end + automatch; + pts.push((back, underlying(&tr, &g.lane, back))); + } + let t0 = pts[0].0; + let t1 = pts.last().map(|p| p.0).unwrap_or(t0); + let tol = if hold { + 0.0 + } else if prefs.linear_keyframe_thinning { + if g.lane == LANE_PAN { 0.25 } else { 0.05 } + } else { + 0.0 + }; + let thinned = thin_points(&pts, tol, min_interval, hold); + let before = (t0.0 > 0).then(|| underlying(&tr, &g.lane, t0 - eps)); + let after = tr.lane(&g.lane).filter(|p| p.keyframes.iter().any(|k| k.time > t1)).map(|_| underlying(&tr, &g.lane, t1 + eps)); + keyframes += thinned.len(); + let lane = g.lane.clone(); + seq.with_strip_mut(g.strip, |tr| { + if let Some(param) = tr.lane_mut(&lane) { + write_lane(param, t0, t1, &thinned, before, after, eps, hold); + } + }); + written.push(json!({"strip": g.strip.0, "lane": lane, "from": t0.0, "to": t1.0, "keyframes": thinned.len(), "points": pts.len()})); + } + if prefs.switch_to_touch_after_write { + for id in seq.strip_ids() { + seq.with_strip_mut(id, |tr| { + if tr.mixer.mode == AutomationMode::Write { + tr.mixer.mode = AutomationMode::Touch; + } + }); + } + } + Ok(()) + }); + s.previews.live.clear_all(); + s.previews.live.publish_project(s.project.clone()); + result?; + Ok(json!({"recording": false, "lanes": written.len(), "keyframes": keyframes, "written": written})) +} + +// ------------------------------------------------------------------------------------- helpers + +fn active_seq(s: &Session) -> Result<&Sequence> { + s.active_sequence().ok_or(EngineError::NoSequence) +} + +/// Parse a strip reference: id, "A1", "S1", "Mix", or a strip name. +pub fn strip_ref(seq: &Sequence, v: &Value) -> Option { + if let Some(id) = v.as_u64() { + let id = TrackId(id); + return seq.strip(id).map(|_| id); + } + let name = v.as_str()?.trim(); + if name.eq_ignore_ascii_case("mix") || name.eq_ignore_ascii_case("master") { + return Some(MASTER_STRIP); + } + let idx = |pre: &str| { + name.strip_prefix(pre).or_else(|| name.strip_prefix(&pre.to_lowercase())).and_then(|n| n.parse::().ok()).and_then(|n| n.checked_sub(1)) + }; + if let Some(i) = idx("A") { + return seq.audio_tracks.get(i).map(|t| t.id); + } + if let Some(i) = idx("S") { + return seq.submix_tracks.get(i).map(|t| t.id); + } + seq.audio_tracks.iter().chain(&seq.submix_tracks).find(|t| t.name.eq_ignore_ascii_case(name)).map(|t| t.id) +} + +/// Display reference of a strip ("A1", "S2", "Mix"). +pub fn strip_label(seq: &Sequence, id: TrackId) -> String { + if id == MASTER_STRIP { + return "Mix".into(); + } + if let Some(i) = seq.audio_tracks.iter().position(|t| t.id == id) { + return format!("A{}", i + 1); + } + if let Some(i) = seq.submix_tracks.iter().position(|t| t.id == id) { + return format!("S{}", i + 1); + } + id.0.to_string() +} + +fn strip_p(s: &Session, p: &Value, cmd: &str) -> Result { + let seq = active_seq(s)?; + let v = p.get("strip").or_else(|| p.get("track")).ok_or_else(|| bad(cmd, "need `strip` (\"A1\", \"S1\", \"Mix\" or an id)"))?; + strip_ref(seq, v).ok_or_else(|| bad(cmd, format!("no strip {v}"))) +} + +fn lane_p(s: &Session, p: &Value, strip: TrackId, cmd: &str) -> Result { + let lane = str_p(p, "lane").unwrap_or(LANE_VOLUME).to_string(); + let seq = active_seq(s)?; + let tr = seq.strip(strip).ok_or_else(|| bad(cmd, "no such strip"))?; + let ok = match parse_fx_lane(&lane) { + Some((slot, param)) => tr.effects.get(slot).and_then(|e| e.param(param)).is_some_and(|p| p.value.as_f64().is_some()), + None => lane_info(&lane).is_some() && (parse_send_lane(&lane).is_none_or(|i| i < tr.mixer.sends.len())), + }; + if !ok { + return Err(bad(cmd, format!("no lane `{lane}` on this strip (volume, pan, mute, send..level, fx..)"))); + } + if strip == MASTER_STRIP && (lane == LANE_PAN || lane == LANE_MUTE || parse_send_lane(&lane).is_some()) { + return Err(bad(cmd, "the Mix track has volume and effect lanes only")); + } + Ok(lane) +} + +fn value_p(p: &Value, lane: &str, cmd: &str) -> Result { + let v = p.get("value").ok_or_else(|| bad(cmd, "need `value`"))?; + let x = v.as_f64().or_else(|| v.as_bool().map(|b| if b { 1.0 } else { 0.0 })).ok_or_else(|| bad(cmd, "`value` must be a number"))?; + Ok(clamp_lane(lane, x)) +} + +/// Set a control outside a recording pass: the static value, or a keyframe at `t` when the lane +/// is automated and the strip reads automation. +pub fn set_value(s: &mut Session, strip: TrackId, lane: &str, v: f64, t: Tick) -> Result<()> { + let lane = lane.to_string(); + let hold = lane_info(&lane).is_some_and(|i| i.hold); + s.edit_sequence("Track Mixer Adjust", move |q, _, _| { + q.with_strip_mut(strip, |tr| { + let animated = tr.lane(&lane).is_some_and(Param::is_animated) && tr.mixer.mode.reads(); + if animated { + if let Some(p) = tr.lane_mut(&lane) { + set_keyframe(p, t, v, hold); + } + } else { + tr.set_lane_static(&lane, v); + } + }) + .ok_or_else(|| EngineError::Other("no such strip".into())) + }) +} + +fn set_keyframe(p: &mut Param, t: Tick, v: f64, hold: bool) { + p.put_keyframe(t, ParamValue::Float(v)); + if hold { + for k in p.keyframes.iter_mut() { + k.interp = filmcraft_project::Interpolation::Hold; + } + } +} + +fn mode_p(p: &Value) -> Option { + str_p(p, "mode").and_then(AutomationMode::from_name) +} + +fn channels_name(c: AudioChannels) -> &'static str { + match c { + AudioChannels::Mono => "Mono", + AudioChannels::Stereo => "Stereo", + AudioChannels::Surround51 => "5.1", + AudioChannels::Adaptive => "Adaptive", + } +} + +fn channels_from(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "mono" => Some(AudioChannels::Mono), + "stereo" => Some(AudioChannels::Stereo), + "5.1" | "surround51" => Some(AudioChannels::Surround51), + "adaptive" => Some(AudioChannels::Adaptive), + _ => None, + } +} + +/// JSON view of one strip at time `t`. +pub fn strip_json(s: &Session, seq: &Sequence, id: TrackId, t: Tick) -> Value { + let Some(tr) = seq.strip(id) else { return Value::Null }; + let live = &s.previews.live; + let shown = |lane: &str| live.get(id, lane).map(|o| o.value).unwrap_or_else(|| underlying(&tr, lane, t)); + let lanes: serde_json::Map = tr + .automated_lanes() + .into_iter() + .map(|k| { + let kf: Vec = tr.lane_keyframes(&k).iter().map(|kf| json!([kf.time.0, kf.value.as_f64()])).collect(); + (k, Value::Array(kf)) + }) + .collect(); + let kind = if id == MASTER_STRIP { + "mix" + } else if seq.submix_tracks.iter().any(|t| t.id == id) { + "submix" + } else { + "audio" + }; + json!({ + "id": id.0, + "ref": strip_label(seq, id), + "name": tr.name, + "kind": kind, + "channels": channels_name(tr.channels), + "mode": tr.mixer.mode.label(), + "volumeDb": tr.volume_db, + "pan": tr.pan, + "muted": tr.muted, + "solo": tr.solo, + "recordArm": tr.mixer.record_arm, + "soloSafe": tr.mixer.solo_safe, + "inputMap": tr.mixer.input_map.label(), + "output": tr.mixer.output.map(|o| strip_label(seq, o)).unwrap_or_else(|| if id == MASTER_STRIP { String::new() } else { "Mix".into() }), + "inserts": tr.effects.iter().enumerate().map(|(i, e)| json!({"slot": i, "effect": e.effect, "name": e.def().map(|d| d.name).unwrap_or(""), "enabled": e.enabled, "postFader": e.post_fader})).collect::>(), + "sends": tr.mixer.sends.iter().enumerate().map(|(i, sd)| json!({"send": i, "target": strip_label(seq, sd.target), "levelDb": sd.level_db, "pan": sd.pan, "preFader": sd.pre_fader, "muted": sd.muted})).collect::>(), + "at": {"time": t.0, "volumeDb": shown(LANE_VOLUME), "pan": shown(LANE_PAN), "mute": shown(LANE_MUTE) >= 0.5}, + "lanes": lanes, + }) +} + +fn inspect(s: &mut Session, p: &Value) -> Result { + let t = time_p(s, p, "").unwrap_or(s.playhead()); + let seq = active_seq(s)?; + let strips: Vec = seq.strip_ids().into_iter().map(|id| strip_json(s, seq, id, t)).collect(); + Ok(json!({ + "strips": strips, + "recording": s.mixrec.active(), + "gestures": s.mixrec.gestures.iter().map(|g| json!({"strip": strip_label(seq, g.strip), "lane": g.lane, "mode": g.mode.label(), "touching": g.touching, "points": g.points.len(), "value": g.value()})).collect::>(), + "latencySamples": filmcraft_render::mixer::graph_latency(seq), + "automatchTime": s.prefs.audio.automatch_time, + })) +} + +fn set_strip(s: &mut Session, p: &Value) -> Result { + let id = strip_p(s, p, "mixer.setStrip")?; + let seq = active_seq(s)?; + let output = match p.get("output") { + None => None, + Some(Value::Null) => Some(None), + Some(v) => { + let o = strip_ref(seq, v).ok_or_else(|| bad("mixer.setStrip", format!("no output {v}")))?; + if o == MASTER_STRIP { + Some(None) + } else if o == id || !seq.submix_tracks.iter().any(|t| t.id == o) { + return Err(bad("mixer.setStrip", "output must be the Mix or a submix")); + } else if seq + .submix_tracks + .iter() + .position(|t| t.id == id) + .is_some_and(|me| seq.submix_tracks.iter().position(|t| t.id == o).is_some_and(|it| it <= me)) + { + return Err(bad("mixer.setStrip", "a submix can only feed a submix after it (no feedback)")); + } else { + Some(Some(o)) + } + } + }; + let p = p.clone(); + s.edit_sequence("Track Mixer Settings", move |q, _, _| { + q.with_strip_mut(id, |t| { + if let Some(v) = str_p(&p, "name") { + t.name = v.to_string(); + } + if let Some(v) = f64_p(&p, "volumeDb") { + t.volume_db = v.clamp(FADER_MIN_DB, FADER_MAX_DB); + } + if let Some(v) = f64_p(&p, "pan") { + t.pan = v.clamp(-100.0, 100.0); + } + if let Some(v) = bool_p(&p, "muted") { + t.muted = v; + } + if let Some(v) = bool_p(&p, "solo") { + t.solo = v; + } + if let Some(v) = bool_p(&p, "recordArm") { + t.mixer.record_arm = v; + } + if let Some(v) = bool_p(&p, "soloSafe") { + t.mixer.solo_safe = v; + } + if let Some(m) = mode_p(&p) { + t.mixer.mode = m; + } + if let Some(m) = str_p(&p, "inputMap").and_then(InputMap::from_name) { + t.mixer.input_map = m; + } + if let Some(c) = str_p(&p, "channels").and_then(channels_from) { + t.channels = c; + } + if let Some(o) = output { + t.mixer.output = o; + } + }) + .ok_or_else(|| EngineError::Other("no such strip".into())) + })?; + let seq = active_seq(s)?; + Ok(strip_json(s, seq, id, s.playhead())) +} + +fn add_submix(s: &mut Session, p: &Value) -> Result { + let name = str_p(p, "name").map(str::to_string); + let channels = str_p(p, "channels").and_then(channels_from).unwrap_or(AudioChannels::Stereo); + let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; + let id = s.edit("Add Submix Track", |pr, _| { + let id = TrackId(pr.alloc_id()); + let seq = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?; + let n = seq.submix_tracks.len() + 1; + let mut t = Track::new(id, TrackKind::Audio, name.unwrap_or_else(|| format!("Submix {n}"))); + t.channels = channels; + seq.submix_tracks.push(t); + Ok(id) + })?; + let seq = active_seq(s)?; + Ok(json!({"id": id.0, "ref": strip_label(seq, id)})) +} + +fn delete_submix(s: &mut Session, p: &Value) -> Result { + let id = strip_p(s, p, "mixer.deleteSubmix")?; + if !active_seq(s)?.submix_tracks.iter().any(|t| t.id == id) { + return Err(bad("mixer.deleteSubmix", "not a submix")); + } + s.edit_sequence("Delete Submix Track", move |q, _, _| { + q.submix_tracks.retain(|t| t.id != id); + // re-route everything that fed it to the Mix and drop sends to it + for sid in q.strip_ids() { + q.with_strip_mut(sid, |t| { + if t.mixer.output == Some(id) { + t.mixer.output = None; + } + while let Some(i) = t.mixer.sends.iter().position(|sd| sd.target == id) { + t.remove_send(i); + } + }); + } + Ok(()) + })?; + Ok(Value::Null) +} + +fn slot_p(p: &Value, cmd: &str) -> Result { + u64_p(p, "slot").map(|v| v as usize).ok_or_else(|| bad(cmd, "need `slot` (0-based)")) +} + +fn add_insert(s: &mut Session, p: &Value) -> Result { + let id = strip_p(s, p, "mixer.addInsert")?; + let eid = str_p(p, "effect").ok_or_else(|| bad("mixer.addInsert", "need `effect` (an audio effect id)"))?; + let def = find_effect(eid) + .filter(|d| d.kind == filmcraft_project::EffectKind::Audio && !d.intrinsic) + .ok_or_else(|| bad("mixer.addInsert", format!("`{eid}` is not an audio effect")))?; + let mut inst = def.instance(); + inst.post_fader = bool_p(p, "postFader").unwrap_or(false); + let slot = u64_p(p, "slot").map(|v| v as usize); + let n = active_seq(s)?.strip(id).map(|t| t.effects.len()).unwrap_or(0); + if n >= pm::MAX_INSERTS { + return Err(bad("mixer.addInsert", format!("all {} effect slots are in use", pm::MAX_INSERTS))); + } + let at = slot.unwrap_or(n).min(n); + s.edit_sequence(&format!("Add Track Effect {}", def.name), move |q, _, _| { + q.with_strip_mut(id, |t| t.effects.insert(at, inst)).ok_or_else(|| EngineError::Other("no such strip".into())) + })?; + Ok(json!({"slot": at})) +} + +fn remove_insert(s: &mut Session, p: &Value) -> Result { + let id = strip_p(s, p, "mixer.removeInsert")?; + let slot = slot_p(p, "mixer.removeInsert")?; + let ok = s.edit_sequence("Remove Track Effect", move |q, _, _| Ok(q.with_strip_mut(id, |t| t.remove_insert(slot)).unwrap_or(false)))?; + if !ok { + return Err(bad("mixer.removeInsert", "no effect in that slot")); + } + Ok(Value::Null) +} + +fn set_insert(s: &mut Session, p: &Value) -> Result { + let id = strip_p(s, p, "mixer.setInsert")?; + let slot = slot_p(p, "mixer.setInsert")?; + let p = p.clone(); + s.edit_sequence("Track Effect Settings", move |q, _, _| { + q.with_strip_mut(id, |t| -> Result<()> { + let e = t.effects.get_mut(slot).ok_or_else(|| bad("mixer.setInsert", "no effect in that slot"))?; + if let Some(v) = bool_p(&p, "enabled") { + e.enabled = v; + } + if let Some(v) = bool_p(&p, "postFader") { + e.post_fader = v; + } + if let Some(obj) = p.get("params").and_then(Value::as_object) { + for (k, v) in obj { + let par = e.param_mut(k).ok_or_else(|| bad("mixer.setInsert", format!("no parameter `{k}`")))?; + par.value = match (&par.value, v) { + (ParamValue::Bool(_), Value::Bool(b)) => ParamValue::Bool(*b), + (ParamValue::Choice(_), v) => ParamValue::Choice(v.as_u64().unwrap_or(0) as u32), + (_, v) => ParamValue::Float(v.as_f64().ok_or_else(|| bad("mixer.setInsert", format!("`{k}` needs a number")))?), + }; + } + } + Ok(()) + }) + .ok_or_else(|| EngineError::Other("no such strip".into()))? + })?; + Ok(Value::Null) +} + +fn send_target(s: &Session, p: &Value, id: TrackId, cmd: &str) -> Result { + let seq = active_seq(s)?; + let v = p.get("target").ok_or_else(|| bad(cmd, "need `target` (a submix)"))?; + let t = strip_ref(seq, v).filter(|t| seq.submix_tracks.iter().any(|x| x.id == *t)).ok_or_else(|| bad(cmd, "`target` must be a submix"))?; + let pos = |x: TrackId| seq.submix_tracks.iter().position(|y| y.id == x); + if t == id || pos(id).is_some_and(|me| pos(t).is_some_and(|it| it <= me)) { + return Err(bad(cmd, "a submix can only send to a submix after it (no feedback)")); + } + Ok(t) +} + +fn add_send(s: &mut Session, p: &Value) -> Result { + let id = strip_p(s, p, "mixer.addSend")?; + if id == MASTER_STRIP { + return Err(bad("mixer.addSend", "the Mix track has no sends")); + } + let target = send_target(s, p, id, "mixer.addSend")?; + let mut snd = TrackSend::new(target); + snd.level_db = f64_p(p, "levelDb").unwrap_or(0.0).clamp(FADER_MIN_DB, FADER_MAX_DB); + snd.pre_fader = bool_p(p, "preFader").unwrap_or(false); + snd.pan = f64_p(p, "pan").unwrap_or(0.0).clamp(-100.0, 100.0); + if active_seq(s)?.strip(id).is_some_and(|t| t.mixer.sends.len() >= pm::MAX_SENDS) { + return Err(bad("mixer.addSend", format!("all {} send slots are in use", pm::MAX_SENDS))); + } + let i = s.edit_sequence("Add Send", move |q, _, _| { + q.with_strip_mut(id, |t| { + t.mixer.sends.push(snd); + t.mixer.sends.len() - 1 + }) + .ok_or_else(|| EngineError::Other("no such strip".into())) + })?; + Ok(json!({"send": i})) +} + +fn set_send(s: &mut Session, p: &Value) -> Result { + let id = strip_p(s, p, "mixer.setSend")?; + let i = u64_p(p, "send").ok_or_else(|| bad("mixer.setSend", "need `send` (0-based)"))? as usize; + let target = if p.get("target").is_some() { Some(send_target(s, p, id, "mixer.setSend")?) } else { None }; + let p = p.clone(); + s.edit_sequence("Send Settings", move |q, _, _| { + q.with_strip_mut(id, |t| -> Result<()> { + let sd = t.mixer.sends.get_mut(i).ok_or_else(|| bad("mixer.setSend", "no such send"))?; + if let Some(v) = f64_p(&p, "levelDb") { + sd.level_db = v.clamp(FADER_MIN_DB, FADER_MAX_DB); + } + if let Some(v) = f64_p(&p, "pan") { + sd.pan = v.clamp(-100.0, 100.0); + } + if let Some(v) = bool_p(&p, "preFader") { + sd.pre_fader = v; + } + if let Some(v) = bool_p(&p, "muted") { + sd.muted = v; + } + if let Some(t) = target { + sd.target = t; + } + Ok(()) + }) + .ok_or_else(|| EngineError::Other("no such strip".into()))? + })?; + Ok(Value::Null) +} + +fn remove_send(s: &mut Session, p: &Value) -> Result { + let id = strip_p(s, p, "mixer.removeSend")?; + let i = u64_p(p, "send").ok_or_else(|| bad("mixer.removeSend", "need `send` (0-based)"))? as usize; + let ok = s.edit_sequence("Remove Send", move |q, _, _| Ok(q.with_strip_mut(id, |t| t.remove_send(i)).unwrap_or(false)))?; + if !ok { + return Err(bad("mixer.removeSend", "no such send")); + } + Ok(Value::Null) +} + +fn set_kf(s: &mut Session, p: &Value) -> Result { + let id = strip_p(s, p, "mixer.setKeyframe")?; + let lane = lane_p(s, p, id, "mixer.setKeyframe")?; + let t = time_p(s, p, "").unwrap_or(s.playhead()); + let v = value_p(p, &lane, "mixer.setKeyframe")?; + let hold = lane_info(&lane).is_some_and(|i| i.hold); + s.edit_sequence("Add Track Keyframe", move |q, _, _| { + q.with_strip_mut(id, |tr| { + if let Some(par) = tr.lane_mut(&lane) { + if par.keyframes.is_empty() { + // the first keyframe keeps the current value everywhere else + let cur = par.value.as_f64().unwrap_or(v); + par.value = ParamValue::Float(cur); + } + set_keyframe(par, t, v, hold); + } + }) + .ok_or_else(|| EngineError::Other("no such strip".into())) + })?; + Ok(json!({"time": t.0, "value": v})) +} + +fn delete_kf(s: &mut Session, p: &Value) -> Result { + let id = strip_p(s, p, "mixer.deleteKeyframe")?; + let lane = lane_p(s, p, id, "mixer.deleteKeyframe")?; + let t = time_p(s, p, "").ok_or_else(|| bad("mixer.deleteKeyframe", "need `time`"))?; + let ok = s.edit_sequence("Delete Track Keyframe", move |q, _, _| { + Ok(q.with_strip_mut(id, |tr| tr.lane_mut(&lane).is_some_and(|par| par.remove_keyframe_at(t))).unwrap_or(false)) + })?; + if !ok { + return Err(bad("mixer.deleteKeyframe", "no keyframe at that time")); + } + Ok(Value::Null) +} + +fn move_kf(s: &mut Session, p: &Value) -> Result { + let id = strip_p(s, p, "mixer.moveKeyframe")?; + let lane = lane_p(s, p, id, "mixer.moveKeyframe")?; + let t = time_p(s, p, "").ok_or_else(|| bad("mixer.moveKeyframe", "need `time` (the keyframe)"))?; + let nt = time_p(s, p, "new"); + let nv = p.get("value").map(|_| value_p(p, &lane, "mixer.moveKeyframe")).transpose()?; + let ok = s.edit_sequence("Move Track Keyframe", move |q, _, _| { + Ok(q.with_strip_mut(id, |tr| { + let Some(par) = tr.lane_mut(&lane) else { return false }; + let Ok(i) = par.keyframes.binary_search_by_key(&t, |k| k.time) else { return false }; + let mut k = par.keyframes.remove(i); + if let Some(nt) = nt { + // keep keyframe order: clamp between the neighbours + let lo = i.checked_sub(1).map(|j| par.keyframes[j].time + Tick(1)).unwrap_or(Tick::ZERO); + let hi = par.keyframes.get(i).map(|n| n.time - Tick(1)).unwrap_or(Tick(i64::MAX)); + k.time = nt.clamp(lo, hi); + } + if let Some(v) = nv { + k.value = ParamValue::Float(v); + } + let j = par.keyframes.partition_point(|x| x.time < k.time); + par.keyframes.insert(j, k); + true + }) + .unwrap_or(false)) + })?; + if !ok { + return Err(bad("mixer.moveKeyframe", "no keyframe at that time")); + } + Ok(Value::Null) +} + +fn clear_lane(s: &mut Session, p: &Value) -> Result { + let id = strip_p(s, p, "mixer.clearLane")?; + let lane = lane_p(s, p, id, "mixer.clearLane")?; + let t = s.playhead(); + s.edit_sequence("Clear Track Keyframes", move |q, _, _| { + q.with_strip_mut(id, |tr| { + let v = tr.lane_value(&lane, t); + if let Some(par) = tr.lane_mut(&lane) { + par.keyframes.clear(); + } + tr.set_lane_static(&lane, v); + if parse_fx_lane(&lane).is_none() { + tr.mixer.lanes.remove(&lane); + } + }) + .ok_or_else(|| EngineError::Other("no such strip".into())) + })?; + Ok(Value::Null) +} + +fn write_automation(s: &mut Session, p: &Value) -> Result { + let id = strip_p(s, p, "mixer.writeAutomation")?; + let lane = lane_p(s, p, id, "mixer.writeAutomation")?; + let pts: Vec<(Tick, f64)> = p + .get("points") + .and_then(Value::as_array) + .ok_or_else(|| bad("mixer.writeAutomation", "need `points`: [[ticks, value], …]"))? + .iter() + .filter_map(|v| Some((Tick(v.get(0)?.as_i64()?), clamp_lane(&lane, v.get(1)?.as_f64()?)))) + .collect(); + if pts.is_empty() { + return Err(bad("mixer.writeAutomation", "no points")); + } + let hold = lane_info(&lane).is_some_and(|i| i.hold); + let tol = f64_p(p, "tolerance").unwrap_or(if hold { 0.0 } else { 0.05 }); + let thinned = thin_points(&pts, tol, Tick::ZERO, hold); + let (t0, t1) = (thinned[0].0, thinned[thinned.len() - 1].0); + let rate = active_seq(s)?.settings.sample_rate.max(1) as i64; + let eps = Tick::from_units(1, rate).max(Tick(1)); + let n = thinned.len(); + s.edit_sequence("Write Automation", move |q, _, _| { + q.with_strip_mut(id, |tr| { + let before = (t0.0 > 0).then(|| underlying(tr, &lane, t0 - eps)); + let after = tr.lane(&lane).filter(|p| p.keyframes.iter().any(|k| k.time > t1)).map(|_| underlying(tr, &lane, t1 + eps)); + if let Some(par) = tr.lane_mut(&lane) { + write_lane(par, t0, t1, &thinned, before, after, eps, hold); + } + }) + .ok_or_else(|| EngineError::Other("no such strip".into())) + })?; + Ok(json!({"keyframes": n, "points": pts.len()})) +} + +fn not_recording(s: &Session) -> std::result::Result<(), String> { + has_seq(s)?; + if s.mixrec.active() { Err("an automation pass is already recording".into()) } else { Ok(()) } +} + +fn spec( + id: &'static str, + label: &'static str, + menu: &'static [&'static str], + shortcut: Option<&'static str>, + params: &'static str, + enabled: Enabled, + run: Run, + journal: bool, +) -> CommandSpec { + CommandSpec { id, label, menu, shortcut, params, enabled, run, journal } +} + +pub fn commands() -> Vec { + vec![ + spec("mixer.inspect", "Inspect Audio Track Mixer", &[], None, r#"{"time":ticks?}"#, always, inspect, false), + spec( + "mixer.setStrip", + "Track Mixer Settings", + &[], + None, + r#"{"strip":"A1"|"S1"|"Mix"|id,"name":str?,"volumeDb":f64?,"pan":f64?,"muted":bool?,"solo":bool?,"recordArm":bool?,"soloSafe":bool?,"mode":"Off|Read|Latch|Touch|Write"?,"output":"Mix"|"S1"?,"inputMap":"Stereo|Left|Right|Swap|Mono"?,"channels":"Mono|Stereo|5.1"?}"#, + has_seq, + set_strip, + true, + ), + spec( + "mixer.setValue", + "Set Mixer Control", + &[], + None, + r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":"volume|pan|mute|send..level|fx..","value":f64,"time":ticks?}"#, + has_seq, + |s, p| { + let id = strip_p(s, p, "mixer.setValue")?; + let lane = lane_p(s, p, id, "mixer.setValue")?; + let v = value_p(p, &lane, "mixer.setValue")?; + let t = time_p(s, p, "").unwrap_or(s.playhead()); + if s.mixrec.active() { + touch(s, id, &lane, v, t)?; + return release(s, id, &lane, t); + } + set_value(s, id, &lane, v, t)?; + Ok(json!({"value": v})) + }, + true, + ), + spec( + "mixer.touch", + "Touch Mixer Control", + &[], + None, + r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":"volume|pan|mute|send..level|fx..","value":f64,"time":ticks?}"#, + has_seq, + |s, p| { + let id = strip_p(s, p, "mixer.touch")?; + let lane = lane_p(s, p, id, "mixer.touch")?; + let v = value_p(p, &lane, "mixer.touch")?; + let t = time_p(s, p, "").unwrap_or(s.playhead()); + touch(s, id, &lane, v, t) + }, + false, + ), + spec( + "mixer.release", + "Release Mixer Control", + &[], + None, + r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":str?,"time":ticks?}"#, + has_seq, + |s, p| { + let id = strip_p(s, p, "mixer.release")?; + let lane = lane_p(s, p, id, "mixer.release")?; + let t = time_p(s, p, "").unwrap_or(s.playhead()); + release(s, id, &lane, t) + }, + true, + ), + spec( + "mixer.recordStart", + "Start Automation Pass", + &[], + None, + r#"{"time":ticks?}"#, + not_recording, + |s, p| { + let t = time_p(s, p, "").unwrap_or(s.playhead()); + record_start(s, t) + }, + false, + ), + spec( + "mixer.recordStop", + "Write Automation", + &[], + None, + r#"{"time":ticks?}"#, + has_seq, + |s, p| { + let t = time_p(s, p, "").unwrap_or(s.playhead()); + record_stop(s, t) + }, + true, + ), + spec("mixer.addSubmix", "Add Audio Submix Track", &["Sequence"], None, r#"{"name":str?,"channels":"Mono|Stereo|5.1"?}"#, has_seq, add_submix, true), + spec("mixer.deleteSubmix", "Delete Submix Track", &[], None, r#"{"strip":"S1"|id}"#, has_seq, delete_submix, true), + spec( + "mixer.addInsert", + "Add Track Effect", + &[], + None, + r#"{"strip":"A1"|"S1"|"Mix"|id,"effect":str,"slot":n?,"postFader":bool?}"#, + has_seq, + add_insert, + true, + ), + spec("mixer.removeInsert", "Remove Track Effect", &[], None, r#"{"strip":"A1"|"S1"|"Mix"|id,"slot":n}"#, has_seq, remove_insert, true), + spec( + "mixer.setInsert", + "Track Effect Settings", + &[], + None, + r#"{"strip":"A1"|"S1"|"Mix"|id,"slot":n,"enabled":bool?,"postFader":bool?,"params":{id:value}?}"#, + has_seq, + set_insert, + true, + ), + spec( + "mixer.addSend", + "Add Send", + &[], + None, + r#"{"strip":"A1"|"S1"|id,"target":"S1"|id,"levelDb":f64?,"preFader":bool?,"pan":f64?}"#, + has_seq, + add_send, + true, + ), + spec( + "mixer.setSend", + "Send Settings", + &[], + None, + r#"{"strip":"A1"|"S1"|id,"send":n,"levelDb":f64?,"pan":f64?,"preFader":bool?,"muted":bool?,"target":"S1"?}"#, + has_seq, + set_send, + true, + ), + spec("mixer.removeSend", "Remove Send", &[], None, r#"{"strip":"A1"|"S1"|id,"send":n}"#, has_seq, remove_send, true), + spec( + "mixer.setKeyframe", + "Add Track Keyframe", + &[], + None, + r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":str?,"time":ticks?,"value":f64}"#, + has_seq, + set_kf, + true, + ), + spec("mixer.deleteKeyframe", "Delete Track Keyframe", &[], None, r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":str?,"time":ticks}"#, has_seq, delete_kf, true), + spec( + "mixer.moveKeyframe", + "Move Track Keyframe", + &[], + None, + r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":str?,"time":ticks,"newTime":ticks?,"value":f64?}"#, + has_seq, + move_kf, + true, + ), + spec("mixer.clearLane", "Clear Track Keyframes", &[], None, r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":str?}"#, has_seq, clear_lane, true), + spec( + "mixer.writeAutomation", + "Write Automation Points", + &[], + None, + r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":str?,"points":[[ticks,value]],"tolerance":f64?}"#, + has_seq, + write_automation, + true, + ), + ] +} diff --git a/crates/engine/src/mixer_tests.rs b/crates/engine/src/mixer_tests.rs new file mode 100644 index 000000000..24c44d80c --- /dev/null +++ b/crates/engine/src/mixer_tests.rs @@ -0,0 +1,282 @@ +//! Audio Track Mixer commands and automation recording. + +use super::*; +use filmcraft_project::mixer::{LANE_VOLUME, MASTER_STRIP}; +use filmcraft_project::{AutomationMode, ParamValue}; +use filmcraft_time::TICKS_PER_SECOND; +use serde_json::json; + +fn demo() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s +} + +fn sec(x: f64) -> i64 { + (x * TICKS_PER_SECOND as f64) as i64 +} + +fn a1(s: &Session) -> &filmcraft_project::Track { + &s.active_sequence().unwrap().audio_tracks[0] +} + +fn vol_at(s: &Session, x: f64) -> f64 { + a1(s).lane_value(LANE_VOLUME, Tick(sec(x))) +} + +fn set_mode(s: &mut Session, strip: &str, mode: &str) { + s.execute("mixer.setStrip", json!({"strip": strip, "mode": mode})).unwrap(); +} + +#[test] +fn every_mixer_command_is_registered_with_a_label() { + for id in [ + "mixer.inspect", + "mixer.setStrip", + "mixer.setValue", + "mixer.touch", + "mixer.release", + "mixer.recordStart", + "mixer.recordStop", + "mixer.addSubmix", + "mixer.deleteSubmix", + "mixer.addInsert", + "mixer.removeInsert", + "mixer.setInsert", + "mixer.addSend", + "mixer.setSend", + "mixer.removeSend", + "mixer.setKeyframe", + "mixer.deleteKeyframe", + "mixer.moveKeyframe", + "mixer.clearLane", + "mixer.writeAutomation", + ] { + let c = commands::find(id).unwrap_or_else(|| panic!("{id} missing")); + assert!(!c.label.is_empty() && !c.params.is_empty()); + } + let s = Session::default(); + assert!(s.clone_disabled("mixer.setStrip"), "needs a sequence"); +} + +impl Session { + fn clone_disabled(&self, id: &str) -> bool { + !self.is_enabled(id) + } +} + +#[test] +fn strip_settings_undo_redo_and_inspect() { + let mut s = demo(); + s.execute("mixer.setStrip", json!({"strip": "A1", "volumeDb": -6.0, "pan": -30.0, "mode": "Touch", "recordArm": true, "solo": true})).unwrap(); + let t = a1(&s); + assert_eq!((t.volume_db, t.pan, t.mixer.mode, t.mixer.record_arm, t.solo), (-6.0, -30.0, AutomationMode::Touch, true, true)); + s.execute("mixer.setStrip", json!({"strip": "Mix", "volumeDb": -3.0})).unwrap(); + assert_eq!(s.active_sequence().unwrap().master_volume_db, -3.0); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(a1(&s).volume_db, 0.0); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(a1(&s).volume_db, -6.0); + let v = s.execute("mixer.inspect", json!({})).unwrap(); + let strips = v["strips"].as_array().unwrap(); + assert_eq!(strips.last().unwrap()["ref"], "Mix"); + assert_eq!(strips[0]["ref"], "A1"); + assert_eq!(strips[0]["mode"], "Touch"); + // errors + assert!(s.execute("mixer.setStrip", json!({"strip": "A99"})).is_err()); + assert!(s.execute("mixer.setValue", json!({"strip": "Mix", "lane": "pan", "value": 3})).is_err(), "Mix has no pan"); +} + +#[test] +fn submixes_sends_and_inserts() { + let mut s = demo(); + let r = s.execute("mixer.addSubmix", json!({"name": "Reverb"})).unwrap(); + assert_eq!(r["ref"], "S1"); + s.execute("mixer.addSubmix", json!({})).unwrap(); + s.execute("mixer.addSend", json!({"strip": "A1", "target": "S1", "levelDb": -6.0, "preFader": true})).unwrap(); + s.execute("mixer.addInsert", json!({"strip": "S1", "effect": "studio_reverb"})).unwrap(); + s.execute("mixer.addInsert", json!({"strip": "A1", "effect": "dynamics", "postFader": true})).unwrap(); + s.execute("mixer.setInsert", json!({"strip": "A1", "slot": 0, "params": {"threshold": -30.0}})).unwrap(); + s.execute("mixer.setStrip", json!({"strip": "S1", "output": "S2"})).unwrap(); + assert!(s.execute("mixer.setStrip", json!({"strip": "S2", "output": "S1"})).is_err(), "no feedback"); + assert!(s.execute("mixer.addSend", json!({"strip": "S2", "target": "S1"})).is_err(), "no feedback"); + assert!(s.execute("mixer.addInsert", json!({"strip": "A1", "effect": "gaussian_blur"})).is_err()); + let q = s.active_sequence().unwrap(); + assert_eq!(q.audio_tracks[0].mixer.sends[0].level_db, -6.0); + assert!(q.audio_tracks[0].effects[0].post_fader); + assert_eq!(q.audio_tracks[0].effects[0].param("threshold").unwrap().value, ParamValue::Float(-30.0)); + // the mix renders through the graph (the demo has audio on A1) + let provider = s.media.provider(s.project.clone(), s.services.clone()); + let b = filmcraft_render::audio::mix_sequence(&s.project, q, 0, 4_800, &provider); + assert_eq!(b.frames(), 4_800); + // deleting a submix re-routes and drops sends + let s1 = q.submix_tracks[0].id.0; + s.execute("mixer.deleteSubmix", json!({"strip": s1})).unwrap(); + let q = s.active_sequence().unwrap(); + assert!(q.audio_tracks[0].mixer.sends.is_empty()); + assert_eq!(q.submix_tracks.len(), 1); + s.execute("mixer.removeInsert", json!({"strip": "A1", "slot": 0})).unwrap(); + assert!(a1(&s).effects.is_empty()); +} + +#[test] +fn keyframes_by_command_and_pen_edits() { + let mut s = demo(); + s.execute("mixer.setKeyframe", json!({"strip": "A1", "time": sec(1.0), "value": -10.0})).unwrap(); + s.execute("mixer.setKeyframe", json!({"strip": "A1", "time": sec(2.0), "value": 0.0})).unwrap(); + assert_eq!(vol_at(&s, 0.0), -10.0); + assert!((vol_at(&s, 1.5) + 5.0).abs() < 1e-9); + s.execute("mixer.moveKeyframe", json!({"strip": "A1", "time": sec(2.0), "newTime": sec(3.0), "value": -20.0})).unwrap(); + assert!((vol_at(&s, 2.0) + 15.0).abs() < 1e-9); + s.execute("mixer.deleteKeyframe", json!({"strip": "A1", "time": sec(1.0)})).unwrap(); + assert_eq!(vol_at(&s, 0.0), -20.0); + // a fader move on an automated lane (Read, stopped) adds a keyframe at the playhead + s.execute("playhead.set", json!({"seconds": 5.0})).unwrap(); + s.execute("mixer.setValue", json!({"strip": "A1", "value": -3.0})).unwrap(); + assert_eq!(vol_at(&s, 5.0), -3.0); + assert_eq!(a1(&s).lane_keyframes(LANE_VOLUME).len(), 2); + // mute keyframes hold + s.execute("mixer.setKeyframe", json!({"strip": "A1", "lane": "mute", "time": sec(1.0), "value": true})).unwrap(); + s.execute("mixer.setKeyframe", json!({"strip": "A1", "lane": "mute", "time": sec(2.0), "value": false})).unwrap(); + assert_eq!(a1(&s).lane_value("mute", Tick(sec(1.9))), 1.0); + s.execute("mixer.clearLane", json!({"strip": "A1", "lane": "volume"})).unwrap(); + assert!(a1(&s).lane_keyframes(LANE_VOLUME).is_empty()); + // effect parameter lanes + s.execute("mixer.addInsert", json!({"strip": "A1", "effect": "amplify"})).unwrap(); + s.execute("mixer.setKeyframe", json!({"strip": "A1", "lane": "fx.0.gain", "time": sec(1.0), "value": 6.0})).unwrap(); + assert!(a1(&s).automated_lanes().contains(&"fx.0.gain".to_string())); +} + +/// Simulate a fader gesture: `(seconds, dB)` samples as the UI would send them each frame. +fn gesture(s: &mut Session, strip: &str, pts: &[(f64, f64)]) { + for &(t, v) in pts { + s.execute("mixer.touch", json!({"strip": strip, "lane": "volume", "value": v, "time": sec(t)})).unwrap(); + } +} + +#[test] +fn touch_records_while_held_and_returns_over_automatch() { + let mut s = demo(); + set_mode(&mut s, "A1", "Touch"); + s.execute("mixer.recordStart", json!({"time": 0})).unwrap(); + assert!(s.execute("mixer.recordStart", json!({})).is_err(), "already recording"); + // hold -10 dB from 1 s to 2 s (frames every 1/30 s) + let pts: Vec<(f64, f64)> = (0..=30).map(|i| (1.0 + i as f64 / 30.0, -10.0)).collect(); + gesture(&mut s, "A1", &pts); + // the audio follows the fader while recording + assert_eq!(s.previews.live.get(a1(&s).id, LANE_VOLUME).map(|o| o.value), Some(-10.0)); + s.execute("mixer.release", json!({"strip": "A1", "time": sec(2.0)})).unwrap(); + let r = s.execute("mixer.recordStop", json!({"time": sec(5.0)})).unwrap(); + assert_eq!(r["lanes"], 1); + assert!(!s.previews.live.is_active()); + assert_eq!(vol_at(&s, 0.5), 0.0, "before the touch: unchanged"); + assert_eq!(vol_at(&s, 1.5), -10.0); + assert!((vol_at(&s, 2.5) + 5.0).abs() < 0.01, "halfway back after 0.5 s of the 1 s automatch: {}", vol_at(&s, 2.5)); + assert_eq!(vol_at(&s, 3.5), 0.0, "back to the old automation"); + // thinned: a held value is a few keyframes, not 31 + assert!(a1(&s).lane_keyframes(LANE_VOLUME).len() <= 5, "{:?}", a1(&s).lane_keyframes(LANE_VOLUME)); + // one undo step removes the pass + s.execute("edit.undo", json!({})).unwrap(); + assert!(a1(&s).lane_keyframes(LANE_VOLUME).is_empty()); +} + +#[test] +fn touch_over_existing_automation_and_custom_automatch() { + let mut s = demo(); + s.execute("prefs.set", json!({"key": "audio.automatchTime", "value": 2.0})).unwrap(); + s.execute("mixer.writeAutomation", json!({"strip": "A1", "points": [[0, -20.0], [sec(8.0), -20.0]]})).unwrap(); + set_mode(&mut s, "A1", "Touch"); + s.execute("mixer.recordStart", json!({"time": sec(0.5)})).unwrap(); + gesture(&mut s, "A1", &[(1.0, 0.0), (1.5, 0.0)]); + s.execute("mixer.release", json!({"strip": "A1", "time": sec(1.5)})).unwrap(); + s.execute("mixer.recordStop", json!({"time": sec(6.0)})).unwrap(); + assert_eq!(vol_at(&s, 0.9), -20.0); + assert_eq!(vol_at(&s, 1.25), 0.0); + assert!((vol_at(&s, 2.5) + 10.0).abs() < 0.05, "{}", vol_at(&s, 2.5)); + assert_eq!(vol_at(&s, 4.0), -20.0); + assert_eq!(vol_at(&s, 7.0), -20.0); +} + +#[test] +fn latch_holds_until_stop() { + let mut s = demo(); + s.execute("mixer.writeAutomation", json!({"strip": "A1", "points": [[0, 0.0], [sec(8.0), 0.0]]})).unwrap(); + set_mode(&mut s, "A1", "Latch"); + s.execute("mixer.recordStart", json!({"time": 0})).unwrap(); + gesture(&mut s, "A1", &[(1.0, -4.0), (1.5, -8.0), (2.0, -12.0)]); + s.execute("mixer.release", json!({"strip": "A1", "time": sec(2.0)})).unwrap(); + s.execute("mixer.recordStop", json!({"time": sec(4.0)})).unwrap(); + assert_eq!(vol_at(&s, 0.5), 0.0); + assert!((vol_at(&s, 1.25) + 6.0).abs() < 1e-6); + assert_eq!(vol_at(&s, 3.0), -12.0, "latched"); + assert_eq!(vol_at(&s, 3.99), -12.0); + assert!(vol_at(&s, 4.5) > -12.0, "existing automation after the stop resumes"); +} + +#[test] +fn write_overwrites_from_start_and_switches_to_touch() { + let mut s = demo(); + s.execute("mixer.writeAutomation", json!({"strip": "A1", "points": [[0, -30.0], [sec(8.0), 0.0]]})).unwrap(); + s.execute("mixer.setStrip", json!({"strip": "A1", "mode": "Write"})).unwrap(); + s.execute("mixer.recordStart", json!({"time": sec(2.0)})).unwrap(); + let start_val = -30.0 + 30.0 * 2.0 / 8.0; + // untouched until 3 s: the fader's value at the start is written + gesture(&mut s, "A1", &[(3.0, -6.0), (3.5, -6.0)]); + s.execute("mixer.release", json!({"strip": "A1", "time": sec(3.5)})).unwrap(); + s.execute("mixer.recordStop", json!({"time": sec(5.0)})).unwrap(); + assert!((vol_at(&s, 1.0) - (-30.0 + 30.0 / 8.0)).abs() < 0.05, "before the pass: unchanged"); + assert!((vol_at(&s, 2.5) - start_val).abs() < 0.05, "{}", vol_at(&s, 2.5)); + assert_eq!(vol_at(&s, 4.0), -6.0); + assert_eq!(vol_at(&s, 4.9), -6.0, "Write holds the last value to the stop"); + assert_eq!(a1(&s).mixer.mode, AutomationMode::Touch, "Switch to Touch after Write"); + // pan and mute are written too + assert!(a1(&s).lane_keyframes("pan").len() >= 2 && a1(&s).lane_keyframes("mute").len() >= 2); + // the preference can keep Write + s.execute("prefs.set", json!({"key": "audio.switchToTouchAfterWrite", "value": false})).unwrap(); + s.execute("mixer.setStrip", json!({"strip": "A1", "mode": "Write"})).unwrap(); + s.execute("mixer.recordStart", json!({"time": sec(6.0)})).unwrap(); + s.execute("mixer.recordStop", json!({"time": sec(7.0)})).unwrap(); + assert_eq!(a1(&s).mixer.mode, AutomationMode::Write); +} + +#[test] +fn read_mode_does_not_record_and_commits_once_without_a_pass() { + let mut s = demo(); + s.execute("mixer.recordStart", json!({"time": 0})).unwrap(); + gesture(&mut s, "A1", &[(1.0, -6.0), (2.0, -9.0)]); + s.execute("mixer.release", json!({"strip": "A1", "time": sec(2.0)})).unwrap(); + let r = s.execute("mixer.recordStop", json!({"time": sec(3.0)})).unwrap(); + assert_eq!(r["lanes"], 0); + assert!(a1(&s).lane_keyframes(LANE_VOLUME).is_empty()); + // stopped: a drag is heard live, then committed as one undo step + let undo0 = s.history.undo.len(); + gesture(&mut s, "A1", &[(0.0, -2.0), (0.0, -4.0), (0.0, -5.0)]); + assert_eq!(a1(&s).volume_db, 0.0); + s.execute("mixer.release", json!({"strip": "A1"})).unwrap(); + assert_eq!(a1(&s).volume_db, -5.0); + assert_eq!(s.history.undo.len(), undo0 + 1); +} + +#[test] +fn recorded_pass_plays_back_through_the_mix() { + let mut s = demo(); + // only A1 (the demo has music on another track) + s.execute("mixer.setStrip", json!({"strip": "A1", "solo": true})).unwrap(); + set_mode(&mut s, "A1", "Latch"); + s.execute("mixer.recordStart", json!({"time": 0})).unwrap(); + gesture(&mut s, "A1", &[(0.5, -96.0)]); + s.execute("mixer.release", json!({"strip": "A1", "time": sec(0.5)})).unwrap(); + s.execute("mixer.recordStop", json!({"time": sec(2.0)})).unwrap(); + let q = s.active_sequence().unwrap(); + let seq_id = s.state.active_sequence.unwrap(); + let provider = s.media.provider(s.project.clone(), s.services.clone()); + let b = s.previews.mix(&s.project, seq_id, 48_000, 4_800, &provider); + assert!(b.channels[0].iter().all(|x| x.abs() < 1e-6), "faded out by the recorded automation"); + let before = filmcraft_render::audio::mix_sequence(&s.project, q, 0, 4_800, &provider); + assert!(before.channels[0].iter().any(|x| x.abs() > 1e-4), "audible before the move"); + // master strip automation + s.execute("mixer.setKeyframe", json!({"strip": "Mix", "time": 0, "value": -6.0})).unwrap(); + assert_eq!(s.active_sequence().unwrap().master_volume_at(Tick(sec(1.0))), -6.0); + assert!(s.active_sequence().unwrap().strip(MASTER_STRIP).is_some()); +} diff --git a/crates/engine/src/previews.rs b/crates/engine/src/previews.rs index c031ea1b2..c49db99c0 100644 --- a/crates/engine/src/previews.rs +++ b/crates/engine/src/previews.rs @@ -79,6 +79,8 @@ pub struct PreviewStore { audio_memo: Mutex>, /// Bumped whenever the set of preview files changes. pub generation: AtomicU64, + /// Live mixer state shared with playback: held controls, meters, newest project snapshot. + pub live: Arc, } fn video_name(hash: &str) -> String { @@ -244,9 +246,11 @@ impl PreviewStore { /// live mix ([`filmcraft_render::audio::mix_sequence`]) everywhere else. pub fn mix(&self, project: &Arc, seq: ItemId, start: i64, frames: usize, sources: &dyn filmcraft_render::SourceProvider) -> AudioBuffer { let Some(q) = project.sequence(seq) else { return AudioBuffer::silence(48_000, 2, frames) }; + let live = Some(&*self.live); let has_audio = self.files.read().unwrap_or_else(|e| e.into_inner()).iter().any(|n| n.ends_with(".wav")); - if !has_audio { - return filmcraft_render::audio::mix_sequence(project, q, start, frames, sources); + // held mixer controls are heard live (rendered previews don't know them) + if !has_audio || self.live.is_active() { + return filmcraft_render::mixer::mix_graph(project, q, start, frames, sources, live); } let segs = self.audio_segments(project, seq); let mut out = AudioBuffer::silence(q.settings.sample_rate, 2, frames); @@ -271,7 +275,7 @@ impl PreviewStore { } } _ => { - let b = filmcraft_render::audio::mix_sequence(project, q, pos, n, sources); + let b = filmcraft_render::mixer::mix_graph(project, q, pos, n, sources, live); for c in 0..2 { out.channels[c][off..off + n].copy_from_slice(&b.channels[c.min(b.channels.len() - 1)][..n]); } diff --git a/crates/interchange/src/common.rs b/crates/interchange/src/common.rs index a8b63526d..d2adb920c 100644 --- a/crates/interchange/src/common.rs +++ b/crates/interchange/src/common.rs @@ -469,6 +469,8 @@ pub(crate) fn empty_sequence(settings: SequenceSettings) -> Sequence { start_timecode: 0, master_volume_db: 0.0, master_effects: Vec::new(), + master_mixer: Default::default(), + submix_tracks: Vec::new(), caption_tracks: Vec::new(), } } @@ -594,6 +596,7 @@ pub(crate) fn transition_effect(name: &str, audio: bool, report: &mut Report) -> enabled: true, params: Default::default(), masks: vec![], + post_fader: false, }) } diff --git a/crates/project/src/effect.rs b/crates/project/src/effect.rs index 80f69e005..201ee52e8 100644 --- a/crates/project/src/effect.rs +++ b/crates/project/src/effect.rs @@ -85,6 +85,7 @@ impl EffectDef { enabled: true, params: self.params.iter().map(|p| (p.id.to_string(), Param::new(p.default.clone()))).collect(), masks: Vec::new(), + post_fader: false, } } } @@ -113,6 +114,9 @@ pub struct EffectInstance { pub params: BTreeMap, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub masks: Vec, + /// Track/Mix inserts only: process after the fader instead of before it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub post_fader: bool, } impl EffectInstance { diff --git a/crates/project/src/keyframe.rs b/crates/project/src/keyframe.rs index 3e1029f00..693e79262 100644 --- a/crates/project/src/keyframe.rs +++ b/crates/project/src/keyframe.rs @@ -186,6 +186,11 @@ impl Param { self.set_at_force(t, v); } + /// Add or replace a keyframe at `t` (the parameter becomes animated). + pub fn put_keyframe(&mut self, t: Tick, value: ParamValue) { + self.set_at_force(t, value); + } + fn set_at_force(&mut self, t: Tick, value: ParamValue) { match self.keyframes.binary_search_by_key(&t, |k| k.time) { Ok(i) => self.keyframes[i].value = value, @@ -238,6 +243,35 @@ impl Param { pub fn f64_at(&self, t: Tick) -> f64 { self.value_at(t).as_f64().unwrap_or(0.0) } + + /// Allocation-free [`Param::f64_at`] for scalar parameters (Float / Choice / Bool), for + /// per-sample evaluation in the audio mixer. Non-scalar values evaluate to 0. + pub fn scalar_at(&self, t: Tick) -> f64 { + let k = &self.keyframes; + let s = |v: &ParamValue| v.as_f64().unwrap_or(0.0); + if k.is_empty() { + return s(&self.value); + } + if t <= k[0].time { + return s(&k[0].value); + } + let last = &k[k.len() - 1]; + if t >= last.time { + return s(&last.value); + } + let i = match k.binary_search_by_key(&t, |kf| kf.time) { + Ok(i) => return s(&k[i].value), + Err(i) => i - 1, + }; + let (a, b) = (&k[i], &k[i + 1]); + let (va, vb) = (s(&a.value), s(&b.value)); + if a.interp == Interpolation::Hold || !matches!(a.value, ParamValue::Float(_)) { + return va; + } + let u = (t - a.time).0 as f64 / (b.time - a.time).0 as f64; + va + (vb - va) * ease(a, b, u) + } + pub fn vec2_at(&self, t: Tick) -> Vec2 { self.value_at(t).as_vec2().unwrap_or_default() } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 20d7ac5e8..1c13277fc 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -10,6 +10,7 @@ pub mod caption; pub mod effect; pub mod keyframe; +pub mod mixer; use std::collections::BTreeMap; @@ -21,6 +22,7 @@ use serde::{Deserialize, Serialize}; pub use caption::{Caption, CaptionAlign, CaptionAnchor, CaptionFormat, CaptionStyle, CaptionTrack, plain_text}; pub use effect::{EffectDef, EffectInstance, EffectKind, ParamDef, ParamKind, effect_defs, find_effect}; pub use keyframe::{Interpolation, Keyframe, Param, ParamValue}; +pub use mixer::{AutomationMode, InputMap, MixerStrip, TrackSend}; macro_rules! id_type { ($name:ident) => { @@ -528,9 +530,12 @@ pub struct Track { /// Audio track volume (dB) and pan (-100..100) for the Track Mixer. pub volume_db: f64, pub pan: f64, - /// Track-level audio effects (mixer inserts). + /// Track-level audio effects (mixer inserts, slots 1–5; `EffectInstance::post_fader` picks the side). #[serde(default)] pub effects: Vec, + /// Audio Track Mixer state: automation mode and lanes, sends, output, record arm. + #[serde(default)] + pub mixer: MixerStrip, } impl Track { @@ -550,6 +555,7 @@ impl Track { volume_db: 0.0, pan: 0.0, effects: Vec::new(), + mixer: MixerStrip::default(), } } pub fn end(&self) -> Tick { @@ -640,8 +646,16 @@ pub struct Sequence { /// Master audio volume (dB). #[serde(default)] pub master_volume_db: f64, + /// Mix track inserts (`EffectInstance::post_fader` picks the side). #[serde(default)] pub master_effects: Vec, + /// Mix track automation (`volume` lane) and mode. + #[serde(default)] + pub master_mixer: MixerStrip, + /// Audio submix tracks (no clips); tracks and sends route into them, they route to the Mix + /// or to a submix after them. + #[serde(default)] + pub submix_tracks: Vec, /// Caption tracks (drawn above the video tracks; first = top). Older files have none (serde default). #[serde(default)] pub caption_tracks: Vec, @@ -826,6 +840,8 @@ impl Project { start_timecode: 0, master_volume_db: 0.0, master_effects: Vec::new(), + master_mixer: MixerStrip::default(), + submix_tracks: Vec::new(), caption_tracks: Vec::new(), }; for i in 0..v { diff --git a/crates/project/src/mixer.rs b/crates/project/src/mixer.rs new file mode 100644 index 000000000..14388a4b0 --- /dev/null +++ b/crates/project/src/mixer.rs @@ -0,0 +1,548 @@ +//! Audio Track Mixer model: automation modes and lanes, sends, submix routing, channel mapping. +//! +//! Every audio track, every submix track ([`crate::Sequence::submix_tracks`]) and the Mix (master) +//! track has a [`MixerStrip`]. Static values stay where they always were (`Track::volume_db`, +//! `Track::pan`, `Track::muted`, `TrackSend::level_db`, effect parameter values); automation is +//! keyframes in **sequence time** on top of them: +//! +//! | lane key | value | stored in | +//! |---|---|---| +//! | `volume` | fader level, dB | `MixerStrip::lanes` | +//! | `pan` | pan / balance, −100 … 100 | `MixerStrip::lanes` | +//! | `mute` | 0 / 1 (hold) | `MixerStrip::lanes` | +//! | `send..level` | send `i` level, dB | `MixerStrip::lanes` | +//! | `fx..` | insert effect parameter | the effect's own `Param` keyframes | +//! +//! [`AutomationMode`] decides whether playback reads the lanes (anything but Off) and how the +//! mixer writes them while recording (Latch / Touch / Write; see the engine's recorder). + +use std::collections::BTreeMap; + +use filmcraft_time::Tick; +use serde::{Deserialize, Serialize}; + +use crate::keyframe::{Interpolation, Keyframe, Param, ParamValue}; +use crate::{Track, TrackId}; + +pub const LANE_VOLUME: &str = "volume"; +pub const LANE_PAN: &str = "pan"; +pub const LANE_MUTE: &str = "mute"; + +/// At most this many insert effects per strip (Premiere's effect slots 1–5). +pub const MAX_INSERTS: usize = 5; +/// At most this many sends per strip (Premiere's send slots 1–5). +pub const MAX_SENDS: usize = 5; +/// Fader range (dB): the Track Mixer's scale runs from −∞ to +15 dB (Clip Mixer and Mix too). +pub const FADER_MAX_DB: f64 = 15.0; +/// Values at or below this are −∞ (silence). +pub const FADER_MIN_DB: f64 = -96.0; + +pub fn send_lane(i: usize) -> String { + format!("send.{i}.level") +} + +pub fn fx_lane(slot: usize, param: &str) -> String { + format!("fx.{slot}.{param}") +} + +/// Track automation mode (the dropdown above the M/S/R buttons). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AutomationMode { + /// Ignore stored automation; play the static values. + Off, + /// Play automation; touching a control during playback does not record. + #[default] + Read, + /// Record from the first touch; after release keep writing the last value until playback stops. + Latch, + /// Record while touched; after release return to the previous automation over the automatch time. + Touch, + /// Record every automatable control from the start of playback until it stops. + Write, +} + +impl AutomationMode { + pub const ALL: [AutomationMode; 5] = [AutomationMode::Off, AutomationMode::Read, AutomationMode::Latch, AutomationMode::Touch, AutomationMode::Write]; + pub fn label(self) -> &'static str { + match self { + AutomationMode::Off => "Off", + AutomationMode::Read => "Read", + AutomationMode::Latch => "Latch", + AutomationMode::Touch => "Touch", + AutomationMode::Write => "Write", + } + } + pub fn from_name(s: &str) -> Option { + Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) + } + /// Plays stored automation. + pub fn reads(self) -> bool { + self != AutomationMode::Off + } + /// Records automation during playback. + pub fn writes(self) -> bool { + matches!(self, AutomationMode::Latch | AutomationMode::Touch | AutomationMode::Write) + } +} + +/// How the clips' (stereo) audio feeds the track input: basic channel mapping. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum InputMap { + /// L → L, R → R. + #[default] + Stereo, + /// Left channel to both sides. + Left, + /// Right channel to both sides. + Right, + /// L ↔ R. + Swap, + /// (L + R) / 2 to both sides. + Mono, +} + +impl InputMap { + pub const ALL: [InputMap; 5] = [InputMap::Stereo, InputMap::Left, InputMap::Right, InputMap::Swap, InputMap::Mono]; + pub fn label(self) -> &'static str { + match self { + InputMap::Stereo => "Stereo", + InputMap::Left => "Left", + InputMap::Right => "Right", + InputMap::Swap => "Swap", + InputMap::Mono => "Mono", + } + } + pub fn from_name(s: &str) -> Option { + Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) + } +} + +/// A send from a strip to a submix. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TrackSend { + /// Destination submix track. + pub target: TrackId, + /// Send level (dB); automatable as lane `send..level`. + pub level_db: f64, + /// Send pan / balance (−100 … 100). + #[serde(default)] + pub pan: f64, + /// Tap before the fader (after the pre-fader inserts) instead of after the post-fader inserts. + #[serde(default)] + pub pre_fader: bool, + #[serde(default)] + pub muted: bool, +} + +impl TrackSend { + pub fn new(target: TrackId) -> Self { + TrackSend { target, level_db: 0.0, pan: 0.0, pre_fader: false, muted: false } + } +} + +/// Mixer-only state of a channel strip (audio track, submix or Mix). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct MixerStrip { + pub mode: AutomationMode, + /// Automation lanes (`volume`, `pan`, `mute`, `send..level`), keyframes in sequence time. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub lanes: BTreeMap, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub sends: Vec, + /// Output assignment: a submix track, or `None` for the Mix track. + pub output: Option, + /// Record-arm (R): the track records voice-over input. + pub record_arm: bool, + /// Solo safe: never solo-muted, and keeps its sources audible while other strips are soloed. + pub solo_safe: bool, + /// How clip audio feeds the track. + pub input_map: InputMap, +} + +/// Description of an automation lane for display and value ranges. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LaneInfo { + pub min: f64, + pub max: f64, + /// Values hold between keyframes (switches such as mute). + pub hold: bool, + pub unit: &'static str, +} + +/// Range and behaviour of a non-effect lane (`volume`, `pan`, `mute`, `send..level`). +pub fn lane_info(key: &str) -> Option { + match key { + LANE_VOLUME => Some(LaneInfo { min: FADER_MIN_DB, max: FADER_MAX_DB, hold: false, unit: "dB" }), + LANE_PAN => Some(LaneInfo { min: -100.0, max: 100.0, hold: false, unit: "" }), + LANE_MUTE => Some(LaneInfo { min: 0.0, max: 1.0, hold: true, unit: "" }), + k if parse_send_lane(k).is_some() => Some(LaneInfo { min: FADER_MIN_DB, max: FADER_MAX_DB, hold: false, unit: "dB" }), + _ => None, + } +} + +pub fn parse_send_lane(key: &str) -> Option { + key.strip_prefix("send.")?.strip_suffix(".level")?.parse().ok() +} + +pub fn parse_fx_lane(key: &str) -> Option<(usize, &str)> { + let rest = key.strip_prefix("fx.")?; + let (slot, param) = rest.split_once('.')?; + Some((slot.parse().ok()?, param)) +} + +impl Track { + /// Static (un-automated) value of a lane. + pub fn lane_static(&self, key: &str) -> Option { + match key { + LANE_VOLUME => Some(self.volume_db), + LANE_PAN => Some(self.pan), + LANE_MUTE => Some(if self.muted { 1.0 } else { 0.0 }), + k => { + if let Some(i) = parse_send_lane(k) { + return self.mixer.sends.get(i).map(|s| s.level_db); + } + let (slot, p) = parse_fx_lane(k)?; + self.effects.get(slot)?.param(p)?.value.as_f64() + } + } + } + + /// The keyframed parameter behind a lane, if any. + pub fn lane(&self, key: &str) -> Option<&Param> { + match parse_fx_lane(key) { + Some((slot, p)) => self.effects.get(slot)?.param(p), + None => self.mixer.lanes.get(key), + } + } + + /// The parameter behind a lane, created (static, from the current value) if missing. + pub fn lane_mut(&mut self, key: &str) -> Option<&mut Param> { + if let Some((slot, p)) = parse_fx_lane(key) { + return self.effects.get_mut(slot)?.param_mut(p); + } + let stat = self.lane_static(key)?; + lane_info(key)?; + Some(self.mixer.lanes.entry(key.to_string()).or_insert_with(|| Param::new(ParamValue::Float(stat)))) + } + + /// Lane keyframes (empty when the lane is not automated). + pub fn lane_keyframes(&self, key: &str) -> &[Keyframe] { + self.lane(key).map(|p| p.keyframes.as_slice()).unwrap_or(&[]) + } + + /// Value of a lane at sequence time `t` as playback hears it (automation unless the mode is Off). + pub fn lane_value(&self, key: &str, t: Tick) -> f64 { + let stat = self.lane_static(key).unwrap_or(0.0); + if !self.mixer.mode.reads() { + return stat; + } + match self.lane(key) { + Some(p) if p.is_animated() => p.scalar_at(t), + _ => stat, + } + } + + /// All automated lane keys of this strip. + pub fn automated_lanes(&self) -> Vec { + let mut v: Vec = self.mixer.lanes.iter().filter(|(_, p)| p.is_animated()).map(|(k, _)| k.clone()).collect(); + for (slot, e) in self.effects.iter().enumerate() { + for (pid, p) in &e.params { + if p.is_animated() { + v.push(fx_lane(slot, pid)); + } + } + } + v + } + + /// Set a lane's static value (fader moves in Off/Read mode, or no automation yet). + pub fn set_lane_static(&mut self, key: &str, v: f64) -> bool { + match key { + LANE_VOLUME => self.volume_db = v.clamp(FADER_MIN_DB, FADER_MAX_DB), + LANE_PAN => self.pan = v.clamp(-100.0, 100.0), + LANE_MUTE => self.muted = v >= 0.5, + k => { + if let Some(i) = parse_send_lane(k) { + match self.mixer.sends.get_mut(i) { + Some(s) => s.level_db = v.clamp(FADER_MIN_DB, FADER_MAX_DB), + None => return false, + } + } else if let Some((slot, p)) = parse_fx_lane(k) { + match self.effects.get_mut(slot).and_then(|e| e.param_mut(p)) { + Some(par) => par.value = ParamValue::Float(v), + None => return false, + } + } else { + return false; + } + } + } + true + } + + /// Remove insert `slot` (its automation goes with it: effect lanes live inside the effect). + pub fn remove_insert(&mut self, slot: usize) -> bool { + if slot < self.effects.len() { + self.effects.remove(slot); + true + } else { + false + } + } + + /// Remove send `i` and shift the automation lanes of the sends above it down. + pub fn remove_send(&mut self, i: usize) -> bool { + if i >= self.mixer.sends.len() { + return false; + } + self.mixer.sends.remove(i); + let mut lanes = std::mem::take(&mut self.mixer.lanes); + let moved: Vec<(String, Param)> = lanes.iter().filter_map(|(k, p)| parse_send_lane(k).filter(|&j| j >= i).map(|_| (k.clone(), p.clone()))).collect(); + for (k, _) in &moved { + lanes.remove(k); + } + for (k, p) in moved { + let j = parse_send_lane(&k).unwrap_or(0); + if j > i { + lanes.insert(send_lane(j - 1), p); + } + } + self.mixer.lanes = lanes; + true + } +} + +/// Strip id of the Mix (master) track. +pub const MASTER_STRIP: TrackId = TrackId(u64::MAX); + +impl crate::Sequence { + /// The Mix track as a [`Track`] (volume, inserts and mixer state; no clips). + pub fn master_strip(&self) -> Track { + let mut m = Track::new(MASTER_STRIP, crate::TrackKind::Audio, "Mix".into()); + m.volume_db = self.master_volume_db; + m.effects = self.master_effects.clone(); + m.mixer = self.master_mixer.clone(); + m.channels = self.settings.audio_master; + m + } + + /// Any mixer strip (audio track, submix or [`MASTER_STRIP`]). + pub fn strip(&self, id: TrackId) -> Option> { + if id == MASTER_STRIP { + return Some(std::borrow::Cow::Owned(self.master_strip())); + } + self.mix_track(id).map(std::borrow::Cow::Borrowed) + } + + /// Edit any mixer strip; edits to the Mix are written back to the sequence's master fields. + pub fn with_strip_mut(&mut self, id: TrackId, f: impl FnOnce(&mut Track) -> R) -> Option { + if id == MASTER_STRIP { + let mut m = self.master_strip(); + let r = f(&mut m); + self.master_volume_db = m.volume_db; + self.master_effects = m.effects; + self.master_mixer = m.mixer; + return Some(r); + } + self.mix_track_mut(id).map(f) + } + + /// Ids of all strips in mixer order: audio tracks, submixes, Mix. + pub fn strip_ids(&self) -> Vec { + self.audio_tracks.iter().chain(&self.submix_tracks).map(|t| t.id).chain(std::iter::once(MASTER_STRIP)).collect() + } + + /// An audio or submix track (the strips that have a fader besides the Mix). + pub fn mix_track(&self, id: TrackId) -> Option<&Track> { + self.audio_tracks.iter().chain(&self.submix_tracks).find(|t| t.id == id) + } + pub fn mix_track_mut(&mut self, id: TrackId) -> Option<&mut Track> { + self.audio_tracks.iter_mut().chain(self.submix_tracks.iter_mut()).find(|t| t.id == id) + } + /// Mix track volume (dB) at sequence time `t` (automation unless the Mix is in Off mode). + pub fn master_volume_at(&self, t: Tick) -> f64 { + match self.master_mixer.lanes.get(LANE_VOLUME) { + Some(p) if p.is_animated() && self.master_mixer.mode.reads() => p.scalar_at(t), + _ => self.master_volume_db, + } + } +} + +/// Thin a recorded gesture (time, value) into keyframes: Ramer–Douglas–Peucker on the value axis +/// (every dropped point lies within `tolerance` of the line between the kept neighbours), then drop +/// points closer than `min_interval` to the previous kept one (except the last). `hold` lanes keep +/// only the points where the value changes. +pub fn thin_points(points: &[(Tick, f64)], tolerance: f64, min_interval: Tick, hold: bool) -> Vec<(Tick, f64)> { + let mut pts: Vec<(Tick, f64)> = Vec::with_capacity(points.len()); + for &(t, v) in points { + match pts.last_mut() { + Some(last) if last.0 == t => last.1 = v, + Some(last) if last.0 > t => {} + _ => pts.push((t, v)), + } + } + if pts.len() <= 2 { + return pts; + } + if hold { + let mut out = vec![pts[0]]; + for w in pts.windows(2) { + if (w[1].1 - w[0].1).abs() > tolerance.max(1e-9) { + out.push(w[1]); + } + } + return out; + } + let mut keep = vec![false; pts.len()]; + keep[0] = true; + keep[pts.len() - 1] = true; + let mut stack = vec![(0usize, pts.len() - 1)]; + while let Some((a, b)) = stack.pop() { + if b <= a + 1 { + continue; + } + let (ta, va) = (pts[a].0.0 as f64, pts[a].1); + let (tb, vb) = (pts[b].0.0 as f64, pts[b].1); + let mut worst = (0.0f64, 0usize); + for (i, p) in pts.iter().enumerate().take(b).skip(a + 1) { + let u = (p.0.0 as f64 - ta) / (tb - ta); + let d = (p.1 - (va + (vb - va) * u)).abs(); + if d > worst.0 { + worst = (d, i); + } + } + if worst.0 > tolerance { + keep[worst.1] = true; + stack.push((a, worst.1)); + stack.push((worst.1, b)); + } + } + let mut out: Vec<(Tick, f64)> = Vec::new(); + let n = pts.len(); + for (i, p) in pts.into_iter().enumerate() { + if !keep[i] { + continue; + } + if i + 1 < n && min_interval.0 > 0 && out.last().is_some_and(|l: &(Tick, f64)| p.0 - l.0 < min_interval) { + continue; + } + out.push(p); + } + out +} + +/// Replace the keyframes of `param` within `[t0, t1]` by `points` (already thinned). +/// +/// `before`/`after` are the lane values just outside the written range: when given, a keyframe is +/// placed one `eps` outside the range so the automation outside `[t0, t1]` is unchanged. +pub fn write_lane(param: &mut Param, t0: Tick, t1: Tick, points: &[(Tick, f64)], before: Option, after: Option, eps: Tick, hold: bool) { + let interp = if hold { Interpolation::Hold } else { Interpolation::Linear }; + param.keyframes.retain(|k| k.time < t0 || k.time > t1); + let mut add = |t: Tick, v: f64| { + let mut k = Keyframe::new(t, ParamValue::Float(v)); + k.interp = interp; + match param.keyframes.binary_search_by_key(&t, |k| k.time) { + Ok(i) => param.keyframes[i] = k, + Err(i) => param.keyframes.insert(i, k), + } + }; + if let Some(b) = before { + let tb = t0 - eps; + if tb.0 >= 0 { + add(tb, b); + } + } + for &(t, v) in points.iter().filter(|p| p.0 >= t0 && p.0 <= t1) { + add(t, v); + } + if let Some(a) = after { + add(t1 + eps, a); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TrackKind; + + fn tr() -> Track { + Track::new(TrackId(1), TrackKind::Audio, "Audio 1".into()) + } + + #[test] + fn mode_names_roundtrip() { + for m in AutomationMode::ALL { + assert_eq!(AutomationMode::from_name(m.label()), Some(m)); + } + assert_eq!(AutomationMode::default(), AutomationMode::Read); + assert!(!AutomationMode::Off.reads() && AutomationMode::Touch.writes() && !AutomationMode::Read.writes()); + } + + #[test] + fn lanes_read_static_and_keyframes() { + let mut t = tr(); + t.volume_db = -6.0; + assert_eq!(t.lane_value(LANE_VOLUME, Tick(0)), -6.0); + let p = t.lane_mut(LANE_VOLUME).unwrap(); + p.keyframes.push(Keyframe::new(Tick(0), ParamValue::Float(0.0))); + p.keyframes.push(Keyframe::new(Tick(100), ParamValue::Float(-20.0))); + assert_eq!(t.lane_value(LANE_VOLUME, Tick(50)), -10.0); + t.mixer.mode = AutomationMode::Off; + assert_eq!(t.lane_value(LANE_VOLUME, Tick(50)), -6.0); + assert_eq!(t.automated_lanes(), vec!["volume".to_string()]); + } + + #[test] + fn thinning_keeps_shape_within_tolerance() { + // a ramp sampled densely collapses to its end points; a corner survives + let pts: Vec<(Tick, f64)> = (0..=100).map(|i| (Tick(i * 10), if i <= 50 { i as f64 * 0.2 } else { 10.0 - (i - 50) as f64 * 0.2 })).collect(); + let th = thin_points(&pts, 0.01, Tick(0), false); + assert_eq!(th, vec![(Tick(0), 0.0), (Tick(500), 10.0), (Tick(1000), 0.0)]); + // every original point within tolerance of the thinned curve + let mut p = Param::new(ParamValue::Float(0.0)); + write_lane(&mut p, Tick(0), Tick(1000), &th, None, None, Tick(1), false); + for (t, v) in pts { + assert!((p.scalar_at(t) - v).abs() <= 0.01 + 1e-9); + } + // hold lanes keep change points only + let m = [(Tick(0), 0.0), (Tick(10), 0.0), (Tick(20), 1.0), (Tick(30), 1.0), (Tick(40), 0.0)]; + assert_eq!(thin_points(&m, 0.0, Tick(0), true), vec![(Tick(0), 0.0), (Tick(20), 1.0), (Tick(40), 0.0)]); + } + + #[test] + fn write_lane_preserves_outside() { + let mut p = Param::new(ParamValue::Float(0.0)); + write_lane(&mut p, Tick(0), Tick(1000), &[(Tick(0), -3.0), (Tick(1000), -3.0)], None, None, Tick(1), false); + write_lane(&mut p, Tick(400), Tick(600), &[(Tick(400), -20.0), (Tick(600), -20.0)], Some(-3.0), Some(-3.0), Tick(1), false); + assert_eq!(p.scalar_at(Tick(200)), -3.0); + assert_eq!(p.scalar_at(Tick(500)), -20.0); + assert_eq!(p.scalar_at(Tick(800)), -3.0); + assert_eq!(p.keyframes.len(), 6); + } + + #[test] + fn send_removal_shifts_lanes() { + let mut t = tr(); + for k in 0..3 { + t.mixer.sends.push(TrackSend::new(TrackId(10 + k))); + t.mixer.sends[k as usize].level_db = -(k as f64); + t.lane_mut(&send_lane(k as usize)).unwrap().keyframes.push(Keyframe::new(Tick(0), ParamValue::Float(-(k as f64)))); + } + assert!(t.remove_send(1)); + assert_eq!(t.mixer.sends.len(), 2); + assert_eq!(t.lane_keyframes(&send_lane(1))[0].value, ParamValue::Float(-2.0)); + assert!(t.lane(&send_lane(2)).is_none()); + } + + #[test] + fn serde_defaults() { + let s: MixerStrip = serde_json::from_str("{}").unwrap(); + assert_eq!(s, MixerStrip::default()); + let mut t = tr(); + t.mixer.mode = AutomationMode::Touch; + let j = serde_json::to_string(&t).unwrap(); + let back: Track = serde_json::from_str(&j).unwrap(); + assert_eq!(back, t); + } +} diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 01cb5643c..1f051593b 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -1,11 +1,9 @@ -//! Sequence audio mixdown (clip gain → Volume/Channel Volume/Panner → track volume/pan → -//! master), with audio transitions, mute/solo, speed changes (varispeed resampling). -//! -//! The dedicated `audio` crate (M7) adds the effect DSP, submixes and meters; this module is the -//! reference mixer used by playback and export. +//! Clip-level audio: clip gain → clip effects → Volume / Channel Volume / Panner, audio transitions +//! and speed changes (varispeed resampling), summed per track. The track/submix/Mix graph that +//! consumes it is [`crate::mixer`]. use filmcraft_frame::AudioBuffer; -use filmcraft_project::{Project, Sequence, TrackItem}; +use filmcraft_project::{Project, Sequence, Track, TrackItem}; use filmcraft_time::{Tick, TimeRange}; use crate::{SourceProvider, transitions}; @@ -14,74 +12,63 @@ pub fn db_to_gain(db: f64) -> f32 { if db <= -96.0 { 0.0 } else { 10f64.powf(db / 20.0) as f32 } } -/// Mix `frames` stereo samples of sequence audio starting at sample `start` (sequence rate). +/// Mix `frames` stereo samples of sequence audio starting at sample `start` (sequence rate), through +/// the full mixer graph ([`crate::mixer`]). Export and playback both use this. pub fn mix_sequence(project: &Project, seq: &Sequence, start: i64, frames: usize, sources: &dyn SourceProvider) -> AudioBuffer { - let sr = seq.settings.sample_rate; - let mut out = AudioBuffer::silence(sr, 2, frames); - let range = TimeRange::from_bounds(Tick::from_units(start, sr as i64), Tick::from_units(start + frames as i64, sr as i64)); - let any_solo = seq.audio_tracks.iter().any(|t| t.solo); - for track in &seq.audio_tracks { - if track.muted || (any_solo && !track.solo) { - continue; - } - let mut tbuf = AudioBuffer::silence(sr, 2, frames); - for item in track.items.iter().filter(|i| i.enabled && i.range().overlaps(&range)) { - mix_item(project, item, start, frames, sr, sources, &mut tbuf, 1.0); - } - // audio transitions: attenuate the covered region of each side - for tr in &track.transitions { - if !tr.range().overlaps(&range) { - continue; - } - // Re-mix: recompute the covered samples with crossfade gains. - let from = tr.from.and_then(|id| track.item(id)); - let to = tr.to.and_then(|id| track.item(id)); - let s0 = tr.start.to_units_floor(sr as i64).max(start); - let s1 = tr.end().to_units_floor(sr as i64).min(start + frames as i64); - if s1 <= s0 { - continue; - } - let n = (s1 - s0) as usize; - let off = (s0 - start) as usize; - for ch in tbuf.channels.iter_mut() { - ch[off..off + n].fill(0.0); - } - let mut a = AudioBuffer::silence(sr, 2, n); - let mut b = AudioBuffer::silence(sr, 2, n); - if let Some(f) = from { - mix_item(project, f, s0, n, sr, sources, &mut a, 1.0); - } - if let Some(tt) = to { - mix_item(project, tt, s0, n, sr, sources, &mut b, 1.0); - } - let dur = (tr.end() - tr.start).to_units_floor(sr as i64).max(1) as f32; - let base = (s0 - tr.start.to_units_floor(sr as i64)) as f32; - for i in 0..n { - let p = (base + i as f32) / dur; - let (ga, gb) = transitions::audio_gains(&tr.effect.effect, p); - for c in 0..2 { - tbuf.channels[c][off + i] += a.channels[c][i] * ga + b.channels[c][i] * gb; - } - } - } - let g = db_to_gain(track.volume_db); - let pan = (track.pan / 100.0).clamp(-1.0, 1.0) as f32; - let (gl, gr) = pan_gains(pan); - out.mix_from(&tbuf, &[g * gl, g * gr]); - } - let mg = db_to_gain(seq.master_volume_db); - if (mg - 1.0).abs() > 1e-6 { - for ch in &mut out.channels { - for s in ch.iter_mut() { - *s *= mg; - } - } - } - out + crate::mixer::mix_graph(project, seq, start, frames, sources, None) } -/// -3 dB constant-power pan law, normalised so centre = unity. -fn pan_gains(pan: f32) -> (f32, f32) { +/// The summed clip audio of one track (clip gain, clip effects, Volume / Channel Volume / Panner, +/// audio transitions) for sequence samples `[start, start + frames)`: the track's mixer input. +pub fn track_input(project: &Project, track: &Track, start: i64, frames: usize, sr: u32, sources: &dyn SourceProvider) -> AudioBuffer { + let range = TimeRange::from_bounds(Tick::from_units(start, sr as i64), Tick::from_units(start + frames as i64, sr as i64)); + let mut tbuf = AudioBuffer::silence(sr, 2, frames); + for item in track.items.iter().filter(|i| i.enabled && i.range().overlaps(&range)) { + mix_item(project, item, start, frames, sr, sources, &mut tbuf, 1.0); + } + // audio transitions: attenuate the covered region of each side + for tr in &track.transitions { + if !tr.range().overlaps(&range) { + continue; + } + // Re-mix: recompute the covered samples with crossfade gains. + let from = tr.from.and_then(|id| track.item(id)); + let to = tr.to.and_then(|id| track.item(id)); + let s0 = tr.start.to_units_floor(sr as i64).max(start); + let s1 = tr.end().to_units_floor(sr as i64).min(start + frames as i64); + if s1 <= s0 { + continue; + } + let n = (s1 - s0) as usize; + let off = (s0 - start) as usize; + for ch in tbuf.channels.iter_mut() { + ch[off..off + n].fill(0.0); + } + let mut a = AudioBuffer::silence(sr, 2, n); + let mut b = AudioBuffer::silence(sr, 2, n); + if let Some(f) = from { + mix_item(project, f, s0, n, sr, sources, &mut a, 1.0); + } + if let Some(tt) = to { + mix_item(project, tt, s0, n, sr, sources, &mut b, 1.0); + } + let dur = (tr.end() - tr.start).to_units_floor(sr as i64).max(1) as f32; + let base = (s0 - tr.start.to_units_floor(sr as i64)) as f32; + for i in 0..n { + let p = (base + i as f32) / dur; + let (ga, gb) = transitions::audio_gains(&tr.effect.effect, p); + for c in 0..2 { + tbuf.channels[c][off + i] += a.channels[c][i] * ga + b.channels[c][i] * gb; + } + } + } + tbuf +} + +/// Balance for stereo signals (clip Panner, stereo track pan): centre = unity on both sides, turning +/// one way attenuates the other side along the −3 dB constant-power curve (normalised so the +/// centre is 0 dB); hard left/right silences the opposite side. +pub fn pan_gains(pan: f32) -> (f32, f32) { let a = (pan + 1.0) * std::f32::consts::FRAC_PI_4; let k = std::f32::consts::SQRT_2; ((a.cos() * k).min(1.0), (a.sin() * k).min(1.0)) @@ -115,19 +102,23 @@ fn mix_item(project: &Project, item: &TrackItem, start: i64, frames: usize, sr: let chv = item.effect("channel_volume").filter(|e| e.enabled && !e.param("bypass").and_then(|p| p.value.as_bool()).unwrap_or(false)); let pan = item.effect("panner").filter(|e| e.enabled); let off = (a0 - start) as usize; - for blk in (0..n).step_by(64) { - let t_tl = Tick::from_units(a0 + blk as i64, sr as i64); + // Gains change on an absolute 64-sample grid, so the output does not depend on how callers cut + // the timeline into requests. + let mut blk = 0usize; + while blk < n { + let t_tl = Tick::from_units((a0 + blk as i64).div_euclid(64) * 64, sr as i64); let mt = item.source_time_at(t_tl); let v = vol.map(|e| db_to_gain(e.f64_at("level", mt))).unwrap_or(1.0); let (cl, cr) = chv.map(|e| (db_to_gain(e.f64_at("left", mt)), db_to_gain(e.f64_at("right", mt)))).unwrap_or((1.0, 1.0)); let (pl, pr) = pan.map(|e| pan_gains((e.f64_at("balance", mt) / 100.0) as f32)).unwrap_or((1.0, 1.0)); let g = clip_gain * v * extra; - let end = (blk + 64).min(n); + let end = (((a0 + blk as i64).div_euclid(64) + 1) * 64 - a0).min(n as i64) as usize; for i in blk..end { let (l, r) = (buf[0][i], buf[1][i]); out.channels[0][off + i] += l * g * cl * pl; out.channels[1][off + i] += r * g * cr * pr; } + blk = end; } } diff --git a/crates/render/src/audio_fx.rs b/crates/render/src/audio_fx.rs index a6a10e8cd..89a3074d3 100644 --- a/crates/render/src/audio_fx.rs +++ b/crates/render/src/audio_fx.rs @@ -15,12 +15,12 @@ use filmcraft_project::{EffectInstance, TrackItem}; use filmcraft_time::Tick; /// How a project effect maps onto a DSP effect. -struct Mapping { - dsp: &'static str, +pub(crate) struct Mapping { + pub(crate) dsp: &'static str, /// Pre-roll (seconds) for a fresh chain; closures get the effect instance at the start time. - preroll: fn(&EffectInstance, Tick) -> f64, + pub(crate) preroll: fn(&EffectInstance, Tick) -> f64, /// Set DSP parameters from the (keyframed) project parameters at media time `mt`. - apply: fn(&mut dyn AudioEffect, &EffectInstance, Tick), + pub(crate) apply: fn(&mut dyn AudioEffect, &EffectInstance, Tick), } fn ignore(_: bool) {} @@ -29,7 +29,7 @@ fn f(e: &EffectInstance, id: &str, mt: Tick) -> f32 { e.f64_at(id, mt) as f32 } -fn mapping(id: &str) -> Option { +pub(crate) fn mapping(id: &str) -> Option { Some(match id { "amplify" => Mapping { dsp: "amplify", preroll: |_, _| 0.0, apply: |d, e, t| ignore(d.set_param("gain", f(e, "gain", t))) }, "dynamics" => Mapping { @@ -160,8 +160,10 @@ fn run(chain: &mut Chain, item: &TrackItem, in0: i64, input: &mut [Vec; 2], let n = input[0].len(); let mut i = 0; while i < n { - let end = (i + BLOCK).min(n); - let mt = item.source_time_at(Tick::from_units(in0 + i as i64, sr as i64)); + // absolute block grid: parameters update at the same samples however requests are cut + let pos = in0 + i as i64; + let end = (((pos.div_euclid(BLOCK as i64) + 1) * BLOCK as i64 - in0) as usize).min(n); + let mt = item.source_time_at(Tick::from_units(pos.div_euclid(BLOCK as i64) * BLOCK as i64, sr as i64)); for ((e, m), d) in act.iter().zip(chain.fx.iter_mut()) { (m.apply)(d.as_mut(), e, mt); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a723b6bc5..329c46c93 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -12,6 +12,7 @@ pub mod audio_fx; pub mod blend; pub mod effects; pub mod image; +pub mod mixer; pub mod plan; pub mod preview; pub mod transitions; @@ -304,6 +305,10 @@ pub fn arc_source(s: impl filmcraft_media::MediaSource + 'static) -> SharedSourc #[cfg(test)] mod tests; +#[cfg(test)] +#[path = "mixer_tests.rs"] +mod mixer_tests; + #[cfg(test)] #[path = "preview_tests.rs"] mod preview_tests; diff --git a/crates/render/src/mixer.rs b/crates/render/src/mixer.rs new file mode 100644 index 000000000..e83e0be20 --- /dev/null +++ b/crates/render/src/mixer.rs @@ -0,0 +1,803 @@ +//! The audio mixer graph (Audio Track Mixer): tracks → submixes → Mix. +//! +//! Signal path of every strip, in Premiere's documented order: +//! +//! ```text +//! track input (clips: gain → clip effects → clip Volume / Channel Volume / Panner, transitions) +//! → input map / mono fold → pre-fader inserts → pre-fader sends → mute → fader (volume) +//! → meter → post-fader inserts → post-fader sends → pan / balance → output (submix or Mix) +//! submix: bus sum → the same strip path → output (Mix or a later submix) +//! Mix: bus sum → pre-fader inserts → fader → meter → post-fader inserts → out +//! ``` +//! +//! * **Sample accurate.** Automated lanes (volume, pan, mute, send levels) are evaluated per sample +//! from their keyframes; effect parameters update on an absolute 64-sample grid. The output does +//! not depend on how callers cut the timeline into requests. +//! * **Latency compensated.** Inserts that report latency (look-ahead limiter, STFT effects) delay +//! their strip; every route into a bus gets a compensation delay so all inputs of a bus line up, +//! and the whole graph is read ahead by its total latency, so the output is aligned with the +//! timeline. Automation is evaluated at *content* time (input time minus the latency so far). +//! * **Stateful and pull-based.** Graph state (insert DSP, compensation delays) is cached per graph +//! structure and keyed by the next sample it will produce. Sequential consumers (playback, export, +//! meters) continue it; any other request starts fresh with a pre-roll long enough for effect +//! tails and the graph latency. +//! * **Solo.** When any strip is soloed, a strip stays audible if it is soloed or solo-safe, feeds +//! (directly or through submixes) a soloed or solo-safe strip, or is fed by one. Everything else +//! is solo-muted (silent, sends included). Track mute sits after the pre-fader sends, so +//! pre-fader sends of a muted track keep sending, as in Premiere. +//! * **Pan law.** Mono tracks pan with the −3 dB constant-power law (centre = −3 dB per side); +//! stereo (and, for now, 5.1) tracks and sends use balance (centre = unity, the far side follows +//! the same constant-power curve normalised to the centre). +//! +//! [`LiveMix`] carries what the UI does while playing: control overrides while a fader is held +//! (with Touch's ramp back to the automation), per-strip meters, and the newest project snapshot. + +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use filmcraft_audio_dsp::AudioEffect; +use filmcraft_frame::AudioBuffer; +use filmcraft_project::mixer::{LANE_MUTE, LANE_PAN, LANE_VOLUME, fx_lane, send_lane}; +use filmcraft_project::{AudioChannels, EffectInstance, InputMap, Param, ParamValue, Project, Sequence, Track, TrackId}; +use filmcraft_time::Tick; +use rayon::prelude::*; + +use crate::SourceProvider; +use crate::audio::{pan_gains, track_input}; +use crate::audio_fx::{Mapping, mapping}; + +/// Strip id of the Mix (master) track in [`LiveMix`] and commands. +pub const MASTER: TrackId = filmcraft_project::mixer::MASTER_STRIP; +/// Effect parameters update on this absolute sample grid. +pub const PARAM_BLOCK: i64 = 64; +/// Longest pre-roll for a fresh graph (seconds). +const MAX_PREROLL_S: f64 = 3.0; + +// ------------------------------------------------------------------------------------- live state + +/// A control held (or just released) on the mixer while playing. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Override { + pub value: f64, + /// Released at `.0`; the value ramps back to the automation over `.1` (Touch). + pub release: Option<(Tick, Tick)>, +} + +/// What the live mixer UI feeds into playback (and reads back from it). +#[derive(Default)] +pub struct LiveMix { + overrides: Mutex>, + active: AtomicBool, + meters: Mutex>, + project: Mutex>>, +} + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +impl LiveMix { + pub fn new() -> Self { + Self::default() + } + /// Hold a control at `value` (a fader or knob being touched/moved). + pub fn hold(&self, strip: TrackId, lane: &str, value: f64) { + lock(&self.overrides).insert((strip, lane.to_string()), Override { value, release: None }); + self.active.store(true, Ordering::Release); + } + /// Release a held control at `at`: playback ramps from the held value back to the automation + /// over `ramp` (zero = jump back immediately). + pub fn release(&self, strip: TrackId, lane: &str, at: Tick, ramp: Tick) { + let mut g = lock(&self.overrides); + if ramp.0 <= 0 { + g.remove(&(strip, lane.to_string())); + } else if let Some(o) = g.get_mut(&(strip, lane.to_string())) { + o.release = Some((at, ramp)); + } + self.active.store(!g.is_empty(), Ordering::Release); + } + pub fn clear(&self, strip: TrackId, lane: &str) { + let mut g = lock(&self.overrides); + g.remove(&(strip, lane.to_string())); + self.active.store(!g.is_empty(), Ordering::Release); + } + pub fn clear_all(&self) { + lock(&self.overrides).clear(); + self.active.store(false, Ordering::Release); + } + /// Whether any control is overridden (rendered audio previews are bypassed then). + pub fn is_active(&self) -> bool { + self.active.load(Ordering::Acquire) + } + pub fn get(&self, strip: TrackId, lane: &str) -> Option { + lock(&self.overrides).get(&(strip, lane.to_string())).copied() + } + pub fn overrides(&self) -> HashMap<(TrackId, String), Override> { + lock(&self.overrides).clone() + } + /// Peak levels (linear, L/R, post-fader) per strip since the last call; [`MASTER`] is the Mix. + pub fn take_meters(&self) -> HashMap { + std::mem::take(&mut *lock(&self.meters)) + } + fn post_meters(&self, m: &[(TrackId, [f32; 2])]) { + let mut g = lock(&self.meters); + for (id, p) in m { + let e = g.entry(*id).or_insert([0.0; 2]); + e[0] = e[0].max(p[0]); + e[1] = e[1].max(p[1]); + } + } + /// Publish the newest project snapshot (playback picks up edits made while it runs). + pub fn publish_project(&self, p: Arc) { + *lock(&self.project) = Some(p); + } + pub fn project(&self) -> Option> { + lock(&self.project).clone() + } +} + +// ------------------------------------------------------------------------------------- pan laws + +/// −3 dB constant-power pan of a mono signal: (left, right) gains; centre = 0.7071 each. +pub fn pan_law_mono(pan: f32) -> (f32, f32) { + let a = (pan.clamp(-1.0, 1.0) + 1.0) * std::f32::consts::FRAC_PI_4; + (a.cos(), a.sin()) +} + +/// Stereo balance (centre = unity). Same curve as the clip Panner. +pub fn balance(pan: f32) -> (f32, f32) { + pan_gains(pan.clamp(-1.0, 1.0)) +} + +#[inline] +fn db_gain(db: f64) -> f32 { + if db <= filmcraft_project::mixer::FADER_MIN_DB { 0.0 } else { 10f64.powf(db / 20.0) as f32 } +} + +// ------------------------------------------------------------------------------------- lanes + +/// One automatable control of a strip, ready for per-sample evaluation. +struct Lane<'a> { + param: Option<&'a Param>, + stat: f64, + ov: Option, + /// Switch lanes (mute) hold between keyframes whatever their interpolation. + hold: bool, +} + +impl<'a> Lane<'a> { + fn new(track: &'a Track, strip: TrackId, key: &str, ovs: &HashMap<(TrackId, String), Override>) -> Self { + let reads = track.mixer.mode.reads(); + let param = track.lane(key).filter(|p| reads && p.is_animated()); + let hold = filmcraft_project::mixer::lane_info(key).is_some_and(|i| i.hold); + Lane { param, stat: track.lane_static(key).unwrap_or(0.0), ov: ovs.get(&(strip, key.to_string())).copied(), hold } + } + fn auto(&self, t: Tick) -> f64 { + match self.param { + None => self.stat, + Some(p) if self.hold => { + let i = p.keyframes.partition_point(|k| k.time <= t); + p.keyframes[i.saturating_sub(1)].value.as_f64().unwrap_or(0.0) + } + Some(p) => p.scalar_at(t), + } + } + /// The value when it is the same at every sample. + fn constant(&self) -> Option { + match self.ov { + Some(Override { value, release: None }) => Some(value), + Some(_) => None, + None => self.param.is_none().then_some(self.stat), + } + } + fn at(&self, t: Tick) -> f64 { + match self.ov { + None => self.auto(t), + Some(Override { value, release: None }) => value, + Some(Override { value, release: Some((r, d)) }) => { + if t < r { + value + } else if t >= r + d { + self.auto(t) + } else { + let u = (t - r).0 as f64 / d.0.max(1) as f64; + value + (self.auto(t) - value) * u + } + } + } + } + /// Per-sample values for content samples `c0 ..`, mapped through `f`, into `out`. + /// Returns the constant instead when the lane does not change. + fn fill(&self, c0: i64, sr: u32, out: &mut [f32], f: impl Fn(f64) -> f32) -> Option { + if let Some(v) = self.constant() { + return Some(f(v)); + } + for (i, o) in out.iter_mut().enumerate() { + *o = f(self.at(Tick::from_units(c0 + i as i64, sr as i64))); + } + None + } +} + +// ------------------------------------------------------------------------------------- plan + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Kind { + Track, + Submix, + Master, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Tap { + /// After the pre-fader inserts. + Pre, + /// After the post-fader inserts (before pan). + Post, + /// The strip output (after pan). + Out, +} + +struct Insert<'a> { + slot: usize, + effect: Cow<'a, EffectInstance>, + map: Mapping, +} + +struct Strip<'a> { + track: Cow<'a, Track>, + kind: Kind, + pre: Vec>, + post: Vec>, + /// Silent (solo-muted, or muted with nothing tapping before the mute). + silent: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct RouteSpec { + src: usize, + tap: Tap, + /// Send index (sends), `None` for the main output. + send: Option, + target: usize, +} + +struct Plan<'a> { + strips: Vec>, + routes: Vec, +} + +fn inserts<'a>(track: &'a Track, post: bool) -> Vec> { + let reads = track.mixer.mode.reads(); + track + .effects + .iter() + .enumerate() + .take(filmcraft_project::mixer::MAX_INSERTS) + .filter(|(_, e)| e.enabled && e.post_fader == post && !e.param("bypass").and_then(|p| p.value.as_bool()).unwrap_or(false)) + .filter_map(|(slot, e)| mapping(&e.effect).map(|m| (slot, e, m))) + .map(|(slot, e, map)| { + let effect = if reads { Cow::Borrowed(e) } else { Cow::Owned(strip_keyframes(e)) }; + Insert { slot, effect, map } + }) + .collect() +} + +fn strip_keyframes(e: &EffectInstance) -> EffectInstance { + let mut c = e.clone(); + for p in c.params.values_mut() { + p.keyframes.clear(); + } + c +} + +impl<'a> Plan<'a> { + fn build(seq: &'a Sequence, ovs: &HashMap<(TrackId, String), Override>) -> Plan<'a> { + let mut strips: Vec> = Vec::new(); + for t in &seq.audio_tracks { + strips.push(Strip { track: Cow::Borrowed(t), kind: Kind::Track, pre: inserts(t, false), post: inserts(t, true), silent: false }); + } + for t in &seq.submix_tracks { + strips.push(Strip { track: Cow::Borrowed(t), kind: Kind::Submix, pre: inserts(t, false), post: inserts(t, true), silent: false }); + } + let m = seq.master_strip(); + strips.push(Strip { track: Cow::Owned(m), kind: Kind::Master, pre: Vec::new(), post: Vec::new(), silent: false }); + let mi = strips.len() - 1; + // master inserts borrow from the sequence (same effects as the owned copy) + { + let reads = seq.master_mixer.mode.reads(); + let mk = |post: bool| -> Vec> { + seq.master_effects + .iter() + .enumerate() + .take(filmcraft_project::mixer::MAX_INSERTS) + .filter(|(_, e)| e.enabled && e.post_fader == post) + .filter_map(|(slot, e)| { + mapping(&e.effect).map(|map| Insert { slot, effect: if reads { Cow::Borrowed(e) } else { Cow::Owned(strip_keyframes(e)) }, map }) + }) + .collect() + }; + strips[mi].pre = mk(false); + strips[mi].post = mk(true); + } + // live overrides of effect parameters + for s in strips.iter_mut() { + let id = s.track.id; + for ins in s.pre.iter_mut().chain(s.post.iter_mut()) { + let slot = ins.slot; + let pids: Vec = ins.effect.params.keys().cloned().collect(); + for pid in pids { + if let Some(o) = ovs.get(&(id, fx_lane(slot, &pid))) { + let e = ins.effect.to_mut(); + if let Some(p) = e.param_mut(&pid) { + p.value = ParamValue::Float(o.value); + p.keyframes.clear(); + } + } + } + } + } + // routes + let ntr = seq.audio_tracks.len(); + let index_of = |id: TrackId| seq.submix_tracks.iter().position(|t| t.id == id).map(|i| ntr + i); + let mut routes = Vec::new(); + for (si, s) in strips.iter().enumerate() { + if s.kind == Kind::Master { + continue; + } + // submixes may only feed submixes after them (no feedback) + let valid = |ti: usize| s.kind == Kind::Track || ti > si; + for (k, snd) in s.track.mixer.sends.iter().enumerate().take(filmcraft_project::mixer::MAX_SENDS) { + if let Some(ti) = index_of(snd.target).filter(|&ti| valid(ti)) { + routes.push(RouteSpec { src: si, tap: if snd.pre_fader { Tap::Pre } else { Tap::Post }, send: Some(k), target: ti }); + } + } + let target = s.track.mixer.output.and_then(index_of).filter(|&ti| valid(ti)).unwrap_or(mi); + routes.push(RouteSpec { src: si, tap: Tap::Out, send: None, target }); + } + let mut plan = Plan { strips, routes }; + plan.solo(ovs); + plan + } + + /// Solo-mute and static-mute pruning. + fn solo(&mut self, ovs: &HashMap<(TrackId, String), Override>) { + let n = self.strips.len(); + let soloed: Vec = (0..n).filter(|&i| self.strips[i].kind != Kind::Master && self.strips[i].track.solo).collect(); + if !soloed.is_empty() { + let mut keep: HashSet = soloed.iter().copied().collect(); + keep.extend((0..n).filter(|&i| self.strips[i].kind != Kind::Master && self.strips[i].track.mixer.solo_safe)); + let seeds: Vec = keep.iter().copied().collect(); + // downstream and upstream closures + for &s in &seeds { + let mut stack = vec![s]; + while let Some(x) = stack.pop() { + for r in self.routes.iter().filter(|r| r.src == x) { + if keep.insert(r.target) { + stack.push(r.target); + } + } + } + let mut stack = vec![s]; + while let Some(x) = stack.pop() { + for r in self.routes.iter().filter(|r| r.target == x) { + if keep.insert(r.src) { + stack.push(r.src); + } + } + } + } + for (i, s) in self.strips.iter_mut().enumerate() { + if s.kind != Kind::Master && !keep.contains(&i) { + s.silent = true; + } + } + } + // statically muted with nothing before the mute that is heard: skip entirely + for (i, s) in self.strips.iter_mut().enumerate() { + let id = s.track.id; + let mute_static = Lane::new(&s.track, id, LANE_MUTE, ovs).constant().is_some_and(|m| m >= 0.5); + let pre_sends = self.routes.iter().any(|r| r.src == i && r.tap == Tap::Pre); + if s.kind != Kind::Master && mute_static && !pre_sends { + s.silent = true; + } + } + } + + fn structure_hash(&self, sr: u32) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + sr.hash(&mut h); + for s in &self.strips { + s.track.id.hash(&mut h); + s.kind.hash(&mut h); + for (side, v) in [(0u8, &s.pre), (1u8, &s.post)] { + for i in v.iter() { + (side, i.slot, &i.effect.effect, i.map.dsp).hash(&mut h); + } + } + } + self.routes.hash(&mut h); + h.finish() + } + + /// Longest pre-roll the inserts need for a fresh graph (samples). + fn preroll(&self, t: Tick, sr: u32) -> usize { + let s = self.strips.iter().flat_map(|s| s.pre.iter().chain(&s.post)).map(|i| (i.map.preroll)(&i.effect, t)).fold(0.0, f64::max); + (s.min(MAX_PREROLL_S) * sr as f64).ceil() as usize + } +} + +// ------------------------------------------------------------------------------------- state + +struct DelayLine { + buf: Vec<[f32; 2]>, + pos: usize, +} + +impl DelayLine { + fn new(len: usize) -> Self { + DelayLine { buf: vec![[0.0; 2]; len], pos: 0 } + } + /// Delay `x` in place. + fn run(&mut self, x: &mut [Vec; 2]) { + if self.buf.is_empty() { + return; + } + let n = self.buf.len(); + for i in 0..x[0].len() { + let out = self.buf[self.pos]; + self.buf[self.pos] = [x[0][i], x[1][i]]; + x[0][i] = out[0]; + x[1][i] = out[1]; + self.pos = (self.pos + 1) % n; + } + } + fn clear(&mut self) { + self.buf.fill([0.0; 2]); + } +} + +struct StripState { + pre: Vec>, + post: Vec>, + lat_pre: usize, + lat_post: usize, + /// Latency of the strip's input (bus alignment). + in_lat: usize, + /// Compensation delay per outgoing route (same order as the plan's routes from this strip). + delays: Vec, +} + +struct GraphState { + next_out: i64, + strips: Vec, + latency: usize, +} + +fn build_state(plan: &Plan, sr: u32) -> GraphState { + let mk = |v: &[Insert]| -> Vec> { v.iter().filter_map(|i| filmcraft_audio_dsp::create_effect(i.map.dsp, sr as f32, 2)).collect() }; + let mut strips: Vec = plan + .strips + .iter() + .map(|s| { + let pre = mk(&s.pre); + let post = mk(&s.post); + let lat_pre = pre.iter().map(|d| d.latency()).sum(); + let lat_post = post.iter().map(|d| d.latency()).sum(); + StripState { pre, post, lat_pre, lat_post, in_lat: 0, delays: Vec::new() } + }) + .collect(); + let tap_lat = |s: &StripState, tap: Tap| s.in_lat + if tap == Tap::Pre { s.lat_pre } else { s.lat_pre + s.lat_post }; + // strips are in topological order (routes only go forward) + for i in 0..strips.len() { + let in_lat = plan.routes.iter().filter(|r| r.target == i).map(|r| tap_lat(&strips[r.src], r.tap)).max().unwrap_or(0); + strips[i].in_lat = in_lat; + } + for r in &plan.routes { + let d = strips[r.target].in_lat - tap_lat(&strips[r.src], r.tap); + strips[r.src].delays.push(DelayLine::new(d)); + } + let m = strips.len() - 1; + let latency = strips[m].in_lat + strips[m].lat_pre + strips[m].lat_post; + GraphState { next_out: 0, strips, latency } +} + +fn cache() -> &'static Mutex>> { + static C: OnceLock>>> = OnceLock::new(); + C.get_or_init(Default::default) +} + +/// Total latency (samples) of the sequence's mixer graph: how far ahead of the timeline the graph +/// is read so its output lines up. +pub fn graph_latency(seq: &Sequence) -> usize { + let plan = Plan::build(seq, &HashMap::new()); + build_state(&plan, seq.settings.sample_rate).latency +} + +// ------------------------------------------------------------------------------------- processing + +type Stereo = [Vec; 2]; + +fn silent(n: usize) -> Stereo { + [vec![0.0; n], vec![0.0; n]] +} + +/// Run `chain` over `buf` (input-time samples from `x0`) with parameters at content time +/// `x - lat` updated on the absolute [`PARAM_BLOCK`] grid. +fn run_inserts(chain: &mut [Box], ins: &[Insert], buf: &mut Stereo, x0: i64, lat: usize, sr: u32) { + if chain.is_empty() { + return; + } + let n = buf[0].len(); + let mut i = 0usize; + while i < n { + let pos = x0 + i as i64; + let end = (((pos.div_euclid(PARAM_BLOCK) + 1) * PARAM_BLOCK - x0) as usize).min(n); + // parameters of the whole grid block (also when a request starts mid-block) + let t = Tick::from_units(pos.div_euclid(PARAM_BLOCK) * PARAM_BLOCK - lat as i64, sr as i64); + for (d, e) in chain.iter_mut().zip(ins) { + (e.map.apply)(d.as_mut(), &e.effect, t); + } + let [l, r] = buf; + let mut ch: [&mut [f32]; 2] = [&mut l[i..end], &mut r[i..end]]; + for d in chain.iter_mut() { + d.process(&mut ch); + } + i = end; + } +} + +/// Multiply by a per-sample (or constant) gain pair. +fn apply_gain(buf: &mut Stereo, g: Option, per: &[f32]) { + match g { + Some(1.0) => {} + Some(g) => { + for c in buf.iter_mut() { + for s in c.iter_mut() { + *s *= g; + } + } + } + None => { + for c in buf.iter_mut() { + for (s, g) in c.iter_mut().zip(per) { + *s *= g; + } + } + } + } +} + +/// Apply a pan/balance lane (values −100 … 100) with `law`. +fn apply_pan(buf: &mut Stereo, lane: &Lane, c0: i64, sr: u32, law: fn(f32) -> (f32, f32), scratch: &mut [f32]) { + match lane.fill(c0, sr, scratch, |v| v as f32) { + Some(p) => { + let (gl, gr) = law(p / 100.0); + if gl != 1.0 || gr != 1.0 { + buf[0].iter_mut().for_each(|s| *s *= gl); + buf[1].iter_mut().for_each(|s| *s *= gr); + } + } + None => { + for (i, p) in scratch.iter().enumerate() { + let (gl, gr) = law(p / 100.0); + buf[0][i] *= gl; + buf[1][i] *= gr; + } + } + } +} + +struct StripOut { + /// (route index in the plan, delayed signal) + routes: Vec<(usize, Stereo)>, + meter: [f32; 2], + /// Final signal (Mix only). + out: Option, +} + +#[allow(clippy::too_many_arguments)] +fn run_strip( + plan: &Plan, + si: usize, + st: &mut StripState, + mut buf: Stereo, + x0: i64, + skip: usize, + sr: u32, + ovs: &HashMap<(TrackId, String), Override>, +) -> StripOut { + let s = &plan.strips[si]; + let tr: &Track = &s.track; + let id = tr.id; + let n = buf[0].len(); + let my_routes: Vec<(usize, &RouteSpec)> = plan.routes.iter().enumerate().filter(|(_, r)| r.src == si).collect(); + let mut outs: Vec<(usize, Stereo)> = Vec::new(); + let mut scratch = vec![0f32; n]; + // input map / mono fold (tracks) + let mono = tr.channels == AudioChannels::Mono; + if s.kind != Kind::Master { + let [l, r] = &mut buf; + match tr.mixer.input_map { + InputMap::Stereo => {} + InputMap::Left => r.copy_from_slice(l), + InputMap::Right => l.copy_from_slice(r), + InputMap::Swap => std::mem::swap(l, r), + InputMap::Mono => { + for (a, b) in l.iter_mut().zip(r.iter_mut()) { + let m = (*a + *b) * 0.5; + *a = m; + *b = m; + } + } + } + if mono && tr.mixer.input_map != InputMap::Mono { + for (a, b) in l.iter_mut().zip(r.iter_mut()) { + let m = (*a + *b) * 0.5; + *a = m; + *b = m; + } + } + } + let lat_a = st.in_lat; + run_inserts(&mut st.pre, &s.pre, &mut buf, x0, lat_a, sr); + let lat_b = st.in_lat + st.lat_pre; + let c_b = x0 - lat_b as i64; + let send = |tap: Tap, buf: &Stereo, c0: i64, scratch: &mut [f32], st: &mut StripState, outs: &mut Vec<(usize, Stereo)>| { + for (k, (ri, r)) in my_routes.iter().enumerate() { + if r.tap != tap { + continue; + } + let mut sig = buf.clone(); + if let Some(si) = r.send { + let snd = &tr.mixer.sends[si]; + if snd.muted { + sig = silent(n); + } else { + let lane = Lane::new(tr, id, &send_lane(si), ovs); + let g = lane.fill(c0, sr, scratch, db_gain); + apply_gain(&mut sig, g, scratch); + let (gl, gr) = balance((snd.pan / 100.0) as f32); + if gl != 1.0 || gr != 1.0 { + sig[0].iter_mut().for_each(|x| *x *= gl); + sig[1].iter_mut().for_each(|x| *x *= gr); + } + } + } + st.delays[k].run(&mut sig); + outs.push((*ri, sig)); + } + }; + send(Tap::Pre, &buf, c_b, &mut scratch, st, &mut outs); + // mute → fader + let mute = Lane::new(tr, id, LANE_MUTE, ovs); + match mute.fill(c_b, sr, &mut scratch, |m| if m >= 0.5 { 0.0 } else { 1.0 }) { + Some(g) => apply_gain(&mut buf, Some(g), &[]), + None => apply_gain(&mut buf, None, &scratch), + } + let vol = Lane::new(tr, id, LANE_VOLUME, ovs); + let g = vol.fill(c_b, sr, &mut scratch, db_gain); + apply_gain(&mut buf, g, &scratch); + let mut meter = [0f32; 2]; + for (c, m) in meter.iter_mut().enumerate() { + *m = buf[c][skip.min(n)..].iter().fold(0f32, |a, x| a.max(x.abs())); + } + run_inserts(&mut st.post, &s.post, &mut buf, x0, lat_b, sr); + let lat_c = lat_b + st.lat_post; + let c_c = x0 - lat_c as i64; + send(Tap::Post, &buf, c_c, &mut scratch, st, &mut outs); + if s.kind == Kind::Master { + return StripOut { routes: outs, meter, out: Some(buf) }; + } + let pan = Lane::new(tr, id, LANE_PAN, ovs); + apply_pan(&mut buf, &pan, c_c, sr, if mono { pan_law_mono } else { balance }, &mut scratch); + send(Tap::Out, &buf, c_c, &mut scratch, st, &mut outs); + StripOut { routes: outs, meter, out: None } +} + +/// Mix `frames` stereo samples of the sequence starting at sample `start` through the mixer graph. +/// `live` adds the UI's held controls and receives the meters. +pub fn mix_graph(project: &Project, seq: &Sequence, start: i64, frames: usize, sources: &dyn SourceProvider, live: Option<&LiveMix>) -> AudioBuffer { + let sr = seq.settings.sample_rate.max(1); + let ovs = live.filter(|l| l.is_active()).map(LiveMix::overrides).unwrap_or_default(); + let plan = Plan::build(seq, &ovs); + let key = plan.structure_hash(sr); + let taken = { + let mut c = lock(cache()); + c.get_mut(&key).and_then(|v| v.iter().position(|g| g.next_out == start).map(|i| v.swap_remove(i))) + }; + let (mut st, skip) = match taken { + Some(st) => (st, 0usize), + None => { + let st = build_state(&plan, sr); + let pre = plan.preroll(Tick::from_units(start, sr as i64), sr).max(st.latency); + (st, pre) + } + }; + let lat = st.latency as i64; + let x0 = start + lat - skip as i64; + let total = skip + frames; + let ns = plan.strips.len(); + let mut bus: Vec> = (0..ns).map(|_| None).collect(); + let mut meters: Vec<(TrackId, [f32; 2])> = Vec::new(); + let range = filmcraft_time::TimeRange::from_bounds(Tick::from_units(x0, sr as i64), Tick::from_units(x0 + total as i64, sr as i64)); + // tracks: independent, in parallel + let ntr = plan.strips.iter().take_while(|s| s.kind == Kind::Track).count(); + let (track_states, rest) = st.strips.split_at_mut(ntr); + let track_outs: Vec> = track_states + .par_iter_mut() + .enumerate() + .map(|(si, ss)| { + let s = &plan.strips[si]; + if s.silent { + ss.delays.iter_mut().for_each(DelayLine::clear); + return None; + } + let has_clips = s.track.items.iter().any(|i| i.enabled && i.range().overlaps(&range)); + if !has_clips && s.pre.is_empty() && s.post.is_empty() && ss.delays.iter().all(|d| d.buf.is_empty()) { + return None; + } + let input = if has_clips { + let b = track_input(project, &s.track, x0, total, sr, sources); + let mut it = b.channels.into_iter(); + [it.next().unwrap_or_else(|| vec![0.0; total]), it.next().unwrap_or_else(|| vec![0.0; total])] + } else { + silent(total) + }; + Some(run_strip(&plan, si, ss, input, x0, skip, sr, &ovs)) + }) + .collect(); + let deliver = |o: StripOut, si: usize, bus: &mut Vec>, meters: &mut Vec<(TrackId, [f32; 2])>| { + meters.push((plan.strips[si].track.id, o.meter)); + for (ri, sig) in o.routes { + let t = plan.routes[ri].target; + let b = bus[t].get_or_insert_with(|| silent(total)); + for c in 0..2 { + for (d, s) in b[c].iter_mut().zip(&sig[c]) { + *d += *s; + } + } + } + o.out + }; + for (si, o) in track_outs.into_iter().enumerate() { + if let Some(o) = o { + deliver(o, si, &mut bus, &mut meters); + } + } + // submixes in order, then the Mix + let mut out = silent(total); + for (k, ss) in rest.iter_mut().enumerate() { + let si = ntr + k; + let s = &plan.strips[si]; + if s.silent { + ss.delays.iter_mut().for_each(DelayLine::clear); + continue; + } + let input = bus[si].take().unwrap_or_else(|| silent(total)); + let o = run_strip(&plan, si, ss, input, x0, skip, sr, &ovs); + if let Some(fin) = deliver(o, si, &mut bus, &mut meters) { + out = fin; + } + } + st.next_out = start + frames as i64; + { + let mut c = lock(cache()); + let v = c.entry(key).or_default(); + v.push(st); + if v.len() > 4 { + v.remove(0); + } + if c.len() > 64 { + c.clear(); + } + } + if let Some(l) = live { + l.post_meters(&meters); + } + let [l, r] = out; + AudioBuffer { sample_rate: sr, channels: vec![l[skip..].to_vec(), r[skip..].to_vec()] } +} diff --git a/crates/render/src/mixer_tests.rs b/crates/render/src/mixer_tests.rs new file mode 100644 index 000000000..d4270dae7 --- /dev/null +++ b/crates/render/src/mixer_tests.rs @@ -0,0 +1,434 @@ +//! Mixer graph tests: gain and pan laws, automation, solo/mute, sends, latency, identity, speed. + +use std::sync::Arc; + +use filmcraft_frame::{AudioBuffer, VideoFrame}; +use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource}; +use filmcraft_project::mixer::{LANE_MUTE, LANE_PAN, LANE_VOLUME, send_lane}; +use filmcraft_project::{ + AudioChannels, AutomationMode, ItemId, ItemKind, Keyframe, Label, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, Track, TrackId, TrackKind, + TrackSend, +}; +use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange}; + +use crate::SourceMap; +use crate::mixer::{LiveMix, balance, graph_latency, mix_graph, pan_law_mono}; + +const SR: i64 = 48_000; + +/// A synthetic stereo source: `f(sample, channel)`. +struct FnSource { + info: MediaInfo, + f: fn(i64, usize) -> f32, +} + +impl MediaSource for FnSource { + fn info(&self) -> &MediaInfo { + &self.info + } + fn video_frame(&self, _: FrameRequest) -> filmcraft_media::Result> { + Err(MediaError::Unsupported("audio only".into())) + } + fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + let f = self.f; + Ok(AudioBuffer { sample_rate, channels: (0..2).map(|c| (0..frames).map(|i| f(start + i as i64, c)).collect()).collect() }) + } +} + +struct Rig { + p: Project, + seq: ItemId, + map: SourceMap, +} + +impl Rig { + /// A sequence with `n` audio tracks, each holding one 10 s clip of its signal. + fn new(signals: &[fn(i64, usize) -> f32]) -> Rig { + let mut p = Project::new("mix"); + let seq = p.new_sequence("s", SequenceSettings::default(), 0, signals.len(), None); + let mut map = SourceMap::default(); + for (k, f) in signals.iter().enumerate() { + let info = MediaInfo { + name: format!("sig{k}"), + kind: MediaKind::AudioOnly, + duration: Tick(10 * TICKS_PER_SECOND), + video: None, + audio: Some(AudioStreamInfo { sample_rate: 48_000, channels: 2, codec: "test".into(), bits_per_sample: Some(32) }), + container: "test".into(), + start_timecode: None, + file_size: None, + }; + let id = p.add_item( + &info.name.clone(), + Label::Iris, + ItemKind::Media(MediaClip { + media: MediaRef::File { path: format!("/sig{k}.wav") }, + info: info.clone(), + interpret: Default::default(), + mark_in: None, + mark_out: None, + markers: vec![], + offline: false, + proxy: None, + }), + None, + ); + map.0.insert(id, Arc::new(FnSource { info, f: *f })); + let ti = p.make_track_item(id, TrackKind::Audio, Tick::ZERO, TimeRange::new(Tick::ZERO, Tick(10 * TICKS_PER_SECOND)), FrameRate::FPS_24).unwrap(); + p.sequence_mut(seq).unwrap().audio_tracks[k].items.push(ti); + } + Rig { p, seq, map } + } + fn track(&mut self, k: usize) -> &mut Track { + &mut self.p.sequence_mut(self.seq).unwrap().audio_tracks[k] + } + fn add_submix(&mut self, name: &str) -> TrackId { + let id = TrackId(self.p.alloc_id()); + self.p.sequence_mut(self.seq).unwrap().submix_tracks.push(Track::new(id, TrackKind::Audio, name.into())); + id + } + fn submix(&mut self, id: TrackId) -> &mut Track { + self.p.sequence_mut(self.seq).unwrap().mix_track_mut(id).unwrap() + } + fn mix(&self, start: i64, n: usize) -> [Vec; 2] { + let b = mix_graph(&self.p, self.p.sequence(self.seq).unwrap(), start, n, &self.map, None); + [b.channels[0].clone(), b.channels[1].clone()] + } + /// Mix `[start, start + n)` in consecutive requests of the given sizes (cycled). + fn mix_cut(&self, start: i64, n: usize, sizes: &[usize], live: Option<&LiveMix>) -> [Vec; 2] { + let mut out = [Vec::new(), Vec::new()]; + let (mut pos, mut k) = (start, 0); + while pos < start + n as i64 { + let m = sizes[k % sizes.len()].min((start + n as i64 - pos) as usize); + let b = mix_graph(&self.p, self.p.sequence(self.seq).unwrap(), pos, m, &self.map, live); + out[0].extend_from_slice(&b.channels[0]); + out[1].extend_from_slice(&b.channels[1]); + pos += m as i64; + k += 1; + } + out + } +} + +fn dc(_: i64, _: usize) -> f32 { + 0.5 +} + +fn kf(t_samples: i64, v: f64) -> Keyframe { + Keyframe::new(Tick::from_units(t_samples, SR), ParamValue::Float(v)) +} + +fn close(a: f32, b: f32, tol: f32) -> bool { + (a - b).abs() <= tol +} + +#[test] +fn fader_gain_is_sample_exact() { + let mut r = Rig::new(&[dc]); + r.track(0).volume_db = -6.0; + let [l, rr] = r.mix(1000, 256); + let g = 0.5 * 10f32.powf(-6.0 / 20.0); + assert!(l.iter().chain(&rr).all(|&s| close(s, g, 1e-6)), "{} vs {g}", l[0]); + // −∞ silences; +15 dB is the top of the fader + r.track(0).volume_db = -96.0; + assert!(r.mix(0, 64)[0].iter().all(|&s| s == 0.0)); + r.track(0).volume_db = 15.0; + assert!(close(r.mix(0, 64)[0][10], 0.5 * 10f32.powf(0.75), 1e-5)); + // Mix fader multiplies on top + r.track(0).volume_db = -6.0; + r.p.sequence_mut(r.seq).unwrap().master_volume_db = -6.0; + assert!(close(r.mix(0, 64)[0][3], 0.5 * 10f32.powf(-12.0 / 20.0), 1e-6)); +} + +#[test] +fn pan_laws() { + // mono track: −3 dB constant power (centre 0.7071 per side, power constant across the arc) + for p in [-100.0, -50.0, 0.0, 25.0, 100.0] { + let (l, r) = pan_law_mono((p / 100.0) as f32); + assert!(close(l * l + r * r, 1.0, 1e-6), "power at {p}"); + } + let (l, r) = pan_law_mono(0.0); + assert!(close(20.0 * l.log10(), -3.0103, 1e-3) && close(l, r, 1e-7)); + let mut rig = Rig::new(&[dc]); + rig.track(0).channels = AudioChannels::Mono; + rig.track(0).pan = 50.0; + let [l, r] = rig.mix(0, 128); + let a = 1.5 * std::f32::consts::FRAC_PI_4; + assert!(close(l[64], 0.5 * a.cos(), 1e-6) && close(r[64], 0.5 * a.sin(), 1e-6), "{} {}", l[64], r[64]); + rig.track(0).pan = -100.0; + let [l, r] = rig.mix(0, 128); + assert!(close(l[1], 0.5, 1e-6) && r[1].abs() < 1e-6); + // stereo track: balance (centre unity, far side attenuated) + rig.track(0).channels = AudioChannels::Stereo; + rig.track(0).pan = 0.0; + let [l, r] = rig.mix(0, 64); + assert!(close(l[5], 0.5, 1e-7) && close(r[5], 0.5, 1e-7)); + rig.track(0).pan = 50.0; + let (bl, br) = balance(0.5); + let [l, r] = rig.mix(0, 64); + assert!(close(l[5], 0.5 * bl, 1e-6) && close(r[5], 0.5 * br, 1e-6) && br == 1.0 && bl < 0.6); +} + +#[test] +fn volume_automation_is_sample_accurate_and_block_invariant() { + let mut r = Rig::new(&[dc]); + { + let t = r.track(0); + let lane = t.lane_mut(LANE_VOLUME).unwrap(); + lane.keyframes = vec![kf(0, 0.0), kf(1000, -20.0), kf(1500, -20.0), kf(1600, 0.0)]; + } + let whole = r.mix(0, 2000); + for k in [0usize, 1, 63, 64, 65, 127, 128, 500, 999, 1000, 1500, 1550, 1599, 1600] { + let db = if k <= 1000 { + -20.0 * k as f64 / 1000.0 + } else if k <= 1500 { + -20.0 + } else if k <= 1600 { + -20.0 + 20.0 * (k - 1500) as f64 / 100.0 + } else { + 0.0 + }; + let want = 0.5 * 10f64.powf(db / 20.0); + assert!((whole[0][k] as f64 - want).abs() < 1e-6, "sample {k}: {} vs {want}", whole[0][k]); + } + // the same range cut into odd-sized requests is bit-identical + for sizes in [&[1usize, 63, 64, 65][..], &[333], &[7, 1024, 5]] { + let cut = r.mix_cut(0, 2000, sizes, None); + assert_eq!(cut, whole, "cut {sizes:?}"); + } + // Off mode ignores the automation + r.track(0).mixer.mode = AutomationMode::Off; + assert!(close(r.mix(1200, 16)[0][0], 0.5, 1e-7)); +} + +#[test] +fn effect_and_pan_automation_on_the_block_grid() { + let mut r = Rig::new(&[dc]); + { + let t = r.track(0); + let mut amp = filmcraft_project::find_effect("amplify").unwrap().instance(); + let g = amp.param_mut("gain").unwrap(); + g.keyframes = vec![kf(0, 0.0), kf(4800, 6.0)]; + t.effects.push(amp); + t.lane_mut(LANE_PAN).unwrap().keyframes = vec![kf(0, -100.0), kf(4800, 100.0)]; + } + let whole = r.mix(0, 4800); + for sizes in [&[100usize, 17][..], &[64], &[4800]] { + assert_eq!(r.mix_cut(0, 4800, sizes, None), whole, "{sizes:?}"); + } + // hard left at the start, hard right at the end + assert!(whole[1][0].abs() < 1e-6 && whole[0][4799].abs() < 1e-3); +} + +#[test] +fn clip_volume_and_track_automation_both_apply() { + let mut r = Rig::new(&[dc]); + { + let t = r.track(0); + let it = &mut t.items[0]; + let lvl = it.effect_mut("volume").unwrap().param_mut("level").unwrap(); + lvl.keyframes = vec![Keyframe::new(Tick::ZERO, ParamValue::Float(-6.0)), Keyframe::new(Tick(TICKS_PER_SECOND), ParamValue::Float(-6.0))]; + t.lane_mut(LANE_VOLUME).unwrap().keyframes = vec![kf(0, -6.0), kf(48_000, -6.0)]; + } + let [l, _] = r.mix(100, 64); + assert!(close(l[0], 0.5 * 10f32.powf(-12.0 / 20.0), 1e-6), "{}", l[0]); +} + +fn level(r: &Rig) -> f32 { + r.mix(2000, 64)[0][10] +} + +fn dc1(_: i64, _: usize) -> f32 { + 0.1 +} +fn dc2(_: i64, _: usize) -> f32 { + 0.2 +} +fn dc4(_: i64, _: usize) -> f32 { + 0.4 +} + +#[test] +fn solo_and_mute_logic() { + let mut r = Rig::new(&[dc1, dc2, dc4]); + let s1 = r.add_submix("Submix 1"); + r.track(1).mixer.output = Some(s1); + assert!(close(level(&r), 0.7, 1e-6)); + r.track(0).solo = true; + assert!(close(level(&r), 0.1, 1e-6), "solo A1"); + r.track(0).muted = true; + assert!(close(level(&r), 0.0, 1e-6), "soloed but muted"); + r.track(0).muted = false; + r.track(0).solo = false; + // soloing a submix keeps its sources + r.submix(s1).solo = true; + assert!(close(level(&r), 0.2, 1e-6), "solo S1"); + r.submix(s1).solo = false; + // soloing a track keeps its destination submix + r.track(1).solo = true; + assert!(close(level(&r), 0.2, 1e-6), "solo A2 through S1"); + // solo safe: a solo-safe submix and its sources stay audible while others solo + let s2 = r.add_submix("Dialogue"); + r.track(2).mixer.output = Some(s2); + r.submix(s2).mixer.solo_safe = true; + assert!(close(level(&r), 0.6, 1e-6), "solo A2 + solo-safe S2"); + r.submix(s2).mixer.solo_safe = false; + assert!(close(level(&r), 0.2, 1e-6)); + r.track(1).solo = false; + // mute automation (hold) + r.track(2).lane_mut(LANE_MUTE).unwrap().keyframes = vec![kf(0, 0.0), kf(2005, 1.0)]; + let x = r.mix(2000, 10)[0].clone(); + assert!(close(x[4], 0.7, 1e-6) && close(x[5], 0.3, 1e-6), "{x:?}"); +} + +#[test] +fn sends_route_to_submixes() { + let mut r = Rig::new(&[dc]); + let fx = r.add_submix("Reverb"); + { + let t = r.track(0); + let mut s = TrackSend::new(fx); + s.level_db = -6.0; + t.mixer.sends.push(s); + } + let g6 = 10f32.powf(-6.0 / 20.0); + assert!(close(level(&r), 0.5 + 0.5 * g6, 1e-6), "post-fader send adds to the Mix"); + // post-fader send follows the fader + r.track(0).volume_db = -6.0; + assert!(close(level(&r), 0.5 * g6 + 0.5 * g6 * g6, 1e-6)); + // pre-fader send ignores fader and mute + r.track(0).mixer.sends[0].pre_fader = true; + r.track(0).muted = true; + assert!(close(level(&r), 0.5 * g6, 1e-6), "pre-fader send of a muted track"); + r.track(0).muted = false; + r.track(0).volume_db = -96.0; + assert!(close(level(&r), 0.5 * g6, 1e-6)); + // send level automation and submix fader + r.track(0).lane_mut(&send_lane(0)).unwrap().keyframes = vec![kf(0, 0.0), kf(4000, 0.0)]; + r.submix(fx).volume_db = -6.0; + assert!(close(level(&r), 0.5 * g6, 1e-6)); + // the track output can go to a submix; submixes chain forward only + r.track(0).volume_db = 0.0; + r.track(0).mixer.sends.clear(); + r.track(0).mixer.output = Some(fx); + let fx2 = r.add_submix("Bus 2"); + r.submix(fx).mixer.output = Some(fx2); + r.submix(fx2).volume_db = -6.0; + assert!(close(level(&r), 0.5 * g6 * g6, 1e-6)); + // a backwards route (feedback) falls back to the Mix + r.submix(fx2).mixer.output = Some(fx); + assert!(close(level(&r), 0.5 * g6 * g6, 1e-6)); +} + +fn impulse(i: i64, _: usize) -> f32 { + if i == 10_000 { 0.25 } else { 0.0 } +} + +#[test] +fn latency_compensation_aligns_paths() { + // A1 goes through a look-ahead limiter (latency), A2 is dry; both carry the same impulse. + let mut r = Rig::new(&[impulse, impulse]); + let mut lim = filmcraft_project::find_effect("hard_limiter").unwrap().instance(); + lim.param_mut("max").unwrap().value = ParamValue::Float(0.0); + r.track(0).effects.push(lim.clone()); + let lat = graph_latency(r.p.sequence(r.seq).unwrap()); + assert!(lat > 0, "limiter reports look-ahead"); + let [l, _] = r.mix(9_000, 2_000); + let peak = l.iter().enumerate().fold((0usize, 0f32), |m, (i, &s)| if s.abs() > m.1 { (i, s.abs()) } else { m }); + assert_eq!(peak.0 + 9_000, 10_000, "aligned impulse lands on its timeline sample"); + assert!(close(peak.1, 0.5, 1e-3), "both paths sum coherently: {}", peak.1); + // through a submix with its own latency, a send and a dry path stay aligned + let bus = r.add_submix("Bus"); + r.submix(bus).effects.push(lim); + r.track(1).mixer.sends.push(TrackSend::new(bus)); + let [l, _] = r.mix(9_000, 2_000); + let peak = l.iter().enumerate().fold((0usize, 0f32), |m, (i, &s)| if s.abs() > m.1 { (i, s.abs()) } else { m }); + assert_eq!(peak.0 + 9_000, 10_000); + assert!(close(peak.1, 0.75, 2e-3), "{}", peak.1); + // automation is evaluated at content time: a volume step on A1 at the impulse applies to it + r.track(0).lane_mut(LANE_VOLUME).unwrap().keyframes = vec![kf(0, 0.0), kf(9_999, 0.0), kf(10_000, -96.0)]; + r.track(0).lane_mut(LANE_VOLUME).unwrap().keyframes[1].interp = filmcraft_project::Interpolation::Hold; + let [l, _] = r.mix(9_000, 2_000); + assert!(close(l[1000], 0.5, 2e-3), "A1 silenced exactly at its content sample: {}", l[1000]); +} + +fn noise(i: i64, c: usize) -> f32 { + let x = (i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (c as u64 * 0x1234_5678); + let x = x ^ (x >> 29); + ((x.wrapping_mul(0xBF58_476D_1CE4_E5B9) >> 40) as f32 / (1u64 << 24) as f32 - 0.5) * 0.4 +} + +fn busy_rig(tracks: usize) -> Rig { + let sig: Vec f32> = vec![noise; tracks]; + let mut r = Rig::new(&sig); + let bus = r.add_submix("Bus"); + let mut comp = filmcraft_project::find_effect("dynamics").unwrap().instance(); + comp.param_mut("threshold").unwrap().value = ParamValue::Float(-20.0); + r.submix(bus).effects.push(comp); + for k in 0..tracks { + let t = r.track(k); + for (id, post) in [("parametric_eq", false), ("dynamics", false), ("studio_reverb", true)] { + let mut e = filmcraft_project::find_effect(id).unwrap().instance(); + e.post_fader = post; + t.effects.push(e); + } + t.pan = (k as f64 * 37.0) % 200.0 - 100.0; + t.lane_mut(LANE_VOLUME).unwrap().keyframes = vec![kf(0, -6.0), kf(96_000, 0.0)]; + if k % 3 == 0 { + t.mixer.sends.push(TrackSend::new(bus)); + } + } + r +} + +#[test] +fn render_and_playback_paths_are_identical() { + // export renders in large batches; playback pulls small device blocks with live meters + let r = busy_rig(4); + let export = r.mix_cut(0, 24_000, &[8_000], None); + let live = LiveMix::new(); + let playback = r.mix_cut(0, 24_000, &[512, 480, 1024], Some(&live)); + assert_eq!(export, playback); + assert!(export[0].iter().any(|s| s.abs() > 1e-3)); + let m = live.take_meters(); + assert!(m.get(&crate::mixer::MASTER).is_some_and(|p| p[0] > 0.0), "meters published"); + // deterministic: a second render from scratch is identical + let again = Rig::new(&[]); + drop(again); + assert_eq!(busy_rig(4).mix_cut(0, 24_000, &[8_000], None), export); +} + +#[test] +fn live_override_holds_and_ramps_back() { + let mut r = Rig::new(&[dc]); + r.track(0).lane_mut(LANE_VOLUME).unwrap().keyframes = vec![kf(0, 0.0), kf(96_000, 0.0)]; + let id = r.track(0).id; + let live = LiveMix::new(); + live.hold(id, LANE_VOLUME, -6.0); + let b = mix_graph(&r.p, r.p.sequence(r.seq).unwrap(), 0, 64, &r.map, Some(&live)); + assert!(close(b.channels[0][0], 0.5 * 10f32.powf(-0.3), 1e-6)); + // released at sample 4800 with a 4800-sample ramp: halfway back at 7200 + live.release(id, LANE_VOLUME, Tick::from_units(4800, SR), Tick::from_units(4800, SR)); + let b = mix_graph(&r.p, r.p.sequence(r.seq).unwrap(), 7200, 1, &r.map, Some(&live)); + assert!(close(b.channels[0][0], 0.5 * 10f32.powf(-3.0 / 20.0), 1e-6), "{}", b.channels[0][0]); + let b = mix_graph(&r.p, r.p.sequence(r.seq).unwrap(), 9600, 1, &r.map, Some(&live)); + assert!(close(b.channels[0][0], 0.5, 1e-6)); +} + +/// 24 tracks × 3 inserts (EQ, Dynamics, Studio Reverb) + a compressed submix at 48 kHz, one core. +#[test] +fn perf_24_tracks_3_effects_realtime_factor() { + let r = busy_rig(24); + let secs = if cfg!(debug_assertions) { 2.0 } else { 10.0 }; + let n = (secs * SR as f64) as usize; + let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap(); + // warm up (fresh graph + pre-roll), then time steady-state playback-sized blocks + pool.install(|| r.mix_cut(0, 4_800, &[1024], None)); + let t0 = std::time::Instant::now(); + pool.install(|| r.mix_cut(4_800, n, &[1024], None)); + let el = t0.elapsed().as_secs_f64(); + let rt = secs / el; + eprintln!("mixer perf: 24 tracks x 3 inserts + submix, {secs} s of 48 kHz stereo in {el:.3} s on one core = {rt:.1}x realtime"); + let min = if cfg!(debug_assertions) { 1.0 } else { 4.0 }; + assert!(rt > min, "realtime factor {rt:.2}"); +} diff --git a/crates/render/src/preview.rs b/crates/render/src/preview.rs index 11769c0a4..8f0a2a249 100644 --- a/crates/render/src/preview.rs +++ b/crates/render/src/preview.rs @@ -247,10 +247,12 @@ pub fn audio_segments(project: &Project, seq_id: ItemId) -> Vec { cuts.dedup(); let any_solo = seq.audio_tracks.iter().any(|t| t.solo); let mut global = Fnv128::new(); - global.json(&(HASH_VERSION, "audio", sr, seq.master_volume_db, &seq.master_effects, any_solo)); + global.json(&(HASH_VERSION, "audio", sr, seq.master_volume_db, &seq.master_effects, &seq.master_mixer, any_solo)); for (ti, tr) in seq.audio_tracks.iter().enumerate() { - global.json(&(ti, tr.muted, tr.solo, tr.volume_db, tr.pan, tr.channels, &tr.effects)); + global.json(&(ti, tr.muted, tr.solo, tr.volume_db, tr.pan, tr.channels, &tr.effects, &tr.mixer)); } + // submixes (routing, inserts, automation) affect every segment + global.json(&seq.submix_tracks); let mut out = Vec::new(); for w in cuts.windows(2) { let (a, b) = (w[0], w[1]); From 491855590630953e926f8ac9d92a86cc2b6d260a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 01:38:04 -0400 Subject: [PATCH 16/24] =?UTF-8?q?M10.2:=20graphic=20clips=20=E2=80=94=20te?= =?UTF-8?q?xt=20and=20shape=20layers=20in=20the=20project=20model,=20vecto?= =?UTF-8?q?r=20renderer=20(CPU=20+=20GPU=20plan),=20graphics.*=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project: ItemKind::Graphic (transparent canvas source, hidden from the Project panel, unlimited duration); graphic layers are hidden effect instances graphic_text / graphic_shape on the clip (serde for free, keyframes on every animatable property, Source Text holds), with appearance (fill, two strokes outer/centre/inner, background box with radius, drop shadow) and transform (position, anchor, scale, rotation, opacity); eval_layer gives a plain LayerSpec. No schema migration: all additive. - render: graphic_clip rasterises each layer straight to the output transform (crisp at any Motion scale/rotation), caches layer rasters, composes a tight image for the GPU plan; standard effects on a graphic run at source resolution first. - engine: graphics.newText (Cmd+T) / newShape (new clip above the footage at the playhead or a layer in an existing clip), setText (typing coalesces into one undo step), set, selectLayer, deleteLayer, arrangeLayer, align (frame / selection), distribute, graphics.list, fonts.list. EditorState.graphic_layers holds the layer selection. - goldens: graphic_title, graphic_shapes (CPU and GPU parity). Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 2 + Cargo.lock | 1 + crates/engine/Cargo.toml | 1 + crates/engine/src/commands.rs | 6 +- crates/engine/src/graphics.rs | 551 ++++++++++++++++++ crates/engine/src/graphics_tests.rs | 130 +++++ crates/engine/src/lib.rs | 6 +- crates/golden/goldens/graphic_shapes.png | Bin 0 -> 31149 bytes .../goldens/graphic_shapes.png.attribution | 8 + crates/golden/goldens/graphic_title.png | Bin 0 -> 17311 bytes .../goldens/graphic_title.png.attribution | 8 + crates/golden/tests/golden.rs | 110 +++- crates/interchange/src/lib.rs | 2 +- crates/project/src/effect.rs | 1 + crates/project/src/graphic.rs | 419 +++++++++++++ crates/project/src/lib.rs | 21 +- crates/render/src/graphic_clip.rs | 469 +++++++++++++++ crates/render/src/lib.rs | 20 +- crates/render/src/plan.rs | 14 + crates/render/src/preview.rs | 3 + crates/ui-egui/src/panels/project.rs | 6 +- 21 files changed, 1767 insertions(+), 11 deletions(-) create mode 100644 crates/engine/src/graphics.rs create mode 100644 crates/engine/src/graphics_tests.rs create mode 100644 crates/golden/goldens/graphic_shapes.png create mode 100644 crates/golden/goldens/graphic_shapes.png.attribution create mode 100644 crates/golden/goldens/graphic_title.png create mode 100644 crates/golden/goldens/graphic_title.png.attribution create mode 100644 crates/project/src/graphic.rs create mode 100644 crates/render/src/graphic_clip.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 148f352a9..5374a629a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -37,6 +37,8 @@ procedural demo footage (`crates/media/src/generators.rs`); no external media. R | `crates/golden/goldens/blend_modes.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `blend_modes`) from procedurally generated media | MIT OR Apache-2.0 | | `crates/golden/goldens/crop.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `crop`) from procedurally generated media | MIT OR Apache-2.0 | | `crates/golden/goldens/gaussian_blur.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `gaussian_blur`) from procedurally generated media | MIT OR Apache-2.0 | +| `crates/golden/goldens/graphic_shapes.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `graphic_shapes`) from procedurally generated media and bundled OFL fonts | MIT OR Apache-2.0 | +| `crates/golden/goldens/graphic_title.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `graphic_title`) from procedurally generated media and bundled OFL fonts | MIT OR Apache-2.0 | | `crates/golden/goldens/lumetri_basic.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `lumetri_basic`) from procedurally generated media | MIT OR Apache-2.0 | | `crates/golden/goldens/text_burnin.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `text_burnin`) from procedurally generated media | MIT OR Apache-2.0 | | `crates/golden/goldens/transform_opacity.png` | FilmCraft contributors | Original work: golden reference rendered by FilmCraft (crates/golden/tests/golden.rs, scene `transform_opacity`) from procedurally generated media | MIT OR Apache-2.0 | diff --git a/Cargo.lock b/Cargo.lock index 86481ba91..341e8fc68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1436,6 +1436,7 @@ dependencies = [ "filmcraft-media", "filmcraft-project", "filmcraft-render", + "filmcraft-text", "filmcraft-time", "log", "serde", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index b8fb90c7d..287c1a325 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -19,6 +19,7 @@ filmcraft-codecs = { workspace = true } filmcraft-project = { workspace = true } filmcraft-edit = { workspace = true } filmcraft-render = { workspace = true } +filmcraft-text = { workspace = true } filmcraft-export = { workspace = true } filmcraft-format = { workspace = true } filmcraft-interchange = { workspace = true } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 687721f3b..565c5d06b 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1575,6 +1575,9 @@ fn build() -> Vec { cmd!("effects.apply", "Apply Effect", [], None, r#"{"clips":[id]?,"effect":"gaussian_blur|Gaussian Blur|…"}"#, has_seq, |s, p| { let name = str_p(p, "effect").ok_or_else(|| bad("effects.apply", "need `effect`"))?; let def = filmcraft_project::effect::find_effect_by_name(name).ok_or_else(|| bad("effects.apply", format!("unknown effect `{name}`")))?; + if filmcraft_project::graphic::is_layer_id(def.id) { + return Err(bad("effects.apply", "graphic layers are added with graphics.newText / graphics.newShape")); + } if matches!(def.kind, filmcraft_project::EffectKind::VideoTransition | filmcraft_project::EffectKind::AudioTransition) { let mut q = p.clone(); q["effect"] = json!(def.id); @@ -1814,6 +1817,7 @@ fn build() -> Vec { )), ]; v.extend(crate::captions::commands()); + v.extend(crate::graphics::commands()); // Labels as individual commands (Edit ▸ Label ▸ ) for l in Label::ALL { let _ = l; @@ -1982,7 +1986,7 @@ fn mark(s: &mut Session, p: &Value, is_in: bool) -> Result { Ok(Value::Null) } -fn json_to_param(template: &ParamValue, v: &Value) -> Option { +pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option { Some(match template { ParamValue::Float(_) => ParamValue::Float(v.as_f64()?), ParamValue::Vec2(_) => { diff --git a/crates/engine/src/graphics.rs b/crates/engine/src/graphics.rs new file mode 100644 index 000000000..f71fb8da1 --- /dev/null +++ b/crates/engine/src/graphics.rs @@ -0,0 +1,551 @@ +//! Graphics commands (`graphics.*`, `fonts.list`): graphic clips with text and shape layers. +//! +//! A graphic clip's layers are the `graphic_text` / `graphic_shape` effect instances on the clip +//! (see `filmcraft_project::graphic`). Commands address a clip by `clip` (default: the first +//! selected graphic clip, else the topmost graphic clip under the playhead) and a layer by +//! `layer` = index among the clip's graphic layers, 0 = back (default: the selected layer, else +//! the frontmost). Property values are keyframe-aware: on an animated property they set the value +//! at the playhead. + +use filmcraft_geom::Vec2; +use filmcraft_project::graphic::{self, LayerContent, SHAPE_OPTS, eval_layer, layer_display_name, layer_indices, new_shape_layer, new_text_layer}; +use filmcraft_project::{ClipId, ItemId, ItemKind, Label, ParamValue, Sequence, TrackKind}; +use filmcraft_render::graphic_clip::{layer_local_bounds, layer_quad}; +use filmcraft_time::{Tick, TimeRange}; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, always, bad, f64_p, has_seq, str_p, time_p, u64_p}; +use crate::{EngineError, Result, Session}; + +type Run = fn(&mut Session, &Value) -> Result; +type Enabled = fn(&Session) -> std::result::Result<(), String>; + +fn spec( + id: &'static str, + label: &'static str, + menu: &'static [&'static str], + shortcut: Option<&'static str>, + params: &'static str, + enabled: Enabled, + run: Run, +) -> CommandSpec { + CommandSpec { id, label, menu, shortcut, params, enabled, run, journal: true } +} +fn query(id: &'static str, label: &'static str, params: &'static str, run: Run) -> CommandSpec { + CommandSpec { id, label, menu: &[], shortcut: None, params, enabled: always, run, journal: false } +} + +/// Whether a track item is a graphic clip. +pub fn is_graphic(s: &Session, seq: &Sequence, c: ClipId) -> bool { + seq.find_item(c).and_then(|(_, it)| s.project.item(it.item)).is_some_and(|p| matches!(p.kind, ItemKind::Graphic { .. })) +} + +/// The graphic clip a command acts on: `clip`, else the first selected graphic clip, else the +/// topmost graphic clip under the playhead. +pub fn target_clip(s: &Session, p: &Value) -> Option { + let seq = s.active_sequence()?; + if let Some(c) = u64_p(p, "clip").map(ClipId) { + return is_graphic(s, seq, c).then_some(c); + } + if let Some(c) = s.state.selection.iter().copied().find(|c| is_graphic(s, seq, *c)) { + return Some(c); + } + let t = s.playhead(); + seq.video_tracks.iter().rev().filter_map(|tr| tr.item_at(t)).map(|it| it.id).find(|c| is_graphic(s, seq, *c)) +} + +fn has_graphic(s: &Session) -> std::result::Result<(), String> { + has_seq(s)?; + target_clip(s, &Value::Null).map(|_| ()).ok_or_else(|| "select a graphic clip".into()) +} + +/// Effect index of graphic layer `layer` (or the selected / frontmost layer) of `clip`. +fn layer_effect_index(s: &Session, clip: ClipId, p: &Value) -> Result<(usize, usize)> { + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (_, it) = seq.find_item(clip).ok_or_else(|| bad("graphics", "no such clip"))?; + let idx = layer_indices(&it.effects); + if idx.is_empty() { + return Err(bad("graphics", "the graphic has no layers")); + } + let l = match p.get("layer") { + Some(Value::Number(n)) => n.as_u64().unwrap_or(0) as usize, + Some(Value::String(name)) => (0..idx.len()) + .find(|&i| layer_display_name(&it.effects[idx[i]], i).eq_ignore_ascii_case(name)) + .ok_or_else(|| bad("graphics", format!("no layer named `{name}`")))?, + _ => s.state.graphic_layers.first().copied().filter(|l| *l < idx.len()).unwrap_or(idx.len() - 1), + }; + let e = *idx.get(l).ok_or_else(|| bad("graphics", format!("no layer {l}")))?; + Ok((l, e)) +} + +fn vec2_p(p: &Value, k: &str) -> Option { + let a = p.get(k)?.as_array()?; + Some(Vec2::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)) +} + +/// Find or create the graphic source item for the active sequence's frame size. +fn graphic_source(p: &mut filmcraft_project::Project, w: u32, h: u32, rate: filmcraft_time::FrameRate) -> ItemId { + if let Some((id, _)) = + p.items.iter().find(|(_, i)| matches!(i.kind, ItemKind::Graphic { width, height, rate: r } if width == w && height == h && r == rate)) + { + return *id; + } + p.add_item("Graphic", Label::Rose, ItemKind::Graphic { width: w, height: h, rate }, None) +} + +/// Place a new graphic clip holding `layer` at the playhead: on the first video track above the +/// topmost clip at the playhead that is free for the duration (a track is added if needed). +fn new_graphic_clip(s: &mut Session, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result { + let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; + let t = time_p(s, p, "").unwrap_or_else(|| s.playhead()); + let seconds = f64_p(p, "seconds").unwrap_or(5.0).max(0.01); + let want_track = u64_p(p, "track").map(|v| v as usize); + let name = name.to_string(); + s.edit("New Graphic", |pr, st| { + let (w, h, rate) = { + let q = pr.sequence(seq_id).ok_or(EngineError::NoSequence)?; + (q.settings.width, q.settings.height, q.settings.frame_rate) + }; + let src = graphic_source(pr, w, h, rate); + let t = rate.snap(t); + let dur = rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration()); + let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad("graphics.newText", "bad item"))?; + ti.name = name; + ti.effects.push(layer); + for e in &mut ti.effects { + filmcraft_project::resolve_auto_points(e, (w, h), (w, h)); + } + let id = ti.id; + let track_id = filmcraft_project::TrackId(pr.alloc_id()); + let q = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?; + let range = TimeRange::new(t, dur); + let free = |tr: &filmcraft_project::Track| !tr.items.iter().any(|i| i.range().overlaps(&range)); + let idx = match want_track { + Some(i) if i < q.video_tracks.len() && free(&q.video_tracks[i]) => i, + Some(i) if i < q.video_tracks.len() => return Err(bad("graphics.newText", format!("V{} is not free here", i + 1))), + _ => { + let top = q.video_tracks.iter().rposition(|tr| tr.item_at(t).is_some()).map_or(0, |k| k + 1); + match (top..q.video_tracks.len()).find(|&i| free(&q.video_tracks[i]) && !q.video_tracks[i].locked) { + Some(i) => i, + None => { + let n = q.video_tracks.len() + 1; + q.video_tracks.push(filmcraft_project::Track::new(track_id, TrackKind::Video, format!("Video {n}"))); + q.video_tracks.len() - 1 + } + } + } + }; + q.video_tracks[idx].items.push(ti); + q.video_tracks[idx].sort(); + q.check().map_err(EngineError::Other)?; + st.selection = vec![id]; + st.graphic_layers = vec![0]; + Ok(id) + }) +} + +/// Add a layer to an existing graphic clip; returns the new layer index. +fn add_layer(s: &mut Session, clip: ClipId, mut layer: filmcraft_project::EffectInstance) -> Result { + let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); + filmcraft_project::resolve_auto_points(&mut layer, frame, frame); + s.edit_sequence("Add Graphic Layer", |q, _, st| { + let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?; + // new layers go in front: after the last graphic layer + let pos = layer_indices(&it.effects).last().map_or(it.effects.len(), |l| l + 1); + it.effects.insert(pos, layer); + let n = layer_indices(&it.effects).len() - 1; + st.selection = vec![clip]; + st.graphic_layers = vec![n]; + Ok(n) + }) +} + +/// Map friendly property names to parameter ids. +fn param_id(k: &str) -> &str { + match k { + "fontSize" => "size", + "fontStyle" | "style" => "font_style", + "fillColor" | "color" => "fill_color", + "strokeColor" => "stroke_color", + "strokeWidth" => "stroke_width", + "backgroundColor" => "background_color", + "shadowColor" => "shadow_color", + "baselineShift" => "baseline_shift", + "fauxBold" => "faux_bold", + "fauxItalic" => "faux_italic", + "boxWidth" => "box_width", + "anchorPoint" => "anchor", + "cornerRadius" => "corner_radius", + other => other, + } +} + +/// JSON value for a property, accepting names for choices ("center", "small caps", "ellipse"…). +fn to_param(template: &ParamValue, id: &str, v: &Value) -> Option { + if let (ParamValue::Choice(_), Value::String(name)) = (template, v) { + let opts: &[&str] = match id { + "align" => graphic::ALIGN_OPTS, + "caps" => graphic::CAPS_OPTS, + "stroke_type" | "stroke2_type" => graphic::STROKE_OPTS, + "shape" => SHAPE_OPTS, + _ => &[], + }; + let n = name.to_ascii_lowercase().replace('_', " "); + let n = if n == "centre" { "center".to_string() } else { n }; + return opts.iter().position(|o| o.to_ascii_lowercase() == n).map(|i| ParamValue::Choice(i as u32)); + } + crate::commands::json_to_param(template, v) +} + +/// Set properties `props` on a layer (keyframe-aware at time `tl`). +fn set_props(s: &mut Session, clip: ClipId, eidx: usize, props: &serde_json::Map, tl: Tick, label: &str) -> Result<()> { + let props = props.clone(); + s.edit_sequence(label, |q, _, _| { + let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?; + let mt = it.source_time_at(tl.clamp(it.start, it.end() - Tick(1))); + let e = it.effects.get_mut(eidx).ok_or_else(|| bad("graphics.set", "no such layer"))?; + for (k, v) in &props { + if k == "enabled" { + e.enabled = v.as_bool().unwrap_or(true); + continue; + } + let id = param_id(k); + let prm = e.params.get_mut(id).ok_or_else(|| bad("graphics.set", format!("no property `{k}`")))?; + let pv = to_param(&prm.value, id, v).ok_or_else(|| bad("graphics.set", format!("`{k}`: value has the wrong type")))?; + prm.set_at(mt, pv); + } + Ok(()) + }) +} + +/// Layers of a graphic clip with their evaluated bounds (sequence/canvas pixels). +fn list_layers(s: &Session, clip: ClipId) -> Result { + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (_, it) = seq.find_item(clip).ok_or_else(|| bad("graphics.list", "no such clip"))?; + let size = match s.project.item(it.item).map(|p| &p.kind) { + Some(ItemKind::Graphic { width, height, .. }) => (*width, *height), + _ => (seq.settings.width, seq.settings.height), + }; + let ph = s.playhead(); + let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1))); + let layers: Vec = layer_indices(&it.effects) + .iter() + .enumerate() + .filter_map(|(i, &ei)| { + let e = &it.effects[ei]; + let sp = eval_layer(e, mt, size)?; + let q = layer_quad(&sp); + let (kind, text) = match &sp.content { + LayerContent::Text(t) => ("text", Some(t.text.clone())), + LayerContent::Shape(sh) => (SHAPE_OPTS.get(sh.shape as usize).copied().unwrap_or("Shape"), None), + }; + Some(json!({ + "layer": i, + "effectIndex": ei, + "name": layer_display_name(e, i), + "kind": kind, + "text": text, + "enabled": e.enabled, + "position": [sp.transform.position.x, sp.transform.position.y], + "localBounds": layer_local_bounds(&sp), + "quad": q.iter().map(|p| [p.x, p.y]).collect::>(), + })) + }) + .collect(); + Ok(json!({"clip": clip.0, "name": it.name, "start": it.start.0, "duration": it.duration.0, "canvas": [size.0, size.1], "layers": layers})) +} + +fn quad_bounds(q: &[Vec2; 4]) -> [f64; 4] { + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for p in q { + b = [b[0].min(p.x), b[1].min(p.y), b[2].max(p.x), b[3].max(p.y)]; + } + b +} + +/// Layer bounds of `layers` (layer indices) of `clip` at the playhead: (effect index, position, bounds). +fn layer_boxes(s: &Session, clip: ClipId, layers: &[usize]) -> Result<(Vec<(usize, Vec2, [f64; 4])>, (u32, u32))> { + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (_, it) = seq.find_item(clip).ok_or_else(|| bad("graphics.align", "no such clip"))?; + let size = match s.project.item(it.item).map(|p| &p.kind) { + Some(ItemKind::Graphic { width, height, .. }) => (*width, *height), + _ => (seq.settings.width, seq.settings.height), + }; + let mt = it.source_time_at(s.playhead().clamp(it.start, it.end() - Tick(1))); + let idx = layer_indices(&it.effects); + let mut out = Vec::new(); + for &l in layers { + let ei = *idx.get(l).ok_or_else(|| bad("graphics.align", format!("no layer {l}")))?; + let sp = eval_layer(&it.effects[ei], mt, size).ok_or_else(|| bad("graphics.align", "bad layer"))?; + out.push((ei, sp.transform.position, quad_bounds(&layer_quad(&sp)))); + } + Ok((out, size)) +} + +fn layers_p(s: &Session, clip: ClipId, p: &Value) -> Result> { + if let Some(a) = p.get("layers").and_then(Value::as_array) { + return Ok(a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()); + } + if !s.state.graphic_layers.is_empty() { + return Ok(s.state.graphic_layers.clone()); + } + let (l, _) = layer_effect_index(s, clip, p)?; + Ok(vec![l]) +} + +fn move_layers(s: &mut Session, clip: ClipId, moves: Vec<(usize, Vec2)>, label: &str) -> Result<()> { + let tl = s.playhead(); + s.edit_sequence(label, |q, _, _| { + let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?; + let mt = it.source_time_at(tl.clamp(it.start, it.end() - Tick(1))); + for (ei, pos) in &moves { + if let Some(prm) = it.effects.get_mut(*ei).and_then(|e| e.params.get_mut("position")) { + prm.set_at(mt, ParamValue::Vec2(*pos)); + } + } + Ok(()) + }) +} + +pub fn commands() -> Vec { + vec![ + spec( + "graphics.newText", + "Text", + &["Graphics and Titles", "New Layer"], + Some("Cmd+T"), + r#"{"text":str="New Text","position":[x,y]?,"clip":id?,"newClip":bool?,"size":px=100,"font":str?,"fontStyle":str?,"seconds":f64=5,"track":index?,"time":ticks?}"#, + has_seq, + |s, p| { + let text = str_p(p, "text").unwrap_or("New Text").to_string(); + let (w, h) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); + let pos = vec2_p(p, "position").unwrap_or(Vec2::new(w as f64 / 2.0, h as f64 / 2.0)); + let mut layer = new_text_layer(&text, pos, f64_p(p, "size").unwrap_or(100.0)); + for (k, id) in [("font", "font"), ("fontStyle", "font_style")] { + if let Some(v) = str_p(p, k) { + layer.params.insert(id.into(), filmcraft_project::Param::new(ParamValue::Text(v.into()))); + } + } + let into = if p.get("newClip").and_then(Value::as_bool) == Some(true) { None } else { u64_p(p, "clip").map(ClipId) }; + let into = into.filter(|c| s.active_sequence().is_some_and(|q| is_graphic(s, q, *c))); + let (clip, layer_i) = match into { + Some(c) => (c, add_layer(s, c, layer)?), + None => { + let name = text.lines().next().filter(|l| !l.trim().is_empty()).unwrap_or("Graphic").to_string(); + (new_graphic_clip(s, layer, &name, p)?, 0) + } + }; + Ok(json!({"clip": clip.0, "layer": layer_i})) + }, + ), + spec( + "graphics.newShape", + "Shape", + &["Graphics and Titles", "New Layer"], + None, + r#"{"shape":"rectangle|ellipse|polygon|path","position":[x,y]?,"size":[w,h]=[400,200],"points":[[x,y],…]?,"clip":id?,"seconds":f64=5}"#, + has_seq, + |s, p| { + let shape = str_p(p, "shape").unwrap_or("rectangle").to_ascii_lowercase(); + let k = SHAPE_OPTS + .iter() + .position(|o| o.to_ascii_lowercase() == shape) + .ok_or_else(|| bad("graphics.newShape", format!("unknown shape `{shape}`")))? as u32; + let (w, h) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); + let pos = vec2_p(p, "position").unwrap_or(Vec2::new(w as f64 / 2.0, h as f64 / 2.0)); + let size = vec2_p(p, "size").unwrap_or(Vec2::new(400.0, 200.0)); + let points: Vec<[f32; 2]> = p + .get("points") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|q| Some([q.get(0)?.as_f64()? as f32, q.get(1)?.as_f64()? as f32])).collect()) + .unwrap_or_default(); + if k == 3 && points.len() < 3 { + return Err(bad("graphics.newShape", "a path needs at least 3 points")); + } + let layer = new_shape_layer(k, pos, size, points); + let into = u64_p(p, "clip").map(ClipId).filter(|c| s.active_sequence().is_some_and(|q| is_graphic(s, q, *c))); + let (clip, layer_i) = match into { + Some(c) => (c, add_layer(s, c, layer)?), + None => (new_graphic_clip(s, layer, "Shape", p)?, 0), + }; + Ok(json!({"clip": clip.0, "layer": layer_i})) + }, + ), + spec( + "graphics.setText", + "Edit Text", + &[], + None, + r#"{"clip":id?,"layer":n|name?,"text":str,"merge":bool? (coalesce with the previous Edit Text undo step)}"#, + has_graphic, + |s, p| { + let clip = target_clip(s, p).ok_or_else(|| bad("graphics.setText", "no graphic clip"))?; + let (_, ei) = layer_effect_index(s, clip, p)?; + let text = str_p(p, "text").ok_or_else(|| bad("graphics.setText", "need `text`"))?.to_string(); + let merge = p.get("merge").and_then(Value::as_bool).unwrap_or(false) && s.history.undo.last().is_some_and(|h| h.0 == "Edit Text"); + let mut props = serde_json::Map::new(); + props.insert("text".into(), Value::String(text)); + let ph = s.playhead(); + set_props(s, clip, ei, &props, ph, "Edit Text")?; + if merge && s.history.undo.len() >= 2 { + // keep the snapshot from before the typing session + s.history.undo.pop(); + } + Ok(Value::Null) + }, + ), + spec( + "graphics.set", + "Set Graphic Properties", + &[], + None, + r##"{"clip":id?,"layer":n|name?,"props":{"font":"Inter","font_style":"Bold","size":120,"align":"center","tracking":50,"leading":0,"fill_color":"#ffcc00","stroke":true,"stroke_width":6,"background":true,"shadow":true,"position":[x,y],"scale":100,"rotation":0,"opacity":100,…},"time":ticks?}"##, + has_graphic, + |s, p| { + let clip = target_clip(s, p).ok_or_else(|| bad("graphics.set", "no graphic clip"))?; + let (_, ei) = layer_effect_index(s, clip, p)?; + let props = p.get("props").and_then(Value::as_object).ok_or_else(|| bad("graphics.set", "need `props`"))?.clone(); + let tl = time_p(s, p, "").unwrap_or_else(|| s.playhead()); + set_props(s, clip, ei, &props, tl, "Change Graphic Property")?; + Ok(Value::Null) + }, + ), + spec("graphics.selectLayer", "Select Graphic Layer", &[], None, r#"{"clip":id?,"layers":[n]}"#, has_graphic, |s, p| { + let clip = target_clip(s, p).ok_or_else(|| bad("graphics.selectLayer", "no graphic clip"))?; + let layers: Vec = + p.get("layers").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()).unwrap_or_default(); + s.state.selection = vec![clip]; + s.state.graphic_layers = layers; + Ok(Value::Null) + }), + spec("graphics.deleteLayer", "Delete Graphic Layer", &[], None, r#"{"clip":id?,"layer":n?}"#, has_graphic, |s, p| { + let clip = target_clip(s, p).ok_or_else(|| bad("graphics.deleteLayer", "no graphic clip"))?; + let (_, ei) = layer_effect_index(s, clip, p)?; + s.edit_sequence("Delete Graphic Layer", |q, _, st| { + let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?; + it.effects.remove(ei); + st.graphic_layers.clear(); + Ok(()) + })?; + Ok(Value::Null) + }), + spec( + "graphics.arrangeLayer", + "Arrange Graphic Layer", + &[], + None, + r#"{"clip":id?,"layer":n?,"to":"front|back|forward|backward"|index}"#, + has_graphic, + |s, p| { + let clip = target_clip(s, p).ok_or_else(|| bad("graphics.arrangeLayer", "no graphic clip"))?; + let (l, _) = layer_effect_index(s, clip, p)?; + let to = p.get("to").cloned().unwrap_or(json!("front")); + let n = s.state.graphic_layers.len(); + let _ = n; + s.edit_sequence("Arrange Graphic Layer", |q, _, st| { + let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?; + let idx = layer_indices(&it.effects); + let last = idx.len() - 1; + let dest = match &to { + Value::Number(v) => (v.as_u64().unwrap_or(0) as usize).min(last), + Value::String(w) => match w.as_str() { + "front" => last, + "back" => 0, + "forward" => (l + 1).min(last), + "backward" => l.saturating_sub(1), + o => return Err(bad("graphics.arrangeLayer", format!("unknown `to` `{o}`"))), + }, + _ => last, + }; + let e = it.effects.remove(idx[l]); + let mut idx2 = layer_indices(&it.effects); + let insert_at = if dest >= idx2.len() { idx2.pop().map_or(idx[0], |x| x + 1) } else { idx2[dest] }; + it.effects.insert(insert_at, e); + st.graphic_layers = vec![dest]; + Ok(()) + })?; + Ok(Value::Null) + }, + ), + spec( + "graphics.align", + "Align Layers", + &[], + None, + r#"{"clip":id?,"layers":[n]?,"align":"left|hcenter|right|top|vcenter|bottom","to":"frame|selection"="frame" (one layer always aligns to the frame)}"#, + has_graphic, + |s, p| { + let clip = target_clip(s, p).ok_or_else(|| bad("graphics.align", "no graphic clip"))?; + let layers = layers_p(s, clip, p)?; + let (boxes, size) = layer_boxes(s, clip, &layers)?; + let how = str_p(p, "align").ok_or_else(|| bad("graphics.align", "need `align`"))?; + let to_sel = str_p(p, "to") == Some("selection") && boxes.len() > 1; + let r = if to_sel { + boxes.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |a, b| [a[0].min(b.2[0]), a[1].min(b.2[1]), a[2].max(b.2[2]), a[3].max(b.2[3])]) + } else { + [0.0, 0.0, size.0 as f64, size.1 as f64] + }; + let mut moves = Vec::new(); + for (ei, pos, b) in boxes { + let (dx, dy) = match how { + "left" => (r[0] - b[0], 0.0), + "right" => (r[2] - b[2], 0.0), + "hcenter" | "center" => ((r[0] + r[2]) / 2.0 - (b[0] + b[2]) / 2.0, 0.0), + "top" => (0.0, r[1] - b[1]), + "bottom" => (0.0, r[3] - b[3]), + "vcenter" | "middle" => (0.0, (r[1] + r[3]) / 2.0 - (b[1] + b[3]) / 2.0), + o => return Err(bad("graphics.align", format!("unknown alignment `{o}`"))), + }; + moves.push((ei, Vec2::new(pos.x + dx, pos.y + dy))); + } + move_layers(s, clip, moves, "Align Layers")?; + Ok(Value::Null) + }, + ), + spec( + "graphics.distribute", + "Distribute Layers", + &[], + None, + r#"{"clip":id?,"layers":[n] (3 or more)?,"axis":"horizontal|vertical"}"#, + has_graphic, + |s, p| { + let clip = target_clip(s, p).ok_or_else(|| bad("graphics.distribute", "no graphic clip"))?; + let layers = layers_p(s, clip, p)?; + let (mut boxes, _) = layer_boxes(s, clip, &layers)?; + if boxes.len() < 3 { + return Err(bad("graphics.distribute", "select three or more layers")); + } + let vertical = str_p(p, "axis") == Some("vertical"); + let c = |b: &[f64; 4]| if vertical { (b[1] + b[3]) / 2.0 } else { (b[0] + b[2]) / 2.0 }; + boxes.sort_by(|a, b| c(&a.2).total_cmp(&c(&b.2))); + let (first, last) = (c(&boxes[0].2), c(&boxes[boxes.len() - 1].2)); + let n = boxes.len() - 1; + let moves = boxes + .iter() + .enumerate() + .map(|(i, (ei, pos, b))| { + let d = first + (last - first) * i as f64 / n as f64 - c(b); + (*ei, if vertical { Vec2::new(pos.x, pos.y + d) } else { Vec2::new(pos.x + d, pos.y) }) + }) + .collect(); + move_layers(s, clip, moves, "Distribute Layers")?; + Ok(Value::Null) + }, + ), + query("graphics.list", "List Graphic Layers", r#"{"clip":id?}"#, |s, p| { + let clip = target_clip(s, p).ok_or_else(|| bad("graphics.list", "no graphic clip"))?; + list_layers(s, clip) + }), + query("fonts.list", "List Fonts", r#"{"system":bool=true (scan the system font folders)}"#, |_, p| { + if p.get("system").and_then(Value::as_bool).unwrap_or(true) { + filmcraft_text::fonts::scan_system(); + } + Ok(json!(filmcraft_text::families().into_iter().map(|(f, st)| json!({"family": f, "styles": st})).collect::>())) + }), + ] +} + +#[cfg(test)] +#[path = "graphics_tests.rs"] +mod tests; diff --git a/crates/engine/src/graphics_tests.rs b/crates/engine/src/graphics_tests.rs new file mode 100644 index 000000000..64f21bab8 --- /dev/null +++ b/crates/engine/src/graphics_tests.rs @@ -0,0 +1,130 @@ +use serde_json::json; + +use crate::Session; +use filmcraft_project::graphic::{LayerContent, eval_layer, layer_indices}; +use filmcraft_project::{ClipId, ItemKind}; +use filmcraft_time::Tick; + +fn demo() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s +} + +fn layers(s: &Session, clip: ClipId) -> Vec { + let q = s.active_sequence().unwrap(); + let (_, it) = q.find_item(clip).unwrap(); + layer_indices(&it.effects).into_iter().filter_map(|i| eval_layer(&it.effects[i], Tick::ZERO, (q.settings.width, q.settings.height))).collect() +} + +fn text_of(l: &filmcraft_project::graphic::LayerSpec) -> String { + match &l.content { + LayerContent::Text(t) => t.text.clone(), + _ => panic!("not text"), + } +} + +#[test] +fn new_text_makes_a_graphic_clip_above_the_footage() { + let mut s = demo(); + let t = s.playhead(); + let r = s.execute("graphics.newText", json!({"text": "Hello", "position": [200, 300]})).unwrap(); + let clip = ClipId(r["clip"].as_u64().unwrap()); + let q = s.active_sequence().unwrap(); + let (tid, it) = q.find_item(clip).unwrap(); + let track_index = q.video_tracks.iter().position(|tr| tr.id == tid).unwrap(); + let below_busy = q.video_tracks[..track_index].iter().any(|tr| tr.item_at(t).is_some()); + assert!(below_busy || track_index == 0, "placed above the clip under the playhead"); + assert_eq!(it.name, "Hello"); + assert!(matches!(s.project.item(it.item).unwrap().kind, ItemKind::Graphic { .. })); + assert_eq!(it.duration, q.settings.frame_rate.snap_nearest(Tick::from_seconds_f64(5.0))); + assert!(!it.has_standard_effects(), "layers are not fx"); + assert_eq!(s.state.selection, vec![clip]); + let ls = layers(&s, clip); + assert_eq!(ls.len(), 1); + assert_eq!(text_of(&ls[0]), "Hello"); + assert_eq!((ls[0].transform.position.x, ls[0].transform.position.y), (200.0, 300.0)); + + // a second text layer in the same clip, then a shape + let r2 = s.execute("graphics.newText", json!({"text": "World", "clip": clip.0})).unwrap(); + assert_eq!(r2["layer"], 1); + s.execute("graphics.newShape", json!({"shape": "ellipse", "clip": clip.0, "size": [100, 50]})).unwrap(); + assert_eq!(layers(&s, clip).len(), 3); + let list = s.execute("graphics.list", json!({"clip": clip.0})).unwrap(); + assert_eq!(list["layers"][2]["kind"], "Ellipse"); + assert_eq!(list["layers"][1]["name"], "World"); + + // undo removes the shape layer again + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layers(&s, clip).len(), 2); +} + +#[test] +fn typing_coalesces_into_one_undo_step_and_props_set() { + let mut s = demo(); + let r = s.execute("graphics.newText", json!({"text": ""})).unwrap(); + let clip = ClipId(r["clip"].as_u64().unwrap()); + let before = s.history.undo.len(); + for (i, t) in ["T", "Ti", "Tit", "Titl", "Title"].iter().enumerate() { + s.execute("graphics.setText", json!({"clip": clip.0, "layer": 0, "text": t, "merge": i > 0})).unwrap(); + } + assert_eq!(s.history.undo.len(), before + 1, "one undo step for the typing session"); + assert_eq!(text_of(&layers(&s, clip)[0]), "Title"); + s.execute("graphics.set", json!({"clip": clip.0, "props": {"font_style": "Bold", "fontSize": 140, "align": "center", "fill_color": "#ffcc00", "stroke": true, "caps": "small caps"}})).unwrap(); + let l = &layers(&s, clip)[0]; + let LayerContent::Text(t) = &l.content else { panic!() }; + assert_eq!((t.style.as_str(), t.size, t.align, t.caps), ("Bold", 140.0, 1, 2)); + assert_eq!(l.appearance.strokes.len(), 1); + assert!((l.appearance.fill.unwrap()[1] - 0.8).abs() < 0.01); + assert!(s.execute("graphics.set", json!({"clip": clip.0, "props": {"nope": 1}})).is_err()); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(text_of(&layers(&s, clip)[0]), ""); +} + +#[test] +fn align_and_distribute_layers() { + let mut s = demo(); + let (w, h) = { + let q = s.active_sequence().unwrap(); + (q.settings.width as f64, q.settings.height as f64) + }; + let r = s.execute("graphics.newShape", json!({"shape": "rectangle", "position": [300, 200], "size": [100, 60]})).unwrap(); + let clip = r["clip"].as_u64().unwrap(); + s.execute("graphics.newShape", json!({"shape": "rectangle", "clip": clip, "position": [500, 400], "size": [40, 40]})).unwrap(); + s.execute("graphics.newShape", json!({"shape": "rectangle", "clip": clip, "position": [1500, 900], "size": [40, 40]})).unwrap(); + s.execute("graphics.align", json!({"clip": clip, "layers": [0], "align": "left"})).unwrap(); + s.execute("graphics.align", json!({"clip": clip, "layers": [0], "align": "bottom"})).unwrap(); + let l = layers(&s, ClipId(clip)); + assert!((l[0].transform.position.x - 50.0).abs() < 1e-6, "left edge on the frame edge"); + assert!((l[0].transform.position.y - (h - 30.0)).abs() < 1e-6); + s.execute("graphics.align", json!({"clip": clip, "layers": [1, 2], "align": "vcenter", "to": "selection"})).unwrap(); + let l = layers(&s, ClipId(clip)); + assert!((l[1].transform.position.y - l[2].transform.position.y).abs() < 1e-6); + s.execute("graphics.distribute", json!({"clip": clip, "layers": [0, 1, 2], "axis": "horizontal"})).unwrap(); + let l = layers(&s, ClipId(clip)); + let xs: Vec = l.iter().map(|x| x.transform.position.x).collect(); + assert!(((xs[1] - xs[0]) - (xs[2] - xs[1])).abs() < 1e-6, "{xs:?}"); + let _ = w; +} + +#[test] +fn graphic_renders_in_the_program_and_survives_save() { + let mut s = demo(); + let r = s.execute("graphics.newText", json!({"text": "BIG", "size": 300, "position": [100, 500]})).unwrap(); + let clip = r["clip"].as_u64().unwrap(); + s.execute("graphics.set", json!({"clip": clip, "props": {"fill_color": "#ff0000"}})).unwrap(); + let img = s.render_program(0.25).unwrap(); + let red = img.px.chunks(4).filter(|p| p[0] > 0.9 && p[1] < 0.05 && p[2] < 0.05).count(); + assert!(red > 200, "red text in the program: {red}"); + let dir = std::env::temp_dir().join(format!("fc-gfx-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("g.fcproj"); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let mut s2 = Session::default(); + s2.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(s2.project.items, s.project.items, "graphic clips round-trip"); + let _ = std::fs::remove_dir_all(&dir); + let fonts = s.execute("fonts.list", json!({"system": false})).unwrap(); + assert!(fonts.as_array().unwrap().iter().any(|f| f["family"] == "Inter")); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dfaba163b..d334cf085 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -12,6 +12,7 @@ pub mod autosave; pub mod captions; pub mod commands; pub mod demo; +pub mod graphics; pub mod interchange; pub mod media_pool; pub mod previews; @@ -127,6 +128,9 @@ pub struct EditorState { /// Selected captions (caption tracks / Captions panel). #[serde(default)] pub caption_selection: Vec, + /// Selected layers (indices among the graphic layers, 0 = back) of the selected graphic clip. + #[serde(default)] + pub graphic_layers: Vec, } /// Events for frontends (drained each frame). @@ -479,7 +483,7 @@ pub fn media_duration(p: &Project, _pool: &MediaPool, id: ItemId) -> Option Some(s.duration()), filmcraft_project::ItemKind::Subclip { range, .. } => Some(range.end()), - filmcraft_project::ItemKind::AdjustmentLayer { .. } => None, + filmcraft_project::ItemKind::AdjustmentLayer { .. } | filmcraft_project::ItemKind::Graphic { .. } => None, } } diff --git a/crates/golden/goldens/graphic_shapes.png b/crates/golden/goldens/graphic_shapes.png new file mode 100644 index 0000000000000000000000000000000000000000..15a1e13a5de79d6fdce4b82c1aca23b17b220e44 GIT binary patch literal 31149 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 XKo>34h~%-Q00000NkvXXu0mjfp&$mT literal 0 HcmV?d00001 diff --git a/crates/golden/goldens/graphic_title.png.attribution b/crates/golden/goldens/graphic_title.png.attribution new file mode 100644 index 000000000..2cafc6a9c --- /dev/null +++ b/crates/golden/goldens/graphic_title.png.attribution @@ -0,0 +1,8 @@ +asset: graphic_title.png +title: Graphic clip: title text with stroke and shadow, lower-third bar +author: FilmCraft contributors +source: original work (rendered by FilmCraft's own renderer from a procedurally generated project; no external media) +license: MIT OR Apache-2.0 +license-file: +added: 2026-10-01 by FilmCraft contributors (FILMCRAFT_BLESS=1) +notes: Golden reference image generated by crates/golden/tests/golden.rs (graphic_title). Regenerate with FILMCRAFT_BLESS=1. Contains no Adobe artwork. diff --git a/crates/golden/tests/golden.rs b/crates/golden/tests/golden.rs index 01747e31f..d007c69c6 100644 --- a/crates/golden/tests/golden.rs +++ b/crates/golden/tests/golden.rs @@ -233,6 +233,100 @@ fn text_burnin() -> Scene { b.at(30) } +/// A graphic clip on V2 over `bg` on V1, with `layers` (graphic layer effect instances). +fn graphic_scene(bg: DemoScene, layers: Vec) -> Scene { + let mut b = Builder::new(2); + let bgi = b.demo(bg); + b.place(0, bgi, 0, 48); + let g = b.p.add_item("Graphic", Label::Rose, ItemKind::Graphic { width: W, height: H, rate: RATE }, None); + let c = b.place(1, g, 0, 48); + for mut l in layers { + filmcraft_project::resolve_auto_points(&mut l, (W, H), (W, H)); + b.clip(c).effects.push(l); + } + b.at(12) +} + +/// A title: bold centred text with an outer stroke and a soft drop shadow, a lower-third bar with +/// rounded corners and a background-boxed caption line. +fn graphic_title() -> Scene { + use filmcraft_project::graphic::{new_shape_layer, new_text_layer}; + let mut bar = new_shape_layer(0, Vec2::new(160.0, 150.0), Vec2::new(280.0, 34.0), vec![]); + set(&mut bar, &[("corner_radius", fl(8.0)), ("fill_color", ParamValue::Color([0.12, 0.35, 0.8, 1.0])), ("opacity", fl(85.0))]); + let mut title = new_text_layer("Night Drive", Vec2::new(160.0, 80.0), 44.0); + set( + &mut title, + &[ + ("font_style", ParamValue::Text("Bold".into())), + ("align", ParamValue::Choice(1)), + ("stroke", ParamValue::Bool(true)), + ("stroke_width", fl(2.5)), + ("stroke_color", ParamValue::Color([0.05, 0.05, 0.1, 1.0])), + ("shadow", ParamValue::Bool(true)), + ("shadow_distance", fl(5.0)), + ("shadow_blur", fl(8.0)), + ("tracking", fl(40.0)), + ], + ); + let mut sub = new_text_layer("Directed by Nobody", Vec2::new(160.0, 156.0), 16.0); + set( + &mut sub, + &[ + ("align", ParamValue::Choice(1)), + ("font", ParamValue::Text("Noto Serif".into())), + ("faux_italic", ParamValue::Bool(true)), + ("caps", ParamValue::Choice(2)), + ("fill_color", ParamValue::Color([1.0, 0.92, 0.6, 1.0])), + ], + ); + graphic_scene(DemoScene::CityNight, vec![bar, title, sub]) +} + +/// Shapes: ellipse with centre stroke, rotated polygon with inner stroke, a pen path, rotated +/// boxed text with a background and 2 strokes. +fn graphic_shapes() -> Scene { + use filmcraft_project::graphic::{new_shape_layer, new_text_layer}; + let mut ell = new_shape_layer(1, Vec2::new(70.0, 60.0), Vec2::new(100.0, 70.0), vec![]); + set( + &mut ell, + &[ + ("stroke", ParamValue::Bool(true)), + ("stroke_width", fl(6.0)), + ("stroke_type", ParamValue::Choice(1)), + ("stroke_color", ParamValue::Color([1.0, 1.0, 1.0, 1.0])), + ], + ); + let mut poly = new_shape_layer(2, Vec2::new(250.0, 60.0), Vec2::new(80.0, 80.0), vec![]); + set( + &mut poly, + &[ + ("sides", fl(5.0)), + ("rotation", fl(18.0)), + ("fill_color", ParamValue::Color([0.2, 0.8, 0.4, 1.0])), + ("stroke", ParamValue::Bool(true)), + ("stroke_type", ParamValue::Choice(2)), + ("stroke_width", fl(5.0)), + ], + ); + let path = new_shape_layer(3, Vec2::new(70.0, 140.0), Vec2::new(0.0, 0.0), vec![[-40.0, 20.0], [0.0, -25.0], [40.0, 20.0], [0.0, 5.0]]); + let mut txt = new_text_layer("Rotated\nTwo lines", Vec2::new(215.0, 140.0), 20.0); + set( + &mut txt, + &[ + ("rotation", fl(-12.0)), + ("align", ParamValue::Choice(1)), + ("background", ParamValue::Bool(true)), + ("background_radius", fl(6.0)), + ("background_size", fl(6.0)), + ("stroke", ParamValue::Bool(true)), + ("stroke_width", fl(1.5)), + ("stroke2", ParamValue::Bool(true)), + ("stroke2_width", fl(3.5)), + ], + ); + graphic_scene(DemoScene::Aurora, vec![ell, poly, path, txt]) +} + /// (name, title, scene). fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> { vec![ @@ -245,6 +339,8 @@ fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> { ("transition_dip_to_black", "Dip to Black at 25%", || transition("dip_to_black", 21)), ("transition_wipe", "Wipe at 50%", || transition("wipe", 24)), ("text_burnin", "Timecode and Clip Name burn-in text", text_burnin), + ("graphic_title", "Graphic clip: title text with stroke and shadow, lower-third bar", graphic_title), + ("graphic_shapes", "Graphic clip: ellipse, polygon, path and rotated text with background", graphic_shapes), ] } @@ -286,7 +382,19 @@ macro_rules! goldens { }; } -goldens!(transform_opacity, blend_modes, gaussian_blur, lumetri_basic, crop, transition_cross_dissolve, transition_dip_to_black, transition_wipe, text_burnin); +goldens!( + transform_opacity, + blend_modes, + gaussian_blur, + lumetri_basic, + crop, + transition_cross_dissolve, + transition_dip_to_black, + transition_wipe, + text_burnin, + graphic_title, + graphic_shapes +); /// Sanity: the scenes are not trivially empty or identical to each other. #[test] diff --git a/crates/interchange/src/lib.rs b/crates/interchange/src/lib.rs index 6e57d825a..b29f5723e 100644 --- a/crates/interchange/src/lib.rs +++ b/crates/interchange/src/lib.rs @@ -427,7 +427,7 @@ pub fn merge_into(target: &mut Project, fragment: Imported, bin: Option) mk.id = MarkerId(target.alloc_id()); } } - ItemKind::AdjustmentLayer { .. } => {} + ItemKind::AdjustmentLayer { .. } | ItemKind::Graphic { .. } => {} } new_items.insert(it.id, it); } diff --git a/crates/project/src/effect.rs b/crates/project/src/effect.rs index 80f69e005..7711af7f0 100644 --- a/crates/project/src/effect.rs +++ b/crates/project/src/effect.rs @@ -935,6 +935,7 @@ fn build_effects() -> Vec { yuv: false, }, ]; + v.extend(crate::graphic::layer_defs()); // YUV badge for the colour/intrinsic set for e in &mut v { if matches!(e.id, "brightness_contrast" | "proc_amp" | "crop" | "gaussian_blur" | "tint" | "lumetri") { diff --git a/crates/project/src/graphic.rs b/crates/project/src/graphic.rs new file mode 100644 index 000000000..14c2a776f --- /dev/null +++ b/crates/project/src/graphic.rs @@ -0,0 +1,419 @@ +//! Graphic clips: text and shape layers. +//! +//! A graphic clip is a track item whose project item is an [`ItemKind::Graphic`](crate::ItemKind) +//! source (a transparent canvas the size of the sequence frame). Its **layers** are effect +//! instances of the hidden effects [`TEXT_LAYER`] and [`SHAPE_LAYER`] in the clip's effect list, +//! in paint order (first = back). Being effect instances, layers serialize with the project, get +//! keyframes for every animatable property (Source Text holds), show in Effect Controls, and copy +//! with the clip — like Premiere's Text / Shape components. Standard effects on the clip apply to +//! the composed graphic; Motion and Opacity apply last. +//! +//! [`eval_layer`] evaluates a layer at a clip time into a plain [`LayerSpec`] that renderers and +//! editors use. + +use filmcraft_geom::Vec2; +use filmcraft_time::Tick; + +use crate::effect::{EffectDef, EffectInstance, EffectKind, ParamDef, ParamKind}; +use crate::keyframe::{Param, ParamValue}; + +pub const TEXT_LAYER: &str = "graphic_text"; +pub const SHAPE_LAYER: &str = "graphic_shape"; + +pub const ALIGN_OPTS: &[&str] = &["Left", "Center", "Right", "Justify"]; +pub const CAPS_OPTS: &[&str] = &["Normal", "All Caps", "Small Caps"]; +pub const STROKE_OPTS: &[&str] = &["Outer", "Center", "Inner"]; +pub const SHAPE_OPTS: &[&str] = &["Rectangle", "Ellipse", "Polygon", "Path"]; + +/// Whether an effect instance is a graphic layer. +pub fn is_layer_id(id: &str) -> bool { + id == TEXT_LAYER || id == SHAPE_LAYER +} + +pub fn is_layer(e: &EffectInstance) -> bool { + is_layer_id(&e.effect) +} + +fn p(id: &'static str, label: &'static str, kind: ParamKind, default: ParamValue, animatable: bool, group: &'static str) -> ParamDef { + ParamDef { id, label, kind, default, animatable, group: if group.is_empty() { None } else { Some(group) } } +} +fn fl(id: &'static str, label: &'static str, def: f64, (min, max): (f64, f64), (smin, smax): (f64, f64), unit: &'static str, group: &'static str) -> ParamDef { + p(id, label, ParamKind::Float { min, max, soft_min: smin, soft_max: smax, unit, decimals: 1 }, ParamValue::Float(def), true, group) +} +fn bo(id: &'static str, label: &'static str, v: bool, group: &'static str) -> ParamDef { + p(id, label, ParamKind::Bool, ParamValue::Bool(v), false, group) +} +fn co(id: &'static str, label: &'static str, c: [f32; 4], group: &'static str) -> ParamDef { + p(id, label, ParamKind::Color, ParamValue::Color(c), true, group) +} +fn chc(id: &'static str, label: &'static str, opts: &'static [&'static str], def: u32, group: &'static str) -> ParamDef { + p(id, label, ParamKind::Choice(opts), ParamValue::Choice(def), false, group) +} +fn tx(id: &'static str, label: &'static str, def: &str, animatable: bool, group: &'static str) -> ParamDef { + p(id, label, ParamKind::Text, ParamValue::Text(def.into()), animatable, group) +} + +fn appearance() -> Vec { + const A: &str = "Appearance"; + vec![ + bo("fill", "Fill", true, A), + co("fill_color", "Fill Color", [1.0, 1.0, 1.0, 1.0], A), + bo("stroke", "Stroke", false, A), + co("stroke_color", "Stroke Color", [0.0, 0.0, 0.0, 1.0], A), + fl("stroke_width", "Stroke Width", 4.0, (0.0, 1000.0), (0.0, 100.0), "", A), + chc("stroke_type", "Stroke Type", STROKE_OPTS, 0, A), + bo("stroke2", "Stroke 2", false, A), + co("stroke2_color", "Stroke 2 Color", [1.0, 0.85, 0.2, 1.0], A), + fl("stroke2_width", "Stroke 2 Width", 10.0, (0.0, 1000.0), (0.0, 100.0), "", A), + chc("stroke2_type", "Stroke 2 Type", STROKE_OPTS, 0, A), + bo("background", "Background", false, A), + co("background_color", "Background Color", [0.0, 0.0, 0.0, 1.0], A), + fl("background_opacity", "Background Opacity", 75.0, (0.0, 100.0), (0.0, 100.0), "%", A), + fl("background_size", "Background Size", 12.0, (0.0, 1000.0), (0.0, 100.0), "", A), + fl("background_radius", "Background Corner Radius", 0.0, (0.0, 1000.0), (0.0, 100.0), "", A), + bo("shadow", "Shadow", false, A), + co("shadow_color", "Shadow Color", [0.0, 0.0, 0.0, 1.0], A), + fl("shadow_opacity", "Shadow Opacity", 75.0, (0.0, 100.0), (0.0, 100.0), "%", A), + p("shadow_angle", "Shadow Angle", ParamKind::Angle, ParamValue::Float(135.0), true, A), + fl("shadow_distance", "Shadow Distance", 10.0, (0.0, 1000.0), (0.0, 100.0), "", A), + fl("shadow_size", "Shadow Size", 0.0, (0.0, 1000.0), (0.0, 100.0), "", A), + fl("shadow_blur", "Shadow Blur", 40.0, (0.0, 1000.0), (0.0, 200.0), "", A), + ] +} + +fn transform() -> Vec { + const T: &str = "Transform"; + vec![ + p("position", "Position", ParamKind::Point, ParamValue::Vec2(Vec2::new(f64::NAN, f64::NAN)), true, T), + p("anchor", "Anchor Point", ParamKind::Point, ParamValue::Vec2(Vec2::new(0.0, 0.0)), true, T), + fl("scale", "Scale", 100.0, (0.0, 100_000.0), (0.0, 400.0), "%", T), + fl("scale_width", "Scale Width", 100.0, (0.0, 100_000.0), (0.0, 400.0), "%", T), + bo("uniform_scale", "Uniform Scale", true, T), + p("rotation", "Rotation", ParamKind::Angle, ParamValue::Float(0.0), true, T), + fl("opacity", "Opacity", 100.0, (0.0, 100.0), (0.0, 100.0), "%", T), + ] +} + +fn layer_def(id: &'static str, name: &'static str, mut params: Vec) -> EffectDef { + params.extend(appearance()); + params.extend(transform()); + EffectDef { id, name, kind: EffectKind::Video, category: &[], params, intrinsic: false, accelerated: true, float32: true, yuv: false } +} + +/// Definitions of the two layer kinds (appended to the effect registry; not in the Effects panel). +pub(crate) fn layer_defs() -> Vec { + const X: &str = "Text"; + const S: &str = "Shape"; + vec![ + layer_def( + TEXT_LAYER, + "Text", + vec![ + tx("name", "Layer Name", "", false, ""), + tx("text", "Source Text", "", true, ""), + tx("font", "Font", "Inter", false, X), + tx("font_style", "Font Style", "Regular", false, X), + fl("size", "Font Size", 100.0, (1.0, 2000.0), (6.0, 400.0), "", X), + chc("align", "Alignment", ALIGN_OPTS, 0, X), + fl("tracking", "Tracking", 0.0, (-1000.0, 10_000.0), (-200.0, 1000.0), "", X), + bo("kerning", "Metrics Kerning", true, X), + bo("ligatures", "Ligatures", true, X), + fl("leading", "Leading", 0.0, (-5000.0, 5000.0), (-100.0, 400.0), "", X), + fl("baseline_shift", "Baseline Shift", 0.0, (-5000.0, 5000.0), (-100.0, 100.0), "", X), + bo("faux_bold", "Faux Bold", false, X), + bo("faux_italic", "Faux Italic", false, X), + chc("caps", "Capitalisation", CAPS_OPTS, 0, X), + bo("underline", "Underline", false, X), + fl("box_width", "Text Box Width", 0.0, (0.0, 100_000.0), (0.0, 3840.0), "", X), + ], + ), + layer_def( + SHAPE_LAYER, + "Shape", + vec![ + tx("name", "Layer Name", "", false, ""), + chc("shape", "Shape", SHAPE_OPTS, 0, S), + p("size", "Size", ParamKind::Point, ParamValue::Vec2(Vec2::new(400.0, 200.0)), true, S), + fl("sides", "Polygon Sides", 6.0, (3.0, 64.0), (3.0, 16.0), "", S), + fl("corner_radius", "Corner Radius", 0.0, (0.0, 10_000.0), (0.0, 200.0), "", S), + p("points", "Path Points", ParamKind::Curve { hue: false }, ParamValue::Curve(vec![]), false, S), + ], + ), + ] +} + +/// Fill / strokes / background / shadow of a layer (colours are sRGB-encoded straight RGBA). +#[derive(Clone, Debug, PartialEq)] +pub struct Appearance { + pub fill: Option<[f32; 4]>, + /// (colour, width px, type: 0 outer, 1 centre, 2 inner), outermost last. + pub strokes: Vec<([f32; 4], f32, u32)>, + /// (colour with opacity in alpha, padding px, corner radius px). + pub background: Option<([f32; 4], f32, f32)>, + pub shadow: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Shadow { + /// Colour with opacity in alpha. + pub color: [f32; 4], + /// Offset in layer pixels (from angle and distance; 135° casts down-right). + pub offset: (f32, f32), + pub size: f32, + pub blur: f32, +} + +/// Layer → graphic-source transform parameters. +#[derive(Clone, Debug, PartialEq)] +pub struct LayerTransform { + pub position: Vec2, + pub anchor: Vec2, + pub scale: Vec2, + pub rotation: f64, + pub opacity: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextProps { + pub text: String, + pub font: String, + pub style: String, + pub size: f32, + /// 0 left, 1 centre, 2 right, 3 justify. + pub align: u32, + pub tracking: f32, + pub kerning: bool, + pub ligatures: bool, + pub leading: f32, + pub baseline_shift: f32, + pub faux_bold: bool, + pub faux_italic: bool, + /// 0 normal, 1 all caps, 2 small caps. + pub caps: u32, + pub underline: bool, + /// Area-text width (0 = point text). + pub box_width: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ShapeProps { + /// 0 rectangle, 1 ellipse, 2 polygon, 3 path. + pub shape: u32, + pub size: (f32, f32), + pub sides: u32, + pub corner_radius: f32, + /// Path vertices in layer pixels (shape 3), closed. + pub points: Vec<[f32; 2]>, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum LayerContent { + Text(TextProps), + Shape(ShapeProps), +} + +/// A layer evaluated at one time. +#[derive(Clone, Debug, PartialEq)] +pub struct LayerSpec { + pub name: String, + pub enabled: bool, + pub content: LayerContent, + pub appearance: Appearance, + pub transform: LayerTransform, +} + +fn val(e: &EffectInstance, id: &str, t: Tick) -> ParamValue { + match e.params.get(id) { + Some(p) => p.value_at(t), + None => crate::effect::find_effect(&e.effect).and_then(|d| d.param(id)).map(|d| d.default.clone()).unwrap_or(ParamValue::Float(0.0)), + } +} +fn ff(e: &EffectInstance, id: &str, t: Tick) -> f32 { + val(e, id, t).as_f64().unwrap_or(0.0) as f32 +} +fn bb(e: &EffectInstance, id: &str, t: Tick) -> bool { + val(e, id, t).as_bool().unwrap_or(false) +} +fn cc(e: &EffectInstance, id: &str, t: Tick) -> [f32; 4] { + val(e, id, t).as_color().unwrap_or([1.0, 1.0, 1.0, 1.0]) +} +fn ch(e: &EffectInstance, id: &str, t: Tick) -> u32 { + match val(e, id, t) { + ParamValue::Choice(c) => c, + v => v.as_f64().unwrap_or(0.0) as u32, + } +} +fn ss(e: &EffectInstance, id: &str, t: Tick) -> String { + match val(e, id, t) { + ParamValue::Text(s) => s, + _ => String::new(), + } +} +fn vv(e: &EffectInstance, id: &str, t: Tick) -> Vec2 { + val(e, id, t).as_vec2().unwrap_or_default() +} + +/// Evaluate a layer at clip (media) time `t`. `frame` is the graphic's canvas size; a NaN position +/// means the canvas centre. +pub fn eval_layer(e: &EffectInstance, t: Tick, frame: (u32, u32)) -> Option { + let content = match e.effect.as_str() { + TEXT_LAYER => LayerContent::Text(TextProps { + text: ss(e, "text", t), + font: ss(e, "font", t), + style: ss(e, "font_style", t), + size: ff(e, "size", t).max(0.1), + align: ch(e, "align", t), + tracking: ff(e, "tracking", t), + kerning: bb(e, "kerning", t), + ligatures: bb(e, "ligatures", t), + leading: ff(e, "leading", t), + baseline_shift: ff(e, "baseline_shift", t), + faux_bold: bb(e, "faux_bold", t), + faux_italic: bb(e, "faux_italic", t), + caps: ch(e, "caps", t), + underline: bb(e, "underline", t), + box_width: ff(e, "box_width", t).max(0.0), + }), + SHAPE_LAYER => { + let sz = vv(e, "size", t); + LayerContent::Shape(ShapeProps { + shape: ch(e, "shape", t), + size: (sz.x.max(0.0) as f32, sz.y.max(0.0) as f32), + sides: ff(e, "sides", t).round().clamp(3.0, 64.0) as u32, + corner_radius: ff(e, "corner_radius", t).max(0.0), + points: val(e, "points", t).as_curve().map(<[_]>::to_vec).unwrap_or_default(), + }) + } + _ => return None, + }; + let mut strokes = Vec::new(); + for (on, c, w, k) in [("stroke", "stroke_color", "stroke_width", "stroke_type"), ("stroke2", "stroke2_color", "stroke2_width", "stroke2_type")] { + if bb(e, on, t) && ff(e, w, t) > 0.0 { + strokes.push((cc(e, c, t), ff(e, w, t), ch(e, k, t))); + } + } + let background = bb(e, "background", t).then(|| { + let mut c = cc(e, "background_color", t); + c[3] *= (ff(e, "background_opacity", t) / 100.0).clamp(0.0, 1.0); + (c, ff(e, "background_size", t).max(0.0), ff(e, "background_radius", t).max(0.0)) + }); + let shadow = bb(e, "shadow", t).then(|| { + let mut c = cc(e, "shadow_color", t); + c[3] *= (ff(e, "shadow_opacity", t) / 100.0).clamp(0.0, 1.0); + // Premiere: 135° casts the shadow down and to the right. + let a = (ff(e, "shadow_angle", t) - 90.0).to_radians(); + let d = ff(e, "shadow_distance", t); + Shadow { color: c, offset: (a.cos() * d, a.sin() * d), size: ff(e, "shadow_size", t).max(0.0), blur: ff(e, "shadow_blur", t).max(0.0) } + }); + let pos = vv(e, "position", t); + let position = if pos.x.is_nan() || pos.y.is_nan() { Vec2::new(frame.0 as f64 / 2.0, frame.1 as f64 / 2.0) } else { pos }; + let s = ff(e, "scale", t) as f64 / 100.0; + let sw = if bb(e, "uniform_scale", t) { s } else { ff(e, "scale_width", t) as f64 / 100.0 }; + Some(LayerSpec { + name: ss(e, "name", t), + enabled: e.enabled, + content, + appearance: Appearance { fill: bb(e, "fill", t).then(|| cc(e, "fill_color", t)), strokes, background, shadow }, + transform: LayerTransform { + position, + anchor: vv(e, "anchor", t), + scale: Vec2::new(sw, s), + rotation: ff(e, "rotation", t) as f64, + opacity: (ff(e, "opacity", t) / 100.0).clamp(0.0, 1.0), + }, + }) +} + +fn set(e: &mut EffectInstance, id: &str, v: ParamValue) { + e.params.insert(id.to_string(), Param::new(v)); +} + +/// A new text layer at `position` (graphic canvas pixels). +pub fn new_text_layer(text: &str, position: Vec2, size: f64) -> EffectInstance { + let mut e = crate::effect::find_effect(TEXT_LAYER).expect("registered").instance(); + set(&mut e, "text", ParamValue::Text(text.into())); + set(&mut e, "position", ParamValue::Vec2(position)); + set(&mut e, "size", ParamValue::Float(size)); + e +} + +/// A new shape layer centred at `position`. +pub fn new_shape_layer(shape: u32, position: Vec2, size: Vec2, points: Vec<[f32; 2]>) -> EffectInstance { + let mut e = crate::effect::find_effect(SHAPE_LAYER).expect("registered").instance(); + set(&mut e, "shape", ParamValue::Choice(shape.min(3))); + set(&mut e, "position", ParamValue::Vec2(position)); + set(&mut e, "size", ParamValue::Vec2(size)); + set(&mut e, "points", ParamValue::Curve(points)); + // shapes default to a red fill (Premiere's new-shape default is a coloured fill) + set(&mut e, "fill_color", ParamValue::Color([0.85, 0.2, 0.25, 1.0])); + e +} + +/// Display name of a layer: its name, else the text (first line), else "Shape 01"-style. +pub fn layer_display_name(e: &EffectInstance, index: usize) -> String { + let name = ss(e, "name", Tick::ZERO); + if !name.is_empty() { + return name; + } + if e.effect == TEXT_LAYER { + let t = ss(e, "text", Tick::ZERO); + let first = t.lines().next().unwrap_or("").trim().to_string(); + if first.is_empty() { "Text".into() } else { first.chars().take(40).collect() } + } else { + format!("Shape {:02}", index + 1) + } +} + +/// Indices (into `effects`) of the graphic layers, in paint order. +pub fn layer_indices(effects: &[EffectInstance]) -> Vec { + effects.iter().enumerate().filter(|(_, e)| is_layer(e)).map(|(i, _)| i).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layers_round_trip_and_evaluate() { + let mut e = new_text_layer("Hello", Vec2::new(100.0, 200.0), 72.0); + e.params.get_mut("fill_color").unwrap().value = ParamValue::Color([1.0, 0.0, 0.0, 1.0]); + let json = serde_json::to_string(&e).unwrap(); + let back: EffectInstance = serde_json::from_str(&json).unwrap(); + assert_eq!(back, e); + let s = eval_layer(&back, Tick::ZERO, (1920, 1080)).unwrap(); + let LayerContent::Text(t) = &s.content else { panic!() }; + assert_eq!(t.text, "Hello"); + assert_eq!(t.size, 72.0); + assert_eq!(t.font, "Inter"); + assert_eq!(s.appearance.fill, Some([1.0, 0.0, 0.0, 1.0])); + assert_eq!(s.transform.position, Vec2::new(100.0, 200.0)); + assert!(s.appearance.strokes.is_empty() && s.appearance.shadow.is_none()); + assert!(is_layer(&e)); + assert_eq!(layer_display_name(&e, 0), "Hello"); + } + + #[test] + fn defaults_centre_and_shadow_direction() { + let mut e = crate::effect::find_effect(SHAPE_LAYER).unwrap().instance(); + e.params.get_mut("shadow").unwrap().value = ParamValue::Bool(true); + let s = eval_layer(&e, Tick::ZERO, (1920, 1080)).unwrap(); + assert_eq!(s.transform.position, Vec2::new(960.0, 540.0)); + let sh = s.appearance.shadow.unwrap(); + assert!(sh.offset.0 > 5.0 && sh.offset.1 > 5.0, "135° casts down-right: {:?}", sh.offset); + assert_eq!(layer_display_name(&e, 2), "Shape 03"); + } + + #[test] + fn source_text_keyframes_hold() { + let mut e = new_text_layer("A", Vec2::new(0.0, 0.0), 50.0); + let p = e.params.get_mut("text").unwrap(); + p.toggle_animation(Tick(0)); + p.set_at(Tick(100), ParamValue::Text("B".into())); + let at = |t| match eval_layer(&e, Tick(t), (10, 10)).unwrap().content { + LayerContent::Text(x) => x.text, + _ => unreachable!(), + }; + assert_eq!(at(50), "A"); + assert_eq!(at(150), "B"); + } +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 20d7ac5e8..e275ae3bb 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -9,6 +9,7 @@ pub mod caption; pub mod effect; +pub mod graphic; pub mod keyframe; use std::collections::BTreeMap; @@ -191,6 +192,14 @@ pub enum ItemKind { rate: FrameRate, duration: Tick, }, + /// The source of graphic clips: a transparent canvas of the sequence frame size. The clip's + /// text and shape layers are effect instances on the track item (see [`graphic`]). Not shown + /// in the Project panel; unlimited duration. + Graphic { + width: u32, + height: u32, + rate: FrameRate, + }, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -235,7 +244,7 @@ impl ProjectItem { pub fn has_video(&self) -> bool { match &self.kind { ItemKind::Media(m) => m.info.has_video(), - ItemKind::Sequence(_) | ItemKind::AdjustmentLayer { .. } => true, + ItemKind::Sequence(_) | ItemKind::AdjustmentLayer { .. } | ItemKind::Graphic { .. } => true, ItemKind::Subclip { .. } => true, } } @@ -252,13 +261,14 @@ impl ProjectItem { ItemKind::Sequence(s) => s.duration(), ItemKind::Subclip { range, .. } => range.duration, ItemKind::AdjustmentLayer { duration, .. } => *duration, + ItemKind::Graphic { .. } => Tick(3600 * TICKS_PER_SECOND), } } pub fn frame_rate(&self) -> FrameRate { match &self.kind { ItemKind::Media(m) => m.frame_rate(), ItemKind::Sequence(s) => s.settings.frame_rate, - ItemKind::AdjustmentLayer { rate, .. } => *rate, + ItemKind::AdjustmentLayer { rate, .. } | ItemKind::Graphic { rate, .. } => *rate, ItemKind::Subclip { .. } => FrameRate::default(), } } @@ -275,6 +285,7 @@ impl ProjectItem { ItemKind::Sequence(_) => "Sequence", ItemKind::Subclip { .. } => "Subclip", ItemKind::AdjustmentLayer { .. } => "Adjustment Layer", + ItemKind::Graphic { .. } => "Graphic", } } } @@ -452,7 +463,11 @@ impl TrackItem { } /// Standard (non-intrinsic) effects applied, for the fx badge. pub fn has_standard_effects(&self) -> bool { - self.effects.iter().any(|e| e.def().is_some_and(|d| !d.intrinsic)) + self.effects.iter().any(|e| e.def().is_some_and(|d| !d.intrinsic) && !graphic::is_layer(e)) + } + /// The graphic layers (text / shape) of a graphic clip, in paint order (first = back). + pub fn graphic_layers(&self) -> impl Iterator { + self.effects.iter().filter(|e| graphic::is_layer(e)) } pub fn has_modified_intrinsics(&self) -> bool { self.effects.iter().any(|e| { diff --git a/crates/render/src/graphic_clip.rs b/crates/render/src/graphic_clip.rs new file mode 100644 index 000000000..cdcdd61d0 --- /dev/null +++ b/crates/render/src/graphic_clip.rs @@ -0,0 +1,469 @@ +//! Graphic clips: text and shape layers drawn as vectors straight into the output (no resampling), +//! with fill, up to two strokes (outer / centre / inner), a background box and a drop shadow. +//! Each layer is rasterised into a tight premultiplied linear-light image that is cached while +//! the layer and its transform stay the same. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex, OnceLock}; + +use filmcraft_geom::{Affine, Vec2}; +use filmcraft_project::EffectInstance; +use filmcraft_project::graphic::{LayerContent, LayerSpec, ShapeProps, TextProps, eval_layer}; +use filmcraft_text::raster::{Xform, apply, fill_into, xform_scale}; +use filmcraft_text::{Align, Caps, Layout, Mask, ParagraphStyle, StrokeKind, TextStyle, layout, mask, render}; +use filmcraft_time::Tick; +use rayon::prelude::*; + +use crate::graphics::premul_linear; +use crate::image::Image; + +/// f64 affine → the text crate's f32 transform. +pub fn xform(a: &Affine) -> Xform { + [a.a as f32, a.b as f32, a.c as f32, a.d as f32, a.e as f32, a.f as f32] +} + +/// Layer pixels → graphic canvas pixels (position / anchor / scale / rotation). +pub fn layer_matrix(spec: &LayerSpec) -> Affine { + let t = &spec.transform; + Affine::motion(t.position, t.scale, t.rotation, t.anchor) +} + +/// Text style + paragraph style of a text layer. +pub fn text_styles(t: &TextProps) -> (TextStyle, ParagraphStyle) { + ( + TextStyle { + family: if t.font.is_empty() { filmcraft_text::fonts::DEFAULT_FAMILY.into() } else { t.font.clone() }, + style: if t.style.is_empty() { "Regular".into() } else { t.style.clone() }, + size: t.size, + tracking: t.tracking, + kerning: t.kerning, + ligatures: t.ligatures, + baseline_shift: t.baseline_shift, + faux_bold: t.faux_bold, + faux_italic: t.faux_italic, + caps: match t.caps { + 1 => Caps::All, + 2 => Caps::Small, + _ => Caps::Normal, + }, + underline: t.underline, + }, + ParagraphStyle { + align: match t.align { + 1 => Align::Center, + 2 => Align::Right, + 3 => Align::Justify, + _ => Align::Left, + }, + leading: t.leading, + width: (t.box_width > 0.0).then_some(t.box_width), + rtl: None, + }, + ) +} + +/// The laid-out text of a text layer (cached by the text engine). +pub fn text_layout(t: &TextProps) -> Arc { + let (st, ps) = text_styles(t); + layout(&t.text, &st, &ps) +} + +/// A shape layer's outline in layer pixels (centred on the layer origin). +pub fn shape_path(s: &ShapeProps) -> filmcraft_text::Path { + let (w, h) = (s.size.0 / 2.0, s.size.1 / 2.0); + match s.shape { + 1 => filmcraft_text::Path::ellipse(0.0, 0.0, w, h), + 2 => { + let n = s.sides.max(3); + let pts: Vec<(f32, f32)> = (0..n) + .map(|i| { + let a = std::f32::consts::TAU * i as f32 / n as f32 - std::f32::consts::FRAC_PI_2; + (a.cos() * w, a.sin() * h) + }) + .collect(); + filmcraft_text::Path::polygon(&pts) + } + 3 if s.points.len() >= 3 => filmcraft_text::Path::polygon(&s.points.iter().map(|p| (p[0], p[1])).collect::>()), + _ => filmcraft_text::Path::round_rect(-w, -h, w, h, s.corner_radius), + } +} + +/// Content bounds `[x0, y0, x1, y1]` of a layer in layer pixels (text box or shape box). +pub fn layer_local_bounds(spec: &LayerSpec) -> [f32; 4] { + match &spec.content { + LayerContent::Text(t) => text_layout(t).bounds, + LayerContent::Shape(s) => match shape_path(s).bounds() { + Some(b) => [b.0, b.1, b.2, b.3], + None => [0.0; 4], + }, + } +} + +/// The four corners of a layer's content box in graphic canvas pixels (selection boxes, hit +/// testing, alignment), clockwise from the top-left. +pub fn layer_quad(spec: &LayerSpec) -> [Vec2; 4] { + let b = layer_local_bounds(spec); + let m = layer_matrix(spec); + [Vec2::new(b[0] as f64, b[1] as f64), Vec2::new(b[2] as f64, b[1] as f64), Vec2::new(b[2] as f64, b[3] as f64), Vec2::new(b[0] as f64, b[3] as f64)] + .map(|p| m.apply(p)) +} + +/// A rendered layer: premultiplied linear RGBA at device position (`x`, `y`). +#[derive(Debug)] +pub struct LayerRaster { + pub x: i32, + pub y: i32, + pub img: Image, +} + +fn stroke_kind(k: u32) -> StrokeKind { + match k { + 1 => StrokeKind::Center, + 2 => StrokeKind::Inner, + _ => StrokeKind::Outer, + } +} + +fn bbox_of(m: &Xform, b: [f32; 4], pad: f32) -> [f32; 4] { + let pts = [(b[0] - pad, b[1] - pad), (b[2] + pad, b[1] - pad), (b[0] - pad, b[3] + pad), (b[2] + pad, b[3] + pad)]; + let mut o = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; + for (x, y) in pts { + let (a, c) = apply(m, x, y); + o = [o[0].min(a), o[1].min(c), o[2].max(a), o[3].max(c)]; + } + o +} + +fn union(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] +} + +/// Over-composite `c` (premultiplied) through `m` into `px` (premultiplied RGBA, same size). +fn over_mask(px: &mut [f32], m: &Mask, c: [f32; 4]) { + if c[3] <= 0.0 || m.w == 0 { + return; + } + px.par_chunks_mut(4 * m.w).zip(m.a.par_chunks(m.w)).for_each(|(row, mrow)| { + for (p, &a) in row.chunks_exact_mut(4).zip(mrow) { + if a > 0.0 { + let k = 1.0 - c[3] * a; + for i in 0..4 { + p[i] = c[i] * a + p[i] * k; + } + } + } + }); +} + +/// Rasterise one layer with layer→device transform `m`, clipped to a `cw`×`ch` canvas. +pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option { + if !spec.enabled || spec.transform.opacity <= 0.0 { + return None; + } + let s = xform_scale(m).max(1e-6); + let ap = &spec.appearance; + let text = match &spec.content { + LayerContent::Text(t) => { + let l = text_layout(t); + if l.glyphs.is_empty() && l.underlines.is_empty() && ap.background.is_none() { + return None; + } + Some(l) + } + LayerContent::Shape(_) => None, + }; + let lb = match &text { + Some(l) => l.bounds, + None => layer_local_bounds(spec), + }; + let shape = match &spec.content { + LayerContent::Shape(sh) => Some(shape_path(sh).transformed(m)), + _ => None, + }; + let stroke_out = ap + .strokes + .iter() + .map(|(_, w, k)| { + if *k == 2 { + 0.0 + } else if *k == 1 { + w / 2.0 + } else { + *w + } + }) + .fold(0.0f32, f32::max); + let overhang = text.as_ref().map_or(0.0, |l| l.glyphs.iter().map(|g| g.size).fold(0.0f32, f32::max) * 0.35); + let mut bb = bbox_of(m, lb, overhang + stroke_out + 2.0 / s); + if let Some((_, pad, _)) = ap.background { + bb = union(bb, bbox_of(m, lb, pad + 1.0 / s)); + } + let mut shadow_dev = (0.0f32, 0.0f32); + if let Some(sh) = &ap.shadow { + // the offset is in layer space, so it turns and scales with the layer + shadow_dev = (m[0] * sh.offset.0 + m[2] * sh.offset.1, m[1] * sh.offset.0 + m[3] * sh.offset.1); + let grow = (sh.size + sh.blur) * s + 2.0; + bb = union(bb, [bb[0] + shadow_dev.0 - grow, bb[1] + shadow_dev.1 - grow, bb[2] + shadow_dev.0 + grow, bb[3] + shadow_dev.1 + grow]); + } + // clip to the canvas, keeping enough margin for blur to read content just outside it + let margin = ap.shadow.as_ref().map_or(2.0, |sh| (sh.blur + sh.size) * s + shadow_dev.0.abs().max(shadow_dev.1.abs()) + 2.0); + let x0 = bb[0].floor().max(-margin) as i32; + let y0 = bb[1].floor().max(-margin) as i32; + let x1 = bb[2].ceil().min(cw as f32 + margin) as i32; + let y1 = bb[3].ceil().min(ch as f32 + margin) as i32; + if x1 <= x0 || y1 <= y0 || x1 <= 0 || y1 <= 0 || x0 >= cw as i32 || y0 >= ch as i32 { + return None; + } + let (w, h) = ((x1 - x0) as usize, (y1 - y0) as usize); + if w * h > 64 << 20 { + return None; + } + let mut cover = Mask::new(w, h); + if let Some(l) = &text { + render::draw(l, m, &mut cover, (x0, y0)); + } + if let Some(p) = &shape { + fill_into(p, &mut cover, -x0 as f32, -y0 as f32); + } + let strokes: Vec<(Mask, [f32; 4])> = ap.strokes.iter().map(|(c, sw, k)| (mask::stroke(&cover, sw * s, stroke_kind(*k)), *c)).collect(); + let bg = ap.background.map(|(c, pad, r)| { + let p = filmcraft_text::Path::round_rect(lb[0] - pad, lb[1] - pad, lb[2] + pad, lb[3] + pad, r).transformed(m); + let mut bm = Mask::new(w, h); + fill_into(&p, &mut bm, -x0 as f32, -y0 as f32); + (bm, c) + }); + let mut px = vec![0.0f32; w * h * 4]; + if let Some(sh) = &ap.shadow { + let mut u = if ap.fill.is_some() { cover.clone() } else { Mask::new(w, h) }; + for (sm, _) in &strokes { + u.max_with(sm); + } + if let Some((bm, _)) = &bg { + u.max_with(bm); + } + if sh.size > 0.0 { + u = mask::stroke(&u, sh.size * s, StrokeKind::Outer); + } + let u = mask::offset(&u, shadow_dev.0.round() as i32, shadow_dev.1.round() as i32); + let u = mask::blur(&u, sh.blur * s / 3.0); + over_mask(&mut px, &u, premul_linear(sh.color)); + } + if let Some((bm, c)) = &bg { + over_mask(&mut px, bm, premul_linear(*c)); + } + // outer strokes sit under the fill; centre and inner strokes are drawn over it + let outer = |k: u32| k != 1 && k != 2; + for ((sm, c), (_, _, k)) in strokes.iter().zip(&ap.strokes).rev() { + if outer(*k) { + over_mask(&mut px, sm, premul_linear(*c)); + } + } + if let Some(c) = ap.fill { + over_mask(&mut px, &cover, premul_linear(c)); + } + for ((sm, c), (_, _, k)) in strokes.iter().zip(&ap.strokes).rev() { + if !outer(*k) { + over_mask(&mut px, sm, premul_linear(*c)); + } + } + let op = spec.transform.opacity; + if op < 1.0 { + px.iter_mut().for_each(|v| *v *= op); + } + Some(LayerRaster { x: x0, y: y0, img: Image { w, h, px } }) +} + +type Cache = Mutex>)>>; + +fn raster_cache() -> &'static Cache { + static C: OnceLock = OnceLock::new(); + C.get_or_init(Default::default) +} + +/// [`raster_layer`] through a small cache (static titles re-composite without re-rasterising). +pub fn raster_layer_cached(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option> { + let mut hs = std::collections::hash_map::DefaultHasher::new(); + format!("{spec:?}").hash(&mut hs); + m.iter().for_each(|v| v.to_bits().hash(&mut hs)); + (cw, ch).hash(&mut hs); + let key = hs.finish(); + static CLOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let now = CLOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if let Some(e) = raster_cache().lock().unwrap_or_else(|e| e.into_inner()).get_mut(&key) { + e.0 = now; + return e.1.clone(); + } + let r = raster_layer(spec, m, cw, ch).map(Arc::new); + let mut c = raster_cache().lock().unwrap_or_else(|e| e.into_inner()); + if c.len() >= 96 + && let Some(k) = c.iter().min_by_key(|(_, v)| v.0).map(|(k, _)| *k) + { + c.remove(&k); + } + c.insert(key, (now, r.clone())); + r +} + +/// Over-composite a raster at (`r.x` + `dx`, `r.y` + `dy`) onto `img`. +pub fn composite_raster(img: &mut Image, r: &LayerRaster, dx: i32, dy: i32) { + let (rx, ry) = (r.x + dx, r.y + dy); + let (w, h) = (img.w as i32, img.h as i32); + let ys = ry.max(0)..(ry + r.img.h as i32).min(h); + let xs = rx.max(0)..(rx + r.img.w as i32).min(w); + if ys.is_empty() || xs.is_empty() { + return; + } + let iw = img.w; + img.px[ys.start as usize * iw * 4..ys.end as usize * iw * 4].par_chunks_mut(iw * 4).enumerate().for_each(|(row_i, row)| { + let sy = (ys.start + row_i as i32 - ry) as usize; + for x in xs.clone() { + let si = (sy * r.img.w + (x - rx) as usize) * 4; + let s = &r.img.px[si..si + 4]; + if s[3] <= 0.0 { + continue; + } + let d = &mut row[x as usize * 4..x as usize * 4 + 4]; + let k = 1.0 - s[3]; + for c in 0..4 { + d[c] = s[c] + d[c] * k; + } + } + }); +} + +/// Evaluated layers of a graphic clip's effect list at clip time `t`, in paint order. +pub fn graphic_specs(effects: &[EffectInstance], t: Tick, canvas: (u32, u32)) -> Vec { + effects.iter().filter(|e| filmcraft_project::graphic::is_layer(e)).filter_map(|e| eval_layer(e, t, canvas)).collect() +} + +/// Draw all layers of a graphic clip onto `out`; `m` maps graphic canvas pixels to `out` pixels. +pub fn render_graphic(effects: &[EffectInstance], t: Tick, canvas: (u32, u32), m: &Affine, out: &mut Image) { + for spec in graphic_specs(effects, t, canvas) { + let lm = xform(&m.then_apply(&layer_matrix(&spec))); + if let Some(r) = raster_layer_cached(&spec, &lm, out.w, out.h) { + composite_raster(out, &r, 0, 0); + } + } +} + +/// All layers composed into one tight image (for the GPU plan): `(image, x, y)` in output pixels. +pub fn render_graphic_tight(effects: &[EffectInstance], t: Tick, canvas: (u32, u32), m: &Affine, w: usize, h: usize) -> Option<(Image, i32, i32)> { + let rasters: Vec> = + graphic_specs(effects, t, canvas).iter().filter_map(|spec| raster_layer_cached(spec, &xform(&m.then_apply(&layer_matrix(spec))), w, h)).collect(); + let x0 = rasters.iter().map(|r| r.x).min()?.max(0); + let y0 = rasters.iter().map(|r| r.y).min()?.max(0); + let x1 = rasters.iter().map(|r| r.x + r.img.w as i32).max()?.min(w as i32); + let y1 = rasters.iter().map(|r| r.y + r.img.h as i32).max()?.min(h as i32); + if x1 <= x0 || y1 <= y0 { + return None; + } + let mut img = Image::new((x1 - x0) as usize, (y1 - y0) as usize); + for r in &rasters { + composite_raster(&mut img, r, -x0, -y0); + } + Some((img, x0, y0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use filmcraft_project::graphic::{new_shape_layer, new_text_layer}; + use filmcraft_project::{ParamValue, find_effect}; + + fn set(e: &mut EffectInstance, k: &str, v: ParamValue) { + e.params.get_mut(k).unwrap_or_else(|| panic!("{k}")).value = v; + } + + #[test] + fn text_layer_renders_white_text_at_position() { + let e = new_text_layer("HELLO", Vec2::new(100.0, 150.0), 60.0); + let mut img = Image::new(400, 200); + render_graphic(&[e], Tick::ZERO, (400, 200), &Affine::IDENTITY, &mut img); + let ink: Vec<(usize, usize)> = (0..200).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[3] > 0.5).collect(); + assert!(ink.len() > 500); + let minx = ink.iter().map(|p| p.0).min().unwrap(); + let maxy = ink.iter().map(|p| p.1).max().unwrap(); + assert!((98..=106).contains(&minx), "starts at the position: {minx}"); + assert!((145..=152).contains(&maxy), "sits on the baseline: {maxy}"); + assert!(img.get(ink[0].0, ink[0].1)[0] > 0.4, "white"); + } + + #[test] + fn shape_appearance_layers() { + let mut e = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(80.0, 40.0), vec![]); + set(&mut e, "fill_color", ParamValue::Color([0.0, 0.0, 1.0, 1.0])); + set(&mut e, "stroke", ParamValue::Bool(true)); + set(&mut e, "stroke_width", ParamValue::Float(6.0)); + set(&mut e, "stroke_color", ParamValue::Color([1.0, 0.0, 0.0, 1.0])); + set(&mut e, "shadow", ParamValue::Bool(true)); + set(&mut e, "shadow_distance", ParamValue::Float(20.0)); + set(&mut e, "shadow_blur", ParamValue::Float(0.0)); + let mut img = Image::new(200, 200); + render_graphic(&[e], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img); + let p = img.get(100, 100); + assert!(p[2] > 0.9 && p[0] < 0.05, "blue fill {p:?}"); + let s = img.get(100, 78); + assert!(s[0] > 0.9 && s[2] < 0.05, "red outer stroke above the box {s:?}"); + // shadow: below-right of the box, outside the stroke + let sh = img.get(150, 135); + assert!(sh[3] > 0.5 && sh[0] < 0.05 && sh[2] < 0.05, "black shadow {sh:?}"); + assert_eq!(img.get(20, 20)[3], 0.0); + } + + #[test] + fn inner_and_centre_strokes() { + for (kind, inside, outside) in [(2u32, true, false), (1, true, true)] { + let mut e = new_shape_layer(2, Vec2::new(100.0, 100.0), Vec2::new(120.0, 120.0), vec![]); + set(&mut e, "sides", ParamValue::Float(4.0)); + set(&mut e, "stroke", ParamValue::Bool(true)); + set(&mut e, "stroke_width", ParamValue::Float(6.0)); + set(&mut e, "stroke_type", ParamValue::Choice(kind)); + set(&mut e, "stroke_color", ParamValue::Color([0.0, 1.0, 0.0, 1.0])); + let mut img = Image::new(200, 200); + render_graphic(&[e], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img); + // diamond: top vertex at y = 40; probe straight below/above the right vertex (160, 100) + let green = |x: usize| { + let p = img.get(x, 100); + p[1] > 0.9 && p[0] < 0.1 + }; + assert_eq!(green(157), inside, "kind {kind} inside"); + assert_eq!(green(161), outside, "kind {kind} outside"); + assert!(!green(120), "fill in the middle"); + } + } + + #[test] + fn rotation_scale_and_opacity() { + let mut e = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(100.0, 10.0), vec![]); + set(&mut e, "rotation", ParamValue::Float(90.0)); + set(&mut e, "opacity", ParamValue::Float(50.0)); + let mut img = Image::new(200, 200); + render_graphic(&[e.clone()], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img); + assert!((img.get(100, 60)[3] - 0.5).abs() < 0.02, "vertical after 90°"); + assert_eq!(img.get(60, 100)[3], 0.0); + // a 2× output transform (e.g. Motion scale) draws vectors crisply at the larger size + let mut big = Image::new(400, 400); + render_graphic(&[e], Tick::ZERO, (200, 200), &Affine::scale(2.0, 2.0), &mut big); + assert!((big.get(200, 110)[3] - 0.5).abs() < 0.02); + let q = layer_quad(&eval_layer(&find_effect("graphic_shape").unwrap().instance(), Tick::ZERO, (200, 200)).unwrap()); + assert!((q[0].x - (100.0 - 200.0)).abs() < 1e-6 && (q[2].y - (100.0 + 100.0)).abs() < 1e-6); + } + + #[test] + fn tight_image_matches_full_render() { + let a = new_text_layer("Tight", Vec2::new(50.0, 80.0), 40.0); + let b = new_shape_layer(1, Vec2::new(150.0, 120.0), Vec2::new(60.0, 60.0), vec![]); + let fx = vec![b, a]; + let mut full = Image::new(220, 160); + render_graphic(&fx, Tick::ZERO, (220, 160), &Affine::IDENTITY, &mut full); + let (tight, x, y) = render_graphic_tight(&fx, Tick::ZERO, (220, 160), &Affine::IDENTITY, 220, 160).unwrap(); + assert!(tight.w < 220 && tight.h < 160); + for yy in 0..tight.h { + for xx in 0..tight.w { + let (p, q) = (tight.get(xx, yy), full.get(xx + x as usize, yy + y as usize)); + assert!((p[3] - q[3]).abs() < 1e-5); + } + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1cb409338..dd618347a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -11,6 +11,7 @@ pub mod audio; pub mod audio_fx; pub mod blend; pub mod effects; +pub mod graphic_clip; pub mod graphics; pub mod image; pub mod plan; @@ -169,7 +170,7 @@ pub(crate) fn source_size(project: &Project, item: ItemId) -> Option<(u32, u32)> match &project.item(item)?.kind { ItemKind::Media(m) => m.info.video.as_ref().map(|v| (v.width, v.height)), ItemKind::Sequence(s) => Some((s.settings.width, s.settings.height)), - ItemKind::AdjustmentLayer { width, height, .. } => Some((*width, *height)), + ItemKind::AdjustmentLayer { width, height, .. } | ItemKind::Graphic { width, height, .. } => Some((*width, *height)), ItemKind::Subclip { parent, .. } => source_size(project, *parent), } } @@ -236,11 +237,26 @@ pub(crate) fn item_layer( render_seq(project, nested, mt, sub, sources) } ItemKind::AdjustmentLayer { .. } => return None, + ItemKind::Graphic { .. } => { + if !(opts.effects && item.has_standard_effects()) { + // vectors straight to the output: no resampling, crisp at any Motion scale + let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion); + let mut canvas = Image::new(w, h); + graphic_clip::render_graphic(&item.effects, mt, src_size, &m, &mut canvas); + let (op, bl) = opacity_blend(item, mt); + return Some((canvas, op, bl)); + } + // standard effects work on the graphic at source resolution, then Motion places it + let (gw, gh) = (((src_size.0 as f32 * want).ceil() as usize).max(1), ((src_size.1 as f32 * want).ceil() as usize).max(1)); + let mut img = Image::new(gw, gh); + graphic_clip::render_graphic(&item.effects, mt, src_size, &Affine::scale(want as f64, want as f64), &mut img); + img + } }; let px_scale = layer.w as f32 / src_size.0.max(1) as f32; if opts.effects { let cx = effects::FxCtx { t: mt, px_scale, seconds: (t - item.start).seconds(), timecode: tc, clip_name: &item.name }; - for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic)) { + for e in item.effects.iter().filter(|e| e.def().is_some_and(|d| !d.intrinsic) && !filmcraft_project::graphic::is_layer(e)) { effects::apply(&mut layer, e, &cx); } } diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index 3732f0452..74a82edf9 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -141,6 +141,20 @@ fn push_item( ) { let mt = item.source_time_at(t); let (op, bl) = crate::opacity_blend(item, mt); + // Graphic clips without standard effects: the layers are rasterised (cached) into one tight + // image the GPU places as a layer. + if bl == Blend::Normal + && !(opts.effects && item.has_standard_effects()) + && project.item(item.item).is_some_and(|p| matches!(p.kind, ItemKind::Graphic { .. })) + { + let Some(size) = crate::source_size(project, item.item) else { return }; + let (w, h) = output_size(seq, opts.scale); + let m = Affine::scale(opts.scale as f64, opts.scale as f64).then_apply(&motion_matrix(seq, item, size, mt)); + if let Some((img, x, y)) = crate::graphic_clip::render_graphic_tight(&item.effects, mt, size, &m, w, h) { + out.push(PlanLayer { frame: cpu_frame(img), matrix: Affine::translate(x as f64, y as f64), opacity: op * extra_opacity }); + } + return; + } if gpu_simple(project, item, mt) && bl == Blend::Normal { let Some(src) = sources.source(item.item) else { return }; let Some(size) = crate::source_size(project, item.item) else { return }; diff --git a/crates/render/src/preview.rs b/crates/render/src/preview.rs index 11769c0a4..59cbdcb7a 100644 --- a/crates/render/src/preview.rs +++ b/crates/render/src/preview.rs @@ -339,6 +339,7 @@ fn hash_source(h: &mut Fnv128, project: &Project, id: ItemId, depth: u32) { hash_source(h, project, *parent, depth + 1); } ItemKind::AdjustmentLayer { .. } => h.json(&("adjustment", &pi.kind)), + ItemKind::Graphic { .. } => h.json(&("graphic", &pi.kind)), ItemKind::Sequence(s) => { h.json(&("sequence", &s.settings)); for (ti, tr) in s.video_tracks.iter().enumerate() { @@ -415,6 +416,8 @@ fn decode_cost_ms(project: &Project, id: ItemId, depth: u32) -> f64 { } ItemKind::Subclip { parent, .. } => decode_cost_ms(project, *parent, depth + 1), ItemKind::AdjustmentLayer { .. } => 0.0, + // vector layers are drawn on the CPU (cached while static) + ItemKind::Graphic { .. } => 1.5, ItemKind::Sequence(s) => { if depth > 8 { return 0.0; diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index ef5098dcf..e9bfb3241 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -21,7 +21,7 @@ fn item_icon(k: &ItemKind) -> Icon { }, ItemKind::Sequence(_) => Icon::Sequence, ItemKind::Subclip { .. } => Icon::Film, - ItemKind::AdjustmentLayer { .. } => Icon::Adjust, + ItemKind::AdjustmentLayer { .. } | ItemKind::Graphic { .. } => Icon::Adjust, } } @@ -166,7 +166,9 @@ fn columns(r: Rect) -> [f32; 4] { } fn matches(app: &FilmcraftApp, id: ItemId, filter: &str) -> bool { - filter.is_empty() || app.session.project.item(id).is_some_and(|i| i.name.to_ascii_lowercase().contains(filter)) + // graphic clip sources are internal (Premiere doesn't list graphics in the Project panel) + app.session.project.item(id).is_some_and(|i| !matches!(i.kind, ItemKind::Graphic { .. })) + && (filter.is_empty() || app.session.project.item(id).is_some_and(|i| i.name.to_ascii_lowercase().contains(filter))) } #[allow(clippy::too_many_arguments)] From d28b28214da71179b510282c06b2239f03ec5bee Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 01:46:40 -0400 Subject: [PATCH 17/24] M3.2: Trim Monitor + dynamic J/K/L trimming - Trim Monitor: with edit points selected the Program monitor shows the outgoing/incoming frames, Out/In timecodes and shift counters, -N/-1/+1/+N buttons (N = Preferences > Trim > Large Trim Offset), Apply Default Transitions, play-around loop and exit; drag in a picture trims that side, between them rolls. Automation ids trimMonitor.*. - Dynamic trimming in the engine (trim.shuttle / trim.tick / trim.shuttleStop / trim.cancelDynamic), driven by a caller-supplied clock: L forward, J back, again = 2x..8x, Shift = slow, K commits as ONE undo step, Esc cancels; holds at limits; any other command commits first. Live updates re-apply the offset to the pre-trim project without touching history or the journal. - trim.playAround: loop/once from preroll to postroll (new Preferences > Playback preroll/postroll, Trim > playhead determines loop). - trim.monitor query, trim.applyDefaultTransition (Shift+D); timeline draws selected edit points (red ripple / yellow roll-trim brackets) and clicking an edge with Selection/Ripple/Rolling selects it; selecting clips leaves trim mode. - J/K/L/Space/Shift+K route to trim mode in the UI; new Shuttle Slow L/R and Play Around commands. - Tests: 6 engine tests on an exact simulated clock; scripted kittest test (buttons, L/K/J, one undo step, Space loop, exit); ignored wgpu screenshot test. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 + crates/engine/src/autosave.rs | 48 ++- crates/engine/src/commands.rs | 46 ++- crates/engine/src/lib.rs | 31 ++ crates/engine/src/trim.rs | 376 +++++++++++++++++++++- crates/engine/src/trim_tests.rs | 212 ++++++++++++ crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/menus.rs | 10 + crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/timeline.rs | 19 ++ crates/ui-egui/src/panels/trim_monitor.rs | 336 +++++++++++++++++++ crates/ui-egui/tests/scripted.rs | 129 +++++++- 13 files changed, 1206 insertions(+), 14 deletions(-) create mode 100644 crates/engine/src/trim_tests.rs create mode 100644 crates/ui-egui/src/panels/trim_monitor.rs diff --git a/Cargo.lock b/Cargo.lock index c27278dbc..dfac6ef23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1111,10 +1111,14 @@ checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" dependencies = [ "eframe", "egui", + "egui-wgpu", + "image", "kittest", "log", + "pollster 1.0.1", "serde", "toml 1.1.6+spec-1.1.0", + "wgpu", ] [[package]] diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 436841ab7..5b6552a62 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -63,15 +63,57 @@ impl AutoSavePrefs { } } +/// Preferences ▸ Playback (the parts trim-mode loop playback uses). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct PlaybackPrefs { + /// "Preroll: N seconds" (Play Around, trim-mode loop playback). + pub preroll_seconds: f64, + /// "Postroll: N seconds". + pub postroll_seconds: f64, +} + +impl Default for PlaybackPrefs { + fn default() -> Self { + Self { preroll_seconds: 3.0, postroll_seconds: 2.0 } + } +} + +/// Preferences ▸ Trim. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TrimPrefs { + /// "Large Trim Offset: N frames" (Trim Forward/Backward Many, the Trim Monitor's ±N buttons). + pub large_trim_offset: u32, + /// "Playhead position determines trim monitor loop playback" (else the edit point does). + pub playhead_determines_loop: bool, +} + +impl Default for TrimPrefs { + fn default() -> Self { + Self { large_trim_offset: 5, playhead_determines_loop: false } + } +} + /// User preferences (persisted as JSON in the per-user data directory). Keys are dotted camelCase -/// paths: `autoSave.enabled`, `autoSave.intervalMinutes`, … +/// paths: `autoSave.enabled`, `autoSave.intervalMinutes`, `playback.prerollSeconds`, … #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct Preferences { pub auto_save: AutoSavePrefs, + pub playback: PlaybackPrefs, + pub trim: TrimPrefs, } impl Preferences { + fn clamp(&mut self) { + self.auto_save.clamp(); + let secs = |v: f64| if v.is_finite() { v.clamp(0.0, 60.0) } else { 0.0 }; + self.playback.preroll_seconds = secs(self.playback.preroll_seconds); + self.playback.postroll_seconds = secs(self.playback.postroll_seconds); + self.trim.large_trim_offset = self.trim.large_trim_offset.clamp(1, 1000); + } + pub fn to_value(&self) -> Value { serde_json::to_value(self).unwrap_or_default() } @@ -117,14 +159,14 @@ impl Preferences { }; *slot = value; let mut p: Preferences = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?; - p.auto_save.clamp(); + p.clamp(); *self = p; Ok(()) } pub fn load(path: &Path) -> Self { let mut p: Preferences = fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); - p.auto_save.clamp(); + p.clamp(); p } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 687721f3b..4c5483441 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1252,8 +1252,10 @@ fn build() -> Vec { Ok(Value::Null) }), // ================= Source monitor ================= - cmd!("source.open", "Open in Source Monitor", [], None, r#"{"item":id}"#, always, |s, p| { - let id = item_p(p, "item").ok_or_else(|| bad("source.open", "need `item`"))?; + cmd!("source.open", "Open in Source Monitor", [], None, r#"{"item":id?}"#, always, |s, p| { + let id = item_p(p, "item") + .or(s.state.project_selection.first().copied()) + .ok_or_else(|| bad("source.open", "need `item` (or a Project panel selection)"))?; s.project.item(id).ok_or_else(|| bad("source.open", "no such item"))?; s.state.source_item = Some(id); s.state.source_playhead = Tick::ZERO; @@ -1313,6 +1315,9 @@ fn build() -> Vec { let clips: Vec = p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default(); let clips = with_links(s, &clips); + // selecting clips leaves trim mode (Premiere: clip and edit point selections are exclusive) + s.state.edit_points.clear(); + s.state.trim_shift = Default::default(); if bool_p(p, "toggle").unwrap_or(false) { for c in clips { if let Some(i) = s.state.selection.iter().position(|x| *x == c) { @@ -1404,16 +1409,43 @@ fn build() -> Vec { }), cmd!("trim.clear", "Clear Edit Point Selection", [], None, "{}", has_edit_points, |s, _| { s.state.edit_points.clear(); + s.state.trim_shift = Default::default(); + s.trim_play.around = None; Ok(Value::Null) }), cmd!("trim.toggleType", "Toggle Trim Type", [], Some("Ctrl+T"), "{}", has_edit_points, |s, _| crate::trim::toggle_type(s)), cmd!("trim.backward", "Trim Backward", [], Some("Alt+Left"), "{}", has_edit_points, |s, _| crate::trim::nudge(s, &json!({"frames": -1}))), cmd!("trim.forward", "Trim Forward", [], Some("Alt+Right"), "{}", has_edit_points, |s, _| crate::trim::nudge(s, &json!({"frames": 1}))), - cmd!("trim.backwardMany", "Trim Backward Many", [], Some("Alt+Shift+Left"), "{}", has_edit_points, |s, _| crate::trim::nudge( - s, - &json!({"frames": -5}) - )), - cmd!("trim.forwardMany", "Trim Forward Many", [], Some("Alt+Shift+Right"), "{}", has_edit_points, |s, _| crate::trim::nudge(s, &json!({"frames": 5}))), + cmd!("trim.backwardMany", "Trim Backward Many", [], Some("Alt+Shift+Left"), "{}", has_edit_points, |s, _| { + let n = -(s.prefs.trim.large_trim_offset as i64); + crate::trim::nudge(s, &json!({"frames": n})) + }), + cmd!("trim.forwardMany", "Trim Forward Many", [], Some("Alt+Shift+Right"), "{}", has_edit_points, |s, _| { + let n = s.prefs.trim.large_trim_offset as i64; + crate::trim::nudge(s, &json!({"frames": n})) + }), + cmd!("trim.applyDefaultTransition", "Apply Default Transitions to Selection", ["Sequence"], Some("Shift+D"), "{}", has_edit_points, |s, _| { + crate::trim::apply_default_transitions(s) + }), + cmd!("trim.shuttle", "Dynamic Trim (Shuttle)", [], None, r#"{"direction":"forward|reverse","slow":bool?,"clock":seconds}"#, has_edit_points, |s, p| { + crate::trim::shuttle(s, p) + }), + cmd!("trim.shuttleStop", "Dynamic Trim Stop", [], None, r#"{"clock":seconds?}"#, has_seq, |s, p| crate::trim::stop(s, p)), + cmd!("trim.cancelDynamic", "Cancel Dynamic Trim", [], None, "{}", has_seq, |s, _| crate::trim::cancel(s)), + cmd!("trim.playAround", "Play Around Edit", [], None, r#"{"clock":seconds,"loop":bool=true,"toggle":bool?}"#, has_seq, |s, p| { + crate::trim::play_around(s, p) + }), + CommandSpec { + id: "trim.tick", + label: "Advance Trim Playback", + menu: &[], + shortcut: None, + params: r#"{"clock":seconds}"#, + enabled: has_seq, + run: |s, p| crate::trim::tick(s, p.get("clock").and_then(Value::as_f64).unwrap_or(0.0)), + journal: false, + }, + query!("trim.monitor", "Trim Monitor State", "{}", |s, _| Ok(crate::trim::monitor_info(s))), cmd!("trim.nudge", "Trim by Frames", [], None, r#"{"frames":i64}"#, has_edit_points, |s, p| crate::trim::nudge(s, p)), cmd!("trim.extendToPlayhead", "Extend Selected Edit to Playhead", [], Some("E"), "{}", has_edit_points, |s, _| { crate::trim::extend_to_playhead(s) }), cmd!("trim.ripplePrevious", "Ripple Trim Previous Edit to Playhead", [], Some("Q"), "{}", has_seq, |s, _| crate::trim::to_playhead( diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dfaba163b..d44d094de 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -124,6 +124,9 @@ pub struct EditorState { /// Selected edit points (trim mode). #[serde(default)] pub edit_points: Vec, + /// Trim Monitor Out/In shift counters for the selected edit point. + #[serde(default)] + pub trim_shift: trim::TrimShift, /// Selected captions (caption tracks / Captions panel). #[serde(default)] pub caption_selection: Vec, @@ -162,6 +165,10 @@ pub struct Session { pub loaded_schema: u32, /// Render preview files + render-bar segments (shared with the frontend's frame workers). pub previews: Arc, + /// Dynamic (J/K/L) trimming and trim-mode loop playback in progress. + pub trim_play: trim::TrimPlayback, + /// Nesting depth of [`Session::execute`] (commands that run other commands). + exec_depth: u32, } /// A background job with shared progress. @@ -221,6 +228,8 @@ impl Session { persistence: None, loaded_schema: filmcraft_format::SCHEMA_VERSION, previews: Arc::new(previews::PreviewStore::temp()), + trim_play: Default::default(), + exec_depth: 0, } } @@ -311,8 +320,13 @@ impl Session { /// Run a command by id. pub fn execute(&mut self, id: &str, params: Value) -> Result { let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + if self.exec_depth == 0 && spec.journal && self.trim_play.active() { + self.settle_trim_playback(id); + } (spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?; + self.exec_depth += 1; let r = (spec.run)(self, ¶ms); + self.exec_depth -= 1; self.sync_persistence(); if r.is_ok() && spec.journal { self.journal.push((id.to_string(), params)); @@ -323,6 +337,21 @@ impl Session { r } + /// Another command arrives while trim-mode playback runs: a dynamic trim is committed first + /// (so e.g. Undo undoes it as one step); loop playback stops unless it is a trim command. + fn settle_trim_playback(&mut self, id: &str) { + const LIVE: [&str; 5] = ["trim.shuttle", "trim.tick", "trim.shuttleStop", "trim.cancelDynamic", "trim.playAround"]; + if LIVE.contains(&id) { + return; + } + if self.trim_play.dynamic.is_some() { + trim::commit(self); + } + if !id.starts_with("trim.") { + self.trim_play.around = None; + } + } + pub fn is_enabled(&self, id: &str) -> bool { commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok()) } @@ -491,3 +520,5 @@ mod file_tests; mod previews_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod trim_tests; diff --git a/crates/engine/src/trim.rs b/crates/engine/src/trim.rs index aa8494827..b334bd801 100644 --- a/crates/engine/src/trim.rs +++ b/crates/engine/src/trim.rs @@ -4,11 +4,24 @@ //! on the edge shared with the adjacent clip on the same track. Trimming delegates to the //! `timeline.trim` / `timeline.roll` commands so linked partners and sync locks behave the same as //! with the mouse. +//! +//! **Trim Monitor** ([`monitor_info`]): while edit points are selected the Program monitor shows the +//! outgoing clip's last frame and the incoming clip's first frame, with the Out/In shift counters +//! ([`TrimShift`]) that count how far each side moved since the edit point was selected. +//! +//! **Dynamic trimming** ([`TrimPlayback`]): in trim mode J/L "play" the edit point — it moves at the +//! shuttle speed (L forward, J backward, pressing again doubles the speed up to 8×, Shift = slow) +//! and the sequence updates live. K stops and commits: the whole dynamic trim is **one** undo step. +//! Space loops playback around the edit (Preferences ▸ Playback preroll/postroll). Everything is +//! driven by a caller-supplied clock (`clock`, seconds): the UI passes its frame time, tests pass +//! exact values, so dynamic trims are deterministic. + +use std::sync::Arc; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use filmcraft_project::{ClipId, Sequence, TrackId}; +use filmcraft_project::{ClipId, Project, Sequence, TrackId, TrackKind}; use filmcraft_time::{Tick, TimeRange}; use crate::{EngineError, Result, Session}; @@ -34,6 +47,15 @@ impl TrimKind { } } +/// Trim Monitor counters: how far the outgoing clip's Out point and the incoming clip's In point +/// moved since the edit point was selected. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TrimShift { + pub out_shift: Tick, + pub in_shift: Tick, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct EditPoint { pub clip: ClipId, @@ -82,6 +104,7 @@ pub fn select(s: &mut Session, p: &Value) -> Result { s.state.edit_points = vec![ep]; } s.state.selection.clear(); + reset_shift(s); Ok(json!({"editPoints": s.state.edit_points})) } @@ -132,6 +155,7 @@ pub fn select_nearest(s: &mut Session, p: &Value) -> Result { } s.state.edit_points = points; s.state.selection.clear(); + reset_shift(s); Ok(json!({"editPoints": s.state.edit_points})) } @@ -145,6 +169,31 @@ pub fn toggle_type(s: &mut Session) -> Result { /// Trim every selected edit point by `delta` (edit point movement; positive = later). fn trim_all(s: &mut Session, delta: Tick) -> Result { + let applied = trim_all_raw(s, delta)?; + let first = applied.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(); + add_shift(s, first); + Ok(json!({"applied": applied})) +} + +/// Add an applied movement of the primary edit point to the Trim Monitor's shift counters. +fn add_shift(s: &mut Session, d: Tick) { + let Some(ep) = s.state.edit_points.first() else { return }; + match (ep.kind, ep.out) { + (TrimKind::Roll, _) => { + s.state.trim_shift.out_shift += d; + s.state.trim_shift.in_shift += d; + } + (_, true) => s.state.trim_shift.out_shift += d, + (_, false) => s.state.trim_shift.in_shift += d, + } +} + +/// Reset the Trim Monitor counters (a new edit point selection starts a new trim session). +fn reset_shift(s: &mut Session) { + s.state.trim_shift = TrimShift::default(); +} + +fn trim_all_raw(s: &mut Session, delta: Tick) -> Result> { if s.state.edit_points.is_empty() { return Err(EngineError::Other("no edit points selected".into())); } @@ -164,7 +213,7 @@ fn trim_all(s: &mut Session, delta: Tick) -> Result { }; applied.push(r.get("delta").cloned().unwrap_or(Value::Null)); } - Ok(json!({"applied": applied})) + Ok(applied) } /// `trim.nudge {frames}`: Trim Forward/Backward (±1), Many (±5 by default). @@ -227,3 +276,326 @@ pub fn to_playhead(s: &mut Session, p: &Value) -> Result { } Ok(json!({"range": [range.start.0, range.end().0]})) } + +// ------------------------------------------------------------------ Trim Monitor + +/// One side of the Trim Monitor: the clip and the frame shown for it. +fn side_json(s: &Session, seq: &Sequence, clip: ClipId, outgoing: bool) -> Option { + let (tid, it) = seq.find_item(clip)?; + let rate = seq.settings.frame_rate; + let fd = rate.frame_duration(); + let t = if outgoing { it.end() - fd } else { it.start }; + let media_time = it.source_time_at(t.clamp(it.start, it.end() - Tick(1))); + let track = seq.track(tid)?; + let tracks = seq.tracks(track.kind); + let idx = tracks.iter().position(|x| x.id == tid).unwrap_or(0) + 1; + let track_name = format!("{}{idx}", if track.kind == TrackKind::Video { "V" } else { "A" }); + let item_rate = s.project.item(it.item).map(|i| i.frame_rate()).unwrap_or(rate); + Some(json!({ + "clip": it.id.0, "item": it.item.0, "name": it.name, "track": track_name, "video": track.kind == TrackKind::Video, + "time": t.0, "mediaTime": media_time.0, "mediaFrame": item_rate.frame_at(media_time), + "sourceIn": it.source_in.0, "sourceOut": it.source_out().0, + })) +} + +/// The (outgoing, incoming) clips at an edit point. +pub fn sides(seq: &Sequence, ep: &EditPoint) -> (Option, Option) { + match roll_pair(seq, ep) { + Some((l, r)) => (Some(l), Some(r)), + None if ep.out => (Some(ep.clip), None), + None => (None, Some(ep.clip)), + } +} + +/// `trim.monitor`: everything the Trim Monitor shows, for the primary (first) edit point. +pub fn monitor_info(s: &Session) -> Value { + let Some(seq) = s.active_sequence() else { return Value::Null }; + let Some(ep) = s.state.edit_points.first() else { return json!({"active": false}) }; + let rate = seq.settings.frame_rate; + let (out_c, in_c) = sides(seq, ep); + let tp = &s.trim_play; + json!({ + "active": true, + "kind": ep.kind, + "edge": if ep.out { "out" } else { "in" }, + "editTime": edit_time(seq, ep).map(|t| t.0), + "editPoints": s.state.edit_points.len(), + "outgoing": out_c.and_then(|c| side_json(s, seq, c, true)), + "incoming": in_c.and_then(|c| side_json(s, seq, c, false)), + "outShift": rate.frame_at(s.state.trim_shift.out_shift), + "inShift": rate.frame_at(s.state.trim_shift.in_shift), + "largeTrimOffset": s.prefs.trim.large_trim_offset, + "dynamic": tp.dynamic.as_ref().map(|d| json!({"offsetFrames": rate.frame_at(d.offset), "offset": d.offset.0, "speed": d.speed, "atLimit": d.at_limit})), + "playAround": tp.around.as_ref().map(|a| json!({"start": a.start.0, "end": a.end.0, "loop": a.looping})), + }) +} + +/// `trim.applyDefaultTransition` (Apply Default Transitions to Selection, Trim Monitor button): the +/// default video/audio transition at every selected edit point. +pub fn apply_default_transitions(s: &mut Session) -> Result { + let pts = s.state.edit_points.clone(); + let mut ids = Vec::new(); + for ep in &pts { + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let Some((tid, _)) = seq.find_item(ep.clip) else { continue }; + let video = seq.track(tid).is_some_and(|t| t.kind == TrackKind::Video); + let cmd = if video { "sequence.applyVideoTransition" } else { "sequence.applyAudioTransition" }; + if let Ok(r) = s.execute(cmd, json!({"clip": ep.clip.0, "edge": if ep.out { "out" } else { "in" }})) { + ids.push(r["transition"].clone()); + } + } + if ids.is_empty() { + return Err(EngineError::Other("no transition could be applied at the selected edit points".into())); + } + Ok(json!({"transitions": ids})) +} + +// ------------------------------------------------------------------ dynamic trimming + +/// Trim-mode playback state (not project data; never saved). +#[derive(Clone, Debug, Default)] +pub struct TrimPlayback { + pub dynamic: Option, + pub around: Option, +} + +impl TrimPlayback { + pub fn active(&self) -> bool { + self.dynamic.is_some() || self.around.is_some() + } +} + +/// A J/K/L trim in progress. +#[derive(Clone, Debug)] +pub struct DynamicTrim { + /// The project before the trim: the one undo step pushed on commit, and the base every live + /// update re-applies the offset to (so updates never accumulate rounding or clamping). + before: Arc, + shift_before: TrimShift, + /// Edit point movement applied so far (whole frames). + pub offset: Tick, + /// Shuttle speed (× real time); negative = backward, 0 = held (stopped at a limit). + pub speed: f64, + anchor_clock: f64, + anchor_offset: Tick, + /// The trim ran into a limit (media handle, neighbour, sync) and stopped there. + pub at_limit: bool, +} + +/// Loop (or one-shot) playback around the edit: preroll before it to postroll after it. +#[derive(Clone, Debug)] +pub struct PlayAround { + pub start: Tick, + pub end: Tick, + pub looping: bool, + clock0: f64, +} + +fn clock_p(p: &Value) -> f64 { + p.get("clock").and_then(Value::as_f64).unwrap_or(0.0) +} + +fn direction_p(p: &Value) -> f64 { + match p.get("direction") { + Some(Value::String(d)) if d.starts_with('r') || d.starts_with('b') || d.eq_ignore_ascii_case("j") => -1.0, + Some(v) if v.as_f64().is_some_and(|x| x < 0.0) => -1.0, + _ => 1.0, + } +} + +/// Whole frames of movement for `secs` of edit point travel (truncated toward zero). +fn frames_for(s: &Session, secs: f64) -> Tick { + let rate = s.sequence_rate(); + let mag = rate.frame_at(Tick::from_seconds_f64(secs.abs())); + rate.tick_of(if secs < 0.0 { -mag } else { mag }) +} + +/// `trim.shuttle {direction: "forward"|"reverse", slow?, clock}` (L / J in trim mode): start a +/// dynamic trim, speed it up (pressing the same direction again doubles the speed, up to 8×) or +/// reverse it. +pub fn shuttle(s: &mut Session, p: &Value) -> Result { + let clock = clock_p(p); + let dir = direction_p(p); + let slow = p.get("slow").and_then(Value::as_bool).unwrap_or(false); + s.trim_play.around = None; + if s.trim_play.dynamic.is_none() { + s.trim_play.dynamic = Some(DynamicTrim { + before: s.project.clone(), + shift_before: s.state.trim_shift, + offset: Tick::ZERO, + speed: 0.0, + anchor_clock: clock, + anchor_offset: Tick::ZERO, + at_limit: false, + }); + } else { + // bring the offset up to date before changing speed + tick(s, clock)?; + } + let rate = s.sequence_rate(); + let d = s.trim_play.dynamic.as_mut().expect("dynamic trim"); + let speed = if slow { + 0.25 * dir + } else if d.speed * dir > 0.0 && !d.at_limit { + (d.speed * 2.0).clamp(-8.0, 8.0) + } else { + dir + }; + d.speed = speed; + d.anchor_clock = clock; + d.anchor_offset = d.offset; + d.at_limit = false; + Ok(json!({"speed": speed, "offsetFrames": rate.frame_at(d.offset)})) +} + +/// `trim.tick {clock}`: advance dynamic trimming / loop playback to `clock` (call once per frame). +pub fn tick(s: &mut Session, clock: f64) -> Result { + if let Some(d) = s.trim_play.dynamic.clone() { + let want = d.anchor_offset + frames_for(s, (clock - d.anchor_clock).max(0.0) * d.speed); + if want != d.offset { + let got = apply_dynamic(s, want)?; + if let Some(dd) = s.trim_play.dynamic.as_mut() { + dd.offset = got; + if got != want { + // ran into a limit: hold there (K still commits) + dd.at_limit = true; + dd.speed = 0.0; + dd.anchor_offset = got; + dd.anchor_clock = clock; + } + } + } + } else if let Some(a) = s.trim_play.around.clone() { + let len = (a.end - a.start).seconds().max(1e-9); + let mut el = (clock - a.clock0).max(0.0); + if a.looping { + el %= len; + } else if el >= len { + s.trim_play.around = None; + s.set_playhead(a.end - s.sequence_rate().frame_duration()); + return Ok(status(s)); + } + s.set_playhead(a.start + Tick::from_seconds_f64(el)); + } + Ok(status(s)) +} + +fn status(s: &Session) -> Value { + let rate = s.sequence_rate(); + let tp = &s.trim_play; + json!({ + "playing": tp.active(), + "dynamic": tp.dynamic.as_ref().map(|d| json!({"offsetFrames": rate.frame_at(d.offset), "speed": d.speed, "atLimit": d.at_limit})), + "playhead": s.playhead().0, + }) +} + +/// Re-apply `offset` to the project as it was before the dynamic trim, without touching the undo +/// history or the journal. Returns the offset that could actually be applied. +fn apply_dynamic(s: &mut Session, offset: Tick) -> Result { + let Some(d) = s.trim_play.dynamic.clone() else { return Ok(Tick::ZERO) }; + let (undo_len, journal_len, limit) = (s.history.undo.len(), s.journal.len(), s.history.limit); + let redo = std::mem::take(&mut s.history.redo); + s.history.limit = usize::MAX; + s.project = d.before.clone(); + let r = if offset == Tick::ZERO { Ok(vec![json!(0)]) } else { trim_all_raw(s, offset) }; + let applied = match r { + Ok(a) => a.first().and_then(Value::as_i64).map(Tick).unwrap_or_default(), + Err(_) => { + // refused outright (e.g. it would break sync): stay where we were + s.project = d.before.clone(); + if d.offset != Tick::ZERO { + let _ = trim_all_raw(s, d.offset); + } + d.offset + } + }; + s.history.undo.truncate(undo_len); + s.history.limit = limit; + s.history.redo = redo; + s.journal.truncate(journal_len); + s.state.trim_shift = d.shift_before; + add_shift(s, applied); + // the Program monitor follows the moving edit + if let Some(t) = s.active_sequence().and_then(|q| s.state.edit_points.first().and_then(|e| edit_time(q, e))) { + s.set_playhead(t); + } + Ok(applied) +} + +/// `trim.shuttleStop {clock?}` (K in trim mode): stop dynamic trimming and commit it as one undo +/// step, and stop loop playback. +pub fn stop(s: &mut Session, p: &Value) -> Result { + if let Some(c) = p.get("clock").and_then(Value::as_f64) + && s.trim_play.dynamic.is_some() + { + tick(s, c)?; + } + s.trim_play.around = None; + Ok(commit(s)) +} + +/// Commit a dynamic trim in progress (one undo step labelled after the trim kind). +pub fn commit(s: &mut Session) -> Value { + let Some(d) = s.trim_play.dynamic.take() else { return json!({"committed": false}) }; + let rate = s.sequence_rate(); + if d.offset == Tick::ZERO || Arc::ptr_eq(&d.before, &s.project) { + s.project = d.before; + s.state.trim_shift = d.shift_before; + return json!({"committed": false, "offsetFrames": 0}); + } + let label = match s.state.edit_points.first().map(|e| e.kind) { + Some(TrimKind::Roll) => "Dynamic Rolling Edit", + Some(TrimKind::Ripple) => "Dynamic Ripple Trim", + _ => "Dynamic Trim", + }; + s.history.undo.push((label.to_string(), d.before)); + if s.history.undo.len() > s.history.limit { + s.history.undo.remove(0); + } + s.history.redo.clear(); + json!({"committed": true, "label": label, "offsetFrames": rate.frame_at(d.offset), "offset": d.offset.0}) +} + +/// `trim.cancelDynamic` (Esc): drop a dynamic trim in progress, restoring the edit. +pub fn cancel(s: &mut Session) -> Result { + s.trim_play.around = None; + if let Some(d) = s.trim_play.dynamic.take() { + s.project = d.before; + s.state.trim_shift = d.shift_before; + s.fix_state(); + if let Some(t) = s.active_sequence().and_then(|q| s.state.edit_points.first().and_then(|e| edit_time(q, e))) { + s.set_playhead(t); + } + s.revision += 1; + s.events.push(crate::Event::ProjectChanged { revision: s.revision }); + } + Ok(Value::Null) +} + +/// `trim.playAround {clock, loop?: true, toggle?}` (Space / Shift+K in trim mode): play from +/// preroll before the edit (or the playhead, per Preferences ▸ Trim) to postroll after it. +pub fn play_around(s: &mut Session, p: &Value) -> Result { + if s.trim_play.dynamic.is_some() { + commit(s); + } + if s.trim_play.around.is_some() && p.get("toggle").and_then(Value::as_bool).unwrap_or(false) { + s.trim_play.around = None; + return Ok(status(s)); + } + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let edit = s.state.edit_points.first().and_then(|e| edit_time(seq, e)); + let center = match edit { + Some(e) if !s.prefs.trim.playhead_determines_loop => e, + _ => s.playhead(), + }; + let rate = seq.settings.frame_rate; + let dur = seq.duration(); + let start = rate.snap((center - Tick::from_seconds_f64(s.prefs.playback.preroll_seconds)).max(Tick::ZERO)); + let end = rate.snap((center + Tick::from_seconds_f64(s.prefs.playback.postroll_seconds)).min(dur.max(center + rate.frame_duration()))); + let end = end.max(start + rate.frame_duration()); + let looping = p.get("loop").and_then(Value::as_bool).unwrap_or(true); + s.trim_play.around = Some(PlayAround { start, end, looping, clock0: clock_p(p) }); + s.set_playhead(start); + Ok(json!({"start": start.0, "end": end.0, "loop": looping})) +} diff --git a/crates/engine/src/trim_tests.rs b/crates/engine/src/trim_tests.rs new file mode 100644 index 000000000..bbeb147f1 --- /dev/null +++ b/crates/engine/src/trim_tests.rs @@ -0,0 +1,212 @@ +//! Trim Monitor + dynamic (J/K/L) trimming, driven by an exact simulated clock. + +use super::*; +use serde_json::json; + +fn demo() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + for t in ["V2", "V3", "A2", "A3"] { + let _ = s.execute("timeline.setTrack", json!({"track": t, "syncLock": false})); + } + s +} + +/// V1 clips as (id, start, end). +fn v1(s: &Session) -> Vec<(u64, Tick, Tick)> { + s.active_sequence().unwrap().video_tracks[0].items.iter().map(|i| (i.id.0, i.start, i.end())).collect() +} + +/// Whole frames covered by `secs` of real time at the sequence rate. +fn frames(s: &Session, secs: f64) -> i64 { + s.sequence_rate().frame_at(Tick::from_seconds_f64(secs)) +} + +/// Select the cut between V1 clips 0 and 1 as `kind` (roll / ripple out of clip 0). +fn select_first_cut(s: &mut Session, kind: &str) -> (u64, Tick) { + let c = v1(s); + s.execute("trim.selectEditPoint", json!({"clip": c[0].0, "edge": "out", "kind": kind})).unwrap(); + (c[0].0, c[0].2) +} + +#[test] +fn dynamic_roll_forward_is_one_undo_step() { + let mut s = demo(); + let (left, cut) = select_first_cut(&mut s, "roll"); + let rate = s.sequence_rate(); + let undo0 = s.history.undo.len(); + let journal0 = s.journal.len(); + let dur0 = s.active_sequence().unwrap().duration(); + s.execute("trim.shuttle", json!({"direction": "forward", "clock": 10.0})).unwrap(); + // live: the cut moves with the clock, no history yet + s.execute("trim.tick", json!({"clock": 10.5})).unwrap(); + let mid = v1(&s).into_iter().find(|c| c.0 == left).unwrap(); + assert_eq!(mid.2, cut + rate.tick_of(frames(&s, 0.5)), "half a second of roll"); + assert_eq!(s.playhead(), mid.2, "the Program monitor follows the edit"); + assert_eq!(s.history.undo.len(), undo0, "no undo step while trimming"); + let info = s.execute("trim.monitor", json!({})).unwrap(); + assert_eq!(info["dynamic"]["offsetFrames"], json!(frames(&s, 0.5)), "{info}"); + assert_eq!(info["outShift"], json!(frames(&s, 0.5))); + assert_eq!(info["inShift"], json!(frames(&s, 0.5)), "roll moves both sides"); + // K commits + let r = s.execute("trim.shuttleStop", json!({"clock": 11.0})).unwrap(); + assert_eq!(r["committed"], json!(true), "{r}"); + assert_eq!(r["offsetFrames"], json!(frames(&s, 1.0))); + let after = v1(&s).into_iter().find(|c| c.0 == left).unwrap(); + assert_eq!(after.2, cut + rate.tick_of(frames(&s, 1.0))); + assert_eq!(s.active_sequence().unwrap().duration(), dur0, "roll keeps the duration"); + assert_eq!(s.history.undo.len(), undo0 + 1, "exactly one undo step"); + assert_eq!(s.history.undo.last().unwrap().0, "Dynamic Rolling Edit"); + assert!( + s.journal[journal0..].iter().all(|(id, _)| id.starts_with("trim.")), + "live updates are not journaled as separate edits: {:?}", + &s.journal[journal0..] + ); + assert!(s.trim_play.dynamic.is_none()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v1(&s).into_iter().find(|c| c.0 == left).unwrap().2, cut, "undo restores the cut in one step"); +} + +#[test] +fn pressing_l_again_doubles_speed_and_j_reverses() { + let mut s = demo(); + let (left, cut) = select_first_cut(&mut s, "roll"); + let rate = s.sequence_rate(); + let r = s.execute("trim.shuttle", json!({"direction": "forward", "clock": 0.0})).unwrap(); + assert_eq!(r["speed"], json!(1.0)); + let r = s.execute("trim.shuttle", json!({"direction": "forward", "clock": 0.5})).unwrap(); + assert_eq!(r["speed"], json!(2.0)); + // 0.5 s at 1× + 0.5 s at 2× + let a = frames(&s, 0.5); + s.execute("trim.tick", json!({"clock": 1.0})).unwrap(); + let expect = a + frames(&s, 1.0); + assert_eq!(v1(&s).into_iter().find(|c| c.0 == left).unwrap().2, cut + rate.tick_of(expect)); + // J: reverse at 1× + let r = s.execute("trim.shuttle", json!({"direction": "reverse", "clock": 1.0})).unwrap(); + assert_eq!(r["speed"], json!(-1.0)); + s.execute("trim.tick", json!({"clock": 1.25})).unwrap(); + let back = expect - frames(&s, 0.25); + assert_eq!(v1(&s).into_iter().find(|c| c.0 == left).unwrap().2, cut + rate.tick_of(back)); + // slow (Shift+J / Shift+L) = quarter speed + let r = s.execute("trim.shuttle", json!({"direction": "forward", "slow": true, "clock": 1.25})).unwrap(); + assert_eq!(r["speed"], json!(0.25)); + let r = s.execute("trim.shuttleStop", json!({"clock": 2.25})).unwrap(); + assert_eq!(r["offsetFrames"], json!(back + frames(&s, 0.25))); + assert_eq!(s.history.undo.last().unwrap().0, "Dynamic Rolling Edit"); +} + +#[test] +fn dynamic_ripple_backward_shortens_and_stops_at_a_limit() { + let mut s = demo(); + let (clip, end0) = select_first_cut(&mut s, "ripple"); + let rate = s.sequence_rate(); + let start0 = v1(&s)[0].1; + let v1_end = |s: &Session| v1(s).last().unwrap().2; + let dur0 = v1_end(&s); + s.execute("trim.shuttle", json!({"direction": "reverse", "clock": 0.0})).unwrap(); + s.execute("trim.tick", json!({"clock": 0.25})).unwrap(); + let n = frames(&s, 0.25); + assert_eq!(v1(&s)[0].2, end0 - rate.tick_of(n)); + assert_eq!(v1_end(&s), dur0 - rate.tick_of(n), "ripple pulls later material in"); + // keep going for a minute: the clip can't get shorter than one frame + s.execute("trim.tick", json!({"clock": 600.0})).unwrap(); + let info = s.execute("trim.monitor", json!({})).unwrap(); + assert_eq!(info["dynamic"]["atLimit"], json!(true), "{info}"); + assert_eq!(info["dynamic"]["speed"], json!(0.0)); + let c = v1(&s).into_iter().find(|c| c.0 == clip).unwrap(); + assert_eq!(c.2 - c.1, rate.frame_duration(), "held at the one-frame limit"); + assert_eq!(c.1, start0); + let r = s.execute("trim.shuttleStop", json!({})).unwrap(); + assert_eq!(r["label"], json!("Dynamic Ripple Trim")); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(v1_end(&s), dur0); +} + +#[test] +fn another_command_commits_first_and_cancel_restores() { + let mut s = demo(); + let (left, cut) = select_first_cut(&mut s, "roll"); + let undo0 = s.history.undo.len(); + s.execute("trim.shuttle", json!({"direction": "forward", "clock": 0.0})).unwrap(); + s.execute("trim.tick", json!({"clock": 0.5})).unwrap(); + // Undo mid-trim: the dynamic trim is committed, then undone as a single step + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.trim_play.dynamic.is_none()); + assert_eq!(v1(&s).into_iter().find(|c| c.0 == left).unwrap().2, cut); + assert_eq!(s.history.undo.len(), undo0); + assert_eq!(s.history.redo.last().unwrap().0, "Dynamic Rolling Edit"); + // Esc cancels without an undo step (and restores the counters) + let shift0 = s.execute("trim.monitor", json!({})).unwrap()["outShift"].clone(); + s.execute("trim.shuttle", json!({"direction": "forward", "clock": 0.0})).unwrap(); + s.execute("trim.tick", json!({"clock": 0.5})).unwrap(); + assert_ne!(v1(&s).into_iter().find(|c| c.0 == left).unwrap().2, cut); + s.execute("trim.cancelDynamic", json!({})).unwrap(); + assert_eq!(v1(&s).into_iter().find(|c| c.0 == left).unwrap().2, cut); + assert_eq!(s.history.undo.len(), undo0); + assert_eq!(s.execute("trim.monitor", json!({})).unwrap()["outShift"], shift0); + // K with nothing moved commits nothing + s.execute("trim.shuttle", json!({"direction": "forward", "clock": 0.0})).unwrap(); + let r = s.execute("trim.shuttleStop", json!({"clock": 0.0})).unwrap(); + assert_eq!(r["committed"], json!(false)); + assert_eq!(s.history.undo.len(), undo0); +} + +#[test] +fn play_around_loops_preroll_to_postroll() { + let mut s = demo(); + let (_, cut) = select_first_cut(&mut s, "roll"); + let rate = s.sequence_rate(); + s.execute("prefs.set", json!({"values": {"playback.prerollSeconds": 1.0, "playback.postrollSeconds": 0.5}})).unwrap(); + let r = s.execute("trim.playAround", json!({"clock": 100.0})).unwrap(); + let start = rate.snap(cut - Tick::from_seconds_f64(1.0)); + let end = rate.snap(cut + Tick::from_seconds_f64(0.5)); + assert_eq!(r["start"], json!(start.0)); + assert_eq!(r["end"], json!(end.0)); + assert_eq!(s.playhead(), start); + s.execute("trim.tick", json!({"clock": 101.0})).unwrap(); + assert_eq!(s.playhead(), rate.snap(start + Tick::from_seconds_f64(1.0))); + // loops: 1.5 s later we're back at the start + (elapsed mod length) + let len = (end - start).seconds(); + let el = 1.75 % len; + s.execute("trim.tick", json!({"clock": 101.75})).unwrap(); + assert_eq!(s.playhead(), rate.snap(start + Tick::from_seconds_f64(el))); + // trim buttons keep the loop running; other commands stop it + s.execute("trim.forward", json!({})).unwrap(); + assert!(s.trim_play.around.is_some()); + s.execute("markers.add", json!({})).unwrap(); + assert!(s.trim_play.around.is_none()); + // playhead mode + s.execute("prefs.set", json!({"key": "trim.playheadDeterminesLoop", "value": true})).unwrap(); + s.execute("playhead.set", json!({"time": (cut + rate.tick_of(48)).0})).unwrap(); + let r = s.execute("trim.playAround", json!({"clock": 0.0, "loop": false})).unwrap(); + assert_eq!(r["start"], json!(rate.snap(cut + rate.tick_of(48) - Tick::from_seconds_f64(1.0)).0)); + s.execute("trim.tick", json!({"clock": 5.0})).unwrap(); + assert!(s.trim_play.around.is_none(), "one-shot play-around stops at the postroll"); +} + +#[test] +fn trim_monitor_sides_and_shift_counters() { + let mut s = demo(); + let c = v1(&s); + s.execute("trim.selectEditPoint", json!({"clip": c[1].0, "edge": "in", "kind": "ripple"})).unwrap(); + let info = s.execute("trim.monitor", json!({})).unwrap(); + assert_eq!(info["outgoing"]["clip"], json!(c[0].0), "{info}"); + assert_eq!(info["incoming"]["clip"], json!(c[1].0)); + assert_eq!(info["outgoing"]["time"], json!((c[0].2 - s.sequence_rate().frame_duration()).0), "outgoing shows its last frame"); + assert_eq!(info["incoming"]["time"], json!(c[1].1)); + assert_eq!(info["edge"], json!("in")); + s.execute("prefs.set", json!({"key": "trim.largeTrimOffset", "value": 3})).unwrap(); + s.execute("trim.forwardMany", json!({})).unwrap(); + s.execute("trim.forward", json!({})).unwrap(); + let info = s.execute("trim.monitor", json!({})).unwrap(); + assert_eq!(info["inShift"], json!(4), "large offset 3 + 1: {info}"); + assert_eq!(info["outShift"], json!(0), "a ripple In trim leaves the outgoing side alone"); + // a new selection starts new counters + s.execute("trim.selectEditPoint", json!({"clip": c[1].0, "edge": "in", "kind": "roll"})).unwrap(); + assert_eq!(s.execute("trim.monitor", json!({})).unwrap()["inShift"], json!(0)); + // Apply Default Transitions to Selection at the edit point + let r = s.execute("trim.applyDefaultTransition", json!({})).unwrap(); + assert_eq!(r["transitions"].as_array().unwrap().len(), 1, "{r}"); + let seq = s.execute("sequence.inspect", json!({})).unwrap(); + assert!(!seq["video"][0]["transitions"].as_array().unwrap().is_empty()); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index a42a66f5f..fae408e5d 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -29,4 +29,4 @@ log = { workspace = true } rayon = { workspace = true } [dev-dependencies] -egui_kittest = { workspace = true, features = ["eframe"] } +egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d34a21221..66ac3d414 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -524,6 +524,10 @@ impl FilmcraftApp { if ctx.egui_wants_keyboard_input() { return; } + // Esc cancels a dynamic trim in progress + if self.session.trim_play.dynamic.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + let _ = self.session.execute("trim.cancelDynamic", json!({})); + } let mut fire = Vec::new(); ctx.input_mut(|i| { for (m, k, id) in &self.bindings { @@ -637,6 +641,7 @@ impl FilmcraftApp { self.auto.begin_frame(); self.frames.set_context(&ctx); self.session.poll_persistence(); + panels::trim_monitor::advance(self, &ctx); if self.session.persistence.is_some() && self.session.is_dirty() { // Keep polling the auto-save worker (status, "also save the project" results). ctx.request_repaint_after(std::time::Duration::from_secs(1)); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ef10d0013..6b39a7cb3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -26,6 +26,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("playback.forward", "Shuttle Right", [], Some("L")), uic!("playback.stop", "Shuttle Stop", [], Some("K")), uic!("playback.reverse", "Shuttle Left", [], Some("J")), + uic!("playback.slowForward", "Shuttle Slow Right", [], Some("Shift+L")), + uic!("playback.slowReverse", "Shuttle Slow Left", [], Some("Shift+J")), + uic!("playback.playAround", "Play Around", [], Some("Shift+K")), uic!("playback.inToOut", "Play In to Out", [], Some("Shift+Space")), uic!("playback.loop", "Loop", [], None), uic!("view.zoomIn", "Zoom In", ["View"], Some("=")), @@ -94,7 +97,14 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val app.ui.tool = tool; return Ok(json!({"tool": tool})); } + if let Some(r) = crate::panels::trim_monitor::route_transport(app, ctx, id) { + return r; + } match id { + "playback.slowForward" | "playback.slowReverse" => { + app.play(if id == "playback.slowForward" { 0.25 } else { -0.25 }); + return Ok(json!({"speed": app.playback.speed})); + } "playback.toggle" => { app.toggle_play(1.0); return Ok(json!({"playing": app.playback.playing})); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 560d6a361..a70b2d967 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -16,6 +16,7 @@ pub mod text; pub mod timeline; pub mod timeline_captions; pub mod tools; +pub mod trim_monitor; use egui::{Align2, Color32, Rect}; use filmcraft_project::ItemId; @@ -26,6 +27,7 @@ use crate::theme::Tokens; pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) { match p { + PanelKind::Program if !app.session.state.edit_points.is_empty() => trim_monitor::show(app, ui, rect), PanelKind::Program => monitor::show(app, ui, rect, monitor::Which::Program), PanelKind::Source => monitor::show(app, ui, rect, monitor::Which::Source), PanelKind::Timeline => timeline::show(app, ui, rect), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 2170471fd..78bf3d9cc 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -253,6 +253,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { draw_clip(app, &ctx, &p, body, it, r.kind, true, &t, rate); } } + // selected edit points (trim mode): red brackets for ripple, yellow for roll / regular trim + super::trim_monitor::paint_edit_points(app, &p, &seq, tr, r, &layout); // transitions for trn in &tr.transitions { let x0 = layout.x_of(trn.start); @@ -1280,6 +1282,22 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let _ = app.session.execute("timeline.select", json!({"clips": ids})); None } + (Tool::Selection | Tool::Ripple | Tool::Rolling, Hit::Clip { clip, edge: Some(edge), .. }) if resp.clicked() => { + // click an edge = select it as an edit point (trim mode); Shift adds + let kind = if tool == Tool::Rolling || (mods.command && mods.shift) { + "roll" + } else if tool == Tool::Ripple || mods.command { + "ripple" + } else { + "trim" + }; + let e = if edge == filmcraft_edit::Edge::In { "in" } else { "out" }; + let r = app.session.execute("trim.selectEditPoint", json!({"clip": clip.0, "edge": e, "kind": kind, "add": mods.shift && !mods.command})); + if let Err(e) = r { + app.ui.status = e.to_string(); + } + None + } (Tool::Selection | Tool::Ripple | Tool::Rolling | Tool::RateStretch, Hit::Clip { clip, edge: Some(edge), track }) => { let tr = seq.track(track).expect("track"); let mode = if tool == Tool::Ripple || (tool == Tool::Selection && mods.command) { @@ -1331,6 +1349,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & Some(Drag::Marquee { start: p }) } else { app.session.state.selection.clear(); + app.session.state.edit_points.clear(); None } } diff --git a/crates/ui-egui/src/panels/trim_monitor.rs b/crates/ui-egui/src/panels/trim_monitor.rs new file mode 100644 index 000000000..92b5e836b --- /dev/null +++ b/crates/ui-egui/src/panels/trim_monitor.rs @@ -0,0 +1,336 @@ +//! The Trim Monitor: while edit points are selected (trim mode) the Program monitor shows the +//! outgoing clip's last frame and the incoming clip's first frame side by side, with their +//! timecodes, the Out/In shift counters and the trim buttons (−N, −1, Apply Default Transitions, +//! +1, +N; N = Preferences ▸ Trim ▸ Large Trim Offset). Dragging in the outgoing picture trims the +//! outgoing side, in the incoming picture the incoming side, between them rolls. +//! +//! J/K/L and Space are routed to dynamic trimming / loop playback in `menus::invoke`; the engine +//! state machine (`filmcraft_engine::trim`) is advanced here once per frame with the UI clock. +//! +//! Automation ids: `trimMonitor.outgoing`, `trimMonitor.incoming`, `trimMonitor.roll`, +//! `trimMonitor.outTimecode`, `trimMonitor.inTimecode`, `trimMonitor.outShift`, +//! `trimMonitor.inShift`, `trimMonitor.backwardMany`, `trimMonitor.backward`, +//! `trimMonitor.applyTransition`, `trimMonitor.forward`, `trimMonitor.forwardMany`, +//! `trimMonitor.playAround`, `trimMonitor.exit`. + +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use filmcraft_project::{ItemKind, Sequence, Track}; +use filmcraft_time::{Tick, TimeDisplay, format_time}; +use serde_json::{Value, json}; + +use crate::FilmcraftApp; +use crate::frames::{FrameKey, Target}; +use crate::icons::{self, Icon}; +use crate::panels::timeline::{Layout, Row}; +use crate::theme::Tokens; + +/// Ripple edit points are red, roll / regular trims yellow (as in Premiere's timeline). +pub const RIPPLE_RED: Color32 = Color32::from_rgb(0xe5, 0x3e, 0x3e); +pub const ROLL_YELLOW: Color32 = Color32::from_rgb(0xf2, 0xc4, 0x3d); + +/// True when J/K/L/Space should drive trim mode instead of normal playback. +pub fn trim_mode(app: &FilmcraftApp) -> bool { + !app.session.state.edit_points.is_empty() && app.ui.focused != crate::dock::PanelKind::Source +} + +/// Advance dynamic trimming / loop playback to the current UI time (once per frame). +pub fn advance(app: &mut FilmcraftApp, ctx: &egui::Context) { + if !app.session.trim_play.active() { + return; + } + let clock = ctx.input(|i| i.time); + if let Err(e) = app.session.execute("trim.tick", json!({"clock": clock})) { + app.ui.status = e.to_string(); + } + ctx.request_repaint(); +} + +/// Route a transport command in trim mode (J/K/L/Space/Shift+K). `None` = not handled. +pub fn route_transport(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str) -> Option> { + let clock = ctx.input(|i| i.time); + let trimming = trim_mode(app); + let (cmd, params) = match id { + "playback.forward" if trimming => ("trim.shuttle", json!({"direction": "forward", "clock": clock})), + "playback.reverse" if trimming => ("trim.shuttle", json!({"direction": "reverse", "clock": clock})), + "playback.slowForward" if trimming => ("trim.shuttle", json!({"direction": "forward", "slow": true, "clock": clock})), + "playback.slowReverse" if trimming => ("trim.shuttle", json!({"direction": "reverse", "slow": true, "clock": clock})), + "playback.stop" if app.session.trim_play.active() => ("trim.shuttleStop", json!({"clock": clock})), + "playback.toggle" if trimming || app.session.trim_play.around.is_some() => ("trim.playAround", json!({"clock": clock, "toggle": true})), + "playback.playAround" => ("trim.playAround", json!({"clock": clock, "loop": false})), + _ => return None, + }; + app.stop(); + Some(app.session.execute(cmd, params).map_err(|e| e.to_string())) +} + +/// Paint the selected edit points that sit on `tr` as brackets at the clip edges. +pub fn paint_edit_points(app: &FilmcraftApp, p: &egui::Painter, seq: &Sequence, tr: &Track, row: &Row, layout: &Layout) { + use filmcraft_engine::trim::{TrimKind, roll_pair}; + for ep in &app.session.state.edit_points { + let mut marks = Vec::new(); // (clip on this track, out edge) + if ep.kind == TrimKind::Roll + && let Some((l, r)) = roll_pair(seq, ep) + { + marks.push((l, true)); + marks.push((r, false)); + } else { + marks.push((ep.clip, ep.out)); + } + let col = if ep.kind == TrimKind::Ripple { RIPPLE_RED } else { ROLL_YELLOW }; + for (clip, out) in marks { + let Some(it) = tr.item(clip) else { continue }; + let x = layout.x_of(if out { it.end() } else { it.start }); + let (y0, y1) = (row.rect.min.y + 2.0, row.rect.max.y - 2.0); + let dir = if out { -1.0 } else { 1.0 }; + let xi = x + dir * 1.5; + let w = 6.0 * dir; + let s = Stroke::new(3.0, col); + p.line_segment([pos2(xi, y0), pos2(xi, y1)], s); + p.line_segment([pos2(xi, y0 + 1.0), pos2(xi + w, y0 + 1.0)], s); + p.line_segment([pos2(xi, y1 - 1.0), pos2(xi + w, y1 - 1.0)], s); + } + } +} + +/// Fit a w×h picture into `area`. +fn fit(area: Rect, w: f32, h: f32) -> Rect { + let s = (area.width() / w.max(1.0)).min(area.height() / h.max(1.0)); + Rect::from_center_size(area.center(), vec2(w * s, h * s)) +} + +/// Draw one side's frame (the clip's media at `media_time`). +fn picture(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect, side: &Value, tex_name: &str) -> Rect { + let t = app.tokens; + let item = filmcraft_project::ItemId(side["item"].as_u64().unwrap_or(0)); + let Some(pi) = app.session.project.item(item) else { return area }; + let size = match &pi.kind { + ItemKind::Media(m) => m.info.video.as_ref().map(|v| (v.width, v.height)).unwrap_or((0, 0)), + ItemKind::Sequence(s) => (s.settings.width, s.settings.height), + _ => (1920, 1080), + }; + let seq_size = app.session.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); + let pic = fit(area, seq_size.0 as f32, seq_size.1 as f32); + ui.painter().rect_filled(pic, 0.0, t.monitor_bg); + if size.0 == 0 || !side["video"].as_bool().unwrap_or(false) { + ui.painter().text(pic.center(), Align2::CENTER_CENTER, format!("♪ {}", side["name"].as_str().unwrap_or("")), Tokens::ui(13.0), t.text_dim); + return pic; + } + let img_rect = fit(pic, size.0 as f32, size.1 as f32); + let rate = pi.frame_rate(); + let media_time = Tick(side["mediaTime"].as_i64().unwrap_or(0)); + let frame = rate.frame_at(media_time); + let ppp = ui.ctx().pixels_per_point(); + let want = (img_rect.width() * ppp / size.0 as f32).clamp(1.0 / 32.0, 1.0); + let buckets = [1.0 / 32.0, 1.0 / 16.0, 1.0 / 8.0, 0.1875, 0.25, 0.375, 0.5, 0.75, 1.0]; + let scale = *buckets.iter().find(|b| **b >= want - 1e-4).unwrap_or(&1.0); + let scale = scale.min(app.ui.program.res.scale()); + let size_key = (scale * 1000.0) as u32; + let key = FrameKey { target: Target::Item(item), frame, size: size_key, revision: 0 }; + let project = app.session.project.clone(); + app.frames.request(key, rate.tick_of(frame), scale, &project, 0); + let ctx = ui.ctx().clone(); + let tex = if let Some(img) = app.frames.get(&key) { + Some(app.texture_for(&ctx, tex_name, key, &img)) + } else { + match app.frames.nearest(Target::Item(item), frame, size_key, 0, 48) { + Some(img) => Some(app.texture_for(&ctx, tex_name, FrameKey { frame: frame - 1, ..key }, &img)), + None => app.texture_existing(tex_name).map(|(id, _)| id), + } + }; + if let Some(tex) = tex { + ui.painter().image(tex, img_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + pic +} + +fn shift_text(frames: i64) -> String { + if frames > 0 { format!("+{frames}") } else { frames.to_string() } +} + +/// The two-up Trim Monitor in the Program monitor's `rect`. +pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let info = filmcraft_engine::trim::monitor_info(&app.session); + let Some(seq) = app.session.active_sequence() else { return }; + let rate = seq.settings.frame_rate; + let df = seq.settings.drop_frame; + ui.painter().rect_filled(rect, 0.0, t.panel_bg); + let controls_h = 26.0 + 40.0; + let header_h = 22.0; + let gap = 14.0; + let area = Rect::from_min_max(rect.min + vec2(6.0, 4.0 + header_h), pos2(rect.max.x - 6.0, rect.max.y - controls_h)); + let half = (area.width() - gap) / 2.0; + let left = Rect::from_min_size(area.min, vec2(half, area.height())); + let right = Rect::from_min_size(pos2(left.max.x + gap, area.min.y), vec2(half, area.height())); + let kind = info["kind"].as_str().unwrap_or("trim").to_string(); + let out_edge = info["edge"] == json!("out"); + // which sides move: roll = both; otherwise the side the edit point belongs to + let (hot_out, hot_in) = match kind.as_str() { + "roll" => (true, true), + _ => (out_edge, !out_edge), + }; + let col = if kind == "ripple" { RIPPLE_RED } else { ROLL_YELLOW }; + // headers: clip names + for (side, r, key) in [(&info["outgoing"], left, "outgoing"), (&info["incoming"], right, "incoming")] { + let name = side["name"].as_str().map(|n| format!("{} ({})", n, side["track"].as_str().unwrap_or(""))).unwrap_or_else(|| "(gap)".into()); + let label = if key == "outgoing" { format!("Out: {name}") } else { format!("In: {name}") }; + ui.painter().text(pos2(r.min.x + 2.0, rect.min.y + 4.0 + header_h / 2.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); + } + let mut pics = [left, right]; + for (i, (side, r)) in [(&info["outgoing"], left), (&info["incoming"], right)].into_iter().enumerate() { + if side.is_null() { + let p = fit(r, 16.0, 9.0); + ui.painter().rect_filled(p, 0.0, Color32::BLACK); + ui.painter().text(p.center(), Align2::CENTER_CENTER, "(gap)", Tokens::ui(12.0), t.text_faint); + pics[i] = p; + } else { + pics[i] = picture(app, ui, r, side, if i == 0 { "trim-outgoing" } else { "trim-incoming" }); + } + } + let [lp, rp] = pics; + let hot = [hot_out, hot_in]; + for (i, p) in pics.iter().enumerate() { + if hot[i] { + ui.painter().rect_stroke(*p, 0.0, Stroke::new(2.0, col), StrokeKind::Outside); + } + } + // dynamic trim indicator + if let Some(d) = info["dynamic"].as_object() { + let sp = d.get("speed").and_then(Value::as_f64).unwrap_or(0.0); + let txt = if sp == 0.0 { + "TRIM ■".to_string() + } else if sp > 0.0 { + format!("TRIM ▶ {sp}×") + } else { + format!("TRIM ◀ {}×", -sp) + }; + let at = pos2((lp.max.x + rp.min.x) / 2.0, area.min.y + 12.0); + ui.painter().text(at, Align2::CENTER_CENTER, txt, Tokens::semibold(11.0), col); + } + app.auto.add("trimMonitor.outgoing", lp, "Outgoing clip (drag to trim the Out point)"); + app.auto.add("trimMonitor.incoming", rp, "Incoming clip (drag to trim the In point)"); + let mid = Rect::from_min_max(pos2(lp.max.x, area.min.y), pos2(rp.min.x, area.max.y)); + app.auto.add("trimMonitor.roll", mid, "Drag to roll the edit"); + + // drag to trim: px → frames (6 px per frame), applied on release as one trim + let fpp = 1.0 / 6.0; + for (r, which) in [(lp, "out"), (rp, "in"), (mid, "roll")] { + let resp = ui.interact(r, egui::Id::new(("trim-monitor-drag", which)), Sense::drag()); + if resp.dragged() || resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeColumn); + } + if resp.dragged() { + let total = ui.ctx().data_mut(|d| { + let v = d.get_temp_mut_or_default::(egui::Id::new("trim-monitor-dx")); + *v += resp.drag_delta().x; + *v + }); + let n = (total * fpp).round() as i64; + ui.painter().text(pos2(r.center().x, r.max.y - 14.0), Align2::CENTER_CENTER, shift_text(n), Tokens::semibold(14.0), col); + } + if resp.drag_stopped() { + let total = ui.ctx().data_mut(|d| d.remove_temp::(egui::Id::new("trim-monitor-dx")).unwrap_or(0.0)); + let n = (total * fpp).round() as i64; + if n != 0 { + drag_trim(app, which, n); + } + } + } + + // ---- counters row: outgoing Out timecode + shift | incoming In timecode + shift + let row = Rect::from_min_size(pos2(rect.min.x + 10.0, area.max.y + 2.0), vec2(rect.width() - 20.0, 24.0)); + let tc = |v: &Value, k: &str| v[k].as_i64().map(|x| format_time(Tick(x), rate, df, TimeDisplay::Timecode, 48000)).unwrap_or_else(|| "--:--:--:--".into()); + let out_tc = tc(&info["outgoing"], "time"); + let in_tc = tc(&info["incoming"], "time"); + let out_shift = shift_text(info["outShift"].as_i64().unwrap_or(0)); + let in_shift = shift_text(info["inShift"].as_i64().unwrap_or(0)); + let p = ui.painter(); + let tcr = p.text(pos2(lp.min.x, row.center().y), Align2::LEFT_CENTER, &out_tc, Tokens::semibold(14.0), t.hot_text); + app.auto.add("trimMonitor.outTimecode", tcr, &out_tc); + let sr = p.text(pos2(lp.max.x, row.center().y), Align2::RIGHT_CENTER, &out_shift, Tokens::semibold(14.0), if hot_out { col } else { t.text_dim }); + app.auto.add("trimMonitor.outShift", sr, &out_shift); + let sr = p.text(pos2(rp.min.x, row.center().y), Align2::LEFT_CENTER, &in_shift, Tokens::semibold(14.0), if hot_in { col } else { t.text_dim }); + app.auto.add("trimMonitor.inShift", sr, &in_shift); + let tcr = p.text(pos2(rp.max.x, row.center().y), Align2::RIGHT_CENTER, &in_tc, Tokens::semibold(14.0), t.hot_text); + app.auto.add("trimMonitor.inTimecode", tcr, &in_tc); + + // ---- trim buttons + let n = info["largeTrimOffset"].as_i64().unwrap_or(5); + let bar = Rect::from_min_size(pos2(rect.min.x, row.max.y + 6.0), vec2(rect.width(), 28.0)); + let buttons: [(&str, String, &str, f32); 6] = [ + ("trimMonitor.playAround", String::new(), "Play Around Edit (Space loops, Shift+K once)", 30.0), + ("trimMonitor.backwardMany", format!("−{n}"), "Trim Backward Many", 40.0), + ("trimMonitor.backward", "−1".into(), "Trim Backward", 40.0), + ("trimMonitor.applyTransition", "Apply Default Transitions".into(), "Apply Default Transitions to Selection (Shift+D)", 176.0), + ("trimMonitor.forward", "+1".into(), "Trim Forward", 40.0), + ("trimMonitor.forwardMany", format!("+{n}"), "Trim Forward Many", 40.0), + ]; + let total: f32 = buttons.iter().map(|b| b.3 + 4.0).sum(); + let mut x = bar.center().x - total / 2.0; + let ctx = ui.ctx().clone(); + for (id, label, tip, w) in buttons { + let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(w, bar.height())); + let resp = ui.interact(r, egui::Id::new(id), Sense::click()).on_hover_text(tip); + let bg = if resp.is_pointer_button_down_on() { + t.pressed + } else if resp.hovered() { + t.hover + } else { + t.field_bg + }; + ui.painter().rect_filled(r, 4.0, bg); + if label.is_empty() { + let playing = app.session.trim_play.around.is_some(); + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), if playing { Icon::Pause } else { Icon::Loop }, t.icon); + } else { + ui.painter().text(r.center(), Align2::CENTER_CENTER, &label, Tokens::ui(12.0), t.text); + } + app.auto.add(id, r, tip); + if resp.clicked() { + let res = match id { + "trimMonitor.playAround" => app.session.execute("trim.playAround", json!({"clock": ctx.input(|i| i.time), "toggle": true})), + "trimMonitor.backwardMany" => app.session.execute("trim.backwardMany", json!({})), + "trimMonitor.backward" => app.session.execute("trim.backward", json!({})), + "trimMonitor.forward" => app.session.execute("trim.forward", json!({})), + "trimMonitor.forwardMany" => app.session.execute("trim.forwardMany", json!({})), + _ => app.session.execute("trim.applyDefaultTransition", json!({})), + }; + if let Err(e) = res { + app.ui.status = e.to_string(); + } + } + x += w + 4.0; + } + // exit trim mode + let r = Rect::from_min_size(pos2(rect.max.x - 30.0, bar.min.y + 3.0), vec2(22.0, 22.0)); + let resp = ui.interact(r, egui::Id::new("trimMonitor.exit"), Sense::click()).on_hover_text("Exit Trim Mode"); + let c = if resp.hovered() { t.tab_text_active } else { t.text_dim }; + ui.painter().line_segment([r.min + vec2(6.0, 6.0), r.max - vec2(6.0, 6.0)], Stroke::new(1.5, c)); + ui.painter().line_segment([pos2(r.max.x - 6.0, r.min.y + 6.0), pos2(r.min.x + 6.0, r.max.y - 6.0)], Stroke::new(1.5, c)); + app.auto.add("trimMonitor.exit", r, "Exit Trim Mode"); + if resp.clicked() { + let _ = app.session.execute("trim.clear", json!({})); + } +} + +/// A drag in the Trim Monitor: re-target the primary edit point to the dragged side, then trim. +fn drag_trim(app: &mut FilmcraftApp, which: &str, frames: i64) { + use filmcraft_engine::trim::{TrimKind, sides}; + let Some(seq) = app.session.active_sequence() else { return }; + let Some(ep) = app.session.state.edit_points.first().copied() else { return }; + let (out_c, in_c) = sides(seq, &ep); + let target = match which { + "out" => out_c.map(|c| (c, "out", if ep.kind == TrimKind::Roll { TrimKind::Ripple } else { ep.kind })), + "in" => in_c.map(|c| (c, "in", if ep.kind == TrimKind::Roll { TrimKind::Ripple } else { ep.kind })), + _ => out_c.or(in_c).map(|c| (c, if out_c.is_some() { "out" } else { "in" }, TrimKind::Roll)), + }; + let Some((clip, edge, kind)) = target else { return }; + let multi = app.session.state.edit_points.len() > 1; + if !multi && (clip != ep.clip || (edge == "out") != ep.out || kind != ep.kind) { + let kind = serde_json::to_value(kind).unwrap_or(json!("trim")); + let _ = app.session.execute("trim.selectEditPoint", json!({"clip": clip.0, "edge": edge, "kind": kind})); + } + if let Err(e) = app.session.execute("trim.nudge", json!({"frames": frames})) { + app.ui.status = e.to_string(); + } +} diff --git a/crates/ui-egui/tests/scripted.rs b/crates/ui-egui/tests/scripted.rs index 2053f2363..98e5f7f58 100644 --- a/crates/ui-egui/tests/scripted.rs +++ b/crates/ui-egui/tests/scripted.rs @@ -24,11 +24,20 @@ struct Driver { impl Driver { /// The app with the demo project open, after its first frames (theme and fonts installed). fn demo() -> Self { + Self::demo_with(false) + } + + /// `render`: with a wgpu renderer so `Harness::render` can take screenshots (needs a GPU). + fn demo_with(render: bool) -> Self { let mut session = Session::default(); session.execute("file.openDemoProject", json!({})).expect("demo project"); let (tx, rx) = channel(); let app = FilmcraftApp::new(session).with_control(rx); - let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if render { + b = b.wgpu(); + } + let harness = b.build_eframe(move |_cc| app); let mut d = Driver { harness, tx }; d.frames(4); d @@ -210,3 +219,121 @@ fn unknown_methods_and_commands_fail_cleanly() { let v = d.call("engine.execute", json!({"command": "no.such.command"})); assert_eq!(v["ok"], json!(false)); } + +/// End of the first V1 clip (ticks). +fn first_cut(d: &mut Driver) -> (u64, i64) { + let c = &track_clips(&d.sequence(), 0)[0]; + (c["clip"].as_u64().unwrap(), c["start"].as_i64().unwrap() + c["duration"].as_i64().unwrap()) +} + +#[test] +fn trim_monitor_buttons_and_dynamic_jkl_trimming() { + let mut d = Driver::demo(); + for t in ["V2", "V3", "A2", "A3"] { + d.exec("timeline.setTrack", json!({"track": t, "syncLock": false})); + } + let (clip, cut) = first_cut(&mut d); + let fd = { + let rate = &d.sequence()["settings"]["frame_rate"]; + 254_016_000_000 * rate["den"].as_i64().unwrap() / rate["num"].as_i64().unwrap() + }; + // Selecting an edit point turns the Program monitor into the Trim Monitor. + assert!(d.element_ids("trimMonitor.").is_empty()); + d.exec("trim.selectEditPoint", json!({"clip": clip, "edge": "out", "kind": "roll"})); + d.frames(3); + let ids = d.element_ids("trimMonitor."); + for id in [ + "trimMonitor.outgoing", + "trimMonitor.incoming", + "trimMonitor.outShift", + "trimMonitor.inShift", + "trimMonitor.forward", + "trimMonitor.backwardMany", + "trimMonitor.applyTransition", + ] { + assert!(ids.iter().any(|x| x == id), "{id} missing from {ids:?}"); + } + // +1 button rolls the cut one frame + d.ok("ui.click", json!({"id": "trimMonitor.forward"})); + d.frames(2); + assert_eq!(first_cut(&mut d).1, cut + fd); + let info = d.exec("trim.monitor", json!({})); + assert_eq!(info["outShift"], json!(1), "{info}"); + let undo0 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(); + + // L starts a dynamic trim; the edit moves while frames run; K commits one undo step. + d.ok("ui.key", json!({"key": "L"})); + d.frames(4); + let live = d.exec("trim.monitor", json!({})); + assert!(live["dynamic"]["offsetFrames"].as_i64().unwrap() > 0, "trimming live: {live}"); + assert_eq!(d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(), undo0, "no undo steps while trimming"); + d.ok("ui.key", json!({"key": "K"})); + d.frames(2); + let after = first_cut(&mut d).1; + assert!(after > cut + fd, "the cut moved later: {cut} -> {after}"); + let hist = d.exec("history.list", json!({})); + let undo = hist["undo"].as_array().unwrap(); + assert_eq!(undo.len(), undo0 + 1, "{hist}"); + assert_eq!(undo.last().unwrap(), &json!("Dynamic Rolling Edit")); + assert!(d.exec("trim.monitor", json!({}))["dynamic"].is_null()); + // J trims backward + d.ok("ui.key", json!({"key": "J"})); + d.frames(2); + d.ok("ui.key", json!({"key": "K"})); + d.frames(1); + assert!(first_cut(&mut d).1 < after, "J trimmed backward"); + // Undo twice: back to the +1 state, then Space loops around the edit + d.ok("ui.menu.invoke", json!({"id": "edit.undo"})); + d.ok("ui.menu.invoke", json!({"id": "edit.undo"})); + assert_eq!(first_cut(&mut d).1, cut + fd); + d.ok("ui.key", json!({"key": "Space"})); + d.frames(2); + assert!(d.exec("trim.monitor", json!({}))["playAround"].is_object()); + d.ok("ui.key", json!({"key": "Space"})); + d.frames(1); + assert!(d.exec("trim.monitor", json!({}))["playAround"].is_null()); + // exit trim mode: the normal Program monitor returns + d.ok("ui.click", json!({"id": "trimMonitor.exit"})); + d.frames(2); + assert!(d.element_ids("trimMonitor.").is_empty()); + assert!(d.element_ids("program.").iter().any(|x| x == "program.picture")); +} + +impl Driver { + /// Let background frame workers deliver, then render the window to `/.png`. + fn screenshot(&mut self, name: &str) -> std::path::PathBuf { + for _ in 0..30 { + std::thread::sleep(std::time::Duration::from_millis(40)); + self.frames(1); + } + let img = self.harness.render().expect("render"); + let png = filmcraft_ui_egui::control::encode_png(img.as_raw(), img.width(), img.height()).expect("png"); + let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("ui-screenshots"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{name}.png")); + std::fs::write(&path, png).unwrap(); + eprintln!("screenshot: {}", path.display()); + path + } +} + +/// Renders the Trim Monitor and the Keyboard Shortcuts dialog to PNGs for visual review (needs a +/// GPU): `cargo test -p filmcraft-ui-egui --test scripted -- --ignored screenshots`. +#[test] +#[ignore] +fn screenshots() { + let mut d = Driver::demo_with(true); + for t in ["V2", "V3", "A2", "A3"] { + d.exec("timeline.setTrack", json!({"track": t, "syncLock": false})); + } + d.exec("playhead.set", json!({"seconds": 4.5})); + d.exec("trim.selectNearest", json!({"kind": "rippleOut"})); + d.exec("trim.forwardMany", json!({})); + d.screenshot("trim-monitor-ripple"); + d.exec("trim.toggleType", json!({})); + d.ok("ui.key", json!({"key": "L"})); + d.frames(3); + d.screenshot("trim-monitor-dynamic-roll"); + d.ok("ui.key", json!({"key": "K"})); + d.frames(2); +} From 985a848c5ef1e18597f63d966f6c9d0581da4155 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 01:56:47 -0400 Subject: [PATCH 18/24] M3.11: keyboard shortcuts editor + presets - Engine shortcut set (crates/engine/src/shortcuts.rs): bindings as data (command, canonical chord, optional panel), panel shortcuts override app ones while the panel has focus; per-OS modifiers (Cmd = Cmd/Ctrl, Ctrl = macOS Control, folded into Ctrl elsewhere and reported as conflicts); conflict detection, reassignment, undo/redo, Cancel restores; frontend commands registered so tools/transport/panels are bindable too. - Presets: FilmCraft Default, Premiere Pro / Final Cut Pro / Avid Media Composer Compatible (key tables mapped onto FilmCraft command ids); custom presets as JSON in /Keyboard Shortcuts/, active set persisted, Save As / Delete / Export / Import (Windows-notation files converted). - shortcuts.* commands for agents/MCP (list/get/set/clear/undo/redo/conflicts/ forKey/resolve/presets/loadPreset/savePreset/deletePreset/export/import/audit). - Premiere-default audit: commands without a shortcut get Premiere's key when free (Shift+D Apply Default Transitions, Shift+J/L slow shuttle, Shift+K Play Around, Project > Shift+O Open in Source Monitor); Audio Clip/Track Mixer window shortcuts corrected (Shift+9 / Shift+6). - Edit > Keyboard Shortcuts... (Cmd+Alt+K): keyboard drawn in code with app/panel/both colouring and modifier filter, searchable command list with a record-on-click Shortcut column and +, Key: detail list, conflict warnings, Undo/Redo/Clear/Cancel/OK; automation ids shortcuts.*. - Menus (in-app and native macOS key equivalents), command.list and the input loop follow the active bindings live. - Tests: 7 engine tests; scripted kittest test (record keys, live menu update, reassignment, undo/redo, cancel, OK, panel-specific Left in History). Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft/src/main.rs | 6 +- apps/filmcraft/src/native_menu.rs | 16 +- crates/engine/src/commands.rs | 10 +- crates/engine/src/lib.rs | 8 + crates/engine/src/shortcut_presets.rs | 247 ++++ crates/engine/src/shortcuts.rs | 1152 +++++++++++++++++ crates/engine/src/shortcuts_tests.rs | 215 +++ crates/ui-egui/src/dock.rs | 4 +- crates/ui-egui/src/lib.rs | 33 +- crates/ui-egui/src/menus.rs | 70 +- crates/ui-egui/src/panels/dialogs.rs | 23 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/shortcuts_dialog.rs | 787 +++++++++++ crates/ui-egui/tests/scripted.rs | 105 ++ docs/control-protocol.md | 14 + 15 files changed, 2627 insertions(+), 64 deletions(-) create mode 100644 crates/engine/src/shortcut_presets.rs create mode 100644 crates/engine/src/shortcuts.rs create mode 100644 crates/engine/src/shortcuts_tests.rs create mode 100644 crates/ui-egui/src/panels/shortcuts_dialog.rs diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index b4111dd8f..07e9359ff 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -109,12 +109,16 @@ fn main() -> eframe::Result { app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| { rfd::FileDialog::new().add_filter(filter, exts).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) })); + app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| { + rfd::FileDialog::new().add_filter(filter, exts).pick_file().map(|p| p.to_string_lossy().to_string()) + })); app.hooks.pick_open_project = Some(Box::new(|| rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).pick_file().map(|p| p.to_string_lossy().to_string()))); #[cfg(target_os = "macos")] { - let rx = native_menu::install(&app, cc.egui_ctx.clone()); + let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone()); app.command_inbox = Some(rx); + app.hooks.shortcuts_changed = Some(update); app.ui.show_menu_bar = false; } if let Some(port) = control_port { diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index 3f9a6543e..fd1422948 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -19,8 +19,12 @@ fn accel(s: &str) -> Option { mapped.parse().ok() } -pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> Receiver { +/// Updates native key equivalents when the active keyboard shortcuts change. +pub type ShortcutUpdater = Box; + +pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, ShortcutUpdater) { let items = menu_items(app); + let mut native: Vec<(String, MenuItem)> = Vec::new(); let bar = Menu::new(); let app_menu = Submenu::new("FilmCraft", true); let _ = app_menu.append_items(&[ @@ -40,6 +44,7 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> Receiver { let mut subs: Vec<(String, Submenu)> = Vec::new(); for it in mine { let mi = MenuItem::with_id(it.id.clone(), &it.label, true, it.shortcut.as_deref().and_then(accel)); + native.push((it.id.clone(), mi.clone())); if let Some(name) = it.path.get(1) { if let Some((_, s)) = subs.iter().find(|(n, _)| n == name) { let _ = s.append(&mi); @@ -69,5 +74,12 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> Receiver { ctx.request_repaint(); } }); - rx + let update: ShortcutUpdater = Box::new(move |items: &[Item]| { + for (id, mi) in &native { + if let Some(it) = items.iter().find(|i| &i.id == id) { + let _ = mi.set_accelerator(it.shortcut.as_deref().and_then(accel)); + } + } + }); + (rx, update) } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 4c5483441..11b631850 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -916,7 +916,7 @@ fn build() -> Vec { s.events.push(crate::Event::OpenSequence(id)); Ok(Value::Null) }), - cmd!("sequence.close", "Close Sequence", [], None, r#"{"item":id}"#, has_seq, |s, p| { + cmd!("sequence.close", "Close Sequence", [], None, r#"{"item":id?}"#, has_seq, |s, p| { let id = item_p(p, "item").or(s.state.active_sequence).ok_or(EngineError::NoSequence)?; s.state.open_sequences.retain(|x| *x != id); if s.state.active_sequence == Some(id) { @@ -1424,7 +1424,7 @@ fn build() -> Vec { let n = s.prefs.trim.large_trim_offset as i64; crate::trim::nudge(s, &json!({"frames": n})) }), - cmd!("trim.applyDefaultTransition", "Apply Default Transitions to Selection", ["Sequence"], Some("Shift+D"), "{}", has_edit_points, |s, _| { + cmd!("trim.applyDefaultTransition", "Apply Default Transitions to Selection", ["Sequence"], None, "{}", has_edit_points, |s, _| { crate::trim::apply_default_transitions(s) }), cmd!("trim.shuttle", "Dynamic Trim (Shuttle)", [], None, r#"{"direction":"forward|reverse","slow":bool?,"clock":seconds}"#, has_edit_points, |s, p| { @@ -1827,7 +1827,10 @@ fn build() -> Vec { Ok(Value::Array( command_specs() .iter() - .map(|c| json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(s).is_ok()})) + .map(|c| { + let all: Vec<&str> = s.shortcuts.for_command(c.id).iter().map(|b| b.keys.as_str()).collect(); + json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": s.shortcuts.primary(c.id), "shortcuts": all, "defaultShortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(s).is_ok()}) + }) .collect(), )) }), @@ -1846,6 +1849,7 @@ fn build() -> Vec { )), ]; v.extend(crate::captions::commands()); + v.extend(crate::shortcuts::commands()); // Labels as individual commands (Edit ▸ Label ▸ ) for l in Label::ALL { let _ = l; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d44d094de..a78c23327 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,8 @@ pub mod demo; pub mod interchange; pub mod media_pool; pub mod previews; +pub mod shortcut_presets; +pub mod shortcuts; pub mod trim; use std::sync::Arc; @@ -167,6 +169,8 @@ pub struct Session { pub previews: Arc, /// Dynamic (J/K/L) trimming and trim-mode loop playback in progress. pub trim_play: trim::TrimPlayback, + /// Keyboard shortcuts (active bindings, presets; `shortcuts.*` commands). + pub shortcuts: shortcuts::Shortcuts, /// Nesting depth of [`Session::execute`] (commands that run other commands). exec_depth: u32, } @@ -229,6 +233,7 @@ impl Session { loaded_schema: filmcraft_format::SCHEMA_VERSION, previews: Arc::new(previews::PreviewStore::temp()), trim_play: Default::default(), + shortcuts: shortcuts::Shortcuts::new(), exec_depth: 0, } } @@ -239,6 +244,7 @@ impl Session { pub fn start_autosave(&mut self, cfg: autosave::AutosaveConfig) -> std::io::Result<()> { let prefs_path = cfg.data_dir.join("preferences.json"); self.prefs = autosave::Preferences::load(&prefs_path); + self.shortcuts.set_dir(&cfg.data_dir); self.prefs_path = Some(prefs_path); self.persistence = Some(autosave::Persistence::start(&cfg, self.prefs.auto_save.clone())?); self.sync_persistence(); @@ -519,6 +525,8 @@ mod file_tests; #[cfg(test)] mod previews_tests; #[cfg(test)] +mod shortcuts_tests; +#[cfg(test)] mod tests; #[cfg(test)] mod trim_tests; diff --git a/crates/engine/src/shortcut_presets.rs b/crates/engine/src/shortcut_presets.rs new file mode 100644 index 000000000..4f4d36c59 --- /dev/null +++ b/crates/engine/src/shortcut_presets.rs @@ -0,0 +1,247 @@ +//! Built-in keyboard shortcut tables (command id, keys, panel; "" = application-wide). +//! +//! Key assignments are functional facts about how other editors are operated; these tables map +//! them onto FilmCraft's own command ids. No files from any other application are used. +//! Keys use FilmCraft's canonical notation (`Cmd` = ⌘ on macOS / Ctrl elsewhere, `Ctrl` = the +//! macOS Control key, `Alt` = ⌥ Option / Alt; `Backspace` = the Mac "delete" key, `Delete` = the +//! forward-delete key). + +pub type Entry = (&'static str, &'static str, &'static str); + +/// FilmCraft additions to the per-command defaults: panel-specific shortcuts. +pub const FILMCRAFT_PANEL: &[Entry] = &[ + ("project.delete", "Backspace", "Project"), + ("file.newBin", "Cmd+B", "Project"), + ("edit.undo", "Left", "History"), + ("edit.redo", "Right", "History"), + ("sequence.close", "Cmd+W", "Timeline"), +]; + +/// Premiere Pro's default keyboard (macOS) mapped onto FilmCraft commands. FilmCraft Default takes +/// every entry whose command has no shortcut yet and whose key is free (the shortcut audit); +/// the "Premiere Pro Compatible" preset applies all of them. +pub const PREMIERE: &[Entry] = &[ + // Tools + ("tool.selection", "V", ""), + ("tool.trackSelectBackward", "Shift+A", ""), + ("tool.trackSelectForward", "A", ""), + ("tool.ripple", "B", ""), + ("tool.rolling", "N", ""), + ("tool.rateStretch", "R", ""), + ("tool.razor", "C", ""), + ("tool.slip", "Y", ""), + ("tool.slide", "U", ""), + ("tool.pen", "P", ""), + ("tool.hand", "H", ""), + ("tool.zoom", "Z", ""), + ("tool.type", "T", ""), + // Application + ("app.preferences.autoSave", "Cmd+,", ""), + ("app.keyboardShortcuts", "Cmd+Alt+K", ""), + // File + ("file.newProject", "Cmd+Alt+N", ""), + ("file.newSequence", "Cmd+N", ""), + ("file.newBin", "Cmd+/", ""), + ("file.newBin", "Cmd+B", "Project"), + ("file.open", "Cmd+O", ""), + ("file.save", "Cmd+S", ""), + ("file.saveAs", "Cmd+Shift+S", ""), + ("file.saveCopy", "Cmd+Alt+S", ""), + ("file.import", "Cmd+I", ""), + ("mode.export", "Cmd+M", ""), + // Edit + ("edit.undo", "Cmd+Z", ""), + ("edit.redo", "Cmd+Shift+Z", ""), + ("edit.cut", "Cmd+X", ""), + ("edit.copy", "Cmd+C", ""), + ("edit.paste", "Cmd+V", ""), + ("edit.pasteInsert", "Cmd+Shift+V", ""), + ("edit.clear", "Delete", ""), + ("edit.clear", "Backspace", "Timeline"), + ("edit.rippleDelete", "Shift+Delete", ""), + ("edit.rippleDelete", "Alt+Backspace", "Timeline"), + ("edit.duplicate", "Cmd+Shift+/", ""), + ("edit.selectAll", "Cmd+A", ""), + ("edit.deselectAll", "Cmd+Shift+A", ""), + // Clip + ("clip.speedDuration", "Cmd+R", ""), + ("source.insert", ",", ""), + ("source.overwrite", ".", ""), + ("clip.enable", "Cmd+Shift+E", ""), + ("clip.link", "Cmd+L", ""), + ("clip.group", "Cmd+G", ""), + ("clip.ungroup", "Cmd+Shift+G", ""), + ("clip.audioGain", "G", ""), + // Sequence + ("sequence.renderEffectsInToOut", "Enter", ""), + ("sequence.matchFrame", "F", ""), + ("sequence.addEdit", "Cmd+K", ""), + ("sequence.addEditAllTracks", "Cmd+Shift+K", ""), + ("trim.edit", "Shift+T", ""), + ("trim.extendToPlayhead", "E", ""), + ("sequence.applyVideoTransition", "Cmd+D", ""), + ("sequence.applyAudioTransition", "Cmd+Shift+D", ""), + ("trim.applyDefaultTransition", "Shift+D", ""), + ("sequence.lift", ";", ""), + ("sequence.extract", "'", ""), + ("view.zoomIn", "=", ""), + ("view.zoomOut", "-", ""), + ("sequence.snap", "S", ""), + // Markers + ("markers.markIn", "I", ""), + ("markers.markOut", "O", ""), + ("markers.markClip", "X", ""), + ("markers.markSelection", "/", ""), + ("markers.goToIn", "Shift+I", ""), + ("markers.goToOut", "Shift+O", ""), + ("markers.clearIn", "Alt+I", ""), + ("markers.clearOut", "Alt+O", ""), + ("markers.clearInOut", "Alt+X", ""), + ("markers.add", "M", ""), + ("markers.goNext", "Shift+M", ""), + ("markers.goPrev", "Cmd+Shift+M", ""), + ("markers.clearCurrent", "Alt+M", ""), + ("markers.clearAll", "Cmd+Alt+M", ""), + // Window + ("window.workspace.reset", "Alt+Shift+0", ""), + ("window.workspace.editing", "Alt+Shift+1", ""), + ("window.workspace.assembly", "Alt+Shift+2", ""), + ("window.workspace.color", "Alt+Shift+3", ""), + ("window.workspace.effects", "Alt+Shift+4", ""), + ("window.workspace.audio", "Alt+Shift+5", ""), + ("window.workspace.captionsandgraphics", "Alt+Shift+6", ""), + ("window.panel.Project", "Shift+1", ""), + ("window.panel.Source", "Shift+2", ""), + ("window.panel.Timeline", "Shift+3", ""), + ("window.panel.Program", "Shift+4", ""), + ("window.panel.EffectControls", "Shift+5", ""), + ("window.panel.AudioTrackMixer", "Shift+6", ""), + ("window.panel.Effects", "Shift+7", ""), + ("window.panel.MediaBrowser", "Shift+8", ""), + ("window.panel.AudioClipMixer", "Shift+9", ""), + // Captions + ("captions.newTrack", "Cmd+Alt+A", ""), + ("captions.add", "Cmd+Alt+C", ""), + ("captions.next", "Cmd+Alt+Down", "Timeline"), + ("captions.previous", "Cmd+Alt+Up", "Timeline"), + // Navigation and playback + ("playhead.nextEdit", "Down", ""), + ("playhead.prevEdit", "Up", ""), + ("playhead.start", "Home", ""), + ("playhead.end", "End", ""), + ("playhead.stepBack", "Left", ""), + ("playhead.stepBack5", "Shift+Left", ""), + ("playhead.stepForward", "Right", ""), + ("playhead.stepForward5", "Shift+Right", ""), + ("playback.toggle", "Space", ""), + ("playback.reverse", "J", ""), + ("playback.stop", "K", ""), + ("playback.forward", "L", ""), + ("playback.slowReverse", "Shift+J", ""), + ("playback.slowForward", "Shift+L", ""), + ("playback.playAround", "Shift+K", ""), + ("playback.inToOut", "Alt+K", ""), + ("view.zoomToSequence", "\\", ""), + // Trimming + ("trim.ripplePrevious", "Q", ""), + ("trim.rippleNext", "W", ""), + ("trim.previous", "Alt+Q", ""), + ("trim.next", "Alt+W", ""), + ("trim.toggleType", "Ctrl+T", ""), + ("trim.backward", "Alt+Left", ""), + ("trim.backwardMany", "Alt+Shift+Left", ""), + ("trim.forward", "Alt+Right", ""), + ("trim.forwardMany", "Alt+Shift+Right", ""), + // Panels + ("project.delete", "Backspace", "Project"), + ("source.open", "Shift+O", "Project"), + ("edit.undo", "Left", "History"), + ("edit.redo", "Right", "History"), +]; + +/// Final Cut Pro's default keys for the equivalent FilmCraft commands. +pub const FINAL_CUT: &[Entry] = &[ + ("tool.selection", "A", ""), + ("tool.razor", "B", ""), + ("tool.ripple", "T", ""), + ("tool.zoom", "Z", ""), + ("tool.hand", "H", ""), + ("source.insert", "W", ""), + ("source.overwrite", "D", ""), + ("sequence.addEdit", "Cmd+B", ""), + ("sequence.addEditAllTracks", "Cmd+Shift+B", ""), + ("edit.rippleDelete", "Backspace", ""), + ("sequence.lift", "Shift+Backspace", ""), + ("markers.markIn", "I", ""), + ("markers.markOut", "O", ""), + ("markers.markClip", "X", ""), + ("markers.clearInOut", "Alt+X", ""), + ("markers.add", "M", ""), + ("markers.goNext", "Ctrl+'", ""), + ("markers.goPrev", "Ctrl+;", ""), + ("playhead.nextEdit", "Down", ""), + ("playhead.prevEdit", "Up", ""), + ("playhead.stepForward5", "Shift+Right", ""), + ("playhead.stepBack5", "Shift+Left", ""), + ("playback.toggle", "Space", ""), + ("playback.reverse", "J", ""), + ("playback.stop", "K", ""), + ("playback.forward", "L", ""), + ("playback.loop", "Cmd+L", ""), + ("playback.inToOut", "/", ""), + ("sequence.snap", "N", ""), + ("view.zoomIn", "Cmd+=", ""), + ("view.zoomOut", "Cmd+-", ""), + ("view.zoomToSequence", "Shift+Z", ""), + ("sequence.applyVideoTransition", "Cmd+T", ""), + ("clip.speedDuration", "Cmd+R", ""), + ("clip.enable", "V", ""), + ("clip.nest", "Alt+G", ""), + ("sequence.matchFrame", "Shift+F", ""), + ("trim.extendToPlayhead", "Shift+X", ""), + ("trim.backward", ",", ""), + ("trim.forward", ".", ""), + ("trim.backwardMany", "Shift+,", ""), + ("trim.forwardMany", "Shift+.", ""), + ("file.newBin", "Alt+N", ""), + ("mode.export", "Cmd+E", ""), + ("sequence.renderEffectsInToOut", "Ctrl+R", ""), + ("sequence.renderInToOut", "Ctrl+Shift+R", ""), +]; + +/// Avid Media Composer's default keys for the equivalent FilmCraft commands. +pub const AVID: &[Entry] = &[ + ("playback.reverse", "J", ""), + ("playback.stop", "K", ""), + ("playback.forward", "L", ""), + ("playback.toggle", "Space", ""), + ("markers.markIn", "I", ""), + ("markers.markIn", "E", ""), + ("markers.markOut", "O", ""), + ("markers.markOut", "R", ""), + ("markers.markClip", "T", ""), + ("markers.clearIn", "D", ""), + ("markers.clearOut", "F", ""), + ("markers.clearInOut", "G", ""), + ("markers.goToIn", "Q", ""), + ("markers.goToOut", "W", ""), + ("source.insert", "V", ""), + ("source.overwrite", "B", ""), + ("sequence.lift", "Z", ""), + ("sequence.extract", "X", ""), + ("sequence.addEdit", "H", ""), + ("playhead.stepBack5", "1", ""), + ("playhead.stepForward5", "2", ""), + ("playhead.stepBack", "3", ""), + ("playhead.stepForward", "4", ""), + ("playhead.prevEdit", "A", ""), + ("playhead.nextEdit", "S", ""), + ("trim.edit", "U", ""), + ("trim.backwardMany", "M", ""), + ("trim.backward", ",", ""), + ("trim.forward", ".", ""), + ("trim.forwardMany", "/", ""), + ("view.zoomIn", "Cmd+]", ""), + ("view.zoomOut", "Cmd+[", ""), + ("markers.add", "F5", ""), +]; diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs new file mode 100644 index 000000000..ac76902fb --- /dev/null +++ b/crates/engine/src/shortcuts.rs @@ -0,0 +1,1152 @@ +//! Keyboard shortcuts as data: the active key bindings, presets, conflict detection and the +//! `shortcuts.*` commands (Edit ▸ Keyboard Shortcuts…, and the same operations for agents over MCP). +//! +//! A [`Binding`] maps a key chord to a command id, either application-wide or for one panel +//! (panel shortcuts apply while that panel has focus and override application shortcuts). +//! Chords are stored in a canonical, platform-neutral notation: +//! +//! | token | macOS | Windows / Linux | +//! |---|---|---| +//! | `Cmd` | ⌘ Command | Ctrl | +//! | `Ctrl` | ⌃ Control | Ctrl (folds into `Cmd`; conflicts are reported) | +//! | `Alt` | ⌥ Option | Alt | +//! | `Shift` | ⇧ Shift | Shift | +//! +//! so one preset works on every OS. Preset files written for Windows (`"platform": "windows"`) +//! are converted on import: their `Ctrl` is the primary modifier (`Cmd`) and `Win`/`Meta` maps to +//! the macOS Control key. +//! +//! Built-in presets: **FilmCraft Default** (each command's default plus the Premiere-default audit, +//! see [`Shortcuts::audit`]), **Premiere Pro Compatible**, **Final Cut Pro Compatible** and +//! **Avid Media Composer Compatible** ([`crate::shortcut_presets`]). Custom presets are JSON files in +//! `/Keyboard Shortcuts/`; the active set persists in `active.json` there. +//! +//! Commands the frontend owns (tools, transport, panels, workspaces) are registered with +//! [`Shortcuts::register_external`] so they can be listed, bound and resolved here too. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, always, bad, bool_p, str_p}; +use crate::shortcut_presets::{self as presets, Entry}; +use crate::{EngineError, Session}; + +pub const APPLICATION: &str = "Application"; + +/// Panels that can have panel-specific shortcuts (the shortcut editor's "Commands" menu). +pub const PANELS: &[&str] = &[ + "Timeline", + "Program", + "Source", + "Project", + "Effect Controls", + "Effects", + "History", + "Markers", + "Media Browser", + "Audio Track Mixer", + "Audio Clip Mixer", + "Essential Graphics", + "Text", + "Properties", + "Lumetri Color", +]; + +pub const DEFAULT_PRESET: &str = "FilmCraft Default"; +pub const PREMIERE_PRESET: &str = "Premiere Pro Compatible"; +pub const FCP_PRESET: &str = "Final Cut Pro Compatible"; +pub const AVID_PRESET: &str = "Avid Media Composer Compatible"; +pub const BUILTIN_PRESETS: [&str; 4] = [DEFAULT_PRESET, PREMIERE_PRESET, FCP_PRESET, AVID_PRESET]; + +const FILE_FORMAT: &str = "filmcraft-keyboard-shortcuts"; +const DIR_NAME: &str = "Keyboard Shortcuts"; +const ACTIVE_FILE: &str = "active.json"; + +/// Every key the editor's keyboard shows, in canonical spelling (row order). +pub const KEYS: &[&str] = &[ + "Escape", + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12", // + "`", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "-", + "=", + "Backspace", // + "Tab", + "Q", + "W", + "E", + "R", + "T", + "Y", + "U", + "I", + "O", + "P", + "[", + "]", + "\\", // + "A", + "S", + "D", + "F", + "G", + "H", + "J", + "K", + "L", + ";", + "'", + "Enter", // + "Z", + "X", + "C", + "V", + "B", + "N", + "M", + ",", + ".", + "/", // + "Space", + "Insert", + "Delete", + "Home", + "End", + "PageUp", + "PageDown", + "Up", + "Down", + "Left", + "Right", +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Platform { + Mac, + Windows, + Linux, +} + +impl Platform { + pub fn current() -> Self { + if cfg!(target_os = "macos") { + Platform::Mac + } else if cfg!(windows) { + Platform::Windows + } else { + Platform::Linux + } + } + pub fn from_name(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "mac" | "macos" | "osx" | "darwin" => Some(Platform::Mac), + "windows" | "win" => Some(Platform::Windows), + "linux" => Some(Platform::Linux), + _ => None, + } + } + pub fn is_mac(self) -> bool { + self == Platform::Mac + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct Mods { + pub cmd: bool, + pub ctrl: bool, + pub alt: bool, + pub shift: bool, +} + +impl Mods { + pub fn count(self) -> u8 { + self.cmd as u8 + self.ctrl as u8 + self.alt as u8 + self.shift as u8 + } + /// Canonical prefix, e.g. `Cmd+Alt+`. + pub fn prefix(self) -> String { + let mut s = String::new(); + for (on, n) in [(self.cmd, "Cmd+"), (self.ctrl, "Ctrl+"), (self.alt, "Alt+"), (self.shift, "Shift+")] { + if on { + s.push_str(n); + } + } + s + } +} + +/// A key plus modifiers. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Chord { + pub mods: Mods, + pub key: &'static str, +} + +fn canon_key(tok: &str) -> Option<&'static str> { + if let Some(k) = KEYS.iter().find(|k| k.eq_ignore_ascii_case(tok)) { + return Some(k); + } + let t = tok.to_ascii_lowercase().replace([' ', '_'], ""); + Some(match t.as_str() { + "return" | "⏎" | "↩" | "numpadenter" => "Enter", + "esc" | "⎋" => "Escape", + "del" | "forwarddelete" | "fwddelete" | "⌦" => "Delete", + "⌫" | "back" => "Backspace", + "arrowup" | "↑" => "Up", + "arrowdown" | "↓" => "Down", + "arrowleft" | "←" => "Left", + "arrowright" | "→" => "Right", + "pgup" | "pageup" => "PageUp", + "pgdn" | "pagedown" => "PageDown", + "spacebar" | "␣" => "Space", + "help" | "ins" => "Insert", + "⇥" => "Tab", + "grave" | "backtick" | "backquote" => "`", + "minus" => "-", + "equal" | "equals" => "=", + "semicolon" => ";", + "quote" | "apostrophe" => "'", + "comma" => ",", + "period" | "dot" => ".", + "slash" => "/", + "backslash" => "\\", + "openbracket" | "bracketleft" => "[", + "closebracket" | "bracketright" => "]", + _ => return None, + }) +} + +impl Chord { + /// Parse canonical (or macOS-style) text: `Cmd+Shift+K`, `⌥⌘K`, `opt+left`… + pub fn parse(s: &str) -> std::result::Result { + Self::parse_for(s, Platform::Mac) + } + + /// Parse text written for `platform` (on Windows/Linux `Ctrl` is the primary modifier). + pub fn parse_for(s: &str, platform: Platform) -> std::result::Result { + let s = s.trim(); + if s.is_empty() { + return Err("empty shortcut".into()); + } + // glyph runs like "⌥⇧⌘K" → tokens + let mut text = String::new(); + for ch in s.chars() { + match ch { + '⌘' => text.push_str("Cmd+"), + '⌃' => text.push_str("Ctrl+"), + '⌥' => text.push_str("Alt+"), + '⇧' => text.push_str("Shift+"), + c => text.push(c), + } + } + let text = text.replace("++", "+").trim_end_matches('+').to_string(); + let mut mods = Mods::default(); + let mut key = None; + let parts: Vec<&str> = text.split('+').map(str::trim).filter(|p| !p.is_empty()).collect(); + for (i, p) in parts.iter().enumerate() { + let low = p.to_ascii_lowercase(); + let is_last = i + 1 == parts.len(); + match low.as_str() { + "cmd" | "command" | "super" => mods.cmd = true, + "meta" | "win" | "windows" if !platform.is_mac() => mods.ctrl = true, + "meta" => mods.cmd = true, + "ctrl" | "control" | "ctl" => { + if platform.is_mac() { + mods.ctrl = true + } else { + mods.cmd = true + } + } + "alt" | "opt" | "option" => mods.alt = true, + "shift" => mods.shift = true, + _ if is_last => key = Some(canon_key(p).ok_or_else(|| format!("unknown key `{p}`"))?), + _ => return Err(format!("unknown modifier `{p}`")), + } + } + let key = key.ok_or_else(|| format!("`{s}` has no key"))?; + Ok(Chord { mods, key }) + } + + pub fn canonical(&self) -> String { + format!("{}{}", self.mods.prefix(), self.key) + } + + /// The chord as this OS sees it: off macOS the Control key is the primary modifier, so `Ctrl` + /// and `Cmd` are the same key. + pub fn effective(&self, p: Platform) -> Chord { + let mut c = self.clone(); + if !p.is_mac() && c.mods.ctrl { + c.mods.ctrl = false; + c.mods.cmd = true; + } + c + } + + /// Human text: `⌥⇧⌘K` on macOS, `Ctrl+Alt+Shift+K` elsewhere. + pub fn display(&self, p: Platform) -> String { + if p.is_mac() { + let mut s = String::new(); + for (on, g) in [(self.mods.ctrl, "⌃"), (self.mods.alt, "⌥"), (self.mods.shift, "⇧"), (self.mods.cmd, "⌘")] { + if on { + s.push_str(g); + } + } + let k = match self.key { + "Backspace" => "⌫", + "Delete" => "⌦", + "Enter" => "↩", + "Escape" => "⎋", + "Tab" => "⇥", + "Left" => "←", + "Right" => "→", + "Up" => "↑", + "Down" => "↓", + "PageUp" => "Page Up", + "PageDown" => "Page Down", + k => k, + }; + s.push_str(k); + s + } else { + let e = self.effective(p); + let mut s = String::new(); + for (on, n) in [(e.mods.cmd, "Ctrl+"), (e.mods.alt, "Alt+"), (e.mods.shift, "Shift+")] { + if on { + s.push_str(n); + } + } + s.push_str(match self.key { + "PageUp" => "Page Up", + "PageDown" => "Page Down", + k => k, + }); + s + } + } +} + +/// Canonical spelling of a chord (`opt+cmd+k` → `Cmd+Alt+K`). +pub fn normalize(s: &str) -> std::result::Result { + Chord::parse(s).map(|c| c.canonical()) +} + +/// One key binding. `panel: None` = application-wide. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Binding { + pub command: String, + pub keys: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub panel: Option, +} + +impl Binding { + fn new(command: &str, keys: &str, panel: Option<&str>) -> Self { + Binding { command: command.to_string(), keys: normalize(keys).unwrap_or_else(|_| keys.to_string()), panel: panel.map(str::to_string) } + } + fn chord(&self) -> Option { + Chord::parse(&self.keys).ok() + } + fn context(&self) -> &str { + self.panel.as_deref().unwrap_or(APPLICATION) + } +} + +/// A command that can be bound (engine command or one registered by the frontend). +#[derive(Clone, Debug, Serialize)] +pub struct CommandInfo { + pub id: String, + pub label: String, + pub category: String, + pub menu: Vec, + /// Default bindings: (keys, panel). + #[serde(skip)] + pub defaults: Vec<(String, Option)>, +} + +impl CommandInfo { + pub fn new(id: &str, label: &str, menu: &[&str], default: Option<&str>) -> Self { + CommandInfo { + id: id.into(), + label: label.into(), + category: category_of(id, menu), + menu: menu.iter().map(|s| s.to_string()).collect(), + defaults: default.map(|k| vec![(k.to_string(), None)]).unwrap_or_default(), + } + } +} + +fn category_of(id: &str, menu: &[&str]) -> String { + let prefix = id.split('.').next().unwrap_or(""); + match prefix { + "tool" => "Tools".into(), + "playback" => "Playback".into(), + "playhead" => "Navigation".into(), + "trim" => "Trimming".into(), + "window" => "Window".into(), + "view" => "View".into(), + "app" | "help" | "mode" => "Application".into(), + "captions" => "Captions".into(), + _ => menu.first().map(|s| s.to_string()).unwrap_or_else(|| { + let mut c = prefix.chars(); + c.next().map(|f| f.to_uppercase().collect::() + c.as_str()).unwrap_or_default() + }), + } +} + +/// Engine commands worth binding: undoable actions that need no required parameters. +fn bindable(c: &CommandSpec) -> bool { + if !c.journal || c.id.starts_with("shortcuts.") || c.id.starts_with("prefs.") || c.id == "trim.tick" { + return false; + } + if c.shortcut.is_some() { + return true; + } + let body = c.params.trim().trim_start_matches('{').trim_end_matches('}'); + if body.trim().is_empty() { + return true; + } + let mut depth = 0; + let mut fields = vec![String::new()]; + for ch in body.chars() { + match ch { + '[' | '{' => depth += 1, + ']' | '}' => depth -= 1, + ',' if depth == 0 => { + fields.push(String::new()); + continue; + } + _ => {} + } + fields.last_mut().expect("field").push(ch); + } + fields.iter().all(|f| f.contains('?') || f.contains('=')) +} + +/// The preset file / `active.json` format. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PresetFile { + pub format: String, + pub version: u32, + pub name: String, + /// The OS the key names were written for; None = canonical (FilmCraft) notation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform: Option, + /// `active.json` only: the active set differs from the preset it started from. + #[serde(default)] + pub modified: bool, + pub bindings: Vec, +} + +#[derive(Clone, Debug)] +struct Snapshot { + bindings: Vec, + preset: String, + modified: bool, +} + +/// The active key bindings and their editing history. +#[derive(Clone, Debug)] +pub struct Shortcuts { + pub bindings: Vec, + /// Preset the active set came from (or was saved as). + pub preset: String, + /// Edited since the preset was loaded. + pub modified: bool, + /// Bumped on every change (frontends rebuild key maps and menus). + pub revision: u64, + undo: Vec, + redo: Vec, + external: Vec, + dir: Option, + /// The editor dialog's starting point (Cancel restores it). + session_start: Option, +} + +impl Default for Shortcuts { + fn default() -> Self { + Self::new() + } +} + +impl Shortcuts { + pub fn new() -> Self { + let mut s = Shortcuts { + bindings: Vec::new(), + preset: DEFAULT_PRESET.into(), + modified: false, + revision: 1, + undo: Vec::new(), + redo: Vec::new(), + external: Vec::new(), + dir: None, + session_start: None, + }; + s.bindings = s.builtin(DEFAULT_PRESET).expect("default preset"); + s + } + + // ---------------------------------------------------------------- commands + + /// Every bindable command (engine + frontend), in registry order. + pub fn commands(&self) -> Vec { + let mut v: Vec = + crate::commands::command_specs().iter().filter(|c| bindable(c)).map(|c| CommandInfo::new(c.id, c.label, c.menu, c.shortcut)).collect(); + for e in &self.external { + if !v.iter().any(|c| c.id == e.id) { + v.push(e.clone()); + } + } + v + } + + pub fn command(&self, id: &str) -> Option { + self.commands().into_iter().find(|c| c.id == id) + } + + fn known(&self, id: &str) -> bool { + crate::commands::find(id).is_some() || self.external.iter().any(|c| c.id == id) + } + + /// Commands owned by the frontend (tools, transport, panels…). Rebuilds the active set when it + /// is an unmodified built-in preset, so their defaults appear. + pub fn register_external(&mut self, cmds: Vec) { + self.external = cmds; + if !self.modified + && let Some(b) = self.builtin(&self.preset.clone()) + { + self.bindings = b; + self.revision += 1; + } + } + + // ---------------------------------------------------------------- presets + + /// Default bindings of every known command, plus FilmCraft's panel shortcuts. + fn base_defaults(&self) -> Vec { + let mut out = Vec::new(); + for c in self.commands() { + for (k, p) in &c.defaults { + out.push(Binding::new(&c.id, k, p.as_deref())); + } + } + for (c, k, p) in presets::FILMCRAFT_PANEL { + if self.known(c) { + out.push(Binding::new(c, k, panel_opt(p))); + } + } + out + } + + /// Premiere-default entries FilmCraft Default adopts: commands with no shortcut of their own + /// whose Premiere key is free in that context. + pub fn audit(&self) -> Vec { + let base = self.base_defaults(); + let mut added: Vec = Vec::new(); + for (c, k, p) in presets::PREMIERE { + if !self.known(c) || base.iter().any(|b| b.command == *c) { + continue; + } + let b = Binding::new(c, k, panel_opt(p)); + let taken = base.iter().chain(added.iter()).any(|o| o.context() == b.context() && o.chord() == b.chord()); + if !taken { + added.push(b); + } + } + added + } + + /// The bindings of a built-in preset. + pub fn builtin(&self, name: &str) -> Option> { + let mut b = self.base_defaults(); + b.extend(self.audit()); + let table: &[Entry] = match name { + DEFAULT_PRESET => &[], + PREMIERE_PRESET => presets::PREMIERE, + FCP_PRESET => presets::FINAL_CUT, + AVID_PRESET => presets::AVID, + _ => return None, + }; + self.apply_table(&mut b, table); + Some(b) + } + + /// Apply a preset table: listed commands get exactly the listed keys, and a listed key is taken + /// away from whatever else had it in that context. + fn apply_table(&self, b: &mut Vec, table: &[Entry]) { + let listed: Vec<&str> = table.iter().map(|e| e.0).filter(|c| self.known(c)).collect(); + b.retain(|x| !listed.contains(&x.command.as_str())); + for (c, k, p) in table { + if !self.known(c) { + continue; + } + let nb = Binding::new(c, k, panel_opt(p)); + b.retain(|o| !(o.context() == nb.context() && o.chord() == nb.chord())); + b.push(nb); + } + } + + fn presets_dir(&self) -> Option { + self.dir.as_ref().map(|d| d.join(DIR_NAME)) + } + + /// Custom preset names (files in the presets directory). + pub fn user_presets(&self) -> Vec { + let Some(dir) = self.presets_dir() else { return Vec::new() }; + let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut v: Vec = rd + .flatten() + .filter(|e| e.file_name() != ACTIVE_FILE) + .filter_map(|e| std::fs::read(e.path()).ok().and_then(|b| serde_json::from_slice::(&b).ok()).map(|p| p.name)) + .filter(|n| !BUILTIN_PRESETS.contains(&n.as_str())) + .collect(); + v.sort(); + v.dedup(); + v + } + + fn preset_path(&self, name: &str) -> Option { + let safe: String = name.chars().map(|c| if c.is_alphanumeric() || " -_().".contains(c) { c } else { '_' }).collect(); + self.presets_dir().map(|d| d.join(format!("{}.json", safe.trim()))) + } + + /// Use the per-user data directory: loads the active set saved there (if any). + pub fn set_dir(&mut self, data_dir: &Path) { + self.dir = Some(data_dir.to_path_buf()); + let Some(path) = self.presets_dir().map(|d| d.join(ACTIVE_FILE)) else { return }; + let Ok(bytes) = std::fs::read(&path) else { return }; + match serde_json::from_slice::(&bytes) { + Ok(f) => { + self.preset = f.name.clone(); + if !f.modified + && let Some(b) = self.builtin(&f.name) + { + self.bindings = b; + self.modified = false; + } else { + self.bindings = convert(f.bindings, f.platform); + self.modified = f.modified; + } + self.revision += 1; + } + Err(e) => log::warn!("{}: {e}", path.display()), + } + } + + fn file(&self, name: &str, modified: bool) -> PresetFile { + PresetFile { format: FILE_FORMAT.into(), version: 1, name: name.into(), platform: None, modified, bindings: self.bindings.clone() } + } + + fn persist(&self) { + let Some(dir) = self.presets_dir() else { return }; + let f = self.file(&self.preset, self.modified); + if let Err(e) = std::fs::create_dir_all(&dir) + .and_then(|_| filmcraft_format::atomic_write(&dir.join(ACTIVE_FILE), &serde_json::to_vec_pretty(&f).unwrap_or_default())) + { + log::warn!("saving keyboard shortcuts: {e}"); + } + } + + // ---------------------------------------------------------------- editing + + fn snapshot(&self) -> Snapshot { + Snapshot { bindings: self.bindings.clone(), preset: self.preset.clone(), modified: self.modified } + } + + fn restore(&mut self, s: Snapshot) { + self.bindings = s.bindings; + self.preset = s.preset; + self.modified = s.modified; + } + + fn changed(&mut self, before: Snapshot) { + self.undo.push(before); + if self.undo.len() > 500 { + self.undo.remove(0); + } + self.redo.clear(); + self.revision += 1; + self.persist(); + } + + /// Assign `keys` to `command`. Unless `add`, the command's other shortcuts in that context are + /// replaced. Unless `keep_conflicts`, the key is taken away from any other command bound to it + /// in the same context (returned as `reassigned`). + pub fn set(&mut self, command: &str, keys: &str, panel: Option<&str>, add: bool, keep_conflicts: bool) -> std::result::Result, String> { + if !self.known(command) { + return Err(format!("unknown command `{command}`")); + } + let panel = check_panel(panel)?; + let chord = Chord::parse(keys)?; + let nb = Binding { command: command.into(), keys: chord.canonical(), panel: panel.map(str::to_string) }; + let before = self.snapshot(); + if !add { + self.bindings.retain(|b| !(b.command == command && b.context() == nb.context())); + } + let mut reassigned = Vec::new(); + if !keep_conflicts { + let p = Platform::current(); + let eff = chord.effective(p); + self.bindings.retain(|b| { + let clash = b.command != command && b.context() == nb.context() && b.chord().is_some_and(|c| c.effective(p) == eff); + if clash { + reassigned.push(b.clone()); + } + !clash + }); + } + if !self.bindings.contains(&nb) { + self.bindings.push(nb); + } + self.modified = true; + self.changed(before); + Ok(reassigned) + } + + /// Remove shortcuts of `command` (one key, or all; in one context, or all). + pub fn clear(&mut self, command: &str, keys: Option<&str>, panel: Option<&str>) -> std::result::Result { + let chord = keys.map(Chord::parse).transpose()?; + let panel = match panel { + Some(p) => Some(check_panel(Some(p))?.map(str::to_string)), + None => None, + }; + let before = self.snapshot(); + let n0 = self.bindings.len(); + self.bindings.retain(|b| { + let hit = b.command == command && chord.as_ref().is_none_or(|c| b.chord().as_ref() == Some(c)) && panel.as_ref().is_none_or(|p| &b.panel == p); + !hit + }); + let n = n0 - self.bindings.len(); + if n > 0 { + self.modified = true; + self.changed(before); + } + Ok(n) + } + + pub fn undo(&mut self) -> bool { + let Some(s) = self.undo.pop() else { return false }; + let cur = self.snapshot(); + self.redo.push(cur); + self.restore(s); + self.revision += 1; + self.persist(); + true + } + + pub fn redo(&mut self) -> bool { + let Some(s) = self.redo.pop() else { return false }; + let cur = self.snapshot(); + self.undo.push(cur); + self.restore(s); + self.revision += 1; + self.persist(); + true + } + + pub fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } + + /// The shortcut editor opened: remember where Cancel goes back to. + pub fn begin_editing(&mut self) { + self.session_start = Some(self.snapshot()); + self.undo.clear(); + self.redo.clear(); + } + + /// OK: keep the edits. + pub fn end_editing(&mut self) { + self.session_start = None; + } + + /// Cancel: restore the set the editor opened with. + pub fn cancel_editing(&mut self) { + if let Some(s) = self.session_start.take() { + self.restore(s); + self.undo.clear(); + self.redo.clear(); + self.revision += 1; + self.persist(); + } + } + + pub fn load_preset(&mut self, name: &str) -> std::result::Result<(), String> { + let bindings = match self.builtin(name) { + Some(b) => b, + None => { + let path = self.preset_path(name).ok_or("no data directory for custom presets")?; + let f = read_file(&path)?; + convert(f.bindings, f.platform) + } + }; + let before = self.snapshot(); + self.bindings = bindings; + self.preset = name.into(); + self.modified = false; + self.changed(before); + Ok(()) + } + + pub fn save_preset(&mut self, name: &str) -> std::result::Result { + let name = name.trim(); + if name.is_empty() { + return Err("a preset needs a name".into()); + } + if BUILTIN_PRESETS.iter().any(|b| b.eq_ignore_ascii_case(name)) { + return Err(format!("`{name}` is a built-in preset; choose another name")); + } + let path = self.preset_path(name).ok_or("no data directory for custom presets")?; + write_file(&path, &self.file(name, false))?; + let before = self.snapshot(); + self.preset = name.into(); + self.modified = false; + self.changed(before); + Ok(path) + } + + pub fn delete_preset(&mut self, name: &str) -> std::result::Result<(), String> { + if BUILTIN_PRESETS.contains(&name) { + return Err("built-in presets cannot be deleted".into()); + } + let path = self.preset_path(name).ok_or("no data directory for custom presets")?; + std::fs::remove_file(&path).map_err(|e| format!("{}: {e}", path.display()))?; + if self.preset == name { + self.modified = true; + self.revision += 1; + self.persist(); + } + Ok(()) + } + + pub fn export(&self, path: &Path) -> std::result::Result<(), String> { + write_file(path, &self.file(&self.preset, false)) + } + + /// Import a preset file: saved among the custom presets (when there is a data directory) and, + /// when `activate`, made the active set. + pub fn import(&mut self, path: &Path, activate: bool) -> std::result::Result { + let f = read_file(path)?; + let name = if f.name.trim().is_empty() || BUILTIN_PRESETS.contains(&f.name.as_str()) { + path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Imported".into()) + } else { + f.name.clone() + }; + let bindings: Vec = convert(f.bindings, f.platform).into_iter().filter(|b| self.known(&b.command)).collect(); + if let Some(p) = self.preset_path(&name) { + let file = PresetFile { format: FILE_FORMAT.into(), version: 1, name: name.clone(), platform: None, modified: false, bindings: bindings.clone() }; + write_file(&p, &file)?; + } + if activate { + let before = self.snapshot(); + self.bindings = bindings; + self.preset = name.clone(); + self.modified = false; + self.changed(before); + } + Ok(name) + } + + // ---------------------------------------------------------------- queries + + pub fn for_command(&self, id: &str) -> Vec<&Binding> { + self.bindings.iter().filter(|b| b.command == id).collect() + } + + /// The shortcut menus show: the first application-wide one. + pub fn primary(&self, id: &str) -> Option { + self.bindings.iter().find(|b| b.command == id && b.panel.is_none()).map(|b| b.keys.clone()) + } + + /// The command a chord runs with `panel` focused (panel shortcuts first). + pub fn resolve(&self, chord: &Chord, panel: Option<&str>, p: Platform) -> Option<&Binding> { + let eff = chord.effective(p); + let m = |b: &&Binding| b.chord().is_some_and(|c| c.effective(p) == eff); + if let Some(pn) = panel + && let Some(b) = self.bindings.iter().filter(|b| b.panel.as_deref() == Some(pn)).find(m) + { + return Some(b); + } + self.bindings.iter().filter(|b| b.panel.is_none()).find(m) + } + + /// Groups of bindings that share a key in one context on platform `p`. + pub fn conflicts(&self, p: Platform) -> Vec<(String, String, Vec)> { + let mut map: BTreeMap<(String, String), Vec> = BTreeMap::new(); + for b in &self.bindings { + let Some(c) = b.chord() else { continue }; + let e = map.entry((b.context().to_string(), c.effective(p).canonical())).or_default(); + if !e.contains(&b.command) { + e.push(b.command.clone()); + } + } + map.into_iter().filter(|(_, v)| v.len() > 1).map(|((ctx, k), v)| (ctx, k, v)).collect() + } + + /// Panel shortcuts that shadow an application shortcut while their panel has focus. + pub fn overrides(&self, p: Platform) -> Vec<(String, String, String, String)> { + let mut out = Vec::new(); + for b in self.bindings.iter().filter(|b| b.panel.is_some()) { + let Some(c) = b.chord() else { continue }; + if let Some(a) = self.resolve(&c, None, p) + && a.command != b.command + { + out.push((b.panel.clone().unwrap_or_default(), b.keys.clone(), b.command.clone(), a.command.clone())); + } + } + out + } + + /// Bindings on one key (any modifiers): the shortcut editor's "Key:" list. + pub fn for_key(&self, key: &str) -> Vec<&Binding> { + let Some(k) = canon_key(key) else { return Vec::new() }; + let mut v: Vec<&Binding> = self.bindings.iter().filter(|b| b.chord().is_some_and(|c| c.key == k)).collect(); + v.sort_by_key(|b| b.chord().map(|c| (c.mods.count(), c.mods))); + v + } + + /// Commands assigned with exactly `mods` held, per key: (application command, panel command). + pub fn keyboard(&self, mods: Mods, panel: Option<&str>) -> BTreeMap<&'static str, (Option, Option)> { + let mut m: BTreeMap<&'static str, (Option, Option)> = BTreeMap::new(); + for b in &self.bindings { + let Some(c) = b.chord() else { continue }; + if c.mods != mods { + continue; + } + let e = m.entry(c.key).or_default(); + match (&b.panel, panel) { + (None, _) => { + e.0.get_or_insert(b.command.clone()); + } + (Some(bp), Some(p)) if bp == p => { + e.1.get_or_insert(b.command.clone()); + } + (Some(_), None) => { + e.1.get_or_insert(b.command.clone()); + } + _ => {} + } + } + m + } + + fn binding_json(&self, b: &Binding, p: Platform) -> Value { + json!({"keys": b.keys, "panel": b.panel, "display": b.chord().map(|c| c.display(p)).unwrap_or_else(|| b.keys.clone())}) + } +} + +fn panel_opt(p: &str) -> Option<&str> { + if p.is_empty() { None } else { Some(p) } +} + +fn check_panel(panel: Option<&str>) -> std::result::Result, String> { + match panel { + None => Ok(None), + Some(p) if p.is_empty() || p.eq_ignore_ascii_case(APPLICATION) => Ok(None), + Some(p) => { + let n = p.to_ascii_lowercase().replace([' ', '_', '-'], ""); + let alias = match n.as_str() { + "programmonitor" => "program", + "sourcemonitor" => "source", + "projects" => "project", + "timelines" => "timeline", + _ => n.as_str(), + }; + PANELS + .iter() + .copied() + .find(|x| x.to_ascii_lowercase().replace(' ', "") == alias) + .map(Some) + .ok_or_else(|| format!("unknown panel `{p}` (one of {})", PANELS.join(", "))) + } + } +} + +/// Normalise bindings read from a file written for `platform`. +fn convert(bindings: Vec, platform: Option) -> Vec { + let p = platform.unwrap_or(Platform::Mac); + bindings + .into_iter() + .filter_map(|b| { + let c = Chord::parse_for(&b.keys, p).ok()?; + let panel = check_panel(b.panel.as_deref()).ok()?.map(str::to_string); + Some(Binding { command: b.command, keys: c.canonical(), panel }) + }) + .collect() +} + +fn read_file(path: &Path) -> std::result::Result { + let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + let f: PresetFile = serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display()))?; + if f.format != FILE_FORMAT { + return Err(format!("{}: not a FilmCraft keyboard shortcuts file", path.display())); + } + Ok(f) +} + +fn write_file(path: &Path, f: &PresetFile) -> std::result::Result<(), String> { + if let Some(d) = path.parent() { + std::fs::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?; + } + filmcraft_format::atomic_write(path, &serde_json::to_vec_pretty(f).unwrap_or_default()).map_err(|e| format!("{}: {e}", path.display())) +} + +// ------------------------------------------------------------------ commands + +fn platform_p(p: &Value) -> Platform { + str_p(p, "platform").and_then(Platform::from_name).unwrap_or_else(Platform::current) +} + +fn err(cmd: &str) -> impl Fn(String) -> EngineError + '_ { + move |m| bad(cmd, m) +} + +fn presets_json(s: &Session) -> Value { + let sc = &s.shortcuts; + json!({"active": sc.preset, "modified": sc.modified, "builtin": BUILTIN_PRESETS, "custom": sc.user_presets(), "canUndo": sc.can_undo(), "canRedo": sc.can_redo()}) +} + +fn list(s: &Session, p: &Value) -> Value { + let sc = &s.shortcuts; + let plat = platform_p(p); + let q = str_p(p, "query").map(str::to_ascii_lowercase); + let assigned_only = bool_p(p, "assigned").unwrap_or(false); + let panel = str_p(p, "panel").and_then(|x| check_panel(Some(x)).ok().flatten()); + let rows: Vec = sc + .commands() + .into_iter() + .filter_map(|c| { + let bs: Vec<&Binding> = sc.for_command(&c.id).into_iter().filter(|b| panel.is_none() || b.panel.as_deref() == panel).collect(); + if assigned_only && bs.is_empty() { + return None; + } + if let Some(q) = &q { + let hit = c.label.to_ascii_lowercase().contains(q.as_str()) + || c.id.to_ascii_lowercase().contains(q.as_str()) + || bs.iter().any(|b| b.keys.to_ascii_lowercase().contains(q.as_str()) || b.chord().is_some_and(|ch| ch.display(plat).to_ascii_lowercase().contains(q.as_str()))); + if !hit { + return None; + } + } + Some(json!({"id": c.id, "label": c.label, "category": c.category, "menu": c.menu, "shortcuts": bs.iter().map(|b| sc.binding_json(b, plat)).collect::>()})) + }) + .collect(); + json!(rows) +} + +pub fn commands() -> Vec { + macro_rules! sc { + ($id:literal, $label:literal, $params:literal, $journal:expr, $run:expr) => { + CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: always, run: $run, journal: $journal } + }; + } + vec![ + sc!("shortcuts.list", "List Keyboard Shortcuts", r#"{"query":str?,"panel":str?,"assigned":bool?,"platform":"mac|windows|linux"?}"#, false, |s, p| Ok( + list(s, p) + )), + sc!("shortcuts.get", "Get Shortcuts of a Command", r#"{"command":id,"platform":str?}"#, false, |s, p| { + let id = str_p(p, "command").ok_or_else(|| bad("shortcuts.get", "need `command`"))?; + let c = s.shortcuts.command(id).ok_or_else(|| bad("shortcuts.get", format!("unknown command `{id}`")))?; + let plat = platform_p(p); + let bs: Vec = s.shortcuts.for_command(id).into_iter().map(|b| s.shortcuts.binding_json(b, plat)).collect(); + Ok(json!({"id": c.id, "label": c.label, "category": c.category, "shortcuts": bs})) + }), + sc!("shortcuts.set", "Assign Shortcut", r#"{"command":id,"keys":"Cmd+Shift+K","panel":str?,"add":bool?,"keepConflicts":bool?}"#, true, |s, p| { + let id = str_p(p, "command").ok_or_else(|| bad("shortcuts.set", "need `command`"))?; + let keys = str_p(p, "keys").ok_or_else(|| bad("shortcuts.set", "need `keys`"))?; + let r = s + .shortcuts + .set(id, keys, str_p(p, "panel"), bool_p(p, "add").unwrap_or(false), bool_p(p, "keepConflicts").unwrap_or(false)) + .map_err(err("shortcuts.set"))?; + let plat = Platform::current(); + let conflicts: Vec = s.shortcuts.conflicts(plat).into_iter().map(|(c, k, v)| json!({"context": c, "keys": k, "commands": v})).collect(); + Ok(json!({"keys": normalize(keys).unwrap_or_default(), "reassigned": r, "conflicts": conflicts})) + }), + sc!("shortcuts.clear", "Clear Shortcut", r#"{"command":id,"keys":str?,"panel":str?}"#, true, |s, p| { + let id = str_p(p, "command").ok_or_else(|| bad("shortcuts.clear", "need `command`"))?; + let n = s.shortcuts.clear(id, str_p(p, "keys"), str_p(p, "panel")).map_err(err("shortcuts.clear"))?; + Ok(json!({"removed": n})) + }), + sc!("shortcuts.undo", "Undo Shortcut Change", "{}", true, |s, _| Ok(json!({"undone": s.shortcuts.undo()}))), + sc!("shortcuts.redo", "Redo Shortcut Change", "{}", true, |s, _| Ok(json!({"redone": s.shortcuts.redo()}))), + sc!("shortcuts.conflicts", "Shortcut Conflicts", r#"{"platform":"mac|windows|linux"?}"#, false, |s, p| { + let plat = platform_p(p); + let c: Vec = s.shortcuts.conflicts(plat).into_iter().map(|(c, k, v)| json!({"context": c, "keys": k, "commands": v})).collect(); + let o: Vec = + s.shortcuts.overrides(plat).into_iter().map(|(pn, k, cmd, app)| json!({"panel": pn, "keys": k, "command": cmd, "overrides": app})).collect(); + Ok(json!({"conflicts": c, "panelOverrides": o})) + }), + sc!("shortcuts.forKey", "Shortcuts on a Key", r#"{"key":"K","platform":str?}"#, false, |s, p| { + let k = str_p(p, "key").ok_or_else(|| bad("shortcuts.forKey", "need `key`"))?; + let plat = platform_p(p); + Ok(json!( + s.shortcuts + .for_key(k) + .into_iter() + .map(|b| json!({"keys": b.keys, "display": b.chord().map(|c| c.display(plat)), "command": b.command, "panel": b.panel})) + .collect::>() + )) + }), + sc!("shortcuts.resolve", "Resolve Shortcut", r#"{"keys":str,"panel":str?,"platform":str?}"#, false, |s, p| { + let keys = str_p(p, "keys").ok_or_else(|| bad("shortcuts.resolve", "need `keys`"))?; + let c = Chord::parse(keys).map_err(err("shortcuts.resolve"))?; + let panel = check_panel(str_p(p, "panel")).map_err(err("shortcuts.resolve"))?; + Ok(s.shortcuts.resolve(&c, panel, platform_p(p)).map(|b| json!({"command": b.command, "panel": b.panel})).unwrap_or(Value::Null)) + }), + sc!("shortcuts.presets", "Keyboard Shortcut Presets", "{}", false, |s, _| Ok(presets_json(s))), + sc!("shortcuts.loadPreset", "Load Shortcut Preset", r#"{"name":str}"#, true, |s, p| { + let n = str_p(p, "name").ok_or_else(|| bad("shortcuts.loadPreset", "need `name`"))?; + s.shortcuts.load_preset(n).map_err(err("shortcuts.loadPreset"))?; + Ok(presets_json(s)) + }), + sc!("shortcuts.savePreset", "Save Shortcut Preset As", r#"{"name":str}"#, true, |s, p| { + let n = str_p(p, "name").ok_or_else(|| bad("shortcuts.savePreset", "need `name`"))?; + let path = s.shortcuts.save_preset(n).map_err(err("shortcuts.savePreset"))?; + Ok(json!({"path": path, "presets": presets_json(s)})) + }), + sc!("shortcuts.deletePreset", "Delete Shortcut Preset", r#"{"name":str}"#, true, |s, p| { + let n = str_p(p, "name").ok_or_else(|| bad("shortcuts.deletePreset", "need `name`"))?; + s.shortcuts.delete_preset(n).map_err(err("shortcuts.deletePreset"))?; + Ok(presets_json(s)) + }), + sc!("shortcuts.export", "Export Keyboard Shortcuts", r#"{"path":str}"#, true, |s, p| { + let path = str_p(p, "path").ok_or_else(|| bad("shortcuts.export", "need `path`"))?; + s.shortcuts.export(Path::new(path)).map_err(err("shortcuts.export"))?; + Ok(json!({"path": path, "bindings": s.shortcuts.bindings.len()})) + }), + sc!("shortcuts.import", "Import Keyboard Shortcuts", r#"{"path":str,"activate":bool=true}"#, true, |s, p| { + let path = str_p(p, "path").ok_or_else(|| bad("shortcuts.import", "need `path`"))?; + let name = s.shortcuts.import(Path::new(path), bool_p(p, "activate").unwrap_or(true)).map_err(err("shortcuts.import"))?; + Ok(json!({"name": name, "presets": presets_json(s)})) + }), + sc!("shortcuts.audit", "Shortcut Audit", "{}", false, |s, _| { + let a = s.shortcuts.audit(); + Ok(json!({"count": a.len(), "assigned": a})) + }), + ] +} diff --git a/crates/engine/src/shortcuts_tests.rs b/crates/engine/src/shortcuts_tests.rs new file mode 100644 index 000000000..23dc6dec6 --- /dev/null +++ b/crates/engine/src/shortcuts_tests.rs @@ -0,0 +1,215 @@ +//! Keyboard shortcuts: chords, presets, conflicts, panel scope, undo, files. + +use super::*; +use crate::shortcuts::{Binding, Chord, CommandInfo, Platform, Shortcuts}; +use serde_json::json; + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("fc-shortcuts-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// The frontend's commands, as the egui UI registers them (a few are enough here). +fn with_ui_commands(s: &mut Session) { + s.shortcuts.register_external(vec![ + CommandInfo::new("playback.toggle", "Play/Stop", &[], Some("Space")), + CommandInfo::new("playback.forward", "Shuttle Right", &[], Some("L")), + CommandInfo::new("playback.slowForward", "Shuttle Slow Right", &[], None), + CommandInfo::new("playback.playAround", "Play Around", &[], None), + CommandInfo::new("tool.razor", "Razor Tool", &[], Some("C")), + CommandInfo::new("app.keyboardShortcuts", "Keyboard Shortcuts…", &["Edit"], Some("Cmd+Alt+K")), + ]); +} + +#[test] +fn chords_parse_normalise_and_display_per_os() { + let c = Chord::parse("⌥⇧⌘K").unwrap(); + assert_eq!(c.canonical(), "Cmd+Alt+Shift+K"); + assert_eq!(Chord::parse("shift+opt+command+k").unwrap(), c); + assert_eq!(c.display(Platform::Mac), "⌥⇧⌘K"); + assert_eq!(c.display(Platform::Windows), "Ctrl+Alt+Shift+K"); + assert_eq!(Chord::parse("Return").unwrap().canonical(), "Enter"); + assert_eq!(Chord::parse("Forward Delete").unwrap().canonical(), "Delete"); + assert_eq!(Chord::parse("Shift+Left").unwrap().display(Platform::Mac), "⇧←"); + // Windows text: Ctrl is the primary modifier + assert_eq!(Chord::parse_for("Ctrl+Shift+Z", Platform::Windows).unwrap().canonical(), "Cmd+Shift+Z"); + assert_eq!(Chord::parse_for("Win+T", Platform::Windows).unwrap().canonical(), "Ctrl+T"); + // macOS Control folds into Ctrl elsewhere + assert_eq!(Chord::parse("Ctrl+T").unwrap().effective(Platform::Windows), Chord::parse("Cmd+T").unwrap()); + assert!(Chord::parse("Cmd+").is_err()); + assert!(Chord::parse("Hyper+K").is_err()); +} + +#[test] +fn defaults_follow_the_registry_and_the_premiere_audit() { + let mut s = Session::default(); + with_ui_commands(&mut s); + let sc = &s.shortcuts; + assert_eq!(sc.preset, "FilmCraft Default"); + assert_eq!(sc.primary("sequence.addEdit").as_deref(), Some("Cmd+K")); + assert_eq!(sc.primary("playback.toggle").as_deref(), Some("Space")); + // the audit adopts Premiere defaults for commands that had none + let audit = sc.audit(); + let has = |c: &str, k: &str, p: Option<&str>| audit.iter().any(|b| b.command == c && b.keys == k && b.panel.as_deref() == p); + assert!(has("trim.applyDefaultTransition", "Shift+D", None), "{audit:?}"); + assert!(has("playback.slowForward", "Shift+L", None)); + assert!(has("playback.playAround", "Shift+K", None)); + assert!(has("source.open", "Shift+O", Some("Project"))); + assert_eq!(sc.primary("trim.applyDefaultTransition").as_deref(), Some("Shift+D")); + // no conflicts in any built-in preset (macOS) + for name in crate::shortcuts::BUILTIN_PRESETS { + let mut t = Session::default(); + with_ui_commands(&mut t); + t.shortcuts.load_preset(name).unwrap(); + assert!(t.shortcuts.conflicts(Platform::Mac).is_empty(), "{name}: {:?}", t.shortcuts.conflicts(Platform::Mac)); + } + // command.list reports the live bindings + let list = s.execute("command.list", json!({})).unwrap(); + let ae = list.as_array().unwrap().iter().find(|c| c["id"] == "sequence.addEdit").unwrap(); + assert_eq!(ae["shortcut"], json!("Cmd+K")); +} + +#[test] +fn assign_reassigns_conflicts_and_undoes() { + let mut s = Session::default(); + let r = s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Shift+K"})).unwrap(); + assert_eq!(r["reassigned"][0]["command"], json!("sequence.addEditAllTracks"), "{r}"); + assert!(s.shortcuts.primary("sequence.addEditAllTracks").is_none()); + assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Shift+K"), "replaces Cmd+Z"); + let hit = s.execute("shortcuts.resolve", json!({"keys": "⇧⌘K"})).unwrap(); + assert_eq!(hit["command"], json!("edit.undo")); + assert!(s.shortcuts.modified); + // add a second shortcut, keep a conflict on purpose + s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Z", "add": true})).unwrap(); + assert_eq!(s.shortcuts.for_command("edit.undo").iter().filter(|b| b.panel.is_none()).count(), 2); + let r = s.execute("shortcuts.set", json!({"command": "edit.redo", "keys": "M", "keepConflicts": true})).unwrap(); + assert_eq!(r["conflicts"].as_array().unwrap().len(), 1, "{r}"); + let c = s.execute("shortcuts.conflicts", json!({"platform": "mac"})).unwrap(); + assert_eq!(c["conflicts"][0]["keys"], json!("M")); + // undo / redo within the editor + s.execute("shortcuts.undo", json!({})).unwrap(); + assert!(s.execute("shortcuts.conflicts", json!({})).unwrap()["conflicts"].as_array().unwrap().is_empty()); + s.execute("shortcuts.undo", json!({})).unwrap(); + s.execute("shortcuts.undo", json!({})).unwrap(); + assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Z")); + assert_eq!(s.shortcuts.primary("sequence.addEditAllTracks").as_deref(), Some("Cmd+Shift+K")); + s.execute("shortcuts.redo", json!({})).unwrap(); + assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Shift+K")); + // clear + let r = s.execute("shortcuts.clear", json!({"command": "edit.undo"})).unwrap(); + assert_eq!(r["removed"], json!(2), "Cmd+Shift+K and History ▸ Left"); + assert!(s.shortcuts.for_command("edit.undo").is_empty()); + assert!(s.execute("shortcuts.set", json!({"command": "no.such", "keys": "K"})).is_err()); + assert!(s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "K", "panel": "Nowhere"})).is_err()); + // editor Cancel restores the set it opened with + s.shortcuts.begin_editing(); + s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "U"})).unwrap(); + s.shortcuts.cancel_editing(); + assert!(s.shortcuts.for_command("edit.undo").is_empty()); +} + +#[test] +fn panel_shortcuts_override_application_ones_with_focus() { + let s = Session::default(); + let left = Chord::parse("Left").unwrap(); + let p = Platform::Mac; + assert_eq!(s.shortcuts.resolve(&left, None, p).unwrap().command, "playhead.stepBack"); + assert_eq!(s.shortcuts.resolve(&left, Some("Timeline"), p).unwrap().command, "playhead.stepBack"); + assert_eq!(s.shortcuts.resolve(&left, Some("History"), p).unwrap().command, "edit.undo"); + let bs = Chord::parse("Backspace").unwrap(); + assert_eq!(s.shortcuts.resolve(&bs, Some("Project"), p).unwrap().command, "project.delete"); + assert_eq!(s.shortcuts.resolve(&bs, None, p).unwrap().command, "edit.clear"); + let ov = s.shortcuts.overrides(p); + assert!(ov.iter().any(|(panel, k, cmd, app)| panel == "History" && k == "Left" && cmd == "edit.undo" && app == "playhead.stepBack"), "{ov:?}"); + // Windows: macOS-Control shortcuts fold into Ctrl and can clash + let mut t = Session::default(); + t.execute("shortcuts.set", json!({"command": "edit.copy", "keys": "Ctrl+C", "add": true, "keepConflicts": true})).unwrap(); + assert!(t.shortcuts.conflicts(Platform::Mac).is_empty()); + assert!(!t.shortcuts.conflicts(Platform::Windows).is_empty() || t.shortcuts.for_command("edit.copy").len() == 2); + let k = t.execute("shortcuts.forKey", json!({"key": "C"})).unwrap(); + assert!(k.as_array().unwrap().iter().any(|b| b["command"] == "edit.copy" && b["keys"] == "Ctrl+C"), "{k}"); +} + +#[test] +fn compat_presets_map_other_editors() { + let mut s = Session::default(); + with_ui_commands(&mut s); + s.execute("shortcuts.loadPreset", json!({"name": "Premiere Pro Compatible"})).unwrap(); + assert_eq!(s.shortcuts.primary("markers.clearIn").as_deref(), Some("Alt+I")); + assert_eq!(s.shortcuts.primary("edit.clear").as_deref(), Some("Delete")); + assert!(s.shortcuts.for_command("edit.clear").iter().any(|b| b.panel.as_deref() == Some("Timeline") && b.keys == "Backspace")); + s.execute("shortcuts.loadPreset", json!({"name": "Final Cut Pro Compatible"})).unwrap(); + assert_eq!(s.shortcuts.primary("source.insert").as_deref(), Some("W")); + assert_eq!(s.shortcuts.primary("tool.razor").as_deref(), Some("B")); + assert!(s.shortcuts.primary("trim.rippleNext").is_none(), "W was taken by Insert"); + s.execute("shortcuts.loadPreset", json!({"name": "Avid Media Composer Compatible"})).unwrap(); + assert_eq!(s.shortcuts.primary("sequence.lift").as_deref(), Some("Z")); + assert_eq!(s.shortcuts.for_command("markers.markIn").len(), 2, "I and E"); + assert!(!s.shortcuts.modified); + assert!(s.execute("shortcuts.loadPreset", json!({"name": "Nope"})).is_err()); + s.execute("shortcuts.loadPreset", json!({"name": "FilmCraft Default"})).unwrap(); + assert_eq!(s.shortcuts.primary("source.insert").as_deref(), Some(",")); +} + +#[test] +fn custom_presets_persist_export_and_import() { + let dir = temp_dir("presets"); + let mut s = Session::default(); + s.shortcuts.set_dir(&dir); + s.execute("shortcuts.set", json!({"command": "sequence.addEdit", "keys": "Cmd+Shift+B"})).unwrap(); + let r = s.execute("shortcuts.savePreset", json!({"name": "My Keys"})).unwrap(); + assert!(std::path::Path::new(r["path"].as_str().unwrap()).exists()); + let p = s.execute("shortcuts.presets", json!({})).unwrap(); + assert_eq!(p["active"], json!("My Keys")); + assert_eq!(p["custom"], json!(["My Keys"])); + assert!(s.execute("shortcuts.savePreset", json!({"name": "FilmCraft Default"})).is_err()); + // the active set survives a restart + let mut t = Shortcuts::new(); + t.set_dir(&dir); + assert_eq!(t.preset, "My Keys"); + assert_eq!(t.primary("sequence.addEdit").as_deref(), Some("Cmd+Shift+B")); + // switch away and back + s.execute("shortcuts.loadPreset", json!({"name": "FilmCraft Default"})).unwrap(); + assert_eq!(s.shortcuts.primary("sequence.addEdit").as_deref(), Some("Cmd+K")); + s.execute("shortcuts.loadPreset", json!({"name": "My Keys"})).unwrap(); + assert_eq!(s.shortcuts.primary("sequence.addEdit").as_deref(), Some("Cmd+Shift+B")); + // export, then import a Windows-notation file + let out = dir.join("export.json"); + s.execute("shortcuts.export", json!({"path": out.to_string_lossy()})).unwrap(); + let f: crate::shortcuts::PresetFile = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap(); + assert!(f.bindings.contains(&Binding { command: "sequence.addEdit".into(), keys: "Cmd+Shift+B".into(), panel: None })); + let win = dir.join("win.json"); + std::fs::write( + &win, + serde_json::to_vec(&json!({"format": "filmcraft-keyboard-shortcuts", "version": 1, "name": "From Windows", "platform": "windows", + "bindings": [{"command": "edit.undo", "keys": "Ctrl+Z"}, {"command": "sequence.addEdit", "keys": "Ctrl+Alt+K"}, {"command": "no.such", "keys": "K"}]})) + .unwrap(), + ) + .unwrap(); + let r = s.execute("shortcuts.import", json!({"path": win.to_string_lossy()})).unwrap(); + assert_eq!(r["name"], json!("From Windows")); + assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Z")); + assert_eq!(s.shortcuts.primary("sequence.addEdit").as_deref(), Some("Cmd+Alt+K")); + assert_eq!(s.shortcuts.bindings.len(), 2, "unknown commands dropped"); + s.execute("shortcuts.deletePreset", json!({"name": "My Keys"})).unwrap(); + assert!(s.execute("shortcuts.deletePreset", json!({"name": "FilmCraft Default"})).is_err()); + assert_eq!(s.execute("shortcuts.presets", json!({})).unwrap()["custom"], json!(["From Windows"])); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn list_searches_labels_and_keys() { + let s = Session::default(); + let r = s.shortcuts.commands(); + assert!(r.iter().any(|c| c.id == "trim.forward")); + assert!(!r.iter().any(|c| c.id == "timeline.move"), "commands with required params are not bindable"); + let mut s = s; + let v = s.execute("shortcuts.list", json!({"query": "ripple delete"})).unwrap(); + assert!(v.as_array().unwrap().iter().any(|c| c["id"] == "edit.rippleDelete"), "{v}"); + let v = s.execute("shortcuts.list", json!({"query": "cmd+k", "platform": "windows"})).unwrap(); + assert!(v.as_array().unwrap().iter().any(|c| c["id"] == "sequence.addEdit"), "{v}"); + let v = s.execute("shortcuts.list", json!({"panel": "History", "assigned": true})).unwrap(); + assert_eq!(v.as_array().unwrap().len(), 2, "{v}"); +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 5dc96855c..c8d871d5c 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -100,10 +100,10 @@ impl PanelKind { PanelKind::Timeline => Some("Shift+3"), PanelKind::Program => Some("Shift+4"), PanelKind::EffectControls => Some("Shift+5"), - PanelKind::AudioClipMixer => Some("Shift+6"), + PanelKind::AudioClipMixer => Some("Shift+9"), PanelKind::Effects => Some("Shift+7"), PanelKind::MediaBrowser => Some("Shift+8"), - PanelKind::AudioTrackMixer => Some("Shift+9"), + PanelKind::AudioTrackMixer => Some("Shift+6"), _ => None, } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 66ac3d414..a68ff6cd6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -50,6 +50,10 @@ pub struct HostHooks { pub pick_open_project: Option Option>>, /// Save dialog with a filter: (filter name, extensions, suggested file name) → path. pub pick_save_as: Option Option>>, + /// The active keyboard shortcuts changed: update native menu key equivalents. + pub shortcuts_changed: Option>, + /// Open dialog for a JSON file (shortcut preset import): filter name, extensions → path. + pub pick_open_file: Option Option>>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -112,7 +116,11 @@ pub struct FilmcraftApp { pub last_timeline_width: f32, pub fps: f32, last_time: f64, - bindings: Vec<(egui::Modifiers, egui::Key, String)>, + bindings: Vec, + /// Shortcut-set revision `bindings` (and the native menu) were built from. + bindings_rev: u64, + /// Keyboard Shortcuts dialog state. + pub shortcut_editor: panels::shortcuts_dialog::EditorState, pub toast: Option<(String, f64)>, pub tl: panels::timeline::TlState, /// Commands from outside the UI (native menu bar), invoked on the UI thread. @@ -168,7 +176,8 @@ impl FilmcraftApp { } impl FilmcraftApp { - pub fn new(session: Session) -> Self { + pub fn new(mut session: Session) -> Self { + session.shortcuts.register_external(menus::external_commands()); let recovery = !session.recovery_candidates().is_empty(); let frames = Arc::new(FrameServer::new( session.media.clone(), @@ -205,7 +214,9 @@ impl FilmcraftApp { last_timeline_width: 1000.0, fps: 60.0, last_time: 0.0, - bindings: menus::bindings(), + bindings: Vec::new(), + bindings_rev: 0, + shortcut_editor: Default::default(), toast: None, tl: Default::default(), command_inbox: None, @@ -521,16 +532,28 @@ impl FilmcraftApp { // ---------------------------------------------------------------- input fn handle_shortcuts(&mut self, ctx: &egui::Context) { - if ctx.egui_wants_keyboard_input() { + if self.bindings_rev != self.session.shortcuts.revision { + self.bindings = menus::bindings(self); + self.bindings_rev = self.session.shortcuts.revision; + if let Some(hook) = self.hooks.shortcuts_changed.as_mut() { + let items = menus::menu_items_for(&self.session); + hook(&items); + } + } + if ctx.egui_wants_keyboard_input() || self.dialog == Some(Dialog::Shortcuts) { return; } // Esc cancels a dynamic trim in progress if self.session.trim_play.dynamic.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { let _ = self.session.execute("trim.cancelDynamic", json!({})); } + // Panel shortcuts of the focused panel first: they override application shortcuts. + let focused = self.ui.focused.title(); let mut fire = Vec::new(); ctx.input_mut(|i| { - for (m, k, id) in &self.bindings { + let panel = self.bindings.iter().filter(|b| b.3.as_deref() == Some(focused)); + let app_wide = self.bindings.iter().filter(|b| b.3.is_none()); + for (m, k, id, _) in panel.chain(app_wide) { if i.consume_key(*m, *k) { fire.push(id.clone()); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6b39a7cb3..8defba45c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -26,9 +26,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("playback.forward", "Shuttle Right", [], Some("L")), uic!("playback.stop", "Shuttle Stop", [], Some("K")), uic!("playback.reverse", "Shuttle Left", [], Some("J")), - uic!("playback.slowForward", "Shuttle Slow Right", [], Some("Shift+L")), - uic!("playback.slowReverse", "Shuttle Slow Left", [], Some("Shift+J")), - uic!("playback.playAround", "Play Around", [], Some("Shift+K")), + uic!("playback.slowForward", "Shuttle Slow Right", [], None), + uic!("playback.slowReverse", "Shuttle Slow Left", [], None), + uic!("playback.playAround", "Play Around", [], None), uic!("playback.inToOut", "Play In to Out", [], Some("Shift+Space")), uic!("playback.loop", "Loop", [], None), uic!("view.zoomIn", "Zoom In", ["View"], Some("=")), @@ -67,7 +67,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("mode.edit", "Edit", [], None), uic!("mode.export", "Export", ["File", "Export"], Some("Cmd+M")), uic!("app.about", "About FilmCraft", ["Help"], None), - uic!("help.shortcuts", "Keyboard Shortcuts", ["Help"], None), + uic!("app.keyboardShortcuts", "Keyboard Shortcuts…", ["Edit"], Some("Cmd+Alt+K")), uic!("app.preferences.autoSave", "Auto Save…", ["Edit", "Preferences"], Some("Cmd+,")), ]; @@ -164,8 +164,8 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val app.dialog = Some(crate::Dialog::About); return Ok(Value::Null); } - "help.shortcuts" => { - app.dialog = Some(crate::Dialog::Shortcuts); + "help.shortcuts" | "app.keyboardShortcuts" => { + crate::panels::shortcuts_dialog::open(app); return Ok(Value::Null); } "app.preferences" | "app.preferences.autoSave" => { @@ -237,6 +237,11 @@ pub struct MenuItem { pub const MENUS: [&str; 9] = ["File", "Edit", "Clip", "Sequence", "Markers", "Graphics and Titles", "View", "Window", "Help"]; pub fn menu_items(app: &FilmcraftApp) -> Vec { + menu_items_for(&app.session) +} + +/// Menu entries with their live shortcuts and enablement. +pub fn menu_items_for(session: &filmcraft_engine::Session) -> Vec { let mut out = Vec::new(); for c in filmcraft_engine::command_specs() { if c.menu.is_empty() { @@ -246,8 +251,8 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { id: c.id.into(), label: c.label.into(), path: c.menu.iter().map(|s| s.to_string()).collect(), - shortcut: c.shortcut.map(Into::into), - enabled: app.session.is_enabled(c.id), + shortcut: session.shortcuts.primary(c.id), + enabled: session.is_enabled(c.id), }); } for c in UI_COMMANDS { @@ -258,7 +263,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { id: c.id.into(), label: c.label.into(), path: c.menu.iter().map(|s| s.to_string()).collect(), - shortcut: c.shortcut.map(Into::into), + shortcut: session.shortcuts.primary(c.id), enabled: true, }); } @@ -267,20 +272,17 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { id: panel_command_id(p), label: p.title().into(), path: vec!["Window".into()], - shortcut: p.window_shortcut().map(Into::into), + shortcut: session.shortcuts.primary(&panel_command_id(p)), enabled: true, }); } out } -/// Pretty shortcut text for menus (macOS glyphs). +/// Shortcut text for menus in this OS's notation (`⇧⌘K` on macOS, `Ctrl+Shift+K` elsewhere). pub fn shortcut_text(s: &str) -> String { - if cfg!(target_os = "macos") { - s.replace("Cmd+", "⌘").replace("Shift+", "⇧").replace("Alt+", "⌥").replace("Ctrl+", "⌃").replace("Backspace", "⌫").replace("Delete", "⌦") - } else { - s.replace("Cmd+", "Ctrl+") - } + use filmcraft_engine::shortcuts::{Chord, Platform}; + Chord::parse(s).map(|c| c.display(Platform::current())).unwrap_or_else(|_| s.to_string()) } /// Parse "Cmd+Shift+K" into modifiers + key. @@ -313,26 +315,26 @@ pub fn parse_shortcut(s: &str) -> Option<(egui::Modifiers, egui::Key)> { key.map(|k| (m, k)) } -/// All shortcut bindings: (modifiers, key, command id). -pub fn bindings() -> Vec<(egui::Modifiers, egui::Key, String)> { - let mut v = Vec::new(); - for c in UI_COMMANDS { - if let Some((m, k)) = c.shortcut.and_then(parse_shortcut) { - v.push((m, k, c.id.to_string())); - } - } - for c in filmcraft_engine::command_specs() { - if let Some((m, k)) = c.shortcut.and_then(parse_shortcut) { - v.push((m, k, c.id.to_string())); - } - } +/// One active key binding for the input loop: (modifiers, key, command id, panel or None). +pub type KeyBinding = (egui::Modifiers, egui::Key, String, Option); + +/// The active key bindings (from the engine's shortcut set), most specific first so Shift+I +/// doesn't also fire I. +pub fn bindings(app: &FilmcraftApp) -> Vec { + let mut v: Vec = + app.session.shortcuts.bindings.iter().filter_map(|b| parse_shortcut(&b.keys).map(|(m, k)| (m, k, b.command.clone(), b.panel.clone()))).collect(); + v.sort_by_key(|(m, ..)| std::cmp::Reverse(m.command as u8 + m.shift as u8 + m.alt as u8 + m.ctrl as u8)); + v +} + +/// Frontend-owned commands, registered with the engine's shortcut set so they can be listed, +/// rebound and resolved alongside engine commands (the `shortcuts.` commands). +pub fn external_commands() -> Vec { + use filmcraft_engine::shortcuts::CommandInfo; + let mut v: Vec = UI_COMMANDS.iter().map(|c| CommandInfo::new(c.id, c.label, c.menu, c.shortcut)).collect(); for p in PanelKind::ALL { - if let Some((m, k)) = p.window_shortcut().and_then(parse_shortcut) { - v.push((m, k, panel_command_id(p))); - } + v.push(CommandInfo::new(&panel_command_id(p), p.title(), &["Window"], p.window_shortcut())); } - // Most specific (more modifiers) first so Shift+I doesn't also fire I. - v.sort_by_key(|(m, _, _)| std::cmp::Reverse(m.command as u8 + m.shift as u8 + m.alt as u8 + m.ctrl as u8)); v } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 73436d25e..0ce1fe042 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1,4 +1,5 @@ -//! Modal dialogs (About, Keyboard Shortcuts; Preferences/Recovery/Revert in `file_dialogs`). +//! Modal dialogs (About; Keyboard Shortcuts in `shortcuts_dialog`; Preferences/Recovery/Revert in +//! `file_dialogs`). use crate::{Dialog, FilmcraftApp}; @@ -21,22 +22,10 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { }); } Dialog::Shortcuts => { - egui::Window::new("Keyboard Shortcuts").open(&mut open).default_size([520.0, 520.0]).show(ctx, |ui| { - egui::ScrollArea::vertical().show(ui, |ui| { - egui::Grid::new("shortcuts").striped(true).show(ui, |ui| { - for c in filmcraft_engine::command_specs().iter().filter(|c| c.shortcut.is_some()) { - ui.label(c.label); - ui.monospace(crate::menus::shortcut_text(c.shortcut.unwrap_or(""))); - ui.end_row(); - } - for c in crate::menus::UI_COMMANDS.iter().filter(|c| c.shortcut.is_some()) { - ui.label(c.label); - ui.monospace(crate::menus::shortcut_text(c.shortcut.unwrap_or(""))); - ui.end_row(); - } - }); - }); - }); + if !crate::panels::shortcuts_dialog::show(app, ctx) && app.dialog == Some(d) { + app.dialog = None; + } + return; } Dialog::NewSequence | Dialog::Preferences | Dialog::Recovery | Dialog::RevertConfirm => {} } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a70b2d967..79ff44cef 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -12,6 +12,7 @@ pub mod meters; pub mod misc; pub mod monitor; pub mod project; +pub mod shortcuts_dialog; pub mod text; pub mod timeline; pub mod timeline_captions; diff --git a/crates/ui-egui/src/panels/shortcuts_dialog.rs b/crates/ui-egui/src/panels/shortcuts_dialog.rs new file mode 100644 index 000000000..262d830c9 --- /dev/null +++ b/crates/ui-egui/src/panels/shortcuts_dialog.rs @@ -0,0 +1,787 @@ +//! Edit ▸ Keyboard Shortcuts… (⌥⌘K): a keyboard drawn in code with every assigned key coloured +//! (application-wide / panel-specific / both), a searchable command list with a shortcut column +//! (click it and press keys to assign; "+" adds another shortcut), the "Key:" detail list, +//! conflict warnings, preset load / Save As / Delete / Export / Import, and Undo / Redo / Clear. +//! Edits apply live (menus update immediately); Cancel restores the set the dialog opened with. +//! +//! All state lives in the engine's shortcut set (`shortcuts.*` commands), so agents can do the +//! same over MCP. Automation ids: `shortcuts.preset`, `shortcuts.preset.`, `shortcuts.saveAs`, +//! `shortcuts.saveAs.name`, `shortcuts.saveAs.ok`, `shortcuts.delete`, `shortcuts.export`, +//! `shortcuts.import`, `shortcuts.context`, `shortcuts.context.`, `shortcuts.search`, +//! `shortcuts.key.`, `shortcuts.mod.`, `shortcuts.row.`, +//! `shortcuts.cell.` (click = record), `shortcuts.add.`, `shortcuts.assignKey`, +//! `shortcuts.undo`, `shortcuts.redo`, `shortcuts.clear`, `shortcuts.cancel`, `shortcuts.ok`. + +use egui::text::LayoutJob; +use egui::{Align2, Color32, CornerRadius, FontId, Rect, RichText, Sense, Stroke, StrokeKind, TextFormat, pos2, vec2}; +use filmcraft_engine::shortcuts::{APPLICATION, Chord, KEYS, Mods, PANELS, Platform}; +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::FilmcraftApp; +use crate::theme::Tokens; + +/// Key colours (our own palette): application-wide, panel-specific, unassigned, held modifier. +pub const APP_KEY: Color32 = Color32::from_rgb(0x74, 0x57, 0xc8); +pub const PANEL_KEY: Color32 = Color32::from_rgb(0x4a, 0xa6, 0x84); +pub const FREE_KEY: Color32 = Color32::from_rgb(0x5a, 0x5a, 0x5e); +const MOD_KEY: Color32 = Color32::from_rgb(0x2f, 0x62, 0xc0); +const WARN: Color32 = Color32::from_rgb(0xe8, 0xa0, 0x3c); + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorState { + pub search: String, + /// "Application" or a panel name (which panel's shortcuts the keyboard and assignments use). + pub context: String, + pub selected: Option, + /// Waiting for a key press: (command, add instead of replace). + pub recording: Option<(String, bool)>, + /// Modifier filter toggled on the drawn keyboard. + pub mods: Mods, + /// Key whose assignments the "Key:" list shows. + pub key: Option, + pub message: String, + /// Save As… name being typed (Some = the field is open). + pub save_name: Option, +} + +impl EditorState { + fn panel(&self) -> Option<&str> { + if self.context.is_empty() || self.context == APPLICATION { None } else { Some(self.context.as_str()) } + } +} + +/// Open the dialog (Edit ▸ Keyboard Shortcuts…). +pub fn open(app: &mut FilmcraftApp) { + app.session.shortcuts.begin_editing(); + app.shortcut_editor = EditorState { context: APPLICATION.into(), ..Default::default() }; + app.dialog = Some(crate::Dialog::Shortcuts); +} + +/// The egui key for a canonical key name. +fn egui_key(name: &str) -> Option { + crate::menus::parse_shortcut(name).map(|(_, k)| k) +} + +/// Canonical chord text from an egui key event. +pub fn chord_of(key: egui::Key, m: egui::Modifiers) -> Option { + let name = KEYS.iter().find(|k| egui_key(k) == Some(key))?; + let mods = Mods { cmd: m.command || m.mac_cmd, ctrl: m.ctrl && cfg!(target_os = "macos"), alt: m.alt, shift: m.shift }; + Some(format!("{}{}", mods.prefix(), name)) +} + +fn label_of(app: &FilmcraftApp, id: &str) -> String { + app.session + .shortcuts + .command(id) + .map(|c| c.label) + .or_else(|| filmcraft_engine::find_command(id).map(|c| c.label.to_string())) + .unwrap_or_else(|| id.to_string()) +} + +fn exec(app: &mut FilmcraftApp, cmd: &str, params: Value) -> Option { + match app.session.execute(cmd, params) { + Ok(v) => Some(v), + Err(e) => { + app.shortcut_editor.message = e.to_string(); + None + } + } +} + +/// Assign a recorded chord. +fn assign(app: &mut FilmcraftApp, command: &str, keys: &str, add: bool) { + let panel = app.shortcut_editor.panel().map(str::to_string); + let p = Platform::current(); + if let Some(r) = exec(app, "shortcuts.set", json!({"command": command, "keys": keys, "panel": panel, "add": add})) { + let disp = Chord::parse(keys).map(|c| c.display(p)).unwrap_or_else(|_| keys.into()); + let mut msg = format!("{disp} → {}", label_of(app, command)); + let moved: Vec = r["reassigned"].as_array().into_iter().flatten().filter_map(|b| b["command"].as_str().map(|c| label_of(app, c))).collect(); + if !moved.is_empty() { + msg.push_str(&format!(" (removed from {})", moved.join(", "))); + } + app.shortcut_editor.message = msg; + } +} + +/// Take the next key press while recording (Esc cancels). +fn record(app: &mut FilmcraftApp, ctx: &egui::Context) { + let Some((cmd, add)) = app.shortcut_editor.recording.clone() else { return }; + let got = ctx.input_mut(|i| { + let mut got = None; + i.events.retain(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if got.is_none() => { + got = Some((*key, *modifiers)); + false + } + egui::Event::Key { .. } | egui::Event::Text(_) => false, + _ => true, + }); + got + }); + let Some((key, m)) = got else { return }; + app.shortcut_editor.recording = None; + if key == egui::Key::Escape && !m.any() { + app.shortcut_editor.message = "Cancelled".into(); + return; + } + match chord_of(key, m) { + Some(k) => assign(app, &cmd, &k, add), + None => app.shortcut_editor.message = format!("{key:?} can't be used as a shortcut"), + } +} + +/// Show the dialog; false when it closed. +pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { + record(app, ctx); + let t = app.tokens; + let screen = ctx.content_rect(); + let size = vec2((screen.width() - 40.0).clamp(760.0, 1320.0), (screen.height() - 60.0).clamp(560.0, 900.0)); + let mut close: Option = None; // Some(true) = OK, Some(false) = Cancel + let mut open = true; + egui::Window::new("Keyboard Shortcuts") + .id(egui::Id::new("keyboard-shortcuts")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .fixed_size(size) + .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0)) + .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel_bg).inner_margin(14.0)) + .show(ctx, |ui| { + ui.set_width(size.x); + header(app, ui, &t); + ui.add_space(8.0); + let kb_h = (size.y * 0.42).clamp(220.0, 360.0); + let (kb, _) = ui.allocate_exact_size(vec2(size.x, kb_h), Sense::hover()); + keyboard(app, ui, kb, &t); + legend(ui, &t); + ui.add_space(6.0); + let rest_h = (size.y - kb_h - 160.0).max(160.0); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(size.x * 0.6); + command_list(app, ui, rest_h, &t); + }); + ui.add_space(12.0); + ui.vertical(|ui| { + ui.set_width(size.x * 0.4 - 14.0); + key_detail(app, ui, rest_h, &t); + }); + }); + ui.add_space(6.0); + close = footer(app, ui, &t); + }); + if !open { + close = Some(false); + } + match close { + Some(ok) => { + if ok { + app.session.shortcuts.end_editing(); + } else { + app.session.shortcuts.cancel_editing(); + } + app.shortcut_editor.recording = None; + false + } + None => true, + } +} + +fn small_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: &str, label: &str, enabled: bool) -> bool { + let r = ui.add_enabled(enabled, egui::Button::new(RichText::new(label).size(12.5)).min_size(vec2(0.0, 24.0))); + app.auto.add(id, r.rect, label); + r.clicked() +} + +fn header(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let presets = app.session.execute("shortcuts.presets", json!({})).unwrap_or_default(); + let active = presets["active"].as_str().unwrap_or("").to_string(); + let modified = presets["modified"].as_bool().unwrap_or(false); + let builtin: Vec = presets["builtin"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect(); + let custom: Vec = presets["custom"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect(); + ui.horizontal(|ui| { + ui.label(RichText::new("Preset:").size(13.0).color(t.text_dim)); + let shown = if modified { format!("{active} (modified)") } else { active.clone() }; + let r = egui::ComboBox::from_id_salt("shortcuts-preset").width(280.0).selected_text(shown).show_ui(ui, |ui| { + for name in builtin.iter().chain(custom.iter()) { + let r = ui.selectable_label(*name == active && !modified, name); + app.auto.add(&format!("shortcuts.preset.{name}"), r.rect, name); + if r.clicked() { + exec(app, "shortcuts.loadPreset", json!({"name": name})); + app.shortcut_editor.message = format!("Loaded “{name}”"); + } + } + }); + app.auto.add("shortcuts.preset", r.response.rect, "Preset"); + ui.add_space(8.0); + if small_button(app, ui, "shortcuts.saveAs", "Save As…", true) { + app.shortcut_editor.save_name = Some(if builtin.contains(&active) { "My Shortcuts".into() } else { active.clone() }); + } + let can_delete = custom.contains(&active); + if small_button(app, ui, "shortcuts.delete", "Delete", can_delete) { + exec(app, "shortcuts.deletePreset", json!({"name": active})); + app.shortcut_editor.message = format!("Deleted preset “{active}”"); + } + if small_button(app, ui, "shortcuts.export", "Export…", true) { + let picked = app.hooks.pick_save_as.as_mut().and_then(|f| f("Keyboard Shortcuts", &["json"], &format!("{active}.json"))); + match picked { + Some(path) => { + if exec(app, "shortcuts.export", json!({"path": path})).is_some() { + app.shortcut_editor.message = format!("Exported to {path}"); + } + } + None => app.shortcut_editor.message = "Export: no file chosen (agents: shortcuts.export {path})".into(), + } + } + if small_button(app, ui, "shortcuts.import", "Import…", true) { + let picked = app.hooks.pick_open_file.as_mut().and_then(|f| f("Keyboard Shortcuts", &["json"])); + match picked { + Some(path) => { + if let Some(r) = exec(app, "shortcuts.import", json!({"path": path})) { + app.shortcut_editor.message = format!("Imported “{}”", r["name"].as_str().unwrap_or("")); + } + } + None => app.shortcut_editor.message = "Import: no file chosen (agents: shortcuts.import {path})".into(), + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(if cfg!(target_os = "macos") { "Keyboard: US (macOS)" } else { "Keyboard: US" }).size(12.0).color(t.text_dim)); + }); + }); + if let Some(mut name) = app.shortcut_editor.save_name.clone() { + ui.horizontal(|ui| { + ui.label(RichText::new("Save preset as:").size(13.0).color(t.text_dim)); + let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)); + app.auto.add("shortcuts.saveAs.name", r.rect, "Preset name"); + let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if small_button(app, ui, "shortcuts.saveAs.ok", "Save", !name.trim().is_empty()) || enter { + if exec(app, "shortcuts.savePreset", json!({"name": name})).is_some() { + app.shortcut_editor.message = format!("Saved preset “{}”", name.trim()); + app.shortcut_editor.save_name = None; + } else { + app.shortcut_editor.save_name = Some(name.clone()); + } + } else if small_button(app, ui, "shortcuts.saveAs.cancel", "Cancel", true) { + app.shortcut_editor.save_name = None; + } else { + app.shortcut_editor.save_name = Some(name.clone()); + } + }); + } + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Commands:").size(13.0).color(t.text_dim)); + let cur = app.shortcut_editor.context.clone(); + let r = + egui::ComboBox::from_id_salt("shortcuts-context").width(280.0).selected_text(if cur.is_empty() { APPLICATION } else { &cur }).show_ui(ui, |ui| { + for c in std::iter::once(APPLICATION).chain(PANELS.iter().copied()) { + let label = if c == APPLICATION { c.to_string() } else { format!("{c} Panel") }; + let r = ui.selectable_label(cur == c, label); + app.auto.add(&format!("shortcuts.context.{c}"), r.rect, c); + if r.clicked() { + app.shortcut_editor.context = c.to_string(); + } + } + }); + app.auto.add("shortcuts.context", r.response.rect, "Commands"); + }); +} + +/// (key name, width in key units); "" = gap. +type KeyRow = &'static [(&'static str, f32)]; +const MAIN_ROWS: [KeyRow; 6] = [ + &[ + ("Escape", 1.0), + ("", 0.5), + ("F1", 1.0), + ("F2", 1.0), + ("F3", 1.0), + ("F4", 1.0), + ("", 0.25), + ("F5", 1.0), + ("F6", 1.0), + ("F7", 1.0), + ("F8", 1.0), + ("", 0.25), + ("F9", 1.0), + ("F10", 1.0), + ("F11", 1.0), + ("F12", 1.0), + ], + &[ + ("`", 1.0), + ("1", 1.0), + ("2", 1.0), + ("3", 1.0), + ("4", 1.0), + ("5", 1.0), + ("6", 1.0), + ("7", 1.0), + ("8", 1.0), + ("9", 1.0), + ("0", 1.0), + ("-", 1.0), + ("=", 1.0), + ("Backspace", 2.0), + ], + &[ + ("Tab", 1.5), + ("Q", 1.0), + ("W", 1.0), + ("E", 1.0), + ("R", 1.0), + ("T", 1.0), + ("Y", 1.0), + ("U", 1.0), + ("I", 1.0), + ("O", 1.0), + ("P", 1.0), + ("[", 1.0), + ("]", 1.0), + ("\\", 1.5), + ], + &[ + ("#Caps", 1.75), + ("A", 1.0), + ("S", 1.0), + ("D", 1.0), + ("F", 1.0), + ("G", 1.0), + ("H", 1.0), + ("J", 1.0), + ("K", 1.0), + ("L", 1.0), + (";", 1.0), + ("'", 1.0), + ("Enter", 2.25), + ], + &[ + ("#Shift", 2.25), + ("Z", 1.0), + ("X", 1.0), + ("C", 1.0), + ("V", 1.0), + ("B", 1.0), + ("N", 1.0), + ("M", 1.0), + (",", 1.0), + (".", 1.0), + ("/", 1.0), + ("#Shift", 2.75), + ], + &[("#Ctrl", 1.5), ("#Alt", 1.25), ("#Cmd", 1.5), ("Space", 6.5), ("#Cmd", 1.5), ("#Alt", 1.25), ("#Ctrl", 1.5)], +]; +/// Navigation cluster rows (x offset in units within the cluster). +const NAV_ROWS: [&[(&str, f32)]; 6] = [ + &[], + &[("Insert", 0.0), ("Home", 1.0), ("PageUp", 2.0)], + &[("Delete", 0.0), ("End", 1.0), ("PageDown", 2.0)], + &[], + &[("Up", 1.0)], + &[("Left", 0.0), ("Down", 1.0), ("Right", 2.0)], +]; + +fn key_legend(name: &str, mac: bool) -> String { + match name { + "#Caps" => "Caps Lock".into(), + "#Shift" => "⇧ Shift".into(), + "#Ctrl" => { + if mac { + "⌃ Control".into() + } else { + "Win".into() + } + } + "#Alt" => { + if mac { + "⌥ Option".into() + } else { + "Alt".into() + } + } + "#Cmd" => { + if mac { + "⌘ Command".into() + } else { + "Ctrl".into() + } + } + "Backspace" => { + if mac { + "⌫ Delete".into() + } else { + "Backspace".into() + } + } + "Delete" => { + if mac { + "⌦ Fwd Del".into() + } else { + "Delete".into() + } + } + "Enter" => "↩ Return".into(), + "Escape" => "Esc".into(), + "PageUp" => "Page Up".into(), + "PageDown" => "Page Down".into(), + "Up" => "↑".into(), + "Down" => "↓".into(), + "Left" => "←".into(), + "Right" => "→".into(), + k => k.into(), + } +} + +fn keyboard(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect, t: &Tokens) { + let mac = cfg!(target_os = "macos"); + // held modifiers count as the filter too (outside recording) + let held = ui.input(|i| i.modifiers); + let mut mods = app.shortcut_editor.mods; + if app.shortcut_editor.recording.is_none() { + mods.cmd |= held.command; + mods.ctrl |= held.ctrl && mac; + mods.alt |= held.alt; + mods.shift |= held.shift; + } + let panel = app.shortcut_editor.panel().map(str::to_string); + let map = app.session.shortcuts.keyboard(mods, panel.as_deref()); + let units_w = 15.0 + 0.5 + 3.0; + let gap = 3.0; + let u = (area.width() / units_w).min(area.height() / 6.2); + let row_h = u * 0.98; + let origin = area.min; + let mut clicked_key: Option = None; + let mut toggled: Option<&str> = None; + let mut draw = |ui: &mut egui::Ui, r: Rect, name: &str| { + let r = r.shrink(gap / 2.0); + let is_mod = name.starts_with('#'); + let (app_cmd, panel_cmd) = map.get(name).cloned().unwrap_or_default(); + let mod_on = match name { + "#Shift" => mods.shift, + "#Alt" => mods.alt, + "#Cmd" => mods.cmd, + "#Ctrl" => mods.ctrl, + _ => false, + }; + // modifier keys appear twice: the right-hand ones get a ".right" suffix + let right = is_mod && r.center().x > area.center().x; + let id = match (is_mod, right) { + (true, false) => format!("shortcuts.mod.{}", &name[1..]), + (true, true) => format!("shortcuts.mod.{}.right", &name[1..]), + _ => format!("shortcuts.key.{name}"), + }; + let resp = ui.interact(r, egui::Id::new(&id), Sense::click()); + let p = ui.painter(); + let base = if is_mod { + if mod_on { MOD_KEY } else { FREE_KEY.gamma_multiply(0.8) } + } else if app_cmd.is_some() { + APP_KEY + } else if panel_cmd.is_some() { + PANEL_KEY + } else { + FREE_KEY + }; + p.rect_filled(r, CornerRadius::same(3), base); + if app_cmd.is_some() && panel_cmd.is_some() { + // both: lower-right triangle in the panel colour + p.add(egui::Shape::convex_polygon(vec![r.right_top(), r.right_bottom(), r.left_bottom()], PANEL_KEY, Stroke::NONE)); + } + if resp.hovered() { + p.rect_stroke(r, CornerRadius::same(3), Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); + } + if app.shortcut_editor.key.as_deref() == Some(name) { + p.rect_stroke(r, CornerRadius::same(3), Stroke::new(2.0, t.accent), StrokeKind::Inside); + } + let fs = (u * 0.2).clamp(8.5, 12.0); + if let Some(cmd) = app_cmd.as_ref().or(panel_cmd.as_ref()) { + let label = label_of(app, cmd); + let mut job = LayoutJob::single_section(label, TextFormat::simple(FontId::proportional(fs), Color32::WHITE)); + job.wrap.max_width = r.width() - 8.0; + job.wrap.max_rows = if r.height() > 3.5 * fs { 2 } else { 1 }; + job.wrap.break_anywhere = false; + job.wrap.overflow_character = Some('…'); + let g = ui.fonts_mut(|f| f.layout_job(job)); + ui.painter().galley(r.min + vec2(4.0, 3.0), g, Color32::WHITE); + } + ui.painter().text( + r.left_bottom() + vec2(4.0, -3.0), + Align2::LEFT_BOTTOM, + key_legend(name, mac), + FontId::proportional(fs), + Color32::from_white_alpha(220), + ); + let tip = match (&app_cmd, &panel_cmd) { + (Some(a), Some(b)) => format!("{} / {} ({})", label_of(app, a), label_of(app, b), panel.clone().unwrap_or_default()), + (Some(a), None) => label_of(app, a), + (None, Some(b)) => label_of(app, b), + _ => key_legend(name, mac), + }; + app.auto.add(&id, r, &tip); + if resp.clicked() { + if is_mod { + toggled = Some(match name { + "#Shift" => "Shift", + "#Alt" => "Alt", + "#Cmd" => "Cmd", + _ => "Ctrl", + }); + } else if name != "#Caps" { + clicked_key = Some(name.to_string()); + } + } + }; + for (ri, row) in MAIN_ROWS.iter().enumerate() { + let mut x = origin.x; + let y = origin.y + ri as f32 * row_h + if ri > 0 { u * 0.15 } else { 0.0 }; + for (name, w) in row.iter() { + let r = Rect::from_min_size(pos2(x, y), vec2(w * u, row_h)); + if !name.is_empty() { + draw(ui, r, name); + } + x += w * u; + } + } + let nav_x = origin.x + 15.5 * u; + for (ri, row) in NAV_ROWS.iter().enumerate() { + let y = origin.y + ri as f32 * row_h + if ri > 0 { u * 0.15 } else { 0.0 }; + for (name, off) in row.iter() { + draw(ui, Rect::from_min_size(pos2(nav_x + off * u, y), vec2(u, row_h)), name); + } + } + let ed = &mut app.shortcut_editor; + if let Some(m) = toggled { + match m { + "Shift" => ed.mods.shift = !ed.mods.shift, + "Alt" => ed.mods.alt = !ed.mods.alt, + "Cmd" => ed.mods.cmd = !ed.mods.cmd, + _ => ed.mods.ctrl = !ed.mods.ctrl, + } + } + if let Some(k) = clicked_key { + ed.key = Some(k); + } +} + +fn legend(ui: &mut egui::Ui, t: &Tokens) { + ui.horizontal(|ui| { + for (c, text) in [ + (APP_KEY, "Application shortcuts work whatever panel has focus"), + (PANEL_KEY, "Panel shortcuts override application shortcuts while that panel has focus"), + (MOD_KEY, "Modifier held / selected"), + ] { + let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); + ui.painter().rect_filled(r, 2.0, c); + ui.label(RichText::new(text).size(12.0).color(t.text_dim)); + ui.add_space(14.0); + } + }); +} + +fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Tokens) { + let plat = Platform::current(); + ui.horizontal(|ui| { + let mut q = app.shortcut_editor.search.clone(); + let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search commands or keys").desired_width(320.0)); + app.auto.add("shortcuts.search", r.rect, "Search"); + app.shortcut_editor.search = q; + if let Some(sel) = app.shortcut_editor.selected.clone() { + ui.label(RichText::new(format!("Selected: {}", label_of(app, &sel))).size(12.0).color(t.text_dim)); + } + }); + ui.add_space(4.0); + let rows = app.session.execute("shortcuts.list", json!({"query": app.shortcut_editor.search})).unwrap_or_default(); + let rows = rows.as_array().cloned().unwrap_or_default(); + let w = ui.available_width(); + // column header + let (hr, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); + ui.painter().text(hr.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, "Command", Tokens::semibold(12.5), t.text_dim); + ui.painter().text(pos2(hr.min.x + w * 0.58, hr.center().y), Align2::LEFT_CENTER, "Shortcut", Tokens::semibold(12.5), t.text_dim); + egui::ScrollArea::vertical().id_salt("shortcuts-list").max_height(height - 50.0).auto_shrink([false, false]).show(ui, |ui| { + let mut last_cat = String::new(); + for (i, row) in rows.iter().enumerate() { + let id = row["id"].as_str().unwrap_or("").to_string(); + let cat = row["category"].as_str().unwrap_or("").to_string(); + if cat != last_cat { + let (cr, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); + let c = cr.left_center() + vec2(9.0, 0.0); + ui.painter().add(egui::Shape::convex_polygon(vec![c + vec2(-4.0, -2.5), c + vec2(4.0, -2.5), c + vec2(0.0, 3.0)], t.text_dim, Stroke::NONE)); + ui.painter().text(cr.left_center() + vec2(20.0, 0.0), Align2::LEFT_CENTER, &cat, Tokens::semibold(12.0), t.text); + last_cat = cat; + } + let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); + let selected = app.shortcut_editor.selected.as_deref() == Some(id.as_str()); + let bg = if selected { + t.row_selected + } else if i % 2 == 0 { + t.app_bg + } else { + t.row_alt + }; + ui.painter().rect_filled(r, 0.0, bg); + ui.painter().text(r.left_center() + vec2(22.0, 0.0), Align2::LEFT_CENTER, row["label"].as_str().unwrap_or(""), Tokens::ui(12.5), t.text); + app.auto.add(&format!("shortcuts.row.{id}"), r, row["label"].as_str().unwrap_or("")); + if resp.clicked() { + app.shortcut_editor.selected = Some(id.clone()); + } + // shortcut cell: click to record (replace); "+" adds another + let cell = Rect::from_min_max(pos2(r.min.x + w * 0.58, r.min.y + 2.0), pos2(r.max.x - 34.0, r.max.y - 2.0)); + let recording = app.shortcut_editor.recording.as_ref().is_some_and(|(c, _)| *c == id); + let text = if recording { + "Type a shortcut… (Esc cancels)".to_string() + } else { + row["shortcuts"] + .as_array() + .into_iter() + .flatten() + .map(|b| { + let d = b["display"].as_str().unwrap_or("").to_string(); + match b["panel"].as_str() { + Some(pn) => format!("{d} ({pn})"), + None => d, + } + }) + .collect::>() + .join(", ") + }; + let cresp = ui.interact(cell, egui::Id::new(("shortcut-cell", &id)), Sense::click()); + if recording { + ui.painter().rect(cell, 3.0, t.field_bg, Stroke::new(1.5, t.accent), StrokeKind::Inside); + } else if cresp.hovered() { + ui.painter().rect_stroke(cell, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + ui.painter().text(cell.left_center() + vec2(6.0, 0.0), Align2::LEFT_CENTER, &text, Tokens::ui(12.5), if recording { t.accent } else { t.hot_text }); + app.auto.add(&format!("shortcuts.cell.{id}"), cell, &text); + if cresp.clicked() { + app.shortcut_editor.selected = Some(id.clone()); + app.shortcut_editor.recording = Some((id.clone(), false)); + } + let plus = Rect::from_min_size(pos2(r.max.x - 30.0, r.min.y + 3.0), vec2(20.0, 20.0)); + let presp = ui.interact(plus, egui::Id::new(("shortcut-add", &id)), Sense::click()).on_hover_text("Add another shortcut"); + ui.painter().text( + plus.center(), + Align2::CENTER_CENTER, + "+", + Tokens::semibold(14.0), + if presp.hovered() { t.tab_text_active } else { t.text_faint }, + ); + app.auto.add(&format!("shortcuts.add.{id}"), plus, "Add shortcut"); + if presp.clicked() { + app.shortcut_editor.selected = Some(id.clone()); + app.shortcut_editor.recording = Some((id.clone(), true)); + } + } + if rows.is_empty() { + ui.label(RichText::new("No matching commands").color(t.text_dim)); + } + }); + let _ = plat; +} + +fn key_detail(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Tokens) { + let plat = Platform::current(); + let key = app.shortcut_editor.key.clone(); + ui.horizontal(|ui| { + ui.label(RichText::new("Key:").size(13.0).strong().color(t.text)); + if let Some(k) = &key { + ui.label(RichText::new(key_legend(k, plat.is_mac())).size(13.0).color(t.hot_text)); + } + }); + let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); + ui.painter().text(hr.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, "Modifiers", Tokens::semibold(12.5), t.text_dim); + ui.painter().text(hr.left_center() + vec2(110.0, 0.0), Align2::LEFT_CENTER, "Command", Tokens::semibold(12.5), t.text_dim); + let list = key.as_ref().and_then(|k| app.session.execute("shortcuts.forKey", json!({"key": k})).ok()).unwrap_or_default(); + egui::ScrollArea::vertical().id_salt("shortcuts-key").max_height(height - 90.0).auto_shrink([false, false]).show(ui, |ui| { + let w = ui.available_width(); + for (i, b) in list.as_array().into_iter().flatten().enumerate() { + let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover()); + ui.painter().rect_filled(r, 0.0, if i % 2 == 0 { t.app_bg } else { t.row_alt }); + let keys = b["keys"].as_str().unwrap_or(""); + let mods = Chord::parse(keys).map(|c| { + let k = Chord { mods: c.mods, key: c.key }; + let d = k.display(plat); + let legend = key_legend(c.key, plat.is_mac()); + let m = d.trim_end_matches(&legend).trim_end_matches(c.key).trim_end_matches('+').to_string(); + if m.is_empty() { "None".to_string() } else { m } + }); + ui.painter().text(r.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, mods.unwrap_or_default(), Tokens::ui(12.5), t.text); + let cmd = b["command"].as_str().unwrap_or(""); + let label = match b["panel"].as_str() { + Some(p) => format!("{} ({p})", label_of(app, cmd)), + None => label_of(app, cmd), + }; + ui.painter().text(r.left_center() + vec2(110.0, 0.0), Align2::LEFT_CENTER, label, Tokens::ui(12.5), t.text); + } + if key.is_some() && list.as_array().is_none_or(|a| a.is_empty()) { + ui.label(RichText::new("Nothing is assigned to this key").color(t.text_dim)); + } + if key.is_none() { + ui.label(RichText::new("Click a key on the keyboard to see its shortcuts").color(t.text_dim)); + } + }); + // assign the selected command to the clicked key with the selected modifiers + if let (Some(k), Some(cmd)) = (key, app.shortcut_editor.selected.clone()) { + let keys = format!("{}{}", app.shortcut_editor.mods.prefix(), k); + let disp = Chord::parse(&keys).map(|c| c.display(plat)).unwrap_or(keys.clone()); + let label = format!("Assign {disp} to {}", label_of(app, &cmd)); + if small_button(app, ui, "shortcuts.assignKey", &label, true) { + assign(app, &cmd, &keys, false); + } + } +} + +/// Bottom row: messages / conflicts, Undo / Redo / Clear, Cancel / OK. Returns Some(ok) to close. +fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) -> Option { + let plat = Platform::current(); + let conflicts = app.session.shortcuts.conflicts(plat); + let mut close = None; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width(ui.available_width() - 420.0); + for (ctx, keys, cmds) in conflicts.iter().take(2) { + let disp = Chord::parse(keys).map(|c| c.display(plat)).unwrap_or(keys.clone()); + let names: Vec = cmds.iter().map(|c| label_of(app, c)).collect(); + let r = ui.label(RichText::new(format!("Conflict: {disp} is assigned to {} ({ctx})", names.join(" and "))).size(12.0).color(WARN)); + app.auto.add("shortcuts.conflict", r.rect, "conflict"); + } + let msg = if app.shortcut_editor.message.is_empty() { + "Click a command's Shortcut cell and press keys to assign it; + adds another shortcut.".to_string() + } else { + app.shortcut_editor.message.clone() + }; + let r = ui.label(RichText::new(msg.clone()).size(12.0).color(t.text_dim)); + app.auto.add("shortcuts.message", r.rect, &msg); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let ok = + ui.add(egui::Button::new(RichText::new("OK").size(13.0).color(Color32::WHITE)).fill(t.accent).min_size(vec2(72.0, 28.0)).corner_radius(14.0)); + app.auto.add("shortcuts.ok", ok.rect, "OK"); + if ok.clicked() { + close = Some(true); + } + let cancel = ui.add(egui::Button::new(RichText::new("Cancel").size(13.0)).min_size(vec2(72.0, 28.0)).corner_radius(14.0)); + app.auto.add("shortcuts.cancel", cancel.rect, "Cancel"); + if cancel.clicked() { + close = Some(false); + } + ui.add_space(16.0); + let sel = app.shortcut_editor.selected.clone(); + let panel = app.shortcut_editor.panel().map(str::to_string); + if small_button(app, ui, "shortcuts.clear", "Clear", sel.is_some()) + && let Some(c) = sel + && let Some(r) = exec(app, "shortcuts.clear", json!({"command": c, "panel": panel.clone().unwrap_or_else(|| APPLICATION.into())})) + { + app.shortcut_editor.message = format!("Cleared {} shortcut(s) of {}", r["removed"], label_of(app, &c)); + } + let (cu, cr) = (app.session.shortcuts.can_undo(), app.session.shortcuts.can_redo()); + if small_button(app, ui, "shortcuts.redo", "Redo", cr) { + exec(app, "shortcuts.redo", json!({})); + } + if small_button(app, ui, "shortcuts.undo", "Undo", cu) { + exec(app, "shortcuts.undo", json!({})); + } + }); + }); + close +} diff --git a/crates/ui-egui/tests/scripted.rs b/crates/ui-egui/tests/scripted.rs index 98e5f7f58..86d9cc4cd 100644 --- a/crates/ui-egui/tests/scripted.rs +++ b/crates/ui-egui/tests/scripted.rs @@ -336,4 +336,109 @@ fn screenshots() { d.screenshot("trim-monitor-dynamic-roll"); d.ok("ui.key", json!({"key": "K"})); d.frames(2); + d.exec("trim.clear", json!({})); + // Keyboard Shortcuts dialog: Premiere-compatible preset, Timeline panel context, key K selected + d.ok("ui.key", json!({"key": "Cmd+Alt+K"})); + d.frames(2); + d.screenshot("shortcuts-dialog"); + d.ok("ui.click", json!({"id": "shortcuts.context"})); + d.frames(2); + d.ok("ui.click", json!({"id": "shortcuts.context.Timeline"})); + d.ok("ui.click", json!({"id": "shortcuts.key.K"})); + d.ok("ui.click", json!({"id": "shortcuts.search"})); + d.ok("ui.type", json!({"text": "trim"})); + d.frames(2); + d.screenshot("shortcuts-dialog-timeline-trim"); + d.ok("ui.click", json!({"id": "shortcuts.mod.Cmd"})); + d.frames(2); + d.screenshot("shortcuts-dialog-cmd"); +} + +#[test] +fn keyboard_shortcuts_dialog_assigns_live_and_cancel_restores() { + let mut d = Driver::demo(); + let menu_shortcut = |d: &mut Driver, id: &str| -> Value { + let items = d.ok("ui.menu.list", json!({})); + items.as_array().unwrap().iter().find(|i| i["id"] == id).map(|i| i["shortcut"].clone()).unwrap_or(Value::Null) + }; + assert_eq!(menu_shortcut(&mut d, "sequence.addEdit"), json!("Cmd+K")); + assert_eq!(menu_shortcut(&mut d, "app.keyboardShortcuts"), json!("Cmd+Alt+K")); + // Edit ▸ Keyboard Shortcuts… by its shortcut + d.ok("ui.key", json!({"key": "Cmd+Alt+K"})); + d.frames(2); + assert_eq!(d.inspect()["dialog"], json!("Shortcuts")); + let keys = d.element_ids("shortcuts.key."); + assert!(keys.len() > 60, "drawn keyboard: {}", keys.len()); + // search, then click the Shortcut cell and press new keys + d.ok("ui.click", json!({"id": "shortcuts.search"})); + d.ok("ui.type", json!({"text": "add edit"})); + d.frames(2); + assert!(d.element_ids("shortcuts.row.").iter().any(|x| x == "shortcuts.row.sequence.addEdit")); + d.ok("ui.click", json!({"id": "shortcuts.cell.sequence.addEdit"})); + d.frames(1); + d.ok("ui.key", json!({"key": "Cmd+Shift+J"})); + d.frames(2); + assert_eq!(menu_shortcut(&mut d, "sequence.addEdit"), json!("Cmd+Shift+J"), "the menu shows the new key live"); + let cmds = d.ok("engine.commands", json!({})); + let ae = cmds.as_array().unwrap().iter().find(|c| c["id"] == "sequence.addEdit").unwrap().clone(); + assert_eq!(ae["shortcut"], json!("Cmd+Shift+J"), "command list too: {ae}"); + // reassigning a used key takes it from the other command (and says so) + d.ok("ui.click", json!({"id": "shortcuts.cell.sequence.addEdit"})); + d.frames(1); + d.ok("ui.key", json!({"key": "Cmd+Shift+K"})); + d.frames(2); + assert_eq!(menu_shortcut(&mut d, "sequence.addEditAllTracks"), Value::Null); + let msg = d.element_ids("shortcuts.message"); + assert_eq!(msg.len(), 1); + // Undo / Redo inside the dialog + d.ok("ui.click", json!({"id": "shortcuts.undo"})); + d.frames(1); + assert_eq!(menu_shortcut(&mut d, "sequence.addEditAllTracks"), json!("Cmd+Shift+K")); + assert_eq!(menu_shortcut(&mut d, "sequence.addEdit"), json!("Cmd+Shift+J")); + d.ok("ui.click", json!({"id": "shortcuts.redo"})); + d.frames(1); + assert_eq!(menu_shortcut(&mut d, "sequence.addEdit"), json!("Cmd+Shift+K")); + // Cancel restores everything + d.ok("ui.click", json!({"id": "shortcuts.cancel"})); + d.frames(2); + assert!(d.inspect()["dialog"].is_null()); + assert_eq!(menu_shortcut(&mut d, "sequence.addEdit"), json!("Cmd+K")); + assert_eq!(menu_shortcut(&mut d, "sequence.addEditAllTracks"), json!("Cmd+Shift+K")); + + // Assign + OK, then the new key really runs the command + d.ok("ui.menu.invoke", json!({"id": "app.keyboardShortcuts"})); + d.frames(2); + d.ok("ui.click", json!({"id": "shortcuts.search"})); + d.ok("ui.type", json!({"text": "add edit"})); + d.frames(2); + d.ok("ui.click", json!({"id": "shortcuts.add.sequence.addEdit"})); + d.frames(1); + d.ok("ui.key", json!({"key": "Cmd+Shift+J"})); + d.frames(2); + d.ok("ui.click", json!({"id": "shortcuts.ok"})); + d.frames(2); + let n0 = track_clips(&d.sequence(), 0).len(); + d.exec("playhead.set", json!({"seconds": 2.0})); + d.ok("ui.set", json!({"focused": "Timeline"})); + d.ok("ui.key", json!({"key": "Cmd+Shift+J"})); + d.frames(2); + assert_eq!(track_clips(&d.sequence(), 0).len(), n0 + 1, "Cmd+Shift+J adds an edit"); + d.ok("ui.key", json!({"key": "Cmd+K"})); + d.frames(2); + d.exec("playhead.set", json!({"seconds": 3.0})); + d.ok("ui.key", json!({"key": "Cmd+K"})); + d.frames(2); + assert_eq!(track_clips(&d.sequence(), 0).len(), n0 + 2, "the original Cmd+K was kept (added, not replaced)"); + + // Panel-specific: Left in the History panel is Undo; in the Timeline it steps back + d.ok("ui.set", json!({"focused": "History"})); + d.ok("ui.key", json!({"key": "Left"})); + d.frames(2); + assert_eq!(track_clips(&d.sequence(), 0).len(), n0 + 1, "History ▸ Left = Step Backward (undo)"); + d.ok("ui.set", json!({"focused": "Timeline"})); + let ph = d.sequence()["playhead"].as_i64().unwrap(); + d.ok("ui.key", json!({"key": "Left"})); + d.frames(2); + assert!(d.sequence()["playhead"].as_i64().unwrap() < ph, "Timeline ▸ Left steps back"); + assert_eq!(track_clips(&d.sequence(), 0).len(), n0 + 1); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ecf201357..7a476a739 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -34,6 +34,20 @@ Element ids are stable, e.g. `timeline.clip.`, `timeline.track.V1.lock`, `to Project files, auto-save, crash recovery and preferences commands (`file.recover`, `prefs.set`, …) and their automation ids are listed in [project-files.md](project-files.md). +**Trim mode** (`crates/engine/src/trim.rs`): `trim.selectEditPoint` / `trim.selectNearest` enter trim +mode (the Program monitor becomes the Trim Monitor, ids `trimMonitor.*`); `trim.monitor` returns what +it shows. Dynamic trimming takes an explicit clock (seconds) so it is deterministic: `trim.shuttle +{direction, slow?, clock}` (J/L), `trim.tick {clock}`, `trim.shuttleStop {clock?}` (K, one undo step), +`trim.cancelDynamic` (Esc), `trim.playAround {clock, loop?}` (Space / Shift+K). + +**Keyboard shortcuts** (`crates/engine/src/shortcuts.rs`): `shortcuts.list {query?, panel?}`, +`shortcuts.get`, `shortcuts.set {command, keys, panel?, add?, keepConflicts?}`, `shortcuts.clear`, +`shortcuts.undo` / `shortcuts.redo`, `shortcuts.conflicts {platform?}`, `shortcuts.forKey {key}`, +`shortcuts.resolve {keys, panel?}`, `shortcuts.presets`, `shortcuts.loadPreset` / `savePreset` / +`deletePreset {name}`, `shortcuts.export` / `import {path}`, `shortcuts.audit`. Keys use `Cmd` (⌘ / +Ctrl), `Ctrl` (macOS ⌃), `Alt`, `Shift`. The dialog (Edit ▸ Keyboard Shortcuts…, ⌥⌘K) uses ids +`shortcuts.*` (`shortcuts.key.K`, `shortcuts.cell.`, `shortcuts.ok`, …). + ## MCP `filmcraft-cli mcp` serves MCP on stdio: headless (in-process session; `--demo` / `--project p.fcproj`) or `--bridge 127.0.0.1:9876` to drive the running app. Tools: `command_list`, `command_run`, From daaee5072c92c30d85ed8d50b1fe2057da8c085e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 01:57:18 -0400 Subject: [PATCH 19/24] M10.2: Type tool with on-monitor text editing, shape and pen tools, layer boxes and handles, graphic editor in Properties / Essential Graphics - Program monitor overlay for graphic clips: Type tool click creates a text layer (in the selected graphic under the playhead or a new clip) and edits it in place with a caret, selection highlight, arrows/word/line navigation, Shift-select, Cmd+A/C/X/V, Return, Backspace/Delete, Esc; typing is one undo step. Selection tool selects, moves and scales layers (bounding box, handles, anchor); double-click edits text. Rectangle/Ellipse tools drag out shapes; Pen tool places a closed path. Automation ids program.layer.*. - Properties (when a graphic is selected) and Essential Graphics: Layers list, Align and Transform (align / distribute), Text (source text, font family + style incl. system fonts, size, paragraph alignment, tracking, leading, baseline shift, box width, faux bold/italic, caps, underline, kerning/ligatures), Shape, Appearance (fill, 2 strokes, background, shadow). Automation ids graphics.*. - Headless UI test (egui_kittest) drives the whole flow through the control channel; with FILMCRAFT_UI_SHOTS it renders PNGs via wgpu (works with a locked screen). - docs/graphics.md; architecture, ROADMAP, ATTRIBUTION (code-drawn glyphs) updated. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + Cargo.lock | 4 + ROADMAP.md | 2 +- crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/panels/graphics.rs | 1243 +++++++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 3 + crates/ui-egui/src/panels/monitor.rs | 3 + crates/ui-egui/src/state.rs | 18 + crates/ui-egui/tests/graphics_ui.rs | 154 +++ docs/architecture.md | 4 +- docs/graphics.md | 92 ++ 11 files changed, 1523 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/panels/graphics.rs create mode 100644 crates/ui-egui/tests/graphics_ui.rs create mode 100644 docs/graphics.md diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5374a629a..38a3d624e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -67,6 +67,7 @@ is traced or derived from Adobe artwork. | Asset | Where | |---|---| | UI icons (tools, transport, panels, header) | `crates/ui-egui/src/icons.rs`: vector paths on a 16×16 grid | +| Align / distribute / paragraph-alignment glyphs in the graphics panel | `crates/ui-egui/src/panels/graphics.rs` (`align_glyph`): bars and lines drawn with the egui painter | | Demo footage (ocean sunset, aurora, city night, dunes, forest, plasma), bars and tone, counting leader, colour matte | `crates/media/src/generators.rs` | | Lumetri "Look" presets (Teal & Orange, Warm Film, …) | `crates/render/src/effects.rs` (`apply_look`): procedural colour transforms, not LUT files | | Colour palette and layout metrics | `crates/ui-egui/src/theme.rs`: colour values and sizes only; no artwork | diff --git a/Cargo.lock b/Cargo.lock index 341e8fc68..bb9c52552 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1111,10 +1111,14 @@ checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" dependencies = [ "eframe", "egui", + "egui-wgpu", + "image", "kittest", "log", + "pollster 1.0.1", "serde", "toml 1.1.6+spec-1.1.0", + "wgpu", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index f86088ad3..39b41c432 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,7 +38,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining | M7 | Audio | 🟡 | Mixer basics, peak meters, BS.1770 loudness meters (M/S/I LUFS, true peak; matches ffmpeg), DSP crate, 12 clip audio effects running on it (EQ, filters, dynamics, limiter, delay, reverb, DeNoise, DeHum, pitch) with keyframes | Effects UI polish (EQ curve), remaining effects (multiband, convolution reverb), track effects/mixers with automation, Essential Sound, audio transitions, 5.1 | 6–10 | | M8 | Colour | 🟡 | Lumetri: basic, creative + looks, RGB & hue curves, wheels, HSL secondary, vignette; basic scopes | LUT import UI, colour match, colour management + HDR (PQ/HLG, log, tone mapping) | 8–12 | | M9 | More codecs | 🟡 | ProRes decode+encode, AAC decode+encode, HEVC Main/Main 10 decoder (bit-exact on 41 fixtures, ~225 fps 1080p), Matroska/WebM import (H.264/HEVC/ProRes/MJPEG + AAC/Opus/FLAC/MP3/Vorbis/PCM), Opus decoder (SILK/CELT/hybrid, 5.1/7.1 multistream; all RFC 8251 vectors range-exact; WebM/MKV/MP4) | VP9 (in progress), Ogg Opus files, AV1 (rav1d), DNxHR, MXF, hardware decode | 12–20 | -| M10 | Graphics & captions | 🟡 | Caption tracks (Subtitle/CEA-608/708/Teletext formats, track style), SRT/WebVTT/SCC import+export (frame-exact, property-tested), caption editing (add/split/merge/trim/move, sync-locked insert/extract), Text panel Captions tab, burn-in in Program monitor and export | Text engine with shaping, Type tool, Essential Graphics, rolls/crawls, MCC/STL/TTML, 608/708 embedding, speech-to-text | 18–27 | +| M10 | Graphics & captions | 🟡 | Caption tracks (Subtitle/CEA-608/708/Teletext formats, track style), SRT/WebVTT/SCC import+export (frame-exact, property-tested), caption editing (add/split/merge/trim/move, sync-locked insert/extract), Text panel Captions tab, burn-in in Program monitor and export; text engine (`crates/text`: bundled + system fonts, harfrust shaping, bidi, line breaking, paragraph layout, glyph cache; 3-line 1080p title ≈ 0.15 ms warm); graphic clips with text + shape layers (fill, 2 strokes, background, shadow, keyframable transform), Type tool with on-monitor editing, shape/pen tools, Properties/Essential Graphics editor, align/distribute | Responsive design pins, rolls/crawls, per-character styles, motion graphics templates, MCC/STL/TTML, 608/708 embedding, speech-to-text | 12–18 | | M11 | Interchange & project management | 🟡 | `.fcproj` schema versions + migrations, atomic saves, Save a Copy/Revert, auto-save ring + crash-recovery journal (Preferences ▸ Auto Save, recovery prompt), FCP7 XML, FCPXML, EDL, OTIO | Relink/offline, project manager, proxies | 8 | | M12–M16 | Web (WASM), platform, long tail | 🟡 | L0–L4 crates compile to wasm32 | Web app shell (file access, WebCodecs, audio), ~850 remaining commands and dialogs, performance hardening | 40–65 | diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 32f88ec17..27d7389cb 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -30,4 +30,4 @@ log = { workspace = true } rayon = { workspace = true } [dev-dependencies] -egui_kittest = { workspace = true, features = ["eframe"] } +egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs new file mode 100644 index 000000000..14976a2f9 --- /dev/null +++ b/crates/ui-egui/src/panels/graphics.rs @@ -0,0 +1,1243 @@ +//! Graphics UI (M10.2): the Program-monitor overlay for graphic clips and the graphic sections of +//! the Properties / Essential Graphics panels. +//! +//! Monitor overlay: +//! - **Type tool (T):** click on empty picture → a new text layer (in the selected graphic clip +//! under the playhead, else a new graphic clip) with a caret; click on a text layer → caret at +//! the click. Typing edits the text (one undo step per typing session); arrows, Home/End, +//! Shift-selection, ⌘A, ⌘C/⌘X/⌘V, Return (new line) and Esc (stop editing) work as in a text +//! field. Double-clicking a text layer with the Selection tool also edits it. +//! - **Selection tool:** click selects a layer (bounding box with handles); drag moves it; drag a +//! corner handle scales it. +//! - **Rectangle / Ellipse tools:** drag out a shape. **Pen tool:** click points; click the first +//! point again (or press Return) to close the path; Esc cancels. +//! +//! All edits go through `graphics.*` commands. Automation ids: `program.layer..`, +//! `program.layer...handle.`, `program.textEdit`, `graphics.*` in the panels. + +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use filmcraft_geom::{Affine, Vec2}; +use filmcraft_project::graphic::{self, LayerContent, LayerSpec, eval_layer, layer_display_name, layer_indices}; +use filmcraft_project::{ClipId, EffectInstance, ItemKind, ParamValue, TrackItem}; +use filmcraft_render::graphic_clip::{layer_local_bounds, layer_matrix, text_layout}; +use filmcraft_time::Tick; +use serde_json::{Value, json}; + +use crate::FilmcraftApp; +use crate::icons::{self, Icon}; +use crate::state::{GfxEdit, Tool}; +use crate::theme::Tokens; + +/// A graphic layer visible at the playhead, with its layer→screen transform. +pub struct LayerView { + pub clip: ClipId, + pub layer: usize, + pub spec: LayerSpec, + pub to_screen: Affine, + /// Graphic canvas → screen (the clip's Motion and the monitor fit). + pub canvas_to_screen: Affine, + pub local: [f32; 4], +} + +impl LayerView { + pub fn quad(&self) -> [Pos2; 4] { + let b = self.local; + [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].map(|(x, y)| sp(&self.to_screen, x, y)) + } + fn to_local(&self, p: Pos2) -> Option<(f32, f32)> { + let v = self.to_screen.inverse()?.apply(Vec2::new(p.x as f64, p.y as f64)); + Some((v.x as f32, v.y as f32)) + } + fn hit(&self, p: Pos2) -> bool { + let pad = 4.0 / screen_scale(&self.to_screen).max(1e-6); + self.to_local(p).is_some_and(|(x, y)| x >= self.local[0] - pad && x <= self.local[2] + pad && y >= self.local[1] - pad && y <= self.local[3] + pad) + } + fn is_text(&self) -> bool { + matches!(self.spec.content, LayerContent::Text(_)) + } +} + +fn sp(a: &Affine, x: f32, y: f32) -> Pos2 { + let v = a.apply(Vec2::new(x as f64, y as f64)); + pos2(v.x as f32, v.y as f32) +} + +fn screen_scale(a: &Affine) -> f32 { + (a.a * a.d - a.b * a.c).abs().sqrt() as f32 +} + +/// Whether a clip id is a graphic clip in the active sequence. +pub fn is_graphic_clip(app: &FilmcraftApp, c: ClipId) -> bool { + app.session + .active_sequence() + .and_then(|q| q.find_item(c)) + .and_then(|(_, it)| app.session.project.item(it.item)) + .is_some_and(|p| matches!(p.kind, ItemKind::Graphic { .. })) +} + +/// The selected graphic clip, if any. +pub fn selected_graphic(app: &FilmcraftApp) -> Option<(ClipId, TrackItem)> { + let q = app.session.active_sequence()?; + app.session.state.selection.iter().copied().find(|c| is_graphic_clip(app, *c)).and_then(|c| q.find_item(c).map(|(_, it)| (c, it.clone()))) +} + +/// Layers of the graphic clips visible at the playhead, front-most first. +pub fn visible_layers(app: &FilmcraftApp, pic: Rect, frame: (u32, u32)) -> Vec { + let Some(seq) = app.session.active_sequence() else { return Vec::new() }; + let t = app.session.playhead(); + let k = pic.width() as f64 / frame.0.max(1) as f64; + let screen = Affine::translate(pic.min.x as f64, pic.min.y as f64).then_apply(&Affine::scale(k, k)); + let mut out = Vec::new(); + for tr in seq.video_tracks.iter().rev() { + if !tr.enabled { + continue; + } + let Some(it) = tr.item_at(t) else { continue }; + if !it.enabled { + continue; + } + let Some(ItemKind::Graphic { width, height, .. }) = app.session.project.item(it.item).map(|p| &p.kind) else { continue }; + let size = (*width, *height); + let mt = it.source_time_at(t); + let base = screen.then_apply(&filmcraft_render::motion_matrix(seq, it, size, mt)); + let idx = layer_indices(&it.effects); + for (li, &ei) in idx.iter().enumerate().rev() { + if let Some(spec) = eval_layer(&it.effects[ei], mt, size) { + let local = layer_local_bounds(&spec); + out.push(LayerView { clip: it.id, layer: li, to_screen: base.then_apply(&layer_matrix(&spec)), canvas_to_screen: base, spec, local }); + } + } + } + out +} + +/// Screen → graphic-canvas mapping for new layers: the selected graphic clip under the playhead +/// (its Motion applies), else the sequence frame. +fn canvas_target(app: &FilmcraftApp, pic: Rect, frame: (u32, u32)) -> (Option, Affine) { + let k = pic.width() as f64 / frame.0.max(1) as f64; + let screen = Affine::translate(pic.min.x as f64, pic.min.y as f64).then_apply(&Affine::scale(k, k)); + let t = app.session.playhead(); + if let Some((c, it)) = selected_graphic(app) + && it.range().contains(t) + && let Some(seq) = app.session.active_sequence() + && let Some(ItemKind::Graphic { width, height, .. }) = app.session.project.item(it.item).map(|p| &p.kind) + { + let m = screen.then_apply(&filmcraft_render::motion_matrix(seq, &it, (*width, *height), it.source_time_at(t))); + return (Some(c), m); + } + (None, screen) +} + +fn to_canvas(m: &Affine, p: Pos2) -> Vec2 { + m.inverse().map(|i| i.apply(Vec2::new(p.x as f64, p.y as f64))).unwrap_or_default() +} + +fn text_of(spec: &LayerSpec) -> Option<&filmcraft_project::graphic::TextProps> { + match &spec.content { + LayerContent::Text(t) => Some(t), + _ => None, + } +} + +fn prev_boundary(s: &str, i: usize) -> usize { + s[..i.min(s.len())].char_indices().next_back().map_or(0, |(b, _)| b) +} +fn next_boundary(s: &str, i: usize) -> usize { + let i = i.min(s.len()); + s[i..].chars().next().map_or(i, |c| i + c.len_utf8()) +} + +#[derive(Clone, Copy, Debug)] +enum DragKind { + Move, + /// A corner handle: scales about the layer's anchor. + Scale, + NewShape, + TextSelect, +} + +#[derive(Clone, Copy, Debug)] +struct DragState { + kind: DragKind, + clip: ClipId, + layer: usize, + start: Pos2, +} + +const TEXT_EDIT_ID: &str = "gfx-text-edit"; + +fn end_edit(app: &mut FilmcraftApp, ui: &egui::Ui) { + app.ui.gfx_edit = None; + ui.memory_mut(|m| m.surrender_focus(egui::Id::new(TEXT_EDIT_ID))); +} + +/// Draw the overlay and handle graphics tools on the Program monitor picture `pic`. +pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32, u32)) { + let t = app.tokens; + let views = visible_layers(app, pic, frame); + let tool = app.ui.tool; + let accent = t.accent; + let painter = ui.painter().with_clip_rect(pic.expand(12.0)); + for v in &views { + let q = v.quad(); + let r = Rect::from_points(&q); + let name = layer_display_name_view(app, v); + app.auto.add(&format!("program.layer.{}.{}", v.clip.0, v.layer), r, &name); + } + let sel_clip = selected_graphic(app).map(|(c, _)| c); + let sel_layers = app.session.state.graphic_layers.clone(); + let editing = app.ui.gfx_edit.clone(); + // ---- boxes and handles + let graphics_tool = matches!(tool, Tool::Selection | Tool::Type | Tool::Rectangle | Tool::Ellipse | Tool::Pen); + if graphics_tool { + for v in views.iter().filter(|v| Some(v.clip) == sel_clip) { + let q = v.quad(); + let selected = sel_layers.contains(&v.layer); + let is_edit = editing.as_ref().is_some_and(|e| e.clip == v.clip.0 && e.layer == v.layer); + let col = if selected || is_edit { accent } else { Color32::from_white_alpha(70) }; + painter.add(egui::Shape::closed_line(q.to_vec(), Stroke::new(1.0, col))); + if selected && !is_edit { + for (n, c) in q.iter().enumerate() { + let hr = Rect::from_center_size(*c, vec2(7.0, 7.0)); + painter.rect_filled(hr, 0.0, Color32::WHITE); + painter.rect_stroke(hr, 0.0, Stroke::new(1.0, accent), StrokeKind::Middle); + app.auto.add(&format!("program.layer.{}.{}.handle.{n}", v.clip.0, v.layer), hr.expand(3.0), "scale handle"); + } + for i in 0..4 { + let m = q[i] + (q[(i + 1) % 4] - q[i]) * 0.5; + painter.rect_filled(Rect::from_center_size(m, vec2(5.0, 5.0)), 0.0, Color32::WHITE); + } + // anchor point + let a = sp(&v.to_screen, v.spec.transform.anchor.x as f32, v.spec.transform.anchor.y as f32); + painter.circle_stroke(a, 4.0, Stroke::new(1.0, accent)); + painter.line_segment([a - vec2(6.0, 0.0), a + vec2(6.0, 0.0)], Stroke::new(1.0, accent)); + painter.line_segment([a - vec2(0.0, 6.0), a + vec2(0.0, 6.0)], Stroke::new(1.0, accent)); + } + } + } + let id = egui::Id::new("gfx-overlay"); + let resp = ui.interact(pic, id, if graphics_tool { Sense::click_and_drag() } else { Sense::hover() }); + let hover = resp.hover_pos(); + let mut actions: Vec<(String, Value)> = Vec::new(); + let drag_id = id.with("drag"); + let mut drag: Option = ui.data(|d| d.get_temp(drag_id)); + + // cursors + if let Some(p) = hover { + match tool { + Tool::Type => ui.ctx().set_cursor_icon(egui::CursorIcon::Text), + Tool::Rectangle | Tool::Ellipse | Tool::Pen => ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair), + Tool::Selection => { + if editing.as_ref().is_some_and(|e| views.iter().any(|v| v.clip.0 == e.clip && v.layer == e.layer && v.hit(p))) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } else if views.iter().any(|v| v.hit(p)) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Move); + } + } + _ => {} + } + } + + // ---- text editing + if let Some(ed) = editing.clone() { + match views.iter().find(|v| v.clip.0 == ed.clip && v.layer == ed.layer && v.is_text()) { + Some(v) => text_edit(app, ui, v, ed, &resp, &mut actions), + None => end_edit(app, ui), + } + } + let editing = app.ui.gfx_edit.clone(); + let edit_view = editing.as_ref().and_then(|e| views.iter().find(|v| v.clip.0 == e.clip && v.layer == e.layer)); + + // ---- presses + if graphics_tool && resp.drag_started() { + let p = resp.interact_pointer_pos().unwrap_or(pic.center()); + let shift = ui.input(|i| i.modifiers.shift); + let in_edit = edit_view.is_some_and(|v| v.hit(p)); + drag = None; + if in_edit { + if let (Some(v), Some(mut ed)) = (edit_view, editing.clone()) { + let l = text_layout(text_of(&v.spec).expect("text")); + if let Some((x, y)) = v.to_local(p) { + ed.caret = l.hit(x, y); + if !shift { + ed.anchor = ed.caret; + } + app.ui.gfx_edit = Some(ed); + } + drag = Some(DragState { kind: DragKind::TextSelect, clip: v.clip, layer: v.layer, start: p }); + } + } else { + match tool { + Tool::Selection | Tool::Type => { + // handles of the selected layers first + let handle = views + .iter() + .filter(|v| Some(v.clip) == sel_clip && sel_layers.contains(&v.layer)) + .find_map(|v| v.quad().iter().position(|c| (*c - p).length() <= 7.0).map(|_| (v.clip, v.layer))); + if let Some((c, l)) = handle { + drag = Some(DragState { kind: DragKind::Scale, clip: c, layer: l, start: p }); + } else if tool == Tool::Selection + && let Some(v) = views.iter().find(|v| v.hit(p)) + { + if editing.is_some() { + end_edit(app, ui); + } + if !(Some(v.clip) == sel_clip && sel_layers.contains(&v.layer)) { + actions.push(("graphics.selectLayer".into(), json!({"clip": v.clip.0, "layers": [v.layer]}))); + } + drag = Some(DragState { kind: DragKind::Move, clip: v.clip, layer: v.layer, start: p }); + } + } + Tool::Rectangle | Tool::Ellipse => { + drag = Some(DragState { kind: DragKind::NewShape, clip: ClipId(0), layer: 0, start: p }); + } + _ => {} + } + } + ui.data_mut(|dd| { + if let Some(d) = drag { + dd.insert_temp(drag_id, d); + } else { + dd.remove::(drag_id); + } + }); + } + // ---- drag feedback / commit + if let Some(d) = drag { + let cur = resp.interact_pointer_pos().or(hover).unwrap_or(d.start); + let v = views.iter().find(|v| v.clip == d.clip && v.layer == d.layer); + match d.kind { + DragKind::Move => { + if let Some(v) = v { + let off = cur - d.start; + painter.add(egui::Shape::closed_line(v.quad().iter().map(|q| *q + off).collect(), Stroke::new(1.0, Color32::WHITE))); + } + } + DragKind::Scale => { + if let Some(v) = v { + let a = sp(&v.to_screen, v.spec.transform.anchor.x as f32, v.spec.transform.anchor.y as f32); + let f = ((cur - a).length() / (d.start - a).length().max(1.0)).max(0.01); + painter.add(egui::Shape::closed_line(v.quad().iter().map(|q| a + (*q - a) * f).collect(), Stroke::new(1.0, Color32::WHITE))); + } + } + DragKind::NewShape => { + let r = Rect::from_two_pos(d.start, cur); + if tool == Tool::Ellipse { + painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, Stroke::new(1.0, Color32::WHITE))); + } else { + painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle); + } + } + DragKind::TextSelect => { + if let (Some(v), Some(mut ed)) = (v, app.ui.gfx_edit.clone()) { + let l = text_layout(text_of(&v.spec).expect("text")); + if let Some((x, y)) = v.to_local(cur) { + ed.caret = l.hit(x, y); + app.ui.gfx_edit = Some(ed); + } + } + } + } + if resp.drag_stopped() { + ui.data_mut(|dd| dd.remove::(drag_id)); + let moved = (cur - d.start).length() > 2.0; + match d.kind { + DragKind::Move if moved => { + if let Some(v) = v { + let lin = Affine { e: 0.0, f: 0.0, ..v.canvas_to_screen }; + let delta = lin.inverse().map(|i| i.apply(Vec2::new((cur.x - d.start.x) as f64, (cur.y - d.start.y) as f64))).unwrap_or_default(); + let p0 = v.spec.transform.position; + actions.push(( + "graphics.set".into(), + json!({"clip": v.clip.0, "layer": v.layer, "props": {"position": [p0.x + delta.x, p0.y + delta.y]}}), + )); + } + } + DragKind::Scale if moved => { + if let Some(v) = v { + let a = sp(&v.to_screen, v.spec.transform.anchor.x as f32, v.spec.transform.anchor.y as f32); + let f = ((cur - a).length() / (d.start - a).length().max(1.0)).max(0.01) as f64; + let s = v.spec.transform.scale; + actions.push(( + "graphics.set".into(), + json!({"clip": v.clip.0, "layer": v.layer, "props": {"scale": s.y * 100.0 * f, "scale_width": s.x * 100.0 * f}}), + )); + } + } + DragKind::NewShape if moved => { + let (into, m) = canvas_target(app, pic, frame); + let (a, b) = (to_canvas(&m, d.start), to_canvas(&m, cur)); + let c = Vec2::new((a.x + b.x) / 2.0, (a.y + b.y) / 2.0); + let mut p = json!({ + "shape": if tool == Tool::Ellipse { "ellipse" } else { "rectangle" }, + "position": [c.x, c.y], + "size": [(b.x - a.x).abs(), (b.y - a.y).abs()], + }); + if let Some(cl) = into { + p["clip"] = json!(cl.0); + } + actions.push(("graphics.newShape".into(), p)); + } + _ => {} + } + } + } + // ---- clicks + if graphics_tool && resp.clicked() && drag.is_none_or(|d| !matches!(d.kind, DragKind::TextSelect)) { + let p = resp.interact_pointer_pos().unwrap_or(pic.center()); + let in_edit = edit_view.is_some_and(|v| v.hit(p)); + match tool { + _ if in_edit => {} + Tool::Type => { + if let Some(v) = views.iter().find(|v| v.is_text() && v.hit(p)) { + start_edit_at(app, ui, v, p, &mut actions); + } else { + end_edit(app, ui); + let (into, m) = canvas_target(app, pic, frame); + let c = to_canvas(&m, p); + let size = 100.0 * frame.1 as f64 / 1080.0; + let mut prm = json!({"text": "", "position": [c.x, c.y], "size": size.round()}); + if let Some(cl) = into { + prm["clip"] = json!(cl.0); + } + actions.push(("graphics.newText#edit".into(), prm)); + } + } + Tool::Selection => { + if resp.double_clicked() { + if let Some(v) = views.iter().find(|v| v.is_text() && v.hit(p)) { + start_edit_at(app, ui, v, p, &mut actions); + } + } else if let Some(v) = views.iter().find(|v| v.hit(p)) { + if app.ui.gfx_edit.is_some() { + end_edit(app, ui); + } + actions.push(("graphics.selectLayer".into(), json!({"clip": v.clip.0, "layers": [v.layer]}))); + } else if app.ui.gfx_edit.is_some() { + end_edit(app, ui); + } else if sel_clip.is_some() && !sel_layers.is_empty() { + app.session.state.graphic_layers.clear(); + } + } + Tool::Pen => { + let (_, m) = canvas_target(app, pic, frame); + let c = to_canvas(&m, p); + let close = app.ui.pen_points.len() >= 3 + && app.ui.pen_points.first().is_some_and(|f| { + let fs = m.apply(Vec2::new(f[0], f[1])); + (pos2(fs.x as f32, fs.y as f32) - p).length() <= 8.0 + }); + if close { + finish_pen(app, pic, frame, &mut actions); + } else { + app.ui.pen_points.push([c.x, c.y]); + } + } + _ => {} + } + } + // ---- pen path in progress + if tool == Tool::Pen && !app.ui.pen_points.is_empty() { + let (_, m) = canvas_target(app, pic, frame); + let pts: Vec = app.ui.pen_points.iter().map(|q| m.apply(Vec2::new(q[0], q[1]))).map(|v| pos2(v.x as f32, v.y as f32)).collect(); + painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, accent))); + if let (Some(last), Some(h)) = (pts.last(), hover) { + painter.line_segment([*last, h], Stroke::new(1.0, accent.gamma_multiply(0.5))); + } + for (i, q) in pts.iter().enumerate() { + painter.rect_filled(Rect::from_center_size(*q, vec2(6.0, 6.0)), 0.0, if i == 0 { accent } else { Color32::WHITE }); + } + let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); + if enter && app.ui.pen_points.len() >= 3 { + finish_pen(app, pic, frame, &mut actions); + } else if esc { + app.ui.pen_points.clear(); + } + } else if tool != Tool::Pen && !app.ui.pen_points.is_empty() { + app.ui.pen_points.clear(); + } + // ---- caret / selection drawing + if let Some(ed) = app.ui.gfx_edit.clone() + && let Some(v) = views.iter().find(|v| v.clip.0 == ed.clip && v.layer == ed.layer) + && let Some(tp) = text_of(&v.spec) + { + let l = text_layout(tp); + let caret = ed.caret.min(tp.text.len()); + for r in l.selection_rects(ed.anchor.min(tp.text.len()), caret) { + let q = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])].map(|(x, y)| sp(&v.to_screen, x, y)); + painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 90), Stroke::NONE)); + } + let (x, base, li) = l.caret(caret); + let line = &l.lines[li.min(l.lines.len() - 1)]; + let (a, b) = (sp(&v.to_screen, x, base - line.ascent), sp(&v.to_screen, x, base + line.descent)); + painter.line_segment([a, b], Stroke::new(1.5, Color32::WHITE)); + painter.line_segment([a + vec2(1.0, 0.0), b + vec2(1.0, 0.0)], Stroke::new(0.5, Color32::BLACK)); + let er = Rect::from_points(&v.quad()).union(Rect::from_two_pos(a, b)); + app.auto.add("program.textEdit", er, &tp.text); + ui.ctx().output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { + purpose: Default::default(), + rect: er, + cursor_rect: Rect::from_two_pos(a, b), + should_interrupt_composition: false, + }) + }); + } + // ---- run + for (cmd, p) in actions { + let (cmd, then_edit) = match cmd.strip_suffix("#edit") { + Some(c) => (c.to_string(), true), + None => (cmd, false), + }; + match app.session.execute(&cmd, p) { + Ok(r) => { + if then_edit && let (Some(c), Some(l)) = (r["clip"].as_u64(), r["layer"].as_u64()) { + app.ui.gfx_edit = Some(GfxEdit { clip: c, layer: l as usize, caret: 0, anchor: 0 }); + app.ui.focused = crate::dock::PanelKind::Program; + } + } + Err(e) => app.ui.status = e.to_string(), + } + } +} + +fn layer_display_name_view(app: &FilmcraftApp, v: &LayerView) -> String { + app.session + .active_sequence() + .and_then(|q| q.find_item(v.clip)) + .and_then(|(_, it)| layer_indices(&it.effects).get(v.layer).map(|&e| layer_display_name(&it.effects[e], v.layer))) + .unwrap_or_default() +} + +fn finish_pen(app: &mut FilmcraftApp, pic: Rect, frame: (u32, u32), actions: &mut Vec<(String, Value)>) { + let pts = std::mem::take(&mut app.ui.pen_points); + if pts.len() < 3 { + return; + } + let n = pts.len() as f64; + let (cx, cy) = (pts.iter().map(|p| p[0]).sum::() / n, pts.iter().map(|p| p[1]).sum::() / n); + let (into, _) = canvas_target(app, pic, frame); + let mut p = json!({ + "shape": "path", + "position": [cx, cy], + "points": pts.iter().map(|q| [q[0] - cx, q[1] - cy]).collect::>(), + }); + if let Some(c) = into { + p["clip"] = json!(c.0); + } + actions.push(("graphics.newShape".into(), p)); +} + +fn start_edit_at(app: &mut FilmcraftApp, ui: &egui::Ui, v: &LayerView, p: Pos2, actions: &mut Vec<(String, Value)>) { + let Some(tp) = text_of(&v.spec) else { return }; + let l = text_layout(tp); + let caret = v.to_local(p).map_or(tp.text.len(), |(x, y)| l.hit(x, y)); + app.ui.gfx_edit = Some(GfxEdit { clip: v.clip.0, layer: v.layer, caret, anchor: caret }); + app.ui.focused = crate::dock::PanelKind::Program; + let _ = ui; + actions.push(("graphics.selectLayer".into(), json!({"clip": v.clip.0, "layers": [v.layer]}))); +} + +/// Keyboard handling while a text layer is being edited. +fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: GfxEdit, resp: &egui::Response, actions: &mut Vec<(String, Value)>) { + let _ = resp; + let eid = egui::Id::new(TEXT_EDIT_ID); + // a real (non-clickable) widget holds keyboard focus so shortcuts pause while typing + let fr = ui.interact(Rect::from_points(&v.quad()), eid, Sense::focusable_noninteractive()); + fr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Text layer")); + ui.memory_mut(|m| { + m.request_focus(eid); + m.set_focus_lock_filter(eid, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }); + }); + let Some(tp) = text_of(&v.spec) else { return }; + let mut text = tp.text.clone(); + ed.caret = ed.caret.min(text.len()); + ed.anchor = ed.anchor.min(text.len()); + let l = text_layout(tp); + let events = ui.input(|i| i.events.clone()); + let mut changed = false; + let mut stop = false; + let sel = |ed: &GfxEdit| (ed.caret.min(ed.anchor), ed.caret.max(ed.anchor)); + let delete_sel = |text: &mut String, ed: &mut GfxEdit| -> bool { + let (a, b) = sel(ed); + if a == b { + return false; + } + text.replace_range(a..b, ""); + ed.caret = a; + ed.anchor = a; + true + }; + for ev in events { + match ev { + egui::Event::Text(s) | egui::Event::Paste(s) => { + let s: String = s.chars().filter(|c| !c.is_control() || *c == '\n').collect(); + if s.is_empty() { + continue; + } + delete_sel(&mut text, &mut ed); + text.insert_str(ed.caret, &s); + ed.caret += s.len(); + ed.anchor = ed.caret; + changed = true; + } + egui::Event::Copy | egui::Event::Cut => { + let (a, b) = sel(&ed); + if a < b { + ui.ctx().copy_text(text[a..b].to_string()); + if matches!(ev, egui::Event::Cut) { + changed |= delete_sel(&mut text, &mut ed); + } + } + } + egui::Event::Key { key, pressed: true, modifiers, .. } => { + let shift = modifiers.shift; + let mut moved = None; + match key { + egui::Key::Escape => stop = true, + egui::Key::Enter => { + delete_sel(&mut text, &mut ed); + text.insert(ed.caret, '\n'); + ed.caret += 1; + ed.anchor = ed.caret; + changed = true; + } + egui::Key::Backspace => { + if !delete_sel(&mut text, &mut ed) && ed.caret > 0 { + let p = if modifiers.alt || modifiers.command { word_left(&text, ed.caret) } else { prev_boundary(&text, ed.caret) }; + text.replace_range(p..ed.caret, ""); + ed.caret = p; + ed.anchor = p; + } + changed = true; + } + egui::Key::Delete => { + if !delete_sel(&mut text, &mut ed) && ed.caret < text.len() { + let n = next_boundary(&text, ed.caret); + text.replace_range(ed.caret..n, ""); + } + changed = true; + } + egui::Key::A if modifiers.command => { + ed.anchor = 0; + ed.caret = text.len(); + } + egui::Key::ArrowLeft => { + let (a, _) = sel(&ed); + moved = Some(if !shift && ed.caret != ed.anchor { + a + } else if modifiers.command { + l.lines[l.caret(ed.caret).2].range.start + } else if modifiers.alt { + word_left(&text, ed.caret) + } else { + prev_boundary(&text, ed.caret) + }); + } + egui::Key::ArrowRight => { + let (_, b) = sel(&ed); + moved = Some(if !shift && ed.caret != ed.anchor { + b + } else if modifiers.command { + l.lines[l.caret(ed.caret).2].range.end + } else if modifiers.alt { + word_right(&text, ed.caret) + } else { + next_boundary(&text, ed.caret) + }); + } + egui::Key::ArrowUp | egui::Key::ArrowDown => { + let (x, _, li) = l.caret(ed.caret); + let target = if key == egui::Key::ArrowUp { li.checked_sub(1) } else { Some(li + 1).filter(|n| *n < l.lines.len()) }; + moved = Some(match target { + Some(n) => l.hit(x, l.lines[n].baseline), + None if key == egui::Key::ArrowUp => 0, + None => text.len(), + }); + } + egui::Key::Home => moved = Some(l.lines[l.caret(ed.caret).2].range.start), + egui::Key::End => moved = Some(l.lines[l.caret(ed.caret).2].range.end), + _ => {} + } + if let Some(m) = moved { + ed.caret = m.min(text.len()); + if !shift { + ed.anchor = ed.caret; + } + } + } + _ => {} + } + } + if changed && text != tp.text { + actions.push(("graphics.setText".into(), json!({"clip": ed.clip, "layer": ed.layer, "text": text, "merge": true}))); + } + if stop { + end_edit(app, ui); + } else { + app.ui.gfx_edit = Some(ed); + } +} + +fn word_left(s: &str, i: usize) -> usize { + let mut j = i; + let b: Vec<(usize, char)> = s[..i].char_indices().collect(); + let mut k = b.len(); + while k > 0 && b[k - 1].1.is_whitespace() { + k -= 1; + } + while k > 0 && !b[k - 1].1.is_whitespace() { + k -= 1; + } + if k < b.len() { + j = b[k].0; + } + if k == 0 { 0 } else { j } +} + +fn word_right(s: &str, i: usize) -> usize { + let mut it = s[i..].char_indices().peekable(); + while it.peek().is_some_and(|(_, c)| c.is_whitespace()) { + it.next(); + } + while it.peek().is_some_and(|(_, c)| !c.is_whitespace()) { + it.next(); + } + it.peek().map_or(s.len(), |(b, _)| i + b) +} + +// --------------------------------------------------------------------------------------------- +// Properties / Essential Graphics +// --------------------------------------------------------------------------------------------- + +const ROW: f32 = 28.0; + +fn pv(e: &EffectInstance, id: &str, mt: Tick) -> ParamValue { + e.params.get(id).map(|p| p.value_at(mt)).or_else(|| e.def().and_then(|d| d.param(id)).map(|d| d.default.clone())).unwrap_or(ParamValue::Float(0.0)) +} + +fn color32(c: [f32; 4]) -> Color32 { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgba_unmultiplied(q(c[0]), q(c[1]), q(c[2]), q(c[3])) +} + +struct Ctx<'a> { + clip: ClipId, + layer: usize, + e: &'a EffectInstance, + mt: Tick, + t: Tokens, + actions: Vec<(String, Value)>, + autos: Vec<(String, Rect, String)>, +} + +impl Ctx<'_> { + fn set(&mut self, id: &str, v: Value) { + self.actions.push(("graphics.set".into(), json!({"clip": self.clip.0, "layer": self.layer, "props": {id: v}}))); + } + fn auto(&mut self, id: &str, r: Rect, label: &str) { + self.autos.push((format!("graphics.prop.{id}"), r, label.to_string())); + } + fn row(&self, ui: &mut egui::Ui, label: &str) -> (Rect, egui::Ui) { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::hover()); + ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), self.t.text_dim); + let v = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(r.min.x + 130.0, r.min.y + 3.0), pos2(r.max.x - 6.0, r.max.y - 3.0))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + (r, v) + } + fn number(&mut self, ui: &mut egui::Ui, label: &str, id: &str, speed: f64, range: (f64, f64), dec: usize, unit: &str) { + let v = pv(self.e, id, self.mt).as_f64().unwrap_or(0.0); + let (r, mut vui) = self.row(ui, label); + let (resp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t); + self.auto(id, resp.rect, label); + let _ = r; + if let Some(nv) = nv { + self.set(id, json!(nv)); + } + } + fn number_in(&mut self, vui: &mut egui::Ui, id: &str, speed: f64, range: (f64, f64), dec: usize, unit: &str) { + let v = pv(self.e, id, self.mt).as_f64().unwrap_or(0.0); + let (resp, nv) = crate::widgets::hot_number(vui, egui::Id::new(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t); + self.auto(id, resp.rect, id); + if let Some(nv) = nv { + self.set(id, json!(nv)); + } + } + fn point(&mut self, ui: &mut egui::Ui, label: &str, id: &str) { + let v = pv(self.e, id, self.mt).as_vec2().unwrap_or_default(); + let (_, mut vui) = self.row(ui, label); + let (rx, nx) = crate::widgets::hot_number( + &mut vui, + egui::Id::new(("gfxp", self.clip.0, self.layer, id, "x")), + v.x, + 1.0, + (-100_000.0, 100_000.0), + 1, + " X", + &self.t, + ); + vui.add_space(10.0); + let (ry, ny) = crate::widgets::hot_number( + &mut vui, + egui::Id::new(("gfxp", self.clip.0, self.layer, id, "y")), + v.y, + 1.0, + (-100_000.0, 100_000.0), + 1, + " Y", + &self.t, + ); + self.auto(&format!("{id}.x"), rx.rect, label); + self.auto(&format!("{id}.y"), ry.rect, label); + if nx.is_some() || ny.is_some() { + self.set(id, json!([nx.unwrap_or(v.x), ny.unwrap_or(v.y)])); + } + } + fn check(&mut self, vui: &mut egui::Ui, id: &str, label: &str) -> bool { + let mut b = pv(self.e, id, self.mt).as_bool().unwrap_or(false); + let r = vui.checkbox(&mut b, label); + self.auto(id, r.rect, label); + if r.changed() { + self.set(id, json!(b)); + } + b + } + fn color(&mut self, vui: &mut egui::Ui, id: &str) { + let c = pv(self.e, id, self.mt).as_color().unwrap_or([1.0; 4]); + let mut c32 = color32(c); + let r = egui::color_picker::color_edit_button_srgba(vui, &mut c32, egui::color_picker::Alpha::Opaque); + self.auto(id, r.rect, id); + if r.changed() { + let [r8, g8, b8, _] = c32.to_srgba_unmultiplied(); + self.set(id, json!(format!("#{r8:02x}{g8:02x}{b8:02x}"))); + } + } + fn choice(&mut self, vui: &mut egui::Ui, id: &str, opts: &[&str], width: f32) { + let cur = match pv(self.e, id, self.mt) { + ParamValue::Choice(c) => c as usize, + _ => 0, + }; + let mut sel = cur; + let r = egui::ComboBox::from_id_salt(("gfxc", self.clip.0, self.layer, id)).selected_text(opts.get(cur).copied().unwrap_or("")).width(width).show_ui( + vui, + |ui| { + for (i, o) in opts.iter().enumerate() { + ui.selectable_value(&mut sel, i, *o); + } + }, + ); + self.auto(id, r.response.rect, id); + if sel != cur { + self.set(id, json!(sel)); + } + } +} + +fn section(ui: &mut egui::Ui, app: &mut FilmcraftApp, name: &str, t: &Tokens) -> bool { + ui.add_space(4.0); + let key = format!("gfx:{name}"); + let open = !app.ui.collapsed_fx.contains(&key); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + ui.painter().line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.separator)); + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(r.min.x + 6.0, r.center().y), vec2(10.0, 10.0)), + if open { Icon::ChevronDown } else { Icon::ChevronRight }, + t.text_dim, + ); + ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::semibold(13.0), t.text); + app.auto.add(&format!("graphics.section.{}", name.replace(' ', "")), r, name); + if resp.clicked() { + if open { + app.ui.collapsed_fx.push(key); + } else { + app.ui.collapsed_fx.retain(|k| *k != key); + } + } + open +} + +/// A small code-drawn alignment glyph (original artwork: bars against an edge line). +fn align_glyph(p: &egui::Painter, r: Rect, how: &str, c: Color32) { + let s = Stroke::new(1.2, c); + let (x0, x1, y0, y1) = (r.min.x + 2.0, r.max.x - 2.0, r.min.y + 2.0, r.max.y - 2.0); + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let bar = |a: Pos2, b: Pos2| p.rect_filled(Rect::from_two_pos(a, b), 0.0, c); + match how { + "left" => { + p.line_segment([pos2(x0, y0), pos2(x0, y1)], s); + bar(pos2(x0 + 2.0, y0 + 2.0), pos2(x1, y0 + 5.0)); + bar(pos2(x0 + 2.0, y1 - 5.0), pos2(cx + 1.0, y1 - 2.0)); + } + "right" => { + p.line_segment([pos2(x1, y0), pos2(x1, y1)], s); + bar(pos2(x0, y0 + 2.0), pos2(x1 - 2.0, y0 + 5.0)); + bar(pos2(cx - 1.0, y1 - 5.0), pos2(x1 - 2.0, y1 - 2.0)); + } + "hcenter" => { + p.line_segment([pos2(cx, y0), pos2(cx, y1)], s); + bar(pos2(x0 + 1.0, y0 + 2.0), pos2(x1 - 1.0, y0 + 5.0)); + bar(pos2(cx - 3.0, y1 - 5.0), pos2(cx + 3.0, y1 - 2.0)); + } + "top" => { + p.line_segment([pos2(x0, y0), pos2(x1, y0)], s); + bar(pos2(x0 + 2.0, y0 + 2.0), pos2(x0 + 5.0, y1)); + bar(pos2(x1 - 5.0, y0 + 2.0), pos2(x1 - 2.0, cy + 1.0)); + } + "bottom" => { + p.line_segment([pos2(x0, y1), pos2(x1, y1)], s); + bar(pos2(x0 + 2.0, y0), pos2(x0 + 5.0, y1 - 2.0)); + bar(pos2(x1 - 5.0, cy - 1.0), pos2(x1 - 2.0, y1 - 2.0)); + } + "vcenter" => { + p.line_segment([pos2(x0, cy), pos2(x1, cy)], s); + bar(pos2(x0 + 2.0, y0 + 1.0), pos2(x0 + 5.0, y1 - 1.0)); + bar(pos2(x1 - 5.0, cy - 3.0), pos2(x1 - 2.0, cy + 3.0)); + } + "dh" => { + for x in [x0 + 1.0, cx - 1.5, x1 - 4.0] { + bar(pos2(x, y0 + 3.0), pos2(x + 3.0, y1 - 3.0)); + } + } + "dv" => { + for y in [y0 + 1.0, cy - 1.5, y1 - 4.0] { + bar(pos2(x0 + 3.0, y), pos2(x1 - 3.0, y + 3.0)); + } + } + // paragraph alignment: four text lines + a @ ("pl" | "pc" | "pr" | "pj") => { + for (i, w) in [1.0f32, 0.6, 0.85, 0.5].iter().enumerate() { + let w = if a == "pj" && i < 3 { 1.0 } else { *w } * (x1 - x0); + let y = y0 + 1.0 + i as f32 * 3.2; + let xa = match a { + "pc" => cx - w / 2.0, + "pr" => x1 - w, + _ => x0, + }; + p.line_segment([pos2(xa, y), pos2(xa + w, y)], s); + } + } + _ => {} + } +} + +fn glyph_button(ui: &mut egui::Ui, how: &str, tip: &str, on: bool, t: &Tokens) -> egui::Response { + let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click()); + if on { + ui.painter().rect_filled(r, 3.0, Color32::from_rgb(0x4b, 0x4b, 0x4b)); + } else if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + align_glyph(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 14.0)), how, if resp.hovered() || on { t.text } else { t.text_dim }); + resp.on_hover_text(tip) +} + +fn letter_button(ui: &mut egui::Ui, text: &str, tip: &str, on: bool, t: &Tokens, font: egui::FontId) -> egui::Response { + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 22.0), Sense::click()); + if on { + ui.painter().rect_filled(r, 3.0, Color32::from_rgb(0x4b, 0x4b, 0x4b)); + } else if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + ui.painter().text(r.center(), Align2::CENTER_CENTER, text, font, if on || resp.hovered() { t.text } else { t.text_dim }); + resp.on_hover_text(tip) +} + +/// Whether the Properties panel should show the graphic editor. +pub fn graphic_selected(app: &FilmcraftApp) -> bool { + selected_graphic(app).is_some() +} + +/// The graphic editor (Properties panel / Essential Graphics ▸ Edit). +pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let Some((clip, it)) = selected_graphic(app) else { + crate::dock::placeholder(ui, rect, &t, "Select a graphic clip, or use the Type tool (T) on the Program monitor"); + let br = Rect::from_center_size(rect.center() + vec2(0.0, 30.0), vec2(150.0, 26.0)); + let resp = ui.put(br, egui::Button::new("Create new graphic")); + app.auto.add("graphics.createNew", br, "Create new graphic"); + if resp.clicked() + && let Err(e) = app.session.execute("graphics.newText", json!({"text": "New Text"})) + { + app.ui.status = e.to_string(); + } + return; + }; + let ph = app.session.playhead(); + let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1))); + let idx = layer_indices(&it.effects); + let sel = app.session.state.graphic_layers.first().copied().filter(|l| *l < idx.len()); + let mut actions: Vec<(String, Value)> = Vec::new(); + let mut autos: Vec<(String, Rect, String)> = Vec::new(); + let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(12.0, 6.0))).id_salt("gfx-props")); + bui.set_clip_rect(rect); + egui::ScrollArea::vertical().id_salt("gfx-props-scroll").auto_shrink([false, false]).show(&mut bui, |ui| { + // header + let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); + let lc = it.label.rgb(); + ui.painter().rect_filled(Rect::from_center_size(pos2(hr.min.x + 8.0, hr.center().y), vec2(12.0, 12.0)), 2.0, Color32::from_rgb(lc[0], lc[1], lc[2])); + ui.painter().text(pos2(hr.min.x + 22.0, hr.center().y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.5), t.text); + // ---- layers (front first) + if section(ui, app, "Layers", &t) { + ui.horizontal(|ui| { + ui.add_space(14.0); + for (label, tip, cmd, p) in [ + ("T", "New Text Layer", "graphics.newText", json!({"clip": clip.0, "text": "New Text"})), + ("", "New Rectangle", "graphics.newShape", json!({"clip": clip.0, "shape": "rectangle"})), + ("", "New Ellipse", "graphics.newShape", json!({"clip": clip.0, "shape": "ellipse", "size": [200, 200]})), + ] { + let r = letter_button(ui, label, tip, false, &t, Tokens::semibold(13.0)); + if label.is_empty() { + let ir = Rect::from_center_size(r.rect.center(), vec2(14.0, 14.0)); + icons::paint(ui.painter(), ir, if tip == "New Rectangle" { Icon::Rectangle } else { Icon::Ellipse }, t.text_dim); + } + autos.push((format!("graphics.{}", tip.replace(' ', "")), r.rect, tip.into())); + if r.clicked() { + actions.push((cmd.into(), p)); + } + } + ui.add_space(8.0); + if let Some(l) = sel { + for (label, tip, to) in [("↑", "Bring Forward", "forward"), ("↓", "Send Backward", "backward")] { + let r = letter_button(ui, label, tip, false, &t, Tokens::ui(13.0)); + autos.push((format!("graphics.arrange.{to}"), r.rect, tip.into())); + if r.clicked() { + actions.push(("graphics.arrangeLayer".into(), json!({"clip": clip.0, "layer": l, "to": to}))); + } + } + let (dr, dresp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click()); + icons::paint(ui.painter(), dr.shrink(4.0), Icon::Trash, if dresp.hovered() { t.text } else { t.text_dim }); + autos.push(("graphics.deleteLayer".into(), dr, "Delete Layer".into())); + if dresp.on_hover_text("Delete Layer").clicked() { + actions.push(("graphics.deleteLayer".into(), json!({"clip": clip.0, "layer": l}))); + } + } + }); + for (li, &ei) in idx.iter().enumerate().rev() { + let e = &it.effects[ei]; + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); + let on = sel == Some(li); + if on { + ui.painter().rect_filled(r, 2.0, Color32::from_rgb(0x2e, 0x3d, 0x55)); + } else if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + let kind = if e.effect == graphic::TEXT_LAYER { "T" } else { "◆" }; + ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::CENTER_CENTER, kind, Tokens::semibold(12.0), t.text_dim); + ui.painter().text(pos2(r.min.x + 38.0, r.center().y), Align2::LEFT_CENTER, layer_display_name(e, li), Tokens::ui(12.0), t.text); + // visibility + let er = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); + let eresp = ui.interact(er, egui::Id::new(("gfx-eye", clip.0, li)), Sense::click()); + icons::paint(ui.painter(), er.shrink(2.0), if e.enabled { Icon::Eye } else { Icon::EyeOff }, t.text_dim); + autos.push((format!("graphics.layers.{li}.visible"), er, "Toggle visibility".into())); + autos.push((format!("graphics.layers.{li}"), r, layer_display_name(e, li))); + if eresp.clicked() { + actions.push(("graphics.set".into(), json!({"clip": clip.0, "layer": li, "props": {"enabled": !e.enabled}}))); + } else if resp.clicked() { + actions.push(("graphics.selectLayer".into(), json!({"clip": clip.0, "layers": [li]}))); + } + } + } + let Some(l) = sel else { + ui.add_space(12.0); + ui.label(egui::RichText::new("Select a layer to edit its properties.").color(t.text_faint)); + return; + }; + let e = &it.effects[idx[l]]; + let mut cx = Ctx { clip, layer: l, e, mt, t, actions: Vec::new(), autos: Vec::new() }; + // ---- align and transform + if section(ui, app, "Align and Transform", &t) { + ui.horizontal(|ui| { + ui.add_space(14.0); + for (how, tip) in [ + ("left", "Align Left"), + ("hcenter", "Align Center Horizontally"), + ("right", "Align Right"), + ("top", "Align Top"), + ("vcenter", "Align Center Vertically"), + ("bottom", "Align Bottom"), + ] { + let r = glyph_button(ui, how, tip, false, &t); + cx.autos.push((format!("graphics.align.{how}"), r.rect, tip.into())); + if r.clicked() { + cx.actions.push(("graphics.align".into(), json!({"clip": clip.0, "align": how}))); + } + } + ui.add_space(6.0); + for (how, axis, tip) in [("dh", "horizontal", "Distribute Horizontally"), ("dv", "vertical", "Distribute Vertically")] { + let r = glyph_button(ui, how, tip, false, &t); + cx.autos.push((format!("graphics.distribute.{axis}"), r.rect, tip.into())); + if r.clicked() { + cx.actions.push(("graphics.distribute".into(), json!({"clip": clip.0, "axis": axis, "layers": (0..idx.len()).collect::>()}))); + } + } + }); + cx.point(ui, "Position", "position"); + cx.point(ui, "Anchor point", "anchor"); + cx.number(ui, "Scale", "scale", 0.5, (0.0, 100_000.0), 0, " %"); + cx.number(ui, "Rotation", "rotation", 0.5, (-36_000.0, 36_000.0), 1, " °"); + cx.number(ui, "Opacity", "opacity", 0.5, (0.0, 100.0), 0, " %"); + } + // ---- text + if e.effect == graphic::TEXT_LAYER && section(ui, app, "Text", &t) { + // source text (multi-line), committed as it is typed + let mut s = match pv(e, "text", mt) { + ParamValue::Text(s) => s, + _ => String::new(), + }; + let before = s.clone(); + ui.horizontal(|ui| { + ui.add_space(14.0); + let r = ui.add(egui::TextEdit::multiline(&mut s).desired_rows(2).desired_width(ui.available_width() - 8.0).font(Tokens::ui(12.0))); + cx.autos.push(("graphics.sourceText".into(), r.rect, "Source Text".into())); + }); + if s != before { + cx.actions.push(("graphics.setText".into(), json!({"clip": clip.0, "layer": l, "text": s, "merge": true}))); + } + let family = match pv(e, "font", mt) { + ParamValue::Text(f) => f, + _ => String::new(), + }; + let style = match pv(e, "font_style", mt) { + ParamValue::Text(f) => f, + _ => String::new(), + }; + let fams = filmcraft_text::families(); + let (_, mut vui) = cx.row(ui, "Font"); + let r = egui::ComboBox::from_id_salt(("gfx-font", clip.0, l)).selected_text(&family).width(170.0).height(400.0).show_ui(&mut vui, |ui| { + if !filmcraft_text::fonts::system_scanned() { + filmcraft_text::fonts::scan_system(); + } + for (f, _) in &fams { + if ui.selectable_label(*f == family, f).clicked() { + cx.set("font", json!(f)); + } + } + }); + cx.auto("font", r.response.rect, "Font"); + let styles: Vec = fams.iter().find(|(f, _)| f.eq_ignore_ascii_case(&family)).map(|(_, s)| s.clone()).unwrap_or_default(); + let (_, mut vui) = cx.row(ui, "Font Style"); + let r = egui::ComboBox::from_id_salt(("gfx-style", clip.0, l)).selected_text(&style).width(170.0).show_ui(&mut vui, |ui| { + for st in styles.iter().map(String::as_str).chain(["Bold", "Italic", "Bold Italic"]).filter({ + let mut seen = Vec::new(); + move |x: &&str| { + if seen.contains(x) { + false + } else { + seen.push(*x); + true + } + } + }) { + if ui.selectable_label(st == style, st).clicked() { + cx.set("font_style", json!(st)); + } + } + }); + cx.auto("font_style", r.response.rect, "Font Style"); + cx.number(ui, "Font Size", "size", 0.5, (1.0, 2000.0), 0, ""); + let align = match pv(e, "align", mt) { + ParamValue::Choice(c) => c, + _ => 0, + }; + let (_, mut vui) = cx.row(ui, "Alignment"); + for (i, (how, tip)) in [("pl", "Left Align Text"), ("pc", "Center Align Text"), ("pr", "Right Align Text"), ("pj", "Justify")].iter().enumerate() { + let r = glyph_button(&mut vui, how, tip, align == i as u32, &t); + cx.auto(&format!("align.{i}"), r.rect, tip); + if r.clicked() { + cx.set("align", json!(i)); + } + } + cx.number(ui, "Tracking", "tracking", 1.0, (-1000.0, 10_000.0), 0, ""); + cx.number(ui, "Leading", "leading", 0.5, (-5000.0, 5000.0), 0, ""); + cx.number(ui, "Baseline Shift", "baseline_shift", 0.5, (-5000.0, 5000.0), 0, ""); + cx.number(ui, "Text Box Width", "box_width", 2.0, (0.0, 100_000.0), 0, ""); + let (_, mut vui) = cx.row(ui, "Style"); + let flag = |id: &str| pv(e, id, mt).as_bool().unwrap_or(false); + let caps = match pv(e, "caps", mt) { + ParamValue::Choice(c) => c, + _ => 0, + }; + for (id, label, tip, on, font) in [ + ("faux_bold", "T", "Faux Bold", flag("faux_bold"), Tokens::semibold(14.0)), + ("faux_italic", "T", "Faux Italic", flag("faux_italic"), egui::FontId::new(14.0, egui::FontFamily::Proportional)), + ("all_caps", "TT", "All Caps", caps == 1, Tokens::ui(12.0)), + ("small_caps", "Tt", "Small Caps", caps == 2, Tokens::ui(12.0)), + ("underline", "U", "Underline", flag("underline"), Tokens::ui(13.0)), + ] { + let r = letter_button(&mut vui, label, tip, on, &t, font); + if id == "faux_italic" { + // slanted stroke to read as italic + let c = r.rect.center(); + vui.painter().line_segment([c + vec2(-5.0, 6.0), c + vec2(5.0, -6.0)], Stroke::new(0.8, t.text_faint)); + } + cx.auto(id, r.rect, tip); + if r.clicked() { + match id { + "all_caps" => cx.set("caps", json!(if caps == 1 { 0 } else { 1 })), + "small_caps" => cx.set("caps", json!(if caps == 2 { 0 } else { 2 })), + _ => cx.set(id, json!(!on)), + } + } + } + let (_, mut vui) = cx.row(ui, "OpenType"); + cx.check(&mut vui, "kerning", "Kerning"); + cx.check(&mut vui, "ligatures", "Ligatures"); + } + if e.effect == graphic::SHAPE_LAYER && section(ui, app, "Shape", &t) { + let (_, mut vui) = cx.row(ui, "Shape"); + cx.choice(&mut vui, "shape", graphic::SHAPE_OPTS, 120.0); + cx.point(ui, "Size", "size"); + cx.number(ui, "Corner Radius", "corner_radius", 0.5, (0.0, 10_000.0), 0, ""); + cx.number(ui, "Polygon Sides", "sides", 0.05, (3.0, 64.0), 0, ""); + } + // ---- appearance + if section(ui, app, "Appearance", &t) { + let (_, mut vui) = cx.row(ui, "Fill"); + cx.check(&mut vui, "fill", ""); + cx.color(&mut vui, "fill_color"); + for (on, col, w, kind, label) in + [("stroke", "stroke_color", "stroke_width", "stroke_type", "Stroke"), ("stroke2", "stroke2_color", "stroke2_width", "stroke2_type", "Stroke 2")] + { + let (_, mut vui) = cx.row(ui, label); + cx.check(&mut vui, on, ""); + cx.color(&mut vui, col); + vui.add_space(6.0); + cx.number_in(&mut vui, w, 0.2, (0.0, 1000.0), 1, ""); + vui.add_space(6.0); + cx.choice(&mut vui, kind, graphic::STROKE_OPTS, 70.0); + } + let (_, mut vui) = cx.row(ui, "Background"); + let bg = cx.check(&mut vui, "background", ""); + cx.color(&mut vui, "background_color"); + if bg { + cx.number(ui, " Opacity", "background_opacity", 0.5, (0.0, 100.0), 0, " %"); + cx.number(ui, " Size", "background_size", 0.5, (0.0, 1000.0), 0, ""); + cx.number(ui, " Corner Radius", "background_radius", 0.5, (0.0, 1000.0), 0, ""); + } + let (_, mut vui) = cx.row(ui, "Shadow"); + let sh = cx.check(&mut vui, "shadow", ""); + cx.color(&mut vui, "shadow_color"); + if sh { + cx.number(ui, " Opacity", "shadow_opacity", 0.5, (0.0, 100.0), 0, " %"); + cx.number(ui, " Angle", "shadow_angle", 0.5, (-36_000.0, 36_000.0), 0, " °"); + cx.number(ui, " Distance", "shadow_distance", 0.5, (0.0, 1000.0), 0, ""); + cx.number(ui, " Size", "shadow_size", 0.5, (0.0, 1000.0), 0, ""); + cx.number(ui, " Blur", "shadow_blur", 0.5, (0.0, 1000.0), 0, ""); + } + } + actions.append(&mut cx.actions); + autos.append(&mut cx.autos); + }); + for (id, r, label) in autos { + app.auto.add(&id, r, &label); + } + for (cmd, p) in actions { + if let Err(e) = app.session.execute(&cmd, p) { + app.ui.status = e.to_string(); + } + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 560d6a361..0701582ea 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -6,6 +6,7 @@ pub mod effect_controls; pub mod effects; pub mod export_mode; pub mod file_dialogs; +pub mod graphics; pub mod import_mode; pub mod lumetri; pub mod meters; @@ -35,7 +36,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) PanelKind::EffectControls => effect_controls::show(app, ui, rect), PanelKind::AudioMeters => meters::show(app, ui, rect), PanelKind::LumetriColor => lumetri::show(app, ui, rect), + PanelKind::Properties if graphics::graphic_selected(app) => graphics::properties(app, ui, rect), PanelKind::Properties => effect_controls::properties_panel(app, ui, rect), + PanelKind::EssentialGraphics => graphics::properties(app, ui, rect), PanelKind::History => misc::history(app, ui, rect), PanelKind::Markers => misc::markers(app, ui, rect), PanelKind::Info => misc::info(app, ui, rect), diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index a610856f0..c8272605d 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -168,6 +168,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) // Click/drag in the picture: the Hand tool pans, otherwise focus. let pic_resp = ui.interact(pic, egui::Id::new((prefix, "pic")), Sense::click()); app.auto.add(&format!("{prefix}.picture"), pic, "picture"); + if which == Which::Program && has_video { + crate::panels::graphics::monitor_overlay(app, ui, pic, frame_size); + } if pic_resp.double_clicked() && which == Which::Source { // (Premiere opens the clip's settings; we show info) app.ui.status = name.clone(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 983cf7747..d7d8ee8bc 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -275,6 +275,22 @@ pub struct UiState { /// Preferences ▸ Playback: play the rendered range when a preview render finishes. #[serde(default = "yes")] pub play_after_render: bool, + /// On-monitor text editing (Type tool / double-click on a text layer). + #[serde(default)] + pub gfx_edit: Option, + /// Pen tool: path points placed so far (sequence pixels). + #[serde(default)] + pub pen_points: Vec<[f64; 2]>, +} + +/// The text layer being edited on the Program monitor: caret and selection anchor are byte +/// offsets into the layer's text. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct GfxEdit { + pub clip: u64, + pub layer: usize, + pub caret: usize, + pub anchor: usize, } fn captions_tab() -> String { @@ -313,6 +329,8 @@ impl Default for UiState { text_tab: captions_tab(), caption_search: String::new(), play_after_render: true, + gfx_edit: None, + pen_points: vec![], } } } diff --git a/crates/ui-egui/tests/graphics_ui.rs b/crates/ui-egui/tests/graphics_ui.rs new file mode 100644 index 000000000..b866b9bfd --- /dev/null +++ b/crates/ui-egui/tests/graphics_ui.rs @@ -0,0 +1,154 @@ +//! Graphics UI, driven headless through the control channel (like `scripted.rs`): the Type tool +//! creates a text layer on the Program monitor, typing and keys edit it, the Properties panel's +//! controls style it, the Selection tool moves it. +//! +//! With `FILMCRAFT_UI_SHOTS=

` the test also renders the UI with wgpu (works without a +//! window or an unlocked screen) and writes PNGs there for review. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, + shots: Option, +} + +impl Driver { + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from); + let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if shots.is_some() { + b = b.wgpu().with_pixels_per_point(1.0); + } + let harness = b.build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, shots }; + d.frames(4); + d + } + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + fn ok(&mut self, method: &str, params: Value) -> Value { + let r = self.call(method, params.clone()); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + r["result"].clone() + } + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + fn rect(&mut self, id: &str) -> [f64; 4] { + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}: {v}")).clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + [r[0], r[1], r[2], r[3]] + } + fn shot(&mut self, name: &str) { + let Some(dir) = self.shots.clone() else { return }; + self.frames(8); + let img = self.harness.render().expect("wgpu render"); + std::fs::create_dir_all(&dir).unwrap(); + img.save(dir.join(format!("{name}.png"))).unwrap(); + } +} + +#[test] +fn type_tool_edit_style_and_move() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Captions and Graphics"})); + d.exec("playhead.set", json!({"seconds": 2})); + d.ok("ui.set", json!({"tool": "Type"})); + d.frames(3); + let pic = d.rect("program.picture"); + let (px, py) = (pic[0] + pic[2] * 0.2, pic[1] + pic[3] * 0.35); + d.ok("ui.click", json!({"x": px, "y": py})); + d.ok("ui.type", json!({"text": "Night Drive"})); + d.ok("ui.key", json!({"key": "Enter"})); + d.ok("ui.type", json!({"text": "through the city"})); + let list = d.exec("graphics.list", json!({})); + assert_eq!(list["layers"][0]["text"], "Night Drive\nthrough the city", "{list}"); + // caret editing: Backspace ×4, select the word "Drive" with Shift+arrows… then retype + for _ in 0..4 { + d.ok("ui.key", json!({"key": "Backspace"})); + } + d.ok("ui.type", json!({"text": "town"})); + d.ok("ui.key", json!({"key": "ArrowUp"})); + d.ok("ui.key", json!({"key": "End"})); + for _ in 0..5 { + d.ok("ui.key", json!({"key": "Shift+ArrowLeft"})); + } + d.ok("ui.type", json!({"text": "Ride"})); + let list = d.exec("graphics.list", json!({})); + assert_eq!(list["layers"][0]["text"], "Night Ride\nthrough the town", "{list}"); + // one undo step for the whole typing session (after "New Graphic") + let hist = d.exec("history.list", json!({})); + let undo: Vec<&str> = hist["undo"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert_eq!(&undo[undo.len() - 2..], ["New Graphic", "Edit Text"], "{undo:?}"); + d.shot("type-tool-editing"); + + // style through the Properties panel controls + d.ok("ui.key", json!({"key": "Escape"})); + d.ok("ui.set", json!({"tool": "Selection"})); + d.ok("ui.click", json!({"id": "graphics.prop.faux_bold"})); + d.ok("ui.click", json!({"id": "graphics.prop.align.1"})); + d.ok("ui.click", json!({"id": "graphics.prop.stroke"})); + d.ok("ui.click", json!({"id": "graphics.prop.shadow"})); + let clip = list["clip"].as_u64().unwrap(); + d.exec("graphics.set", json!({"clip": clip, "props": {"font_style": "Bold", "fill_color": "#ffd23f", "size": 120}})); + let seq = d.exec("sequence.inspect", json!({})); + let s = seq.to_string(); + assert!(s.contains("graphic_text"), "graphic layer in the sequence"); + let list2 = d.exec("graphics.list", json!({"clip": clip})); + let q0 = list2["layers"][0]["quad"][0][0].as_f64().unwrap(); + // selection-tool drag moves the layer + let lr = d.rect(&format!("program.layer.{clip}.0")); + let pic = d.rect("program.picture"); + // a point on the layer that is inside the picture + let x0 = lr[0].max(pic[0]); + let x1 = (lr[0] + lr[2]).min(pic[0] + pic[2]); + let (cx, cy) = ((x0 + x1) / 2.0, lr[1] + lr[3] * 0.3); + let from = json!({"x": cx, "y": cy}); + let to = json!({"x": cx + 60.0, "y": cy + 20.0}); + d.ok("ui.drag", json!({"from": from, "to": to, "steps": 6})); + let list3 = d.exec("graphics.list", json!({"clip": clip})); + let q1 = list3["layers"][0]["quad"][0][0].as_f64().unwrap(); + assert!(q1 > q0 + 20.0, "moved right: {q0} → {q1}"); + d.shot("properties-styled"); + + // shapes: rectangle tool drag + d.ok("ui.set", json!({"tool": "Rectangle"})); + let pic = d.rect("program.picture"); + d.ok("ui.drag", json!({"from": {"x": pic[0] + pic[2] * 0.15, "y": pic[1] + pic[3] * 0.75}, "to": {"x": pic[0] + pic[2] * 0.85, "y": pic[1] + pic[3] * 0.9}, "steps": 5})); + let list4 = d.exec("graphics.list", json!({"clip": clip})); + assert_eq!(list4["layers"].as_array().unwrap().len(), 2, "{list4}"); + assert_eq!(list4["layers"][1]["kind"], "Rectangle"); + d.exec("graphics.arrangeLayer", json!({"clip": clip, "layer": 1, "to": "back"})); + d.ok("ui.set", json!({"tool": "Selection"})); + d.shot("shape-layer"); +} diff --git a/docs/architecture.md b/docs/architecture.md index 73a01dd13..9976ee0d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -106,7 +106,7 @@ the boundary. Project ├─ root: Bin tree of bins ├─ items: map ItemId → ProjectItem flat -│ kind: Media(MediaClip) | Sequence(Sequence) | Subclip{..} | AdjustmentLayer{..} +│ kind: Media(MediaClip) | Sequence(Sequence) | Subclip{..} | AdjustmentLayer{..} | Graphic{..} └─ next_id Sequence @@ -125,6 +125,8 @@ EffectInstance └─ effect id, enabled, params: id → constant value or keyframe track ``` +- **Graphic clips** ([graphics.md](graphics.md)) reference a `Graphic` canvas item; their text and + shape layers are hidden `graphic_text` / `graphic_shape` effect instances on the track item. - Everything is plain serde data. `Sequence::check()` validates the invariants (no overlaps, unique ids), and the engine runs it after every sequence edit. - Timeline positions are sequence ticks; `source_in` and keyframes are in media time, so trims and diff --git a/docs/graphics.md b/docs/graphics.md new file mode 100644 index 000000000..e0509e127 --- /dev/null +++ b/docs/graphics.md @@ -0,0 +1,92 @@ +# Graphics + +FilmCraft's graphics work like Premiere's Essential Graphics: a **graphic clip** on a video track +holds **text** and **shape** layers. Text is set by the text engine in `crates/text` +([README](../crates/text/README.md)): bundled OFL fonts plus system fonts, OpenType shaping with +kerning and ligatures, bidi, line breaking, all drawn as vectors in linear light. + +## Model + +- A graphic clip's project item is an `ItemKind::Graphic { width, height, rate }`: a transparent + canvas the size of the sequence frame, with unlimited duration. These items are internal and do + not appear in the Project panel. +- Its **layers** are effect instances `graphic_text` / `graphic_shape` in the clip's effect list, + in paint order (first = back). They are hidden from the Effects panel. Because they are effect + instances they save with the project, take keyframes on every animatable property (Source Text + keyframes hold), appear in Effect Controls and copy with the clip. Standard effects on a graphic + clip apply to the composed graphic; Motion and Opacity apply last. +- `filmcraft_project::graphic::eval_layer` evaluates a layer at a time into a `LayerSpec`. +- Projects with graphics need no schema migration: everything is additive (new item kind, new + effect ids). Builds older than this one refuse such files with a parse error. + +### Layer properties + +| Group | Properties (parameter ids) | +|---|---| +| Text | `text` (Source Text), `font`, `font_style`, `size` (px), `align` (Left/Center/Right/Justify), `tracking` (1/1000 em), `kerning`, `ligatures`, `leading` (px added to the natural line height), `baseline_shift`, `faux_bold`, `faux_italic`, `caps` (Normal/All Caps/Small Caps), `underline`, `box_width` (0 = point text, else area text wrapped at that width) | +| Shape | `shape` (Rectangle/Ellipse/Polygon/Path), `size` [w, h], `sides`, `corner_radius`, `points` (path vertices relative to the layer origin) | +| Appearance | `fill`, `fill_color`; `stroke`, `stroke_color`, `stroke_width`, `stroke_type` (Outer/Center/Inner) and the same for `stroke2`; `background`, `background_color`, `background_opacity`, `background_size` (padding), `background_radius`; `shadow`, `shadow_color`, `shadow_opacity`, `shadow_angle` (135° = down-right), `shadow_distance`, `shadow_size`, `shadow_blur` | +| Transform | `position` (graphic canvas px), `anchor` (layer px), `scale`, `scale_width`, `uniform_scale`, `rotation`, `opacity` | + +Point text's origin is its alignment point on the first baseline (the click point of the Type +tool); a shape's origin is its centre. Colours are `#rrggbb` (sRGB). + +## Rendering + +`crates/render/src/graphic_clip.rs` rasterises each layer straight to the output transform +(sequence Motion × layer transform), so text stays sharp at any scale or rotation. Fill coverage +comes from the text engine; strokes come from a Euclidean distance transform of that coverage +(outer strokes sit under the fill, centre and inner strokes over it); the shadow is the union of +all parts, spread by Size, offset and blurred. Each layer's raster is cached while it stays the same. +The GPU plan receives one tight image per graphic clip and places it with a translation. The +Timecode and Clip Name effects also draw with the text engine. + +## Commands + +| Command | Menu / shortcut | Params | +|---|---|---| +| `graphics.newText` | Graphics and Titles ▸ New Layer ▸ Text (⌘T) | `text`, `position`, `clip` (add to this graphic), `size`, `font`, `fontStyle`, `seconds` (5), `track`, `time` | +| `graphics.newShape` | Graphics and Titles ▸ New Layer ▸ Shape | `shape` (rectangle/ellipse/polygon/path), `position`, `size`, `points`, `clip` | +| `graphics.setText` | typing on the monitor | `clip`, `layer`, `text`, `merge` (coalesce one typing session into one undo step) | +| `graphics.set` | Properties panel | `clip`, `layer`, `props` {parameter id or camelCase alias: value; choices by index or name}, `time` | +| `graphics.selectLayer` | layer list / monitor click | `clip`, `layers` | +| `graphics.deleteLayer`, `graphics.arrangeLayer` | layer list | `clip`, `layer`, `to` (front/back/forward/backward/index) | +| `graphics.align` | Align and Transform | `align` (left/hcenter/right/top/vcenter/bottom), `to` (frame/selection), `layers` | +| `graphics.distribute` | Align and Transform | `axis` (horizontal/vertical), `layers` (3+) | +| `graphics.list` | (query) | `clip` — layers with names, kinds, text, position and on-canvas quads | +| `fonts.list` | (query) | `system` (scan system font folders, default true) — families and styles | + +Without `clip`, commands use the selected graphic clip, else the topmost graphic clip under the +playhead. Without `layer`, they use the selected layer, else the front one. New graphic clips are +placed at the playhead on the first video track above the clips there that is free for the +duration (a track is added if needed). + +## Program monitor + +| Tool | On the Program monitor | +|---|---| +| Type (T) | Click empty picture: new text layer with a caret. Click a text layer: caret there. Type; ←/→ (⌥ word, ⌘ line), ↑/↓, Home/End, Shift to select, ⌘A, ⌘C/⌘X/⌘V, Return = new line, Backspace/Delete, Esc = stop editing. Drag inside the edited text to select. | +| Selection (V) | Click a layer to select it (box with handles and anchor point); drag to move; drag a corner handle to scale; double-click a text layer to edit it. | +| Rectangle / Ellipse | Drag to draw a shape layer. | +| Pen (P) | Click to place points; click the first point (or Return) to close the path; Esc cancels. | + +Automation ids: `program.layer..`, `program.layer...handle.`, +`program.textEdit` (while editing). + +## Properties / Essential Graphics panels + +With a graphic clip selected, the Properties panel (and Essential Graphics) shows: **Layers** +(front first; new text / rectangle / ellipse, bring forward / send backward, delete, visibility), +**Align and Transform** (six align buttons, two distribute buttons, position, anchor, scale, +rotation, opacity), **Text** (Source Text field, font family and style, size, paragraph alignment, +tracking, leading, baseline shift, box width, faux bold / faux italic / all caps / small caps / +underline, kerning and ligatures), **Shape**, and **Appearance** (fill, two strokes, background, +shadow). Automation ids: `graphics.layers.`, `graphics.prop.`, +`graphics.align.`, `graphics.distribute.`, `graphics.sourceText`, `graphics.NewTextLayer`, +`graphics.NewRectangle`, `graphics.NewEllipse`, `graphics.deleteLayer`, `graphics.section.`. + +## Not yet + +Per-character styling inside one text layer, responsive design pins and rolls/crawls (M10.3), +motion graphics templates, the Browse tab, mask-with-text, gradient fills, per-layer blend modes, and +Bézier curves in the pen tool (paths are polygons). From 0a8bd59a09dda98edc09557927058b4b9ba720c6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 02:06:38 -0400 Subject: [PATCH 20/24] M1.2: MP4 muxer wrote all-empty sample tables as uniform stsz size 0 (= 'table follows'), producing unreadable files; write a table instead (regression test; found by progressive_roundtrip proptest) Co-Authored-By: Claude Opus 5.5 --- crates/isobmff/src/mux.rs | 4 +++- .../tests/roundtrip.proptest-regressions | 7 +++++++ crates/isobmff/tests/roundtrip.rs | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 crates/isobmff/tests/roundtrip.proptest-regressions diff --git a/crates/isobmff/src/mux.rs b/crates/isobmff/src/mux.rs index 83a4dc100..900353685 100644 --- a/crates/isobmff/src/mux.rs +++ b/crates/isobmff/src/mux.rs @@ -423,7 +423,9 @@ fn write_trak(b: &mut BoxBuf, t: &WTrack, movie_ts: u32, qt: bool, shift: u64, f } None => { let first = tb.sizes.first().copied().unwrap_or(0); - if !tb.sizes.is_empty() && tb.sizes.iter().all(|&s| s == first) { + // A uniform size of 0 would mean "table follows" (ISO/IEC 14496-12 §8.7.3), so + // all-empty samples must still be written as a table. + if first != 0 && !tb.sizes.is_empty() && tb.sizes.iter().all(|&s| s == first) { b.u32(first); b.u32(tb.sizes.len() as u32); } else { diff --git a/crates/isobmff/tests/roundtrip.proptest-regressions b/crates/isobmff/tests/roundtrip.proptest-regressions new file mode 100644 index 000000000..645fba077 --- /dev/null +++ b/crates/isobmff/tests/roundtrip.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 2f1e39c31dcb71e2f413c6f8046cb8976a8d51bab762a3d6cecce91feeda3a96 # shrinks to tracks = [(0, [S { size: 0, duration: 1, cto: 0, sync: false }])], order = [], mov = false, fast = false, with_pcm = false, start = None diff --git a/crates/isobmff/tests/roundtrip.rs b/crates/isobmff/tests/roundtrip.rs index 584b767ad..277aa66e2 100644 --- a/crates/isobmff/tests/roundtrip.rs +++ b/crates/isobmff/tests/roundtrip.rs @@ -204,3 +204,20 @@ fn large_offsets_use_co64_and_largesize_mdat() { z.extend_from_slice(&[0, 0, 0, 0, b'f', b'r', b'e', b'e', 1, 2, 3]); assert!(open(z.as_slice()).is_ok()); } + +/// Found by `progressive_roundtrip`: only zero-byte samples must not be written as uniform +/// `stsz` size 0, which readers take to mean "a size table follows". +#[test] +fn all_empty_samples_roundtrip() { + for n in [1usize, 3] { + let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap(); + w.add_track(TrackConfig::new(entry(0), 1000)).unwrap(); + for _ in 0..n { + w.write_sample(0, WriteSample { data: &[], duration: 1, composition_offset: 0, is_sync: false }).unwrap(); + } + let out = w.finish().unwrap().into_inner(); + let f = open(out.as_slice()).unwrap(); + assert_eq!(f.tracks[0].samples.len(), n); + assert!(f.tracks[0].samples.iter().all(|s| s.size == 0)); + } +} From 14ae01885fdc677390bc170cf64b2ce498e3e0d1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 02:10:29 -0400 Subject: [PATCH 21/24] M7.2: Audio Track Mixer and Audio Clip Mixer panels, timeline track keyframes, live recording - Audio Track Mixer: strips for audio tracks, submixes and the Mix with effect/send slots (disclosure), input mapping, output assignment, pan knob, automation mode, M/S/R (solo safe on submixes), fader with dB scale and our own taper, stereo meters fed by the playing mix, value hot text, footer transport; horizontal/vertical scrolling. Fader and knob drags are mixer.touch while held and mixer.release on let go, so they are heard live and recorded in Latch/Touch/Write. - Audio Clip Mixer: per-track strips for the clip under the playhead (clip Volume / Panner, write-keyframes toggle), clipMixer.set with one undo step per drag (Session::edit_merged). - Timeline: per-track "Show Keyframes" button picks clip keyframes or a track lane (volume, panner, mute, sends, insert params); the lane is drawn across the track, Pen / Cmd-click adds, diamonds drag in time and value, right-click deletes. - Playback starts an automation pass and stopping writes it; the audio callback reads the newest project snapshot (LiveMix), so mixer moves are heard while playing. Audio Meters read the graph's live meters instead of re-mixing at the playhead. - Automation ids on every control; headless kittest tests drive the panels by id and by multi-frame drags (FILMCRAFT_UI_SNAPSHOT_DIR renders offscreen PNGs through wgpu). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 5 + crates/engine/src/lib.rs | 24 + crates/engine/src/mixer.rs | 42 + crates/render/src/audio_fx.rs | 5 + crates/ui-egui/Cargo.toml | 3 +- crates/ui-egui/src/lib.rs | 14 + crates/ui-egui/src/panels/meters.rs | 80 +- crates/ui-egui/src/panels/mixer.rs | 802 ++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 5 +- crates/ui-egui/src/panels/timeline.rs | 4 + .../ui-egui/src/panels/timeline_automation.rs | 240 ++++++ crates/ui-egui/src/state.rs | 9 + crates/ui-egui/tests/mixer_ui.rs | 256 ++++++ 13 files changed, 1413 insertions(+), 76 deletions(-) create mode 100644 crates/ui-egui/src/panels/mixer.rs create mode 100644 crates/ui-egui/src/panels/timeline_automation.rs create mode 100644 crates/ui-egui/tests/mixer_ui.rs diff --git a/Cargo.lock b/Cargo.lock index c551bf47b..ffb1e1716 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1111,10 +1111,14 @@ checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" dependencies = [ "eframe", "egui", + "egui-wgpu", + "image", "kittest", "log", + "pollster 1.0.1", "serde", "toml 1.1.6+spec-1.1.0", + "wgpu", ] [[package]] @@ -1685,6 +1689,7 @@ dependencies = [ "filmcraft-project", "filmcraft-render", "filmcraft-time", + "image", "log", "rayon", "serde", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f5346461c..f20d400fa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -78,6 +78,9 @@ pub struct History { pub redo: Vec<(String, Arc)>, /// Labels of all applied states, oldest first (History panel). pub limit: usize, + /// Key of the last [`Session::edit_merged`] step: a continuous gesture (a fader drag) with the + /// same key folds into that one undo step. + pub merge_key: Option, } impl History { @@ -344,6 +347,25 @@ impl Session { self.history.undo.remove(0); } self.history.redo.clear(); + self.history.merge_key = None; + self.state = st; + self.bump(); + Ok(r) + } + + /// Like [`Session::edit`], but consecutive calls with the same `key` (and no other edit in + /// between) share one undo step: dragging a control is one undoable change. + pub fn edit_merged(&mut self, label: &str, key: &str, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { + let merge = self.history.merge_key.as_deref() == Some(key) && self.history.undo.last().is_some_and(|u| u.0 == label); + if !merge { + let r = self.edit(label, f)?; + self.history.merge_key = Some(key.to_string()); + return Ok(r); + } + let mut p = (*self.project).clone(); + let mut st = self.state.clone(); + let r = f(&mut p, &mut st)?; + self.project = Arc::new(p); self.state = st; self.bump(); Ok(r) @@ -373,6 +395,7 @@ impl Session { pub fn undo(&mut self) -> Option { let (label, prev) = self.history.undo.pop()?; + self.history.merge_key = None; let cur = std::mem::replace(&mut self.project, prev); self.history.redo.push((label.clone(), cur)); self.fix_state(); @@ -382,6 +405,7 @@ impl Session { pub fn redo(&mut self) -> Option { let (label, next) = self.history.redo.pop()?; + self.history.merge_key = None; let cur = std::mem::replace(&mut self.project, next); self.history.undo.push((label.clone(), cur)); self.fix_state(); diff --git a/crates/engine/src/mixer.rs b/crates/engine/src/mixer.rs index 78bd9fd74..1e4a25b53 100644 --- a/crates/engine/src/mixer.rs +++ b/crates/engine/src/mixer.rs @@ -837,6 +837,38 @@ fn write_automation(s: &mut Session, p: &Value) -> Result { Ok(json!({"keyframes": n, "points": pts.len()})) } +/// Audio Clip Mixer: set the clip's Volume level / Panner balance (or any scalar clip effect +/// parameter) at the playhead. With `keyframe` (or when already animated) it writes a keyframe at the +/// playhead; a drag (same clip and parameter, no other edit between) is one undo step. +fn clip_set(s: &mut Session, p: &Value) -> Result { + let clip = filmcraft_project::ClipId(u64_p(p, "clip").ok_or_else(|| bad("clipMixer.set", "need `clip`"))?); + let effect = str_p(p, "effect").unwrap_or("volume").to_string(); + let param = str_p(p, "param").unwrap_or("level").to_string(); + let v = f64_p(p, "value").ok_or_else(|| bad("clipMixer.set", "need `value`"))?; + let kf = bool_p(p, "keyframe").unwrap_or(false); + let t = time_p(s, p, "").unwrap_or(s.playhead()); + let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?; + let key = format!("clipMixer:{}:{effect}:{param}", clip.0); + if bool_p(p, "begin").unwrap_or(false) { + s.history.merge_key = None; + } + s.edit_merged("Audio Clip Mixer", &key, |pr, _| { + let seq = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?; + let (_, it) = seq.find_item_mut(clip).ok_or_else(|| bad("clipMixer.set", "no such clip"))?; + let mt = it.source_time_at(t.clamp(it.start, it.end())); + let e = it.effect_mut(&effect).ok_or_else(|| bad("clipMixer.set", format!("the clip has no `{effect}` effect")))?; + let par = e.param_mut(¶m).ok_or_else(|| bad("clipMixer.set", format!("no parameter `{param}`")))?; + let v = if param == "level" { v.clamp(FADER_MIN_DB, FADER_MAX_DB) } else { v.clamp(-100.0, 100.0) }; + if kf || par.is_animated() { + par.put_keyframe(mt, ParamValue::Float(v)); + } else { + par.value = ParamValue::Float(v); + } + Ok(()) + })?; + Ok(Value::Null) +} + fn not_recording(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; if s.mixrec.active() { Err("an automation pass is already recording".into()) } else { Ok(()) } @@ -946,6 +978,16 @@ pub fn commands() -> Vec { }, true, ), + spec( + "clipMixer.set", + "Audio Clip Mixer Adjust", + &[], + None, + r#"{"clip":id,"effect":"volume"|"panner","param":"level"|"balance","value":f64,"keyframe":bool?,"time":ticks?,"begin":bool?}"#, + has_seq, + clip_set, + true, + ), spec("mixer.addSubmix", "Add Audio Submix Track", &["Sequence"], None, r#"{"name":str?,"channels":"Mono|Stereo|5.1"?}"#, has_seq, add_submix, true), spec("mixer.deleteSubmix", "Delete Submix Track", &[], None, r#"{"strip":"S1"|id}"#, has_seq, delete_submix, true), spec( diff --git a/crates/render/src/audio_fx.rs b/crates/render/src/audio_fx.rs index 89a3074d3..28f335b4a 100644 --- a/crates/render/src/audio_fx.rs +++ b/crates/render/src/audio_fx.rs @@ -110,6 +110,11 @@ pub(crate) fn mapping(id: &str) -> Option { }) } +/// Whether a project audio effect has a DSP implementation (clip effects and mixer inserts). +pub fn supported(effect_id: &str) -> bool { + mapping(effect_id).is_some() +} + fn band(d: &mut dyn AudioEffect, kind: f32, freq: f32, q: f32) { d.set_param("b1.on", 1.0); d.set_param("b1.type", kind); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index a42a66f5f..321fdeae1 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -29,4 +29,5 @@ log = { workspace = true } rayon = { workspace = true } [dev-dependencies] -egui_kittest = { workspace = true, features = ["eframe"] } +egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } +image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d34a21221..8350b5d2e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -299,6 +299,11 @@ impl FilmcraftApp { self.playback.dropped = 0; self.playback.shown = 0; self.start_audio(); + // Audio Track Mixer: an automation pass runs while playing forward in real time + if (speed - 1.0).abs() < 1e-9 && !self.session.mixrec.active() { + let t = self.session.playhead(); + let _ = self.session.execute("mixer.recordStart", json!({"time": t.0})); + } } pub fn stop(&mut self) { @@ -307,6 +312,12 @@ impl FilmcraftApp { a.stop(); } self.playback.audio_clock = false; + if self.session.mixrec.active() { + let t = self.session.playhead(); + if let Err(e) = self.session.execute("mixer.recordStop", json!({"time": t.0})) { + self.ui.status = e.to_string(); + } + } } fn start_audio(&mut self) { @@ -325,7 +336,10 @@ impl FilmcraftApp { let Some(a) = self.audio.as_mut() else { return }; let sr = a.sample_rate(); let mut cursor = start_tick.to_units_floor(sr as i64); + previews.live.publish_project(project.clone()); let fill = Box::new(move |buf: &mut [f32], ch: usize| { + // the newest project snapshot: mixer moves and other edits are heard while playing + let project = previews.live.project().filter(|p| p.sequence(seq_id).is_some()).unwrap_or_else(|| project.clone()); let Some(seq) = project.sequence(seq_id) else { return }; let n = buf.len() / ch.max(1); // Mix at the sequence rate; convert when the device rate differs (nearest sample). diff --git a/crates/ui-egui/src/panels/meters.rs b/crates/ui-egui/src/panels/meters.rs index cc3db6d3c..00cd45506 100644 --- a/crates/ui-egui/src/panels/meters.rs +++ b/crates/ui-egui/src/panels/meters.rs @@ -1,29 +1,14 @@ -//! Audio Meters (stereo peak meters with dB scale and peak hold) and a basic Audio Track Mixer. -//! Levels are measured from the mixed sequence audio around the playhead. +//! Audio Meters: stereo peak meters of the Mix (dB scale, peak hold) fed by the playing mixer +//! graph, plus the BS.1770 loudness readout. -use egui::{Align2, Rect, Sense, pos2, vec2}; -use serde_json::json; +use egui::{Align2, Rect, pos2, vec2}; use crate::FilmcraftApp; use crate::theme::Tokens; -fn levels(app: &FilmcraftApp) -> [f32; 2] { - let Some(seq) = app.session.active_sequence() else { return [-90.0; 2] }; - if !app.playback.playing { - return [-90.0; 2]; - } - let sr = seq.settings.sample_rate as i64; - let s0 = app.session.playhead().to_units_floor(sr); - let provider = app.session.media.provider(app.session.project.clone(), app.session.services.clone()); - let Some(seq_id) = app.session.state.active_sequence else { return [-90.0; 2] }; - let buf = app.session.previews.mix(&app.session.project, seq_id, s0, 1024, &provider); - let p = buf.peaks(); - [20.0 * p.first().copied().unwrap_or(0.0).max(1e-6).log10(), 20.0 * p.get(1).copied().unwrap_or(0.0).max(1e-6).log10()] -} - /// Feed the loudness meter with exactly the programme audio played since the last frame /// (consecutive, non-overlapping blocks, so gating and integration are correct). Seeks reset it. -fn feed_loudness(app: &mut FilmcraftApp) { +pub(crate) fn feed_loudness(app: &mut FilmcraftApp) { if !app.playback.playing { return; } @@ -62,18 +47,8 @@ fn lufs_text(v: f64) -> String { pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; - feed_loudness(app); - let lv = levels(app); - let id = egui::Id::new("meter-state"); - let now = ui.input(|i| i.time); - let mut st: [f32; 4] = ui.data(|d| d.get_temp(id)).unwrap_or([-90.0; 4]); - for c in 0..2 { - // fast attack, 20 dB/s release; peak hold decays after 1.5 s - st[c] = if lv[c] > st[c] { lv[c] } else { (st[c] - 20.0 * ui.input(|i| i.stable_dt)).max(-90.0) }; - st[c + 2] = if lv[c] > st[c + 2] { lv[c] } else { st[c + 2] - 6.0 * ui.input(|i| i.stable_dt) }; - } - ui.data_mut(|d| d.insert_temp(id, st)); - let _ = now; + // levels of the Mix as it plays (fast attack, 20 dB/s release, peak hold) + let st = super::mixer::poll_meters(app, ui).get(&filmcraft_render::mixer::MASTER.0).copied().unwrap_or([-90.0; 4]); // Premiere: black meter area, scale 0 … −57 dB in 3 dB steps on the right, "dB" at the foot. // Loudness readout (BS.1770 / EBU R128) under the bars. let lufs_h = if rect.height() > 260.0 { 64.0 } else { 0.0 }; @@ -128,46 +103,3 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add("audioMeters", area, "Audio Meters"); } - -pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { - let t = app.tokens; - let Some(seq) = app.session.active_sequence().cloned() else { - crate::dock::placeholder(ui, rect, &t, "(no sequence)"); - return; - }; - let strip_w = 86.0; - let mut actions = Vec::new(); - for (i, tr) in seq.audio_tracks.iter().enumerate().chain(std::iter::empty()) { - let x = rect.min.x + 8.0 + i as f32 * (strip_w + 4.0); - if x + strip_w > rect.max.x { - break; - } - let strip = Rect::from_min_max(pos2(x, rect.min.y + 6.0), pos2(x + strip_w, rect.max.y - 6.0)); - ui.painter().rect_filled(strip, 4.0, t.tl_header_bg); - ui.painter().text(pos2(strip.center().x, strip.min.y + 12.0), Align2::CENTER_CENTER, format!("A{}", i + 1), Tokens::semibold(11.0), t.text); - // pan knob as hot number - let mut pan_ui = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(strip.min.x + 20.0, strip.min.y + 26.0), vec2(60.0, 18.0)))); - let (_, np) = crate::widgets::hot_number(&mut pan_ui, egui::Id::new(("pan", tr.id.0)), tr.pan, 1.0, (-100.0, 100.0), 0, "", &t); - if let Some(np) = np { - actions.push(json!({"track": tr.id.0, "pan": np})); - } - // fader - let fader = Rect::from_min_max(pos2(strip.center().x - 3.0, strip.min.y + 54.0), pos2(strip.center().x + 3.0, strip.max.y - 40.0)); - ui.painter().rect_filled(fader, 3.0, t.field_bg); - let norm = ((tr.volume_db + 60.0) / 66.0).clamp(0.0, 1.0) as f32; - let ky = fader.max.y - norm * fader.height(); - let knob = Rect::from_center_size(pos2(fader.center().x, ky), vec2(26.0, 10.0)); - let resp = ui.interact(knob.expand(4.0), egui::Id::new(("fader", tr.id.0)), Sense::drag()); - ui.painter().rect_filled(knob, 2.0, if resp.dragged() { t.accent } else { egui::Color32::from_rgb(160, 160, 160) }); - if resp.dragged() { - let nn = (norm - resp.drag_delta().y / fader.height()).clamp(0.0, 1.0); - actions.push(json!({"track": tr.id.0, "volumeDb": nn as f64 * 66.0 - 60.0})); - } - app.auto.add(&format!("mixer.A{}.fader", i + 1), knob, "volume"); - ui.painter().text(pos2(strip.center().x, strip.max.y - 26.0), Align2::CENTER_CENTER, format!("{:.1}", tr.volume_db), Tokens::ui(11.0), t.hot_text); - ui.painter().text(pos2(strip.center().x, strip.max.y - 10.0), Align2::CENTER_CENTER, &tr.name, Tokens::ui(10.0), t.text_dim); - } - for a in actions { - let _ = app.session.execute("timeline.setTrack", a); - } -} diff --git a/crates/ui-egui/src/panels/mixer.rs b/crates/ui-egui/src/panels/mixer.rs new file mode 100644 index 000000000..1a1cfbf39 --- /dev/null +++ b/crates/ui-egui/src/panels/mixer.rs @@ -0,0 +1,802 @@ +//! Audio Track Mixer and Audio Clip Mixer. +//! +//! Track Mixer strips (audio tracks, submixes, Mix): effect and send slots (behind the ▸ +//! disclosure), input mapping, output assignment, pan knob, automation mode, M / S / R, fader with +//! a dB scale, stereo meters fed by the playing mix, the fader value and the strip name. Every +//! control goes through `mixer.*` commands: fader and knob drags are `mixer.touch` while held and +//! `mixer.release` when let go, so they are heard live during playback and recorded as automation +//! in Latch / Touch / Write mode. +//! +//! Automation ids: `mixer..|send.|meter|name>`, popup entries below them (`mixer.A1.mode.Touch`…), the +//! effects disclosure `mixer.showEffects`, and the footer transport `mixer.transport.`. +//! The Clip Mixer uses `clipMixer.A1.`. + +use std::collections::HashMap; + +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use filmcraft_engine::mixer::strip_label; +use filmcraft_project::mixer::{FADER_MAX_DB, FADER_MIN_DB, LANE_MUTE, LANE_PAN, LANE_VOLUME, MASTER_STRIP}; +use filmcraft_project::{AutomationMode, InputMap, Sequence, Track, TrackId}; +use filmcraft_time::{TimeDisplay, format_time}; +use serde_json::{Value, json}; + +use crate::FilmcraftApp; +use crate::icons::{self, Icon}; +use crate::theme::Tokens; + +// ------------------------------------------------------------------------------------- fader law + +/// Fader taper: (dB, position 0 … 1). Our own law: most of the travel around unity gain. +const TAPER: [(f64, f32); 10] = + [(-96.0, 0.0), (-60.0, 0.05), (-40.0, 0.14), (-30.0, 0.24), (-20.0, 0.38), (-12.0, 0.52), (-6.0, 0.64), (0.0, 0.76), (6.0, 0.88), (15.0, 1.0)]; +/// Labels on the fader scale. +const SCALE: [f64; 10] = [15.0, 6.0, 0.0, -6.0, -12.0, -20.0, -30.0, -40.0, -60.0, -96.0]; +const STRIP_W: f32 = 98.0; +const SLOT_H: f32 = 15.0; +const RED: Color32 = Color32::from_rgb(0xd8, 0x50, 0x3f); +const MUTE_ON: Color32 = Color32::from_rgb(0x2d, 0x9d, 0x78); +const SOLO_ON: Color32 = Color32::from_rgb(0xf0, 0xf0, 0x4f); + +/// Fader position (0 bottom … 1 top) of a dB value. +pub fn db_to_pos(db: f64) -> f32 { + if db <= TAPER[0].0 { + return 0.0; + } + for w in TAPER.windows(2) { + let ((d0, p0), (d1, p1)) = (w[0], w[1]); + if db <= d1 { + return p0 + (p1 - p0) * ((db - d0) / (d1 - d0)) as f32; + } + } + 1.0 +} + +/// dB value of a fader position. +pub fn pos_to_db(p: f32) -> f64 { + let p = p.clamp(0.0, 1.0); + for w in TAPER.windows(2) { + let ((d0, p0), (d1, p1)) = (w[0], w[1]); + if p <= p1 { + let db = d0 + (d1 - d0) * ((p - p0) / (p1 - p0)) as f64; + return if db <= FADER_MIN_DB + 0.5 { FADER_MIN_DB } else { db }; + } + } + FADER_MAX_DB +} + +fn db_text(db: f64) -> String { + if db <= FADER_MIN_DB { "-∞".into() } else { format!("{db:.1}") } +} + +// ------------------------------------------------------------------------------------- meters + +/// Per-strip meter state, `strip id → [level L, level R, peak L, peak R]` (dBFS), fed by the playing +/// mix ([`filmcraft_render::mixer::LiveMix`]) with fast attack and a 20 dB/s fall. +pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap { + let id = egui::Id::new("mixer-meters"); + let frame = ui.ctx().cumulative_frame_nr(); + let (last, mut st): (u64, HashMap) = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + if last == frame && frame != 0 { + return st; + } + // without an audio device, the loudness feed is what mixes the programme (and posts meters) + super::meters::feed_loudness(app); + let fresh = app.session.previews.live.take_meters(); + let dt = ui.input(|i| i.stable_dt).min(0.1); + for v in st.values_mut() { + v[0] = (v[0] - 20.0 * dt).max(-90.0); + v[1] = (v[1] - 20.0 * dt).max(-90.0); + v[2] = (v[2] - 6.0 * dt).max(-90.0); + v[3] = (v[3] - 6.0 * dt).max(-90.0); + } + if app.playback.playing { + for (k, p) in fresh { + let e = st.entry(k.0).or_insert([-90.0; 4]); + for c in 0..2 { + let l = 20.0 * p[c].max(1e-6).log10(); + e[c] = e[c].max(l); + e[c + 2] = e[c + 2].max(l); + } + } + ui.ctx().request_repaint(); + } + ui.data_mut(|d| d.insert_temp(id, (frame, st.clone()))); + st +} + +fn draw_meters(ui: &egui::Ui, r: Rect, m: [f32; 4], t: &Tokens) { + let p = ui.painter(); + p.rect_filled(r, 0.0, Color32::BLACK); + let w = (r.width() - 2.0) / 2.0; + for c in 0..2 { + let br = Rect::from_min_size(pos2(r.min.x + c as f32 * (w + 2.0), r.min.y + 6.0), vec2(w, r.height() - 6.0)); + crate::widgets::meter_bar(p, br, m[c], m[c + 2], t); + // clip light + let lr = Rect::from_min_size(pos2(br.min.x, r.min.y), vec2(w, 4.0)); + p.rect_filled(lr, 0.0, if m[c + 2] >= -0.1 { RED } else { Color32::from_gray(0x30) }); + } +} + +// ------------------------------------------------------------------------------------- helpers + +struct Ctx<'a> { + t: Tokens, + seq: &'a Sequence, + now: filmcraft_time::Tick, + recording: bool, + acts: Vec<(String, Value)>, +} + +impl Ctx<'_> { + fn cmd(&mut self, id: &str, p: Value) { + self.acts.push((id.to_string(), p)); + } +} + +/// The value a control shows: a held override, else the automation (or static value) at the playhead. +fn shown(app: &FilmcraftApp, tr: &Track, lane: &str, now: filmcraft_time::Tick) -> f64 { + app.session.previews.live.get(tr.id, lane).map(|o| o.value).unwrap_or_else(|| tr.lane_value(lane, now)) +} + +fn small_text(ui: &egui::Ui, pos: egui::Pos2, align: Align2, s: &str, size: f32, c: Color32) { + ui.painter().text(pos, align, s, Tokens::ui(size), c); +} + +fn slot_box(ui: &mut egui::Ui, r: Rect, text: &str, filled: bool, dim: bool, t: &Tokens, id: egui::Id) -> egui::Response { + let resp = ui.interact(r, id, Sense::click()); + ui.painter().rect_filled(r, 2.0, if resp.hovered() { t.hover } else { t.field_bg }); + ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + let col = if !filled || dim { t.text_faint } else { t.text }; + let galley = ui.painter().layout_no_wrap(text.to_string(), Tokens::ui(10.0), col); + let clip = ui.painter().with_clip_rect(r.shrink(1.0).intersect(ui.clip_rect())); + clip.galley(pos2(r.min.x + 4.0, r.center().y - galley.size().y / 2.0), galley, col); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 7.0, r.center().y), vec2(7.0, 7.0)), Icon::ChevronDown, t.text_dim); + resp +} + +/// The DSP-backed audio effects that can go in an insert slot. +fn insert_effects() -> Vec<&'static filmcraft_project::EffectDef> { + filmcraft_project::effect_defs() + .iter() + .filter(|d| d.kind == filmcraft_project::EffectKind::Audio && !d.intrinsic && filmcraft_render::audio_fx::supported(d.id)) + .collect() +} + +/// Submixes a strip may route to (all of them for tracks; only later ones for submixes). +fn targets(seq: &Sequence, id: TrackId) -> Vec<&Track> { + let me = seq.submix_tracks.iter().position(|t| t.id == id); + seq.submix_tracks.iter().enumerate().filter(|(i, _)| me.is_none_or(|m| *i > m)).map(|(_, t)| t).collect() +} + +// ------------------------------------------------------------------------------------- track mixer + +pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let Some(seq) = app.session.active_sequence().cloned() else { + crate::dock::placeholder(ui, rect, &t, "(no sequence)"); + return; + }; + let meters = poll_meters(app, ui); + let mut cx = Ctx { t, seq: &seq, now: app.session.playhead(), recording: app.session.mixrec.active(), acts: Vec::new() }; + let footer = Rect::from_min_max(pos2(rect.min.x, rect.max.y - 34.0), rect.max); + let body = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 2.0), pos2(rect.max.x, footer.min.y)); + // effects / sends disclosure at the left edge + let arrow = Rect::from_min_size(pos2(body.min.x + 2.0, body.min.y + 4.0), vec2(14.0, 18.0)); + let aresp = ui.interact(arrow, egui::Id::new("mixer-fx-toggle"), Sense::click()).on_hover_text("Show/Hide Effects and Sends"); + icons::paint(ui.painter(), arrow.shrink(2.0), if app.ui.mixer_fx_open { Icon::ChevronDown } else { Icon::ChevronRight }, t.icon); + app.auto.add("mixer.showEffects", arrow, "Show/Hide Effects and Sends"); + if aresp.clicked() { + app.ui.mixer_fx_open = !app.ui.mixer_fx_open; + } + let strips = seq.strip_ids(); + let x0 = body.min.x + 18.0; + let total = strips.len() as f32 * (STRIP_W + 2.0); + let avail = body.max.x - x0 - 2.0; + let max_off = (total - avail).max(0.0); + let off_id = egui::Id::new("mixer-hscroll"); + let mut off: f32 = ui.data(|d| d.get_temp(off_id)).unwrap_or(0.0); + if ui.rect_contains_pointer(body) { + let sd = ui.input(|i| i.smooth_scroll_delta); + off -= sd.x + if ui.input(|i| i.modifiers.shift) { sd.y } else { 0.0 }; + } + off = off.clamp(0.0, max_off); + let sb_h = if max_off > 0.0 { 12.0 } else { 0.0 }; + // strips never get shorter than their controls plus a usable fader: scroll vertically instead + let need = 4.0 + if app.ui.mixer_fx_open { 10.0 * SLOT_H + 10.0 } else { 0.0 } + 112.0 + 26.0 + 24.0 + 6.0 + 170.0 + 42.0; + let clip = Rect::from_min_max(pos2(x0, body.min.y), pos2(body.max.x - 8.0, body.max.y - sb_h)); + let voff_id = egui::Id::new("mixer-vscroll"); + let max_voff = (need - (clip.height() - 4.0)).max(0.0); + let mut voff: f32 = ui.data(|d| d.get_temp(voff_id)).unwrap_or(0.0); + if ui.rect_contains_pointer(body) && !ui.input(|i| i.modifiers.shift) { + voff -= ui.input(|i| i.smooth_scroll_delta.y); + } + voff = voff.clamp(0.0, max_voff); + if max_voff > 0.0 { + let bar = Rect::from_min_max(pos2(body.max.x - 7.0, clip.min.y + 2.0), pos2(body.max.x - 2.0, clip.max.y - 2.0)); + ui.painter().rect_filled(bar, 3.0, t.field_bg); + let kh = (bar.height() * clip.height() / (need + 4.0)).max(20.0); + let ky = bar.min.y + (bar.height() - kh) * voff / max_voff; + let knob = Rect::from_min_size(pos2(bar.min.x, ky), vec2(bar.width(), kh)); + let kr = ui.interact(knob, egui::Id::new("mixer-vscroll-knob"), Sense::drag()); + ui.painter().rect_filled(knob, 3.0, if kr.dragged() { t.accent } else { Color32::from_gray(0x5a) }); + if kr.dragged() { + voff = (voff + kr.drag_delta().y * (need + 4.0) / bar.height()).clamp(0.0, max_voff); + } + app.auto.add("mixer.vscroll", knob, "Scroll strips vertically"); + } + ui.data_mut(|d| d.insert_temp(voff_id, voff)); + if max_off > 0.0 { + let bar = Rect::from_min_max(pos2(x0, body.max.y - 10.0), pos2(body.max.x - 4.0, body.max.y - 2.0)); + ui.painter().rect_filled(bar, 4.0, t.field_bg); + let kw = (bar.width() * avail / total).max(20.0); + let kx = bar.min.x + (bar.width() - kw) * off / max_off; + let knob = Rect::from_min_size(pos2(kx, bar.min.y), vec2(kw, bar.height())); + let kr = ui.interact(knob, egui::Id::new("mixer-hscroll-knob"), Sense::drag()); + ui.painter().rect_filled(knob, 4.0, if kr.dragged() { t.accent } else { Color32::from_gray(0x5a) }); + if kr.dragged() { + off = (off + kr.drag_delta().x * total / bar.width()).clamp(0.0, max_off); + } + app.auto.add("mixer.scroll", knob, "Scroll strips"); + } + ui.data_mut(|d| d.insert_temp(off_id, off)); + let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(clip)); + cui.set_clip_rect(clip.intersect(ui.clip_rect())); + for (i, id) in strips.iter().enumerate() { + let sx = x0 + i as f32 * (STRIP_W + 2.0) - off; + if sx + STRIP_W < clip.min.x || sx > clip.max.x { + continue; + } + let sr = Rect::from_min_size(pos2(sx, clip.min.y + 2.0 - voff), vec2(STRIP_W, (clip.height() - 4.0).max(need))); + let m = meters.get(&id.0).copied().unwrap_or([-90.0; 4]); + strip(app, &mut cui, &mut cx, *id, sr, m); + } + transport(app, ui, footer, &mut cx); + let acts = std::mem::take(&mut cx.acts); + run_actions(app, &ui.ctx().clone(), acts); +} + +fn run_actions(app: &mut FilmcraftApp, ctx: &egui::Context, acts: Vec<(String, Value)>) { + for (id, p) in acts { + let r = if filmcraft_engine::find_command(&id).is_some() { + app.session.execute(&id, p).map(|_| ()).map_err(|e| e.to_string()) + } else { + crate::menus::invoke(app, ctx, &id, p).map(|_| ()) + }; + if let Err(e) = r { + app.ui.status = e; + } + } +} + +fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, sr: Rect, meter: [f32; 4]) { + let t = cx.t; + let seq = cx.seq; + let Some(tr) = seq.strip(id).map(|c| c.into_owned()) else { return }; + let label = strip_label(seq, id); + let ap = format!("mixer.{label}"); + let is_master = id == MASTER_STRIP; + let sid = json!(id.0); + ui.painter().rect_filled(sr, 3.0, Color32::from_gray(0x24)); + ui.painter().line_segment([pos2(sr.max.x + 1.0, sr.min.y), pos2(sr.max.x + 1.0, sr.max.y)], Stroke::new(1.0, t.separator)); + let x = sr.min.x; + let w = sr.width(); + let mut y = sr.min.y + 4.0; + + // ---- effect and send slots + if app.ui.mixer_fx_open { + for k in 0..filmcraft_project::mixer::MAX_INSERTS { + let r = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, SLOT_H - 2.0)); + let fx = tr.effects.get(k); + let text = match fx { + Some(e) => format!("{}{}", if e.post_fader { "▸ " } else { "" }, e.def().map(|d| d.name).unwrap_or(&e.effect)), + None => String::new(), + }; + let resp = slot_box(ui, r, &text, fx.is_some(), fx.is_some_and(|e| !e.enabled), &t, egui::Id::new((&ap, "fx", k))); + app.auto.add(&format!("{ap}.fx.{k}"), r, &if text.is_empty() { format!("Effect slot {}", k + 1) } else { text.clone() }); + let can_add = fx.is_none() && k == tr.effects.len(); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(180.0); + match fx { + Some(e) => { + let on = e.enabled; + let r1 = ui.selectable_label(!on, "Bypass"); + app.auto.add(&format!("{ap}.fx.{k}.bypass"), r1.rect, "Bypass"); + if r1.clicked() { + cx.cmd("mixer.setInsert", json!({"strip": sid, "slot": k, "enabled": !on})); + } + let r2 = ui.selectable_label(e.post_fader, "Post-Fader"); + app.auto.add(&format!("{ap}.fx.{k}.postFader"), r2.rect, "Post-Fader"); + if r2.clicked() { + cx.cmd("mixer.setInsert", json!({"strip": sid, "slot": k, "postFader": !e.post_fader})); + } + ui.separator(); + let r3 = ui.button("Remove Effect"); + app.auto.add(&format!("{ap}.fx.{k}.remove"), r3.rect, "Remove Effect"); + if r3.clicked() { + cx.cmd("mixer.removeInsert", json!({"strip": sid, "slot": k})); + } + } + None if can_add => { + for d in insert_effects() { + let r = ui.button(d.name); + app.auto.add(&format!("{ap}.fx.{k}.{}", d.id), r.rect, d.name); + if r.clicked() { + cx.cmd("mixer.addInsert", json!({"strip": sid, "effect": d.id})); + } + } + } + None => { + ui.label("Fill the slots above first"); + } + } + }); + y += SLOT_H; + } + y += 4.0; + for k in 0..filmcraft_project::mixer::MAX_SENDS { + let r = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, SLOT_H - 2.0)); + let snd = tr.mixer.sends.get(k); + let text = match snd { + Some(s) => format!("{}{} {}", if s.pre_fader { "pre " } else { "" }, strip_label(seq, s.target), db_text(s.level_db)), + None => String::new(), + }; + if is_master { + y += SLOT_H; + continue; + } + let resp = slot_box(ui, r, &text, snd.is_some(), snd.is_some_and(|s| s.muted), &t, egui::Id::new((&ap, "send", k))); + app.auto.add(&format!("{ap}.send.{k}"), r, &if text.is_empty() { format!("Send slot {}", k + 1) } else { text.clone() }); + let can_add = snd.is_none() && k == tr.mixer.sends.len(); + let tg = targets(seq, id); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(180.0); + match snd { + Some(s) => { + let mut lvl = s.level_db; + let sl = ui.add(egui::Slider::new(&mut lvl, FADER_MIN_DB..=FADER_MAX_DB).text("dB")); + app.auto.add(&format!("{ap}.send.{k}.level"), sl.rect, "Send level"); + if sl.changed() { + cx.cmd("mixer.setSend", json!({"strip": sid, "send": k, "levelDb": lvl})); + } + let r1 = ui.selectable_label(s.pre_fader, "Pre-Fader"); + app.auto.add(&format!("{ap}.send.{k}.preFader"), r1.rect, "Pre-Fader"); + if r1.clicked() { + cx.cmd("mixer.setSend", json!({"strip": sid, "send": k, "preFader": !s.pre_fader})); + } + let r2 = ui.selectable_label(s.muted, "Mute Send"); + app.auto.add(&format!("{ap}.send.{k}.mute"), r2.rect, "Mute Send"); + if r2.clicked() { + cx.cmd("mixer.setSend", json!({"strip": sid, "send": k, "muted": !s.muted})); + } + ui.separator(); + let r3 = ui.button("Remove Send"); + app.auto.add(&format!("{ap}.send.{k}.remove"), r3.rect, "Remove Send"); + if r3.clicked() { + cx.cmd("mixer.removeSend", json!({"strip": sid, "send": k})); + } + } + None if can_add => { + for s in &tg { + let lbl = format!("{} ({})", strip_label(seq, s.id), s.name); + let r = ui.button(&lbl); + app.auto.add(&format!("{ap}.send.{k}.{}", strip_label(seq, s.id)), r.rect, &lbl); + if r.clicked() { + cx.cmd("mixer.addSend", json!({"strip": sid, "target": s.id.0})); + } + } + if seq.submix_tracks.iter().all(|s| s.id != id) { + let r = ui.button("New Submix"); + app.auto.add(&format!("{ap}.send.{k}.newSubmix"), r.rect, "New Submix"); + if r.clicked() { + let n = seq.submix_tracks.len(); + cx.cmd("mixer.addSubmix", json!({})); + cx.cmd("mixer.addSend", json!({"strip": sid, "target": format!("S{}", n + 1)})); + } + } + } + None => { + ui.label("Fill the slots above first"); + } + } + }); + y += SLOT_H; + } + y += 6.0; + } + + // ---- input / output + if !is_master { + let ir = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); + let iresp = crate::widgets::dropdown_text(ui, ir, tr.mixer.input_map.label(), &t, egui::Id::new((&ap, "input"))); + app.auto.add(&format!("{ap}.input"), ir, "Input channel mapping"); + egui::Popup::menu(&iresp).show(|ui| { + for m in InputMap::ALL { + let r = ui.selectable_label(m == tr.mixer.input_map, m.label()); + app.auto.add(&format!("{ap}.input.{}", m.label()), r.rect, m.label()); + if r.clicked() { + cx.cmd("mixer.setStrip", json!({"strip": sid, "inputMap": m.label()})); + } + } + }); + y += 24.0; + let or = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); + let out_label = tr.mixer.output.map(|o| seq.mix_track(o).map(|s| s.name.clone()).unwrap_or_else(|| "Mix".into())).unwrap_or_else(|| "Mix".into()); + let oresp = crate::widgets::dropdown_text(ui, or, &out_label, &t, egui::Id::new((&ap, "output"))); + app.auto.add(&format!("{ap}.output"), or, "Track output assignment"); + let tg = targets(seq, id); + egui::Popup::menu(&oresp).show(|ui| { + let r = ui.selectable_label(tr.mixer.output.is_none(), "Mix"); + app.auto.add(&format!("{ap}.output.Mix"), r.rect, "Mix"); + if r.clicked() { + cx.cmd("mixer.setStrip", json!({"strip": sid, "output": "Mix"})); + } + for s in &tg { + let r = ui.selectable_label(tr.mixer.output == Some(s.id), &s.name); + app.auto.add(&format!("{ap}.output.{}", strip_label(seq, s.id)), r.rect, &s.name); + if r.clicked() { + cx.cmd("mixer.setStrip", json!({"strip": sid, "output": s.id.0})); + } + } + }); + y += 26.0; + // ---- pan knob + let pan = shown(app, &tr, LANE_PAN, cx.now); + let kc = pos2(sr.center().x, y + 20.0); + let kr = Rect::from_center_size(kc, vec2(38.0, 38.0)); + let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()).on_hover_text("Pan / balance (drag; double-click: centre)"); + let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { Color32::from_gray(0xb0) }; + ui.painter().circle_stroke(kc, 16.0, Stroke::new(2.0, ring)); + let ang = (pan / 100.0) as f32 * 135f32.to_radians(); + ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 14.0], Stroke::new(2.0, ring)); + small_text(ui, pos2(kc.x - 22.0, kc.y + 17.0), Align2::CENTER_CENTER, "L", 11.0, t.hot_text); + small_text(ui, pos2(kc.x + 22.0, kc.y + 17.0), Align2::CENTER_CENTER, "R", 11.0, t.hot_text); + app.auto.add(&format!("{ap}.pan"), kr, "Pan"); + if kresp.dragged() { + let d = kresp.drag_delta(); + let np = (pan + (d.x - d.y) as f64).clamp(-100.0, 100.0); + if np != pan { + cx.cmd("mixer.touch", json!({"strip": sid, "lane": LANE_PAN, "value": np, "time": cx.now.0})); + } + } + if kresp.drag_stopped() { + cx.cmd("mixer.release", json!({"strip": sid, "lane": LANE_PAN, "time": cx.now.0})); + } + if kresp.double_clicked() { + cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN, "value": 0.0})); + } + let vr = Rect::from_center_size(pos2(kc.x, y + 48.0), vec2(56.0, 16.0)); + let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(vr).layout(egui::Layout::top_down(egui::Align::Center))); + let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "panValue")), pan, 1.0, (-100.0, 100.0), 1, "", &t); + app.auto.add(&format!("{ap}.panValue"), vresp.rect, &format!("{pan:.1}")); + if let Some(v) = nv { + cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN, "value": v})); + } + y += 62.0; + } else { + y += 112.0; + } + + // ---- automation mode + let mr = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); + let mode = tr.mixer.mode; + let mresp = crate::widgets::dropdown_text(ui, mr, mode.label(), &t, egui::Id::new((&ap, "mode"))); + if cx.recording && mode.writes() { + ui.painter().rect_stroke(mr, 4.0, Stroke::new(1.0, RED), StrokeKind::Inside); + } + app.auto.add(&format!("{ap}.mode"), mr, &format!("Automation mode: {}", mode.label())); + egui::Popup::menu(&mresp).show(|ui| { + for m in AutomationMode::ALL { + let r = ui.selectable_label(m == mode, m.label()); + app.auto.add(&format!("{ap}.mode.{}", m.label()), r.rect, m.label()); + if r.clicked() { + cx.cmd("mixer.setStrip", json!({"strip": sid, "mode": m.label()})); + } + } + }); + y += 26.0; + + // ---- M / S / R + if !is_master { + let muted = shown(app, &tr, LANE_MUTE, cx.now) >= 0.5; + let bw = 18.0; + let bx = sr.center().x - bw * 1.5 - 6.0; + let b = |i: f32| Rect::from_min_size(pos2(bx + i * (bw + 6.0), y), vec2(bw, 16.0)); + let m_r = crate::widgets::letter_toggle(ui, b(0.0), "M", muted, MUTE_ON, &t, egui::Id::new((&ap, "M"))); + app.auto.add(&format!("{ap}.mute"), b(0.0), "Mute Track"); + if m_r.clicked() { + if cx.recording && mode.writes() { + let v = if muted { 0.0 } else { 1.0 }; + cx.cmd("mixer.touch", json!({"strip": sid, "lane": LANE_MUTE, "value": v, "time": cx.now.0})); + cx.cmd("mixer.release", json!({"strip": sid, "lane": LANE_MUTE, "time": cx.now.0})); + } else { + cx.cmd("mixer.setStrip", json!({"strip": sid, "muted": !tr.muted})); + } + } + let s_r = crate::widgets::letter_toggle(ui, b(1.0), "S", tr.solo, SOLO_ON, &t, egui::Id::new((&ap, "S"))); + app.auto.add(&format!("{ap}.solo"), b(1.0), "Solo Track"); + if s_r.clicked() { + cx.cmd("mixer.setStrip", json!({"strip": sid, "solo": !tr.solo})); + } + let is_sub = seq.submix_tracks.iter().any(|s| s.id == id); + if is_sub { + let r_r = crate::widgets::letter_toggle(ui, b(2.0), "◆", tr.mixer.solo_safe, Color32::from_gray(0xc8), &t, egui::Id::new((&ap, "safe"))) + .on_hover_text("Solo safe"); + app.auto.add(&format!("{ap}.soloSafe"), b(2.0), "Solo Safe"); + if r_r.clicked() { + cx.cmd("mixer.setStrip", json!({"strip": sid, "soloSafe": !tr.mixer.solo_safe})); + } + } else { + let r_r = crate::widgets::letter_toggle(ui, b(2.0), "R", tr.mixer.record_arm, RED, &t, egui::Id::new((&ap, "R"))) + .on_hover_text("Enable track for recording"); + app.auto.add(&format!("{ap}.record"), b(2.0), "Enable Track for Recording"); + if r_r.clicked() { + cx.cmd("mixer.setStrip", json!({"strip": sid, "recordArm": !tr.mixer.record_arm})); + } + } + } + y += 24.0; + + // ---- fader + meters + let name_h = 20.0; + let value_h = 18.0; + let area = Rect::from_min_max(pos2(x, y + 6.0), pos2(sr.max.x, sr.max.y - name_h - value_h - 4.0)); + if area.height() > 40.0 { + let track = Rect::from_min_max(pos2(x + 33.0, area.min.y + 8.0), pos2(x + 37.0, area.max.y - 8.0)); + let ypos = |db: f64| track.max.y - db_to_pos(db) * track.height(); + let tall = track.height() > 150.0; + // scale + small_text(ui, pos2(x + 26.0, area.min.y - 2.0), Align2::RIGHT_CENTER, "dB", 9.0, t.text_dim); + for d in SCALE.iter().copied().filter(|d| tall || [15.0, 0.0, -12.0, -30.0, -96.0].contains(d)) { + let yy = ypos(d); + let s = if d <= FADER_MIN_DB { "-∞".to_string() } else { format!("{}", d as i32) }; + small_text(ui, pos2(x + 26.0, yy), Align2::RIGHT_CENTER, &s, 9.0, t.text_dim); + ui.painter().line_segment([pos2(x + 28.0, yy), pos2(x + 31.0, yy)], Stroke::new(1.0, t.text_faint)); + } + ui.painter().rect_filled(track, 2.0, Color32::from_gray(0x10)); + ui.painter().rect_stroke(track, 2.0, Stroke::new(1.0, Color32::from_gray(0x50)), StrokeKind::Inside); + let vol = shown(app, &tr, LANE_VOLUME, cx.now); + let cy = ypos(vol); + let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0)); + let fresp = ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()).on_hover_text("Volume (drag; double-click: 0 dB)"); + let held = app.session.previews.live.get(id, LANE_VOLUME).is_some(); + let col = if fresp.dragged() || held { t.accent } else { Color32::from_gray(0xe0) }; + ui.painter().rect_filled(cap, 3.0, Color32::from_gray(0x2a)); + ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, col), StrokeKind::Inside); + ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, col)); + app.auto.add(&format!("{ap}.fader"), cap, &format!("Volume {}", db_text(vol))); + if fresp.dragged() { + let dy = fresp.drag_delta().y; + if dy != 0.0 { + let fine = if ui.input(|i| i.modifiers.command) { 0.1 } else { 1.0 }; + let nv = pos_to_db(db_to_pos(vol) - dy * fine / track.height()); + cx.cmd("mixer.touch", json!({"strip": sid, "lane": LANE_VOLUME, "value": nv, "time": cx.now.0})); + } else if fresp.drag_started() { + cx.cmd("mixer.touch", json!({"strip": sid, "lane": LANE_VOLUME, "value": vol, "time": cx.now.0})); + } + } + if fresp.drag_stopped() { + cx.cmd("mixer.release", json!({"strip": sid, "lane": LANE_VOLUME, "time": cx.now.0})); + } + if fresp.double_clicked() { + cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_VOLUME, "value": 0.0})); + } + // meters with their own scale (0 … −54 dB) + let mrect = Rect::from_min_max(pos2(x + 46.0, area.min.y), pos2(x + 70.0, area.max.y)); + draw_meters(ui, mrect, meter, &t); + app.auto.add(&format!("{ap}.meter"), mrect, &format!("Meter {:.1} / {:.1} dB", meter[0], meter[1])); + let bar_top = mrect.min.y + 6.0; + let bar_h = mrect.height() - 6.0; + let mut d = 0; + let step = if tall { 6 } else { 18 }; + while d >= -54 { + let yy = bar_top + bar_h * (-d as f32 / 60.0); + small_text(ui, pos2(x + 91.0, yy), Align2::RIGHT_CENTER, &format!("{d}"), 8.5, t.text_dim); + ui.painter().line_segment([pos2(x + 71.0, yy), pos2(x + 74.0, yy)], Stroke::new(1.0, t.text_faint)); + d -= step; + } + small_text(ui, pos2(x + 91.0, mrect.max.y + 6.0), Align2::RIGHT_CENTER, "dB", 8.5, t.text_dim); + // fader value (hot text) + let vr = Rect::from_min_size(pos2(x + 4.0, sr.max.y - name_h - value_h), vec2(56.0, value_h)); + let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(vr)); + let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); + app.auto.add(&format!("{ap}.value"), vresp.rect, &db_text(vol)); + if let Some(v) = nv { + cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_VOLUME, "value": v})); + } + } + // ---- name + let nr = Rect::from_min_max(pos2(x, sr.max.y - name_h), sr.max); + ui.painter().line_segment([pos2(nr.min.x + 4.0, nr.min.y), pos2(nr.max.x - 4.0, nr.min.y)], Stroke::new(1.0, t.separator)); + if is_master { + small_text(ui, nr.center(), Align2::CENTER_CENTER, "Mix", 12.0, t.text); + } else { + small_text(ui, pos2(nr.min.x + 6.0, nr.center().y), Align2::LEFT_CENTER, &label, 12.0, t.text); + let name = ui.painter().with_clip_rect(Rect::from_min_max(pos2(nr.min.x + 26.0, nr.min.y), nr.max).intersect(ui.clip_rect())); + name.text(pos2(nr.min.x + 30.0, nr.center().y), Align2::LEFT_CENTER, &tr.name, Tokens::ui(12.0), t.text); + } + app.auto.add(&format!("{ap}.name"), nr, &tr.name); +} + +fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, cx: &mut Ctx) { + let t = cx.t; + let seq = cx.seq; + ui.painter().line_segment([pos2(row.min.x, row.min.y + 0.5), pos2(row.max.x, row.min.y + 0.5)], Stroke::new(1.0, t.separator)); + let tc = format_time(cx.now, seq.settings.frame_rate, seq.settings.drop_frame, TimeDisplay::Timecode, seq.settings.sample_rate as i64); + ui.painter().text(pos2(row.min.x + 12.0, row.center().y), Align2::LEFT_CENTER, tc, Tokens::mono(13.0), t.timecode); + let dur = format_time(seq.duration(), seq.settings.frame_rate, seq.settings.drop_frame, TimeDisplay::Timecode, seq.settings.sample_rate as i64); + ui.painter().text(pos2(row.max.x - 12.0, row.center().y), Align2::RIGHT_CENTER, dur, Tokens::mono(12.0), t.text_dim); + let playing = app.playback.playing; + let buttons: [(Icon, &str, &str); 6] = [ + (Icon::GoToIn, "markers.goToIn", "Go to In Point"), + (Icon::GoToOut, "markers.goToOut", "Go to Out Point"), + (if playing { Icon::Pause } else { Icon::Play }, "playback.toggle", "Play-Stop Toggle (Space)"), + (Icon::Play, "playback.inToOut", "Play In to Out"), + (Icon::Loop, "playback.loop", "Loop"), + (Icon::Mic, "record", "Record (voice-over recording is not available yet)"), + ]; + let bw = 30.0; + let mut x = row.center().x - bw * buttons.len() as f32 / 2.0; + for (icon, cmd, tip) in buttons { + let r = Rect::from_min_size(pos2(x, row.min.y + 4.0), vec2(bw - 2.0, 26.0)); + let resp = ui.interact(r, egui::Id::new(("mixer-transport", cmd)), Sense::click()).on_hover_text(tip); + app.auto.add(&format!("mixer.transport.{cmd}"), r, tip); + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover); + } + let on = cmd == "playback.loop" && app.playback.looping; + let col = if on { + t.accent + } else if resp.hovered() { + t.tab_text_active + } else { + t.icon + }; + if cmd == "record" { + ui.painter().circle_filled(r.center(), 6.0, if cx.recording { RED } else { RED.gamma_multiply(0.55) }); + } else { + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), icon, col); + if cmd == "playback.inToOut" { + // braces around the play triangle + let c = r.center(); + for s in [-1.0f32, 1.0] { + ui.painter().line_segment([pos2(c.x + s * 10.0, c.y - 6.0), pos2(c.x + s * 10.0, c.y + 6.0)], Stroke::new(1.5, col)); + } + } + } + if resp.clicked() && cmd != "record" { + cx.cmd(cmd, json!({})); + } + x += bw; + } +} + +// ------------------------------------------------------------------------------------- clip mixer + +/// Audio Clip Mixer: one strip per audio track, controlling the clip under the playhead (clip +/// Volume and Panner). With the keyframe button on, changes add keyframes at the playhead. +pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let Some(seq) = app.session.active_sequence().cloned() else { + crate::dock::placeholder(ui, rect, &t, "(no sequence)"); + return; + }; + let meters = poll_meters(app, ui); + let now = app.session.playhead(); + let mut acts: Vec<(String, Value)> = Vec::new(); + let kf_id = egui::Id::new("clip-mixer-keyframes"); + let mut write_kf: HashMap = ui.data(|d| d.get_temp(kf_id)).unwrap_or_default(); + for (i, tr) in seq.audio_tracks.iter().enumerate() { + let x = rect.min.x + 6.0 + i as f32 * (STRIP_W + 2.0); + if x + STRIP_W > rect.max.x { + break; + } + let sr = Rect::from_min_max(pos2(x, rect.min.y + 4.0), pos2(x + STRIP_W, rect.max.y - 4.0)); + let label = format!("A{}", i + 1); + let ap = format!("clipMixer.{label}"); + ui.painter().rect_filled(sr, 3.0, Color32::from_gray(0x24)); + let clip = tr.item_at(now).filter(|c| c.enabled); + let active = clip.is_some(); + let dimc = |c: Color32| if active { c } else { c.gamma_multiply(0.4) }; + let mt = clip.map(|c| c.source_time_at(now)); + let vol = clip.and_then(|c| c.effect("volume")).map(|e| e.f64_at("level", mt.unwrap_or_default())).unwrap_or(0.0); + let pan = clip.and_then(|c| c.effect("panner")).map(|e| e.f64_at("balance", mt.unwrap_or_default())).unwrap_or(0.0); + let kf = *write_kf.get(&tr.id.0).unwrap_or(&false); + let set = |acts: &mut Vec<(String, Value)>, effect: &str, param: &str, v: f64, begin: bool| { + if let Some(c) = clip { + acts.push(("clipMixer.set".into(), json!({"clip": c.id.0, "effect": effect, "param": param, "value": v, "keyframe": kf, "begin": begin}))); + } + }; + // pan knob + let kc = pos2(sr.center().x, sr.min.y + 26.0); + let kr = Rect::from_center_size(kc, vec2(34.0, 34.0)); + let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()); + ui.painter().circle_stroke(kc, 14.0, Stroke::new(2.0, dimc(Color32::from_gray(0xb0)))); + let ang = (pan / 100.0) as f32 * 135f32.to_radians(); + ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 12.0], Stroke::new(2.0, dimc(Color32::from_gray(0xb0)))); + small_text(ui, pos2(kc.x - 18.0, kc.y + 16.0), Align2::CENTER_CENTER, "L", 11.0, dimc(t.hot_text)); + small_text(ui, pos2(kc.x + 18.0, kc.y + 16.0), Align2::CENTER_CENTER, "R", 11.0, dimc(t.hot_text)); + small_text(ui, pos2(kc.x, kc.y + 32.0), Align2::CENTER_CENTER, &format!("{pan:.1}"), 11.5, dimc(t.hot_text)); + app.auto.add(&format!("{ap}.pan"), kr, "Clip pan"); + if kresp.dragged() && active { + let d = kresp.drag_delta(); + set(&mut acts, "panner", "balance", (pan + (d.x - d.y) as f64).clamp(-100.0, 100.0), kresp.drag_started()); + } + if kresp.double_clicked() { + set(&mut acts, "panner", "balance", 0.0, true); + } + // M S ◇ + let y = sr.min.y + 72.0; + let b = |k: f32| Rect::from_min_size(pos2(sr.center().x - 33.0 + k * 24.0, y), vec2(18.0, 16.0)); + let mr = crate::widgets::letter_toggle(ui, b(0.0), "M", tr.muted, MUTE_ON, &t, egui::Id::new((&ap, "M"))); + app.auto.add(&format!("{ap}.mute"), b(0.0), "Mute Track"); + if mr.clicked() { + acts.push(("mixer.setStrip".into(), json!({"strip": tr.id.0, "muted": !tr.muted}))); + } + let so = crate::widgets::letter_toggle(ui, b(1.0), "S", tr.solo, SOLO_ON, &t, egui::Id::new((&ap, "S"))); + app.auto.add(&format!("{ap}.solo"), b(1.0), "Solo Track"); + if so.clicked() { + acts.push(("mixer.setStrip".into(), json!({"strip": tr.id.0, "solo": !tr.solo}))); + } + let kresp = + crate::widgets::icon_toggle(ui, b(2.0), Icon::Keyframe, kf, &t, egui::Id::new((&ap, "kf")), Some(t.accent)).on_hover_text("Write keyframes"); + app.auto.add(&format!("{ap}.keyframe"), b(2.0), "Write Keyframes"); + if kresp.clicked() { + write_kf.insert(tr.id.0, !kf); + } + // fader + meter + let area = Rect::from_min_max(pos2(sr.min.x, y + 30.0), pos2(sr.max.x, sr.max.y - 46.0)); + if area.height() > 40.0 { + let track = Rect::from_min_max(pos2(sr.min.x + 33.0, area.min.y + 8.0), pos2(sr.min.x + 37.0, area.max.y - 8.0)); + let ypos = |db: f64| track.max.y - db_to_pos(db) * track.height(); + for d in SCALE { + let s = if d <= FADER_MIN_DB { "-∞".to_string() } else { format!("{}", d as i32) }; + small_text(ui, pos2(sr.min.x + 26.0, ypos(d)), Align2::RIGHT_CENTER, &s, 9.0, t.text_dim); + } + ui.painter().rect_filled(track, 2.0, Color32::from_gray(0x10)); + let cy = ypos(vol); + let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0)); + let fresp = ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()); + ui.painter().rect_filled(cap, 3.0, Color32::from_gray(0x2a)); + ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, dimc(Color32::from_gray(0xe0))), StrokeKind::Inside); + ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, dimc(Color32::from_gray(0xe0)))); + app.auto.add(&format!("{ap}.fader"), cap, &format!("Clip volume {}", db_text(vol))); + if fresp.dragged() && active && fresp.drag_delta().y != 0.0 { + set(&mut acts, "volume", "level", pos_to_db(db_to_pos(vol) - fresp.drag_delta().y / track.height()), fresp.drag_started()); + } + if fresp.double_clicked() { + set(&mut acts, "volume", "level", 0.0, true); + } + let mrect = Rect::from_min_max(pos2(sr.min.x + 46.0, area.min.y), pos2(sr.max.x - 6.0, area.max.y)); + draw_meters(ui, mrect, meters.get(&tr.id.0).copied().unwrap_or([-90.0; 4]), &t); + } + small_text(ui, pos2(sr.min.x + 8.0, sr.max.y - 34.0), Align2::LEFT_CENTER, &db_text(vol), 11.5, dimc(t.hot_text)); + app.auto.add(&format!("{ap}.value"), Rect::from_min_size(pos2(sr.min.x + 4.0, sr.max.y - 42.0), vec2(50.0, 16.0)), &db_text(vol)); + small_text(ui, pos2(sr.min.x + 6.0, sr.max.y - 10.0), Align2::LEFT_CENTER, &label, 12.0, t.text); + small_text(ui, pos2(sr.min.x + 30.0, sr.max.y - 10.0), Align2::LEFT_CENTER, &tr.name, 12.0, t.text); + } + ui.data_mut(|d| d.insert_temp(kf_id, write_kf)); + run_actions(app, &ui.ctx().clone(), acts); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn taper_roundtrips_and_is_monotonic() { + let mut last = f64::NEG_INFINITY; + for i in 0..=100 { + let p = i as f32 / 100.0; + let db = pos_to_db(p); + assert!(db >= last - 1e-9); + last = db; + if db > FADER_MIN_DB { + assert!((db_to_pos(db) - p).abs() < 1e-4, "{p} → {db}"); + } + } + assert_eq!(db_to_pos(0.0), 0.76); + assert_eq!(pos_to_db(1.0), 15.0); + assert_eq!(pos_to_db(0.0), FADER_MIN_DB); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 560d6a361..0c5e71a5b 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -10,10 +10,12 @@ pub mod import_mode; pub mod lumetri; pub mod meters; pub mod misc; +pub mod mixer; pub mod monitor; pub mod project; pub mod text; pub mod timeline; +pub mod timeline_automation; pub mod timeline_captions; pub mod tools; @@ -40,7 +42,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) PanelKind::Markers => misc::markers(app, ui, rect), PanelKind::Info => misc::info(app, ui, rect), PanelKind::MediaBrowser => misc::media_browser(app, ui, rect), - PanelKind::AudioTrackMixer => meters::track_mixer(app, ui, rect), + PanelKind::AudioTrackMixer => mixer::track_mixer(app, ui, rect), + PanelKind::AudioClipMixer => mixer::clip_mixer(app, ui, rect), PanelKind::LumetriScopes => misc::scopes(app, ui, rect), PanelKind::Text => text::show(app, ui, rect), other => crate::dock::placeholder(ui, rect, &app.tokens, &format!("{} — coming in a later milestone", other.title())), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 2170471fd..af95b9ed7 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -343,6 +343,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // ---- interaction interact(app, ui, &seq, &layout, rect); + // track keyframes (drawn and edited on top of the clips) + super::timeline_automation::show(app, ui, &seq, &layout, aclip); if cap_n > 0 { super::timeline_captions::interact(app, ui, &seq, caption_area, &layout); } @@ -744,6 +746,8 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: } let vr = small(x + 42.0, upper_y); crate::widgets::icon_toggle(ui, vr.intersect(visible), Icon::Mic, true, t, egui::Id::new(("vo", r.track.0)), Some(t.text_dim)); + let kr = small(x + 64.0, upper_y); + super::timeline_automation::header_button(app, ui, seq, r, kr, visible, &label, t); } // 5. name on the lower line (or inline when short) if hrect.height() >= 40.0 { diff --git a/crates/ui-egui/src/panels/timeline_automation.rs b/crates/ui-egui/src/panels/timeline_automation.rs new file mode 100644 index 000000000..e34e6c254 --- /dev/null +++ b/crates/ui-egui/src/panels/timeline_automation.rs @@ -0,0 +1,240 @@ +//! Track keyframes in the timeline. +//! +//! An audio track can show one of its automation lanes (Volume, Panner, Mute, send levels, insert +//! parameters) instead of clip keyframes: the header's keyframe button picks it (Premiere's +//! "Show Keyframes ▸ Track Keyframes"). The lane is drawn across the whole track as a line with +//! keyframe diamonds; the Pen tool (or ⌘-click) adds keyframes, dragging a diamond moves it in time +//! and value, right-click deletes. Everything goes through `mixer.setKeyframe` / +//! `mixer.moveKeyframe` / `mixer.deleteKeyframe`. +//! +//! Automation ids: `timeline.track.A1.keyframes` (the header button) with its menu entries +//! (`timeline.track.A1.keyframes.`, `….clip`), `timeline.track.A1.lane` (the band) and +//! `timeline.track.A1.lane.kf.` (the diamonds). + +use egui::{Align2, Color32, CursorIcon, Rect, Sense, Stroke, pos2, vec2}; +use filmcraft_project::mixer::{LANE_MUTE, LANE_PAN, LANE_VOLUME, lane_info, parse_fx_lane, send_lane}; +use filmcraft_project::{ParamKind, Sequence, Track, TrackKind}; +use filmcraft_time::Tick; +use serde_json::json; + +use super::timeline::{Layout, Row}; +use crate::FilmcraftApp; +use crate::icons::{self, Icon}; +use crate::state::Tool; +use crate::theme::Tokens; + +const LANE_COL: Color32 = Color32::from_rgb(0xe8, 0xc5, 0x47); + +/// The lanes an audio track can show: (key, label). +pub fn lane_options(tr: &Track) -> Vec<(String, String)> { + let mut v = + vec![(LANE_VOLUME.to_string(), "Volume".to_string()), (LANE_PAN.to_string(), "Panner".to_string()), (LANE_MUTE.to_string(), "Mute".to_string())]; + for (i, s) in tr.mixer.sends.iter().enumerate() { + let _ = s; + v.push((send_lane(i), format!("Send {} Level", i + 1))); + } + for (slot, e) in tr.effects.iter().enumerate() { + let Some(def) = e.def() else { continue }; + for p in def.params.iter().filter(|p| matches!(p.kind, ParamKind::Float { .. }) && p.animatable) { + v.push((filmcraft_project::mixer::fx_lane(slot, p.id), format!("{}: {}", def.name, p.label))); + } + } + v +} + +/// Value range of a lane: (min, max, hold). +fn range(tr: &Track, key: &str) -> (f64, f64, bool) { + if let Some(i) = lane_info(key) { + return (i.min, i.max, i.hold); + } + if let Some((slot, pid)) = parse_fx_lane(key) + && let Some(ParamKind::Float { soft_min, soft_max, .. }) = tr.effects.get(slot).and_then(|e| e.def()).and_then(|d| d.param(pid)).map(|p| p.kind.clone()) + { + return (soft_min, soft_max, false); + } + (0.0, 1.0, false) +} + +/// 0 (bottom) … 1 (top) for a value. +fn norm(tr: &Track, key: &str, v: f64) -> f32 { + if key == LANE_VOLUME || key.starts_with("send.") { + return super::mixer::db_to_pos(v); + } + let (lo, hi, _) = range(tr, key); + (((v - lo) / (hi - lo).max(1e-9)) as f32).clamp(0.0, 1.0) +} + +fn denorm(tr: &Track, key: &str, n: f32) -> f64 { + if key == LANE_VOLUME || key.starts_with("send.") { + return super::mixer::pos_to_db(n); + } + let (lo, hi, hold) = range(tr, key); + let v = lo + (hi - lo) * n.clamp(0.0, 1.0) as f64; + if hold { v.round() } else { v } +} + +/// The stored automation (shown even when the strip's mode is Off). +fn lane_at(tr: &Track, key: &str, t: Tick) -> f64 { + match tr.lane(key).filter(|p| p.is_animated()) { + Some(p) if range(tr, key).2 => { + let i = p.keyframes.partition_point(|k| k.time <= t); + p.keyframes[i.saturating_sub(1)].value.as_f64().unwrap_or(0.0) + } + Some(p) => p.scalar_at(t), + None => tr.lane_static(key).unwrap_or(0.0), + } +} + +fn band(r: &Row) -> Rect { + Rect::from_min_max(pos2(r.rect.min.x, r.rect.min.y + 17.0), pos2(r.rect.max.x, r.rect.max.y - 4.0)) +} + +/// Header button (diamond) choosing clip keyframes or a track lane. +#[allow(clippy::too_many_arguments)] +pub fn header_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, r: &Row, rect: Rect, visible: Rect, label: &str, t: &Tokens) { + let Some(tr) = seq.track(r.track) else { return }; + let showing = app.ui.timeline.track_lanes.get(&r.track.0).cloned(); + let resp = + crate::widgets::icon_toggle(ui, rect.intersect(visible), Icon::Keyframe, showing.is_some(), t, egui::Id::new(("lanebtn", r.track.0)), Some(LANE_COL)) + .on_hover_text("Show Keyframes"); + app.auto.add(&format!("timeline.track.{label}.keyframes"), rect, "Show Keyframes"); + let opts = lane_options(tr); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(170.0); + let c = ui.selectable_label(showing.is_none(), "Clip Keyframes"); + app.auto.add(&format!("timeline.track.{label}.keyframes.clip"), c.rect, "Clip Keyframes"); + if c.clicked() { + app.ui.timeline.track_lanes.remove(&r.track.0); + } + ui.separator(); + ui.label(egui::RichText::new("Track Keyframes").small()); + for (k, l) in &opts { + let e = ui.selectable_label(showing.as_deref() == Some(k.as_str()), l); + app.auto.add(&format!("timeline.track.{label}.keyframes.{k}"), e.rect, l); + if e.clicked() { + app.ui.timeline.track_lanes.insert(r.track.0, k.clone()); + } + } + }); +} + +#[derive(Clone, Copy)] +struct KfDrag { + track: u64, + index: usize, + orig: Tick, + /// pointer offset at grab + grab: egui::Vec2, +} + +fn drag_id() -> egui::Id { + egui::Id::new("tl-lane-kf-drag") +} + +/// Draw the shown lanes (after clips) and handle their editing (after the timeline's own interaction, +/// so keyframes sit on top). +pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &Layout, aclip: Rect) { + if app.ui.timeline.track_lanes.is_empty() { + return; + } + let rows: Vec = layout.rows.iter().filter(|r| r.kind == TrackKind::Audio).cloned().collect(); + let drag: Option = ui.data(|d| d.get_temp(drag_id())); + let pointer = ui.input(|i| i.pointer.interact_pos()); + let mut acts: Vec<(&str, serde_json::Value)> = Vec::new(); + for r in &rows { + let Some(key) = app.ui.timeline.track_lanes.get(&r.track.0).cloned() else { continue }; + let Some(tr) = seq.track(r.track) else { continue }; + if !lane_options(tr).iter().any(|(k, _)| *k == key) { + continue; + } + let label = format!("A{}", r.index + 1); + let b = band(r); + let vis = b.intersect(aclip).intersect(layout.content); + if vis.height() <= 2.0 { + continue; + } + let p = ui.painter().with_clip_rect(vis.expand2(vec2(0.0, 5.0)).intersect(aclip)); + let y_of = |v: f64| b.max.y - norm(tr, &key, v) * b.height(); + // the curve + let mut pts = Vec::new(); + let mut x = vis.min.x; + while x <= vis.max.x + 2.0 { + pts.push(pos2(x, y_of(lane_at(tr, &key, layout.tick_at(x))))); + x += 2.0; + } + p.add(egui::Shape::line(pts.iter().map(|q| *q + vec2(0.0, 1.0)).collect(), Stroke::new(1.0, Color32::BLACK))); + p.add(egui::Shape::line(pts, Stroke::new(1.5, LANE_COL))); + let name = lane_options(tr).into_iter().find(|(k, _)| *k == key).map(|(_, l)| l).unwrap_or_default(); + p.text(pos2(vis.max.x - 4.0, b.min.y + 1.0), Align2::RIGHT_TOP, &name, Tokens::ui(9.5), LANE_COL.gamma_multiply(0.85)); + app.auto.add(&format!("timeline.track.{label}.lane"), vis, &format!("{name} track keyframes")); + // add keyframes: Pen tool, or ⌘-click with any tool + let adding = app.ui.tool == Tool::Pen || ui.input(|i| i.modifiers.command); + if adding { + let bresp = ui.interact(vis, egui::Id::new(("lane-band", r.track.0)), Sense::click()); + if bresp.hovered() { + ui.ctx().set_cursor_icon(CursorIcon::Crosshair); + } + if bresp.clicked() + && let Some(pp) = pointer + { + let tt = layout.tick_at(pp.x).max(Tick::ZERO); + let n = ((b.max.y - pp.y) / b.height()).clamp(0.0, 1.0); + acts.push(("mixer.setKeyframe", json!({"strip": r.track.0, "lane": key, "time": tt.0, "value": denorm(tr, &key, n)}))); + } + } + // keyframe diamonds + let kfs = tr.lane_keyframes(&key).to_vec(); + for (i, k) in kfs.iter().enumerate() { + let v = k.value.as_f64().unwrap_or(0.0); + let mut c = pos2(layout.x_of(k.time), y_of(v)); + let dragging = drag.filter(|d| d.track == r.track.0 && d.index == i); + if let (Some(d), Some(pp)) = (dragging, pointer) { + c = pp - d.grab; + c.y = c.y.clamp(b.min.y, b.max.y); + } + if c.x < vis.min.x - 6.0 || c.x > vis.max.x + 6.0 { + continue; + } + let hr = Rect::from_center_size(c, vec2(11.0, 11.0)); + let resp = ui.interact(hr, egui::Id::new(("lane-kf", r.track.0, i)), Sense::click_and_drag()); + let hot = resp.hovered() || dragging.is_some(); + icons::paint(&p, Rect::from_center_size(c, vec2(9.0, 9.0)), Icon::Keyframe, if hot { Color32::WHITE } else { LANE_COL }); + app.auto.add(&format!("timeline.track.{label}.lane.kf.{i}"), hr, &format!("{name} keyframe {v:.1}")); + if resp.drag_started() + && let Some(pp) = pointer + { + ui.data_mut(|d| d.insert_temp(drag_id(), KfDrag { track: r.track.0, index: i, orig: k.time, grab: pp - c })); + } + if resp.drag_stopped() + && let Some(d) = dragging + { + let nt = layout.tick_at(c.x).max(Tick::ZERO); + let nv = denorm(tr, &key, ((b.max.y - c.y) / b.height()).clamp(0.0, 1.0)); + acts.push(("mixer.moveKeyframe", json!({"strip": r.track.0, "lane": key, "time": d.orig.0, "newTime": nt.0, "value": nv}))); + ui.data_mut(|dd| dd.remove::(drag_id())); + } + if dragging.is_some() { + let nv = denorm(tr, &key, ((b.max.y - c.y) / b.height()).clamp(0.0, 1.0)); + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("lane-kf-tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.label(if key == LANE_VOLUME { format!("{nv:.2} dB") } else { format!("{nv:.2}") }); + }); + } + let time = k.time; + let key2 = key.clone(); + resp.context_menu(|ui| { + if ui.button("Delete").clicked() { + acts.push(("mixer.deleteKeyframe", json!({"strip": r.track.0, "lane": key2, "time": time.0}))); + ui.close(); + } + }); + } + } + if drag.is_some() && !ui.input(|i| i.pointer.any_down()) { + ui.data_mut(|d| d.remove::(drag_id())); + } + for (id, p) in acts { + if let Err(e) = app.session.execute(id, p) { + app.ui.status = e.to_string(); + } + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 983cf7747..7b0586e68 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -185,6 +185,10 @@ pub struct TimelineView { /// Follow playhead during playback (page scroll). pub follow: bool, pub fit_pending: bool, + /// Audio tracks showing track keyframes instead of clip keyframes: track id → lane + /// (`volume`, `pan`, `mute`, `send..level`, `fx..`). + #[serde(default)] + pub track_lanes: std::collections::BTreeMap, } impl Default for TimelineView { @@ -204,6 +208,7 @@ impl Default for TimelineView { show_waveforms: true, follow: true, fit_pending: true, + track_lanes: Default::default(), } } } @@ -275,6 +280,9 @@ pub struct UiState { /// Preferences ▸ Playback: play the rendered range when a preview render finishes. #[serde(default = "yes")] pub play_after_render: bool, + /// Audio Track Mixer: effects and sends section expanded. + #[serde(default)] + pub mixer_fx_open: bool, } fn captions_tab() -> String { @@ -313,6 +321,7 @@ impl Default for UiState { text_tab: captions_tab(), caption_search: String::new(), play_after_render: true, + mixer_fx_open: false, } } } diff --git a/crates/ui-egui/tests/mixer_ui.rs b/crates/ui-egui/tests/mixer_ui.rs new file mode 100644 index 000000000..ed29d02f2 --- /dev/null +++ b/crates/ui-egui/tests/mixer_ui.rs @@ -0,0 +1,256 @@ +//! Headless UI tests of the Audio Track Mixer, Audio Clip Mixer and timeline track keyframes: the +//! real `FilmcraftApp` under `egui_kittest`, driven by automation id and by frame-by-frame pointer +//! input (a fader drag spans several frames, as with a mouse). +//! +//! Set `FILMCRAFT_UI_SNAPSHOT_DIR=` to also render the window offscreen with wgpu and write PNGs +//! there (`mixer-*.png`); without it no GPU is needed. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, + snapshots: Option, +} + +impl Driver { + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(std::path::PathBuf::from); + let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if snapshots.is_some() { + b = b.wgpu(); + } + let harness = b.build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, snapshots }; + d.frames(4); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let v = self.call(method, params.clone()); + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + v["result"].clone() + } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + fn click(&mut self, id: &str) { + self.ok("ui.click", json!({"id": id})); + self.frames(2); + } + + fn element(&mut self, id: &str) -> Option<[f32; 4]> { + let v = self.ok("ui.elements", json!({"prefix": id})); + v.as_array()?.iter().find(|e| e["id"] == id).map(|e| { + let r = &e["rect"]; + [r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32, r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32] + }) + } + + fn ids(&mut self, prefix: &str) -> Vec { + let v = self.ok("ui.elements", json!({"prefix": prefix})); + v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect() + } + + /// Press on `id`, move `dy` pixels over several frames (calling `between` before each move), + /// release. + fn drag_frames(&mut self, id: &str, dy: f32, steps: usize, mut between: impl FnMut(&mut Self, usize)) { + let r = self.element(id).unwrap_or_else(|| panic!("no element {id}")); + let mut p = egui::pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0); + let push = |d: &mut Self, e: egui::Event| d.harness.input_mut().events.push(e); + push(self, egui::Event::PointerMoved(p)); + self.frames(1); + push(self, egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + self.frames(1); + for i in 0..steps { + between(self, i); + p.y += dy / steps as f32; + push(self, egui::Event::PointerMoved(p)); + self.frames(1); + } + push(self, egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + self.frames(2); + } + + fn strip(&mut self, r: &str) -> Value { + let m = self.exec("mixer.inspect", json!({})); + m["strips"].as_array().unwrap().iter().find(|s| s["ref"] == r).cloned().unwrap_or(Value::Null) + } + + /// Offscreen render of the window (or the bounding box of the elements under `prefix`). + fn snapshot(&mut self, name: &str, prefix: Option<&str>) { + let Some(dir) = self.snapshots.clone() else { return }; + let crop = prefix.map(|p| { + let v = self.ok("ui.elements", json!({"prefix": p})); + let mut bb = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; + for e in v.as_array().unwrap() { + let r = &e["rect"]; + let (x, y, w, h) = (r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32, r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32); + bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x + w), bb[3].max(y + h)]; + } + bb + }); + self.frames(2); + let img = match self.harness.render() { + Ok(i) => i, + Err(e) => { + eprintln!("snapshot {name} skipped: {e}"); + return; + } + }; + let ppp = img.width() as f32 / 1600.0; + let img = match crop { + Some(bb) => { + let x0 = ((bb[0] - 24.0) * ppp).max(0.0) as u32; + let y0 = ((bb[1] - 30.0) * ppp).max(0.0) as u32; + let x1 = (((bb[2] + 16.0) * ppp) as u32).min(img.width()); + let y1 = (((bb[3] + 46.0) * ppp) as u32).min(img.height()); + image::imageops::crop_imm(&img, x0, y0, x1 - x0, y1 - y0).to_image() + } + None => img, + }; + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{name}.png")); + img.save(&path).unwrap(); + eprintln!("snapshot: {}", path.display()); + } +} + +#[test] +fn track_mixer_controls_by_id_and_by_drag() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Audio"})); + d.frames(3); + let ids = d.ids("mixer."); + for id in [ + "mixer.A1.fader", + "mixer.A1.pan", + "mixer.A1.mode", + "mixer.A1.mute", + "mixer.A1.solo", + "mixer.A1.record", + "mixer.A1.output", + "mixer.Mix.fader", + "mixer.showEffects", + ] { + assert!(ids.iter().any(|i| i == id), "{id} missing from {ids:?}"); + } + // automation mode from the dropdown + d.click("mixer.A1.mode"); + d.click("mixer.A1.mode.Touch"); + assert_eq!(d.strip("A1")["mode"], "Touch"); + // mute / solo buttons + d.click("mixer.A2.mute"); + assert_eq!(d.strip("A2")["muted"], true); + d.click("mixer.A2.mute"); + // a fader drag (stopped) is heard live while held and committed once on release + let undo0 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(); + d.drag_frames("mixer.A1.fader", 60.0, 6, |_, _| {}); + let a1 = d.strip("A1"); + assert!(a1["volumeDb"].as_f64().unwrap() < -3.0, "fader moved down: {a1}"); + let undo = d.exec("history.list", json!({}))["undo"].as_array().unwrap().clone(); + assert_eq!(undo.len(), undo0 + 1, "one undo step: {undo:?}"); + // effects and sends + d.click("mixer.showEffects"); + d.click("mixer.A1.fx.0"); + d.click("mixer.A1.fx.0.studio_reverb"); + assert_eq!(d.strip("A1")["inserts"][0]["effect"], "studio_reverb"); + d.exec("mixer.addSubmix", json!({"name": "Reverb Bus"})); + d.frames(2); + d.click("mixer.A2.send.0"); + d.click("mixer.A2.send.0.S1"); + assert_eq!(d.strip("A2")["sends"][0]["target"], "S1"); + let s1 = d.ids("mixer.S1."); + assert!(s1.iter().any(|i| i == "mixer.S1.fader"), "submix strip shown: {s1:?}"); + d.snapshot("mixer-track-mixer-effects", Some("mixer.")); +} + +#[test] +fn recording_a_touch_pass_from_the_fader() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Audio"})); + d.frames(3); + d.exec("mixer.setStrip", json!({"strip": "A1", "mode": "Touch"})); + d.exec("playhead.set", json!({"seconds": 1.0})); + d.exec("mixer.recordStart", json!({})); + // the playhead advances while the fader is held (as during playback) + d.drag_frames("mixer.A1.fader", 80.0, 8, |d, i| { + d.exec("playhead.set", json!({"seconds": 1.0 + 0.1 * i as f64})); + }); + let held_lanes = d.strip("A1")["lanes"].clone(); + assert!(held_lanes.as_object().is_none_or(|m| m.is_empty()), "nothing written until the pass ends"); + d.exec("playhead.set", json!({"seconds": 3.0})); + let r = d.exec("mixer.recordStop", json!({})); + assert_eq!(r["lanes"], 1, "{r}"); + let a1 = d.strip("A1"); + let kfs = a1["lanes"]["volume"].as_array().unwrap().clone(); + assert!(kfs.len() >= 3, "{kfs:?}"); + // the lane in the timeline: header button → Volume + d.click("timeline.track.A1.keyframes"); + d.click("timeline.track.A1.keyframes.volume"); + let ids = d.ids("timeline.track.A1.lane"); + assert!(ids.iter().any(|i| i == "timeline.track.A1.lane"), "{ids:?}"); + assert!(ids.iter().filter(|i| i.contains(".kf.")).count() >= 1, "diamonds: {ids:?}"); + // the pen tool adds a keyframe on the lane + d.ok("ui.set", json!({"tool": "Pen"})); + d.frames(2); + let lane = d.element("timeline.track.A1.lane").unwrap(); + d.ok("ui.click", json!({"x": lane[0] + lane[2] * 0.9, "y": lane[1] + lane[3] * 0.5})); + d.frames(3); + let n = d.strip("A1")["lanes"]["volume"].as_array().unwrap().len(); + assert_eq!(n, kfs.len() + 1, "pen click adds a track keyframe"); + d.ok("ui.set", json!({"tool": "Selection"})); + d.snapshot("mixer-timeline-track-keyframes", Some("timeline.track.A")); + d.snapshot("mixer-audio-workspace", None); +} + +#[test] +fn clip_mixer_strips_follow_the_playhead() { + let mut d = Driver::demo(); + d.ok("ui.panel.show", json!({"panel": "AudioClipMixer"})); + d.frames(3); + let ids = d.ids("clipMixer."); + assert!(ids.iter().any(|i| i == "clipMixer.A1.fader"), "{ids:?}"); + d.exec("playhead.set", json!({"seconds": 1.0})); + d.drag_frames("clipMixer.A1.fader", 40.0, 5, |_, _| {}); + let seq = d.exec("sequence.inspect", json!({})); + let fx = &seq["audio"][0]["items"][0]["effects"]; + let vol = fx.as_array().unwrap().iter().find(|e| e["effect"] == "volume").unwrap(); + let level: f64 = vol["params"]["level"]["value"].as_str().and_then(|v| v.strip_prefix("Float(")?.strip_suffix(')')?.parse().ok()).unwrap(); + assert!(level < -8.0, "clip volume lowered from the demo's -8 dB: {level}"); + d.snapshot("mixer-clip-mixer", Some("clipMixer.")); +} From c42fa205cd01ef6b28698f762c907116fc41e3e2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 02:11:57 -0400 Subject: [PATCH 22/24] M7.5: Audio Gain dialog (set / adjust / normalize max peak / normalize all peaks) and default audio transitions - clip.audioGain takes mode set|adjust|normalizeMax|normalizeAll (the old {db, relative} form still works); peaks are measured on the clips' source ranges. Normalize Max Peak applies one adjustment so the loudest clip lands on the target; Normalize All Peaks sets each clip. - clip.audioPeak reports the selection's peak amplitude (with gain). - Clip > Audio Gain... (G) opens the dialog with the four options, dB fields and the peak amplitude; automation ids audioGain.*. - effects.setDefaultTransition sets the default video or audio transition (Constant Power / Constant Gain / Exponential Fade) that sequence.applyAudioTransition (Cmd+Shift+D) uses. - tests: gain modes and peaks, dialog by automation id, transition curves (power / amplitude preservation, end points). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 24 +++--- crates/engine/src/mixer.rs | 123 +++++++++++++++++++++++++++ crates/engine/src/mixer_tests.rs | 44 ++++++++++ crates/render/src/mixer_tests.rs | 19 +++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 9 ++ crates/ui-egui/src/panels/dialogs.rs | 87 +++++++++++++++++++ crates/ui-egui/src/state.rs | 16 ++++ crates/ui-egui/tests/mixer_ui.rs | 21 +++++ 9 files changed, 331 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 300f0a21c..6bc65609d 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -876,20 +876,16 @@ fn build() -> Vec { })?; Ok(Value::Null) }), - cmd!("clip.audioGain", "Audio Gain…", ["Clip"], Some("G"), r#"{"db":f64,"relative":bool}"#, has_selection, |s, p| { - let db = f64_p(p, "db").unwrap_or(0.0); - let rel = bool_p(p, "relative").unwrap_or(true); - let sel = with_links(s, &s.state.selection.clone()); - s.edit_sequence("Audio Gain", |q, _, _| { - for t in q.audio_tracks.iter_mut() { - for i in t.items.iter_mut().filter(|i| sel.contains(&i.id)) { - i.gain_db = if rel { i.gain_db + db } else { db }; - } - } - Ok(()) - })?; - Ok(Value::Null) - }), + cmd!( + "clip.audioGain", + "Audio Gain…", + ["Clip"], + Some("G"), + r#"{"mode":"set|adjust|normalizeMax|normalizeAll"?,"db":f64,"relative":bool?}"#, + has_selection, + |s, p| crate::mixer::audio_gain(s, p) + ), + query!("clip.audioPeak", "Audio Clip Peak Amplitude", r#"{"clips":[id]?}"#, |s, p| crate::mixer::audio_peak(s, p)), cmd!("clip.frameHold", "Add Frame Hold", ["Clip", "Video Options"], None, "{}", has_selection, |s, _| { let t = s.playhead(); let sel = s.state.selection.clone(); diff --git a/crates/engine/src/mixer.rs b/crates/engine/src/mixer.rs index 1e4a25b53..5f3781efb 100644 --- a/crates/engine/src/mixer.rs +++ b/crates/engine/src/mixer.rs @@ -869,6 +869,119 @@ fn clip_set(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +// ------------------------------------------------------------------------------------- audio gain + +/// Peak (dBFS, before clip gain) of each selected audio clip's source range. +fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraft_project::ClipId, f64)> { + let Some(seq) = s.active_sequence() else { return Vec::new() }; + let sr = seq.settings.sample_rate.max(1); + let provider = s.media.provider(s.project.clone(), s.services.clone()); + let mut out = Vec::new(); + for t in &seq.audio_tracks { + for it in t.items.iter().filter(|i| clips.contains(&i.id)) { + let Some(src) = filmcraft_render::SourceProvider::source(&provider, it.item) else { continue }; + if !src.info().has_audio() { + continue; + } + let s0 = it.source_in.to_units_floor(sr as i64); + let len = ((it.duration.to_units_floor(sr as i64) as f64) * it.speed.abs().max(1e-6)).ceil() as i64; + let mut peak = 0f32; + let mut pos = s0; + while pos < s0 + len { + let n = (s0 + len - pos).min(sr as i64) as usize; + if let Ok(b) = src.audio(pos, n, sr) { + peak = b.peaks().into_iter().fold(peak, f32::max); + } + pos += n as i64; + } + out.push((it.id, 20.0 * (peak.max(1e-9) as f64).log10())); + } + } + out +} + +fn gain_targets(s: &Session, p: &Value) -> Vec { + let sel: Vec = match p.get("clips").and_then(Value::as_array) { + Some(a) => a.iter().filter_map(|v| v.as_u64().map(filmcraft_project::ClipId)).collect(), + None => s.state.selection.clone(), + }; + crate::commands::with_links(s, &sel) +} + +/// `clip.audioPeak`: the peak amplitude of the selection (with its current clip gain), as the Audio +/// Gain dialog shows it. +pub fn audio_peak(s: &mut Session, p: &Value) -> Result { + let clips = gain_targets(s, p); + let seq = active_seq(s)?; + let gain = |c: filmcraft_project::ClipId| seq.find_item(c).map(|(_, i)| i.gain_db).unwrap_or(0.0); + let peaks = clip_peaks(s, &clips); + let max = peaks.iter().map(|(c, pk)| pk + gain(*c)).fold(f64::NEG_INFINITY, f64::max); + Ok(json!({ + "peakDb": if max.is_finite() { json!(max) } else { Value::Null }, + "clips": peaks.iter().map(|(c, pk)| json!({"clip": c.0, "sourcePeakDb": pk, "gainDb": gain(*c)})).collect::>(), + })) +} + +/// `clip.audioGain`: Set Gain to / Adjust Gain by / Normalize Max Peak to / Normalize All Peaks to. +pub fn audio_gain(s: &mut Session, p: &Value) -> Result { + let db = f64_p(p, "db").unwrap_or(0.0); + let mode = match str_p(p, "mode") { + Some(m) => m.to_string(), + None => if bool_p(p, "relative").unwrap_or(true) { "adjust" } else { "set" }.to_string(), + }; + let clips = gain_targets(s, p); + let new_gain: Vec<(filmcraft_project::ClipId, f64)> = match mode.as_str() { + "set" => clips.iter().map(|c| (*c, db)).collect(), + "adjust" => { + let seq = active_seq(s)?; + clips.iter().filter_map(|c| seq.find_item(*c).map(|(_, i)| (*c, i.gain_db + db))).collect() + } + "normalizeMax" | "normalizeAll" => { + let peaks = clip_peaks(s, &clips); + if peaks.is_empty() { + return Err(bad("clip.audioGain", "no audio clips with audio in the selection")); + } + let seq = active_seq(s)?; + let gain = |c: filmcraft_project::ClipId| seq.find_item(c).map(|(_, i)| i.gain_db).unwrap_or(0.0); + if mode == "normalizeAll" { + peaks.iter().map(|(c, pk)| (*c, db - pk)).collect() + } else { + let loudest = peaks.iter().map(|(c, pk)| pk + gain(*c)).fold(f64::NEG_INFINITY, f64::max); + let delta = db - loudest; + peaks.iter().map(|(c, _)| (*c, gain(*c) + delta)).collect() + } + } + m => return Err(bad("clip.audioGain", format!("unknown mode `{m}` (set, adjust, normalizeMax, normalizeAll)"))), + }; + let n = new_gain.len(); + let applied = new_gain.clone(); + s.edit_sequence("Audio Gain", move |q, _, _| { + for t in q.audio_tracks.iter_mut() { + for i in t.items.iter_mut() { + if let Some((_, g)) = new_gain.iter().find(|(c, _)| *c == i.id) { + i.gain_db = g.clamp(-96.0, 96.0); + } + } + } + Ok(()) + })?; + Ok(json!({"mode": mode, "clips": n, "gainDb": applied.iter().map(|(c, g)| json!({"clip": c.0, "gainDb": g})).collect::>()})) +} + +/// Effects panel ▸ Set Selected as Default Transition (video or audio, from the effect's kind). +fn set_default_transition(s: &mut Session, p: &Value) -> Result { + let id = str_p(p, "effect").ok_or_else(|| bad("effects.setDefaultTransition", "need `effect`"))?; + let def = find_effect(id) + .or_else(|| filmcraft_project::effect_defs().iter().find(|d| d.name.eq_ignore_ascii_case(id))) + .ok_or_else(|| bad("effects.setDefaultTransition", format!("no effect `{id}`")))?; + match def.kind { + filmcraft_project::EffectKind::AudioTransition => s.state.default_audio_transition = def.id.to_string(), + filmcraft_project::EffectKind::VideoTransition => s.state.default_video_transition = def.id.to_string(), + _ => return Err(bad("effects.setDefaultTransition", format!("`{}` is not a transition", def.name))), + } + Ok(json!({"video": s.state.default_video_transition, "audio": s.state.default_audio_transition})) +} + fn not_recording(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; if s.mixrec.active() { Err("an automation pass is already recording".into()) } else { Ok(()) } @@ -988,6 +1101,16 @@ pub fn commands() -> Vec { clip_set, true, ), + spec( + "effects.setDefaultTransition", + "Set Selected as Default Transition", + &[], + None, + r#"{"effect":"constant_power|constant_gain|exponential_fade|