From 08b0704b0cf0b4c7e44d4e88851cc5eb3c98fa66 Mon Sep 17 00:00:00 2001 From: Saad Abdullah Date: Tue, 13 Oct 2026 16:37:51 +0000 Subject: [PATCH 01/33] fix(video): reject zero/invalid NAL length widths in length_prefixed_nals (#469) --- crates/video/src/bitstream.rs | 60 ++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/crates/video/src/bitstream.rs b/crates/video/src/bitstream.rs index 779970f1..b80e702a 100644 --- a/crates/video/src/bitstream.rs +++ b/crates/video/src/bitstream.rs @@ -13,6 +13,8 @@ pub enum BitError { Eof, /// An Exp-Golomb code longer than 32 leading zeros. InvalidExpGolomb, + /// A NAL length-prefix width outside the AVCC/HVCC range of 1..=4 bytes. + InvalidLengthSize, } impl fmt::Display for BitError { @@ -20,6 +22,7 @@ impl fmt::Display for BitError { match self { BitError::Eof => f.write_str("unexpected end of bitstream"), BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"), + BitError::InvalidLengthSize => f.write_str("invalid NAL length size"), } } } @@ -302,24 +305,37 @@ pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> { out } -/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1/2/4 bytes) into NAL units. +/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1..=4 bytes) into NAL units. +/// +/// The width comes from the stream's configuration record (`(byte & 3) + 1`), so anything +/// outside 1..=4 is a programming or parsing error and is rejected up front: a width of 0 +/// would never advance the cursor, looping forever and appending empty NALs until the +/// process runs out of memory, and a wider prefix cannot occur in a real sample. pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result> { + if !(1..=4).contains(&length_size) { + return Err(BitError::InvalidLengthSize); + } let mut out = Vec::new(); let mut i = 0; while i < data.len() { - if i + length_size > data.len() { + let Some(start) = i.checked_add(length_size) else { + return Err(BitError::Eof); + }; + if start > data.len() { return Err(BitError::Eof); } let mut len = 0usize; - for k in 0..length_size { - len = (len << 8) | data[i + k] as usize; + for &b in &data[i..start] { + len = (len << 8) | b as usize; } - i += length_size; - if i + len > data.len() { + let Some(end) = start.checked_add(len) else { + return Err(BitError::Eof); + }; + if end > data.len() { return Err(BitError::Eof); } - out.push(&data[i..i + len]); - i += len; + out.push(&data[start..end]); + i = end; } Ok(out) } @@ -392,6 +408,34 @@ mod tests { assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]); } + #[test] + fn length_prefixed_rejects_bad_length_size() { + // Regression test for #469: with a width of 0 neither the prefix nor a + // zero-length payload advanced the cursor, so any non-empty sample + // looped forever appending empty NALs. Widths above 4 cannot come + // from an AVCC/HVCC record either. + let d = [0, 0, 0, 2, 0x67, 1]; + assert_eq!(length_prefixed_nals(&d, 0), Err(BitError::InvalidLengthSize)); + assert_eq!(length_prefixed_nals(&d, 5), Err(BitError::InvalidLengthSize)); + assert_eq!(length_prefixed_nals(&d, usize::MAX), Err(BitError::InvalidLengthSize)); + assert_eq!(length_prefixed_nals(&[], 0), Err(BitError::InvalidLengthSize)); + } + + #[test] + fn length_prefixed_widths() { + // 1-byte prefixes. + assert_eq!(length_prefixed_nals(&[2, 0x67, 1, 1, 0x68], 1).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]); + // 2-byte prefixes. + assert_eq!(length_prefixed_nals(&[0, 2, 0x67, 1, 0, 1, 0x68], 2).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]); + // 3-byte prefixes (lengthSizeMinusOne == 2 in the configuration record). + assert_eq!(length_prefixed_nals(&[0, 0, 2, 0x67, 1], 3).unwrap(), vec![&[0x67, 1][..]]); + // A truncated prefix or payload is Eof, not a panic or a short read. + assert_eq!(length_prefixed_nals(&[0, 0], 4), Err(BitError::Eof)); + assert_eq!(length_prefixed_nals(&[0, 0, 0, 9, 0x67], 4), Err(BitError::Eof)); + // Empty input splits to no NALs. + assert!(length_prefixed_nals(&[], 4).unwrap().is_empty()); + } + #[test] fn more_rbsp() { let d = [0b1100_0000]; From 0a9d29f227d5ba12b3b8d0d9a7e69ed4861d6b0b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:21:32 +0000 Subject: [PATCH 02/33] Keep playlist sorted after delete_fades trims overlapping clips Fixes #697 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/edit.rs | 16 ++++++++++++++++ .../tests/random_edits.proptest-regressions | 1 + 2 files changed, 17 insertions(+) diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 5531953d..e7a8e977 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -835,6 +835,8 @@ pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize { } } } + // Trimming can move a start past a later clip's start when one clip overlapped several. + pl.sort(); n } @@ -1041,4 +1043,18 @@ mod tests { s.find_clip_mut(id).unwrap().time_locked = true; assert_eq!(move_clips(&mut s, &[id], 500, &[]), 0); } + + #[test] + fn delete_fades_keeps_playlist_sorted_when_a_clip_overlaps_several() { + let (mut s, t) = session_with_clip(); + let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).expect("track has a playlist"); + pl.clips.clear(); + pl.clips.push(Clip::audio(ClipId(901), "a", SourceId(500), 0, 0, 1000)); + pl.clips.push(Clip::audio(ClipId(902), "b", SourceId(500), 100, 100, 100)); + pl.clips.push(Clip::audio(ClipId(903), "c", SourceId(500), 150, 150, 750)); + delete_fades(&mut s, t, Range::new(0, 10_000)); + let got = clips(&s, t); + assert!(got.windows(2).all(|w| w[0].0 <= w[1].0), "playlist not sorted: {got:?}"); + assert!(got.iter().all(|c| c.1 > 0), "empty clip: {got:?}"); + } } diff --git a/crates/engine/tests/random_edits.proptest-regressions b/crates/engine/tests/random_edits.proptest-regressions index 3d338463..dc40fac6 100644 --- a/crates/engine/tests/random_edits.proptest-regressions +++ b/crates/engine/tests/random_edits.proptest-regressions @@ -5,3 +5,4 @@ # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. cc 1dc212f0026723d05bfcfbb29fcff476eda7f491e9bea9cd5d26dccf006a81f6 # shrinks to ops = [(9, 0, 2420494, 1), (3, 0, 2396495, 1)] +cc 87a0afb56f43d6ad377071e28bd6ba94c3d67485fc69dbefa9a2c7de3f698ed6 # shrinks to ops = [(10, 2215386, 0, 0), (0, 844227, 213110, 1), (3, 0, 443078, 0), (0, 443079, 443079, 0), (11, 0, 0, 0)] From 9ac264184ee3ac0f3fd4b9d0788655d78ec9c5cd Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 19 Oct 2026 20:01:40 +0800 Subject: [PATCH 03/33] fix(ui): bare "=" key nudges the selected clip right, matching "-" `edit.nudge`'s "Plus/Minus" shortcut only matched `Key::Plus`, which a real keyboard without a dedicated numpad `+` can never send: typing "+" needs Shift, and Shift+=/- is already a separate, intentional shortcut (Grid value). So nudge-right was unreachable on most keyboards, while nudge-left worked fine ("-" needs no shift). Fixes #736. Add `Key::Equals` (the bare, unshifted key) to the nudge match arm, the same way it already works for `Key::Minus`. Also exclude `m.alt` from the arm, since `Alt+=` is the separate, already-registered `window.event_list` shortcut that would otherwise be shadowed. Verified live against a running demo session over the control channel before writing the fix (plain "-" moved a clip; plain "=" and Shift+"=" both did nothing), and again after. New regression test covers bare Equals/Plus/Minus nudging, Shift+Equals still stepping the Grid value instead, and Alt+Equals still opening the MIDI Event List instead. Co-Authored-By: Claude Sonnet 5 --- crates/ui-egui/src/shortcuts.rs | 7 ++++-- crates/ui-egui/tests/shortcuts.rs | 38 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index eae794dc..4aa47a9e 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -162,8 +162,11 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let _ = app.run("edit.tool", json!({"tool": tool})); true } - Key::Plus | Key::Minus if !m.command && app.ui.window == MainWindow::Edit => { - let dir = if key == Key::Plus { 1 } else { -1 }; + // `=` is what a bare press of the unlabeled +/- key reports (it has no shift held, so it + // can never be `Key::Plus`); `Key::Plus` is kept for a keyboard that does send it (e.g. a + // numpad +). `!m.alt` leaves Alt+= to `window.event_list`'s registered shortcut. + Key::Plus | Key::Equals | Key::Minus if !m.command && !m.alt && app.ui.window == MainWindow::Edit => { + let dir = if key == Key::Minus { -1 } else { 1 }; let _ = app.run("edit.nudge", json!({"direction": dir})); true } diff --git a/crates/ui-egui/tests/shortcuts.rs b/crates/ui-egui/tests/shortcuts.rs index 190cd406..7ffe8a56 100644 --- a/crates/ui-egui/tests/shortcuts.rs +++ b/crates/ui-egui/tests/shortcuts.rs @@ -56,6 +56,44 @@ fn tab_leaves_the_keys_to_the_shortcuts() { } } +#[test] +fn bare_plus_and_minus_nudge_the_selected_clip_in_edit() { + // A laptop keyboard has no dedicated "+": typing it needs Shift, which is already claimed by + // the Grid-value shortcut (#125 above). So nudge must fire on the bare, unshifted key the "+" + // symbol sits on, same as "-" already does; `Key::Plus` stays bound too for a keyboard (e.g. a + // numpad) that does send it on its own. + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id; + app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap(); + let ctx = egui::Context::default(); + let start = |app: &SoundApp| app.engine.session().find_clip(kick).unwrap().1.start; + let original = start(&app); + + press(&ctx, &mut app, Key::Minus, Modifiers::NONE); + let after_minus = start(&app); + assert!(after_minus < original, "bare Minus must nudge left"); + + press(&ctx, &mut app, Key::Equals, Modifiers::NONE); + assert_eq!(start(&app), original, "bare Equals must nudge back right, same step as Minus"); + + press(&ctx, &mut app, Key::Plus, Modifiers::NONE); + assert!(start(&app) > original, "Key::Plus (e.g. a numpad +) must still nudge right"); + + // Shift+Equals is the pre-existing, separate Grid-value shortcut: it must not also nudge. + let before_shift = start(&app); + let grid = app.engine.session().edit.grid; + press(&ctx, &mut app, Key::Equals, Modifiers::SHIFT); + assert_eq!(start(&app), before_shift, "Shift+Equals steps the Grid value, not the clip"); + assert_ne!(app.engine.session().edit.grid, grid, "Shift+Equals must still step the Grid value"); + + // Alt+Equals is the registered `window.event_list` shortcut: it must not also nudge. + let before_alt = start(&app); + assert!(!app.ui.show_event_list); + press(&ctx, &mut app, Key::Equals, Modifiers::ALT); + assert_eq!(start(&app), before_alt, "Alt+Equals opens the MIDI Event List, not a nudge"); + assert!(app.ui.show_event_list, "Alt+Equals must still open the MIDI Event List"); +} + #[test] fn cmd_equals_toggles_mix_and_edit_without_zooming() { let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; From 1816f2dc92f4ab89c722d45e4bc1c9064e2c2779 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:30:22 +0200 Subject: [PATCH 04/33] Keep existing breakpoint at end + 1 in AutomationLane::write_range Found while testing crates/model/src/automation.rs:190-203 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/model/src/automation.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/model/src/automation.rs b/crates/model/src/automation.rs index a6d3963b..21f128b7 100644 --- a/crates/model/src/automation.rs +++ b/crates/model/src/automation.rs @@ -199,7 +199,11 @@ impl AutomationLane { } self.set_point(start, value); self.set_point(end, value); - self.set_point(end.saturating_add(1), after); + let anchor = end.saturating_add(1); + // A real breakpoint already at `end + 1` keeps its own value. + if self.points.binary_search_by(|p| p.at.cmp(&anchor)).is_err() { + self.set_point(anchor, after); + } } /// Thin: remove points whose removal changes the curve by less than `tolerance`. @@ -266,6 +270,15 @@ mod tests { assert_eq!(m.value_at(100, 0.0), 1.0); } + #[test] + fn write_range_keeps_point_right_after_end() { + let mut l = AutomationLane::new(AutoParam::Volume); + l.set_point(0, 0.0); + l.set_point(11, -100.0); + l.write_range(2, 10, -20.0, 0.0); + assert_eq!(l.value_at(11, 0.0), -100.0); + } + #[test] fn write_range_preserves_outside() { let mut l = AutomationLane::new(AutoParam::Volume); From 5703db0b80eba55b9d24662b4c3bf5c56c52f737 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:24:47 +0200 Subject: [PATCH 05/33] Fix normalize producing inf samples for subnormal peaks Found while testing crates/dsp/src/offline.rs:62-79 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/dsp/src/offline.rs | 18 ++++++++++++++---- crates/dsp/tests/offline_normalize.rs | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 crates/dsp/tests/offline_normalize.rs diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs index 8bbc80a6..337f3589 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -65,19 +65,29 @@ pub fn normalize(ch: &mut [Vec], target_db: f32, per_channel: bool) { for c in ch.iter_mut() { let p = peak(std::slice::from_ref(c)); if p > 0.0 { - let g = target / p; - c.iter_mut().for_each(|s| *s *= g); + scale_to(c.iter_mut(), target, p); } } } else { let p = peak(ch); if p > 0.0 { - let g = target / p; - ch.iter_mut().flatten().for_each(|s| *s *= g); + scale_to(ch.iter_mut().flatten(), target, p); } } } +/// Scales samples whose peak is `p` to `target`. A subnormal `p` makes the f32 gain overflow to +/// infinity, so that case scales in f64. +fn scale_to<'a>(samples: impl Iterator, target: f32, p: f32) { + let g = target / p; + if g.is_finite() { + samples.for_each(|s| *s *= g); + } else { + let g = f64::from(target) / f64::from(p); + samples.for_each(|s| *s = (f64::from(*s) * g) as f32); + } +} + /// Applies a gain change in dB. pub fn gain(ch: &mut [Vec], db: f32) { let g = db_to_gain(if db.is_nan() { 0.0 } else { db.min(96.0) }); diff --git a/crates/dsp/tests/offline_normalize.rs b/crates/dsp/tests/offline_normalize.rs new file mode 100644 index 00000000..6ed47973 --- /dev/null +++ b/crates/dsp/tests/offline_normalize.rs @@ -0,0 +1,19 @@ +use soundcraft_dsp::offline::{normalize, peak}; + +#[test] +fn normalize_subnormal_peak_stays_finite() { + for per_channel in [false, true] { + let mut c = vec![vec![1e-44f32, -1e-44]]; + normalize(&mut c, 0.0, per_channel); + assert!(c[0].iter().all(|s| s.is_finite()), "{c:?}"); + assert!((peak(&c) - 1.0).abs() < 1e-3, "{c:?}"); + } +} + +#[test] +fn normalize_silent_and_normal_channels() { + let mut c = vec![vec![0.0f32; 4], vec![0.25, -0.5]]; + normalize(&mut c, 0.0, true); + assert_eq!(c[0], vec![0.0; 4]); + assert!((peak(&c[1..]) - 1.0).abs() < 1e-5); +} From 988d678eec0bcd08ee92cc00f10a427e5058cab6 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:50:38 +0200 Subject: [PATCH 06/33] Fix overflow in extreme timecode frame counts Widen the magnitude and drop-frame label arithmetic to i128 so i64::MIN stays exact and extreme drop-frame counts cannot overflow. Add round-trip regressions for all frame rates and both drop-frame variants. --- crates/time/src/timecode.rs | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/time/src/timecode.rs b/crates/time/src/timecode.rs index f8f6041b..1adf3901 100644 --- a/crates/time/src/timecode.rs +++ b/crates/time/src/timecode.rs @@ -133,8 +133,10 @@ impl Timecode { /// Label for an absolute frame count. pub fn from_frames(frames: i64, rate: FrameRate) -> Timecode { let negative = frames < 0; - let mut f = frames.saturating_abs(); - let base = rate.nominal().max(1); + // Drop-frame labels can exceed i64::MAX even when the frame count fits. Widen before + // taking the magnitude so i64::MIN is represented exactly too. + let mut f = i128::from(frames).abs(); + let base = i128::from(rate.nominal().max(1)); if rate.is_drop() { // Drop 2 (or 4) labels each minute except every tenth. let drop = if base >= 60 { 4 } else { 2 }; @@ -147,7 +149,13 @@ impl Timecode { } let frames_l = f % base; let total_secs = f / base; - Timecode { negative, hours: total_secs / 3600, minutes: (total_secs / 60) % 60, seconds: total_secs % 60, frames: frames_l } + Timecode { + negative, + hours: i64::try_from(total_secs / 3600).unwrap_or(i64::MAX), + minutes: i64::try_from((total_secs / 60) % 60).unwrap_or(0), + seconds: i64::try_from(total_secs % 60).unwrap_or(0), + frames: i64::try_from(frames_l).unwrap_or(0), + } } /// Absolute frame count of this label. @@ -247,6 +255,28 @@ mod tests { assert_eq!(tc.to_samples(FrameRate::Fps30, sr), 178_710_400); } + #[test] + fn extreme_frame_counts_round_trip_at_every_rate() { + for rate in FrameRate::ALL { + for frames in [i64::MIN, i64::MIN + 1, -1, 0, 1, i64::MAX - 1, i64::MAX] { + let tc = Timecode::from_frames(frames, rate); + assert_eq!(tc.to_frames(rate), frames, "{rate:?}: {frames}"); + assert_eq!(tc.negative, frames < 0); + assert!((0..60).contains(&tc.minutes)); + assert!((0..60).contains(&tc.seconds)); + assert!((0..rate.nominal()).contains(&tc.frames)); + assert_eq!(Timecode::parse(&tc.format(rate)).unwrap(), tc); + } + } + } + + #[test] + fn extreme_drop_frame_labels_do_not_overflow() { + for rate in [FrameRate::Fps29_97Drop, FrameRate::Fps59_94Drop] { + assert_eq!(Timecode::from_frames(i64::MAX, rate).to_frames(rate), i64::MAX); + } + } + #[test] fn parse_variants() { let tc = Timecode::parse("01:02:03:04").unwrap(); From 96130d22c7e31bbda0f5f875f75eaaa2fee506ed Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:30:04 +0000 Subject: [PATCH 07/33] Reject mix.send to an unknown bus name instead of creating a bus Fixes #816 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/mix.rs | 18 +++++++++++++++++- crates/engine/src/cmd/surround.rs | 1 + crates/ui-egui/src/mix_window.rs | 8 ++++++-- crates/ui-egui/tests/menu_search.rs | 1 + crates/ui-egui/tests/send_window.rs | 1 + 5 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 3e5d75cc..7502b4f2 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -444,7 +444,7 @@ fn send(e: &mut Engine, p: &Value) -> Result { let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?; let explicit = optional_slot(p, "mix.send", SEND_SLOTS)?; let s = e.session_mut(); - let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo); + let bid = s.bus_by_name(&bus).map(|b| b.id).ok_or_else(|| bad("mix.send", format!("no bus named \"{bus}\"")))?; let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?; let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?; // Re-targeting an existing send keeps its level, pan, mute and pre/post setting. @@ -930,8 +930,10 @@ mod tests { fn retargeting_a_send_keeps_its_settings() { let mut e = Engine::default(); e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap(); + e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap(); e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap(); e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap(); + e.execute("mix.new_bus", &json!({"name": "Delay"})).unwrap(); e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap(); let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap(); let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id; @@ -985,10 +987,24 @@ mod tests { assert_eq!(count(&e), 0, "absent param must still clear every lane"); } + #[test] + fn send_rejects_unknown_bus() { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap(); + e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap(); + let err = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Nowhere"})).unwrap_err(); + assert!(err.to_string().contains("no bus named \"Nowhere\""), "{err}"); + assert_eq!(e.session().busses.len(), 1); + assert!(e.session().tracks[0].mixer.sends.iter().all(Option::is_none)); + e.execute("mix.send", &json!({"track": "Gtr", "bus": "verb"})).unwrap(); + assert_eq!(e.session().busses.len(), 1); + } + #[test] fn insert_and_send_reject_invalid_slot() { let mut e = Engine::default(); e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap(); + e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap(); for slot in [json!(12), json!("k"), json!("x1")] { let r = e.execute("mix.insert", &json!({"track": "Gtr", "slot": slot, "plugin": "compressor"})); assert!(r.is_err(), "insert slot {slot} accepted: {r:?}"); diff --git a/crates/engine/src/cmd/surround.rs b/crates/engine/src/cmd/surround.rs index 5a6630d3..bd23be54 100644 --- a/crates/engine/src/cmd/surround.rs +++ b/crates/engine/src/cmd/surround.rs @@ -184,6 +184,7 @@ mod tests { assert!(e.session().track(t).unwrap().mixer.surround.is_none()); // Sends. assert!(e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0})).is_err(), "empty slot"); + e.execute("mix.new_bus", &json!({"name": "FX"})).unwrap(); e.execute("mix.send", &json!({"track": t.0, "slot": 0, "bus": "FX"})).unwrap(); e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0, "y": -1.0})).unwrap(); let snd = e.session().track(t).unwrap().mixer.sends[0].clone().unwrap(); diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 35b5f5cb..4ee3fb83 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -1247,10 +1247,14 @@ fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, cu ui.separator(); if ui.button("new bus...").clicked() { let mut n = busses.len() + 1; - while busses.iter().any(|bus| bus.name == format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() { + while busses.iter().any(|bus| bus.name.eq_ignore_ascii_case(&format!("Bus {n}"))) + || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() + { n += 1; } - let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")})); + if app.run("mix.new_bus", json!({"name": format!("Bus {n}")})).is_ok() { + let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")})); + } ui.close(); } } diff --git a/crates/ui-egui/tests/menu_search.rs b/crates/ui-egui/tests/menu_search.rs index 973e6f5d..21b99a6c 100644 --- a/crates/ui-egui/tests/menu_search.rs +++ b/crates/ui-egui/tests/menu_search.rs @@ -143,6 +143,7 @@ fn send_and_output_menus_search_buses_as_soon_as_they_open() { let mut engine = vox(); let vox = engine.session().tracks[0].id; for (slot, bus) in [(0, "Verb"), (1, "Delay")] { + engine.execute("mix.new_bus", &json!({"name": bus})).unwrap(); engine.execute("mix.send", &json!({"track": vox.0, "slot": slot, "bus": bus})).unwrap(); } let mut h = harness(engine); diff --git a/crates/ui-egui/tests/send_window.rs b/crates/ui-egui/tests/send_window.rs index 908885d3..dcdef662 100644 --- a/crates/ui-egui/tests/send_window.rs +++ b/crates/ui-egui/tests/send_window.rs @@ -12,6 +12,7 @@ fn harness() -> (Harness<'static, SoundApp>, soundcraft_model::TrackId) { engine.execute("track.new", &json!({"name": "Vox"})).unwrap(); let vox = engine.session().tracks[0].id; for (slot, bus) in [(0, "Verb"), (1, "Delay")] { + engine.execute("mix.new_bus", &json!({"name": bus})).unwrap(); engine.execute("mix.send", &json!({"track": vox.0, "slot": slot, "bus": bus, "level_db": -10.0})).unwrap(); } let mut app = SoundApp::new(engine, None, Services::default()); From 152c2c45e6adf27ae0561748e9b2c3c4f6785418 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:51:29 +0200 Subject: [PATCH 08/33] Reject undefined SMPTE frame rates in MIDI headers Accept only the four defined SMPTE frame-rate codes. Add exhaustive invalid-code rejection and valid-rate timing and zero-tick regressions. --- crates/midi/src/smf.rs | 2 +- crates/midi/tests/smf.rs | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/midi/src/smf.rs b/crates/midi/src/smf.rs index 7747a061..3b4508bc 100644 --- a/crates/midi/src/smf.rs +++ b/crates/midi/src/smf.rs @@ -122,7 +122,7 @@ impl TickConv { let [hi, lo] = division.to_be_bytes(); let fps = -i128::from(hi as i8); let tpf = i128::from(lo); - if fps <= 0 || tpf == 0 { + if !matches!(fps, 24 | 25 | 29 | 30) || tpf == 0 { return Err(MidiError::Invalid { offset, reason: "invalid SMPTE division" }); } let two_ppq = 2 * i128::from(PPQ); diff --git a/crates/midi/tests/smf.rs b/crates/midi/tests/smf.rs index f069be39..aa79ef2b 100644 --- a/crates/midi/tests/smf.rs +++ b/crates/midi/tests/smf.rs @@ -148,6 +148,29 @@ fn division_conversion_96_and_smpte() { assert_eq!(smf.tracks[0].sequence.notes[0].length, 480); } +#[test] +fn smpte_division_rejects_undefined_frame_rates() { + let body = vec![0, 0x90, 60, 100, 40, 0x80, 60, 64, 0, 0xFF, 0x2F, 0]; + for high in 0x80..=0xFF { + if [(-24i8) as u8, (-25i8) as u8, (-29i8) as u8, (-30i8) as u8].contains(&high) { + continue; + } + let division = u16::from_be_bytes([high, 40]); + assert!(matches!(read_smf(&file(0, division, std::slice::from_ref(&body))), Err(MidiError::Invalid { offset: 12, .. })), "{division:#06x}"); + } +} + +#[test] +fn smpte_division_keeps_all_four_standard_frame_rates() { + for (fps, file_ticks, model_ticks) in [(24i8, 960, 1920), (25, 1000, 1920), (29, 1200, 1922), (30, 1200, 1920)] { + let body = [vec![0, 0x90, 60, 100], vlq(file_ticks), vec![0x80, 60, 64, 0, 0xFF, 0x2F, 0]].concat(); + let division = u16::from_be_bytes([(-fps) as u8, 40]); + let smf = read_smf(&file(0, division, std::slice::from_ref(&body))).unwrap(); + assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, model_ticks)], "{fps} fps"); + assert!(read_smf(&file(0, u16::from_be_bytes([(-fps) as u8, 0]), std::slice::from_ref(&body))).is_err()); + } +} + #[test] fn running_status_and_velocity_zero_note_off() { // Note-on with running status for both on and off (velocity 0). From 4ef6458e4eb9381f057b1c04483b5095e9df9a3c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:12:42 +0200 Subject: [PATCH 09/33] Fix integer overflow in TempoMap::bar_beat_at_tick for i64::MIN Found while testing crates/time/src/tempo.rs:271 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/time/src/tempo.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/time/src/tempo.rs b/crates/time/src/tempo.rs index 24a8f49a..6aaa50b8 100644 --- a/crates/time/src/tempo.rs +++ b/crates/time/src/tempo.rs @@ -268,9 +268,12 @@ impl TempoMap { let first = self.meter_at_tick(0); if tick < 0 { let tpb = first.ticks_per_bar(); - let bars_back = (-tick + tpb - 1) / tpb; - let bar_tick = -bars_back * tpb; - let within = tick - bar_tick; + let abs = tick.unsigned_abs(); + let tpb_u = tpb.unsigned_abs(); + let bars_back_u = abs / tpb_u + u64::from(!abs.is_multiple_of(tpb_u)); + let bars_back = i64::try_from(bars_back_u).unwrap_or(i64::MAX); + // Offset of `tick` inside its bar, without negating `i64::MIN`. + let within = ((tpb_u - abs % tpb_u) % tpb_u) as i64; let beat_t = first.ticks_per_beat(); return BarBeat { bar: 1 - bars_back, beat: within / beat_t + 1, tick: within % beat_t }; } @@ -341,6 +344,15 @@ mod tests { assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4); } + #[test] + fn bar_beat_at_min_tick_does_not_overflow() { + let m = TempoMap::default(); + let bb = m.bar_beat_at_tick(i64::MIN); + assert!(bb.bar < 1); + assert!(bb.beat >= 1 && bb.tick >= 0); + assert_eq!(m.bar_beat_at_tick(-1).bar, 0); + } + #[test] fn tempo_change_is_integrated() { let mut m = TempoMap::default(); From 01c8c85ebc0d9e35c2c3335e37b858c98d7abf23 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:12:50 +0200 Subject: [PATCH 10/33] Fix integer overflow in AutoParam::label for Pan(255) Found while testing crates/model/src/automation.rs:73 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/model/src/automation.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/model/src/automation.rs b/crates/model/src/automation.rs index a6d3963b..5afba72c 100644 --- a/crates/model/src/automation.rs +++ b/crates/model/src/automation.rs @@ -70,7 +70,7 @@ impl AutoParam { match self { AutoParam::Volume => "volume".into(), AutoParam::Pan(0) => "pan".into(), - AutoParam::Pan(i) => format!("pan {}", i + 1), + AutoParam::Pan(i) => format!("pan {}", u16::from(*i) + 1), AutoParam::Mute => "mute".into(), AutoParam::SendLevel(i) => format!("send {} level", slot_letter(*i)), AutoParam::SendPan(i) => format!("send {} pan", slot_letter(*i)), @@ -251,6 +251,11 @@ impl AutomationLane { mod tests { use super::*; + #[test] + fn pan_label_max_does_not_overflow() { + assert_eq!(AutoParam::Pan(255).label(), "pan 256"); + } + #[test] fn interpolation_and_steps() { let mut l = AutomationLane::new(AutoParam::Volume); From b74fca0fb0f8b4a8474c447c1b6043a5028a5de2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 21 Oct 2026 13:57:33 -0400 Subject: [PATCH 11/33] Release 0.11.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 36 ++++++++++++++++++------------------ Cargo.toml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85b092db..1b392467 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3572,7 +3572,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.10.0" +version = "0.11.0" dependencies = [ "eframe", "egui", @@ -3590,7 +3590,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.10.0" +version = "0.11.0" dependencies = [ "log", "serde", @@ -3600,7 +3600,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.10.0" +version = "0.11.0" dependencies = [ "log", "proptest", @@ -3612,7 +3612,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.10.0" +version = "0.11.0" dependencies = [ "log", "serde_json", @@ -3622,7 +3622,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.10.0" +version = "0.11.0" dependencies = [ "clap-sys", "libloading", @@ -3635,7 +3635,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.10.0" +version = "0.11.0" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3646,7 +3646,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.10.0" +version = "0.11.0" dependencies = [ "log", "proptest", @@ -3657,7 +3657,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.10.0" +version = "0.11.0" dependencies = [ "craft-docpath", "log", @@ -3680,7 +3680,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.10.0" +version = "0.11.0" dependencies = [ "proptest", "serde", @@ -3690,7 +3690,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.10.0" +version = "0.11.0" dependencies = [ "log", "rayon", @@ -3706,7 +3706,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.10.0" +version = "0.11.0" dependencies = [ "proptest", "serde", @@ -3719,7 +3719,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cpal", "log", @@ -3734,7 +3734,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.10.0" +version = "0.11.0" dependencies = [ "proptest", "serde", @@ -3744,7 +3744,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.10.0" +version = "0.11.0" dependencies = [ "craft-ui", "egui", @@ -3770,7 +3770,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.10.0" +version = "0.11.0" dependencies = [ "image", "proptest", @@ -3781,7 +3781,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.10.0" +version = "0.11.0" dependencies = [ "libloading", "log", @@ -3797,7 +3797,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.10.0" +version = "0.11.0" dependencies = [ "eframe", "egui", @@ -5354,7 +5354,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.10.0" +version = "0.11.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 1a82f986..e9030e9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.10.0" +version = "0.11.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 7587ae269041ea62e8e848ac2a1c89f1a243c9ac Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 15:47:06 -0400 Subject: [PATCH 12/33] ci: add SonarCloud scan via shared reusable workflow Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main with secrets: inherit. Adds SonarCloud badge to README where missing and removes the redundant sonar-project.properties (config now passed by the reusable workflow). --- .github/workflows/sonarcloud.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/sonarcloud.yml diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 00000000..ecee45a6 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit From 30ef8306f8a4b569b6ef96d6faeaf07ad714e999 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 16:00:49 -0400 Subject: [PATCH 13/33] ci: add sonar-project.properties for CI-based analysis Marks the project ineligible for SonarCloud Automatic Analysis, resolving the CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds. --- sonar-project.properties | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..28d1c1b6 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=storytold_soundcraft +sonar.organization=storytold From 9b8283b90877813a929f5e28b2dc92e5774da933 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 20:13:52 -0400 Subject: [PATCH 14/33] security: enforce HTTPS on curl downloads (SonarCloud S6506) (#860) Add --proto '=https' to curl invocations so redirects cannot downgrade to plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions). --- packaging/linux/package.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index a949dba4..8f7c9160 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -115,7 +115,7 @@ if has appimage; then if [ -z "$TOOL" ]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" + curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi fi From 24543e1486be4b578739295f37dd6891202b53fe Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 22 Oct 2026 17:58:22 +0700 Subject: [PATCH 15/33] README: the app icon above the title --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index c683c34b..c31fc87a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@

+

+ SoundCraft icon: a singing bird on a blue tile +

+

SoundCraft

From 2cfc56d5156823479a4b3b06d73f564c740399b1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 15:38:08 -0400 Subject: [PATCH 16/33] Release 0.12.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 36 ++++++++++++++++++------------------ Cargo.toml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b392467..3ec7f2f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3572,7 +3572,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.11.0" +version = "0.12.0" dependencies = [ "eframe", "egui", @@ -3590,7 +3590,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.11.0" +version = "0.12.0" dependencies = [ "log", "serde", @@ -3600,7 +3600,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.11.0" +version = "0.12.0" dependencies = [ "log", "proptest", @@ -3612,7 +3612,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.11.0" +version = "0.12.0" dependencies = [ "log", "serde_json", @@ -3622,7 +3622,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.11.0" +version = "0.12.0" dependencies = [ "clap-sys", "libloading", @@ -3635,7 +3635,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3646,7 +3646,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.11.0" +version = "0.12.0" dependencies = [ "log", "proptest", @@ -3657,7 +3657,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.11.0" +version = "0.12.0" dependencies = [ "craft-docpath", "log", @@ -3680,7 +3680,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.11.0" +version = "0.12.0" dependencies = [ "proptest", "serde", @@ -3690,7 +3690,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.11.0" +version = "0.12.0" dependencies = [ "log", "rayon", @@ -3706,7 +3706,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.11.0" +version = "0.12.0" dependencies = [ "proptest", "serde", @@ -3719,7 +3719,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.11.0" +version = "0.12.0" dependencies = [ "cpal", "log", @@ -3734,7 +3734,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.11.0" +version = "0.12.0" dependencies = [ "proptest", "serde", @@ -3744,7 +3744,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.11.0" +version = "0.12.0" dependencies = [ "craft-ui", "egui", @@ -3770,7 +3770,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.11.0" +version = "0.12.0" dependencies = [ "image", "proptest", @@ -3781,7 +3781,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.11.0" +version = "0.12.0" dependencies = [ "libloading", "log", @@ -3797,7 +3797,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.11.0" +version = "0.12.0" dependencies = [ "eframe", "egui", @@ -5354,7 +5354,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index e9030e9d..975accb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.11.0" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 239c78e3a0ac1b85d42f95e8a1cc42556604f202 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 16:16:16 -0400 Subject: [PATCH 17/33] macOS packaging: retry stapler when Apple's CloudKit drops the connection stapler staple/validate fetch the notarization ticket from CloudKit, which sometimes fails with "The network connection was lost" (Error 68). That failed this release's macOS job twice in a row. Both steps are idempotent, so retry up to 5 times with backoff, like hdiutil_retry. Co-Authored-By: Claude Opus 5.5 --- packaging/macos/package.sh | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 8208aac1..ff7aba25 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -80,6 +80,20 @@ sign() { codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } +# stapler fetches the ticket from Apple's CloudKit, which sometimes drops the connection ("The +# network connection was lost", Error 68). Stapling and validating are idempotent, so retry. +stapler_retry() { + local attempt + for attempt in 1 2 3 4 5; do + if xcrun stapler "$@"; then + return 0 + fi + [ "$attempt" = 5 ] && { echo "stapler $1 failed 5 times" >&2; return 1; } + warn "stapler $1 failed (attempt $attempt of 5); retrying in $((attempt * 15))s" + sleep $((attempt * 15)) + done +} + notarize() { local file="$1" out id status echo "==> notarizing $(basename "$file") (this can take a few minutes)" @@ -122,8 +136,8 @@ codesign --verify --strict --deep --verbose=2 "$APP" if [ "$NOTARIZE" = 1 ]; then ditto -c -k --keepParent "$APP" "$WORK/SoundCraft-notarize.zip" notarize "$WORK/SoundCraft-notarize.zip" - xcrun stapler staple "$APP" - xcrun stapler validate "$APP" + stapler_retry staple "$APP" + stapler_retry validate "$APP" spctl --assess --type execute -vvv "$APP" fi @@ -185,8 +199,8 @@ if [ "$dmg_ok" != 1 ]; then fi if [ "$NOTARIZE" = 1 ]; then notarize "$DMG" - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" + stapler_retry staple "$DMG" + stapler_retry validate "$DMG" spctl --assess --type open --context context:primary-signature -vvv "$DMG" fi From e85a4801e9029c9a1f5ce536b11be65aea832ed8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 16:16:17 -0400 Subject: [PATCH 18/33] Release 0.12.1 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 36 ++++++++++++++++++------------------ Cargo.toml | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ec7f2f4..848fa2c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3572,7 +3572,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.12.0" +version = "0.12.1" dependencies = [ "eframe", "egui", @@ -3590,7 +3590,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.12.0" +version = "0.12.1" dependencies = [ "log", "serde", @@ -3600,7 +3600,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.12.0" +version = "0.12.1" dependencies = [ "log", "proptest", @@ -3612,7 +3612,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.12.0" +version = "0.12.1" dependencies = [ "log", "serde_json", @@ -3622,7 +3622,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.12.0" +version = "0.12.1" dependencies = [ "clap-sys", "libloading", @@ -3635,7 +3635,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.12.0" +version = "0.12.1" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3646,7 +3646,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.12.0" +version = "0.12.1" dependencies = [ "log", "proptest", @@ -3657,7 +3657,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.12.0" +version = "0.12.1" dependencies = [ "craft-docpath", "log", @@ -3680,7 +3680,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.12.0" +version = "0.12.1" dependencies = [ "proptest", "serde", @@ -3690,7 +3690,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.12.0" +version = "0.12.1" dependencies = [ "log", "rayon", @@ -3706,7 +3706,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.12.0" +version = "0.12.1" dependencies = [ "proptest", "serde", @@ -3719,7 +3719,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.12.0" +version = "0.12.1" dependencies = [ "cpal", "log", @@ -3734,7 +3734,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.12.0" +version = "0.12.1" dependencies = [ "proptest", "serde", @@ -3744,7 +3744,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.12.0" +version = "0.12.1" dependencies = [ "craft-ui", "egui", @@ -3770,7 +3770,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.12.0" +version = "0.12.1" dependencies = [ "image", "proptest", @@ -3781,7 +3781,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.12.0" +version = "0.12.1" dependencies = [ "libloading", "log", @@ -3797,7 +3797,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.12.0" +version = "0.12.1" dependencies = [ "eframe", "egui", @@ -5354,7 +5354,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.12.0" +version = "0.12.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 975accb8..6668c3f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.12.0" +version = "0.12.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 52471b37e957feaea56dfd48932ef16d314cc74a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:36:57 -0400 Subject: [PATCH 19/33] chore(deps): bump the cargo-dependencies group across 1 directory with 11 updates (#767) Bumps the cargo-dependencies group with 11 updates in the / directory: | Package | From | To | | --- | --- | --- | | [serde_json](https://github.com/serde-rs/json) | `1.0.151` | `1.0.154` | | [proptest](https://github.com/proptest-rs/proptest) | `1.11.0` | `1.12.0` | | [symphonia](https://github.com/pdeljanov/Symphonia) | `0.5.5` | `0.6.1` | | [zip](https://github.com/zip-rs/zip2) | `9.0.1` | `9.0.5` | | [cpal](https://github.com/RustAudio/cpal) | `0.16.0` | `0.18.2` | | [egui](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` | | [eframe](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` | | [egui_kittest](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` | | [libloading](https://github.com/nagisa/rust_libloading) | `0.8.9` | `0.9.0` | | [muda](https://github.com/tauri-apps/muda) | `0.19.3` | `0.21.2` | | [winresource](https://github.com/BenjaminRi/winresource) | `0.1.31` | `0.1.34` | Updates `serde_json` from 1.0.151 to 1.0.154 - [Release notes](https://github.com/serde-rs/json/releases) - [Commits](https://github.com/serde-rs/json/compare/v1.0.151...v1.0.154) Updates `proptest` from 1.11.0 to 1.12.0 - [Release notes](https://github.com/proptest-rs/proptest/releases) - [Changelog](https://github.com/proptest-rs/proptest/blob/main/CHANGELOG.md) - [Commits](https://github.com/proptest-rs/proptest/compare/v1.11.0...v1.12.0) Updates `symphonia` from 0.5.5 to 0.6.1 - [Release notes](https://github.com/pdeljanov/Symphonia/releases) - [Commits](https://github.com/pdeljanov/Symphonia/compare/v0.5.5...v0.6.1) Updates `zip` from 9.0.1 to 9.0.5 - [Release notes](https://github.com/zip-rs/zip2/releases) - [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md) - [Commits](https://github.com/zip-rs/zip2/compare/v9.0.1...v9.0.5) Updates `cpal` from 0.16.0 to 0.18.2 - [Release notes](https://github.com/RustAudio/cpal/releases) - [Changelog](https://github.com/RustAudio/cpal/blob/master/CHANGELOG.md) - [Commits](https://github.com/RustAudio/cpal/compare/v0.16.0...v0.18.2) Updates `egui` from 0.36.2 to 0.37.0 - [Release notes](https://github.com/emilk/egui/releases) - [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md) - [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0) Updates `eframe` from 0.36.2 to 0.37.0 - [Release notes](https://github.com/emilk/egui/releases) - [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md) - [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0) Updates `egui_kittest` from 0.36.2 to 0.37.0 - [Release notes](https://github.com/emilk/egui/releases) - [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md) - [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0) Updates `libloading` from 0.8.9 to 0.9.0 - [Commits](https://github.com/nagisa/rust_libloading/compare/0.8.9...0.9.0) Updates `muda` from 0.19.3 to 0.21.2 - [Release notes](https://github.com/tauri-apps/muda/releases) - [Changelog](https://github.com/tauri-apps/muda/blob/dev/CHANGELOG.md) - [Commits](https://github.com/tauri-apps/muda/compare/muda-v0.19.3...muda-v0.21.2) Updates `winresource` from 0.1.31 to 0.1.34 - [Commits](https://github.com/BenjaminRi/winresource/commits) --- updated-dependencies: - dependency-name: cpal dependency-version: 0.18.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: eframe dependency-version: 0.37.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: egui dependency-version: 0.37.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: egui_kittest dependency-version: 0.37.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: libloading dependency-version: 0.9.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: muda dependency-version: 0.21.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: proptest dependency-version: 1.12.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: serde_json dependency-version: 1.0.154 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-dependencies - dependency-name: symphonia dependency-version: 0.6.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: cargo-dependencies - dependency-name: winresource dependency-version: 0.1.34 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-dependencies - dependency-name: zip dependency-version: 9.0.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 1004 ++++++++++++++++++----------------- Cargo.toml | 10 +- apps/soundcraft/Cargo.toml | 2 +- crates/clap-host/Cargo.toml | 2 +- crates/vst3-host/Cargo.toml | 2 +- 5 files changed, 528 insertions(+), 492 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 848fa2c2..d006e25c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,13 +12,22 @@ dependencies = [ ] [[package]] -name = "accesskit_atspi_common" -version = "0.18.1" +name = "accesskit" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ - "accesskit", - "accesskit_consumer 0.36.0", + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" +dependencies = [ + "accesskit 0.25.1", + "accesskit_consumer", "atspi-common", "phf", "serde", @@ -27,43 +36,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit 0.25.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ - "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit 0.25.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ - "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit 0.25.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,11 +74,11 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ - "accesskit", + "accesskit 0.25.1", "accesskit_atspi_common", "async-channel", "async-executor", @@ -89,25 +92,26 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ - "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit 0.25.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", ] [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ - "accesskit", + "accesskit 0.25.1", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -151,9 +155,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alsa" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", "bitflags 2.13.2", @@ -163,9 +167,9 @@ dependencies = [ [[package]] name = "alsa-sys" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" dependencies = [ "libc", "pkg-config", @@ -180,7 +184,7 @@ dependencies = [ "android-properties", "bitflags 2.13.2", "cc", - "jni 0.22.4", + "jni", "libc", "log", "ndk", @@ -228,6 +232,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -249,20 +254,19 @@ version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] name = "asio-sys" -version = "0.2.6" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "826194e1612938c9be09b78b58323fbb2e326de3d491b4230186cf6e832d8ded" +checksum = "477f1cf5b65679fc2e5422af426f87becba79d1cfa2cd77eff3433e36c8021b9" dependencies = [ "bindgen", "cc", "num-derive", "num-traits", - "parse_cfg", "walkdir", ] @@ -466,15 +470,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -485,10 +480,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -496,6 +494,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -507,9 +515,6 @@ name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -dependencies = [ - "serde_core", -] [[package]] name = "block2" @@ -542,6 +547,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -594,42 +623,18 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "calloop" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" -dependencies = [ - "bitflags 2.13.2", - "polling", - "rustix 1.1.5", - "tracing", -] - [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop 0.13.0", + "calloop", "rustix 0.38.44", "wayland-backend", "wayland-client", ] -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop 0.14.5", - "rustix 1.1.5", - "wayland-backend", - "wayland-client", -] - [[package]] name = "cc" version = "1.6.0" @@ -642,19 +647,13 @@ dependencies = [ "shlex 2.0.1", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cexpr" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -678,6 +677,17 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "clang-sys" version = "1.9.1" @@ -686,7 +696,7 @@ checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", ] [[package]] @@ -807,11 +817,11 @@ checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" [[package]] name = "coreaudio-rs" -version = "0.13.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.2", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -821,15 +831,18 @@ dependencies = [ [[package]] name = "cpal" -version = "0.16.0" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" +checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1" dependencies = [ "alsa", "asio-sys", + "block2 0.6.2", "coreaudio-rs", "dasp_sample", - "jni 0.21.1", + "futures-channel", + "futures-util", + "jni", "js-sys", "libc", "mach2", @@ -837,13 +850,27 @@ dependencies = [ "ndk-context", "num-derive", "num-traits", + "objc2 0.6.5", "objc2-audio-toolbox", + "objc2-avf-audio", "objc2-core-audio", "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.54.0", + "windows", + "windows-core", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", ] [[package]] @@ -856,7 +883,7 @@ name = "craft-ui" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" dependencies = [ - "egui", + "egui 0.36.2", "serde", ] @@ -969,7 +996,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] @@ -998,21 +1025,30 @@ name = "ecolor" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "emath 0.36.2", +] + +[[package]] +name = "ecolor" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31bcbef0d4263a63eb32c4834e5d711613035ce1333468e107d928923e21e6e2" dependencies = [ "bytemuck", - "emath", + "emath 0.37.0", ] [[package]] name = "eframe" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +checksum = "9c5642501a65ca657d05f00c643a5c14ff2346cb3818032bfe23d58e1f8e5f84" dependencies = [ "ahash", "bytemuck", "document-features", - "egui", + "egui 0.37.0", "egui-wgpu", "egui-winit", "egui_glow", @@ -1029,6 +1065,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1044,11 +1081,31 @@ version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" dependencies = [ - "accesskit", + "accesskit 0.24.1", "ahash", "bitflags 2.13.2", - "emath", - "epaint", + "emath 0.36.2", + "epaint 0.36.2", + "itertools 0.15.0", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9144f49b7a28b5f395d3147e325b4fe3e77ba686eac11d1bdf0a7da3f375f" +dependencies = [ + "accesskit 0.25.1", + "ahash", + "bitflags 2.13.2", + "emath 0.37.0", + "epaint 0.37.0", "itertools 0.15.0", "log", "nohash-hasher", @@ -1060,15 +1117,15 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +checksum = "0309cbe1febed48af5b974e4bcc424b35e9664863a4d402697147b57a488c864" dependencies = [ "ahash", "bytemuck", "document-features", - "egui", - "epaint", + "egui 0.37.0", + "epaint 0.37.0", "log", "profiling", "thiserror 2.0.21", @@ -1080,34 +1137,36 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +checksum = "e4e449f4c4a2a60c7ccb4ba7fa4078417bfe1f9ddc03b5e05ce75748e33a70de" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", - "egui", + "egui 0.37.0", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", - "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +checksum = "9275ca5cc35a6b7c94e0e8cedf22542f66cb0cf45ed8ab2657c78ff49fec7268" dependencies = [ "bytemuck", - "egui", + "egui 0.37.0", "glow", "log", "profiling", @@ -1116,13 +1175,13 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +checksum = "0979c2e37860fcf13a2bea77508607f067a9a6a84ba9bd47b5e298582fb59b86" dependencies = [ "dify", "eframe", - "egui", + "egui 0.37.0", "egui-wgpu", "image", "kittest", @@ -1146,22 +1205,14 @@ name = "emath" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" -dependencies = [ - "bytemuck", -] [[package]] -name = "encoding_rs" -version = "0.8.42" +name = "emath" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +checksum = "e73ce90f54750be8672c02be0743fe47530e6f251e066f0ee2869b59eb297af5" dependencies = [ - "cfg-if", - "core_detect", - "multiversion_no_op", - "rustversion", - "scopeguard", - "simdutf8", + "bytemuck", ] [[package]] @@ -1198,10 +1249,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" dependencies = [ "ahash", - "bytemuck", - "ecolor", - "emath", - "epaint_default_fonts", + "ecolor 0.36.2", + "emath 0.36.2", "font-types", "harfrust", "log", @@ -1213,14 +1262,40 @@ dependencies = [ "smallvec", "unicode-general-category", "unicode-segmentation", - "vello_cpu", + "vello_cpu 0.1.0", +] + +[[package]] +name = "epaint" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97bac86ae3dde93fc3951e790951caea739ff39a6c89d909cdaf1d9278dbb1bd" +dependencies = [ + "ahash", + "bytemuck", + "ecolor 0.37.0", + "emath 0.37.0", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-properties", + "unicode-segmentation", + "vello_cpu 0.2.0", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +checksum = "cfc1cc505827f4c23eedc297975a0fd038268d41293f389dd8ff3b574aefab19" [[package]] name = "equivalent" @@ -1312,6 +1387,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1329,6 +1410,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1380,6 +1467,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1475,6 +1571,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core", ] [[package]] @@ -1495,13 +1592,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", "skrifa", "smallvec", - "vello_common", + "vello_common 0.1.0", +] + +[[package]] +name = "glifo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common 0.2.0", ] [[package]] @@ -1535,7 +1648,7 @@ dependencies = [ "glutin_egl_sys", "glutin_glx_sys", "glutin_wgl_sys", - "libloading", + "libloading 0.8.9", "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", @@ -1599,7 +1712,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.21", - "windows 0.62.2", + "windows", ] [[package]] @@ -1635,6 +1748,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1643,7 +1765,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1652,7 +1774,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1840,22 +1962,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - [[package]] name = "jni" version = "0.22.4" @@ -1937,13 +2043,12 @@ dependencies = [ [[package]] name = "keyboard-types" -version = "0.7.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" dependencies = [ "bitflags 2.13.2", "serde", - "unicode-segmentation", ] [[package]] @@ -1953,7 +2058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", - "libloading", + "libloading 0.8.9", "pkg-config", ] @@ -1965,12 +2070,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ - "accesskit", - "accesskit_consumer 0.35.0", + "accesskit 0.25.1", + "accesskit_consumer", ] [[package]] @@ -2007,6 +2112,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -2072,12 +2187,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "mach2" -version = "0.4.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" [[package]] name = "memchr" @@ -2141,9 +2253,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.19.3" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" dependencies = [ "crossbeam-channel", "dpi", @@ -2152,18 +2264,11 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", - "once_cell", "png", "thiserror 2.0.21", "windows-sys 0.61.2", ] -[[package]] -name = "multiversion_no_op" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" - [[package]] name = "naga" version = "30.0.1" @@ -2260,6 +2365,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -2391,6 +2505,17 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-cloud-kit" version = "0.2.2" @@ -2425,6 +2550,7 @@ dependencies = [ "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -2456,7 +2582,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", + "block2 0.6.2", "dispatch2", + "libc", "objc2 0.6.5", ] @@ -2524,6 +2652,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", + "libc", "objc2 0.6.5", "objc2-core-foundation", ] @@ -2714,6 +2843,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "parking" version = "2.2.1" @@ -2743,15 +2882,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "parse_cfg" -version = "4.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "905787a434a2c721408e7c9a252e85f3d93ca0f118a5283022636c0e05a7ea49" -dependencies = [ - "nom", -] - [[package]] name = "peniko" version = "0.6.1" @@ -2772,10 +2902,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "phf" -version = "0.13.1" +name = "petgraph" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -2784,9 +2925,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -2794,9 +2935,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -2807,9 +2948,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -2935,15 +3076,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "presser" version = "0.3.1" @@ -2995,16 +3127,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", "rand", - "rand_chacha", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -3062,38 +3195,25 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.5" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", + "getrandom 0.4.3", "rand_core", ] [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ "rand_core", ] @@ -3203,6 +3323,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.11" @@ -3354,6 +3480,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -3386,9 +3518,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -3505,8 +3637,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.2", - "calloop 0.13.0", - "calloop-wayland-source 0.3.0", + "calloop", + "calloop-wayland-source", "cursor-icon", "libc", "log", @@ -3523,44 +3655,6 @@ dependencies = [ "xkeysym", ] -[[package]] -name = "smithay-client-toolkit" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" -dependencies = [ - "bitflags 2.13.2", - "calloop 0.14.5", - "calloop-wayland-source 0.4.1", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 1.1.5", - "thiserror 2.0.21", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-experimental", - "wayland-protocols-misc", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-clipboard" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" -dependencies = [ - "libc", - "smithay-client-toolkit 0.20.0", - "wayland-backend", -] - [[package]] name = "smol_str" version = "0.2.2" @@ -3575,7 +3669,7 @@ name = "soundcraft" version = "0.12.1" dependencies = [ "eframe", - "egui", + "egui 0.37.0", "log", "muda", "objc2 0.6.5", @@ -3625,7 +3719,7 @@ name = "soundcraft-clap-host" version = "0.12.1" dependencies = [ "clap-sys", - "libloading", + "libloading 0.9.0", "log", "serde", "soundcraft-dsp", @@ -3747,7 +3841,7 @@ name = "soundcraft-ui-egui" version = "0.12.1" dependencies = [ "craft-ui", - "egui", + "egui 0.37.0", "egui_kittest", "image", "log", @@ -3783,7 +3877,7 @@ dependencies = [ name = "soundcraft-vst3-host" version = "0.12.1" dependencies = [ - "libloading", + "libloading 0.9.0", "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", @@ -3800,7 +3894,7 @@ name = "soundcraft-web" version = "0.12.1" dependencies = [ "eframe", - "egui", + "egui 0.37.0", "js-sys", "log", "rfd", @@ -3843,9 +3937,9 @@ checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" [[package]] name = "symphonia" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424" dependencies = [ "lazy_static", "symphonia-bundle-flac", @@ -3866,44 +3960,44 @@ dependencies = [ [[package]] name = "symphonia-bundle-flac" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +checksum = "6405d5c34ff6f8f7ca08a4101efe66d21e180f7322ef9359900a0e55698a7892" dependencies = [ "log", + "symphonia-common", "symphonia-core", "symphonia-metadata", - "symphonia-utils-xiph", ] [[package]] name = "symphonia-bundle-mp3" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +checksum = "98ea5ffc8716bff677dfb3b01b420c7b758de901a72b8c330bf2040ab74b4add" dependencies = [ "lazy_static", "log", "symphonia-core", - "symphonia-metadata", ] [[package]] name = "symphonia-codec-aac" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" +checksum = "f5bf8e39552d34a3c4c98333370e62f48c92456d2e814f273b5c3ad7c4a5f45c" dependencies = [ "lazy_static", "log", + "symphonia-common", "symphonia-core", ] [[package]] name = "symphonia-codec-adpcm" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +checksum = "445932ecb0c59362fde9c082dd63a75380650c5077fdb8b80b8cac850442a74c" dependencies = [ "log", "symphonia-core", @@ -3911,19 +4005,20 @@ dependencies = [ [[package]] name = "symphonia-codec-alac" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" +checksum = "920a78f96f3cf62932d0c497959c0cc2100ef36b03c7ca1417b260f432a482d8" dependencies = [ "log", + "symphonia-common", "symphonia-core", ] [[package]] name = "symphonia-codec-pcm" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +checksum = "e04ba75686acbe43542fdd374571195f0530c0b7785ca25cc6840e9c6c4b6eea" dependencies = [ "log", "symphonia-core", @@ -3931,82 +4026,92 @@ dependencies = [ [[package]] name = "symphonia-codec-vorbis" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" +checksum = "73d90b4fcf796137cc683c538282804ff9629f8ad9dbfd881fcbba331ac4e986" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", +] + +[[package]] +name = "symphonia-common" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acc3fcc18ec9b8cdd48614e259c4cf0d27b71d41e5d9b120b42c5adab12d7c4" dependencies = [ "log", "symphonia-core", - "symphonia-utils-xiph", + "symphonia-metadata", ] [[package]] name = "symphonia-core" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0" dependencies = [ - "arrayvec", - "bitflags 1.3.2", + "bitflags 2.13.2", "bytemuck", "lazy_static", "log", + "num-complex", + "smallvec", ] [[package]] name = "symphonia-format-caf" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" +checksum = "ab64327ee1920531c5bf86cf7e9cd1a599d57013ddc30691da62683cefc65191" dependencies = [ "log", + "symphonia-common", "symphonia-core", - "symphonia-metadata", ] [[package]] name = "symphonia-format-isomp4" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" +checksum = "0e681a70e1870d34e02abf1dbc51e4267c3f1827801474e8870be8c689fc4dc3" dependencies = [ - "encoding_rs", "log", + "symphonia-common", "symphonia-core", "symphonia-metadata", - "symphonia-utils-xiph", ] [[package]] name = "symphonia-format-mkv" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" +checksum = "d015c5c0558864665894b3f4cbd95e10abb01b9c868e751c72670f326a56360e" dependencies = [ "lazy_static", "log", + "symphonia-common", "symphonia-core", - "symphonia-metadata", - "symphonia-utils-xiph", ] [[package]] name = "symphonia-format-ogg" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +checksum = "0b5495e7f7e3c7035328d82b6d6e377eef289bb0c4105bdeb557fc93a833f994" dependencies = [ "log", + "symphonia-common", "symphonia-core", "symphonia-metadata", - "symphonia-utils-xiph", ] [[package]] name = "symphonia-format-riff" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +checksum = "1ff70929083a8c1a5f6cd7c904b6071c7914ad04739b510c2f7239dfc9b7dabe" dependencies = [ "extended", "log", @@ -4016,26 +4121,17 @@ dependencies = [ [[package]] name = "symphonia-metadata" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +checksum = "83713a97705d77bdef7cdbc0768fd6e5a54e4cd7e48d60a806ae85639e2c87c6" dependencies = [ - "encoding_rs", "lazy_static", "log", + "regex-lite", + "smallvec", "symphonia-core", ] -[[package]] -name = "symphonia-utils-xiph" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" -dependencies = [ - "symphonia-core", - "symphonia-metadata", -] - [[package]] name = "syn" version = "2.0.119" @@ -4221,7 +4317,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4257,6 +4352,17 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "type-map" version = "0.5.1" @@ -4301,6 +4407,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4357,6 +4469,21 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "vello_common" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] + [[package]] name = "vello_cpu" version = "0.1.0" @@ -4364,9 +4491,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" dependencies = [ "bytemuck", - "glifo", + "glifo 0.2.0", "hashbrown 0.17.1", - "vello_common", + "vello_common 0.1.0", +] + +[[package]] +name = "vello_cpu" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" +dependencies = [ + "bytemuck", + "glifo 0.3.0", + "hashbrown 0.17.1", + "vello_common 0.2.0", ] [[package]] @@ -4528,32 +4667,6 @@ dependencies = [ "wayland-scanner", ] -[[package]] -name = "wayland-protocols-experimental" -version = "20250721.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-misc" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - [[package]] name = "wayland-protocols-plasma" version = "0.3.12" @@ -4629,7 +4742,7 @@ version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ - "jni 0.22.4", + "jni", "log", "ndk-context", "objc2 0.6.5", @@ -4768,7 +4881,7 @@ dependencies = [ "js-sys", "khronos-egl", "libc", - "libloading", + "libloading 0.8.9", "log", "naga", "naga-types", @@ -4797,9 +4910,9 @@ dependencies = [ "web-sys", "wgpu-naga-bridge", "wgpu-types", - "windows 0.62.2", - "windows-core 0.62.2", - "windows-result 0.4.1", + "windows", + "windows-core", + "windows-result", ] [[package]] @@ -4837,16 +4950,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "windows" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" -dependencies = [ - "windows-core 0.54.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.62.2" @@ -4854,7 +4957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core 0.62.2", + "windows-core", "windows-future", "windows-numerics", ] @@ -4865,17 +4968,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.62.2", -] - -[[package]] -name = "windows-core" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" -dependencies = [ - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core", ] [[package]] @@ -4887,7 +4980,7 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link", - "windows-result 0.4.1", + "windows-result", "windows-strings", ] @@ -4897,7 +4990,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.62.2", + "windows-core", "windows-link", "windows-threading", ] @@ -4936,19 +5029,10 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.62.2", + "windows-core", "windows-link", ] -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -4967,15 +5051,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -5012,21 +5087,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -5069,12 +5129,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5087,12 +5141,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5105,12 +5153,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5135,12 +5177,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5153,12 +5189,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5171,12 +5201,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5189,12 +5213,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5219,7 +5237,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "bytemuck", - "calloop 0.13.0", + "calloop", "cfg_aliases", "concurrent-queue", "core-foundation", @@ -5240,7 +5258,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", - "smithay-client-toolkit 0.19.2", + "smithay-client-toolkit", "smol_str", "tracing", "unicode-segmentation", @@ -5269,9 +5287,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -5283,6 +5301,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.4" @@ -5309,7 +5345,7 @@ dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "libloading", + "libloading 0.8.9", "once_cell", "rustix 1.1.5", "x11rb-protocol", @@ -5564,9 +5600,9 @@ dependencies = [ [[package]] name = "zip" -version = "9.0.1" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ "crc32fast", "indexmap", diff --git a/Cargo.toml b/Cargo.toml index 6668c3f7..146f58d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,20 +36,20 @@ rayon = "1" proptest = "1" # audio -symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] } +symphonia = { version = "0.6", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] } rustfft = "6" # Session bundles (one file holding a session and its audio, for the browser): stored entries only. zip = { version = "9", default-features = false } -cpal = "0.16" +cpal = "0.18" # Which file paths stored in a session may be opened. craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # ui craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -egui = "0.36" -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = "0.37" +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/apps/soundcraft/Cargo.toml b/apps/soundcraft/Cargo.toml index 0f30c7a9..634bf218 100644 --- a/apps/soundcraft/Cargo.toml +++ b/apps/soundcraft/Cargo.toml @@ -31,6 +31,6 @@ rfd.workspace = true winresource = "0.1" [target.'cfg(target_os = "macos")'.dependencies] -muda = { version = "0.19.2", default-features = false } +muda = { version = "0.21.2", default-features = false } objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] } diff --git a/crates/clap-host/Cargo.toml b/crates/clap-host/Cargo.toml index e08bbdfa..96c7de7c 100644 --- a/crates/clap-host/Cargo.toml +++ b/crates/clap-host/Cargo.toml @@ -23,7 +23,7 @@ harness = false [target.'cfg(not(target_arch = "wasm32"))'.dependencies] clap-sys = "0.5" -libloading = "0.8" +libloading = "0.9" # The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace # `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the diff --git a/crates/vst3-host/Cargo.toml b/crates/vst3-host/Cargo.toml index 1c1583a6..00ace628 100644 --- a/crates/vst3-host/Cargo.toml +++ b/crates/vst3-host/Cargo.toml @@ -15,7 +15,7 @@ log.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] vst3 = "0.3" -libloading = "0.8" +libloading = "0.9" # The macOS editor host window (an NSWindow whose content view the plugin's IPlugView attaches to). [target.'cfg(target_os = "macos")'.dependencies] From 44067c8779eb364b10efd0ca59c28beff10c4280 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 09:29:32 -0400 Subject: [PATCH 20/33] style: normalize the prores DCT basis literals (SonarCloud rust:S7454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.490_392_64 and 0.49039264 are the same f32 value; the underscored form reads as a numeric suffix to the analyzer. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- crates/video/src/prores/tables.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/video/src/prores/tables.rs b/crates/video/src/prores/tables.rs index a395e906..975e9d3f 100644 --- a/crates/video/src/prores/tables.rs +++ b/crates/video/src/prores/tables.rs @@ -88,13 +88,13 @@ pub(crate) fn qscale(qidx: u8) -> u32 { /// Orthonormal 1-D DCT-II basis: `BASIS[u][x] = C(u)/2 · cos((2x+1)uπ/16)`, `C(0) = 1/√2`. pub(crate) const BASIS: [[f32; 8]; 8] = [ [0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39, 0.353_553_39], - [0.490_392_64, 0.415_734_8, 0.277_785_12, 0.097_545_16, -0.097_545_16, -0.277_785_12, -0.415_734_8, -0.490_392_64], + [0.49039264, 0.415_734_8, 0.277_785_12, 0.097_545_16, -0.097_545_16, -0.277_785_12, -0.415_734_8, -0.49039264], [0.461_939_77, 0.191_341_72, -0.191_341_72, -0.461_939_77, -0.461_939_77, -0.191_341_72, 0.191_341_72, 0.461_939_77], - [0.415_734_8, -0.097_545_16, -0.490_392_64, -0.277_785_12, 0.277_785_12, 0.490_392_64, 0.097_545_16, -0.415_734_8], + [0.415_734_8, -0.097_545_16, -0.49039264, -0.277_785_12, 0.277_785_12, 0.49039264, 0.097_545_16, -0.415_734_8], [0.353_553_39, -0.353_553_39, -0.353_553_39, 0.353_553_39, 0.353_553_39, -0.353_553_39, -0.353_553_39, 0.353_553_39], - [0.277_785_12, -0.490_392_64, 0.097_545_16, 0.415_734_8, -0.415_734_8, -0.097_545_16, 0.490_392_64, -0.277_785_12], + [0.277_785_12, -0.49039264, 0.097_545_16, 0.415_734_8, -0.415_734_8, -0.097_545_16, 0.49039264, -0.277_785_12], [0.191_341_72, -0.461_939_77, 0.461_939_77, -0.191_341_72, -0.191_341_72, 0.461_939_77, -0.461_939_77, 0.191_341_72], - [0.097_545_16, -0.277_785_12, 0.415_734_8, -0.490_392_64, 0.490_392_64, -0.415_734_8, 0.277_785_12, -0.097_545_16], + [0.097_545_16, -0.277_785_12, 0.415_734_8, -0.49039264, 0.49039264, -0.415_734_8, 0.277_785_12, -0.097_545_16], ]; #[cfg(test)] From f431791c26fcd5971c9b503f82ae299a7cccf56b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 11:17:44 -0400 Subject: [PATCH 21/33] style: use [[ ]], named locals and explicit returns in packaging scripts (SonarCloud S7688/S7679/S7682) (#950) Bash scripts under packaging/ now use [[ ]] for tests, copy positional parameters into named locals inside functions, and end env.sh helpers with an explicit return (bare, so each keeps its exit status). shellcheck clean. --- packaging/env.sh | 15 ++++++++---- packaging/freebsd/package.sh | 4 ++-- packaging/icons.sh | 5 +++- packaging/linux/flatpak-bundle.sh | 8 +++---- packaging/linux/package.sh | 21 +++++++++-------- packaging/macos/import-cert.sh | 6 ++--- packaging/macos/package.sh | 38 +++++++++++++++---------------- packaging/web/package.sh | 4 ++-- 8 files changed, 56 insertions(+), 45 deletions(-) diff --git a/packaging/env.sh b/packaging/env.sh index cb4bcdb6..abb99b52 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -19,10 +19,11 @@ workspace_version() { /^\[/ { in_pkg = ($0 == "[workspace.package]") ; next } in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit } ' "$ROOT/Cargo.toml" + return } VERSION="${SOUNDCRAFT_VERSION:-$(workspace_version)}" -if [ -z "$VERSION" ]; then +if [[ -z "$VERSION" ]]; then echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2 exit 1 fi @@ -32,7 +33,7 @@ DIST="${DIST:-$ROOT/dist/release}" mkdir -p "$DIST" export DIST -if [ -z "${SOUNDCRAFT_BUILD_SHA:-}" ]; then +if [[ -z "${SOUNDCRAFT_BUILD_SHA:-}" ]]; then SOUNDCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" fi export SOUNDCRAFT_BUILD_SHA @@ -41,19 +42,23 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" # Emit a GitHub Actions warning (plain stderr outside Actions). warn() { - if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + return } # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md COPYRIGHT; do - if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi + if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done + return } # Portable SHA-256 of a file (prints just the hash). sha256() { - if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi + local file="$1" + if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi + return } diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 71af80c3..61679c8e 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -20,7 +20,7 @@ LINUX="$ROOT/packaging/linux" APP_ID=ai.storyteller.soundcraft SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; -h | --help) sed -n '2,15p' "$0"; exit 0 ;; @@ -38,7 +38,7 @@ BASENAME="soundcraft-$VERSION-freebsd-$ARCH" echo "==> SoundCraft $VERSION for FreeBSD $ARCH" -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then (cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli) fi BIN="$CARGO_TARGET_DIR/release" diff --git a/packaging/icons.sh b/packaging/icons.sh index 7308e799..d32e610f 100755 --- a/packaging/icons.sh +++ b/packaging/icons.sh @@ -21,7 +21,10 @@ MAC="$TMP/mac.svg" sed 's/viewBox="0 0 512 512"/viewBox="-62.136 -62.136 636.272 636.272"/' "$SVG" >"$MAC" grep -q 'viewBox="-62.136' "$MAC" || { echo "error: expected viewBox=\"0 0 512 512\" in $SVG" >&2; exit 1; } -render() { resvg -w "$2" -h "$2" "$1" "$3" &2; exit 1; } +[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } for tool in flatpak flatpak-builder; do command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } done @@ -49,7 +49,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle" rm -rf "$WORK" mkdir -p "$WORK/stage" tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 -[ -x "$WORK/stage/bin/soundcraft" ] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; } +[[ -x "$WORK/stage/bin/soundcraft" ]] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; } cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" flatpak remote-add --user --if-not-exists flathub "$FLATHUB" @@ -62,7 +62,7 @@ OUT="$DIST/$BASENAME.flatpak" flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" -if [ "$TEST" = 1 ]; then +if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" flatpak run --command=soundcraft-cli "$APP_ID" --version diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 8f7c9160..fb677dd7 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -25,7 +25,7 @@ APP_ID=ai.storyteller.soundcraft SKIP_BUILD=0 FORMATS="appimage deb rpm tar" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; @@ -46,8 +46,8 @@ BASENAME="soundcraft-$VERSION-linux-$ARCH" echo "==> SoundCraft $VERSION for Linux $ARCH ($FORMATS)" -if [ "$SKIP_BUILD" = 0 ]; then - if [ -n "${CROSS_TARGET:-}" ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then + if [[ -n "${CROSS_TARGET:-}" ]]; then (cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli --target "$CROSS_TARGET") else (cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli) @@ -79,7 +79,10 @@ if command -v appstreamcli >/dev/null; then appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" fi -has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } +has() { + local format="$1" + case " $FORMATS " in *" $format "*) return 0 ;; *) return 1 ;; esac +} # ---- .tar.gz ------------------------------------------------------------------------------------ if has tar; then @@ -112,9 +115,9 @@ if has appimage; then ln -s "$APP_ID.png" "$APPDIR/.DirIcon" TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" - if [ -z "$TOOL" ]; then + if [[ -z "$TOOL" ]]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [ ! -x "$TOOL" ]; then + if [[ ! -x "$TOOL" ]]; then curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi @@ -137,16 +140,16 @@ if has appimage; then # package) appimagetool also writes the .zsync, into its working directory, hence the cd. (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" - if [ -s "$OUT.zsync" ]; then + if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" else warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" fi fi -if [ -z "${CROSS_TARGET:-}" ]; then +if [[ -z "${CROSS_TARGET:-}" ]]; then "$STAGE/usr/bin/soundcraft-cli" --version -elif [ -n "${EMULATOR:-}" ]; then +elif [[ -n "${EMULATOR:-}" ]]; then "$EMULATOR" "$STAGE/usr/bin/soundcraft-cli" --version fi echo "==> done" diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 17a1a7f8..91e422f5 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -12,7 +12,7 @@ umask 077 # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" -if [ -z "${APPLE_CERTIFICATE:-}" ]; then +if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized" exit 0 fi @@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')" -if [ -z "$IDENTITY" ]; then +if [[ -z "$IDENTITY" ]]; then security find-identity -v -p codesigning "$KEYCHAIN" >&2 echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2 exit 1 fi echo "Imported signing identity $IDENTITY" -if [ -n "${GITHUB_ENV:-}" ]; then +if [[ -n "${GITHUB_ENV:-}" ]]; then { echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV" else echo "MACOS_SIGN_IDENTITY=$IDENTITY" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index ff7aba25..dd3676d9 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos" ARCH=universal SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; --skip-build) SKIP_BUILD=1; shift ;; @@ -44,9 +44,9 @@ DMG="$DIST/soundcraft-$VERSION-macos-$ARCH.dmg" CLI_ZIP="$DIST/soundcraft-cli-$VERSION-macos-$ARCH.zip" NOTARIZE=0 -if [ "$IDENTITY" = "-" ]; then +if [[ "$IDENTITY" = "-" ]]; then warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized" -elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then +elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then NOTARIZE=1 else warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized" @@ -55,7 +55,7 @@ fi echo "==> SoundCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" # ---- build ------------------------------------------------------------------------------------- -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then args=() for t in "${TARGETS[@]}"; do args+=(--target "$t"); done (cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli "${args[@]}") @@ -74,22 +74,22 @@ done sign() { # Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped. local ts=(--timestamp) - [ "$IDENTITY" = "-" ] && ts=(--timestamp=none) + [[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none) local kc=() - [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN") + [[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN") codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } # stapler fetches the ticket from Apple's CloudKit, which sometimes drops the connection ("The # network connection was lost", Error 68). Stapling and validating are idempotent, so retry. stapler_retry() { - local attempt + local action="$1" attempt for attempt in 1 2 3 4 5; do if xcrun stapler "$@"; then return 0 fi - [ "$attempt" = 5 ] && { echo "stapler $1 failed 5 times" >&2; return 1; } - warn "stapler $1 failed (attempt $attempt of 5); retrying in $((attempt * 15))s" + [[ "$attempt" = 5 ]] && { echo "stapler $action failed 5 times" >&2; return 1; } + warn "stapler $action failed (attempt $attempt of 5); retrying in $((attempt * 15))s" sleep $((attempt * 15)) done } @@ -102,8 +102,8 @@ notarize() { echo "$out" id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)" status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)" - if [ "$status" != "Accepted" ]; then - if [ -n "$id" ]; then + if [[ "$status" != "Accepted" ]]; then + if [[ -n "$id" ]]; then xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true fi echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2 @@ -133,7 +133,7 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP" codesign --verify --strict --deep --verbose=2 "$APP" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/SoundCraft-notarize.zip" notarize "$WORK/SoundCraft-notarize.zip" stapler_retry staple "$APP" @@ -166,7 +166,7 @@ hdiutil_retry() { if hdiutil "$@"; then return 0 fi - [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; } + [[ "$attempt" = 5 ]] && { echo "hdiutil $what failed 5 times" >&2; return 1; } warn "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" sleep $((attempt * 10)) done @@ -183,21 +183,21 @@ mkdir -p "$MOUNT" "$COPY" hdiutil_retry attach attach -nobrowse -readonly -noautoopen -mountpoint "$MOUNT" "$DMG" >/dev/null dmg_ok=1 codesign --verify --strict --deep --verbose=2 "$MOUNT/SoundCraft.app" || dmg_ok=0 -if [ "$dmg_ok" = 1 ]; then +if [[ "$dmg_ok" = 1 ]]; then { ditto "$MOUNT/SoundCraft.app" "$COPY/SoundCraft.app" && codesign --verify --strict --deep --verbose=2 "$COPY/SoundCraft.app"; } || dmg_ok=0 fi -if [ "$dmg_ok" = 1 ] && [ "$NOTARIZE" = 1 ]; then +if [[ "$dmg_ok" = 1 ]] && [[ "$NOTARIZE" = 1 ]]; then spctl --assess --type execute -vvv "$COPY/SoundCraft.app" || dmg_ok=0 fi -[ "$dmg_ok" = 1 ] || xattr -lr "$MOUNT/SoundCraft.app" >&2 || true +[[ "$dmg_ok" = 1 ]] || xattr -lr "$MOUNT/SoundCraft.app" >&2 || true hdiutil_retry detach detach "$MOUNT" >/dev/null || dmg_ok=0 rm -rf "$COPY" -if [ "$dmg_ok" != 1 ]; then +if [[ "$dmg_ok" != 1 ]]; then echo "error: SoundCraft.app in $(basename "$DMG") fails strict verification" >&2 exit 1 fi -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" stapler_retry staple "$DMG" stapler_retry validate "$DMG" @@ -215,7 +215,7 @@ codesign --verify --strict --verbose=2 "$CLI_DIR/soundcraft-cli" rm -f "$CLI_ZIP" ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" # A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online. -if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi +if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi "$WORK/bin/soundcraft-cli" --version echo "==> done" diff --git a/packaging/web/package.sh b/packaging/web/package.sh index a35fa9ef..5038508a 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -11,13 +11,13 @@ set -euo pipefail . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" HERE="$ROOT/packaging/web" -if [ "${1:-}" != "--skip-build" ]; then +if [[ "${1:-}" != "--skip-build" ]]; then command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; } (cd "$ROOT/apps/soundcraft-web" && trunk build --release) fi SITE="$ROOT/dist/web" -[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } +[[ -f "$SITE/index.html" ]] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } # Paths must be relative so the site works under any prefix (public_url = "./" in Trunk.toml). if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2 From d47abe484d4f9ef0cc9f8e872e0b3880a61a4588 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 11:35:06 -0400 Subject: [PATCH 22/33] Use chrono instead of hand-rolled UTC conversion (#844) * Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the civil_from_days copy behind the desktop app's log timestamp with chrono as a workspace dependency. The output format is unchanged and pinned by the existing test. AGENTS.md now says time goes through chrono. Follow-up to storytold/cadcraft#1785 * Never panic logging on a clock outside chrono's range chrono's From unwraps internally, so a clock past year 262143 panicked inside the logger. timestamp now reads the duration since the epoch and goes through the fallible DateTime::from_timestamp, reading an out-of-range clock as the epoch, exactly as the pre-epoch clamp already did. The year is written with {:04} rather than %Y, which would prefix a '+' past year 9999. Tests cover the far-future and year-10000 cases. * Don't build an unrepresentable SystemTime in the far-future test UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows' SystemTime and panicked before timestamp() ran, failing the Windows test job. Build it with checked_add and only assert where it exists. * time: preserve old out-of-range log timestamp (no epoch fallback) `DateTime::from_timestamp(...).unwrap_or_default()` silently reset any clock past chrono's year range to 1970-01-01, changing output the old hand-rolled `civil_from_days` converted exactly. Restore the old conversion domain with a cycle-based helper that routes the calendar through chrono (the calendar repeats every 400 years = 146_097 days, so month/day/time come from chrono and only the year is carried in i128, keeping every i64 exact). Keep the pre-1970 epoch clamp; u64 seconds past i64::MAX saturate rather than falling back. Pinned old outputs in the test (replacing the u64::MAX/2 -> epoch assertion): u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z 10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z * Don't build a year-318857 SystemTime unchecked in the logging test Windows' SystemTime only reaches about year 30828, so UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before timestamp() ran and failed the Windows test job. Build it with checked_add and assert where the platform can hold it. The year-10000 case fits everywhere and stays unconditional. * Name the 400-year range extension as the one exception to the time rule The rule said never to hand-roll calendar math, but the range fix (shifting by whole 400-year Gregorian cycles so dates past chrono's year range keep their old output) is cycle arithmetic, so the rule and the code disagreed. The rule now names that exception: the library still computes month, day and time, and only whole cycles are carried in the year. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does. --- AGENTS.md | 1 + Cargo.lock | 10 ++++++ Cargo.toml | 2 ++ apps/soundcraft/Cargo.toml | 1 + apps/soundcraft/src/logging.rs | 58 +++++++++++++++++++++++----------- 5 files changed, 54 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4fdf3cb7..76338a2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra - The audio callback never blocks, allocates in steady state, or panics. - Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. - `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). ## Architecture (layers enforced by `cargo xtask layers`) diff --git a/Cargo.lock b/Cargo.lock index d006e25c..eb578a28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -688,6 +688,15 @@ dependencies = [ "rand_core", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "clang-sys" version = "1.9.1" @@ -3668,6 +3677,7 @@ dependencies = [ name = "soundcraft" version = "0.12.1" dependencies = [ + "chrono", "eframe", "egui 0.37.0", "log", diff --git a/Cargo.toml b/Cargo.toml index 146f58d8..b0fda5df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" proptest = "1" diff --git a/apps/soundcraft/Cargo.toml b/apps/soundcraft/Cargo.toml index 634bf218..5dd042b9 100644 --- a/apps/soundcraft/Cargo.toml +++ b/apps/soundcraft/Cargo.toml @@ -22,6 +22,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", " egui.workspace = true serde_json.workspace = true log.workspace = true +chrono.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd.workspace = true diff --git a/apps/soundcraft/src/logging.rs b/apps/soundcraft/src/logging.rs index 53be177b..8209f3c2 100644 --- a/apps/soundcraft/src/logging.rs +++ b/apps/soundcraft/src/logging.rs @@ -112,28 +112,36 @@ fn matches(name: &str, target: &str) -> bool { } /// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so +/// logging never panics and never silently resets to the epoch for a convertible instant. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value + // past `i64::MAX` seconds saturates rather than falling back to the epoch. + let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX); + let (y, t) = utc_parts(secs, since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd -/// clock can't overflow). -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days.saturating_add(719_468); - let era = z / 146_097; - let doe = z % 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 = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days), +/// so month, day and time of day come from chrono and only the year is carried, which keeps every +/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic. +fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -533,6 +541,20 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's year range still converts via the 400-year cycle (the old code did too, + // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' + // SystemTime can't hold this instant, so it is only checked where it exists. + // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); + // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } } #[test] From f0d5acb800c65bcd2a9d637cd1726cf6597cb629 Mon Sep 17 00:00:00 2001 From: Superior <57851423+wayward-L@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:15:56 +0800 Subject: [PATCH 23/33] Deps: fix the workspace build after the dependency bump (#954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Audio: migrate file decoding to symphonia 0.6 Upstream bumped symphonia to 0.6 without adapting crates/audio-io, so `soundcraft-audio-io` no longer built: the probe module, codec parameters and decoded-buffer APIs all moved. - Probe with `default::get_probe().probe(&Hint, …)`. The 0.5 prober's detection-only path is gone, so scan for the container marker ourselves before instantiating a reader, and keep running the existing size checks (CAF/MP4/FLAC/WAV) on the first marker — the damaged-file regression tests depend on those checks firing before any allocation. - Skip leading ID3v2 tags with `ReadBytes::ignore_bytes` as before. - Read `Track::codec_params` (now an `Option`), decode through `make_audio_decoder` and `GenericAudioBufferRef::copy_to_vecs_planar`, and read the new public `Packet` fields. - Keep the per-packet frame limit and the sample sanitisation. * Playback: migrate the audio device layer to cpal 0.18 - Sample rates are plain `u32` now: drop `.0` and the `cpal::SampleRate` constructor. - One `cpal::Error` replaces the build/stream/play/pause error types; detect `DeviceNotAvailable` through `error.kind()`. - Stream builders take `StreamConfig` by value, and device names come from `Device::description()` (the deprecated `name()` is gone). - The test `Fake` implements the new `StreamTrait::{buffer_size, now}`. * Deps: pin egui back to 0.36 until craft-ui supports 0.37 The dependabot group bump (#767) moved the workspace to egui, eframe and egui_kittest 0.37, but the pinned `craft-ui` revision still builds against egui 0.36, so the graph carries two incompatible egui versions and `soundcraft-ui-egui` does not compile. craft-libs#199 updates craft-ui to 0.37 and is still open; pin 0.36 and refresh the lockfile meanwhile. The other version bumps from #767 stay. Re-land egui 0.37 together with the craft-ui update. * Audio: refuse a size-checked container the probe did not validate The dependency-migration review showed the preflight size checks can validate one container marker while symphonia's probe selects a different container, so a misleading marker could skip the FLAC/MP4/CAF/WAVE checks. Probe 0.6 reports the selected format only through the reader it returns, so `open()` now records which container's checks ran (including the RF64/BW64 RIFF forms) and refuses the stream when a size-checked reader was chosen without its checks, before any packet is decoded. Markers found in audio data of non-container streams (MP3, Ogg, AIFF) do not reject valid files. Adds a regression test with a fake RIFF prefix followed by a valid FLAC stream, and doc comments for the functions touched by the dependency migration (the review's docstring-coverage warning). --- Cargo.lock | 477 +++++++++++------------------ Cargo.toml | 7 +- crates/audio-io/src/symph.rs | 253 +++++++++------ crates/audio-io/tests/malformed.rs | 16 + crates/playback/src/devices.rs | 8 +- crates/playback/src/lib.rs | 41 ++- crates/playback/src/record.rs | 21 +- 7 files changed, 408 insertions(+), 415 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb578a28..f36f359d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,23 +11,14 @@ dependencies = [ "uuid", ] -[[package]] -name = "accesskit" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" -dependencies = [ - "uuid", -] - [[package]] name = "accesskit_atspi_common" -version = "0.21.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" dependencies = [ - "accesskit 0.25.1", - "accesskit_consumer", + "accesskit", + "accesskit_consumer 0.36.0", "atspi-common", "phf", "serde", @@ -36,37 +27,43 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.39.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" dependencies = [ - "accesskit 0.25.1", - "hashbrown 0.17.1", + "accesskit", + "hashbrown 0.16.1", ] [[package]] -name = "accesskit_ios" -version = "0.2.1" +name = "accesskit_consumer" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" dependencies = [ - "accesskit 0.25.1", - "accesskit_consumer", - "hashbrown 0.17.1", - "objc2 0.5.3", - "objc2-foundation 0.2.2", - "objc2-ui-kit 0.2.2", + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", ] [[package]] name = "accesskit_macos" -version = "0.27.1" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" dependencies = [ - "accesskit 0.25.1", - "accesskit_consumer", - "hashbrown 0.17.1", + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -74,11 +71,11 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.24.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" dependencies = [ - "accesskit 0.25.1", + "accesskit", "accesskit_atspi_common", "async-channel", "async-executor", @@ -92,13 +89,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.35.1" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" dependencies = [ - "accesskit 0.25.1", - "accesskit_consumer", - "hashbrown 0.17.1", + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", "static_assertions", "windows", "windows-core", @@ -106,12 +103,11 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.34.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" dependencies = [ - "accesskit 0.25.1", - "accesskit_ios", + "accesskit", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -232,7 +228,6 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", - "wl-clipboard-rs", "x11rb", ] @@ -623,18 +618,42 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop", + "calloop 0.13.0", "rustix 0.38.44", "wayland-backend", "wayland-client", ] +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cc" version = "1.6.0" @@ -653,7 +672,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom 7.1.3", + "nom", ] [[package]] @@ -892,7 +911,7 @@ name = "craft-ui" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" dependencies = [ - "egui 0.36.2", + "egui", "serde", ] @@ -1034,30 +1053,21 @@ name = "ecolor" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" -dependencies = [ - "emath 0.36.2", -] - -[[package]] -name = "ecolor" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31bcbef0d4263a63eb32c4834e5d711613035ce1333468e107d928923e21e6e2" dependencies = [ "bytemuck", - "emath 0.37.0", + "emath", ] [[package]] name = "eframe" -version = "0.37.0" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c5642501a65ca657d05f00c643a5c14ff2346cb3818032bfe23d58e1f8e5f84" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" dependencies = [ "ahash", "bytemuck", "document-features", - "egui 0.37.0", + "egui", "egui-wgpu", "egui-winit", "egui_glow", @@ -1074,7 +1084,6 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", - "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1090,31 +1099,11 @@ version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" dependencies = [ - "accesskit 0.24.1", + "accesskit", "ahash", "bitflags 2.13.2", - "emath 0.36.2", - "epaint 0.36.2", - "itertools 0.15.0", - "log", - "nohash-hasher", - "profiling", - "smallvec", - "unicode-segmentation", - "web-sys", -] - -[[package]] -name = "egui" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9144f49b7a28b5f395d3147e325b4fe3e77ba686eac11d1bdf0a7da3f375f" -dependencies = [ - "accesskit 0.25.1", - "ahash", - "bitflags 2.13.2", - "emath 0.37.0", - "epaint 0.37.0", + "emath", + "epaint", "itertools 0.15.0", "log", "nohash-hasher", @@ -1126,15 +1115,15 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.37.0" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0309cbe1febed48af5b974e4bcc424b35e9664863a4d402697147b57a488c864" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" dependencies = [ "ahash", "bytemuck", "document-features", - "egui 0.37.0", - "epaint 0.37.0", + "egui", + "epaint", "log", "profiling", "thiserror 2.0.21", @@ -1146,36 +1135,34 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.37.0" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e449f4c4a2a60c7ccb4ba7fa4078417bfe1f9ddc03b5e05ce75748e33a70de" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" dependencies = [ "accesskit_winit", "arboard", - "block2 0.6.2", "bytemuck", - "egui 0.37.0", + "egui", "log", "objc2 0.6.5", - "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", + "smithay-clipboard", "web-time", "webbrowser", - "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.37.0" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9275ca5cc35a6b7c94e0e8cedf22542f66cb0cf45ed8ab2657c78ff49fec7268" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" dependencies = [ "bytemuck", - "egui 0.37.0", + "egui", "glow", "log", "profiling", @@ -1184,13 +1171,13 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.37.0" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0979c2e37860fcf13a2bea77508607f067a9a6a84ba9bd47b5e298582fb59b86" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" dependencies = [ "dify", "eframe", - "egui 0.37.0", + "egui", "egui-wgpu", "image", "kittest", @@ -1214,12 +1201,6 @@ name = "emath" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" - -[[package]] -name = "emath" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e73ce90f54750be8672c02be0743fe47530e6f251e066f0ee2869b59eb297af5" dependencies = [ "bytemuck", ] @@ -1256,34 +1237,11 @@ name = "epaint" version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" -dependencies = [ - "ahash", - "ecolor 0.36.2", - "emath 0.36.2", - "font-types", - "harfrust", - "log", - "nohash-hasher", - "parking_lot", - "profiling", - "self_cell", - "skrifa", - "smallvec", - "unicode-general-category", - "unicode-segmentation", - "vello_cpu 0.1.0", -] - -[[package]] -name = "epaint" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97bac86ae3dde93fc3951e790951caea739ff39a6c89d909cdaf1d9278dbb1bd" dependencies = [ "ahash", "bytemuck", - "ecolor 0.37.0", - "emath 0.37.0", + "ecolor", + "emath", "epaint_default_fonts", "font-types", "harfrust", @@ -1295,16 +1253,15 @@ dependencies = [ "skrifa", "smallvec", "unicode-general-category", - "unicode-properties", "unicode-segmentation", - "vello_cpu 0.2.0", + "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.37.0" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc1cc505827f4c23eedc297975a0fd038268d41293f389dd8ff3b574aefab19" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" [[package]] name = "equivalent" @@ -1396,12 +1353,6 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - [[package]] name = "flate2" version = "1.1.10" @@ -1419,12 +1370,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1601,29 +1546,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" dependencies = [ "bytemuck", - "foldhash 0.2.0", + "foldhash", "hashbrown 0.17.1", "log", "peniko", "skrifa", "smallvec", - "vello_common 0.1.0", -] - -[[package]] -name = "glifo" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" -dependencies = [ - "bytemuck", - "foldhash 0.2.0", - "hashbrown 0.17.1", - "log", - "peniko", - "skrifa", - "smallvec", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -1757,15 +1686,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.16.1" @@ -1774,7 +1694,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1783,7 +1703,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -2079,12 +1999,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.5.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" dependencies = [ - "accesskit 0.25.1", - "accesskit_consumer", + "accesskit", + "accesskit_consumer 0.35.0", ] [[package]] @@ -2374,15 +2294,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -2852,16 +2763,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "os_pipe" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "parking" version = "2.2.1" @@ -2910,22 +2811,11 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap", -] - [[package]] name = "phf" -version = "0.14.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", "phf_shared", @@ -2934,9 +2824,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.14.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", "phf_shared", @@ -2944,9 +2834,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.14.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator", "phf_shared", @@ -2957,9 +2847,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.14.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ "siphasher", ] @@ -3489,12 +3379,6 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" - [[package]] name = "serde" version = "1.0.229" @@ -3646,8 +3530,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.2", - "calloop", - "calloop-wayland-source", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", "cursor-icon", "libc", "log", @@ -3664,6 +3548,44 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + [[package]] name = "smol_str" version = "0.2.2" @@ -3679,7 +3601,7 @@ version = "0.12.1" dependencies = [ "chrono", "eframe", - "egui 0.37.0", + "egui", "log", "muda", "objc2 0.6.5", @@ -3851,7 +3773,7 @@ name = "soundcraft-ui-egui" version = "0.12.1" dependencies = [ "craft-ui", - "egui 0.37.0", + "egui", "egui_kittest", "image", "log", @@ -3904,7 +3826,7 @@ name = "soundcraft-web" version = "0.12.1" dependencies = [ "eframe", - "egui 0.37.0", + "egui", "js-sys", "log", "rfd", @@ -4327,6 +4249,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4362,17 +4285,6 @@ dependencies = [ "strength_reduce", ] -[[package]] -name = "tree_magic_mini" -version = "3.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" -dependencies = [ - "memchr", - "nom 8.0.0", - "petgraph", -] - [[package]] name = "type-map" version = "0.5.1" @@ -4417,12 +4329,6 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4479,21 +4385,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "vello_common" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" -dependencies = [ - "bytemuck", - "fearless_simd", - "guillotiere", - "log", - "peniko", - "smallvec", - "thiserror 2.0.21", -] - [[package]] name = "vello_cpu" version = "0.1.0" @@ -4501,21 +4392,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" dependencies = [ "bytemuck", - "glifo 0.2.0", + "glifo", "hashbrown 0.17.1", - "vello_common 0.1.0", -] - -[[package]] -name = "vello_cpu" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" -dependencies = [ - "bytemuck", - "glifo 0.3.0", - "hashbrown 0.17.1", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -4677,6 +4556,32 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-plasma" version = "0.3.12" @@ -5247,7 +5152,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "bytemuck", - "calloop", + "calloop 0.13.0", "cfg_aliases", "concurrent-queue", "core-foundation", @@ -5268,7 +5173,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", - "smithay-client-toolkit", + "smithay-client-toolkit 0.19.2", "smol_str", "tracing", "unicode-segmentation", @@ -5311,24 +5216,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wl-clipboard-rs" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" -dependencies = [ - "libc", - "log", - "os_pipe", - "rustix 1.1.5", - "thiserror 2.0.21", - "tree_magic_mini", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-wlr", -] - [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index b0fda5df..b5ea0ddc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,9 +49,10 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # ui craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -egui = "0.37" -eframe = { version = "0.37", default-features = false } -egui_kittest = "0.37" +# egui 0.37 needs craft-libs#199 (craft-ui) first; pin 0.36 until it lands. +egui = "0.36" +eframe = { version = "0.36", default-features = false } +egui_kittest = "0.36" rfd = "0.17" image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index 5bfe91da..db6efbbb 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -3,18 +3,18 @@ use std::io::Cursor; use std::panic::{AssertUnwindSafe, catch_unwind}; -use symphonia::core::audio::SampleBuffer; -use symphonia::core::codecs::{ - CODEC_TYPE_AAC, CODEC_TYPE_ADPCM_G722, CODEC_TYPE_ADPCM_G726, CODEC_TYPE_ADPCM_G726LE, CODEC_TYPE_ADPCM_IMA_QT, CODEC_TYPE_ADPCM_IMA_WAV, - CODEC_TYPE_ADPCM_MS, CODEC_TYPE_ALAC, CODEC_TYPE_FLAC, CODEC_TYPE_MP1, CODEC_TYPE_MP2, CODEC_TYPE_MP3, CODEC_TYPE_NULL, CODEC_TYPE_OPUS, - CODEC_TYPE_PCM_ALAW, CODEC_TYPE_PCM_F32BE, CODEC_TYPE_PCM_F32LE, CODEC_TYPE_PCM_F64BE, CODEC_TYPE_PCM_F64LE, CODEC_TYPE_PCM_MULAW, - CODEC_TYPE_VORBIS, CodecParameters, CodecType, DecoderOptions, +use symphonia::core::codecs::CodecParameters; +use symphonia::core::codecs::audio::well_known::{ + CODEC_ID_AAC, CODEC_ID_ADPCM_G722, CODEC_ID_ADPCM_G726, CODEC_ID_ADPCM_G726LE, CODEC_ID_ADPCM_IMA_QT, CODEC_ID_ADPCM_IMA_WAV, CODEC_ID_ADPCM_MS, + CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP1, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_OPUS, CODEC_ID_PCM_ALAW, CODEC_ID_PCM_F32BE, CODEC_ID_PCM_F32LE, + CODEC_ID_PCM_F64BE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_MULAW, CODEC_ID_VORBIS, }; +use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, AudioDecoderOptions, CODEC_ID_NULL_AUDIO}; use symphonia::core::errors::Error as SymError; +use symphonia::core::formats::probe::Hint; use symphonia::core::formats::{FormatOptions, FormatReader}; use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes}; use symphonia::core::meta::MetadataOptions; -use symphonia::core::probe::Instantiate; use crate::pcm::{MAX_DECODE_SAMPLES, be_u32}; use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat}; @@ -23,6 +23,8 @@ use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat /// takes its frame length from the file (encoders use 4096). const MAX_PACKET_FRAMES: u64 = 1 << 20; +/// Turn a symphonia error into the audio layer's error, keeping "unsupported" and "too large" +/// distinct from a malformed file. fn map_err(e: SymError) -> AudioError { match e { SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()), @@ -42,80 +44,160 @@ enum Opened { Empty(AudioInfo), } -/// Symphonia's probe loop (`Probe::format`, whose hint 0.5 ignores), checking the container it -/// finds before its reader allocates whatever the file claims. -fn open(bytes: &[u8]) -> Result { - let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default()); - let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() }; +/// Skip a leading ID3v2 tag before probing. +/// +/// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims, so the tag is skipped +/// with [`ReadBytes::ignore_bytes`] (which allocates nothing) instead of being handed to the +/// metadata reader. Returns the stream position the container starts at. +fn skip_leading_id3(mss: &mut MediaSourceStream<'_>, bytes: &[u8]) -> Result { loop { - let found = symphonia::default::get_probe().next(&mut mss).map_err(map_err)?; let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX); let head = bytes.get(at..).unwrap_or_default(); - match (found, head.get(..10)) { - (Instantiate::Format(reader), _) => { - match head.get(..4) { - Some(b"caff") => crate::caf::check(bytes, at)?, - Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?, - Some(b"fLaC") => { - crate::flac::check_metadata(bytes, at)?; - if let Some(info) = crate::flac::empty_stream_info(bytes, at) { - return Ok(Opened::Empty(info)); - } - } - Some(b"RIFF") => crate::wav::check_info(bytes, at)?, - _ => {} - } - return reader(mss, &fmt_opts).map(Opened::Reader).map_err(map_err); - } - // Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims: skip it. - (Instantiate::Metadata(_), Some([b'I', b'D', b'3', _, _, _, size @ ..])) => { - let size = size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f)); - mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?; - } - (Instantiate::Metadata(tag), _) => { - tag(&MetadataOptions::default()).read_all(&mut mss).map_err(map_err)?; - } + if head.len() < 10 || head.get(..3) != Some(b"ID3") { + return Ok(at); } + let size = head.get(6..10).map(|size| size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f))).unwrap_or(0); + mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?; } } -fn format_from_codec(codec: CodecType, detected: FileFormat) -> FileFormat { +/// How far symphonia's probe scans for a format marker before giving up. +const MAX_PROBE_DEPTH: usize = 1024 * 1024; + +/// The container marker each detected format starts with, if its reader needs size checks first. +fn container_markers(detected: FileFormat) -> &'static [&'static [u8; 4]] { + match detected { + // RF64 and BW64 are the >4 GiB RIFF forms. + FileFormat::Wav => &[b"RIFF", b"RF64", b"BW64"], + FileFormat::Flac => &[b"fLaC"], + FileFormat::Caf => &[b"caff"], + // MP4/M4A put `ftyp` after the first atom's size field. + FileFormat::Aac | FileFormat::Alac => &[b"ftyp"], + // Unknown files may carry junk before the container; symphonia's probe scans for markers. + FileFormat::Other => &[b"caff", b"ftyp", b"fLaC", b"RIFF"], + FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => &[], + } +} + +/// The probe short names a container marker stands for. +fn expected_probe_format(marker: &[u8; 4]) -> &'static [&'static str] { + match marker { + b"caff" => &["caf"], + // The isomp4 reader reads the `ftyp` atom. + b"ftyp" => &["isomp4"], + b"fLaC" => &["flac"], + b"RIFF" | b"RF64" | b"BW64" => &["wave"], + _ => &[], + } +} + +/// Probe short names whose demuxer allocates from sizes in the file, so the matching container +/// check has to run before the stream is decoded. +const SIZE_CHECKED_PROBE_FORMATS: &[&str] = &["caf", "flac", "isomp4", "wave"]; + +/// Find the first container marker at or after `at`, within symphonia's probe depth. +fn find_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> { + let end = bytes.len().min(at.saturating_add(MAX_PROBE_DEPTH)); + for pos in at..end.saturating_sub(3) { + let window = bytes.get(pos..pos + 4)?; + if let Some(marker) = markers.iter().find(|marker| window == marker.as_slice()) { + return Some((pos, *marker)); + } + } + None +} + +/// Probe for a container, checking the container it finds before its reader allocates whatever the +/// file claims. +/// +/// The check has to run on the container the probe actually selects, but the probe reports that +/// container only through the reader it returns. The check therefore runs on the first marker the +/// detected format allows, and the reader is refused if its format is not the checked one: a +/// misleading marker must not let a later container slip through unchecked. +fn open(bytes: &[u8], detected: FileFormat) -> Result { + let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default()); + let at = skip_leading_id3(&mut mss, bytes)?; + let mut checked: Option<&'static [&'static str]> = None; + if let Some((found, marker)) = find_marker(bytes, at, container_markers(detected)) { + match marker { + b"caff" => { + crate::caf::check(bytes, found)?; + checked = Some(expected_probe_format(marker)); + } + b"ftyp" => { + crate::mp4::check(bytes, found.saturating_sub(4))?; + checked = Some(expected_probe_format(marker)); + } + b"fLaC" => { + crate::flac::check_metadata(bytes, found)?; + if let Some(info) = crate::flac::empty_stream_info(bytes, found) { + return Ok(Opened::Empty(info)); + } + checked = Some(expected_probe_format(marker)); + } + b"RIFF" | b"RF64" | b"BW64" => { + crate::wav::check_info(bytes, found)?; + checked = Some(expected_probe_format(marker)); + } + _ => {} + } + } + let reader = symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?; + let probed = reader.format_info().short_name; + // Only containers whose readers allocate from sizes in the file need the check. A stream the + // probe reads with another reader (MP3, Ogg, AIFF, ...) has no such size fields, so a marker + // found in its audio data must not reject it. + if let Some(names) = checked.filter(|_| SIZE_CHECKED_PROBE_FORMATS.contains(&probed)) { + if !names.contains(&probed) { + return Err(AudioError::Malformed(format!("the probed container ({probed}) is not the one whose size checks ran"))); + } + } else if checked.is_none() && SIZE_CHECKED_PROBE_FORMATS.contains(&probed) { + return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked"))); + } + Ok(Opened::Reader(reader)) +} + +/// The container `detected` stands for: the detection wins when it knows the file, otherwise the +/// decoded codec names the family. +fn format_from_codec(codec: AudioCodecId, detected: FileFormat) -> FileFormat { if detected != FileFormat::Other { return detected; } match codec { - c if c == CODEC_TYPE_FLAC => FileFormat::Flac, - c if c == CODEC_TYPE_MP3 || c == CODEC_TYPE_MP2 || c == CODEC_TYPE_MP1 => FileFormat::Mp3, - c if c == CODEC_TYPE_AAC => FileFormat::Aac, - c if c == CODEC_TYPE_ALAC => FileFormat::Alac, - c if c == CODEC_TYPE_VORBIS => FileFormat::Ogg, + c if c == CODEC_ID_FLAC => FileFormat::Flac, + c if c == CODEC_ID_MP3 || c == CODEC_ID_MP2 || c == CODEC_ID_MP1 => FileFormat::Mp3, + c if c == CODEC_ID_AAC => FileFormat::Aac, + c if c == CODEC_ID_ALAC => FileFormat::Alac, + c if c == CODEC_ID_VORBIS => FileFormat::Ogg, _ => FileFormat::Other, } } -fn sample_format_of(p: &CodecParameters) -> SampleFormat { +/// The stored sample format, using the codec and its declared bit depth. Lossy and unusual +/// codecs decode to `Compressed`. +fn sample_format_of(p: &AudioCodecParameters) -> SampleFormat { let c = p.codec; - if c == CODEC_TYPE_PCM_F32LE || c == CODEC_TYPE_PCM_F32BE { + if c == CODEC_ID_PCM_F32LE || c == CODEC_ID_PCM_F32BE { return SampleFormat::Float32; } - if c == CODEC_TYPE_PCM_F64LE || c == CODEC_TYPE_PCM_F64BE { + if c == CODEC_ID_PCM_F64LE || c == CODEC_ID_PCM_F64BE { return SampleFormat::Float64; } let lossy = [ - CODEC_TYPE_MP1, - CODEC_TYPE_MP2, - CODEC_TYPE_MP3, - CODEC_TYPE_AAC, - CODEC_TYPE_VORBIS, - CODEC_TYPE_OPUS, - CODEC_TYPE_ADPCM_MS, - CODEC_TYPE_ADPCM_IMA_WAV, - CODEC_TYPE_ADPCM_IMA_QT, - CODEC_TYPE_ADPCM_G722, - CODEC_TYPE_ADPCM_G726, - CODEC_TYPE_ADPCM_G726LE, - CODEC_TYPE_PCM_ALAW, - CODEC_TYPE_PCM_MULAW, + CODEC_ID_MP1, + CODEC_ID_MP2, + CODEC_ID_MP3, + CODEC_ID_AAC, + CODEC_ID_VORBIS, + CODEC_ID_OPUS, + CODEC_ID_ADPCM_MS, + CODEC_ID_ADPCM_IMA_WAV, + CODEC_ID_ADPCM_IMA_QT, + CODEC_ID_ADPCM_G722, + CODEC_ID_ADPCM_G726, + CODEC_ID_ADPCM_G726LE, + CODEC_ID_PCM_ALAW, + CODEC_ID_PCM_MULAW, ]; let lossless = !lossy.contains(&c); match (lossless, p.bits_per_sample) { @@ -127,15 +209,18 @@ fn sample_format_of(p: &CodecParameters) -> SampleFormat { } } +/// The first track with a decodable audio codec, with the stream information the UI shows. fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, AudioInfo)> { - let track = reader + let (track, p) = reader .tracks() .iter() - .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .find_map(|t| match t.codec_params.as_ref().and_then(CodecParameters::audio) { + Some(p) if p.codec != CODEC_ID_NULL_AUDIO => Some((t, p)), + _ => None, + }) .ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?; - let p = &track.codec_params; let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?; - let channels = p.channels.map(|c| c.count()).unwrap_or(0); + let channels = p.channels.as_ref().map(|c| c.count()).unwrap_or(0); let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?; Ok(( track.id, @@ -144,7 +229,7 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi sample_format: sample_format_of(p), sample_rate, channels, - frames: p.n_frames.unwrap_or(0), + frames: track.num_frames.unwrap_or(0), bwf: None, }, )) @@ -152,7 +237,7 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi /// Decode the whole first audio track. When `count_only`, samples are counted but not stored. fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> { - let mut reader = match open(bytes)? { + let mut reader = match open(bytes, detected)? { Opened::Reader(reader) => reader, Opened::Empty(info) => { let buf = AudioBuffer::new(info.sample_rate, usize::from(info.channels), 0); @@ -160,7 +245,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf } }; let (track_id, mut info) = info_of(reader.as_ref(), detected)?; - if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) { + if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.num_frames) { let total = n.saturating_mul(u64::from(info.channels.max(1))); if !count_only && total > MAX_DECODE_SAMPLES { return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit"))); @@ -170,21 +255,24 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf .tracks() .iter() .find(|t| t.id == track_id) - .map(|t| t.codec_params.clone()) + .and_then(|t| t.codec_params.as_ref()) + .and_then(CodecParameters::audio) + .cloned() .ok_or_else(|| AudioError::Malformed("track vanished".into()))?; - let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_TYPE_ALAC).and_then(|d| be_u32(d, 0)); + let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0)); if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) { return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into())); } - let mut decoder = symphonia::default::get_codecs().make(¶ms, &DecoderOptions::default()).map_err(map_err)?; + let mut decoder = symphonia::default::get_codecs().make_audio_decoder(¶ms, &AudioDecoderOptions::default()).map_err(map_err)?; let mut planar: Vec> = Vec::new(); + let mut scratch: Vec> = Vec::new(); let mut frames: u64 = 0; - let mut sbuf: Option> = None; let mut errors = 0usize; loop { let packet = match reader.next_packet() { - Ok(p) => p, + Ok(Some(packet)) => packet, + Ok(None) => break, Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, Err(SymError::ResetRequired) => break, Err(e) => { @@ -195,7 +283,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf break; } }; - if packet.track_id() != track_id { + if packet.track_id != track_id { continue; } let decoded = match decoder.decode(&packet) { @@ -211,8 +299,8 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf Err(SymError::IoError(_)) => break, Err(e) => return Err(map_err(e)), }; - let spec = *decoded.spec(); - let ch = spec.channels.count(); + let spec = decoded.spec(); + let ch = spec.channels().count(); if ch == 0 { continue; } @@ -229,16 +317,9 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf if count_only { continue; } - let needed = decoded.capacity() as u64; - let sb = match &mut sbuf { - Some(sb) if sb.capacity() >= decoded.capacity() * ch => sb, - _ => sbuf.insert(SampleBuffer::::new(needed, spec)), - }; - sb.copy_interleaved_ref(decoded); - for frame in sb.samples().chunks_exact(ch) { - for (dst, &s) in planar.iter_mut().zip(frame) { - dst.push(if s.is_finite() { s } else { 0.0 }); - } + decoded.copy_to_vecs_planar::(&mut scratch); + for (dst, src) in planar.iter_mut().zip(&scratch) { + dst.extend(src.iter().map(|&s| if s.is_finite() { s } else { 0.0 })); } } if info.channels == 0 { @@ -254,7 +335,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result { guarded(|| { - let reader = match open(bytes)? { + let reader = match open(bytes, detected)? { Opened::Reader(reader) => reader, Opened::Empty(info) => return Ok(info), }; diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/malformed.rs index ed0d6e31..f7da8746 100644 --- a/crates/audio-io/tests/malformed.rs +++ b/crates/audio-io/tests/malformed.rs @@ -451,6 +451,22 @@ fn garbage_in_compressed_containers_errors() { assert!(decode(&[], None).is_err()); } +/// A marker that passes its container check must not let the probe select a different container +/// without its own checks: the reader is refused when the probed format disagrees. +#[test] +fn a_probed_container_that_disagrees_with_the_checked_marker_is_refused() { + let flac = small(FileFormat::Flac, BitDepth::Int24, 2); + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&3u32.to_le_bytes()); + bytes.extend_from_slice(b"FAKE"); + bytes.extend_from_slice(&flac); + match decode(&bytes, None) { + Err(AudioError::Malformed(message)) => assert!(message.contains("probed container"), "unexpected message: {message}"), + Err(other) => panic!("unexpected error kind: {other:?}"), + Ok(_) => panic!("the mismatched container must be refused"), + } +} + proptest! { #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })] diff --git a/crates/playback/src/devices.rs b/crates/playback/src/devices.rs index 19df07b6..a2847097 100644 --- a/crates/playback/src/devices.rs +++ b/crates/playback/src/devices.rs @@ -64,9 +64,9 @@ pub struct AudioSystem { pub inputs: Vec, } -#[allow(deprecated)] -fn device_name(d: &cpal::Device) -> Option { - d.name().ok() +/// The name the host gives `d`, if it can produce a description for it. +pub(crate) fn device_name(d: &cpal::Device) -> Option { + d.description().ok().map(|description| description.name().to_owned()) } fn describe(d: &cpal::Device, default_name: Option<&str>, output: bool) -> Option { @@ -78,7 +78,7 @@ fn describe(d: &cpal::Device, default_name: Option<&str>, output: bool) -> Optio }; let max_channels = ranges.iter().map(cpal::SupportedStreamConfigRange::channels).max().unwrap_or(0); let sample_rates = - STANDARD_RATES.iter().copied().filter(|r| ranges.iter().any(|c| c.min_sample_rate().0 <= *r && c.max_sample_rate().0 >= *r)).collect(); + STANDARD_RATES.iter().copied().filter(|r| ranges.iter().any(|c| c.min_sample_rate() <= *r && c.max_sample_rate() >= *r)).collect(); let (mut buffer_min, mut buffer_max) = (None::, None::); for c in &ranges { if let cpal::SupportedBufferSize::Range { min, max } = c.buffer_size() { diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 3821e266..41bb89ed 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -522,14 +522,14 @@ impl Player { match open_device(want, want_ch, cfg) { Ok((device, config, format, name)) => { // The chosen buffer size first; if the device refuses it, its default. - let mut attempts = vec![config.clone()]; + let mut attempts = vec![config]; if config.buffer_size != cpal::BufferSize::Default { attempts.push(cpal::StreamConfig { buffer_size: cpal::BufferSize::Default, ..config }); } let opened = first_started( attempts, - |config: &cpal::StreamConfig| -> Result { - let rate = config.sample_rate.0; + |config: &cpal::StreamConfig| -> Result { + let rate = config.sample_rate; let channels = usize::from(config.channels); let (tx, rx) = channel(); let (gtx, grx) = channel(); @@ -546,7 +546,7 @@ impl Player { ); match opened { Some((config, Opened { stream, tx, grx, fault })) => { - let rate = config.sample_rate.0; + let rate = config.sample_rate; let channels = usize::from(config.channels); let buffer = match config.buffer_size { cpal::BufferSize::Fixed(n) => Some(n), @@ -926,7 +926,7 @@ fn build_output( channels: usize, fault: &Arc, shared: &Arc, -) -> Result +) -> Result where T: cpal::SizedSample + cpal::FromSample, { @@ -936,7 +936,7 @@ where let chunk = scratch.len(); let (failed, guard, gone) = (Arc::clone(fault), Arc::clone(shared), Arc::clone(fault)); device.build_output_stream( - config, + *config, move |data: &mut [T], _: &cpal::OutputCallbackInfo| { mark_audio_thread(); for out in data.chunks_mut(chunk) { @@ -949,9 +949,9 @@ where }, // cpal may call this on the audio thread (on ALSA in a loop while the device stays // broken): marked, so the logger hands the record off instead of writing it. - move |e: cpal::StreamError| { + move |e: cpal::Error| { mark_audio_thread(); - if matches!(e, cpal::StreamError::DeviceNotAvailable) { + if e.kind() == cpal::ErrorKind::DeviceNotAvailable { gone.gone.store(true, Ordering::Relaxed); } log::warn!("audio stream error: {e}"); @@ -968,8 +968,7 @@ fn open_device(want_rate: u32, want_ch: u16, cfg: &AudioConfig) -> Result<(cpal: use cpal::traits::DeviceTrait; let host = devices::host(cfg); let device = devices::device(&host, cfg.output.as_deref(), true).ok_or_else(|| "no output device".to_string())?; - #[allow(deprecated)] - let name = device.name().unwrap_or_else(|_| "Audio device".into()); + let name = devices::device_name(&device).unwrap_or_else(|| "Audio device".into()); // Prefer a config at the chosen (else session) rate; else the device default (we resample). let want_ch = want_ch.max(2); let rates: Vec = cfg.sample_rate.into_iter().chain(Some(want_rate)).collect(); @@ -984,13 +983,13 @@ fn open_device(want_rate: u32, want_ch: u16, cfg: &AudioConfig) -> Result<(cpal: for rate in rates { for c in &configs { if devices::format_rank(c.sample_format()).is_some() - && c.min_sample_rate().0 <= rate - && c.max_sample_rate().0 >= rate + && c.min_sample_rate() <= rate + && c.max_sample_rate() >= rate && c.channels() >= 2 && c.channels() <= want_ch && chosen.as_ref().is_none_or(|x| better(c, x)) { - chosen = Some((c.with_sample_rate(cpal::SampleRate(rate)).config(), *c.buffer_size(), c.sample_format())); + chosen = Some((c.with_sample_rate(rate).config(), *c.buffer_size(), c.sample_format())); } } if chosen.is_some() { @@ -1029,12 +1028,22 @@ mod tests { } impl cpal::traits::StreamTrait for Fake { - fn play(&self) -> Result<(), cpal::PlayStreamError> { - if self.starts { Ok(()) } else { Err(cpal::PlayStreamError::DeviceNotAvailable) } + /// Play reports the device's refusal when `starts` is false. + fn play(&self) -> Result<(), cpal::Error> { + if self.starts { Ok(()) } else { Err(cpal::Error::new(cpal::ErrorKind::DeviceNotAvailable)) } } - fn pause(&self) -> Result<(), cpal::PauseStreamError> { + /// Pause always succeeds on the fake stream. + fn pause(&self) -> Result<(), cpal::Error> { Ok(()) } + /// The fake stream claims one fixed buffer. + fn buffer_size(&self) -> Result { + Ok(1024) + } + /// The fake stream has no clock. + fn now(&self) -> cpal::StreamInstant { + cpal::StreamInstant::new(0, 0) + } } #[test] diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index 8072adac..a49b643c 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -145,7 +145,7 @@ fn build_input( config: &cpal::StreamConfig, shared: &Arc, monitor: &Arc, -) -> Result +) -> Result where T: cpal::SizedSample, f32: cpal::FromSample, @@ -158,7 +158,7 @@ where let chunk = scratch.len(); let mut owed = 0; device.build_input_stream( - config, + *config, move |data: &[T], _: &cpal::InputCallbackInfo| { crate::mark_audio_thread(); sh.guarded(|| { @@ -176,10 +176,10 @@ where } }); }, - move |e| { + move |e: cpal::Error| { // Possibly the audio thread (see `crate::mark_audio_thread`). crate::mark_audio_thread(); - if matches!(e, cpal::StreamError::DeviceNotAvailable) { + if e.kind() == cpal::ErrorKind::DeviceNotAvailable { let _ = errors.fault.compare_exchange(0, GONE, Ordering::Relaxed, Ordering::Relaxed); } log::warn!("input stream error: {e}"); @@ -202,17 +202,16 @@ impl Recorder { use cpal::traits::{DeviceTrait, StreamTrait}; let host = crate::devices::host(audio); let device = crate::devices::device(&host, audio.input.as_deref(), false).ok_or("no audio input device")?; - #[allow(deprecated)] - let name = device.name().unwrap_or_else(|_| "Input".into()); + let name = crate::devices::device_name(&device).unwrap_or_else(|| "Input".into()); // At the chosen rate when the device offers it, so input and output run together: the // best format first, then the most channels. let at_rate = audio.sample_rate.and_then(|r| { device .supported_input_configs() .ok()? - .filter(|c| crate::devices::format_rank(c.sample_format()).is_some() && c.min_sample_rate().0 <= r && c.max_sample_rate().0 >= r) + .filter(|c| crate::devices::format_rank(c.sample_format()).is_some() && c.min_sample_rate() <= r && c.max_sample_rate() >= r) .min_by_key(|c| (crate::devices::format_rank(c.sample_format()), std::cmp::Reverse(c.channels()))) - .map(|c| c.with_sample_rate(cpal::SampleRate(r))) + .map(|c| c.with_sample_rate(r)) }); let cfg = match at_rate { Some(c) => c, @@ -225,9 +224,9 @@ impl Recorder { let mut config = cfg.config(); config.buffer_size = crate::devices::buffer_size(audio.buffer_size, cfg.buffer_size()); let channels = usize::from(config.channels).max(1); - let ring = CaptureRing::new((config.sample_rate.0 as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25)); + let ring = CaptureRing::new((config.sample_rate as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25)); let shared = Arc::new(Shared::new(ring)); - let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0); + let monitor = InputRing::new(usize::from(config.channels), config.sample_rate); let build = |config: &cpal::StreamConfig| match format { cpal::SampleFormat::I32 => build_input::(&device, config, &shared, &monitor), cpal::SampleFormat::I16 => build_input::(&device, config, &shared, &monitor), @@ -245,7 +244,7 @@ impl Recorder { }; stream.play().map_err(|e| e.to_string())?; let seen = (0, Instant::now()); - Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None, seen }) + Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate, channels, writer: None, seen }) } #[cfg(target_os = "freebsd")] From ef8a63f8c3bd1e1694910ec47f7e7f2c16055cd4 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:35:58 -0400 Subject: [PATCH 24/33] Packaging: pin the signing client and appimagetool, and check their hashes (#827) * Packaging: pin the Windows signing client and check its hash * Packaging: pin appimagetool and check its hash * Reject AppImage download for architectures without a pinned appimagetool * Pin the AppImage runtime --- packaging/linux/package.sh | 46 +++++++++++++++++++++++++++++++++----- packaging/windows/sign.ps1 | 13 ++++++++--- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index fb677dd7..72d95f8b 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -106,6 +106,23 @@ fi # ---- AppImage ----------------------------------------------------------------------------------- if has appimage; then + APPIMAGETOOL_VERSION=1.9.1 + case "$ARCH" in + x86_64) APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 ;; + aarch64) APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 ;; + *) APPIMAGETOOL_SHA256='' ;; + esac + # appimagetool otherwise fetches the unpinned type2-runtime `continuous` build and embeds it. + APPIMAGE_RUNTIME_VERSION=20251108 + case "$ARCH" in + x86_64) APPIMAGE_RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d ;; + aarch64) APPIMAGE_RUNTIME_SHA256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 ;; + *) APPIMAGE_RUNTIME_SHA256='' ;; + esac + if [[ -z "$APPIMAGE_RUNTIME_SHA256" ]]; then + echo "no pinned AppImage runtime for $ARCH; build the AppImage on x86_64 or aarch64" >&2 + exit 2 + fi APPDIR="$WORK/SoundCraft.AppDir" cp -R "$STAGE" "$APPDIR" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" @@ -116,17 +133,36 @@ if has appimage; then TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" if [[ -z "$TOOL" ]]; then - TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [[ ! -x "$TOOL" ]]; then - curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" - chmod +x "$TOOL" + if [[ -z "$APPIMAGETOOL_SHA256" ]]; then + echo "no pinned appimagetool for $ARCH; set APPIMAGETOOL or put appimagetool on PATH" >&2 + exit 2 fi + TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage" + if [[ ! -x "$TOOL" ]]; then + download="$TOOL.download" + curl --proto '=https' -fsSL -o "$download" \ + "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" + printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c - + chmod 755 "$download" + mv "$download" "$TOOL" + fi + printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c - fi + RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH" + if [[ ! -f "$RUNTIME" ]]; then + download="$RUNTIME.download" + curl --proto '=https' -fsSL -o "$download" \ + "https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-$ARCH" + printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$download" | sha256sum -c - + mv "$download" "$RUNTIME" + fi + printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME" | sha256sum -c - # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be # relative). OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" APPDIR="$(cd "$APPDIR" && pwd)" TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" + RUNTIME="$(cd "$(dirname "$RUNTIME")" && pwd)/$(basename "$RUNTIME")" # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. rm -f "$OUT.zsync" # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and @@ -138,7 +174,7 @@ if has appimage; then # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync # package) appimagetool also writes the .zsync, into its working directory, hence the cd. - (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream --runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" diff --git a/packaging/windows/sign.ps1 b/packaging/windows/sign.ps1 index 448af0f2..40d2692e 100644 --- a/packaging/windows/sign.ps1 +++ b/packaging/windows/sign.ps1 @@ -56,8 +56,8 @@ if (-not $haveCert -and -not $haveAzure) { $script:SignTool = Find-SignTool $common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'SoundCraft', '/du', 'https://github.com/storytold/soundcraft') -$tmp = Join-Path ([IO.Path]::GetTempPath()) "soundcraft-sign-$PID" -New-Item -ItemType Directory -Force -Path $tmp | Out-Null +$tmp = Join-Path ([IO.Path]::GetTempPath()) "soundcraft-sign-$([IO.Path]::GetRandomFileName())" +New-Item -ItemType Directory -Path $tmp | Out-Null try { if ($haveCert) { @@ -73,8 +73,15 @@ try { Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')" # The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID, # AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment. + $trustedSigningVersion = '1.0.95' + $trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f' $pkg = Join-Path $tmp 'trusted-signing.zip' - Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg + $uri = "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$trustedSigningVersion/microsoft.trusted.signing.client.$trustedSigningVersion.nupkg" + Invoke-WebRequest -Uri $uri -OutFile $pkg + $actualHash = (Get-FileHash -Path $pkg -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $trustedSigningSha256) { + throw "Microsoft.Trusted.Signing.Client $trustedSigningVersion checksum mismatch" + } Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force $dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll | Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 From 9b6898273816ed44a37171d8ca9b6e54808657fe Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Fri, 23 Oct 2026 19:36:57 +0200 Subject: [PATCH 25/33] autosave: give every session its own folder; keep the tests apart from coarse mtimes (#579) (#594) With /tmp on tmpfs, four autosave and recovery tests failed: - Autosave folders were named by millisecond and process id, and created with create_dir_all, so two sessions started in the same millisecond shared one folder and the second overwrote the first's unsaved work (recovering_keeps_the_unsaved_work_it_replaces, closing_the_session_or_quitting_drops_its_autosave). Autosave::create now creates the folder exclusively and adds a -2, -3... suffix when the name is taken. - leftovers() offers an autosave only when it is strictly newer than its session file. A save and the autosave right after it can share one coarse mtime, so the tests that save and then autosave now move the saved file's mtime a second back (unsaved_work_is_offered_once_its_session_closes, an_exit_without_quit_offers_unsaved_changes_at_the_next_launch). --- crates/engine/src/autosave.rs | 45 +++++++++++++++++++++++++++++--- crates/ui-egui/tests/recovery.rs | 4 +++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 01899d2f..fd56478b 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -176,11 +176,25 @@ struct Autosave { } impl Autosave { - /// A new folder under `root` for the session called `name`. + /// A new folder under `root` for the session called `name`. Two sessions started within the + /// same millisecond get folders of their own, never a shared one. fn create(root: &Path, name: &str) -> Result { let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis(); - let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id())); - let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?; + let base = format!("{} {stamp}-{}", sanitize_name(name), std::process::id()); + std::fs::create_dir_all(root).map_err(|err| io_error(root, err))?; + let mut dir = root.join(&base); + let mut n = 1; + loop { + match std::fs::create_dir(&dir) { + Ok(()) => break, + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + n += 1; + dir = root.join(format!("{base}-{n}")); + } + Err(err) => return Err(io_error(&dir, err)), + } + } + let lock = std::fs::File::create(dir.join(LOCK)).map_err(|err| io_error(&dir, err))?; lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false }) } @@ -427,6 +441,14 @@ mod tests { dir } + /// Moves a saved session file's mtime a second into the past. Filesystems with coarse + /// timestamps (tmpfs) can give it and an autosave written right after it the same mtime, + /// and `leftovers` only offers autosaves strictly newer than the file they came from. + fn backdate(path: &Path) { + let then = SystemTime::now() - Duration::from_secs(1); + std::fs::File::options().write(true).open(path).unwrap().set_modified(then).unwrap(); + } + fn record(e: &mut Engine, level: f32) { let before = e.session_arc(); let s = e.session_mut(); @@ -453,6 +475,7 @@ mod tests { let mut e = Engine::default(); record(&mut e, 0.5); crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap(); + backdate(&path); record(&mut e, -0.25); let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap(); a.write(e.session(), e.path.as_deref(), None).unwrap(); @@ -590,6 +613,21 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn sessions_started_together_get_folders_of_their_own() { + // #579: the folder name has millisecond resolution, so two sessions created back to back + // shared one folder and the second overwrote the first's unsaved work. + let dir = folder("autosave-unique"); + let root = dir.join("Autosave"); + let made: Vec = (0..20).map(|_| Autosave::create(&root, "Song").unwrap()).collect(); + let mut dirs: Vec<&PathBuf> = made.iter().map(|a| &a.dir).collect(); + dirs.sort(); + dirs.dedup(); + assert_eq!(dirs.len(), made.len()); + drop(made); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn unsaved_work_is_offered_once_its_session_closes() { let dir = folder("autosave-leftovers"); @@ -598,6 +636,7 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let mut e = Engine::default(); crate::io::save_session(&mut e, &path).unwrap(); + backdate(Path::new(&path)); record(&mut e, 0.5); let mut a = Autosave::create(&root, "Song").unwrap(); a.write(e.session(), e.path.as_deref(), None).unwrap(); diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs index 00ea2654..5c295c80 100644 --- a/crates/ui-egui/tests/recovery.rs +++ b/crates/ui-egui/tests/recovery.rs @@ -52,6 +52,10 @@ fn an_exit_without_quit_offers_unsaved_changes_at_the_next_launch() { let path = dir.join("Song.scraft"); let mut first = launch(&root); first.run("session.save_as", json!({"path": path})).unwrap(); + // Leftovers are offered only when newer than their session file; on filesystems with coarse + // timestamps (tmpfs) the save and the autosave right after it can share one mtime (#579). + let then = std::time::SystemTime::now() - std::time::Duration::from_secs(1); + std::fs::File::options().write(true).open(&path).unwrap().set_modified(then).unwrap(); first.run("track.new", json!({"name": "Vocal"})).unwrap(); first.close_autosave(); drop(first); From 265645a8fc5db0098b97b7c09786f6ec60550bf5 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:37:13 -0400 Subject: [PATCH 26/33] Sessions: resolve stored source paths with craft-docpath's resolve_absolute (#826) * Sessions: use resolve_absolute for stored source paths * CI: check that git dependencies come from storytold * CI: release only with git dependencies from storytold * Parse Cargo.lock as TOML when checking git sources * Use the shared git-sources check in CI * CI: parse cargo config, including the legacy .cargo/config, in the git-sources check --- .github/workflows/nix.yml | 38 +++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 40 +++++++++++++++++++++++++++++++++++ crates/engine/src/io.rs | 18 ++++++++++------ 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 01cb9cd6..318f6bba 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -21,3 +21,41 @@ jobs: nix: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/nix.yml@main + + git-sources: + name: Git dependencies come from storytold + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d60e8202..28e47f7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,47 @@ concurrency: cancel-in-progress: false jobs: + git-sources: + name: Git dependencies come from storytold + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + release: + needs: git-sources # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index eff105ec..4b6601b6 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -68,13 +68,7 @@ pub fn file_found(path: impl AsRef) -> bool { /// Paths the person picks in this session (imports) are not stored paths and skip this. pub fn stored_path(stored: &str, session: &Path) -> std::result::Result { let session = std::path::absolute(session).unwrap_or_else(|_| session.to_path_buf()); - let path = craft_docpath::resolve(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))?; - // A path that is still relative would be taken from the working directory, which the session - // does not choose. - if path.is_relative() { - return Err("not opened: the path is not absolute".into()); - } - Ok(path) + craft_docpath::resolve_absolute(stored, Some(&session)).map_err(|why| format!("not opened: {why}")) } /// Create `dir` and its parents, inside the automation write root when there is one. @@ -1883,6 +1877,16 @@ mod open_tests { assert_eq!(std::fs::read_dir(dir.join("copy/Audio Files")).unwrap().count(), 1); let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn stored_paths_are_taken_from_the_session_folder() { + let session = std::env::temp_dir().join("s").join("Song.scraft"); + assert_eq!(stored_path("Audio Files/k.wav", &session), Ok(session.with_file_name("Audio Files").join("k.wav"))); + #[cfg(windows)] + assert_eq!(stored_path("Audio Files/take:2.wav", &session), Err("not opened: the path is not valid".into())); + #[cfg(not(windows))] + assert_eq!(stored_path(r"C:\Audio\k.wav", &session), Err("not opened: the path is not valid".into())); + } + #[test] fn stored_media_paths_are_followed_only_on_this_computer() { let dir = std::env::temp_dir().join(format!("soundcraft-stored-paths-{}", std::process::id())); From ab1849cbf8f3f046ad626124edce25436367b45e Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:37:39 -0400 Subject: [PATCH 27/33] Audio: check the WAVE behind a skipped RIFF form; hold egui at 0.36 in Dependabot (#978) * Audio: check the WAVE behind a RIFF form the probe skips The probe skips a RIFF form other than WAVE and reads a WAVE behind it, so the marker search skips it too and the INFO check runs on the WAVE the probe reads. The mismatched-container test moves to an input that still mismatches: a hinted FLAC behind a WAVE. * Dependabot: hold egui, eframe and egui_kittest at 0.36 craft-ui stays on egui 0.36 until storytold/craft-libs#199 lands; the daily group would otherwise propose 0.37 again. --- .github/dependabot.yml | 8 ++++++++ crates/audio-io/src/symph.rs | 6 +++++- crates/audio-io/tests/malformed.rs | 22 ++++++++++++++++------ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2c4b8cb1..b19dc402 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,6 +12,14 @@ updates: cargo-dependencies: patterns: - "*" + # craft-ui is on egui 0.36 until storytold/craft-libs#199 lands. + ignore: + - dependency-name: "egui" + update-types: ["version-update:semver-major", "version-update:semver-minor"] + - dependency-name: "eframe" + update-types: ["version-update:semver-major", "version-update:semver-minor"] + - dependency-name: "egui_kittest" + update-types: ["version-update:semver-major", "version-update:semver-minor"] commit-message: prefix: "chore(deps)" diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index db6efbbb..d6563e1a 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -95,12 +95,16 @@ fn expected_probe_format(marker: &[u8; 4]) -> &'static [&'static str] { /// check has to run before the stream is decoded. const SIZE_CHECKED_PROBE_FORMATS: &[&str] = &["caf", "flac", "isomp4", "wave"]; -/// Find the first container marker at or after `at`, within symphonia's probe depth. +/// Find the first container marker at or after `at`, within symphonia's probe depth. A RIFF form +/// other than WAVE is skipped, as the probe skips it and reads a WAVE behind it. fn find_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> { let end = bytes.len().min(at.saturating_add(MAX_PROBE_DEPTH)); for pos in at..end.saturating_sub(3) { let window = bytes.get(pos..pos + 4)?; if let Some(marker) = markers.iter().find(|marker| window == marker.as_slice()) { + if matches!(*marker, b"RIFF" | b"RF64" | b"BW64") && bytes.get(pos + 8..pos + 12) != Some(&b"WAVE"[..]) { + continue; + } return Some((pos, *marker)); } } diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/malformed.rs index f7da8746..f029b537 100644 --- a/crates/audio-io/tests/malformed.rs +++ b/crates/audio-io/tests/malformed.rs @@ -328,6 +328,18 @@ fn wav_info_entries_beyond_the_file_are_refused() { assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); } +#[test] +fn a_wave_behind_another_riff_form_is_checked() { + let info = [&b"INFO"[..], b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat(); + let mut wav = riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])]); + wav[4..8].copy_from_slice(&u32::MAX.to_le_bytes()); + let list = wav.windows(4).position(|w| w == b"LIST").unwrap(); + wav[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes()); + // The probe skips the AVI form and reads the WAVE behind it. + let f = [&b"RIFF"[..], &4u32.to_le_bytes(), b"AVI ", &wav].concat(); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); +} + #[test] fn wav_info_entries_are_found_where_symphonia_reads_them() { let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat(); @@ -455,12 +467,10 @@ fn garbage_in_compressed_containers_errors() { /// without its own checks: the reader is refused when the probed format disagrees. #[test] fn a_probed_container_that_disagrees_with_the_checked_marker_is_refused() { - let flac = small(FileFormat::Flac, BitDepth::Int24, 2); - let mut bytes = b"RIFF".to_vec(); - bytes.extend_from_slice(&3u32.to_le_bytes()); - bytes.extend_from_slice(b"FAKE"); - bytes.extend_from_slice(&flac); - match decode(&bytes, None) { + // The junk byte hides both magics, so the hint picks the marker: the FLAC is checked, but the + // probe reads the WAVE before it. + let bytes = [vec![0], small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Flac, BitDepth::Int24, 2)].concat(); + match decode(&bytes, Some("flac")) { Err(AudioError::Malformed(message)) => assert!(message.contains("probed container"), "unexpected message: {message}"), Err(other) => panic!("unexpected error kind: {other:?}"), Ok(_) => panic!("the mismatched container must be refused"), From e8abd447128f604fc49251bc6b790644c1e14507 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:00:38 -0400 Subject: [PATCH 28/33] CI: one test binary per crate, nextest, line tables only (#985) * Build: line tables only for workspace crates, no debug info for dependencies Full debug info made most of the dev and test target size and link time. Line tables keep file and line numbers in backtraces; dependency frames lose them. * Tests: build each crate's integration tests as one binary Each tests/*.rs file was its own test binary: 98 binaries, each linking the crate and its dependencies. The files are now modules of tests/it/main.rs, one binary per crate, which cuts link time and target size. Filter by module: `cargo test -p --test it ::`. Binaries with their own main (harness = false) in clap-host and vst3-host stay separate, as do the ui-egui tests that switch the process-wide Light/Dark palette and recovery.rs, whose autosave locks leak into child processes other tests start. * Xtask: run tests with nextest, doctests apart, and no examples in the test build cargo xtask ci runs the tests with cargo nextest when it is installed, else cargo test with a notice, and doctests with cargo test --doc. The test build leaves out examples (--lib --bins --tests); clippy --all-targets still compiles them. Test binaries with harness = false run under cargo test: their own main cannot answer nextest's --list. clippy and the wasm check keep going past a failing crate. cargo xtask test runs the test step alone. --- Cargo.toml | 2 + apps/soundcraft-cli/tests/{ => it}/cli.rs | 1 - .../tests/{ => it}/closed_stdout.rs | 0 apps/soundcraft-cli/tests/it/main.rs | 5 + crates/au-host/tests/it/main.rs | 4 + crates/au-host/tests/{ => it}/system_units.rs | 1 - .../audio-io/tests/{ => it}/encode_format.rs | 0 crates/audio-io/tests/it/main.rs | 7 ++ crates/audio-io/tests/{ => it}/malformed.rs | 0 crates/audio-io/tests/{ => it}/roundtrip.rs | 0 crates/audio-io/tests/{ => it}/stream.rs | 0 .../audio-io/tests/{ => it}/wav_empty_data.rs | 0 .../automation/tests/{ => it}/agent_tasks.rs | 0 .../tests/{ => it}/automation_roots.rs | 1 - crates/automation/tests/it/main.rs | 5 + .../clap-host/tests/{ => it}/fixture_e2e.rs | 3 +- crates/clap-host/tests/it/main.rs | 6 ++ crates/dsp/tests/{ => it}/fuzz.rs | 0 crates/dsp/tests/it/main.rs | 7 ++ crates/dsp/tests/{ => it}/offline_meters.rs | 0 .../dsp/tests/{ => it}/offline_normalize.rs | 0 crates/dsp/tests/{ => it}/plugins.rs | 0 crates/dsp/tests/{ => it}/transients.rs | 0 crates/engine/tests/{ => it}/bounce.rs | 0 crates/engine/tests/{ => it}/clap_e2e.rs | 0 .../tests/{ => it}/consolidate_selection.rs | 0 .../{ => it}/current_feet_frames_position.rs | 0 crates/engine/tests/{ => it}/edit_modes.rs | 0 .../{ => it}/fader_merge_full_history.rs | 0 .../hostile_params.proptest-regressions | 0 .../engine/tests/{ => it}/hostile_params.rs | 0 .../hostile_sessions.proptest-regressions | 0 .../engine/tests/{ => it}/hostile_sessions.rs | 0 crates/engine/tests/{ => it}/long_renders.rs | 0 crates/engine/tests/it/main.rs | 17 +++ crates/engine/tests/{ => it}/midi_offset.rs | 0 .../tests/{ => it}/mix_required_params.rs | 0 .../random_edits.proptest-regressions | 0 crates/engine/tests/{ => it}/random_edits.rs | 0 crates/engine/tests/{ => it}/recording.rs | 0 .../tests/{ => it}/session_transport.rs | 0 .../tests/{ => it}/trim_to_selection.rs | 0 crates/midi/tests/it/main.rs | 4 + crates/midi/tests/{ => it}/ops.rs | 0 crates/midi/tests/{ => it}/smf.rs | 0 crates/mix/tests/{ => it}/bus_aux_interval.rs | 1 - crates/mix/tests/it/main.rs | 6 ++ .../tests/{ => it}/master_insert_latency.rs | 1 - crates/time/tests/it/main.rs | 4 + .../tests/{ => it}/meter_grid_contract.rs | 0 .../time/tests/{ => it}/min_secs_exact_ms.rs | 0 crates/ui-egui/tests/{ => it}/appearance.rs | 0 crates/ui-egui/tests/{ => it}/audio_device.rs | 0 crates/ui-egui/tests/{ => it}/bounce.rs | 0 .../ui-egui/tests/{ => it}/bounce_dialog.rs | 0 .../ui-egui/tests/{ => it}/clip_gain_fader.rs | 0 crates/ui-egui/tests/{ => it}/clip_menu.rs | 0 .../tests/{ => it}/command_reachability.rs | 0 .../ui-egui/tests/{ => it}/commands_filter.rs | 0 .../tests/{ => it}/docking_workflows.rs | 0 crates/ui-egui/tests/{ => it}/edit_menu.rs | 0 crates/ui-egui/tests/{ => it}/edit_modes.rs | 0 .../tests/{ => it}/edit_window_headers.rs | 0 crates/ui-egui/tests/{ => it}/group_dialog.rs | 0 .../ui-egui/tests/{ => it}/hide_floating.rs | 0 crates/ui-egui/tests/{ => it}/idle.rs | 0 crates/ui-egui/tests/{ => it}/key_families.rs | 0 crates/ui-egui/tests/it/main.rs | 53 +++++++++ .../ui-egui/tests/{ => it}/marker_rename.rs | 0 crates/ui-egui/tests/{ => it}/menu_search.rs | 0 .../tests/{ => it}/midi_double_click.rs | 0 .../tests/{ => it}/midi_editor_keys.rs | 0 .../tests/{ => it}/midi_front_visibility.rs | 0 crates/ui-egui/tests/{ => it}/midi_panel.rs | 0 .../ui-egui/tests/{ => it}/midi_quantize.rs | 0 .../tests/{ => it}/mix_narrow_headers.rs | 0 crates/ui-egui/tests/{ => it}/mix_separate.rs | 0 .../ui-egui/tests/{ => it}/new_midi_clip.rs | 0 crates/ui-egui/tests/{ => it}/new_tracks.rs | 0 .../ui-egui/tests/{ => it}/open_on_insert.rs | 0 .../ui-egui/tests/{ => it}/plugin_window.rs | 0 .../ui-egui/tests/{ => it}/pro_tools_keys.rs | 0 crates/ui-egui/tests/{ => it}/record_hint.rs | 0 .../ui-egui/tests/{ => it}/rename_dialog.rs | 0 .../ui-egui/tests/{ => it}/sampler_window.rs | 0 .../ui-egui/tests/{ => it}/save_as_replace.rs | 0 crates/ui-egui/tests/{ => it}/save_changes.rs | 0 crates/ui-egui/tests/{ => it}/send_window.rs | 0 .../ui-egui/tests/{ => it}/shared_controls.rs | 0 crates/ui-egui/tests/{ => it}/shortcuts.rs | 0 .../tests/{ => it}/side_lists_access.rs | 0 .../ui-egui/tests/{ => it}/toolbar_tempo.rs | 0 crates/ui-egui/tests/{ => it}/track_height.rs | 0 crates/ui-egui/tests/{ => it}/track_list.rs | 0 .../ui-egui/tests/{ => it}/track_name_menu.rs | 0 crates/ui-egui/tests/{ => it}/trackpad.rs | 0 .../tests/{ => it}/tracks_scroll_bar.rs | 0 crates/ui-egui/tests/{ => it}/waveform.rs | 0 .../tests/{ => it}/window_shortcuts.rs | 0 crates/ui-egui/tests/{ => it}/zoom_toggle.rs | 0 .../vst3-host/tests/{ => it}/fixture_e2e.rs | 3 +- crates/vst3-host/tests/it/main.rs | 6 ++ docs/mcp.md | 2 +- xtask/src/main.rs | 102 ++++++++++++++++-- 104 files changed, 223 insertions(+), 18 deletions(-) rename apps/soundcraft-cli/tests/{ => it}/cli.rs (99%) rename apps/soundcraft-cli/tests/{ => it}/closed_stdout.rs (100%) create mode 100644 apps/soundcraft-cli/tests/it/main.rs create mode 100644 crates/au-host/tests/it/main.rs rename crates/au-host/tests/{ => it}/system_units.rs (99%) rename crates/audio-io/tests/{ => it}/encode_format.rs (100%) create mode 100644 crates/audio-io/tests/it/main.rs rename crates/audio-io/tests/{ => it}/malformed.rs (100%) rename crates/audio-io/tests/{ => it}/roundtrip.rs (100%) rename crates/audio-io/tests/{ => it}/stream.rs (100%) rename crates/audio-io/tests/{ => it}/wav_empty_data.rs (100%) rename crates/automation/tests/{ => it}/agent_tasks.rs (100%) rename crates/automation/tests/{ => it}/automation_roots.rs (99%) create mode 100644 crates/automation/tests/it/main.rs rename crates/clap-host/tests/{ => it}/fixture_e2e.rs (99%) create mode 100644 crates/clap-host/tests/it/main.rs rename crates/dsp/tests/{ => it}/fuzz.rs (100%) create mode 100644 crates/dsp/tests/it/main.rs rename crates/dsp/tests/{ => it}/offline_meters.rs (100%) rename crates/dsp/tests/{ => it}/offline_normalize.rs (100%) rename crates/dsp/tests/{ => it}/plugins.rs (100%) rename crates/dsp/tests/{ => it}/transients.rs (100%) rename crates/engine/tests/{ => it}/bounce.rs (100%) rename crates/engine/tests/{ => it}/clap_e2e.rs (100%) rename crates/engine/tests/{ => it}/consolidate_selection.rs (100%) rename crates/engine/tests/{ => it}/current_feet_frames_position.rs (100%) rename crates/engine/tests/{ => it}/edit_modes.rs (100%) rename crates/engine/tests/{ => it}/fader_merge_full_history.rs (100%) rename crates/engine/tests/{ => it}/hostile_params.proptest-regressions (100%) rename crates/engine/tests/{ => it}/hostile_params.rs (100%) rename crates/engine/tests/{ => it}/hostile_sessions.proptest-regressions (100%) rename crates/engine/tests/{ => it}/hostile_sessions.rs (100%) rename crates/engine/tests/{ => it}/long_renders.rs (100%) create mode 100644 crates/engine/tests/it/main.rs rename crates/engine/tests/{ => it}/midi_offset.rs (100%) rename crates/engine/tests/{ => it}/mix_required_params.rs (100%) rename crates/engine/tests/{ => it}/random_edits.proptest-regressions (100%) rename crates/engine/tests/{ => it}/random_edits.rs (100%) rename crates/engine/tests/{ => it}/recording.rs (100%) rename crates/engine/tests/{ => it}/session_transport.rs (100%) rename crates/engine/tests/{ => it}/trim_to_selection.rs (100%) create mode 100644 crates/midi/tests/it/main.rs rename crates/midi/tests/{ => it}/ops.rs (100%) rename crates/midi/tests/{ => it}/smf.rs (100%) rename crates/mix/tests/{ => it}/bus_aux_interval.rs (98%) create mode 100644 crates/mix/tests/it/main.rs rename crates/mix/tests/{ => it}/master_insert_latency.rs (97%) create mode 100644 crates/time/tests/it/main.rs rename crates/time/tests/{ => it}/meter_grid_contract.rs (100%) rename crates/time/tests/{ => it}/min_secs_exact_ms.rs (100%) rename crates/ui-egui/tests/{ => it}/appearance.rs (100%) rename crates/ui-egui/tests/{ => it}/audio_device.rs (100%) rename crates/ui-egui/tests/{ => it}/bounce.rs (100%) rename crates/ui-egui/tests/{ => it}/bounce_dialog.rs (100%) rename crates/ui-egui/tests/{ => it}/clip_gain_fader.rs (100%) rename crates/ui-egui/tests/{ => it}/clip_menu.rs (100%) rename crates/ui-egui/tests/{ => it}/command_reachability.rs (100%) rename crates/ui-egui/tests/{ => it}/commands_filter.rs (100%) rename crates/ui-egui/tests/{ => it}/docking_workflows.rs (100%) rename crates/ui-egui/tests/{ => it}/edit_menu.rs (100%) rename crates/ui-egui/tests/{ => it}/edit_modes.rs (100%) rename crates/ui-egui/tests/{ => it}/edit_window_headers.rs (100%) rename crates/ui-egui/tests/{ => it}/group_dialog.rs (100%) rename crates/ui-egui/tests/{ => it}/hide_floating.rs (100%) rename crates/ui-egui/tests/{ => it}/idle.rs (100%) rename crates/ui-egui/tests/{ => it}/key_families.rs (100%) create mode 100644 crates/ui-egui/tests/it/main.rs rename crates/ui-egui/tests/{ => it}/marker_rename.rs (100%) rename crates/ui-egui/tests/{ => it}/menu_search.rs (100%) rename crates/ui-egui/tests/{ => it}/midi_double_click.rs (100%) rename crates/ui-egui/tests/{ => it}/midi_editor_keys.rs (100%) rename crates/ui-egui/tests/{ => it}/midi_front_visibility.rs (100%) rename crates/ui-egui/tests/{ => it}/midi_panel.rs (100%) rename crates/ui-egui/tests/{ => it}/midi_quantize.rs (100%) rename crates/ui-egui/tests/{ => it}/mix_narrow_headers.rs (100%) rename crates/ui-egui/tests/{ => it}/mix_separate.rs (100%) rename crates/ui-egui/tests/{ => it}/new_midi_clip.rs (100%) rename crates/ui-egui/tests/{ => it}/new_tracks.rs (100%) rename crates/ui-egui/tests/{ => it}/open_on_insert.rs (100%) rename crates/ui-egui/tests/{ => it}/plugin_window.rs (100%) rename crates/ui-egui/tests/{ => it}/pro_tools_keys.rs (100%) rename crates/ui-egui/tests/{ => it}/record_hint.rs (100%) rename crates/ui-egui/tests/{ => it}/rename_dialog.rs (100%) rename crates/ui-egui/tests/{ => it}/sampler_window.rs (100%) rename crates/ui-egui/tests/{ => it}/save_as_replace.rs (100%) rename crates/ui-egui/tests/{ => it}/save_changes.rs (100%) rename crates/ui-egui/tests/{ => it}/send_window.rs (100%) rename crates/ui-egui/tests/{ => it}/shared_controls.rs (100%) rename crates/ui-egui/tests/{ => it}/shortcuts.rs (100%) rename crates/ui-egui/tests/{ => it}/side_lists_access.rs (100%) rename crates/ui-egui/tests/{ => it}/toolbar_tempo.rs (100%) rename crates/ui-egui/tests/{ => it}/track_height.rs (100%) rename crates/ui-egui/tests/{ => it}/track_list.rs (100%) rename crates/ui-egui/tests/{ => it}/track_name_menu.rs (100%) rename crates/ui-egui/tests/{ => it}/trackpad.rs (100%) rename crates/ui-egui/tests/{ => it}/tracks_scroll_bar.rs (100%) rename crates/ui-egui/tests/{ => it}/waveform.rs (100%) rename crates/ui-egui/tests/{ => it}/window_shortcuts.rs (100%) rename crates/ui-egui/tests/{ => it}/zoom_toggle.rs (100%) rename crates/vst3-host/tests/{ => it}/fixture_e2e.rs (99%) create mode 100644 crates/vst3-host/tests/it/main.rs diff --git a/Cargo.toml b/Cargo.toml index b5ea0ddc..d2fe3187 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,9 +67,11 @@ type_complexity = "allow" [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" [profile.release] opt-level = 3 diff --git a/apps/soundcraft-cli/tests/cli.rs b/apps/soundcraft-cli/tests/it/cli.rs similarity index 99% rename from apps/soundcraft-cli/tests/cli.rs rename to apps/soundcraft-cli/tests/it/cli.rs index ef2fb881..c10bcf60 100644 --- a/apps/soundcraft-cli/tests/cli.rs +++ b/apps/soundcraft-cli/tests/it/cli.rs @@ -1,6 +1,5 @@ //! `soundcraft-cli mcp` with the automation root flags, run as the real binary. // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::io::Write; use std::path::PathBuf; diff --git a/apps/soundcraft-cli/tests/closed_stdout.rs b/apps/soundcraft-cli/tests/it/closed_stdout.rs similarity index 100% rename from apps/soundcraft-cli/tests/closed_stdout.rs rename to apps/soundcraft-cli/tests/it/closed_stdout.rs diff --git a/apps/soundcraft-cli/tests/it/main.rs b/apps/soundcraft-cli/tests/it/main.rs new file mode 100644 index 00000000..c573712b --- /dev/null +++ b/apps/soundcraft-cli/tests/it/main.rs @@ -0,0 +1,5 @@ +//! Integration tests, built as one test binary: each file is a module. + +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod cli; +mod closed_stdout; diff --git a/crates/au-host/tests/it/main.rs b/crates/au-host/tests/it/main.rs new file mode 100644 index 00000000..a4505476 --- /dev/null +++ b/crates/au-host/tests/it/main.rs @@ -0,0 +1,4 @@ +//! Integration tests, built as one test binary: each file is a module. + +#[cfg(target_os = "macos")] +mod system_units; diff --git a/crates/au-host/tests/system_units.rs b/crates/au-host/tests/it/system_units.rs similarity index 99% rename from crates/au-host/tests/system_units.rs rename to crates/au-host/tests/it/system_units.rs index 8f4e6924..3d16749e 100644 --- a/crates/au-host/tests/system_units.rs +++ b/crates/au-host/tests/it/system_units.rs @@ -1,6 +1,5 @@ //! End to end against the Audio Units Apple ships with macOS: scan → instantiate → params → //! process → state, plus an instrument playing a note. Skips (passes) when a unit is missing. -#![cfg(target_os = "macos")] use soundcraft_dsp::{Category, Plugin}; diff --git a/crates/audio-io/tests/encode_format.rs b/crates/audio-io/tests/it/encode_format.rs similarity index 100% rename from crates/audio-io/tests/encode_format.rs rename to crates/audio-io/tests/it/encode_format.rs diff --git a/crates/audio-io/tests/it/main.rs b/crates/audio-io/tests/it/main.rs new file mode 100644 index 00000000..36b5129f --- /dev/null +++ b/crates/audio-io/tests/it/main.rs @@ -0,0 +1,7 @@ +//! Integration tests, built as one test binary: each file is a module. + +mod encode_format; +mod malformed; +mod roundtrip; +mod stream; +mod wav_empty_data; diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/it/malformed.rs similarity index 100% rename from crates/audio-io/tests/malformed.rs rename to crates/audio-io/tests/it/malformed.rs diff --git a/crates/audio-io/tests/roundtrip.rs b/crates/audio-io/tests/it/roundtrip.rs similarity index 100% rename from crates/audio-io/tests/roundtrip.rs rename to crates/audio-io/tests/it/roundtrip.rs diff --git a/crates/audio-io/tests/stream.rs b/crates/audio-io/tests/it/stream.rs similarity index 100% rename from crates/audio-io/tests/stream.rs rename to crates/audio-io/tests/it/stream.rs diff --git a/crates/audio-io/tests/wav_empty_data.rs b/crates/audio-io/tests/it/wav_empty_data.rs similarity index 100% rename from crates/audio-io/tests/wav_empty_data.rs rename to crates/audio-io/tests/it/wav_empty_data.rs diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/it/agent_tasks.rs similarity index 100% rename from crates/automation/tests/agent_tasks.rs rename to crates/automation/tests/it/agent_tasks.rs diff --git a/crates/automation/tests/automation_roots.rs b/crates/automation/tests/it/automation_roots.rs similarity index 99% rename from crates/automation/tests/automation_roots.rs rename to crates/automation/tests/it/automation_roots.rs index 2c4fa160..58d4aad2 100644 --- a/crates/automation/tests/automation_roots.rs +++ b/crates/automation/tests/it/automation_roots.rs @@ -1,7 +1,6 @@ //! `Headless::with_automation_roots` through the real MCP protocol: every file a command reads //! stays inside the read root, every file it writes inside the write root. // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::{Value, json}; use soundcraft_automation::{Headless, mcp::Server}; diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs new file mode 100644 index 00000000..fc738242 --- /dev/null +++ b/crates/automation/tests/it/main.rs @@ -0,0 +1,5 @@ +//! Integration tests, built as one test binary: each file is a module. + +mod agent_tasks; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod automation_roots; diff --git a/crates/clap-host/tests/fixture_e2e.rs b/crates/clap-host/tests/it/fixture_e2e.rs similarity index 99% rename from crates/clap-host/tests/fixture_e2e.rs rename to crates/clap-host/tests/it/fixture_e2e.rs index 8525d740..f531db54 100644 --- a/crates/clap-host/tests/fixture_e2e.rs +++ b/crates/clap-host/tests/it/fixture_e2e.rs @@ -3,8 +3,7 @@ use soundcraft_dsp::{Category, Unit}; -mod common; -use common::fixture_dir; +use crate::common::fixture_dir; const GAIN: &str = "clap:org.soundcraft.test.gain"; const SYNTH: &str = "clap:org.soundcraft.test.synth"; diff --git a/crates/clap-host/tests/it/main.rs b/crates/clap-host/tests/it/main.rs new file mode 100644 index 00000000..ce956746 --- /dev/null +++ b/crates/clap-host/tests/it/main.rs @@ -0,0 +1,6 @@ +//! Integration tests, built as one test binary: each file is a module. + +#[path = "../common/mod.rs"] +mod common; + +mod fixture_e2e; diff --git a/crates/dsp/tests/fuzz.rs b/crates/dsp/tests/it/fuzz.rs similarity index 100% rename from crates/dsp/tests/fuzz.rs rename to crates/dsp/tests/it/fuzz.rs diff --git a/crates/dsp/tests/it/main.rs b/crates/dsp/tests/it/main.rs new file mode 100644 index 00000000..c214cd3e --- /dev/null +++ b/crates/dsp/tests/it/main.rs @@ -0,0 +1,7 @@ +//! Integration tests, built as one test binary: each file is a module. + +mod fuzz; +mod offline_meters; +mod offline_normalize; +mod plugins; +mod transients; diff --git a/crates/dsp/tests/offline_meters.rs b/crates/dsp/tests/it/offline_meters.rs similarity index 100% rename from crates/dsp/tests/offline_meters.rs rename to crates/dsp/tests/it/offline_meters.rs diff --git a/crates/dsp/tests/offline_normalize.rs b/crates/dsp/tests/it/offline_normalize.rs similarity index 100% rename from crates/dsp/tests/offline_normalize.rs rename to crates/dsp/tests/it/offline_normalize.rs diff --git a/crates/dsp/tests/plugins.rs b/crates/dsp/tests/it/plugins.rs similarity index 100% rename from crates/dsp/tests/plugins.rs rename to crates/dsp/tests/it/plugins.rs diff --git a/crates/dsp/tests/transients.rs b/crates/dsp/tests/it/transients.rs similarity index 100% rename from crates/dsp/tests/transients.rs rename to crates/dsp/tests/it/transients.rs diff --git a/crates/engine/tests/bounce.rs b/crates/engine/tests/it/bounce.rs similarity index 100% rename from crates/engine/tests/bounce.rs rename to crates/engine/tests/it/bounce.rs diff --git a/crates/engine/tests/clap_e2e.rs b/crates/engine/tests/it/clap_e2e.rs similarity index 100% rename from crates/engine/tests/clap_e2e.rs rename to crates/engine/tests/it/clap_e2e.rs diff --git a/crates/engine/tests/consolidate_selection.rs b/crates/engine/tests/it/consolidate_selection.rs similarity index 100% rename from crates/engine/tests/consolidate_selection.rs rename to crates/engine/tests/it/consolidate_selection.rs diff --git a/crates/engine/tests/current_feet_frames_position.rs b/crates/engine/tests/it/current_feet_frames_position.rs similarity index 100% rename from crates/engine/tests/current_feet_frames_position.rs rename to crates/engine/tests/it/current_feet_frames_position.rs diff --git a/crates/engine/tests/edit_modes.rs b/crates/engine/tests/it/edit_modes.rs similarity index 100% rename from crates/engine/tests/edit_modes.rs rename to crates/engine/tests/it/edit_modes.rs diff --git a/crates/engine/tests/fader_merge_full_history.rs b/crates/engine/tests/it/fader_merge_full_history.rs similarity index 100% rename from crates/engine/tests/fader_merge_full_history.rs rename to crates/engine/tests/it/fader_merge_full_history.rs diff --git a/crates/engine/tests/hostile_params.proptest-regressions b/crates/engine/tests/it/hostile_params.proptest-regressions similarity index 100% rename from crates/engine/tests/hostile_params.proptest-regressions rename to crates/engine/tests/it/hostile_params.proptest-regressions diff --git a/crates/engine/tests/hostile_params.rs b/crates/engine/tests/it/hostile_params.rs similarity index 100% rename from crates/engine/tests/hostile_params.rs rename to crates/engine/tests/it/hostile_params.rs diff --git a/crates/engine/tests/hostile_sessions.proptest-regressions b/crates/engine/tests/it/hostile_sessions.proptest-regressions similarity index 100% rename from crates/engine/tests/hostile_sessions.proptest-regressions rename to crates/engine/tests/it/hostile_sessions.proptest-regressions diff --git a/crates/engine/tests/hostile_sessions.rs b/crates/engine/tests/it/hostile_sessions.rs similarity index 100% rename from crates/engine/tests/hostile_sessions.rs rename to crates/engine/tests/it/hostile_sessions.rs diff --git a/crates/engine/tests/long_renders.rs b/crates/engine/tests/it/long_renders.rs similarity index 100% rename from crates/engine/tests/long_renders.rs rename to crates/engine/tests/it/long_renders.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 00000000..f021c292 --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,17 @@ +//! Integration tests, built as one test binary: each file is a module. + +mod bounce; +mod clap_e2e; +mod consolidate_selection; +mod current_feet_frames_position; +mod edit_modes; +mod fader_merge_full_history; +mod hostile_params; +mod hostile_sessions; +mod long_renders; +mod midi_offset; +mod mix_required_params; +mod random_edits; +mod recording; +mod session_transport; +mod trim_to_selection; diff --git a/crates/engine/tests/midi_offset.rs b/crates/engine/tests/it/midi_offset.rs similarity index 100% rename from crates/engine/tests/midi_offset.rs rename to crates/engine/tests/it/midi_offset.rs diff --git a/crates/engine/tests/mix_required_params.rs b/crates/engine/tests/it/mix_required_params.rs similarity index 100% rename from crates/engine/tests/mix_required_params.rs rename to crates/engine/tests/it/mix_required_params.rs diff --git a/crates/engine/tests/random_edits.proptest-regressions b/crates/engine/tests/it/random_edits.proptest-regressions similarity index 100% rename from crates/engine/tests/random_edits.proptest-regressions rename to crates/engine/tests/it/random_edits.proptest-regressions diff --git a/crates/engine/tests/random_edits.rs b/crates/engine/tests/it/random_edits.rs similarity index 100% rename from crates/engine/tests/random_edits.rs rename to crates/engine/tests/it/random_edits.rs diff --git a/crates/engine/tests/recording.rs b/crates/engine/tests/it/recording.rs similarity index 100% rename from crates/engine/tests/recording.rs rename to crates/engine/tests/it/recording.rs diff --git a/crates/engine/tests/session_transport.rs b/crates/engine/tests/it/session_transport.rs similarity index 100% rename from crates/engine/tests/session_transport.rs rename to crates/engine/tests/it/session_transport.rs diff --git a/crates/engine/tests/trim_to_selection.rs b/crates/engine/tests/it/trim_to_selection.rs similarity index 100% rename from crates/engine/tests/trim_to_selection.rs rename to crates/engine/tests/it/trim_to_selection.rs diff --git a/crates/midi/tests/it/main.rs b/crates/midi/tests/it/main.rs new file mode 100644 index 00000000..26fb0ad9 --- /dev/null +++ b/crates/midi/tests/it/main.rs @@ -0,0 +1,4 @@ +//! Integration tests, built as one test binary: each file is a module. + +mod ops; +mod smf; diff --git a/crates/midi/tests/ops.rs b/crates/midi/tests/it/ops.rs similarity index 100% rename from crates/midi/tests/ops.rs rename to crates/midi/tests/it/ops.rs diff --git a/crates/midi/tests/smf.rs b/crates/midi/tests/it/smf.rs similarity index 100% rename from crates/midi/tests/smf.rs rename to crates/midi/tests/it/smf.rs diff --git a/crates/mix/tests/bus_aux_interval.rs b/crates/mix/tests/it/bus_aux_interval.rs similarity index 98% rename from crates/mix/tests/bus_aux_interval.rs rename to crates/mix/tests/it/bus_aux_interval.rs index ff3b6813..e672e536 100644 --- a/crates/mix/tests/bus_aux_interval.rs +++ b/crates/mix/tests/it/bus_aux_interval.rs @@ -1,6 +1,5 @@ //! A bounce of a bus keeps the requested timeline interval when an aux returns onto the bus //! (its insert's lookahead is compensated like the main mix's). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use soundcraft_audio_io::AudioBuffer; use soundcraft_mix::{render_bus, render_range}; diff --git a/crates/mix/tests/it/main.rs b/crates/mix/tests/it/main.rs new file mode 100644 index 00000000..77997334 --- /dev/null +++ b/crates/mix/tests/it/main.rs @@ -0,0 +1,6 @@ +//! Integration tests, built as one test binary: each file is a module. + +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod bus_aux_interval; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod master_insert_latency; diff --git a/crates/mix/tests/master_insert_latency.rs b/crates/mix/tests/it/master_insert_latency.rs similarity index 97% rename from crates/mix/tests/master_insert_latency.rs rename to crates/mix/tests/it/master_insert_latency.rs index d4aed976..981c080f 100644 --- a/crates/mix/tests/master_insert_latency.rs +++ b/crates/mix/tests/it/master_insert_latency.rs @@ -1,6 +1,5 @@ //! Delay compensation covers the inserts of the master faders: a bounce of the main mix lines up //! with the same mix without them. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use soundcraft_audio_io::AudioBuffer; use soundcraft_mix::{MixEngine, render_range}; diff --git a/crates/time/tests/it/main.rs b/crates/time/tests/it/main.rs new file mode 100644 index 00000000..a43e92bf --- /dev/null +++ b/crates/time/tests/it/main.rs @@ -0,0 +1,4 @@ +//! Integration tests, built as one test binary: each file is a module. + +mod meter_grid_contract; +mod min_secs_exact_ms; diff --git a/crates/time/tests/meter_grid_contract.rs b/crates/time/tests/it/meter_grid_contract.rs similarity index 100% rename from crates/time/tests/meter_grid_contract.rs rename to crates/time/tests/it/meter_grid_contract.rs diff --git a/crates/time/tests/min_secs_exact_ms.rs b/crates/time/tests/it/min_secs_exact_ms.rs similarity index 100% rename from crates/time/tests/min_secs_exact_ms.rs rename to crates/time/tests/it/min_secs_exact_ms.rs diff --git a/crates/ui-egui/tests/appearance.rs b/crates/ui-egui/tests/it/appearance.rs similarity index 100% rename from crates/ui-egui/tests/appearance.rs rename to crates/ui-egui/tests/it/appearance.rs diff --git a/crates/ui-egui/tests/audio_device.rs b/crates/ui-egui/tests/it/audio_device.rs similarity index 100% rename from crates/ui-egui/tests/audio_device.rs rename to crates/ui-egui/tests/it/audio_device.rs diff --git a/crates/ui-egui/tests/bounce.rs b/crates/ui-egui/tests/it/bounce.rs similarity index 100% rename from crates/ui-egui/tests/bounce.rs rename to crates/ui-egui/tests/it/bounce.rs diff --git a/crates/ui-egui/tests/bounce_dialog.rs b/crates/ui-egui/tests/it/bounce_dialog.rs similarity index 100% rename from crates/ui-egui/tests/bounce_dialog.rs rename to crates/ui-egui/tests/it/bounce_dialog.rs diff --git a/crates/ui-egui/tests/clip_gain_fader.rs b/crates/ui-egui/tests/it/clip_gain_fader.rs similarity index 100% rename from crates/ui-egui/tests/clip_gain_fader.rs rename to crates/ui-egui/tests/it/clip_gain_fader.rs diff --git a/crates/ui-egui/tests/clip_menu.rs b/crates/ui-egui/tests/it/clip_menu.rs similarity index 100% rename from crates/ui-egui/tests/clip_menu.rs rename to crates/ui-egui/tests/it/clip_menu.rs diff --git a/crates/ui-egui/tests/command_reachability.rs b/crates/ui-egui/tests/it/command_reachability.rs similarity index 100% rename from crates/ui-egui/tests/command_reachability.rs rename to crates/ui-egui/tests/it/command_reachability.rs diff --git a/crates/ui-egui/tests/commands_filter.rs b/crates/ui-egui/tests/it/commands_filter.rs similarity index 100% rename from crates/ui-egui/tests/commands_filter.rs rename to crates/ui-egui/tests/it/commands_filter.rs diff --git a/crates/ui-egui/tests/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs similarity index 100% rename from crates/ui-egui/tests/docking_workflows.rs rename to crates/ui-egui/tests/it/docking_workflows.rs diff --git a/crates/ui-egui/tests/edit_menu.rs b/crates/ui-egui/tests/it/edit_menu.rs similarity index 100% rename from crates/ui-egui/tests/edit_menu.rs rename to crates/ui-egui/tests/it/edit_menu.rs diff --git a/crates/ui-egui/tests/edit_modes.rs b/crates/ui-egui/tests/it/edit_modes.rs similarity index 100% rename from crates/ui-egui/tests/edit_modes.rs rename to crates/ui-egui/tests/it/edit_modes.rs diff --git a/crates/ui-egui/tests/edit_window_headers.rs b/crates/ui-egui/tests/it/edit_window_headers.rs similarity index 100% rename from crates/ui-egui/tests/edit_window_headers.rs rename to crates/ui-egui/tests/it/edit_window_headers.rs diff --git a/crates/ui-egui/tests/group_dialog.rs b/crates/ui-egui/tests/it/group_dialog.rs similarity index 100% rename from crates/ui-egui/tests/group_dialog.rs rename to crates/ui-egui/tests/it/group_dialog.rs diff --git a/crates/ui-egui/tests/hide_floating.rs b/crates/ui-egui/tests/it/hide_floating.rs similarity index 100% rename from crates/ui-egui/tests/hide_floating.rs rename to crates/ui-egui/tests/it/hide_floating.rs diff --git a/crates/ui-egui/tests/idle.rs b/crates/ui-egui/tests/it/idle.rs similarity index 100% rename from crates/ui-egui/tests/idle.rs rename to crates/ui-egui/tests/it/idle.rs diff --git a/crates/ui-egui/tests/key_families.rs b/crates/ui-egui/tests/it/key_families.rs similarity index 100% rename from crates/ui-egui/tests/key_families.rs rename to crates/ui-egui/tests/it/key_families.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs new file mode 100644 index 00000000..d9eb9fe6 --- /dev/null +++ b/crates/ui-egui/tests/it/main.rs @@ -0,0 +1,53 @@ +//! Integration tests, built as one test binary: each file is a module. +//! +//! Tests that switch the process-wide Light/Dark palette, and `recovery.rs` (whose autosave locks +//! leak into child processes other tests start), are separate binaries in `tests/`. + +mod appearance; +mod audio_device; +mod bounce; +mod bounce_dialog; +mod clip_gain_fader; +mod clip_menu; +mod command_reachability; +mod commands_filter; +mod docking_workflows; +mod edit_menu; +mod edit_modes; +mod edit_window_headers; +mod group_dialog; +mod hide_floating; +mod idle; +mod key_families; +mod marker_rename; +mod menu_search; +mod midi_double_click; +mod midi_editor_keys; +mod midi_front_visibility; +mod midi_panel; +mod midi_quantize; +mod mix_narrow_headers; +mod mix_separate; +mod new_midi_clip; +mod new_tracks; +mod open_on_insert; +mod plugin_window; +mod pro_tools_keys; +mod record_hint; +mod rename_dialog; +mod sampler_window; +mod save_as_replace; +mod save_changes; +mod send_window; +mod shared_controls; +mod shortcuts; +mod side_lists_access; +mod toolbar_tempo; +mod track_height; +mod track_list; +mod track_name_menu; +mod trackpad; +mod tracks_scroll_bar; +mod waveform; +mod window_shortcuts; +mod zoom_toggle; diff --git a/crates/ui-egui/tests/marker_rename.rs b/crates/ui-egui/tests/it/marker_rename.rs similarity index 100% rename from crates/ui-egui/tests/marker_rename.rs rename to crates/ui-egui/tests/it/marker_rename.rs diff --git a/crates/ui-egui/tests/menu_search.rs b/crates/ui-egui/tests/it/menu_search.rs similarity index 100% rename from crates/ui-egui/tests/menu_search.rs rename to crates/ui-egui/tests/it/menu_search.rs diff --git a/crates/ui-egui/tests/midi_double_click.rs b/crates/ui-egui/tests/it/midi_double_click.rs similarity index 100% rename from crates/ui-egui/tests/midi_double_click.rs rename to crates/ui-egui/tests/it/midi_double_click.rs diff --git a/crates/ui-egui/tests/midi_editor_keys.rs b/crates/ui-egui/tests/it/midi_editor_keys.rs similarity index 100% rename from crates/ui-egui/tests/midi_editor_keys.rs rename to crates/ui-egui/tests/it/midi_editor_keys.rs diff --git a/crates/ui-egui/tests/midi_front_visibility.rs b/crates/ui-egui/tests/it/midi_front_visibility.rs similarity index 100% rename from crates/ui-egui/tests/midi_front_visibility.rs rename to crates/ui-egui/tests/it/midi_front_visibility.rs diff --git a/crates/ui-egui/tests/midi_panel.rs b/crates/ui-egui/tests/it/midi_panel.rs similarity index 100% rename from crates/ui-egui/tests/midi_panel.rs rename to crates/ui-egui/tests/it/midi_panel.rs diff --git a/crates/ui-egui/tests/midi_quantize.rs b/crates/ui-egui/tests/it/midi_quantize.rs similarity index 100% rename from crates/ui-egui/tests/midi_quantize.rs rename to crates/ui-egui/tests/it/midi_quantize.rs diff --git a/crates/ui-egui/tests/mix_narrow_headers.rs b/crates/ui-egui/tests/it/mix_narrow_headers.rs similarity index 100% rename from crates/ui-egui/tests/mix_narrow_headers.rs rename to crates/ui-egui/tests/it/mix_narrow_headers.rs diff --git a/crates/ui-egui/tests/mix_separate.rs b/crates/ui-egui/tests/it/mix_separate.rs similarity index 100% rename from crates/ui-egui/tests/mix_separate.rs rename to crates/ui-egui/tests/it/mix_separate.rs diff --git a/crates/ui-egui/tests/new_midi_clip.rs b/crates/ui-egui/tests/it/new_midi_clip.rs similarity index 100% rename from crates/ui-egui/tests/new_midi_clip.rs rename to crates/ui-egui/tests/it/new_midi_clip.rs diff --git a/crates/ui-egui/tests/new_tracks.rs b/crates/ui-egui/tests/it/new_tracks.rs similarity index 100% rename from crates/ui-egui/tests/new_tracks.rs rename to crates/ui-egui/tests/it/new_tracks.rs diff --git a/crates/ui-egui/tests/open_on_insert.rs b/crates/ui-egui/tests/it/open_on_insert.rs similarity index 100% rename from crates/ui-egui/tests/open_on_insert.rs rename to crates/ui-egui/tests/it/open_on_insert.rs diff --git a/crates/ui-egui/tests/plugin_window.rs b/crates/ui-egui/tests/it/plugin_window.rs similarity index 100% rename from crates/ui-egui/tests/plugin_window.rs rename to crates/ui-egui/tests/it/plugin_window.rs diff --git a/crates/ui-egui/tests/pro_tools_keys.rs b/crates/ui-egui/tests/it/pro_tools_keys.rs similarity index 100% rename from crates/ui-egui/tests/pro_tools_keys.rs rename to crates/ui-egui/tests/it/pro_tools_keys.rs diff --git a/crates/ui-egui/tests/record_hint.rs b/crates/ui-egui/tests/it/record_hint.rs similarity index 100% rename from crates/ui-egui/tests/record_hint.rs rename to crates/ui-egui/tests/it/record_hint.rs diff --git a/crates/ui-egui/tests/rename_dialog.rs b/crates/ui-egui/tests/it/rename_dialog.rs similarity index 100% rename from crates/ui-egui/tests/rename_dialog.rs rename to crates/ui-egui/tests/it/rename_dialog.rs diff --git a/crates/ui-egui/tests/sampler_window.rs b/crates/ui-egui/tests/it/sampler_window.rs similarity index 100% rename from crates/ui-egui/tests/sampler_window.rs rename to crates/ui-egui/tests/it/sampler_window.rs diff --git a/crates/ui-egui/tests/save_as_replace.rs b/crates/ui-egui/tests/it/save_as_replace.rs similarity index 100% rename from crates/ui-egui/tests/save_as_replace.rs rename to crates/ui-egui/tests/it/save_as_replace.rs diff --git a/crates/ui-egui/tests/save_changes.rs b/crates/ui-egui/tests/it/save_changes.rs similarity index 100% rename from crates/ui-egui/tests/save_changes.rs rename to crates/ui-egui/tests/it/save_changes.rs diff --git a/crates/ui-egui/tests/send_window.rs b/crates/ui-egui/tests/it/send_window.rs similarity index 100% rename from crates/ui-egui/tests/send_window.rs rename to crates/ui-egui/tests/it/send_window.rs diff --git a/crates/ui-egui/tests/shared_controls.rs b/crates/ui-egui/tests/it/shared_controls.rs similarity index 100% rename from crates/ui-egui/tests/shared_controls.rs rename to crates/ui-egui/tests/it/shared_controls.rs diff --git a/crates/ui-egui/tests/shortcuts.rs b/crates/ui-egui/tests/it/shortcuts.rs similarity index 100% rename from crates/ui-egui/tests/shortcuts.rs rename to crates/ui-egui/tests/it/shortcuts.rs diff --git a/crates/ui-egui/tests/side_lists_access.rs b/crates/ui-egui/tests/it/side_lists_access.rs similarity index 100% rename from crates/ui-egui/tests/side_lists_access.rs rename to crates/ui-egui/tests/it/side_lists_access.rs diff --git a/crates/ui-egui/tests/toolbar_tempo.rs b/crates/ui-egui/tests/it/toolbar_tempo.rs similarity index 100% rename from crates/ui-egui/tests/toolbar_tempo.rs rename to crates/ui-egui/tests/it/toolbar_tempo.rs diff --git a/crates/ui-egui/tests/track_height.rs b/crates/ui-egui/tests/it/track_height.rs similarity index 100% rename from crates/ui-egui/tests/track_height.rs rename to crates/ui-egui/tests/it/track_height.rs diff --git a/crates/ui-egui/tests/track_list.rs b/crates/ui-egui/tests/it/track_list.rs similarity index 100% rename from crates/ui-egui/tests/track_list.rs rename to crates/ui-egui/tests/it/track_list.rs diff --git a/crates/ui-egui/tests/track_name_menu.rs b/crates/ui-egui/tests/it/track_name_menu.rs similarity index 100% rename from crates/ui-egui/tests/track_name_menu.rs rename to crates/ui-egui/tests/it/track_name_menu.rs diff --git a/crates/ui-egui/tests/trackpad.rs b/crates/ui-egui/tests/it/trackpad.rs similarity index 100% rename from crates/ui-egui/tests/trackpad.rs rename to crates/ui-egui/tests/it/trackpad.rs diff --git a/crates/ui-egui/tests/tracks_scroll_bar.rs b/crates/ui-egui/tests/it/tracks_scroll_bar.rs similarity index 100% rename from crates/ui-egui/tests/tracks_scroll_bar.rs rename to crates/ui-egui/tests/it/tracks_scroll_bar.rs diff --git a/crates/ui-egui/tests/waveform.rs b/crates/ui-egui/tests/it/waveform.rs similarity index 100% rename from crates/ui-egui/tests/waveform.rs rename to crates/ui-egui/tests/it/waveform.rs diff --git a/crates/ui-egui/tests/window_shortcuts.rs b/crates/ui-egui/tests/it/window_shortcuts.rs similarity index 100% rename from crates/ui-egui/tests/window_shortcuts.rs rename to crates/ui-egui/tests/it/window_shortcuts.rs diff --git a/crates/ui-egui/tests/zoom_toggle.rs b/crates/ui-egui/tests/it/zoom_toggle.rs similarity index 100% rename from crates/ui-egui/tests/zoom_toggle.rs rename to crates/ui-egui/tests/it/zoom_toggle.rs diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/it/fixture_e2e.rs similarity index 99% rename from crates/vst3-host/tests/fixture_e2e.rs rename to crates/vst3-host/tests/it/fixture_e2e.rs index eb9037f5..4f913231 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/it/fixture_e2e.rs @@ -4,8 +4,7 @@ use soundcraft_dsp::{Category, Taper, Unit}; -mod common; -use common::fixture_dir; +use crate::common::fixture_dir; fn id_of(name: &str) -> String { let found = soundcraft_vst3_host::scan_paths(&[fixture_dir().to_path_buf()]); diff --git a/crates/vst3-host/tests/it/main.rs b/crates/vst3-host/tests/it/main.rs new file mode 100644 index 00000000..ce956746 --- /dev/null +++ b/crates/vst3-host/tests/it/main.rs @@ -0,0 +1,6 @@ +//! Integration tests, built as one test binary: each file is a module. + +#[path = "../common/mod.rs"] +mod common; + +mod fixture_e2e; diff --git a/docs/mcp.md b/docs/mcp.md index 558852c1..2a08e879 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -107,6 +107,6 @@ Resources: `soundcraft://session` (full session JSON), `soundcraft://commands`. - Tracks by name or id; clips by id. - Every command is undoable through `edit.undo` unless it is a query or a view setting. -The acceptance test `crates/automation/tests/agent_tasks.rs` drives a realistic session end to end +The acceptance test `crates/automation/tests/it/agent_tasks.rs` drives a realistic session end to end through MCP only (mixing, plugins, routing, editing with undo, markers, tempo, automation, bounce, save and reopen). diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 8727c05f..c2da6561 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,18 +1,22 @@ -//! `cargo xtask ` — repository gates. +//! `cargo xtask ` — repository gates. mod ico; mod version; +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode}; +use std::process::{Command, ExitCode, Stdio}; fn root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from(".")) } +fn cargo_bin() -> String { + std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()) +} + fn cargo(args: &[&str]) -> Result<(), String> { - let st = - Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())).args(args).current_dir(root()).status().map_err(|e| e.to_string())?; + let st = Command::new(cargo_bin()).args(args).current_dir(root()).status().map_err(|e| e.to_string())?; if st.success() { Ok(()) } else { Err(format!("cargo {} failed", args.join(" "))) } } @@ -132,7 +136,7 @@ const WASM: &[&str] = &[ ]; fn wasm() -> Result<(), String> { - let mut args = vec!["check", "--target", "wasm32-unknown-unknown"]; + let mut args = vec!["check", "--target", "wasm32-unknown-unknown", "--keep-going"]; for c in WASM { args.push("-p"); args.push(c); @@ -144,11 +148,83 @@ fn wasm() -> Result<(), String> { cargo(&args) } +/// Test targets for the test build. Examples are left out so they aren't linked; clippy +/// `--all-targets` still compiles them. +const TEST_TARGETS: [&str; 4] = ["--workspace", "--lib", "--bins", "--tests"]; + +fn nextest_available() -> bool { + Command::new(cargo_bin()).args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success()) +} + +/// `[[test]]` targets with `harness = false`, as (package, target). Their own `main` cannot answer +/// nextest's `--list`, so they run under `cargo test`. +fn own_main_tests() -> Result, String> { + let out = + Command::new(cargo_bin()).args(["metadata", "--format-version", "1", "--no-deps"]).current_dir(root()).output().map_err(|e| e.to_string())?; + let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| e.to_string())?; + let mut found = Vec::new(); + for p in v["packages"].as_array().cloned().unwrap_or_default() { + let (Some(name), Some(manifest)) = (p["name"].as_str(), p["manifest_path"].as_str()) else { continue }; + let text = std::fs::read_to_string(manifest).map_err(|e| format!("{manifest}: {e}"))?; + found.extend(harness_false_tests(&text).into_iter().map(|t| (name.to_string(), t))); + } + Ok(found) +} + +/// Names of the `[[test]]` tables with `harness = false` in a Cargo.toml. +fn harness_false_tests(manifest: &str) -> Vec { + // Each `[[test]]` table runs up to the next table header. + let tables = manifest.split("\n[").filter(|t| t.starts_with("[test]]")); + tables + .filter_map(|table| { + let value = + |key: &str| table.lines().find_map(|l| l.split_once('=').filter(|(k, _)| k.trim() == key).map(|(_, v)| v.trim().trim_matches('"'))); + (value("harness") == Some("false")).then(|| value("name").map(str::to_string)).flatten() + }) + .collect() +} + +/// Workspace tests: cargo nextest when installed, else cargo test. Doctests run apart. +fn test() -> Result<(), String> { + if !nextest_available() { + eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)"); + return cargo(&[&["test"][..], &TEST_TARGETS[..]].concat()); + } + let own = own_main_tests()?; + let mut args = vec!["nextest", "run"]; + args.extend(TEST_TARGETS); + let skip = format!("not ({})", own.iter().map(|(p, t)| format!("binary_id(={p}::{t})")).collect::>().join(" | ")); + if !own.is_empty() { + // A binary-level filter keeps nextest from running these binaries to list their tests. + args.extend(["-E", &skip]); + } + cargo(&args)?; + if own.is_empty() { + return Ok(()); + } + let packages: BTreeSet<&str> = own.iter().map(|(p, _)| p.as_str()).collect(); + let targets: BTreeSet<&str> = own.iter().map(|(_, t)| t.as_str()).collect(); + let mut args = vec!["test"]; + for p in packages { + args.extend(["-p", p]); + } + for t in targets { + args.extend(["--test", t]); + } + cargo(&args) +} + +/// nextest does not run doctests. +fn doctest() -> Result<(), String> { + cargo(&["test", "--workspace", "--doc"]) +} + fn ci() -> Result<(), String> { let steps: Vec<(&str, Box Result<(), String>>)> = vec![ ("fmt", Box::new(|| cargo(&["fmt", "--all", "--", "--check"]))), - ("clippy", Box::new(|| cargo(&["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))), - ("test", Box::new(|| cargo(&["test", "--workspace"]))), + ("clippy", Box::new(|| cargo(&["clippy", "--workspace", "--all-targets", "--keep-going", "--", "-D", "warnings"]))), + ("test", Box::new(test)), + ("doctest", Box::new(doctest)), ("assets", Box::new(assets)), ("layers", Box::new(layers)), ("wasm", Box::new(wasm)), @@ -172,13 +248,14 @@ fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); let r = match args.first().map(String::as_str) { Some("ci") => ci(), + Some("test") => test(), Some("layers") => layers(), Some("assets") => assets(), Some("parity") => parity(), Some("wasm") => wasm(), Some("version") => version::run(args.get(1..).unwrap_or(&[])), Some("ico") => ico::run(args.get(1..).unwrap_or(&[])), - _ => Err("usage: cargo xtask ".into()), + _ => Err("usage: cargo xtask ".into()), }; match r { Ok(()) => ExitCode::SUCCESS, @@ -188,3 +265,12 @@ fn main() -> ExitCode { } } } + +#[cfg(test)] +mod tests { + #[test] + fn harness_false_tests_are_found_by_table() { + let manifest = "[package]\nname = \"x\"\n\n[[test]]\nname = \"own_main\"\nharness = false\n\n[[test]]\nname = \"plain\"\n\n[[test]]\nharness = false\nname = \"second\"\n\n[dependencies]\nharness = false\n"; + assert_eq!(super::harness_false_tests(manifest), ["own_main", "second"]); + } +} From a4d3d8541ff60f819d17333d8921968e79aecb48 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:17:28 -0400 Subject: [PATCH 29/33] CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release (#955) ci.yml is new: it calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD runs nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69. --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ .github/workflows/freebsd.yml | 11 +++++------ 2 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..2095acad --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# A newer push to a PR branch cancels its older run; every push to main runs to the end. +concurrency: + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + gate: + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers + libgl1-mesa-dev libasound2-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index d819e138..cfcae78b 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -3,20 +3,19 @@ # player falls back to its silent clock when the VM has no sound device. name: FreeBSD -# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push -# or dependency change: a drifted dependency still shows up within a day, and always before a -# release ships. PRs that change the FreeBSD packaging itself still run it. +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: - schedule: - - cron: "47 3 * * *" push: branches: [release] pull_request: paths: - - "crates/playback/**" - .github/workflows/freebsd.yml - packaging/freebsd/** - packaging/env.sh + schedule: + - cron: "47 3 * * *" workflow_dispatch: concurrency: From fe4bb3fe0cb3cf8363f243e3e4a6c607cdbd83e0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 14:53:00 -0400 Subject: [PATCH 30/33] style: replace flagged glob imports with explicit uses (SonarCloud rust:S2208); move release workflow's write permission to the job (S8233) (#988) * security: move the release workflow's write permission to the job level (SonarCloud githubactions:S8233) * style: replace wildcard imports with explicit uses (SonarCloud rust:S2208) Rewrites every glob `use` Sonar flags into explicit imports, so what each command module consumes is visible at the top of the file. * release: the git-sources gate moves into craft-common-actions (PR #78) The check is shared at release.yml@main now; keep only the reusable call. --------- Co-authored-by: Dylan Taylor --- .github/workflows/release.yml | 43 --------------------------- crates/au-host/src/lib.rs | 4 +-- crates/clap-host/src/lib.rs | 4 +-- crates/engine/src/cmd/audio_midi.rs | 6 ++-- crates/engine/src/cmd/audiosuite.rs | 7 +++-- crates/engine/src/cmd/clap.rs | 4 +-- crates/engine/src/cmd/clip.rs | 8 +++-- crates/engine/src/cmd/clip_list.rs | 8 ++--- crates/engine/src/cmd/clip_more.rs | 6 ++-- crates/engine/src/cmd/edit.rs | 12 +++++--- crates/engine/src/cmd/edit_more.rs | 8 +++-- crates/engine/src/cmd/event.rs | 6 ++-- crates/engine/src/cmd/event_more.rs | 12 ++++---- crates/engine/src/cmd/file.rs | 11 ++++--- crates/engine/src/cmd/health.rs | 6 ++-- crates/engine/src/cmd/instrument.rs | 6 ++-- crates/engine/src/cmd/midi.rs | 6 ++-- crates/engine/src/cmd/mix.rs | 8 +++-- crates/engine/src/cmd/more_util.rs | 6 ++-- crates/engine/src/cmd/options.rs | 6 ++-- crates/engine/src/cmd/plugin_state.rs | 5 ++-- crates/engine/src/cmd/query.rs | 4 +-- crates/engine/src/cmd/sampler.rs | 8 ++--- crates/engine/src/cmd/setup_more.rs | 7 ++--- crates/engine/src/cmd/surround.rs | 6 ++-- crates/engine/src/cmd/tab.rs | 10 +++---- crates/engine/src/cmd/track.rs | 11 +++---- crates/engine/src/cmd/track_more.rs | 7 ++--- crates/engine/src/cmd/transport.rs | 6 ++-- crates/engine/src/cmd/video.rs | 7 +++-- crates/engine/src/cmd/view.rs | 11 +++---- crates/engine/src/cmd/view_more.rs | 7 ++--- crates/engine/src/cmd/vst3.rs | 4 +-- crates/engine/src/cmd/zoom_toggle.rs | 13 ++++---- crates/mix/src/lib.rs | 4 ++- crates/video/src/h264/cabac_mb.rs | 2 +- crates/video/src/h264/cavlc.rs | 2 +- crates/video/src/h264/params.rs | 2 +- crates/video/src/h264/slicedec.rs | 6 ++-- crates/video/src/prores/decode.rs | 4 ++- crates/video/src/prores/encode.rs | 2 +- crates/vst3-host/src/lib.rs | 4 +-- 42 files changed, 145 insertions(+), 164 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28e47f7a..bf2e1796 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,55 +10,12 @@ on: type: string default: "" -permissions: - contents: write - concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: - git-sources: - name: Git dependencies come from storytold - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Git dependencies come from storytold - run: | - set -euo pipefail - test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } - # Source replacement, patches and path overrides in cargo config never show up - # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. - # Cargo still reads the legacy .cargo/config. - python3 - <<'PY' - import os, sys, tomllib - for path in (".cargo/config", ".cargo/config.toml"): - if not os.path.exists(path): - continue - config = tomllib.load(open(path, "rb")) - replaced = [name for name, source in config.get("source", {}).items() - if isinstance(source, dict) and "replace-with" in source] - if replaced or "patch" in config or "paths" in config: - print(f"{path} replaces, patches or overrides a source") - sys.exit(1) - PY - # Fails when Cargo.lock is out of date with the manifests. - cargo metadata --locked --format-version 1 > /dev/null - python3 - <<'PY' - import sys, tomllib - lock = tomllib.load(open("Cargo.lock", "rb")) - bad = sorted({p["source"] for p in lock.get("package", []) - if p.get("source", "").startswith("git+") - and not p["source"].startswith("git+https://github.com/storytold/")}) - if bad: - print("Git dependencies outside github.com/storytold:", *bad, sep="\n") - sys.exit(1) - PY - release: - needs: git-sources # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: diff --git a/crates/au-host/src/lib.rs b/crates/au-host/src/lib.rs index 39172c0b..3a664a4d 100644 --- a/crates/au-host/src/lib.rs +++ b/crates/au-host/src/lib.rs @@ -192,7 +192,7 @@ pub enum AuError { #[cfg(target_os = "macos")] mod api { - use super::*; + use super::{AuDescriptor, AuError, Plugin, PluginInfo}; pub fn scan() -> Vec { registry::scan() @@ -210,7 +210,7 @@ mod api { #[cfg(not(target_os = "macos"))] mod api { - use super::*; + use super::{AuDescriptor, AuError, Plugin, PluginInfo}; pub fn scan() -> Vec { Vec::new() diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs index 955b6614..a5e042a3 100644 --- a/crates/clap-host/src/lib.rs +++ b/crates/clap-host/src/lib.rs @@ -88,7 +88,7 @@ pub enum ClapError { #[cfg(not(target_arch = "wasm32"))] mod api { - use super::*; + use super::{ClapDescriptor, ClapError, Path, PathBuf, Plugin, PluginInfo, registry}; pub fn cached() -> Option> { registry::cached() @@ -118,7 +118,7 @@ mod api { #[cfg(target_arch = "wasm32")] mod api { - use super::*; + use super::{ClapDescriptor, ClapError, Path, PathBuf, Plugin, PluginInfo}; pub fn cached() -> Option> { Some(Vec::new()) diff --git a/crates/engine/src/cmd/audio_midi.rs b/crates/engine/src/cmd/audio_midi.rs index 218e3953..267f4789 100644 --- a/crates/engine/src/cmd/audio_midi.rs +++ b/crates/engine/src/cmd/audio_midi.rs @@ -1,9 +1,9 @@ //! Edit › Copy Special › Audio as MIDI: pitch-track the selected audio into MIDI notes on the //! clipboard (paste them onto a MIDI or instrument track). +use super::{CommandSpec, bad, has_range, range_param, tracks_required}; -use super::*; -use crate::{Clipboard, cmd}; -use serde_json::json; +use crate::{Clipboard, Engine, Result, cmd}; +use serde_json::{Value, json}; use soundcraft_midi::{Note, Sequence}; use soundcraft_model::{Clip, ClipId}; diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index 911ccca5..302d1eb3 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -1,8 +1,9 @@ //! AudioSuite-style offline processing of selected clips. +use super::{CommandSpec, bad, clip_ids_param, has_selection, range_param, str_param, tracks_param}; +use soundcraft_time::Range; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; use soundcraft_audio_io::{AudioBuffer, FileFormat}; use soundcraft_model::{Clip, ClipContent, TrackKind}; diff --git a/crates/engine/src/cmd/clap.rs b/crates/engine/src/cmd/clap.rs index 15ddcd74..4dfade07 100644 --- a/crates/engine/src/cmd/clap.rs +++ b/crates/engine/src/cmd/clap.rs @@ -1,9 +1,9 @@ //! Third-party CLAP plugins: discovery (`engine.clap_plugins`) and id lookup shared by the mixer //! commands. Hosting itself lives in `soundcraft-clap-host`. +use super::{CommandSpec, always}; -use super::*; use crate::cmd; -use serde_json::json; +use serde_json::{Value, json}; use soundcraft_dsp::PluginInfo; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index aff6d8cc..916c439e 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -1,8 +1,10 @@ //! Clip menu commands. +use super::{CommandSpec, always, bad, clip_ids_param, f64_or, has_range, has_selection, i64_or, position_param, str_param, track_param}; +use soundcraft_model::ClipId; +use soundcraft_time::Samples; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/clip_list.rs b/crates/engine/src/cmd/clip_list.rs index a8529881..ae9cd499 100644 --- a/crates/engine/src/cmd/clip_list.rs +++ b/crates/engine/src/cmd/clip_list.rs @@ -1,9 +1,9 @@ //! Clip List commands. +use super::{CommandSpec, bad, bool_or}; -use super::*; -use crate::cmd; -use serde_json::json; -use soundcraft_model::SourceId; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; +use soundcraft_model::{ClipId, SourceId}; use std::collections::BTreeSet; const CLEAR: &str = "clip.clear"; diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs index 59e57179..98bed8c7 100644 --- a/crates/engine/src/cmd/clip_more.rs +++ b/crates/engine/src/cmd/clip_more.rs @@ -1,10 +1,10 @@ //! More Clip menu commands: Regroup, Unloop, Clip Effects bypass/render, Conform to Tempo and //! Remove Pitch Shift. +use super::{CommandSpec, bad, clip_ids_param, has_selection, str_param}; use super::more_util::{render_stretched, replace_clip}; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; use soundcraft_model::{Clip, ClipContent, ClipId, Session, TrackId}; use soundcraft_time::Samples; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 468170ca..5536668b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,11 +1,15 @@ //! Edit menu commands. +use super::{ + CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, f64_or, has_clipboard, has_range, has_selection, + has_tracks, i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required, +}; +use soundcraft_time::Samples; use super::more_util::toggle_flag; -use super::*; use crate::edit as ops; -use crate::{Clipboard, cmd}; -use serde_json::json; -use soundcraft_model::{EditMode, Fade, FadeShape, Session}; +use crate::{Clipboard, Engine, Result, cmd}; +use serde_json::{Value, json}; +use soundcraft_model::{ClipId, EditMode, Fade, FadeShape, Session, TrackId}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs index fdd265c1..57e134aa 100644 --- a/crates/engine/src/cmd/edit_more.rs +++ b/crates/engine/src/cmd/edit_more.rs @@ -1,11 +1,13 @@ //! More Edit menu commands: Cut/Copy/Clear Special variants, Paste Special, selection helpers, //! Space Clips, Snap to, Copy/Move Selection to playlists, TCE, and automation coalesce/trim/glide. +use super::{ + CommandSpec, always, bad, clip_ids_param, has_clipboard, has_range, has_selection, position_param, range_param, str_param, tracks_required, +}; use super::more_util::{clip_db, render_stretched, replace_clip, set_clip_env, target_playlist}; -use super::*; use crate::edit as ops; -use crate::{Clipboard, TransportRequest, cmd}; -use serde_json::json; +use crate::{Clipboard, Engine, Result, TransportRequest, cmd}; +use serde_json::{Value, json}; use soundcraft_model::{AutoParam, AutomationLane, Clip, ClipContent, ClipId, EditMode, Playlist, Session}; use soundcraft_time::{Range, Samples}; diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index 80af311f..018209b6 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -1,8 +1,8 @@ //! Event menu: time/tempo operations, MIDI operations, markers. +use super::{CommandSpec, always, bad, clip_ids_param, f32_or, f64_or, has_selection, has_tracks, i64_or, position_param, range_param, str_param}; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; use soundcraft_midi::ops::{DurationOp, QuantizeOptions, VelocityOp}; use soundcraft_model::{ClipContent, MarkerKind}; use soundcraft_time::TICKS_PER_QUARTER; diff --git a/crates/engine/src/cmd/event_more.rs b/crates/engine/src/cmd/event_more.rs index 7e7d3e86..51016627 100644 --- a/crates/engine/src/cmd/event_more.rs +++ b/crates/engine/src/cmd/event_more.rs @@ -2,15 +2,18 @@ //! operations (split notes, input quantize, step input, restore/flatten performance), MIDI track //! offsets and real-time properties, chord extraction, Beat Detective, Identify Beat and //! Retrospective Record. +use super::{ + CommandSpec, always, bad, bool_or, clip_ids_param, f32_or, f64_or, has_range, has_selection, has_tracks, i64_or, position_param, range_param, + str_param, track_param, tracks_param, tracks_required, +}; use super::more_util::{store_value, track_key, values_with_prefix}; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; use soundcraft_midi::Note; use soundcraft_midi::ops::QuantizeOptions; use soundcraft_model::{ChannelFormat, Clip, ClipContent, ClipId, MarkerKind, Session, TrackId, TrackKind}; -use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER, TempoMap}; +use soundcraft_time::{MAX_POSITION, Range, Samples, TICKS_PER_QUARTER, TempoMap}; /// Most tempo events one curve may write. const MAX_TEMPO_EVENTS: i64 = 10_000; @@ -782,7 +785,6 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result { #[cfg(test)] mod tests { use super::*; - fn midi_engine() -> (Engine, TrackId) { let mut e = Engine::default(); e.execute("track.new", &json!({"kind": "midi"})).unwrap(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 87bba997..40e3d3db 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1,9 +1,12 @@ //! File menu commands. +use super::{ + CommandSpec, always, bad, bool_or, clip_ids_param, has_selection, has_tracks, i64_or, position_param, range_param, str_param, track_param, + tracks_param, tracks_required, +}; -use super::*; -use crate::cmd; -use serde_json::json; -use soundcraft_model::{Session, TrackKind}; +use crate::{Engine, EngineError, Result, cmd}; +use serde_json::{Value, json}; +use soundcraft_model::{Session, TrackId, TrackKind}; use soundcraft_time::SampleRate; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/health.rs b/crates/engine/src/cmd/health.rs index d2971e48..260503ec 100644 --- a/crates/engine/src/cmd/health.rs +++ b/crates/engine/src/cmd/health.rs @@ -1,8 +1,8 @@ //! Media availability checks without decoding files or changing the session. +use super::{CommandSpec, always}; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; use soundcraft_model::ClipContent; use std::collections::BTreeMap; diff --git a/crates/engine/src/cmd/instrument.rs b/crates/engine/src/cmd/instrument.rs index c7e51f11..2a71ba50 100644 --- a/crates/engine/src/cmd/instrument.rs +++ b/crates/engine/src/cmd/instrument.rs @@ -1,10 +1,10 @@ //! An instrument track's instrument (`mix.instrument`): the plugin that turns the track's MIDI //! into audio, built in or hosted (CLAP, VST3, Audio Units). The mixer plays `Track::instrument`; //! the instrument is otherwise only set when the track is created (`track.new`). +use super::{CommandSpec, bad, has_selection, str_param, tracks_required}; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; use soundcraft_model::{Insert, TrackKind}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index 129fd070..1d2584e4 100644 --- a/crates/engine/src/cmd/midi.rs +++ b/crates/engine/src/cmd/midi.rs @@ -1,8 +1,8 @@ //! MIDI note editing (MIDI Editor, pencil on MIDI tracks). +use super::{CommandSpec, always, bad, clip_id, has_tracks, i64_or, range_param, tracks_required}; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; use soundcraft_midi::{Note, Sequence}; use soundcraft_model::{Clip, ClipContent, ClipId}; use soundcraft_time::TICKS_PER_QUARTER; diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 7502b4f2..8fb5f1f4 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -1,8 +1,10 @@ //! Mixer, insert, send and automation commands. +use super::{ + CommandSpec, always, bad, bool_or, f32_or, has_range, has_selection, has_tracks, i64_or, position_param, range_param, str_param, tracks_required, +}; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, EngineError, Result, cmd}; +use serde_json::{Value, json}; use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/more_util.rs b/crates/engine/src/cmd/more_util.rs index bf169821..3d68b6c0 100644 --- a/crates/engine/src/cmd/more_util.rs +++ b/crates/engine/src/cmd/more_util.rs @@ -1,8 +1,9 @@ //! Helpers shared by the newer command modules: settings kept in `EditState.flags`/`values`, //! per-track settings, target playlists, clip-gain envelopes and stretch rendering. +use super::bad; +use crate::{Engine, Result}; -use super::*; -use serde_json::json; +use serde_json::{Value, json}; use soundcraft_audio_io::{AudioBuffer, FileFormat}; use soundcraft_model::{Clip, ClipContent, EditState, Session, SourceId, TrackId}; use soundcraft_time::Samples; @@ -187,7 +188,6 @@ pub fn replace_clip(s: &mut Session, t: TrackId, old: soundcraft_model::ClipId, #[cfg(test)] mod tests { use super::*; - #[test] fn text_settings_round_trip() { let mut ed = EditState::default(); diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index 47356f54..45c4b63a 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -1,8 +1,8 @@ //! Options menu toggles and Setup › Session settings. +use super::{CommandSpec, always, bad, bool_or, position_param, str_param}; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; use soundcraft_model::{BitDepthSetting, EditState}; use soundcraft_time::FrameRate; diff --git a/crates/engine/src/cmd/plugin_state.rs b/crates/engine/src/cmd/plugin_state.rs index e999c863..bc86ff17 100644 --- a/crates/engine/src/cmd/plugin_state.rs +++ b/crates/engine/src/cmd/plugin_state.rs @@ -1,9 +1,10 @@ //! Third-party plugin state in the session (`mix.insert_state`): the UI reads each live plugin's //! state right before saving and stores it on its insert (base64), from where the mixer restores //! it into new instances. +use super::{CommandSpec, bad, has_tracks, track_param}; +use crate::{Engine, Result}; -use super::*; -use serde_json::json; +use serde_json::{Value, json}; use soundcraft_model::{INSERT_SLOTS, b64}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 72eb09ea..3aefa852 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -1,8 +1,8 @@ //! Read-only queries for agents. +use super::{CommandInfo, CommandSpec, always, bad, command_specs, has_tracks, position_param, str_param, track_param}; -use super::*; use crate::cmd; -use serde_json::json; +use serde_json::{Value, json}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/sampler.rs b/crates/engine/src/cmd/sampler.rs index fc3bf182..eb401bbd 100644 --- a/crates/engine/src/cmd/sampler.rs +++ b/crates/engine/src/cmd/sampler.rs @@ -4,11 +4,11 @@ //! The source is saved in `Audio Files/` with the session like any other audio, so a session that //! uses a sample reopens with it. Sampler parameters (root note, key range, ADSR, loop…) are set //! with `mix.instrument_param`, or all at once with `params` here. +use super::{CommandSpec, bad, has_selection, str_param, tracks_required}; -use super::*; -use crate::cmd; -use serde_json::json; -use soundcraft_model::{Insert, TrackKind}; +use crate::{Engine, EngineError, Result, cmd}; +use serde_json::{Value, json}; +use soundcraft_model::{Insert, TrackId, TrackKind}; use std::path::Path; const SAMPLER: &str = "sampler"; diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index aee13ba1..369e1542 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -1,10 +1,10 @@ //! More Options toggles, the Setup menu (hardware, playback engine, I/O, timecode, click, //! shortcuts, MIDI setup) and File › Import › Session Data. +use super::{CommandSpec, always, bad, command_specs, i64_or, str_param, track_param, tracks_param}; use super::more_util::{set_text, store_value, text, toggle_flag, values_with_prefix}; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, EngineError, Result, cmd}; +use serde_json::{Value, json}; use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind}; use soundcraft_time::Timecode; use std::path::Path; @@ -503,7 +503,6 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result { #[cfg(test)] mod tests { use super::*; - #[test] fn option_flags_toggle() { let mut e = Engine::default(); diff --git a/crates/engine/src/cmd/surround.rs b/crates/engine/src/cmd/surround.rs index bd23be54..6d6f2138 100644 --- a/crates/engine/src/cmd/surround.rs +++ b/crates/engine/src/cmd/surround.rs @@ -1,8 +1,8 @@ //! Surround mixing: the main output format and the surround panners of tracks and sends. +use super::{CommandSpec, always, bad, bool_or, f32_or, has_selection, str_param, tracks_required}; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; use soundcraft_model::{ChannelFormat, Mixer, OutputPath, Route, SEND_SLOTS, Session, SurroundPan, TrackKind}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/tab.rs b/crates/engine/src/cmd/tab.rs index af4c783e..432a6386 100644 --- a/crates/engine/src/cmd/tab.rs +++ b/crates/engine/src/cmd/tab.rs @@ -1,10 +1,10 @@ //! Tab and Tab to Transient: the insertion point or selection end to the next or previous clip boundary, transient or note. +use super::{CommandSpec, always, bool_or}; -use super::*; -use crate::{TransportRequest, cmd}; -use serde_json::json; -use soundcraft_model::{Clip, ClipContent, Session, SourceAudio}; -use soundcraft_time::SampleRate; +use crate::{Engine, Result, TransportRequest, cmd}; +use serde_json::{Value, json}; +use soundcraft_model::{Clip, ClipContent, Session, SourceAudio, TrackId}; +use soundcraft_time::{Range, SampleRate, Samples}; use std::sync::{Arc, Mutex, PoisonError, Weak}; /// Transients are found in fixed blocks of each clip, so a long clip costs a block per press and diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 4929b468..f76664a0 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -1,9 +1,11 @@ //! Track menu commands. +use super::{ + CommandSpec, always, bad, bool_or, has_selection, has_tracks, i64_or, range_param, str_param, track_param, tracks_param, tracks_required, +}; -use super::*; -use crate::cmd; -use serde_json::json; -use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackKind}; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; +use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind}; pub fn specs() -> Vec { vec![ @@ -944,7 +946,6 @@ fn route(e: &mut Engine, p: &Value, input: bool) -> Result { #[cfg(test)] mod tests { use super::*; - #[test] fn comp_from_alternate_playlist() { let mut e = crate::demo::demo_engine(); diff --git a/crates/engine/src/cmd/track_more.rs b/crates/engine/src/cmd/track_more.rs index 9f547597..99255344 100644 --- a/crates/engine/src/cmd/track_more.rs +++ b/crates/engine/src/cmd/track_more.rs @@ -1,10 +1,10 @@ //! More Track menu commands: Convert Aux to Routing Folder, Extract MIDI, track presets, insert / //! send bypass subsets, MIDI real-time properties, trim/VCA coalescing and target playlists. +use super::{CommandSpec, bad, has_selection, has_tracks, range_param, str_param, tracks_param, tracks_required}; use super::more_util::{activate_playlist, target_playlist, track_key}; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, EngineError, Result, cmd}; +use serde_json::{Value, json}; use soundcraft_dsp::Category; use soundcraft_model::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipContent, Insert, Route, Session, Track, TrackId, TrackKind}; use soundcraft_time::Samples; @@ -547,7 +547,6 @@ fn toggle_recent(e: &mut Engine, p: &Value) -> Result { #[cfg(test)] mod tests { use super::*; - #[test] fn bypass_by_category_and_range() { let mut e = crate::demo::demo_engine(); diff --git a/crates/engine/src/cmd/transport.rs b/crates/engine/src/cmd/transport.rs index 5fbd48c0..50827802 100644 --- a/crates/engine/src/cmd/transport.rs +++ b/crates/engine/src/cmd/transport.rs @@ -1,9 +1,9 @@ //! Transport commands. They update the session's stopped playhead and queue requests for the //! host's audio engine (which may be absent when headless). +use super::{CommandSpec, always, bad, f64_or, position_param}; -use super::*; -use crate::{TransportRequest, cmd}; -use serde_json::json; +use crate::{Engine, Result, TransportRequest, cmd}; +use serde_json::{Value, json}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/video.rs b/crates/engine/src/cmd/video.rs index c65e40d6..b3a6402a 100644 --- a/crates/engine/src/cmd/video.rs +++ b/crates/engine/src/cmd/video.rs @@ -2,10 +2,11 @@ //! //! Movies are imported with `file.import_video`; the session only references them //! (`Session::videos`). Decoding happens in the UI (`soundcraft-video`), never in the engine. +use super::{CommandSpec, always, bad, position_param, track_param}; +use soundcraft_time::MAX_POSITION; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; use soundcraft_model::{ClipContent, TrackKind}; /// `edit.flags` key set while the Video track is offline (online is the default). diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 6dee816c..b214a81f 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -1,9 +1,10 @@ //! View menu commands that change saved view state (zoom, rulers, counters, grid/nudge). +use super::{CommandSpec, always, bad, bool_or, f32_or, f64_or, i64_or, position_param, str_param}; -use super::*; -use crate::cmd; -use serde_json::json; -use soundcraft_time::{GridValue, NoteValue, TimeFormat}; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; +use soundcraft_time::TimeFormat; +use soundcraft_time::{GridValue, MAX_POSITION, NoteValue}; pub fn specs() -> Vec { vec![ @@ -160,7 +161,7 @@ pub fn grid_param(p: &Value, key: &str) -> Option { #[cfg(test)] mod tests { use super::*; - + use soundcraft_time::Samples; #[test] fn grid_values_parse() { assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false })); diff --git a/crates/engine/src/cmd/view_more.rs b/crates/engine/src/cmd/view_more.rs index 79997ed2..e5bd9313 100644 --- a/crates/engine/src/cmd/view_more.rs +++ b/crates/engine/src/cmd/view_more.rs @@ -1,11 +1,11 @@ //! More View menu commands: Edit-window column views, extra rulers, marker displays, clip //! display options, waveform styles, expanded sends, transport views and other view toggles. //! Every setting is saved with the session in `edit.flags` (or `edit.rulers` for rulers). +use super::{CommandSpec, always, str_param}; use super::more_util::{exclusive_flag, set_flags, toggle_flag}; -use super::*; -use crate::cmd; -use serde_json::json; +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; /// A view toggle: `{value?: bool}` sets it, otherwise it flips. macro_rules! flag { @@ -219,7 +219,6 @@ fn toggle_ruler(e: &mut Engine, p: &Value, id: &str) -> Result { #[cfg(test)] mod tests { use super::*; - #[test] fn flags_toggle_and_persist() { let mut e = Engine::default(); diff --git a/crates/engine/src/cmd/vst3.rs b/crates/engine/src/cmd/vst3.rs index 927bb433..338c466c 100644 --- a/crates/engine/src/cmd/vst3.rs +++ b/crates/engine/src/cmd/vst3.rs @@ -1,10 +1,10 @@ //! Third-party VST3 plugins: discovery (`engine.vst3_plugins`). Hosting itself lives in //! `soundcraft-vst3-host`; id lookup for the mixer commands is shared with CLAP in //! `cmd::clap::plugin_info`. +use super::{CommandSpec, always}; -use super::*; use crate::cmd; -use serde_json::json; +use serde_json::{Value, json}; pub fn specs() -> Vec { vec![cmd!( diff --git a/crates/engine/src/cmd/zoom_toggle.rs b/crates/engine/src/cmd/zoom_toggle.rs index d91d515e..24781759 100644 --- a/crates/engine/src/cmd/zoom_toggle.rs +++ b/crates/engine/src/cmd/zoom_toggle.rs @@ -1,9 +1,10 @@ //! Zoom Toggle: zoom in on the edit selection, then back out to the view before. - -use super::*; -use crate::cmd; -use serde_json::json; +use super::{CommandSpec, always, f64_or}; use soundcraft_model::{TrackHeight, ZoomToggle}; +use soundcraft_time::MAX_POSITION; + +use crate::{Engine, Result, cmd}; +use serde_json::{Value, json}; pub fn specs() -> Vec { vec![cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle)] @@ -37,8 +38,10 @@ fn toggle(e: &mut Engine, p: &Value) -> Result { #[cfg(test)] mod tests { + use super::TrackHeight::{Fit, Large, Medium}; use super::*; - use TrackHeight::{Fit, Large, Medium}; + use soundcraft_model::TrackId; + use soundcraft_time::Samples; fn engine(select: Value) -> Engine { let mut e = Engine::default(); diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index c04559af..f73f0a71 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -1360,7 +1360,9 @@ fn receives_solo(s: &Session, t: &Track) -> bool { /// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of /// each bar. Settings come from `setup.click_countoff` (`click.*` values). mod click { - use super::*; + use super::db_to_gain; + use soundcraft_model::Session; + use soundcraft_time::Samples; /// Blip length in seconds. const LEN_S: f64 = 0.03; diff --git a/crates/video/src/h264/cabac_mb.rs b/crates/video/src/h264/cabac_mb.rs index 4a3125e5..76c9c9a5 100644 --- a/crates/video/src/h264/cabac_mb.rs +++ b/crates/video/src/h264/cabac_mb.rs @@ -3,7 +3,7 @@ use crate::h264::cabac::Cabac; use crate::h264::error::{Result, ensure, invalid}; -use crate::h264::mbtypes::*; +use crate::h264::mbtypes::{MbTypeInfo, Part, SubMbInfo, b_mb_type, b_sub_mb_type, intra_mb_type, p_mb_type, p_sub_mb_type}; use crate::h264::picture::MbKind; use crate::h264::slice::SliceType; use crate::h264::slicedec::{MbCur, SliceDecoder}; diff --git a/crates/video/src/h264/cavlc.rs b/crates/video/src/h264/cavlc.rs index 2126a451..9611868e 100644 --- a/crates/video/src/h264/cavlc.rs +++ b/crates/video/src/h264/cavlc.rs @@ -1,7 +1,7 @@ //! CAVLC residual block parsing (9.2). use crate::bitstream::BitReader; -use crate::h264::cavlc_tables::*; +use crate::h264::cavlc_tables::{COEFF_TOKEN, RUN_BEFORE, TOTAL_ZEROS_2X2, TOTAL_ZEROS_2X4, TOTAL_ZEROS_4X4}; use crate::h264::error::{Result, ensure}; use std::sync::OnceLock; diff --git a/crates/video/src/h264/params.rs b/crates/video/src/h264/params.rs index 583c96bd..99232ac7 100644 --- a/crates/video/src/h264/params.rs +++ b/crates/video/src/h264/params.rs @@ -2,7 +2,7 @@ use crate::bitstream::BitReader; use crate::h264::error::{Error, Result, ensure, unsupported}; -use crate::h264::tables::*; +use crate::h264::tables::{DEFAULT_4X4_INTER, DEFAULT_4X4_INTRA, DEFAULT_8X8_INTER, DEFAULT_8X8_INTRA, ZIGZAG4, ZIGZAG8}; /// `v + k` for a coded `*_minus*` field, or the error its range check gives. fn plus(v: u32, k: u32, err: &str) -> Result { diff --git a/crates/video/src/h264/slicedec.rs b/crates/video/src/h264/slicedec.rs index a71a6b4e..dfab85b5 100644 --- a/crates/video/src/h264/slicedec.rs +++ b/crates/video/src/h264/slicedec.rs @@ -6,11 +6,11 @@ use crate::h264::deblock; use crate::h264::error::{Result, ensure, invalid}; use crate::h264::inter::{self, Weight}; use crate::h264::intra::{self, Avail}; -use crate::h264::mbtypes::*; +use crate::h264::mbtypes::{CBP_ME, MbTypeInfo, Part, SubMbInfo, SubPart, b_mb_type, b_sub_mb_type, intra_mb_type, p_mb_type, p_sub_mb_type}; use crate::h264::params::{Pps, Sps}; -use crate::h264::picture::*; +use crate::h264::picture::{Frame, FrameRef, MbKind, MbState, Planes, RefPic}; use crate::h264::slice::{SliceHeader, SliceType}; -use crate::h264::tables::*; +use crate::h264::tables::{BLK4_XY, QPC_TABLE, RASTER_TO_BLK4, ZIGZAG4, ZIGZAG8}; use crate::h264::transform::{self, LevelScale}; /// Per-slice data kept with the picture (deblocking, motion field export). diff --git a/crates/video/src/prores/decode.rs b/crates/video/src/prores/decode.rs index 305a5b1b..fcb8b07f 100644 --- a/crates/video/src/prores/decode.rs +++ b/crates/video/src/prores/decode.rs @@ -3,7 +3,9 @@ use crate::prores::bits::Reader; use crate::prores::dct::idct8x8; use crate::prores::header::{FrameHeader, PictureHeader, row_slice_widths}; -use crate::prores::tables::*; +use crate::prores::tables::{ + DC_CB, DC_CTX_INIT, FIRST_DC_CB, LEVEL_CB, LEVEL_CTX_INIT, RUN_CB, RUN_CTX_INIT, SCAN_INTERLACED, SCAN_PROGRESSIVE, qscale, +}; use crate::prores::{AlphaType, ChromaFormat, Error, Frame, Interlace, Result}; /// Decoder options. diff --git a/crates/video/src/prores/encode.rs b/crates/video/src/prores/encode.rs index 3e5a1f4e..fd4d931a 100644 --- a/crates/video/src/prores/encode.rs +++ b/crates/video/src/prores/encode.rs @@ -3,7 +3,7 @@ use crate::prores::bits::{Counter, Sink, Writer}; use crate::prores::dct::fdct8x8; use crate::prores::header::{FRAME_ID, FrameHeader, PictureHeader, row_slice_widths}; -use crate::prores::tables::*; +use crate::prores::tables::{DC_CB, DC_CTX_INIT, FIRST_DC_CB, LEVEL_CB, LEVEL_CTX_INIT, RUN_CB, RUN_CTX_INIT, SCAN_PROGRESSIVE, qscale}; use crate::prores::{AlphaType, ChromaFormat, ColorInfo, Error, Frame, Interlace, Profile, Result}; /// Encoder configuration. diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs index b78e932d..9c76446c 100644 --- a/crates/vst3-host/src/lib.rs +++ b/crates/vst3-host/src/lib.rs @@ -114,7 +114,7 @@ pub enum Vst3Error { #[cfg(not(target_arch = "wasm32"))] mod api { - use super::*; + use super::{Path, PathBuf, Plugin, PluginInfo, Vst3Descriptor, Vst3Error, ffi, registry}; pub fn cached() -> Option> { registry::cached() @@ -147,7 +147,7 @@ mod api { #[cfg(target_arch = "wasm32")] mod api { - use super::*; + use super::{Path, PathBuf, Plugin, PluginInfo, Vst3Descriptor, Vst3Error}; pub fn cached() -> Option> { Some(Vec::new()) From 67865b23e8033cf8590d193bda7f9a4e76d2f46d Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:41:42 -0400 Subject: [PATCH 31/33] Build: use craft-media for bitstream and isobmff (#776) * Build: use craft-media for bitstream and isobmff soundcraft-video re-exports craft_media::{bitstream, isobmff} from craft-libs instead of carrying a copy of FilmCraft's crates. The module paths stay, so crate::bitstream and crate::isobmff resolve as before. The shared copy is newer: it adds APV, .mp2 as MPEG audio, AAC channel configurations 11-14, SampleEntry::av1/flac, Mp4Writer::set_sample_entry and the se(v) fix for i32::MIN. * Build: pin craft-media to craft-libs f0ad6c1e --- Cargo.lock | 9 + Cargo.toml | 3 + crates/video/Cargo.toml | 1 + crates/video/src/bitstream.rs | 475 --------- crates/video/src/isobmff/bytes.rs | 361 ------- crates/video/src/isobmff/codec.rs | 1579 ---------------------------- crates/video/src/isobmff/demux.rs | 1173 --------------------- crates/video/src/isobmff/error.rs | 38 - crates/video/src/isobmff/mod.rs | 26 - crates/video/src/isobmff/mux.rs | 966 ----------------- crates/video/src/isobmff/source.rs | 123 --- crates/video/src/lib.rs | 18 +- 12 files changed, 22 insertions(+), 4750 deletions(-) delete mode 100644 crates/video/src/bitstream.rs delete mode 100644 crates/video/src/isobmff/bytes.rs delete mode 100644 crates/video/src/isobmff/codec.rs delete mode 100644 crates/video/src/isobmff/demux.rs delete mode 100644 crates/video/src/isobmff/error.rs delete mode 100644 crates/video/src/isobmff/mod.rs delete mode 100644 crates/video/src/isobmff/mux.rs delete mode 100644 crates/video/src/isobmff/source.rs diff --git a/Cargo.lock b/Cargo.lock index f36f359d..6158b44b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -906,6 +906,14 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]] +name = "craft-media" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -3798,6 +3806,7 @@ dependencies = [ name = "soundcraft-video" version = "0.12.1" dependencies = [ + "craft-media", "image", "proptest", "rayon", diff --git a/Cargo.toml b/Cargo.toml index d2fe3187..f5637624 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,9 @@ cpal = "0.18" # Which file paths stored in a session may be opened. craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +# Video containers and bitstreams, shared with the other craft apps. +craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" } + # ui craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } # egui 0.37 needs craft-libs#199 (craft-ui) first; pin 0.36 until it lands. diff --git a/crates/video/Cargo.toml b/crates/video/Cargo.toml index d8165ab2..3289b021 100644 --- a/crates/video/Cargo.toml +++ b/crates/video/Cargo.toml @@ -14,6 +14,7 @@ default = [] threads = ["dep:rayon"] [dependencies] +craft-media.workspace = true thiserror.workspace = true serde.workspace = true image = { workspace = true, features = ["jpeg"] } diff --git a/crates/video/src/bitstream.rs b/crates/video/src/bitstream.rs deleted file mode 100644 index b80e702a..00000000 --- a/crates/video/src/bitstream.rs +++ /dev/null @@ -1,475 +0,0 @@ -//! Bit-level reading and writing shared by FilmCraft's codec crates. -//! -//! - [`BitReader`]: MSB-first reader with Exp-Golomb (`ue(v)`, `se(v)`), as used by H.264/HEVC/AV1 headers. -//! - [`BitWriter`]: the matching writer (for encoders and header rewriting). -//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes. -//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units. - -use std::fmt; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BitError { - /// Tried to read past the end of the buffer. - Eof, - /// An Exp-Golomb code longer than 32 leading zeros. - InvalidExpGolomb, - /// A NAL length-prefix width outside the AVCC/HVCC range of 1..=4 bytes. - InvalidLengthSize, -} - -impl fmt::Display for BitError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - BitError::Eof => f.write_str("unexpected end of bitstream"), - BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"), - BitError::InvalidLengthSize => f.write_str("invalid NAL length size"), - } - } -} -impl std::error::Error for BitError {} - -pub type Result = std::result::Result; - -/// MSB-first bit reader over a byte slice. -#[derive(Clone)] -pub struct BitReader<'a> { - data: &'a [u8], - /// Position in bits. - pos: usize, -} - -impl<'a> BitReader<'a> { - pub fn new(data: &'a [u8]) -> Self { - Self { data, pos: 0 } - } - pub fn data(&self) -> &'a [u8] { - self.data - } - /// Bit position from the start. - pub fn position(&self) -> usize { - self.pos - } - pub fn bits_left(&self) -> usize { - (self.data.len() * 8).saturating_sub(self.pos) - } - pub fn is_byte_aligned(&self) -> bool { - self.pos.is_multiple_of(8) - } - pub fn byte_align(&mut self) { - self.pos = self.pos.div_ceil(8) * 8; - } - /// Byte offset of the current position (rounded down). - pub fn byte_pos(&self) -> usize { - self.pos / 8 - } - pub fn seek_bits(&mut self, pos: usize) { - self.pos = pos; - } - - /// Peek up to 32 bits without consuming (zero-padded past the end). - #[inline] - pub fn peek(&self, n: u32) -> u32 { - debug_assert!(n <= 32); - if n == 0 { - return 0; - } - let byte = self.pos / 8; - let shift = self.pos % 8; - let mut v: u64 = 0; - for i in 0..5 { - v = (v << 8) | *self.data.get(byte + i).unwrap_or(&0) as u64; - } - // v holds 40 bits starting at `byte` - ((v << (24 + shift)) >> (64 - n)) as u32 - } - - #[inline] - pub fn read_bits(&mut self, n: u32) -> Result { - if n as usize > self.bits_left() { - return Err(BitError::Eof); - } - let v = self.peek(n); - self.pos += n as usize; - Ok(v) - } - - pub fn read_bits_u64(&mut self, n: u32) -> Result { - if n <= 32 { - return self.read_bits(n).map(u64::from); - } - let hi = self.read_bits(n - 32)? as u64; - let lo = self.read_bits(32)? as u64; - Ok((hi << 32) | lo) - } - - #[inline] - pub fn read_bit(&mut self) -> Result { - self.read_bits(1).map(|b| b == 1) - } - pub fn read_flag(&mut self) -> Result { - self.read_bit() - } - - pub fn skip(&mut self, n: usize) -> Result<()> { - if n > self.bits_left() { - return Err(BitError::Eof); - } - self.pos += n; - Ok(()) - } - - /// Unsigned Exp-Golomb `ue(v)`. - pub fn read_ue(&mut self) -> Result { - let mut zeros = 0u32; - while !self.read_bit()? { - zeros += 1; - if zeros > 32 { - return Err(BitError::InvalidExpGolomb); - } - } - if zeros == 0 { - return Ok(0); - } - let v = self.read_bits_u64(zeros)?; - let r = (1u64 << zeros) - 1 + v; - u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb) - } - - /// Signed Exp-Golomb `se(v)`. - pub fn read_se(&mut self) -> Result { - let k = self.read_ue()? as i64; - Ok(if k & 1 == 1 { ((k + 1) / 2) as i32 } else { -((k / 2) as i32) }) - } - - /// Truncated Exp-Golomb `te(v)` with range `max`. - pub fn read_te(&mut self, max: u32) -> Result { - if max > 1 { self.read_ue() } else { Ok(!self.read_bit()? as u32) } - } - - /// H.264/HEVC `more_rbsp_data()`: true if there is data before the rbsp trailing bits. - pub fn more_rbsp_data(&self) -> bool { - let total = self.data.len() * 8; - if self.pos >= total { - return false; - } - // find last set bit (the rbsp_stop_one_bit) - let mut last = None; - for (i, &b) in self.data.iter().enumerate().rev() { - if b != 0 { - last = Some(i * 8 + 7 - b.trailing_zeros() as usize); - break; - } - } - match last { - Some(stop) => self.pos < stop, - None => false, - } - } -} - -/// MSB-first bit writer. -#[derive(Default, Clone)] -pub struct BitWriter { - buf: Vec, - acc: u64, - nbits: u32, -} - -impl BitWriter { - pub fn new() -> Self { - Self::default() - } - pub fn write_bits(&mut self, value: u32, n: u32) { - debug_assert!(n <= 32); - if n == 0 { - return; - } - let v = (value as u64) & ((1u64 << n) - 1); - self.acc = (self.acc << n) | v; - self.nbits += n; - while self.nbits >= 8 { - self.nbits -= 8; - self.buf.push((self.acc >> self.nbits) as u8); - } - self.acc &= (1u64 << self.nbits) - 1; - } - pub fn write_bit(&mut self, b: bool) { - self.write_bits(b as u32, 1); - } - pub fn write_ue(&mut self, v: u32) { - let x = v as u64 + 1; - let len = 64 - x.leading_zeros(); - let zeros = len - 1; - for _ in 0..zeros { - self.write_bit(false); - } - if len > 32 { - self.write_bits((x >> 32) as u32, len - 32); - self.write_bits(x as u32, 32); - } else { - self.write_bits(x as u32, len); - } - } - pub fn write_se(&mut self, v: i32) { - let k = if v > 0 { (v as u32) * 2 - 1 } else { (-(v as i64) as u32) * 2 }; - self.write_ue(k); - } - pub fn bit_len(&self) -> usize { - self.buf.len() * 8 + self.nbits as usize - } - pub fn is_byte_aligned(&self) -> bool { - self.nbits == 0 - } - /// Pad with zero bits to a byte boundary. - pub fn align_zero(&mut self) { - if self.nbits > 0 { - self.write_bits(0, 8 - self.nbits); - } - } - /// `rbsp_trailing_bits()`: a one bit then zeros to alignment. - pub fn rbsp_trailing(&mut self) { - self.write_bit(true); - self.align_zero(); - } - pub fn write_bytes(&mut self, bytes: &[u8]) { - if self.nbits == 0 { - self.buf.extend_from_slice(bytes); - } else { - for &b in bytes { - self.write_bits(b as u32, 8); - } - } - } - pub fn finish(mut self) -> Vec { - self.align_zero(); - self.buf - } - pub fn bytes(&self) -> &[u8] { - &self.buf - } -} - -/// Remove emulation-prevention bytes (`00 00 03` → `00 00`) from a NAL payload. -pub fn unescape_rbsp(data: &[u8]) -> Vec { - let mut out = Vec::with_capacity(data.len()); - let mut zeros = 0; - for &b in data { - if zeros >= 2 && b == 3 { - zeros = 0; - continue; - } - out.push(b); - zeros = if b == 0 { zeros + 1 } else { 0 }; - } - out -} - -/// Insert emulation-prevention bytes so no `00 00 0x` (x ≤ 3) sequence appears. -pub fn escape_rbsp(data: &[u8]) -> Vec { - let mut out = Vec::with_capacity(data.len() + data.len() / 64); - let mut zeros = 0; - for &b in data { - if zeros >= 2 && b <= 3 { - out.push(3); - zeros = 0; - } - out.push(b); - zeros = if b == 0 { zeros + 1 } else { 0 }; - } - out -} - -/// Split an Annex-B byte stream (start codes `00 00 01` / `00 00 00 01`) into NAL units (escaped payloads). -pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> { - let mut starts = Vec::new(); - let mut i = 0; - while i + 3 <= data.len() { - if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 { - starts.push(i + 3); - i += 3; - } else { - i += 1; - } - } - let mut out = Vec::with_capacity(starts.len()); - for (k, &s) in starts.iter().enumerate() { - let mut e = if k + 1 < starts.len() { starts[k + 1] - 3 } else { data.len() }; - // trim trailing zero bytes (part of a 4-byte start code or trailing_zero_8bits) - while e > s && data[e - 1] == 0 { - e -= 1; - } - if e > s { - out.push(&data[s..e]); - } - } - out -} - -/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1..=4 bytes) into NAL units. -/// -/// The width comes from the stream's configuration record (`(byte & 3) + 1`), so anything -/// outside 1..=4 is a programming or parsing error and is rejected up front: a width of 0 -/// would never advance the cursor, looping forever and appending empty NALs until the -/// process runs out of memory, and a wider prefix cannot occur in a real sample. -pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result> { - if !(1..=4).contains(&length_size) { - return Err(BitError::InvalidLengthSize); - } - let mut out = Vec::new(); - let mut i = 0; - while i < data.len() { - let Some(start) = i.checked_add(length_size) else { - return Err(BitError::Eof); - }; - if start > data.len() { - return Err(BitError::Eof); - } - let mut len = 0usize; - for &b in &data[i..start] { - len = (len << 8) | b as usize; - } - let Some(end) = start.checked_add(len) else { - return Err(BitError::Eof); - }; - if end > data.len() { - return Err(BitError::Eof); - } - out.push(&data[start..end]); - i = end; - } - Ok(out) -} - -/// CRC-32 (IEEE, reflected) — used by containers (e.g. Matroska CRC-32 elements, PNG). -pub fn crc32(data: &[u8]) -> u32 { - let mut crc = 0xFFFF_FFFFu32; - for &b in data { - crc ^= b as u32; - for _ in 0..8 { - crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; - } - } - !crc -} - -#[cfg(test)] -mod tests { - use super::*; - use proptest::prelude::*; - - #[test] - fn reads_bits() { - let d = [0b1010_1100, 0b0101_0011]; - let mut r = BitReader::new(&d); - assert_eq!(r.read_bits(1).unwrap(), 1); - assert_eq!(r.read_bits(3).unwrap(), 0b010); - assert_eq!(r.read_bits(8).unwrap(), 0b1100_0101); - assert_eq!(r.bits_left(), 4); - assert_eq!(r.read_bits(5), Err(BitError::Eof)); - } - - #[test] - fn exp_golomb_known() { - // 1 | 010 | 011 | 00100 | 00101 -> 0,1,2,3,4 - let mut w = BitWriter::new(); - for v in 0..5 { - w.write_ue(v); - } - let b = w.finish(); - let mut r = BitReader::new(&b); - for v in 0..5 { - assert_eq!(r.read_ue().unwrap(), v); - } - let mut w = BitWriter::new(); - for v in [0, 1, -1, 2, -2, 1000, -1000] { - w.write_se(v); - } - let b = w.finish(); - let mut r = BitReader::new(&b); - for v in [0, 1, -1, 2, -2, 1000, -1000] { - assert_eq!(r.read_se().unwrap(), v); - } - } - - #[test] - fn emulation_prevention() { - let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3]; - let esc = escape_rbsp(&raw); - assert_eq!(esc, vec![0, 0, 3, 1, 0, 0, 3, 0, 5, 0, 0, 3, 3]); - assert_eq!(unescape_rbsp(&esc), raw); - } - - #[test] - fn annexb_split() { - let s = [0, 0, 0, 1, 0x67, 1, 2, 0, 0, 1, 0x68, 3, 0, 0, 0, 1, 0x65, 9, 9]; - let n = annexb_nals(&s); - assert_eq!(n, vec![&[0x67, 1, 2][..], &[0x68, 3][..], &[0x65, 9, 9][..]]); - let l = [0, 0, 0, 2, 0x67, 1, 0, 0, 0, 1, 0x68]; - assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]); - } - - #[test] - fn length_prefixed_rejects_bad_length_size() { - // Regression test for #469: with a width of 0 neither the prefix nor a - // zero-length payload advanced the cursor, so any non-empty sample - // looped forever appending empty NALs. Widths above 4 cannot come - // from an AVCC/HVCC record either. - let d = [0, 0, 0, 2, 0x67, 1]; - assert_eq!(length_prefixed_nals(&d, 0), Err(BitError::InvalidLengthSize)); - assert_eq!(length_prefixed_nals(&d, 5), Err(BitError::InvalidLengthSize)); - assert_eq!(length_prefixed_nals(&d, usize::MAX), Err(BitError::InvalidLengthSize)); - assert_eq!(length_prefixed_nals(&[], 0), Err(BitError::InvalidLengthSize)); - } - - #[test] - fn length_prefixed_widths() { - // 1-byte prefixes. - assert_eq!(length_prefixed_nals(&[2, 0x67, 1, 1, 0x68], 1).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]); - // 2-byte prefixes. - assert_eq!(length_prefixed_nals(&[0, 2, 0x67, 1, 0, 1, 0x68], 2).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]); - // 3-byte prefixes (lengthSizeMinusOne == 2 in the configuration record). - assert_eq!(length_prefixed_nals(&[0, 0, 2, 0x67, 1], 3).unwrap(), vec![&[0x67, 1][..]]); - // A truncated prefix or payload is Eof, not a panic or a short read. - assert_eq!(length_prefixed_nals(&[0, 0], 4), Err(BitError::Eof)); - assert_eq!(length_prefixed_nals(&[0, 0, 0, 9, 0x67], 4), Err(BitError::Eof)); - // Empty input splits to no NALs. - assert!(length_prefixed_nals(&[], 4).unwrap().is_empty()); - } - - #[test] - fn more_rbsp() { - let d = [0b1100_0000]; - let mut r = BitReader::new(&d); - assert!(r.more_rbsp_data()); - r.read_bit().unwrap(); - assert!(!r.more_rbsp_data()); - } - - #[test] - fn crc() { - assert_eq!(crc32(b"123456789"), 0xCBF4_3926); - } - - proptest! { - #[test] - fn roundtrip(vals in proptest::collection::vec((0u32..u32::MAX, 1u32..=32), 0..200), ues in proptest::collection::vec(0u32..u32::MAX - 1, 0..50)) { - let mut w = BitWriter::new(); - for &(v, n) in &vals { w.write_bits(v, n); } - for &u in &ues { w.write_ue(u); } - let b = w.finish(); - let mut r = BitReader::new(&b); - for &(v, n) in &vals { - let mask = if n == 32 { u32::MAX } else { (1 << n) - 1 }; - prop_assert_eq!(r.read_bits(n).unwrap(), v & mask); - } - for &u in &ues { prop_assert_eq!(r.read_ue().unwrap(), u); } - } - - #[test] - fn escape_roundtrip(data in proptest::collection::vec(0u8..4, 0..300)) { - let e = escape_rbsp(&data); - prop_assert_eq!(unescape_rbsp(&e), data); - for w in e.windows(3) { prop_assert!(!(w[0] == 0 && w[1] == 0 && w[2] < 3)); } - } - } -} diff --git a/crates/video/src/isobmff/bytes.rs b/crates/video/src/isobmff/bytes.rs deleted file mode 100644 index 7f59443e..00000000 --- a/crates/video/src/isobmff/bytes.rs +++ /dev/null @@ -1,361 +0,0 @@ -//! Low-level helpers: four-character codes, a bounds-checked big-endian cursor, box iteration -//! over in-memory payloads, and a box builder for the muxers. - -use crate::isobmff::error::{Error, Result}; -use std::fmt; - -/// A four-character code (box type, sample entry format, brand, handler type…). -#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] -pub struct FourCc(pub [u8; 4]); - -impl FourCc { - pub const fn new(b: &[u8; 4]) -> Self { - FourCc(*b) - } - pub fn as_u32(self) -> u32 { - u32::from_be_bytes(self.0) - } - /// Lossy string form; `©` (0xA9) is mapped to U+00A9, other non-printables to `?`. - pub fn to_string_lossy(self) -> String { - self.0 - .iter() - .map(|&b| match b { - 0xA9 => '©', - 0x20..=0x7E => b as char, - _ => '?', - }) - .collect() - } -} - -impl fmt::Display for FourCc { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.to_string_lossy()) - } -} -impl fmt::Debug for FourCc { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "FourCc({:?})", self.to_string_lossy()) - } -} -impl From<&[u8; 4]> for FourCc { - fn from(b: &[u8; 4]) -> Self { - FourCc(*b) - } -} -impl PartialEq<[u8; 4]> for FourCc { - fn eq(&self, other: &[u8; 4]) -> bool { - &self.0 == other - } -} -impl PartialEq<&[u8; 4]> for FourCc { - fn eq(&self, other: &&[u8; 4]) -> bool { - &self.0 == *other - } -} -impl PartialEq<&&[u8; 4]> for FourCc { - fn eq(&self, other: &&&[u8; 4]) -> bool { - &self.0 == **other - } -} - -/// Bounds-checked big-endian cursor over a byte slice. -#[derive(Clone)] -pub(crate) struct Cur<'a> { - pub data: &'a [u8], - pub pos: usize, - what: &'static str, -} - -impl<'a> Cur<'a> { - pub fn new(data: &'a [u8], what: &'static str) -> Self { - Cur { data, pos: 0, what } - } - pub fn remaining(&self) -> usize { - self.data.len().saturating_sub(self.pos) - } - pub fn rest(&self) -> &'a [u8] { - self.data.get(self.pos..).unwrap_or(&[]) - } - pub fn bytes(&mut self, n: usize) -> Result<&'a [u8]> { - let end = self.pos.checked_add(n).ok_or(Error::Truncated(self.what))?; - let s = self.data.get(self.pos..end).ok_or(Error::Truncated(self.what))?; - self.pos = end; - Ok(s) - } - pub fn skip(&mut self, n: usize) -> Result<()> { - self.bytes(n).map(|_| ()) - } - pub fn array(&mut self) -> Result<[u8; N]> { - let s = self.bytes(N)?; - let mut a = [0u8; N]; - a.copy_from_slice(s); - Ok(a) - } - pub fn u8(&mut self) -> Result { - Ok(self.array::<1>()?[0]) - } - pub fn u16(&mut self) -> Result { - Ok(u16::from_be_bytes(self.array()?)) - } - pub fn i16(&mut self) -> Result { - Ok(i16::from_be_bytes(self.array()?)) - } - pub fn u24(&mut self) -> Result { - let a = self.array::<3>()?; - Ok(((a[0] as u32) << 16) | ((a[1] as u32) << 8) | a[2] as u32) - } - pub fn u32(&mut self) -> Result { - Ok(u32::from_be_bytes(self.array()?)) - } - pub fn i32(&mut self) -> Result { - Ok(i32::from_be_bytes(self.array()?)) - } - pub fn u64(&mut self) -> Result { - Ok(u64::from_be_bytes(self.array()?)) - } - pub fn i64(&mut self) -> Result { - Ok(i64::from_be_bytes(self.array()?)) - } - pub fn f64(&mut self) -> Result { - Ok(f64::from_be_bytes(self.array()?)) - } - pub fn fourcc(&mut self) -> Result { - Ok(FourCc(self.array()?)) - } - /// Full-box header: (version, flags). - pub fn full_header(&mut self) -> Result<(u8, u32)> { - let v = self.u32()?; - Ok(((v >> 24) as u8, v & 0x00FF_FFFF)) - } - /// Read a count and verify that `count * entry_size` bytes remain (allocation guard). - pub fn count(&mut self, entry_size: usize) -> Result { - let n = self.u32()? as usize; - self.check_count(n, entry_size)?; - Ok(n) - } - pub fn check_count(&self, n: usize, entry_size: usize) -> Result<()> { - match n.checked_mul(entry_size) { - Some(b) if b <= self.remaining() => Ok(()), - _ => Err(Error::Truncated(self.what)), - } - } -} - -/// Header of one box as found in a byte stream. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct BoxHeader { - pub kind: FourCc, - /// Header length including largesize and uuid extended type. - pub header_len: u64, - /// Total box size including header; `None` means "extends to end of the enclosing container". - pub size: Option, - pub uuid: Option<[u8; 16]>, -} - -/// Parse a box header from `buf` (needs up to 32 bytes; fewer if available). -pub(crate) fn parse_box_header(buf: &[u8]) -> Result { - let mut c = Cur::new(buf, "box header"); - let size32 = c.u32()?; - let kind = c.fourcc()?; - let mut header_len = 8u64; - let size = match size32 { - 0 => None, - 1 => { - header_len += 8; - let s = c.u64()?; - Some(s) - } - s => Some(s as u64), - }; - let uuid = if kind == b"uuid" { - header_len += 16; - Some(c.array::<16>()?) - } else { - None - }; - if let Some(s) = size - && s < header_len - { - return Err(Error::Invalid(format!("box '{kind}' size {s} smaller than its header"))); - } - Ok(BoxHeader { kind, header_len, size, uuid }) -} - -/// One child box inside an in-memory payload. -#[derive(Clone, Copy, Debug)] -pub(crate) struct RawBox<'a> { - pub kind: FourCc, - pub payload: &'a [u8], - #[allow(dead_code)] - pub uuid: Option<[u8; 16]>, -} - -/// Iterator over the child boxes of a payload. Yields an error for a malformed box and then stops. -pub(crate) struct Boxes<'a> { - data: &'a [u8], - pos: usize, - done: bool, -} - -pub(crate) fn boxes(data: &[u8]) -> Boxes<'_> { - Boxes { data, pos: 0, done: false } -} - -impl<'a> Iterator for Boxes<'a> { - type Item = Result>; - fn next(&mut self) -> Option { - if self.done { - return None; - } - let rest = &self.data[self.pos..]; - if rest.is_empty() { - self.done = true; - return None; - } - // QuickTime allows a 32-bit zero terminator at the end of some containers. - if rest.len() < 8 { - self.done = true; - if rest.iter().all(|&b| b == 0) { - return None; - } - return Some(Err(Error::Truncated("box header"))); - } - let h = match parse_box_header(rest) { - Ok(h) => h, - Err(e) => { - self.done = true; - return Some(Err(e)); - } - }; - let size = h.size.unwrap_or(rest.len() as u64); - if size > rest.len() as u64 { - self.done = true; - return Some(Err(Error::Invalid(format!("box '{}' size {} exceeds its container ({} bytes left)", h.kind, size, rest.len())))); - } - let size = size as usize; - let payload = &rest[h.header_len as usize..size]; - self.pos += size; - Some(Ok(RawBox { kind: h.kind, payload, uuid: h.uuid })) - } -} - -/// Find the first child of the given type (malformed trailing children are ignored). -pub(crate) fn find<'a>(data: &'a [u8], kind: &[u8; 4]) -> Option<&'a [u8]> { - boxes(data).map_while(|b| b.ok()).find(|b| b.kind == kind).map(|b| b.payload) -} - -/// Growable buffer for building boxes. -#[derive(Default, Clone, Debug)] -pub(crate) struct BoxBuf { - pub buf: Vec, -} - -impl BoxBuf { - pub fn new() -> Self { - Self::default() - } - /// Begin a box; returns a marker to pass to [`end`](Self::end). - pub fn start(&mut self, kind: &[u8; 4]) -> usize { - let at = self.buf.len(); - self.buf.extend_from_slice(&[0, 0, 0, 0]); - self.buf.extend_from_slice(kind); - at - } - pub fn start_full(&mut self, kind: &[u8; 4], version: u8, flags: u32) -> usize { - let at = self.start(kind); - self.u32(((version as u32) << 24) | (flags & 0xFF_FFFF)); - at - } - pub fn end(&mut self, at: usize) { - let size = (self.buf.len() - at) as u32; - self.buf[at..at + 4].copy_from_slice(&size.to_be_bytes()); - } - /// Append a complete box with the given payload. - pub fn leaf(&mut self, kind: &[u8; 4], payload: &[u8]) { - let at = self.start(kind); - self.bytes(payload); - self.end(at); - } - pub fn u8(&mut self, v: u8) { - self.buf.push(v); - } - pub fn u16(&mut self, v: u16) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn i16(&mut self, v: i16) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn u32(&mut self, v: u32) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn i32(&mut self, v: i32) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn u64(&mut self, v: u64) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn i64(&mut self, v: i64) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn f64(&mut self, v: f64) { - self.buf.extend_from_slice(&v.to_be_bytes()); - } - pub fn bytes(&mut self, b: &[u8]) { - self.buf.extend_from_slice(b); - } - pub fn zeros(&mut self, n: usize) { - self.buf.resize(self.buf.len() + n, 0); - } - pub fn len(&self) -> usize { - self.buf.len() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn header_forms() { - let h = parse_box_header(&[0, 0, 0, 16, b'f', b'r', b'e', b'e']).unwrap(); - assert_eq!((h.kind, h.header_len, h.size), (FourCc(*b"free"), 8, Some(16))); - let h = parse_box_header(&[0, 0, 0, 0, b'm', b'd', b'a', b't']).unwrap(); - assert_eq!(h.size, None); - let mut v = vec![0, 0, 0, 1, b'm', b'd', b'a', b't']; - v.extend_from_slice(&100u64.to_be_bytes()); - let h = parse_box_header(&v).unwrap(); - assert_eq!((h.header_len, h.size), (16, Some(100))); - let mut v = vec![0, 0, 0, 30, b'u', b'u', b'i', b'd']; - v.extend_from_slice(&[7u8; 16]); - let h = parse_box_header(&v).unwrap(); - assert_eq!((h.header_len, h.uuid), (24, Some([7u8; 16]))); - assert!(parse_box_header(&[0, 0, 0, 4, b'f', b'r', b'e', b'e']).is_err()); - assert!(parse_box_header(&[0, 0, 0]).is_err()); - } - - #[test] - fn iterate_children() { - let mut b = BoxBuf::new(); - b.leaf(b"aaaa", &[1, 2]); - let m = b.start(b"bbbb"); - b.leaf(b"cccc", &[]); - b.end(m); - b.u32(0); // QT terminator - let kids: Vec<_> = boxes(&b.buf).collect::>>().unwrap(); - assert_eq!(kids.len(), 2); - assert_eq!(kids[0].payload, &[1, 2]); - assert_eq!(find(&b.buf, b"bbbb").map(|p| p.len()), Some(8)); - // Oversized child → error, no panic. - let bad = [0u8, 0, 0, 99, b'x', b'x', b'x', b'x', 0]; - assert!(boxes(&bad).next().unwrap().is_err()); - } - - #[test] - fn cursor_bounds() { - let mut c = Cur::new(&[0, 0, 0, 5, 1], "t"); - assert!(c.count(1).is_err()); - let mut c = Cur::new(&[0xFF, 0xFF, 0xFF, 0xFF], "t"); - assert!(c.count(usize::MAX).is_err()); - } -} diff --git a/crates/video/src/isobmff/codec.rs b/crates/video/src/isobmff/codec.rs deleted file mode 100644 index 45f08bd9..00000000 --- a/crates/video/src/isobmff/codec.rs +++ /dev/null @@ -1,1579 +0,0 @@ -//! Sample entries (`stsd` children) and codec configuration records: parsing and serialization. - -use crate::bitstream::BitReader; -use crate::isobmff::bytes::{BoxBuf, Cur, FourCc, boxes}; -use crate::isobmff::error::{Error, Result}; - -/// One sample description (an entry of `stsd`). -#[derive(Clone, Debug, PartialEq)] -pub struct SampleEntry { - /// Sample entry format (`avc1`, `mp4a`, `apch`, …). - pub format: FourCc, - pub data_reference_index: u16, - pub codec: CodecConfig, - /// Present for visual sample entries. - pub video: Option, - /// Present for audio sample entries. - pub audio: Option, - /// `btrt` box. - pub bitrate: Option, -} - -/// Fields common to all visual sample entries plus the optional descriptive boxes. -#[derive(Clone, Debug, PartialEq, Default)] -pub struct VideoParams { - pub width: u16, - pub height: u16, - /// Horizontal/vertical resolution in pixels per inch (16.16 fixed point; 72 dpi = 0x0048_0000). - pub horiz_resolution: u32, - pub vert_resolution: u32, - pub frame_count: u16, - pub compressor_name: String, - pub depth: u16, - /// QuickTime `vendor` / version fields (zero in ISO files). - pub vendor: FourCc, - pub temporal_quality: u32, - pub spatial_quality: u32, - pub color: Option, - /// `pasp`: (hSpacing, vSpacing). - pub pixel_aspect: Option<(u32, u32)>, - pub clean_aperture: Option, - pub field_info: Option, - /// QuickTime `gama` (16.16 fixed point). - pub gamma: Option, - /// `mdcv`: mastering display colour volume (SMPTE ST 2086). - pub mastering_display: Option, - /// `clli`: content light level (MaxCLL, MaxFALL in cd/m²; 0 = unknown). - pub content_light: Option<(u16, u16)>, -} - -/// SMPTE ST 2086 mastering display metadata, in the units of the `mdcv` box and the H.264/HEVC -/// SEI: chromaticities in 0.00002 steps (order G, B, R as the SEI), luminance in 0.0001 cd/m². -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct MasteringDisplay { - pub primaries: [(u16, u16); 3], - pub white_point: (u16, u16), - pub max_luminance: u32, - pub min_luminance: u32, -} - -impl MasteringDisplay { - /// BT.2020 primaries, D65, with the given peak and black in cd/m². - pub fn bt2020(max_nits: f64, min_nits: f64) -> Self { - let q = |v: f64| (v / 0.00002).round() as u16; - MasteringDisplay { - primaries: [(q(0.170), q(0.797)), (q(0.131), q(0.046)), (q(0.708), q(0.292))], - white_point: (q(0.3127), q(0.3290)), - max_luminance: (max_nits * 10_000.0).round() as u32, - min_luminance: (min_nits * 10_000.0).round() as u32, - } - } - pub fn max_nits(&self) -> f64 { - self.max_luminance as f64 / 10_000.0 - } - /// The 24-byte payload shared by `mdcv` and the SEI message. - pub fn to_bytes(&self) -> Vec { - let mut v = Vec::with_capacity(24); - for (x, y) in self.primaries { - v.extend_from_slice(&x.to_be_bytes()); - v.extend_from_slice(&y.to_be_bytes()); - } - v.extend_from_slice(&self.white_point.0.to_be_bytes()); - v.extend_from_slice(&self.white_point.1.to_be_bytes()); - v.extend_from_slice(&self.max_luminance.to_be_bytes()); - v.extend_from_slice(&self.min_luminance.to_be_bytes()); - v - } - pub fn parse(p: &[u8]) -> Option { - if p.len() < 24 { - return None; - } - let u16_at = |i: usize| u16::from_be_bytes([p[i], p[i + 1]]); - let u32_at = |i: usize| u32::from_be_bytes([p[i], p[i + 1], p[i + 2], p[i + 3]]); - Some(MasteringDisplay { - primaries: [(u16_at(0), u16_at(2)), (u16_at(4), u16_at(6)), (u16_at(8), u16_at(10))], - white_point: (u16_at(12), u16_at(14)), - max_luminance: u32_at(16), - min_luminance: u32_at(20), - }) - } -} - -/// `colr` box contents. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ColorInfo { - /// ISO `nclx` (with full-range flag). - Nclx { primaries: u16, transfer: u16, matrix: u16, full_range: bool }, - /// QuickTime `nclc`. - Nclc { primaries: u16, transfer: u16, matrix: u16 }, - /// Embedded ICC profile (`prof` restricted/unrestricted `rICC`). - Icc { kind: FourCc, profile: Vec }, -} - -/// `clap` clean aperture (all rationals num/den; offsets are signed). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct CleanAperture { - pub width: (u32, u32), - pub height: (u32, u32), - pub horiz_offset: (i32, u32), - pub vert_offset: (i32, u32), -} - -/// QuickTime `fiel`: field count (1 progressive, 2 interlaced) and ordering detail -/// (0, 1 = top first, 6 = bottom first, 9 / 14 per the QuickTime spec). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FieldInfo { - pub fields: u8, - pub detail: u8, -} - -/// `btrt` box. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct BitRate { - pub buffer_size: u32, - pub max_bitrate: u32, - pub avg_bitrate: u32, -} - -/// Fields of audio sample entries (ISO v0 plus QuickTime sound description v1/v2 extensions). -#[derive(Clone, Debug, PartialEq, Default)] -pub struct AudioParams { - /// QuickTime sound description version (0, 1, 2). ISO files use 0. - pub qt_version: u16, - pub channels: u32, - /// Bits per sample from the entry (16 for most compressed formats). - pub sample_size: u16, - pub sample_rate: f64, - pub compression_id: i16, - /// v1 fields. - pub samples_per_packet: u32, - pub bytes_per_packet: u32, - pub bytes_per_frame: u32, - pub bytes_per_sample: u32, - /// v2 fields. - pub const_bits_per_channel: u32, - pub lpcm_flags: u32, - pub const_bytes_per_packet: u32, - pub const_frames_per_packet: u32, - /// `enda` little-endian flag inside a QuickTime `wave` box. - pub little_endian: Option, -} - -/// Codec configuration decoded from the sample entry. -#[derive(Clone, Debug, PartialEq)] -pub enum CodecConfig { - Avc(AvcConfig), - Hevc(HevcConfig), - Av1(Av1Config), - Vp9(VpcConfig), - /// Apple ProRes; fourcc is `apch`/`apcn`/`apcs`/`apco`/`ap4h`/`ap4x`. - ProRes { - fourcc: FourCc, - }, - /// Motion JPEG (`jpeg`, `mjpa`, `mjpb`). - Jpeg { - fourcc: FourCc, - }, - /// Avid DNxHD/DNxHR (`AVdn`, `AVdh`). - Dnx { - fourcc: FourCc, - }, - Aac(AacConfig), - /// MPEG-1/2 audio layer III (esds object type 0x69/0x6B, or QuickTime `.mp3`). - Mp3, - Pcm(PcmConfig), - /// ALAC magic cookie (ALACSpecificConfig, 24 bytes, optionally followed by a channel layout). - Alac { - cookie: Vec, - }, - Opus(OpusConfig), - Flac(FlacConfig), - /// `dac3` payload. - Ac3 { - dac3: Vec, - }, - /// `dec3` payload. - Eac3 { - dec3: Vec, - }, - Timecode(TimecodeConfig), - /// Anything else; `raw` is the full sample entry payload (after the 8-byte box header). - Unknown { - fourcc: FourCc, - raw: Vec, - }, -} - -impl CodecConfig { - /// A short human-readable codec name. - pub fn name(&self) -> &'static str { - match self { - CodecConfig::Avc(_) => "h264", - CodecConfig::Hevc(_) => "hevc", - CodecConfig::Av1(_) => "av1", - CodecConfig::Vp9(_) => "vp9", - CodecConfig::ProRes { .. } => "prores", - CodecConfig::Jpeg { .. } => "mjpeg", - CodecConfig::Dnx { .. } => "dnxhd", - CodecConfig::Aac(_) => "aac", - CodecConfig::Mp3 => "mp3", - CodecConfig::Pcm(_) => "pcm", - CodecConfig::Alac { .. } => "alac", - CodecConfig::Opus(_) => "opus", - CodecConfig::Flac(_) => "flac", - CodecConfig::Ac3 { .. } => "ac3", - CodecConfig::Eac3 { .. } => "eac3", - CodecConfig::Timecode(_) => "timecode", - CodecConfig::Unknown { .. } => "unknown", - } - } -} - -/// `avcC` — AVCDecoderConfigurationRecord (ISO/IEC 14496-15 §5.3.3). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct AvcConfig { - pub profile: u8, - pub compatibility: u8, - pub level: u8, - /// NAL length field size in bytes (1, 2 or 4). - pub length_size: u8, - pub sps: Vec>, - pub pps: Vec>, - /// Bytes after the PPS list (high-profile extension: chroma format, bit depths, SPS-ext). - pub ext: Vec, -} - -impl AvcConfig { - /// Build from parameter sets (NAL units without start codes); profile/level taken from the first SPS. - pub fn new(sps: Vec>, pps: Vec>, length_size: u8) -> Self { - let (profile, compatibility, level) = sps.first().filter(|s| s.len() >= 4).map(|s| (s[1], s[2], s[3])).unwrap_or((0, 0, 0)); - AvcConfig { profile, compatibility, level, length_size, sps, pps, ext: Vec::new() } - } - - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "avcC"); - let _version = c.u8()?; - let profile = c.u8()?; - let compatibility = c.u8()?; - let level = c.u8()?; - let length_size = (c.u8()? & 3) + 1; - let nsps = (c.u8()? & 0x1F) as usize; - let mut sps = Vec::with_capacity(nsps); - for _ in 0..nsps { - let n = c.u16()? as usize; - sps.push(c.bytes(n)?.to_vec()); - } - let npps = c.u8()? as usize; - let mut pps = Vec::with_capacity(npps); - for _ in 0..npps { - let n = c.u16()? as usize; - pps.push(c.bytes(n)?.to_vec()); - } - Ok(AvcConfig { profile, compatibility, level, length_size, sps, pps, ext: c.rest().to_vec() }) - } - - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(1); - b.u8(self.profile); - b.u8(self.compatibility); - b.u8(self.level); - b.u8(0xFC | (self.length_size.clamp(1, 4) - 1)); - b.u8(0xE0 | (self.sps.len().min(31) as u8)); - for s in self.sps.iter().take(31) { - b.u16(s.len() as u16); - b.bytes(s); - } - b.u8(self.pps.len().min(255) as u8); - for p in self.pps.iter().take(255) { - b.u16(p.len() as u16); - b.bytes(p); - } - b.bytes(&self.ext); - b.buf - } -} - -/// One NAL array of an `hvcC` record. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct HevcNalArray { - pub completeness: bool, - pub nal_type: u8, - pub nalus: Vec>, -} - -/// `hvcC` — HEVCDecoderConfigurationRecord (ISO/IEC 14496-15 §8.3.3). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct HevcConfig { - pub general_profile_space: u8, - pub general_tier_flag: bool, - pub general_profile_idc: u8, - pub general_profile_compatibility_flags: u32, - /// 48 bits of general constraint indicator flags. - pub general_constraint_indicator_flags: u64, - pub general_level_idc: u8, - pub min_spatial_segmentation_idc: u16, - pub parallelism_type: u8, - pub chroma_format_idc: u8, - pub bit_depth_luma: u8, - pub bit_depth_chroma: u8, - pub avg_frame_rate: u16, - pub constant_frame_rate: u8, - pub num_temporal_layers: u8, - pub temporal_id_nested: bool, - pub length_size: u8, - pub arrays: Vec, -} - -impl HevcConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "hvcC"); - let _version = c.u8()?; - let b = c.u8()?; - let compat = c.u32()?; - let hi = c.u16()? as u64; - let lo = c.u32()? as u64; - let level = c.u8()?; - let min_seg = c.u16()? & 0x0FFF; - let par = c.u8()? & 3; - let chroma = c.u8()? & 3; - let bdl = (c.u8()? & 7) + 8; - let bdc = (c.u8()? & 7) + 8; - let afr = c.u16()?; - let x = c.u8()?; - let narrays = c.u8()? as usize; - let mut arrays = Vec::new(); - for _ in 0..narrays { - let t = c.u8()?; - let n = c.u16()? as usize; - c.check_count(n, 2)?; - let mut nalus = Vec::with_capacity(n); - for _ in 0..n { - let len = c.u16()? as usize; - nalus.push(c.bytes(len)?.to_vec()); - } - arrays.push(HevcNalArray { completeness: t & 0x80 != 0, nal_type: t & 0x3F, nalus }); - } - Ok(HevcConfig { - general_profile_space: b >> 6, - general_tier_flag: b & 0x20 != 0, - general_profile_idc: b & 0x1F, - general_profile_compatibility_flags: compat, - general_constraint_indicator_flags: (hi << 32) | lo, - general_level_idc: level, - min_spatial_segmentation_idc: min_seg, - parallelism_type: par, - chroma_format_idc: chroma, - bit_depth_luma: bdl, - bit_depth_chroma: bdc, - avg_frame_rate: afr, - constant_frame_rate: x >> 6, - num_temporal_layers: (x >> 3) & 7, - temporal_id_nested: x & 4 != 0, - length_size: (x & 3) + 1, - arrays, - }) - } - - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(1); - b.u8((self.general_profile_space << 6) | ((self.general_tier_flag as u8) << 5) | (self.general_profile_idc & 0x1F)); - b.u32(self.general_profile_compatibility_flags); - b.u16((self.general_constraint_indicator_flags >> 32) as u16); - b.u32(self.general_constraint_indicator_flags as u32); - b.u8(self.general_level_idc); - b.u16(0xF000 | (self.min_spatial_segmentation_idc & 0x0FFF)); - b.u8(0xFC | (self.parallelism_type & 3)); - b.u8(0xFC | (self.chroma_format_idc & 3)); - b.u8(0xF8 | (self.bit_depth_luma.saturating_sub(8) & 7)); - b.u8(0xF8 | (self.bit_depth_chroma.saturating_sub(8) & 7)); - b.u16(self.avg_frame_rate); - b.u8((self.constant_frame_rate << 6) - | ((self.num_temporal_layers & 7) << 3) - | ((self.temporal_id_nested as u8) << 2) - | (self.length_size.clamp(1, 4) - 1)); - b.u8(self.arrays.len() as u8); - for a in &self.arrays { - b.u8(((a.completeness as u8) << 7) | (a.nal_type & 0x3F)); - b.u16(a.nalus.len() as u16); - for n in &a.nalus { - b.u16(n.len() as u16); - b.bytes(n); - } - } - b.buf - } - - fn nalus_of(&self, t: u8) -> Vec<&[u8]> { - self.arrays.iter().filter(|a| a.nal_type == t).flat_map(|a| a.nalus.iter().map(|n| n.as_slice())).collect() - } - pub fn vps(&self) -> Vec<&[u8]> { - self.nalus_of(32) - } - pub fn sps(&self) -> Vec<&[u8]> { - self.nalus_of(33) - } - pub fn pps(&self) -> Vec<&[u8]> { - self.nalus_of(34) - } -} - -/// `av1C` — AV1CodecConfigurationRecord. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct Av1Config { - pub seq_profile: u8, - pub seq_level_idx_0: u8, - pub seq_tier_0: bool, - pub high_bitdepth: bool, - pub twelve_bit: bool, - pub monochrome: bool, - pub chroma_subsampling_x: bool, - pub chroma_subsampling_y: bool, - pub chroma_sample_position: u8, - pub initial_presentation_delay_minus_one: Option, - pub config_obus: Vec, -} - -impl Av1Config { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "av1C"); - let m = c.u8()?; - if m & 0x80 == 0 { - return Err(Error::Invalid("av1C marker bit not set".into())); - } - let a = c.u8()?; - let b = c.u8()?; - let d = c.u8()?; - Ok(Av1Config { - seq_profile: a >> 5, - seq_level_idx_0: a & 0x1F, - seq_tier_0: b & 0x80 != 0, - high_bitdepth: b & 0x40 != 0, - twelve_bit: b & 0x20 != 0, - monochrome: b & 0x10 != 0, - chroma_subsampling_x: b & 0x08 != 0, - chroma_subsampling_y: b & 0x04 != 0, - chroma_sample_position: b & 3, - initial_presentation_delay_minus_one: if d & 0x10 != 0 { Some(d & 0x0F) } else { None }, - config_obus: c.rest().to_vec(), - }) - } - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(0x81); - b.u8((self.seq_profile << 5) | (self.seq_level_idx_0 & 0x1F)); - b.u8(((self.seq_tier_0 as u8) << 7) - | ((self.high_bitdepth as u8) << 6) - | ((self.twelve_bit as u8) << 5) - | ((self.monochrome as u8) << 4) - | ((self.chroma_subsampling_x as u8) << 3) - | ((self.chroma_subsampling_y as u8) << 2) - | (self.chroma_sample_position & 3)); - b.u8(match self.initial_presentation_delay_minus_one { - Some(d) => 0x10 | (d & 0x0F), - None => 0, - }); - b.bytes(&self.config_obus); - b.buf - } -} - -/// `vpcC` — VP codec configuration (version 1). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct VpcConfig { - pub profile: u8, - pub level: u8, - pub bit_depth: u8, - pub chroma_subsampling: u8, - pub full_range: bool, - pub colour_primaries: u8, - pub transfer_characteristics: u8, - pub matrix_coefficients: u8, - pub codec_init: Vec, -} - -impl VpcConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "vpcC"); - let (version, _) = c.full_header()?; - if version == 0 { - // Version 0 draft layout: profile, level, bitDepth(4)|colorSpace(4), chroma(4)|transfer(3)|range(1) - let profile = c.u8()?; - let level = c.u8()?; - let a = c.u8()?; - let b = c.u8()?; - return Ok(VpcConfig { profile, level, bit_depth: a >> 4, chroma_subsampling: b >> 4, full_range: b & 1 != 0, ..Default::default() }); - } - let profile = c.u8()?; - let level = c.u8()?; - let a = c.u8()?; - let colour_primaries = c.u8()?; - let transfer_characteristics = c.u8()?; - let matrix_coefficients = c.u8()?; - let n = c.u16()? as usize; - let codec_init = c.bytes(n)?.to_vec(); - Ok(VpcConfig { - profile, - level, - bit_depth: a >> 4, - chroma_subsampling: (a >> 1) & 7, - full_range: a & 1 != 0, - colour_primaries, - transfer_characteristics, - matrix_coefficients, - codec_init, - }) - } - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u32(1 << 24); - b.u8(self.profile); - b.u8(self.level); - b.u8((self.bit_depth << 4) | ((self.chroma_subsampling & 7) << 1) | self.full_range as u8); - b.u8(self.colour_primaries); - b.u8(self.transfer_characteristics); - b.u8(self.matrix_coefficients); - b.u16(self.codec_init.len() as u16); - b.bytes(&self.codec_init); - b.buf - } -} - -/// AAC (MPEG-4 audio) configuration from `esds`. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct AacConfig { - /// AudioSpecificConfig bytes (DecoderSpecificInfo). - pub asc: Vec, - /// Audio object type (2 = AAC-LC, 5 = SBR/HE-AAC, 29 = PS …) as signalled first in the ASC. - pub object_type: u8, - /// Sampling rate signalled by the ASC (core rate; 0 if the ASC is missing). - pub sample_rate: u32, - /// Channel configuration from the ASC (0 = defined elsewhere). - pub channel_config: u8, - /// DecoderConfigDescriptor fields. - pub max_bitrate: u32, - pub avg_bitrate: u32, - pub buffer_size: u32, -} - -const AAC_RATES: [u32; 13] = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350]; - -impl AacConfig { - /// Build from an AudioSpecificConfig, decoding its header fields. - pub fn from_asc(asc: Vec) -> Self { - let (object_type, sample_rate, channel_config) = parse_asc(&asc).unwrap_or((0, 0, 0)); - AacConfig { asc, object_type, sample_rate, channel_config, max_bitrate: 0, avg_bitrate: 0, buffer_size: 0 } - } - /// Channel count implied by the channel configuration (0 if unspecified). - pub fn channels(&self) -> u32 { - match self.channel_config { - 1..=6 => self.channel_config as u32, - 7 => 8, - _ => 0, - } - } -} - -/// Decode (audio object type, sampling frequency, channel configuration) from an AudioSpecificConfig. -pub fn parse_asc(asc: &[u8]) -> Option<(u8, u32, u8)> { - let mut r = BitReader::new(asc); - let aot = |r: &mut BitReader| -> Option { - let t = r.read_bits(5).ok()? as u8; - if t == 31 { Some(32 + r.read_bits(6).ok()? as u8) } else { Some(t) } - }; - let rate = |r: &mut BitReader| -> Option { - let i = r.read_bits(4).ok()? as usize; - if i == 15 { r.read_bits(24).ok() } else { AAC_RATES.get(i).copied() } - }; - let t = aot(&mut r)?; - let sr = rate(&mut r)?; - let ch = r.read_bits(4).ok()? as u8; - Some((t, sr, ch)) -} - -/// Uncompressed PCM description. -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub struct PcmConfig { - pub bits: u16, - pub float: bool, - pub big_endian: bool, - /// Signed integer samples (8-bit `raw ` is unsigned). - pub signed: bool, - pub channels: u32, - pub sample_rate: f64, -} - -impl PcmConfig { - /// Bytes per interleaved audio frame (all channels). - pub fn bytes_per_frame(&self) -> u32 { - (self.bits as u32).div_ceil(8) * self.channels.max(1) - } -} - -/// `dOps` — Opus specific box. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct OpusConfig { - pub output_channels: u8, - pub pre_skip: u16, - pub input_sample_rate: u32, - pub output_gain: i16, - pub channel_mapping_family: u8, - /// StreamCount, CoupledCount and ChannelMapping bytes when family != 0. - pub channel_mapping: Vec, -} - -impl OpusConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "dOps"); - let _version = c.u8()?; - Ok(OpusConfig { - output_channels: c.u8()?, - pre_skip: c.u16()?, - input_sample_rate: c.u32()?, - output_gain: c.i16()?, - channel_mapping_family: c.u8()?, - channel_mapping: c.rest().to_vec(), - }) - } - pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u8(0); - b.u8(self.output_channels); - b.u16(self.pre_skip); - b.u32(self.input_sample_rate); - b.i16(self.output_gain); - b.u8(self.channel_mapping_family); - b.bytes(&self.channel_mapping); - b.buf - } -} - -/// `dfLa` — FLAC metadata blocks, with STREAMINFO decoded. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct FlacConfig { - /// Raw metadata blocks (each with its 4-byte header) as stored in `dfLa`. - pub metadata_blocks: Vec, - pub sample_rate: u32, - pub channels: u8, - pub bits_per_sample: u8, - pub total_samples: u64, -} - -impl FlacConfig { - pub fn parse(data: &[u8]) -> Result { - let mut c = Cur::new(data, "dfLa"); - c.full_header()?; - let blocks = c.rest().to_vec(); - let mut cfg = FlacConfig { metadata_blocks: blocks.clone(), ..Default::default() }; - let mut b = Cur::new(&blocks, "dfLa"); - let hdr = b.u32()?; - if (hdr >> 24) & 0x7F == 0 { - let si = b.bytes(((hdr & 0xFF_FFFF) as usize).min(34))?; - if si.len() >= 18 { - let mut r = BitReader::new(&si[10..18]); - cfg.sample_rate = r.read_bits(20).unwrap_or(0); - cfg.channels = r.read_bits(3).unwrap_or(0) as u8 + 1; - cfg.bits_per_sample = r.read_bits(5).unwrap_or(0) as u8 + 1; - cfg.total_samples = r.read_bits_u64(36).unwrap_or(0); - } - } - Ok(cfg) - } -} - -/// QuickTime timecode sample description (`tmcd`) plus the start frame read from its first sample. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct TimecodeConfig { - /// Bit 0 drop frame, bit 1 24-hour max, bit 2 negative times OK, bit 3 counter. - pub flags: u32, - pub timescale: u32, - pub frame_duration: u32, - /// Nominal frames per second (`numberOfFrames`), e.g. 30 for 29.97. - pub frames_per_second: u8, - /// Frame number of the first timecode sample (filled in by the demuxer). - pub start_frame: Option, - /// Source reel name (`name` box) if present. - pub name: Option, -} - -impl TimecodeConfig { - pub fn drop_frame(&self) -> bool { - self.flags & 1 != 0 - } - /// Format a frame number as `HH:MM:SS:FF` (`;` before the frames when drop-frame). - pub fn format_frame(&self, frame: u32) -> String { - let fps = self.frames_per_second.max(1) as u64; - let mut f = frame as u64; - if self.drop_frame() && fps >= 30 { - let drop = (fps / 30) * 2; // 2 frames per minute at 30, 4 at 60 - let per_min = fps * 60 - drop; - let per_10 = per_min * 10 + drop; - let d = f / per_10; - let m = f % per_10; - f += drop * 9 * d + if m > drop { drop * ((m - drop) / per_min) } else { 0 }; - } - let ff = f % fps; - let s = (f / fps) % 60; - let mi = (f / fps / 60) % 60; - let h = (f / fps / 3600) % 24; - let sep = if self.drop_frame() { ';' } else { ':' }; - format!("{h:02}:{mi:02}:{s:02}{sep}{ff:02}") - } -} - -// --------------------------------------------------------------------------------------------- -// Parsing - -fn is_video_fourcc(f: &[u8; 4]) -> bool { - matches!( - f, - b"avc1" - | b"avc3" - | b"hvc1" - | b"hev1" - | b"av01" - | b"vp09" - | b"vp08" - | b"apch" - | b"apcn" - | b"apcs" - | b"apco" - | b"ap4h" - | b"ap4x" - | b"jpeg" - | b"mjpa" - | b"mjpb" - | b"AVdn" - | b"AVdh" - | b"mp4v" - | b"encv" - | b"raw " - | b"2vuy" - | b"v210" - ) -} - -fn is_audio_fourcc(f: &[u8; 4]) -> bool { - matches!( - f, - b"mp4a" - | b"lpcm" - | b"sowt" - | b"twos" - | b"in24" - | b"in32" - | b"fl32" - | b"fl64" - | b"ipcm" - | b"fpcm" - | b"alac" - | b"Opus" - | b"fLaC" - | b"ac-3" - | b"ec-3" - | b".mp3" - | b"enca" - | b"ulaw" - | b"alaw" - | b"NONE" - ) -} - -/// Parse one `stsd` entry. `handler` is the track handler type; `is_qt` selects QuickTime -/// interpretation of the sound description version field. -pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc, is_qt: bool) -> Result { - let mut c = Cur::new(payload, "sample entry"); - c.skip(6)?; - let data_reference_index = c.u16()?; - let f = &format.0; - let unknown = || CodecConfig::Unknown { fourcc: format, raw: payload.to_vec() }; - if f == b"tmcd" || (handler == b"tmcd" && !is_video_fourcc(f)) { - let mut tc = TimecodeConfig::default(); - c.skip(4)?; - tc.flags = c.u32()?; - tc.timescale = c.u32()?; - tc.frame_duration = c.u32()?; - tc.frames_per_second = c.u8()?; - c.skip(1)?; - for b in boxes(c.rest()).map_while(|b| b.ok()) { - if b.kind == b"name" && b.payload.len() >= 4 { - let n = u16::from_be_bytes([b.payload[0], b.payload[1]]) as usize; - let s = b.payload.get(4..4 + n).unwrap_or(&b.payload[4..]); - tc.name = Some(String::from_utf8_lossy(s).into_owned()); - } - } - return Ok(SampleEntry { format, data_reference_index, codec: CodecConfig::Timecode(tc), video: None, audio: None, bitrate: None }); - } - let video_like = is_video_fourcc(f) || (handler == b"vide" && !is_audio_fourcc(f)); - let audio_like = is_audio_fourcc(f) || (handler == b"soun" && !video_like); - if video_like { - let mut v = VideoParams::default(); - let _version = c.u16()?; - let _revision = c.u16()?; - v.vendor = c.fourcc()?; - v.temporal_quality = c.u32()?; - v.spatial_quality = c.u32()?; - v.width = c.u16()?; - v.height = c.u16()?; - v.horiz_resolution = c.u32()?; - v.vert_resolution = c.u32()?; - c.skip(4)?; - v.frame_count = c.u16()?; - let name = c.bytes(32)?; - let n = (name[0] as usize).min(31); - v.compressor_name = String::from_utf8_lossy(&name[1..1 + n]).into_owned(); - v.depth = c.u16()?; - let _ctab = c.i16()?; - let mut codec = None; - let mut bitrate = None; - for b in boxes(c.rest()).map_while(|b| b.ok()) { - let p = b.payload; - match &b.kind.0 { - b"avcC" if matches!(f, b"avc1" | b"avc3") => codec = Some(CodecConfig::Avc(AvcConfig::parse(p)?)), - b"hvcC" if matches!(f, b"hvc1" | b"hev1") => codec = Some(CodecConfig::Hevc(HevcConfig::parse(p)?)), - b"av1C" if f == b"av01" => codec = Some(CodecConfig::Av1(Av1Config::parse(p)?)), - b"vpcC" if f == b"vp09" => codec = Some(CodecConfig::Vp9(VpcConfig::parse(p)?)), - b"colr" => v.color = parse_colr(p).ok(), - b"mdcv" => v.mastering_display = MasteringDisplay::parse(p), - b"clli" if p.len() >= 4 => v.content_light = Some((u16::from_be_bytes([p[0], p[1]]), u16::from_be_bytes([p[2], p[3]]))), - b"pasp" => { - let mut c = Cur::new(p, "pasp"); - v.pixel_aspect = Some((c.u32()?, c.u32()?)); - } - b"clap" => { - let mut c = Cur::new(p, "clap"); - v.clean_aperture = Some(CleanAperture { - width: (c.u32()?, c.u32()?), - height: (c.u32()?, c.u32()?), - horiz_offset: (c.i32()?, c.u32()?), - vert_offset: (c.i32()?, c.u32()?), - }); - } - b"fiel" => { - let mut c = Cur::new(p, "fiel"); - v.field_info = Some(FieldInfo { fields: c.u8()?, detail: c.u8()? }); - } - b"gama" => v.gamma = Some(Cur::new(p, "gama").u32()?), - b"btrt" => bitrate = Some(parse_btrt(p)?), - _ => {} - } - } - let codec = match codec { - Some(c) => c, - None => match f { - b"apch" | b"apcn" | b"apcs" | b"apco" | b"ap4h" | b"ap4x" => CodecConfig::ProRes { fourcc: format }, - b"jpeg" | b"mjpa" | b"mjpb" => CodecConfig::Jpeg { fourcc: format }, - b"AVdn" | b"AVdh" => CodecConfig::Dnx { fourcc: format }, - _ => unknown(), - }, - }; - return Ok(SampleEntry { format, data_reference_index, codec, video: Some(v), audio: None, bitrate }); - } - if audio_like { - let mut a = AudioParams { qt_version: c.u16()?, ..Default::default() }; - let _revision = c.u16()?; - let _vendor = c.u32()?; - a.channels = c.u16()? as u32; - a.sample_size = c.u16()?; - a.compression_id = c.i16()?; - let _packet_size = c.u16()?; - a.sample_rate = c.u32()? as f64 / 65536.0; - if is_qt && a.qt_version == 1 { - a.samples_per_packet = c.u32()?; - a.bytes_per_packet = c.u32()?; - a.bytes_per_frame = c.u32()?; - a.bytes_per_sample = c.u32()?; - } else if is_qt && a.qt_version == 2 { - let _size_of_struct = c.u32()?; - a.sample_rate = c.f64()?; - a.channels = c.u32()?; - let _always = c.u32()?; - a.const_bits_per_channel = c.u32()?; - a.lpcm_flags = c.u32()?; - a.const_bytes_per_packet = c.u32()?; - a.const_frames_per_packet = c.u32()?; - } - // Flatten QuickTime `wave` children into the list of extension boxes. - let mut ext: Vec<(FourCc, &[u8])> = Vec::new(); - for b in boxes(c.rest()).map_while(|b| b.ok()) { - if b.kind == b"wave" { - for w in boxes(b.payload).map_while(|b| b.ok()) { - ext.push((w.kind, w.payload)); - } - } else { - ext.push((b.kind, b.payload)); - } - } - let get = |k: &[u8; 4]| ext.iter().find(|(kk, _)| kk == k).map(|(_, p)| *p); - if let Some(p) = get(b"enda") { - let v = if p.len() >= 2 { u16::from_be_bytes([p[0], p[1]]) } else { p.first().copied().unwrap_or(0) as u16 }; - a.little_endian = Some(v != 0); - } - let bitrate = get(b"btrt").and_then(|p| parse_btrt(p).ok()); - let codec = match f { - b"mp4a" => match get(b"esds") { - Some(p) => parse_esds(p)?, - None => unknown(), - }, - b".mp3" => CodecConfig::Mp3, - b"alac" => match get(b"alac") { - Some(p) if p.len() >= 4 => CodecConfig::Alac { cookie: p[4..].to_vec() }, - _ => unknown(), - }, - b"Opus" => match get(b"dOps") { - Some(p) => CodecConfig::Opus(OpusConfig::parse(p)?), - None => unknown(), - }, - b"fLaC" => match get(b"dfLa") { - Some(p) => CodecConfig::Flac(FlacConfig::parse(p)?), - None => unknown(), - }, - b"ac-3" => CodecConfig::Ac3 { dac3: get(b"dac3").unwrap_or(&[]).to_vec() }, - b"ec-3" => CodecConfig::Eac3 { dec3: get(b"dec3").unwrap_or(&[]).to_vec() }, - b"lpcm" | b"sowt" | b"twos" | b"in24" | b"in32" | b"fl32" | b"fl64" | b"raw " | b"NONE" => CodecConfig::Pcm(qt_pcm(f, &a)), - b"ipcm" | b"fpcm" => { - let mut pcm = PcmConfig { - bits: a.sample_size, - float: f == b"fpcm", - big_endian: true, - signed: true, - channels: a.channels, - sample_rate: a.sample_rate, - }; - if let Some(p) = get(b"pcmC") { - let mut c = Cur::new(p, "pcmC"); - c.full_header()?; - let flags = c.u8()?; - pcm.big_endian = flags & 1 == 0; - pcm.bits = c.u8()? as u16; - } - CodecConfig::Pcm(pcm) - } - _ => unknown(), - }; - return Ok(SampleEntry { format, data_reference_index, codec, video: None, audio: Some(a), bitrate }); - } - Ok(SampleEntry { format, data_reference_index, codec: unknown(), video: None, audio: None, bitrate: None }) -} - -fn qt_pcm(f: &[u8; 4], a: &AudioParams) -> PcmConfig { - let mut p = PcmConfig { channels: a.channels, sample_rate: a.sample_rate, signed: true, ..Default::default() }; - match f { - b"lpcm" => { - p.bits = a.const_bits_per_channel as u16; - p.float = a.lpcm_flags & 1 != 0; - p.big_endian = a.lpcm_flags & 2 != 0; - p.signed = a.lpcm_flags & 4 != 0 || p.float; - if p.bits == 0 { - p.bits = a.sample_size; - } - } - b"sowt" => { - p.bits = if a.sample_size == 0 { 16 } else { a.sample_size }; - } - b"twos" => { - p.bits = if a.sample_size == 0 { 16 } else { a.sample_size }; - p.big_endian = true; - } - b"raw " | b"NONE" => { - p.bits = if a.sample_size == 0 { 8 } else { a.sample_size }; - p.signed = p.bits != 8; - p.big_endian = true; - } - b"in24" | b"in32" | b"fl32" | b"fl64" => { - p.bits = match f { - b"in24" => 24, - b"in32" | b"fl32" => 32, - _ => 64, - }; - p.float = f[0] == b'f'; - p.big_endian = !a.little_endian.unwrap_or(false); - } - _ => {} - } - p -} - -fn parse_btrt(p: &[u8]) -> Result { - let mut c = Cur::new(p, "btrt"); - Ok(BitRate { buffer_size: c.u32()?, max_bitrate: c.u32()?, avg_bitrate: c.u32()? }) -} - -fn parse_colr(p: &[u8]) -> Result { - let mut c = Cur::new(p, "colr"); - let kind = c.fourcc()?; - Ok(match &kind.0 { - b"nclx" => { - ColorInfo::Nclx { primaries: c.u16()?, transfer: c.u16()?, matrix: c.u16()?, full_range: c.u8().map(|v| v & 0x80 != 0).unwrap_or(false) } - } - b"nclc" => ColorInfo::Nclc { primaries: c.u16()?, transfer: c.u16()?, matrix: c.u16()? }, - _ => ColorInfo::Icc { kind, profile: c.rest().to_vec() }, - }) -} - -fn descriptor(c: &mut Cur) -> Result<(u8, usize)> { - let tag = c.u8()?; - let mut len = 0usize; - for _ in 0..4 { - let b = c.u8()?; - len = (len << 7) | (b & 0x7F) as usize; - if b & 0x80 == 0 { - break; - } - } - Ok((tag, len)) -} - -fn parse_esds(p: &[u8]) -> Result { - let mut c = Cur::new(p, "esds"); - c.full_header()?; - let (tag, len) = descriptor(&mut c)?; - if tag != 3 { - return Err(Error::Invalid(format!("esds: expected ES_Descriptor, got tag {tag}"))); - } - let es = c.bytes(len.min(c.remaining()))?; - let mut c = Cur::new(es, "ES_Descriptor"); - c.u16()?; - let fl = c.u8()?; - if fl & 0x80 != 0 { - c.u16()?; - } - if fl & 0x40 != 0 { - let n = c.u8()? as usize; - c.skip(n)?; - } - if fl & 0x20 != 0 { - c.u16()?; - } - while c.remaining() > 0 { - let (tag, len) = descriptor(&mut c)?; - let body = c.bytes(len.min(c.remaining()))?; - if tag != 4 { - continue; - } - let mut d = Cur::new(body, "DecoderConfigDescriptor"); - let oti = d.u8()?; - let _stream_type = d.u8()?; - let buffer_size = d.u24()?; - let max_bitrate = d.u32()?; - let avg_bitrate = d.u32()?; - let mut asc = Vec::new(); - while d.remaining() > 0 { - let (t, l) = descriptor(&mut d)?; - let b = d.bytes(l.min(d.remaining()))?; - if t == 5 { - asc = b.to_vec(); - break; - } - } - return Ok(match oti { - 0x69 | 0x6B => CodecConfig::Mp3, - _ => { - let mut a = AacConfig::from_asc(asc); - a.max_bitrate = max_bitrate; - a.avg_bitrate = avg_bitrate; - a.buffer_size = buffer_size; - CodecConfig::Aac(a) - } - }); - } - Err(Error::Invalid("esds without DecoderConfigDescriptor".into())) -} - -// --------------------------------------------------------------------------------------------- -// Serialization (muxer) - -fn put_descriptor(b: &mut BoxBuf, tag: u8, body: &[u8]) { - b.u8(tag); - let n = body.len() as u32; - // Always use the 4-byte length form for simplicity (as many encoders do). - b.u8(0x80 | ((n >> 21) & 0x7F) as u8); - b.u8(0x80 | ((n >> 14) & 0x7F) as u8); - b.u8(0x80 | ((n >> 7) & 0x7F) as u8); - b.u8((n & 0x7F) as u8); - b.bytes(body); -} - -pub(crate) fn esds_payload(oti: u8, asc: &[u8], max_bitrate: u32, avg_bitrate: u32, buffer_size: u32) -> Vec { - let mut dcd = BoxBuf::new(); - dcd.u8(oti); - dcd.u8(0x15); // streamType audio (5) << 2 | upstream 0 | reserved 1 - dcd.u8((buffer_size >> 16) as u8); - dcd.u16(buffer_size as u16); - dcd.u32(max_bitrate); - dcd.u32(avg_bitrate); - if !asc.is_empty() { - put_descriptor(&mut dcd, 5, asc); - } - let mut es = BoxBuf::new(); - es.u16(1); // ES_ID - es.u8(0); - put_descriptor(&mut es, 4, &dcd.buf); - put_descriptor(&mut es, 6, &[2]); // SLConfigDescriptor, predefined = 2 (MP4) - let mut b = BoxBuf::new(); - b.u32(0); - put_descriptor(&mut b, 3, &es.buf); - b.buf -} - -impl SampleEntry { - /// Convenience constructor for a video entry. - pub fn video(format: FourCc, codec: CodecConfig, width: u16, height: u16) -> Self { - let compressor_name = match &codec { - CodecConfig::ProRes { fourcc } => match &fourcc.0 { - b"apch" => "Apple ProRes 422 HQ", - b"apcn" => "Apple ProRes 422", - b"apcs" => "Apple ProRes 422 LT", - b"apco" => "Apple ProRes 422 Proxy", - b"ap4h" => "Apple ProRes 4444", - _ => "Apple ProRes 4444 XQ", - }, - CodecConfig::Jpeg { .. } => "Photo - JPEG", - _ => "", - }; - SampleEntry { - format, - data_reference_index: 1, - codec, - video: Some(VideoParams { - width, - height, - horiz_resolution: 0x0048_0000, - vert_resolution: 0x0048_0000, - frame_count: 1, - compressor_name: compressor_name.into(), - depth: 24, - ..Default::default() - }), - audio: None, - bitrate: None, - } - } - - /// Convenience constructor for an audio entry (ISO v0 layout; the muxer upgrades PCM in MOV to v2). - pub fn audio(format: FourCc, codec: CodecConfig, channels: u32, sample_rate: f64, sample_size: u16) -> Self { - SampleEntry { - format, - data_reference_index: 1, - codec, - video: None, - audio: Some(AudioParams { channels, sample_rate, sample_size, ..Default::default() }), - bitrate: None, - } - } - - /// H.264 entry (`avc1`). - pub fn avc(cfg: AvcConfig, width: u16, height: u16) -> Self { - Self::video(FourCc(*b"avc1"), CodecConfig::Avc(cfg), width, height) - } - /// HEVC entry (`hvc1`, parameter sets in the config only). - pub fn hevc(cfg: HevcConfig, width: u16, height: u16) -> Self { - Self::video(FourCc(*b"hvc1"), CodecConfig::Hevc(cfg), width, height) - } - /// ProRes entry; `fourcc` one of `apch`/`apcn`/`apcs`/`apco`/`ap4h`/`ap4x`. - pub fn prores(fourcc: FourCc, width: u16, height: u16) -> Self { - let mut e = Self::video(fourcc, CodecConfig::ProRes { fourcc }, width, height); - if let Some(v) = e.video.as_mut() - && matches!(&fourcc.0, b"ap4h" | b"ap4x") - { - v.depth = 32; - } - e - } - /// Avid DNxHD / DNxHR entry (`AVdn`, or `AVdh` for DNxHR). - pub fn dnx(fourcc: FourCc, width: u16, height: u16) -> Self { - let mut e = Self::video(fourcc, CodecConfig::Dnx { fourcc }, width, height); - if let Some(v) = e.video.as_mut() { - v.depth = 24; - } - e - } - /// Motion-JPEG entry (`jpeg`). - pub fn jpeg(width: u16, height: u16) -> Self { - let f = FourCc(*b"jpeg"); - Self::video(f, CodecConfig::Jpeg { fourcc: f }, width, height) - } - /// AAC entry (`mp4a` + `esds`) from an AudioSpecificConfig. - pub fn aac(asc: Vec, channels: u32, sample_rate: u32) -> Self { - Self::audio(FourCc(*b"mp4a"), CodecConfig::Aac(AacConfig::from_asc(asc)), channels, sample_rate as f64, 16) - } - /// PCM entry. The muxer picks the on-disk format (`sowt`/`twos`/`lpcm` for MOV, `ipcm`/`fpcm` for MP4). - pub fn pcm(cfg: PcmConfig) -> Self { - Self::audio(FourCc(*b"lpcm"), CodecConfig::Pcm(cfg), cfg.channels, cfg.sample_rate, cfg.bits) - } - - /// Serialize as a complete sample entry box. For PCM the format is chosen from the brand. - pub(crate) fn write(&self, b: &mut BoxBuf, qt: bool) -> Result<()> { - if let CodecConfig::Unknown { fourcc, raw } = &self.codec { - let m = b.start(&fourcc.0); - b.bytes(raw); - b.end(m); - return Ok(()); - } - if let CodecConfig::Timecode(tc) = &self.codec { - let m = b.start(b"tmcd"); - b.zeros(6); - b.u16(self.data_reference_index); - b.u32(0); - b.u32(tc.flags); - b.u32(tc.timescale); - b.u32(tc.frame_duration); - b.u8(tc.frames_per_second); - b.u8(0); - if let Some(name) = &tc.name { - let n = b.start(b"name"); - b.u16(name.len() as u16); - b.u16(0); - b.bytes(name.as_bytes()); - b.end(n); - } - b.end(m); - return Ok(()); - } - if let Some(v) = &self.video { - let m = b.start(&self.format.0); - b.zeros(6); - b.u16(self.data_reference_index); - b.u16(0); - b.u16(0); - b.u32(v.vendor.as_u32()); - b.u32(v.temporal_quality); - b.u32(v.spatial_quality); - b.u16(v.width); - b.u16(v.height); - b.u32(v.horiz_resolution); - b.u32(v.vert_resolution); - b.u32(0); - b.u16(v.frame_count); - let name = v.compressor_name.as_bytes(); - let n = name.len().min(31); - let mut nb = [0u8; 32]; - nb[0] = n as u8; - nb[1..1 + n].copy_from_slice(&name[..n]); - b.bytes(&nb); - b.u16(v.depth); - b.i16(-1); - match &self.codec { - CodecConfig::Avc(c) => b.leaf(b"avcC", &c.to_bytes()), - CodecConfig::Hevc(c) => b.leaf(b"hvcC", &c.to_bytes()), - CodecConfig::Av1(c) => b.leaf(b"av1C", &c.to_bytes()), - CodecConfig::Vp9(c) => b.leaf(b"vpcC", &c.to_bytes()), - _ => {} - } - if let Some(g) = v.gamma { - b.leaf(b"gama", &g.to_be_bytes()); - } - if let Some(fi) = v.field_info { - b.leaf(b"fiel", &[fi.fields, fi.detail]); - } - if let Some(ci) = &v.color { - let m = b.start(b"colr"); - match ci { - ColorInfo::Nclx { primaries, transfer, matrix, full_range } => { - b.bytes(b"nclx"); - b.u16(*primaries); - b.u16(*transfer); - b.u16(*matrix); - b.u8(if *full_range { 0x80 } else { 0 }); - } - ColorInfo::Nclc { primaries, transfer, matrix } => { - b.bytes(b"nclc"); - b.u16(*primaries); - b.u16(*transfer); - b.u16(*matrix); - } - ColorInfo::Icc { kind, profile } => { - b.bytes(&kind.0); - b.bytes(profile); - } - } - b.end(m); - } - if let Some(md) = &v.mastering_display { - b.leaf(b"mdcv", &md.to_bytes()); - } - if let Some((cll, fall)) = v.content_light { - let mut d = cll.to_be_bytes().to_vec(); - d.extend_from_slice(&fall.to_be_bytes()); - b.leaf(b"clli", &d); - } - if let Some((h, vv)) = v.pixel_aspect { - let m = b.start(b"pasp"); - b.u32(h); - b.u32(vv); - b.end(m); - } - if let Some(c) = v.clean_aperture { - let m = b.start(b"clap"); - b.u32(c.width.0); - b.u32(c.width.1); - b.u32(c.height.0); - b.u32(c.height.1); - b.i32(c.horiz_offset.0); - b.u32(c.horiz_offset.1); - b.i32(c.vert_offset.0); - b.u32(c.vert_offset.1); - b.end(m); - } - if let Some(br) = self.bitrate { - write_btrt(b, br); - } - b.end(m); - return Ok(()); - } - let a = self.audio.clone().ok_or_else(|| Error::Mux("sample entry has neither video nor audio params".into()))?; - // Choose the on-disk format and sound description version. - let (format, version, pcm) = match &self.codec { - CodecConfig::Pcm(p) => { - if qt { - if p.bits == 16 && !p.float { - (if p.big_endian { *b"twos" } else { *b"sowt" }, 0u16, Some(*p)) - } else if p.bits == 8 && !p.float { - (*b"raw ", 0, Some(*p)) - } else { - (*b"lpcm", 2, Some(*p)) - } - } else { - (if p.float { *b"fpcm" } else { *b"ipcm" }, 0, Some(*p)) - } - } - _ => (self.format.0, if qt { a.qt_version.min(2) } else { 0 }, None), - }; - let m = b.start(&format); - b.zeros(6); - b.u16(self.data_reference_index); - if version == 2 { - let p = pcm.unwrap_or_default(); - b.u16(2); - b.u16(0); - b.u32(0); - b.u16(3); - b.u16(16); - b.i16(-2); - b.u16(0); - b.u32(65536); - b.u32(72); - b.f64(p.sample_rate); - b.u32(p.channels); - b.u32(0x7F00_0000); - b.u32(p.bits as u32); - let flags = (p.float as u32) | ((p.big_endian as u32) << 1) | (((p.signed && !p.float) as u32) << 2) | 8; - b.u32(flags); - b.u32(p.bytes_per_frame()); - b.u32(1); - } else { - b.u16(version); - b.u16(0); - b.u32(0); - b.u16(a.channels.min(u16::MAX as u32) as u16); - b.u16(match pcm { - Some(p) => p.bits, - None => { - if a.sample_size == 0 { - 16 - } else { - a.sample_size - } - } - }); - b.i16(if version == 1 { a.compression_id } else { 0 }); - b.u16(0); - let sr = if a.sample_rate < 65536.0 { (a.sample_rate * 65536.0).round() as u32 } else { 0 }; - b.u32(sr); - if version == 1 { - b.u32(a.samples_per_packet); - b.u32(a.bytes_per_packet); - b.u32(a.bytes_per_frame); - b.u32(a.bytes_per_sample); - } - } - match &self.codec { - CodecConfig::Aac(c) => { - b.leaf(b"esds", &esds_payload(0x40, &c.asc, c.max_bitrate, c.avg_bitrate, c.buffer_size)); - } - CodecConfig::Mp3 if format == *b"mp4a" => { - b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0)); - } - CodecConfig::Alac { cookie } => { - let m = b.start_full(b"alac", 0, 0); - b.bytes(cookie); - b.end(m); - } - CodecConfig::Opus(o) => b.leaf(b"dOps", &o.to_bytes()), - CodecConfig::Flac(fl) => { - let m = b.start_full(b"dfLa", 0, 0); - b.bytes(&fl.metadata_blocks); - b.end(m); - } - CodecConfig::Ac3 { dac3 } => b.leaf(b"dac3", dac3), - CodecConfig::Eac3 { dec3 } => b.leaf(b"dec3", dec3), - CodecConfig::Pcm(p) if !qt => { - let m = b.start_full(b"pcmC", 0, 0); - b.u8(if p.big_endian { 0 } else { 1 }); - b.u8(p.bits as u8); - b.end(m); - } - CodecConfig::Pcm(p) if p.channels == 6 => { - // QuickTime channel layout: kAudioChannelLayoutTag_MPEG_5_1_A (L R C LFE Ls Rs) - let m = b.start_full(b"chan", 0, 0); - b.u32((121 << 16) | 6); - b.u32(0); - b.u32(0); - b.end(m); - } - _ => {} - } - if let Some(br) = self.bitrate { - write_btrt(b, br); - } - b.end(m); - Ok(()) - } -} - -fn write_btrt(b: &mut BoxBuf, br: BitRate) { - let m = b.start(b"btrt"); - b.u32(br.buffer_size); - b.u32(br.max_bitrate); - b.u32(br.avg_bitrate); - b.end(m); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn roundtrip(e: &SampleEntry, qt: bool, handler: &[u8; 4]) -> SampleEntry { - let mut b = BoxBuf::new(); - e.write(&mut b, qt).unwrap(); - let bx = crate::isobmff::bytes::boxes(&b.buf).next().unwrap().unwrap(); - parse_sample_entry(bx.kind, bx.payload, FourCc(*handler), qt).unwrap() - } - - #[test] - fn avcc_roundtrip() { - let cfg = AvcConfig::new(vec![vec![0x67, 0x64, 0x00, 0x1F, 0xAC]], vec![vec![0x68, 0xEB]], 4); - assert_eq!(cfg.profile, 0x64); - assert_eq!(AvcConfig::parse(&cfg.to_bytes()).unwrap(), cfg); - let mut e = SampleEntry::avc(cfg, 1920, 1080); - e.video.as_mut().unwrap().color = Some(ColorInfo::Nclx { primaries: 1, transfer: 1, matrix: 1, full_range: false }); - e.video.as_mut().unwrap().pixel_aspect = Some((4, 3)); - e.video.as_mut().unwrap().field_info = Some(FieldInfo { fields: 2, detail: 9 }); - e.video.as_mut().unwrap().clean_aperture = - Some(CleanAperture { width: (1920, 1), height: (1080, 1), horiz_offset: (-2, 1), vert_offset: (0, 1) }); - e.video.as_mut().unwrap().mastering_display = Some(MasteringDisplay::bt2020(1000.0, 0.0001)); - e.video.as_mut().unwrap().content_light = Some((1000, 400)); - e.bitrate = Some(BitRate { buffer_size: 1, max_bitrate: 2, avg_bitrate: 3 }); - assert_eq!(roundtrip(&e, false, b"vide"), e); - assert_eq!(MasteringDisplay::bt2020(1000.0, 0.0001).max_nits(), 1000.0); - } - - #[test] - fn hvcc_roundtrip() { - let cfg = HevcConfig { - general_profile_idc: 1, - general_profile_compatibility_flags: 0x6000_0000, - general_constraint_indicator_flags: 0x9000_0000_0000, - general_level_idc: 93, - chroma_format_idc: 1, - bit_depth_luma: 8, - bit_depth_chroma: 8, - length_size: 4, - num_temporal_layers: 1, - temporal_id_nested: true, - arrays: vec![ - HevcNalArray { completeness: true, nal_type: 32, nalus: vec![vec![0x40, 1]] }, - HevcNalArray { completeness: true, nal_type: 33, nalus: vec![vec![0x42, 1, 2]] }, - HevcNalArray { completeness: true, nal_type: 34, nalus: vec![vec![0x44, 1]] }, - ], - ..Default::default() - }; - let back = HevcConfig::parse(&cfg.to_bytes()).unwrap(); - assert_eq!(back, cfg); - assert_eq!(back.sps(), vec![&[0x42u8, 1, 2][..]]); - } - - #[test] - fn av1_vp9_opus_roundtrip() { - let a = Av1Config { seq_profile: 1, seq_level_idx_0: 8, high_bitdepth: true, config_obus: vec![1, 2], ..Default::default() }; - assert_eq!(Av1Config::parse(&a.to_bytes()).unwrap(), a); - let v = VpcConfig { - profile: 2, - level: 31, - bit_depth: 10, - chroma_subsampling: 1, - full_range: true, - colour_primaries: 9, - transfer_characteristics: 16, - matrix_coefficients: 9, - codec_init: vec![], - }; - assert_eq!(VpcConfig::parse(&v.to_bytes()).unwrap(), v); - let o = OpusConfig { - output_channels: 2, - pre_skip: 312, - input_sample_rate: 48000, - output_gain: 0, - channel_mapping_family: 0, - channel_mapping: vec![], - }; - assert_eq!(OpusConfig::parse(&o.to_bytes()).unwrap(), o); - } - - #[test] - fn aac_esds_roundtrip() { - let e = SampleEntry::aac(vec![0x12, 0x10], 2, 44100); - match &e.codec { - CodecConfig::Aac(a) => { - assert_eq!((a.object_type, a.sample_rate, a.channel_config), (2, 44100, 2)); - } - _ => panic!(), - } - assert_eq!(roundtrip(&e, false, b"soun"), e); - assert_eq!(roundtrip(&e, true, b"soun"), e); - } - - #[test] - fn pcm_entries() { - for (bits, float, be, qt) in [ - (16, false, false, true), - (16, false, true, true), - (24, false, false, true), - (32, true, false, true), - (24, false, true, false), - (32, true, false, false), - ] { - let p = PcmConfig { bits, float, big_endian: be, signed: true, channels: 2, sample_rate: 48000.0 }; - let e = SampleEntry::pcm(p); - let back = roundtrip(&e, qt, b"soun"); - assert_eq!(back.codec, CodecConfig::Pcm(p), "{bits} {float} {be} {qt}"); - } - } - - #[test] - fn timecode_and_prores() { - let tc = - TimecodeConfig { flags: 1, timescale: 30000, frame_duration: 1001, frames_per_second: 30, start_frame: None, name: Some("reel".into()) }; - let e = SampleEntry { - format: FourCc(*b"tmcd"), - data_reference_index: 1, - codec: CodecConfig::Timecode(tc.clone()), - video: None, - audio: None, - bitrate: None, - }; - assert_eq!(roundtrip(&e, true, b"tmcd"), e); - assert_eq!(tc.format_frame(107892), "01:00:00;00"); - let nd = TimecodeConfig { flags: 0, frames_per_second: 24, ..Default::default() }; - assert_eq!(nd.format_frame(86400), "01:00:00:00"); - let p = SampleEntry::prores(FourCc(*b"apch"), 1920, 1080); - assert_eq!(roundtrip(&p, true, b"vide"), p); - } - - #[test] - fn asc_parse() { - assert_eq!(parse_asc(&[0x11, 0x90]), Some((2, 48000, 2))); - assert_eq!(parse_asc(&[]), None); - } -} diff --git a/crates/video/src/isobmff/demux.rs b/crates/video/src/isobmff/demux.rs deleted file mode 100644 index 0b82e5c0..00000000 --- a/crates/video/src/isobmff/demux.rs +++ /dev/null @@ -1,1173 +0,0 @@ -//! Demuxer: parses box structure lazily (headers + `moov`/`moof` only) and builds per-track sample tables. - -use crate::isobmff::bytes::{Cur, FourCc, boxes, find, parse_box_header}; -use crate::isobmff::codec::{CodecConfig, SampleEntry, parse_sample_entry}; -use crate::isobmff::error::{Error, Result, invalid}; -use crate::isobmff::source::ByteSource; -use std::collections::HashMap; - -/// Hard cap on per-track sample count (after PCM chunk merging). -const MAX_SAMPLES: usize = 1 << 26; -/// Hard cap on the size of an in-memory `moov`/`moof` box. -const MAX_HEADER_BOX: u64 = 1 << 30; - -/// One sample (access unit) of a track, in the track's media timescale. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub struct Sample { - /// Absolute byte offset in the file. - pub offset: u64, - pub size: u32, - /// Decode timestamp (raw media time, edit lists not applied). - pub dts: i64, - /// Presentation timestamp = dts + composition offset (raw media time). - pub pts: i64, - pub duration: u32, - pub is_sync: bool, - /// 1-based index into [`Track::entries`]. - pub description_index: u32, -} - -/// One edit list entry (`elst`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Edit { - /// Duration of this edit in the movie timescale. - pub segment_duration: u64, - /// Start time within the media (track timescale); `-1` marks an empty edit. - pub media_time: i64, - /// Media rate as 16.16 fixed point (0x0001_0000 = normal speed, 0 = dwell). - pub media_rate: i32, -} - -/// Broad classification of a track by its handler. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TrackKind { - Video, - Audio, - Timecode, - Subtitle, - Other, -} - -/// A sample-to-group mapping (`sbgp`) with the matching descriptions (`sgpd`) kept raw. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct SampleGroup { - pub grouping_type: FourCc, - /// (sample_count, group_description_index) runs. - pub runs: Vec<(u32, u32)>, - /// Raw description entries from `sgpd`. - pub descriptions: Vec>, -} - -/// A demuxed track. -#[derive(Clone, Debug, PartialEq)] -pub struct Track { - pub id: u32, - pub kind: TrackKind, - pub handler: FourCc, - pub handler_name: String, - /// Media timescale (`mdhd`). - pub timescale: u32, - /// Media duration in the media timescale (`mdhd`). - pub duration: u64, - /// Track duration in the movie timescale (`tkhd`). - pub tkhd_duration: u64, - /// ISO 639-2/T language code (e.g. `und`, `eng`). - pub language: String, - /// `tkhd` presentation size (16.16 fixed point converted to integer pixels, rounded down). - pub width: u32, - pub height: u32, - pub enabled: bool, - pub alternate_group: u16, - /// `tkhd` volume (8.8 fixed point). - pub volume: i16, - /// `tkhd` transformation matrix. - pub matrix: [i32; 9], - pub entries: Vec, - pub samples: Vec, - pub edits: Vec, - /// Offset to add to raw pts to get presentation time (track timescale), derived from the - /// leading empty edits and the first non-empty edit's `media_time`. - pub edit_offset: i64, - /// Track references (`tref`): (type, track ids). - pub references: Vec<(FourCc, Vec)>, - /// Raw `sdtp` bytes (one per sample) if present. - pub sdtp: Vec, - pub sample_groups: Vec, - /// True if uncompressed audio samples were merged into one [`Sample`] per chunk. - pub pcm_chunked: bool, - sync_index: Vec, - pts_order: Vec, -} - -impl Track { - /// The first sample entry's codec configuration. - pub fn codec(&self) -> Option<&CodecConfig> { - self.entries.first().map(|e| &e.codec) - } - pub fn video(&self) -> Option<&crate::isobmff::codec::VideoParams> { - self.entries.first().and_then(|e| e.video.as_ref()) - } - /// Clockwise quarter turns (0–3) the `tkhd` matrix applies for display, or None when it is not - /// a pure 0/90/180/270° rotation (mirrored, scaled or sheared — left to the caller). - /// - /// ISO/IEC 14496-12 §8.3.2: a sample point (p, q) is displayed at p′ = a·p + c·q + x, - /// q′ = b·p + d·q + y for the matrix {a, b, u, c, d, v, x, y, w}, with y pointing down. So - /// {0, 1, −1, 0} sends the top row to the right-hand column (90° clockwise — iPhone portrait - /// video), {−1, 0, 0, −1} is 180° and {0, −1, 1, 0} is 270°. Only the signs matter: a·d − b·c - /// carries the scale, which the presentation size already reflects. - pub fn display_rotation(&self) -> Option { - let [a, b, _, c, d, _, _, _, _] = self.matrix; - match (a.signum(), b.signum(), c.signum(), d.signum()) { - (1, 0, 0, 1) => Some(0), - (0, 1, -1, 0) => Some(1), - (-1, 0, 0, -1) => Some(2), - (0, -1, 1, 0) => Some(3), - _ => None, - } - } - pub fn audio(&self) -> Option<&crate::isobmff::codec::AudioParams> { - self.entries.first().and_then(|e| e.audio.as_ref()) - } - /// Presentation (edit-adjusted) pts of sample `i`. - pub fn presentation_pts(&self, i: usize) -> Option { - self.samples.get(i).map(|s| s.pts.saturating_add(self.edit_offset)) - } - /// Index of the sample being presented at raw media time `pts` (the last sample, in - /// presentation order, whose pts is ≤ `pts`; the first sample if `pts` precedes all). - pub fn sample_at_pts(&self, pts: i64) -> Option { - if self.pts_order.is_empty() { - return None; - } - let k = self.pts_order.partition_point(|&i| self.samples[i as usize].pts <= pts); - Some(self.pts_order[k.saturating_sub(1)] as usize) - } - /// Like [`sample_at_pts`](Self::sample_at_pts) but for edit-adjusted presentation time. - pub fn sample_at_presentation_time(&self, t: i64) -> Option { - self.sample_at_pts(t.saturating_sub(self.edit_offset)) - } - /// The closest sync sample at or before `index` (decode order); 0 if there is none. - pub fn sync_sample_before(&self, index: usize) -> usize { - let k = self.sync_index.partition_point(|&i| i as usize <= index); - if k == 0 { 0 } else { self.sync_index[k - 1] as usize } - } - /// Indices of all sync samples. - pub fn sync_samples(&self) -> &[u32] { - &self.sync_index - } - - fn finish_indexes(&mut self) { - self.sync_index = self.samples.iter().enumerate().filter(|(_, s)| s.is_sync).map(|(i, _)| i as u32).collect(); - let mut order: Vec = (0..self.samples.len() as u32).collect(); - order.sort_by_key(|&i| (self.samples[i as usize].pts, i)); - self.pts_order = order; - } -} - -/// Text metadata from `udta` (QuickTime `©xxx` atoms and iTunes-style `meta/ilst`, incl. `keys`). -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub struct Metadata { - pub entries: Vec<(String, String)>, -} - -impl Metadata { - pub fn get(&self, key: &str) -> Option<&str> { - self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) - } - /// Title from `©nam` or the `com.apple.quicktime.title` key. - pub fn title(&self) -> Option<&str> { - self.get("©nam").or_else(|| self.get("com.apple.quicktime.title")).or_else(|| self.get("title")) - } -} - -/// A parsed MP4/MOV file (headers and sample tables; media data stays in the source). -#[derive(Clone, Debug, PartialEq)] -pub struct Mp4File { - pub major_brand: FourCc, - pub minor_version: u32, - pub compatible_brands: Vec, - /// QuickTime file (major brand `qt ` or no `ftyp`). - pub is_quicktime: bool, - /// Movie timescale (`mvhd`). - pub timescale: u32, - /// Movie duration in the movie timescale (`mvhd`; for fragmented files, the longest track). - pub duration: u64, - pub tracks: Vec, - pub metadata: Metadata, - /// File uses movie fragments (`mvex` present). - pub fragmented: bool, - /// Byte offset of the `moov` box. - pub moov_offset: u64, - /// Number of `moof` boxes found. - pub fragment_count: usize, -} - -impl Mp4File { - /// Read the bytes of sample `index` of track `track` (index into [`tracks`](Self::tracks)). - pub fn read_sample(&self, src: &S, track: usize, index: usize) -> Result> { - let t = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - let s = t.samples.get(index).ok_or(Error::NoSuchSample(index))?; - read_range(src, s.offset, s.size as u64) - } - pub fn sample_at_pts(&self, track: usize, pts: i64) -> Option { - self.tracks.get(track)?.sample_at_pts(pts) - } - pub fn sync_sample_before(&self, track: usize, index: usize) -> Option { - Some(self.tracks.get(track)?.sync_sample_before(index)) - } - /// First track of the given kind. - pub fn track_of_kind(&self, kind: TrackKind) -> Option { - self.tracks.iter().position(|t| t.kind == kind) - } -} - -fn read_range(src: &S, offset: u64, size: u64) -> Result> { - let end = offset.checked_add(size).ok_or(Error::Truncated("sample"))?; - if end > src.len() { - return Err(Error::Truncated("sample data")); - } - let mut v = vec![0u8; size as usize]; - src.read_at(offset, &mut v)?; - Ok(v) -} - -/// Parse an MP4/MOV file. Only box headers, `ftyp`, `moov` and `moof` boxes are read. -pub fn open(src: S) -> Result { - let len = src.len(); - let mut offset = 0u64; - let mut ftyp: Option> = None; - let mut moov: Option<(u64, Vec)> = None; - let mut moofs: Vec<(u64, Vec)> = Vec::new(); - while offset + 8 <= len { - let n = (len - offset).min(32) as usize; - let mut hb = [0u8; 32]; - src.read_at(offset, &mut hb[..n])?; - let h = parse_box_header(&hb[..n])?; - let size = h.size.unwrap_or(len - offset); - let end = offset.saturating_add(size).min(len); - let body_len = end.saturating_sub(offset + h.header_len); - let read_body = |max: u64| -> Result> { - if body_len > max { - return Err(Error::TooLarge("header box")); - } - read_range(&src, offset + h.header_len, body_len) - }; - match &h.kind.0 { - b"ftyp" if ftyp.is_none() => ftyp = Some(read_body(1 << 16)?), - b"moov" if moov.is_none() => moov = Some((offset, read_body(MAX_HEADER_BOX)?)), - b"moof" => moofs.push((offset, read_body(MAX_HEADER_BOX)?)), - _ => {} - } - if h.size.is_none() { - break; - } - offset = offset.saturating_add(size); - } - let (moov_offset, moov) = moov.ok_or(Error::NoMoov)?; - let mut file = Mp4File { - major_brand: FourCc::default(), - minor_version: 0, - compatible_brands: Vec::new(), - is_quicktime: true, - timescale: 0, - duration: 0, - tracks: Vec::new(), - metadata: Metadata::default(), - fragmented: false, - moov_offset, - fragment_count: moofs.len(), - }; - if let Some(f) = &ftyp { - let mut c = Cur::new(f, "ftyp"); - file.major_brand = c.fourcc()?; - file.minor_version = c.u32().unwrap_or(0); - while c.remaining() >= 4 { - file.compatible_brands.push(c.fourcc()?); - } - file.is_quicktime = file.major_brand == b"qt "; - } - let mut trex: HashMap = HashMap::new(); - let mut trak_payloads = Vec::new(); - for b in boxes(&moov) { - let b = b?; - match &b.kind.0 { - b"cmov" => return Err(Error::Unsupported("compressed movie header (cmov)".into())), - b"mvhd" => { - let mut c = Cur::new(b.payload, "mvhd"); - let (v, _) = c.full_header()?; - if v == 1 { - c.skip(16)?; - file.timescale = c.u32()?; - file.duration = c.u64()?; - } else { - c.skip(8)?; - file.timescale = c.u32()?; - file.duration = c.u32()? as u64; - } - } - b"trak" => trak_payloads.push(b.payload), - b"mvex" => { - file.fragmented = true; - for m in boxes(b.payload).map_while(|b| b.ok()) { - if m.kind == b"trex" { - let mut c = Cur::new(m.payload, "trex"); - c.full_header()?; - let id = c.u32()?; - trex.insert(id, Trex { description_index: c.u32()?, duration: c.u32()?, size: c.u32()?, flags: c.u32()? }); - } - } - } - b"udta" => parse_udta(b.payload, &mut file.metadata), - b"meta" => parse_meta(b.payload, &mut file.metadata), - _ => {} - } - } - for p in trak_payloads { - let t = parse_trak(p, &file, len)?; - file.tracks.push(t); - } - // Movie fragments. - let mut next_dts: Vec = file.tracks.iter().map(|t| t.samples.last().map(|s| s.dts + s.duration as i64).unwrap_or(0)).collect(); - for (moof_off, moof) in &moofs { - parse_moof(*moof_off, moof, &mut file.tracks, &trex, &mut next_dts, len)?; - } - for t in &mut file.tracks { - if file.fragmented && !moofs.is_empty() { - let d: u64 = t.samples.iter().map(|s| s.duration as u64).sum(); - t.duration = t.duration.max(d); - if t.timescale > 0 && file.timescale > 0 { - let md = (t.duration as u128 * file.timescale as u128 / t.timescale as u128) as u64; - file.duration = file.duration.max(md); - } - } - t.finish_indexes(); - // Timecode start frame from the first sample. - if let Some(CodecConfig::Timecode(tc)) = t.entries.first_mut().map(|e| &mut e.codec) - && let Some(s) = t.samples.first() - && s.size >= 4 - { - let mut b = [0u8; 4]; - if src.read_at(s.offset, &mut b).is_ok() { - tc.start_frame = Some(u32::from_be_bytes(b)); - } - } - } - Ok(file) -} - -#[derive(Clone, Copy, Debug, Default)] -struct Trex { - description_index: u32, - duration: u32, - size: u32, - flags: u32, -} - -fn parse_language(code: u16) -> String { - if code < 0x400 { - // QuickTime Macintosh language code. - return if code == 0 { "eng".into() } else { "und".into() }; - } - let c = |s: u16| (((code >> s) & 0x1F) as u8 + 0x60) as char; - let s: String = [c(10), c(5), c(0)].into_iter().collect(); - if s.chars().all(|ch| ch.is_ascii_lowercase()) { s } else { "und".into() } -} - -fn rescale(v: i128, from: u32, to: u32) -> i64 { - if from == 0 { - return 0; - } - (v * to as i128 / from as i128).clamp(i64::MIN as i128, i64::MAX as i128) as i64 -} - -fn parse_trak(p: &[u8], file: &Mp4File, file_len: u64) -> Result { - let mut t = Track { - id: 0, - kind: TrackKind::Other, - handler: FourCc::default(), - handler_name: String::new(), - timescale: 0, - duration: 0, - tkhd_duration: 0, - language: "und".into(), - width: 0, - height: 0, - enabled: true, - alternate_group: 0, - volume: 0, - matrix: [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x4000_0000], - entries: Vec::new(), - samples: Vec::new(), - edits: Vec::new(), - edit_offset: 0, - references: Vec::new(), - sdtp: Vec::new(), - sample_groups: Vec::new(), - pcm_chunked: false, - sync_index: Vec::new(), - pts_order: Vec::new(), - }; - let mut mdia = None; - for b in boxes(p) { - let b = b?; - match &b.kind.0 { - b"tkhd" => { - let mut c = Cur::new(b.payload, "tkhd"); - let (v, flags) = c.full_header()?; - t.enabled = flags & 1 != 0; - if v == 1 { - c.skip(16)?; - t.id = c.u32()?; - c.skip(4)?; - t.tkhd_duration = c.u64()?; - } else { - c.skip(8)?; - t.id = c.u32()?; - c.skip(4)?; - t.tkhd_duration = c.u32()? as u64; - } - c.skip(8)?; - let _layer = c.i16()?; - t.alternate_group = c.u16()?; - t.volume = c.i16()?; - c.skip(2)?; - for m in t.matrix.iter_mut() { - *m = c.i32()?; - } - t.width = c.u32()? >> 16; - t.height = c.u32()? >> 16; - } - b"edts" => { - if let Some(e) = find(b.payload, b"elst") { - let mut c = Cur::new(e, "elst"); - let (v, _) = c.full_header()?; - let n = c.count(if v == 1 { 20 } else { 12 })?; - for _ in 0..n { - let (segment_duration, media_time) = if v == 1 { (c.u64()?, c.i64()?) } else { (c.u32()? as u64, c.i32()? as i64) }; - t.edits.push(Edit { segment_duration, media_time, media_rate: c.i32()? }); - } - } - } - b"tref" => { - for r in boxes(b.payload).map_while(|b| b.ok()) { - let ids = r.payload.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes([c[0], c[1], c[2], c[3]])).collect(); - t.references.push((r.kind, ids)); - } - } - b"mdia" => mdia = Some(b.payload), - _ => {} - } - } - let mdia = mdia.ok_or_else(|| Error::Invalid("trak without mdia".into()))?; - let mut minf = None; - for b in boxes(mdia) { - let b = b?; - match &b.kind.0 { - b"mdhd" => { - let mut c = Cur::new(b.payload, "mdhd"); - let (v, _) = c.full_header()?; - if v == 1 { - c.skip(16)?; - t.timescale = c.u32()?; - t.duration = c.u64()?; - } else { - c.skip(8)?; - t.timescale = c.u32()?; - t.duration = c.u32()? as u64; - } - t.language = parse_language(c.u16()?); - } - b"hdlr" => { - let mut c = Cur::new(b.payload, "hdlr"); - c.full_header()?; - let _pre = c.u32()?; - t.handler = c.fourcc()?; - c.skip(12)?; - let name = c.rest(); - // ISO: null-terminated UTF-8; QuickTime: Pascal string. - let s = if !name.is_empty() && name[0] as usize == name.len() - 1 && name.len() > 1 { - &name[1..] - } else { - name.split(|&b| b == 0).next().unwrap_or(&[]) - }; - t.handler_name = String::from_utf8_lossy(s).into_owned(); - } - b"minf" => minf = Some(b.payload), - _ => {} - } - } - t.kind = match &t.handler.0 { - b"vide" => TrackKind::Video, - b"soun" => TrackKind::Audio, - b"tmcd" => TrackKind::Timecode, - b"text" | b"sbtl" | b"subt" | b"clcp" => TrackKind::Subtitle, - _ => TrackKind::Other, - }; - // Edit offset in media timescale. - let mut empty = 0i128; - for e in &t.edits { - if e.media_time == -1 { - empty += e.segment_duration as i128; - } else { - t.edit_offset = rescale(empty, file.timescale, t.timescale).saturating_sub(e.media_time); - break; - } - } - if let Some(stbl) = minf.and_then(|m| find(m, b"stbl")) { - parse_stbl(stbl, &mut t, file.is_quicktime, file_len)?; - } - Ok(t) -} - -struct SttsCursor<'a> { - runs: &'a [(u32, u32)], - run: usize, - used: u32, -} - -impl SttsCursor<'_> { - /// Advance by `n` samples, returning the summed duration. - fn take(&mut self, mut n: u64) -> u64 { - let mut total = 0u64; - while n > 0 && self.run < self.runs.len() { - let (count, delta) = self.runs[self.run]; - let avail = (count - self.used) as u64; - let k = avail.min(n); - total = total.saturating_add(k.saturating_mul(delta as u64)); - n -= k; - self.used += k as u32; - if self.used >= count { - self.run += 1; - self.used = 0; - } - } - total - } - fn next_value(&mut self) -> u32 { - while self.run < self.runs.len() && self.runs[self.run].0 == 0 { - self.run += 1; - } - let Some(&(_, d)) = self.runs.get(self.run) else { return 0 }; - self.take(1); - d - } -} - -fn parse_stbl(stbl: &[u8], t: &mut Track, is_qt: bool, file_len: u64) -> Result<()> { - let mut stts: Vec<(u32, u32)> = Vec::new(); - let mut ctts: Vec<(u32, u32)> = Vec::new(); - let mut stss: Option> = None; - let mut stsc: Vec<(u32, u32, u32)> = Vec::new(); - let mut const_size = 0u32; - let mut sample_count = 0usize; - let mut sizes: Vec = Vec::new(); - let mut chunks: Vec = Vec::new(); - let mut sbgp: Vec<(FourCc, Vec<(u32, u32)>)> = Vec::new(); - let mut sgpd: HashMap>> = HashMap::new(); - for b in boxes(stbl) { - let b = b?; - let mut c = Cur::new(b.payload, "stbl child"); - match &b.kind.0 { - b"stsd" => { - c.full_header()?; - let n = c.count(8)?; - for e in boxes(c.rest()).take(n) { - let e = e?; - t.entries.push(parse_sample_entry(e.kind, e.payload, t.handler, is_qt)?); - } - } - b"stts" => { - c.full_header()?; - let n = c.count(8)?; - stts = (0..n).map(|_| Ok((c.u32()?, c.u32()?))).collect::>()?; - } - b"ctts" => { - c.full_header()?; - let n = c.count(8)?; - ctts = (0..n).map(|_| Ok((c.u32()?, c.u32()?))).collect::>()?; - } - b"stss" => { - c.full_header()?; - let n = c.count(4)?; - let mut v: Vec = (0..n).map(|_| c.u32()).collect::>()?; - v.sort_unstable(); - stss = Some(v); - } - b"stsc" => { - c.full_header()?; - let n = c.count(12)?; - stsc = (0..n).map(|_| Ok((c.u32()?, c.u32()?, c.u32()?))).collect::>()?; - } - b"stsz" => { - c.full_header()?; - const_size = c.u32()?; - let n = c.u32()? as usize; - if const_size == 0 { - c.check_count(n, 4)?; - sizes = (0..n).map(|_| c.u32()).collect::>()?; - sample_count = n; - } else { - // Samples of constant size must fit in the file: clamp absurd counts. - sample_count = (n as u64).min(file_len / const_size as u64) as usize; - } - } - b"stz2" => { - c.full_header()?; - let field = c.u32()? & 0xFF; - let n = c.u32()? as usize; - let bits = match field { - 4 | 8 | 16 => field as usize, - _ => return invalid(format!("stz2 field size {field}")), - }; - if n.checked_mul(bits).is_none_or(|b| b.div_ceil(8) > c.remaining()) { - return Err(Error::Truncated("stz2")); - } - let d = c.rest(); - sizes = (0..n) - .map(|i| match bits { - 4 => ((d[i / 2] >> if i % 2 == 0 { 4 } else { 0 }) & 0xF) as u32, - 8 => d[i] as u32, - _ => u16::from_be_bytes([d[2 * i], d[2 * i + 1]]) as u32, - }) - .collect(); - sample_count = n; - } - b"stco" => { - c.full_header()?; - let n = c.count(4)?; - chunks = (0..n).map(|_| c.u32().map(|v| v as u64)).collect::>()?; - } - b"co64" => { - c.full_header()?; - let n = c.count(8)?; - chunks = (0..n).map(|_| c.u64()).collect::>()?; - } - b"sdtp" => { - c.full_header()?; - t.sdtp = c.rest().to_vec(); - } - b"sbgp" => { - let (v, _) = c.full_header()?; - let gt = c.fourcc()?; - if v == 1 { - c.u32()?; - } - let n = c.count(8)?; - let runs = (0..n).map(|_| Ok((c.u32()?, c.u32()?))).collect::>()?; - sbgp.push((gt, runs)); - } - b"sgpd" => { - let (v, _) = c.full_header()?; - let gt = c.fourcc()?; - let default_len = if v >= 1 { c.u32()? } else { 0 }; - if v >= 2 { - c.u32()?; - } - let n = c.count(0)?; - let mut descs = Vec::new(); - for _ in 0..n { - let l = if v >= 1 && default_len == 0 { c.u32()? } else { default_len }; - if l == 0 { - // Version 0 without known length: keep the remainder as a single entry. - descs.push(c.rest().to_vec()); - break; - } - descs.push(c.bytes(l as usize)?.to_vec()); - } - sgpd.insert(gt, descs); - } - _ => {} - } - } - t.sample_groups = - sbgp.into_iter().map(|(gt, runs)| SampleGroup { grouping_type: gt, runs, descriptions: sgpd.remove(>).unwrap_or_default() }).collect(); - if sample_count == 0 || chunks.is_empty() || stsc.is_empty() { - return Ok(()); - } - if stsc[0].0 == 0 || stsc.windows(2).any(|w| w[1].0 <= w[0].0) { - return invalid("stsc first_chunk values are not increasing from 1"); - } - let pcm = match t.entries.first().map(|e| &e.codec) { - Some(CodecConfig::Pcm(p)) if const_size != 0 => Some(*p), - _ => None, - }; - let mut stts_c = SttsCursor { runs: &stts, run: 0, used: 0 }; - let mut ctts_c = SttsCursor { runs: &ctts, run: 0, used: 0 }; - let mut samples = Vec::new(); - let mut sidx = 0usize; - let mut dts = 0i64; - let mut stsc_i = 0usize; - if let Some(p) = pcm { - t.pcm_chunked = true; - let a = t.audio().cloned().unwrap_or_default(); - let frame_bytes = |frames: u64| -> u64 { - if a.qt_version == 1 && a.bytes_per_frame > 0 && a.samples_per_packet > 0 { - frames / a.samples_per_packet as u64 * a.bytes_per_frame as u64 - } else if const_size > 1 { - frames * const_size as u64 - } else { - frames * p.bytes_per_frame() as u64 - } - }; - if chunks.len() > MAX_SAMPLES { - return Err(Error::TooLarge("chunk count")); - } - samples.reserve(chunks.len()); - for (ci, &off) in chunks.iter().enumerate() { - while stsc_i + 1 < stsc.len() && stsc[stsc_i + 1].0 as usize <= ci + 1 { - stsc_i += 1; - } - let spc = (stsc[stsc_i].1 as usize).min(sample_count - sidx); - if spc == 0 { - continue; - } - let dur = stts_c.take(spc as u64); - let size = frame_bytes(spc as u64).min(u32::MAX as u64) as u32; - samples.push(Sample { - offset: off, - size, - dts, - pts: dts, - duration: dur.min(u32::MAX as u64) as u32, - is_sync: true, - description_index: stsc[stsc_i].2, - }); - dts = dts.saturating_add(dur.min(i64::MAX as u64) as i64); - sidx += spc; - if sidx >= sample_count { - break; - } - } - } else { - if sample_count > MAX_SAMPLES { - return Err(Error::TooLarge("sample count")); - } - samples.reserve(sample_count); - let mut stss_i = 0usize; - 'chunks: for (ci, &off) in chunks.iter().enumerate() { - while stsc_i + 1 < stsc.len() && stsc[stsc_i + 1].0 as usize <= ci + 1 { - stsc_i += 1; - } - let (_, spc, sdi) = stsc[stsc_i]; - let mut pos = off; - for _ in 0..spc { - if sidx >= sample_count { - break 'chunks; - } - let size = if const_size != 0 { const_size } else { sizes[sidx] }; - let duration = stts_c.next_value(); - let cts = ctts_c.next_value() as i32 as i64; - let is_sync = match &stss { - None => true, - Some(v) => { - while stss_i < v.len() && (v[stss_i] as usize) < sidx + 1 { - stss_i += 1; - } - stss_i < v.len() && v[stss_i] as usize == sidx + 1 - } - }; - samples.push(Sample { offset: pos, size, dts, pts: dts.saturating_add(cts), duration, is_sync, description_index: sdi }); - pos = pos.saturating_add(size as u64); - dts = dts.saturating_add(duration as i64); - sidx += 1; - } - } - } - t.samples = samples; - Ok(()) -} - -fn parse_moof(moof_off: u64, moof: &[u8], tracks: &mut [Track], trex: &HashMap, next_dts: &mut [i64], file_len: u64) -> Result<()> { - // End of data of the previous traf, for the base-offset default of subsequent trafs. - let mut prev_end: Option = None; - for b in boxes(moof) { - let b = b?; - if b.kind != b"traf" { - continue; - } - let mut tfhd = None; - let mut tfdt = None; - let mut truns = Vec::new(); - for c in boxes(b.payload) { - let c = c?; - match &c.kind.0 { - b"tfhd" => tfhd = Some(c.payload), - b"tfdt" => tfdt = Some(c.payload), - b"trun" => truns.push(c.payload), - _ => {} - } - } - let tfhd = tfhd.ok_or_else(|| Error::Invalid("traf without tfhd".into()))?; - let mut c = Cur::new(tfhd, "tfhd"); - let (_, fl) = c.full_header()?; - let id = c.u32()?; - let Some(ti) = tracks.iter().position(|t| t.id == id) else { continue }; - let d = trex.get(&id).copied().unwrap_or_default(); - let base = if fl & 1 != 0 { - c.u64()? - } else if fl & 0x20000 != 0 { - moof_off - } else { - prev_end.unwrap_or(moof_off) - }; - let sdi = if fl & 2 != 0 { c.u32()? } else { d.description_index }; - let def_dur = if fl & 8 != 0 { c.u32()? } else { d.duration }; - let def_size = if fl & 0x10 != 0 { c.u32()? } else { d.size }; - let def_flags = if fl & 0x20 != 0 { c.u32()? } else { d.flags }; - if let Some(p) = tfdt { - let mut c = Cur::new(p, "tfdt"); - let (v, _) = c.full_header()?; - let t = if v == 1 { c.u64()? } else { c.u32()? as u64 }; - next_dts[ti] = t.min(i64::MAX as u64) as i64; - } - let mut data_pos = base; - for tr in truns { - let mut c = Cur::new(tr, "trun"); - let (v, tf) = c.full_header()?; - let per = [0x100u32, 0x200, 0x400, 0x800].iter().filter(|&&f| tf & f != 0).count() * 4; - let n = c.u32()? as usize; - if per > 0 { - c.check_count(n, per)?; - } else if (n as u64).saturating_mul(def_size.max(1) as u64) > file_len { - return Err(Error::TooLarge("trun sample count")); - } - if tracks[ti].samples.len() + n > MAX_SAMPLES { - return Err(Error::TooLarge("sample count")); - } - if tf & 1 != 0 { - data_pos = base.wrapping_add_signed(c.i32()? as i64); - } - let first_flags = if tf & 4 != 0 { Some(c.u32()?) } else { None }; - let t = &mut tracks[ti]; - t.samples.reserve(n); - for i in 0..n { - let dur = if tf & 0x100 != 0 { c.u32()? } else { def_dur }; - let size = if tf & 0x200 != 0 { c.u32()? } else { def_size }; - let flags = if tf & 0x400 != 0 { c.u32()? } else { def_flags }; - // first_sample_flags overrides the flags of the first sample only. - let flags = if i == 0 { first_flags.unwrap_or(flags) } else { flags }; - let cto = if tf & 0x800 != 0 { - let raw = c.u32()?; - if v == 0 { raw as i64 } else { raw as i32 as i64 } - } else { - 0 - }; - let dts = next_dts[ti]; - t.samples.push(Sample { - offset: data_pos, - size, - dts, - pts: dts.saturating_add(cto), - duration: dur, - is_sync: flags & 0x1_0000 == 0, - description_index: sdi, - }); - data_pos = data_pos.saturating_add(size as u64); - next_dts[ti] = dts.saturating_add(dur as i64); - } - } - prev_end = Some(data_pos); - } - Ok(()) -} - -fn parse_udta(p: &[u8], md: &mut Metadata) { - for b in boxes(p).map_while(|b| b.ok()) { - if b.kind == b"meta" { - parse_meta(b.payload, md); - } else if b.kind.0[0] == 0xA9 && b.payload.len() >= 4 { - let n = u16::from_be_bytes([b.payload[0], b.payload[1]]) as usize; - if let Some(s) = b.payload.get(4..4 + n) { - md.entries.push((b.kind.to_string_lossy(), String::from_utf8_lossy(s).into_owned())); - } - } - } -} - -fn parse_meta(p: &[u8], md: &mut Metadata) { - // QuickTime `meta` is a plain box; ISO `meta` is a full box. - let body = if p.len() >= 8 && &p[4..8] == b"hdlr" { p } else { p.get(4..).unwrap_or(&[]) }; - let mut keys: Vec = Vec::new(); - if let Some(k) = find(body, b"keys") { - let mut c = Cur::new(k, "keys"); - if c.full_header().is_ok() - && let Ok(n) = c.count(8) - { - for _ in 0..n { - let Ok(sz) = c.u32() else { break }; - let Ok(_ns) = c.u32() else { break }; - let Ok(name) = c.bytes((sz as usize).saturating_sub(8)) else { break }; - keys.push(String::from_utf8_lossy(name).into_owned()); - } - } - } - let Some(ilst) = find(body, b"ilst") else { return }; - for item in boxes(ilst).map_while(|b| b.ok()) { - let idx = item.kind.as_u32() as usize; - let key = if !keys.is_empty() && idx >= 1 && idx <= keys.len() { keys[idx - 1].clone() } else { item.kind.to_string_lossy() }; - if let Some(d) = find(item.payload, b"data") - && d.len() >= 8 - { - let ty = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) & 0xFF_FFFF; - if ty == 1 || ty == 0 { - md.entries.push((key, String::from_utf8_lossy(&d[8..]).into_owned())); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn language_codes() { - // "und" = 0x55C4 - assert_eq!(parse_language(0x55C4), "und"); - assert_eq!(parse_language(0), "eng"); - assert_eq!(parse_language(0x15C7), "eng"); - } - - #[test] - fn stts_cursor() { - let runs = [(2, 10), (0, 99), (3, 5)]; - let mut c = SttsCursor { runs: &runs, run: 0, used: 0 }; - assert_eq!(c.next_value(), 10); - assert_eq!(c.take(3), 10 + 5 + 5); - assert_eq!(c.next_value(), 5); - assert_eq!(c.next_value(), 0); - } - - #[test] - fn rejects_no_moov_and_cmov() { - let mut b = crate::isobmff::bytes::BoxBuf::new(); - b.leaf(b"free", &[0; 4]); - assert!(matches!(open(b.buf.clone()), Err(Error::NoMoov))); - let m = b.start(b"moov"); - b.leaf(b"cmov", &[]); - b.end(m); - assert!(matches!(open(b.buf), Err(Error::Unsupported(_)))); - } - - #[test] - fn metadata_qt_and_ilst() { - let mut b = crate::isobmff::bytes::BoxBuf::new(); - let m = b.start(&[0xA9, b'n', b'a', b'm']); - b.u16(2); - b.u16(0); - b.bytes(b"hi"); - b.end(m); - let meta = b.start_full(b"meta", 0, 0); - b.leaf(b"hdlr", &[0; 24]); - let il = b.start(b"ilst"); - let it = b.start(&[0xA9, b'A', b'R', b'T']); - let d = b.start(b"data"); - b.u32(1); - b.u32(0); - b.bytes(b"me"); - b.end(d); - b.end(it); - b.end(il); - b.end(meta); - let mut md = Metadata::default(); - parse_udta(&b.buf, &mut md); - assert_eq!(md.title(), Some("hi")); - assert_eq!(md.get("©ART"), Some("me")); - } - - use crate::isobmff::bytes::BoxBuf; - - fn full(b: &mut BoxBuf, kind: &[u8; 4], v: u8, f: impl FnOnce(&mut BoxBuf)) { - let m = b.start_full(kind, v, 0); - f(b); - b.end(m); - } - - /// Hand-built file: uuid/wide/skip boxes, stz2 sizes, co64, ctts v1 with negative offsets, - /// stss, sdtp, sbgp/sgpd, and an edit list with a leading empty edit. - fn handmade(stsz_override: Option<(u32, u32)>) -> Vec { - let mut b = BoxBuf::new(); - let m = b.start(b"ftyp"); - b.bytes(b"isom"); - b.u32(0); - b.bytes(b"isom"); - b.end(m); - let m = b.start(b"uuid"); - b.bytes(&[0xAB; 16]); - b.bytes(b"payload"); - b.end(m); - b.leaf(b"wide", &[]); - b.leaf(b"skip", &[1, 2, 3]); - // mdat with 64-bit size: 4 samples of 3,1,2,4 bytes. - let data_start = b.len() as u64 + 16; - b.u32(1); - b.bytes(b"mdat"); - b.u64(16 + 10); - b.bytes(&[1, 1, 1, 2, 3, 3, 4, 4, 4, 4]); - let moov = b.start(b"moov"); - full(&mut b, b"mvhd", 0, |b| { - b.u32(0); - b.u32(0); - b.u32(1000); - b.u32(500); - b.zeros(80); - }); - let trak = b.start(b"trak"); - full(&mut b, b"tkhd", 0, |b| { - b.u32(0); - b.u32(0); - b.u32(7); - b.u32(0); - b.u32(500); - b.zeros(52); - b.u32(320 << 16); - b.u32(240 << 16); - }); - let edts = b.start(b"edts"); - full(&mut b, b"elst", 1, |b| { - b.u32(2); - b.u64(100); - b.i64(-1); - b.i32(0x10000); - b.u64(400); - b.i64(20); - b.i32(0x10000); - }); - b.end(edts); - let mdia = b.start(b"mdia"); - full(&mut b, b"mdhd", 0, |b| { - b.u32(0); - b.u32(0); - b.u32(100); - b.u32(40); - b.u16(0x15C7); - b.u16(0); - }); - full(&mut b, b"hdlr", 0, |b| { - b.u32(0); - b.bytes(b"vide"); - b.zeros(12); - b.bytes(b"Video\0"); - }); - let minf = b.start(b"minf"); - let stbl = b.start(b"stbl"); - full(&mut b, b"stsd", 0, |b| { - b.u32(1); - crate::isobmff::codec::SampleEntry::jpeg(320, 240).write(b, false).unwrap(); - }); - full(&mut b, b"stts", 0, |b| { - b.u32(1); - b.u32(4); - b.u32(10); - }); - full(&mut b, b"ctts", 1, |b| { - b.u32(2); - b.u32(2); - b.i32(-5); - b.u32(2); - b.i32(5); - }); - full(&mut b, b"stss", 0, |b| { - b.u32(2); - b.u32(3); - b.u32(1); - }); - full(&mut b, b"stsc", 0, |b| { - b.u32(2); - b.u32(1); - b.u32(3); - b.u32(1); - b.u32(2); - b.u32(1); - b.u32(1); - }); - match stsz_override { - Some((size, count)) => full(&mut b, b"stsz", 0, |b| { - b.u32(size); - b.u32(count); - }), - None => full(&mut b, b"stz2", 0, |b| { - b.u32(4); - b.u32(4); - b.u8(0x31); - b.u8(0x24); - }), - } - full(&mut b, b"co64", 0, |b| { - b.u32(2); - b.u64(data_start); - b.u64(data_start + 6); - }); - full(&mut b, b"sdtp", 0, |b| b.bytes(&[0x20, 0x10, 0x10, 0x10])); - full(&mut b, b"sbgp", 0, |b| { - b.bytes(b"roll"); - b.u32(1); - b.u32(4); - b.u32(1); - }); - full(&mut b, b"sgpd", 1, |b| { - b.bytes(b"roll"); - b.u32(2); - b.u32(1); - b.i16(-1); - }); - b.end(stbl); - b.end(minf); - b.end(mdia); - b.end(trak); - b.end(moov); - // Trailing box extending to EOF (size 0). - b.u32(0); - b.bytes(b"free"); - b.bytes(&[9; 5]); - b.buf - } - - #[test] - fn handmade_sample_table() { - let d = handmade(None); - let f = open(d.as_slice()).unwrap(); - assert_eq!(f.major_brand, *b"isom"); - let t = &f.tracks[0]; - assert_eq!((t.id, t.kind, t.timescale, t.language.as_str()), (7, TrackKind::Video, 100, "eng")); - assert_eq!((t.width, t.height), (320, 240)); - assert_eq!(t.handler_name, "Video"); - let sizes: Vec = t.samples.iter().map(|s| s.size).collect(); - assert_eq!(sizes, [3, 1, 2, 4]); - let offs: Vec = t.samples.iter().map(|s| s.offset - t.samples[0].offset).collect(); - assert_eq!(offs, [0, 3, 4, 6]); - let pts: Vec = t.samples.iter().map(|s| s.pts).collect(); - assert_eq!(pts, [-5, 5, 25, 35]); - let sync: Vec = t.samples.iter().map(|s| s.is_sync).collect(); - assert_eq!(sync, [true, false, true, false]); - for (i, want) in [vec![1u8, 1, 1], vec![2], vec![3, 3], vec![4; 4]].iter().enumerate() { - assert_eq!(&f.read_sample(d.as_slice(), 0, i).unwrap(), want); - } - // Empty edit of 100 movie ticks = 10 media ticks, then media starts at 20. - assert_eq!(t.edits.len(), 2); - assert_eq!(t.edit_offset, 10 - 20); - assert_eq!(t.presentation_pts(2), Some(15)); - assert_eq!(t.sdtp.len(), 4); - assert_eq!(t.sample_groups[0].grouping_type, *b"roll"); - assert_eq!(t.sample_groups[0].descriptions, vec![vec![0xFF, 0xFF]]); - assert!(f.read_sample(d.as_slice(), 0, 4).is_err()); - assert!(f.read_sample(d.as_slice(), 1, 0).is_err()); - } - - #[test] - fn absurd_counts_are_bounded() { - // Constant-size stsz claiming 4 billion samples is clamped to what fits in the file. - let d = handmade(Some((1, u32::MAX))); - let f = open(d.as_slice()).unwrap(); - assert!(f.tracks[0].samples.len() <= d.len()); - // A table claiming more entries than its box holds fails cleanly. - let mut d = handmade(None); - let p = d.windows(4).position(|w| w == b"stts").unwrap(); - d[p + 8..p + 12].copy_from_slice(&u32::MAX.to_be_bytes()); - assert!(matches!(open(d.as_slice()), Err(Error::Truncated(_)))); - // Box larger than its parent. - let mut d = handmade(None); - let p = d.windows(4).position(|w| w == b"stsc").unwrap(); - d[p - 4..p].copy_from_slice(&0x7FFF_FFFFu32.to_be_bytes()); - assert!(open(d.as_slice()).is_err()); - } -} diff --git a/crates/video/src/isobmff/error.rs b/crates/video/src/isobmff/error.rs deleted file mode 100644 index b5dfd752..00000000 --- a/crates/video/src/isobmff/error.rs +++ /dev/null @@ -1,38 +0,0 @@ -use std::io; - -/// Errors produced by the demuxer and muxer. -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("I/O error: {0}")] - Io(#[from] io::Error), - /// A box or structure ended before all required fields were read. - #[error("truncated data while reading {0}")] - Truncated(&'static str), - /// Structurally invalid data. - #[error("invalid data: {0}")] - Invalid(String), - /// Valid but unsupported feature (e.g. compressed `cmov` movie header). - #[error("unsupported: {0}")] - Unsupported(String), - /// A count or size exceeds sanity limits relative to the file size. - #[error("size limit exceeded: {0}")] - TooLarge(&'static str), - /// No `moov` box was found. - #[error("no moov box found")] - NoMoov, - /// A track index was out of range. - #[error("track index {0} out of range")] - NoSuchTrack(usize), - /// A sample index was out of range. - #[error("sample index {0} out of range")] - NoSuchSample(usize), - /// Muxer usage error (e.g. invalid track config, bad sample). - #[error("muxer: {0}")] - Mux(String), -} - -pub type Result = std::result::Result; - -pub(crate) fn invalid(msg: impl Into) -> Result { - Err(Error::Invalid(msg.into())) -} diff --git a/crates/video/src/isobmff/mod.rs b/crates/video/src/isobmff/mod.rs deleted file mode 100644 index bfb8a51b..00000000 --- a/crates/video/src/isobmff/mod.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Clean-room ISO Base Media File Format (MP4, ISO/IEC 14496-12/14/15) and QuickTime (MOV) support. -//! -//! - **Demux:** [`open`] parses box headers plus `moov` (and `moof` for fragmented files) from a -//! [`ByteSource`] and builds per-track [`Sample`] tables; media data is read on demand with -//! [`Mp4File::read_sample`]. -//! - **Mux:** [`Mp4Writer`] writes progressive MP4/MOV (optionally faststart); -//! [`FragmentedWriter`] writes fragmented MP4. -//! -//! See the crate README for supported boxes and limitations. - -mod bytes; -mod codec; -mod demux; -mod error; -mod mux; -mod source; - -pub use bytes::FourCc; -pub use codec::{ - AacConfig, AudioParams, Av1Config, AvcConfig, BitRate, CleanAperture, CodecConfig, ColorInfo, FieldInfo, FlacConfig, HevcConfig, HevcNalArray, - MasteringDisplay, OpusConfig, PcmConfig, SampleEntry, TimecodeConfig, VideoParams, VpcConfig, parse_asc, -}; -pub use demux::{Edit, Metadata, Mp4File, Sample, SampleGroup, Track, TrackKind, open}; -pub use error::{Error, Result}; -pub use mux::{Brand, FragmentedWriter, Mp4Writer, TrackConfig, WriteSample, WriterOptions}; -pub use source::ByteSource; diff --git a/crates/video/src/isobmff/mux.rs b/crates/video/src/isobmff/mux.rs deleted file mode 100644 index 6a3d4322..00000000 --- a/crates/video/src/isobmff/mux.rs +++ /dev/null @@ -1,966 +0,0 @@ -//! Muxers: progressive MP4/MOV ([`Mp4Writer`], optional faststart) and fragmented MP4 ([`FragmentedWriter`]). - -use crate::isobmff::bytes::BoxBuf; -use crate::isobmff::codec::{CodecConfig, SampleEntry, TimecodeConfig}; -use crate::isobmff::demux::Edit; -use crate::isobmff::error::{Error, Result}; -use std::io::{Read, Seek, SeekFrom, Write}; - -/// Output flavour: determines brands and some sample-entry encodings. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Brand { - /// ISO MP4: `isom` major brand, compatible `isom iso2 avc1 mp41 mp42`. - Mp4, - /// QuickTime MOV: `qt ` major brand. - Mov, -} - -/// Movie-level writer options. -#[derive(Clone, Debug)] -pub struct WriterOptions { - pub brand: Brand, - /// Movie timescale (`mvhd`); default 1000. - pub movie_timescale: u32, - /// Text metadata written to `moov/udta` (keys like `©nam`, `©ART`, `©cmt`, `©too`). - pub metadata: Vec<(String, String)>, -} - -impl WriterOptions { - pub fn new(brand: Brand) -> Self { - WriterOptions { brand, movie_timescale: 1000, metadata: Vec::new() } - } -} - -/// Per-track configuration. -#[derive(Clone, Debug)] -pub struct TrackConfig { - pub entry: SampleEntry, - /// Media timescale. For PCM it must equal the sample rate (one tick per audio frame). - pub timescale: u32, - /// ISO 639-2/T language (`und` by default). - pub language: String, - /// Explicit edit list (segment durations in the movie timescale). Takes precedence over `media_start`. - pub edits: Vec, - /// Convenience: create one edit starting at this media time (e.g. AAC encoder delay / priming, - /// or the first pts of a B-frame stream) spanning the rest of the media. - pub media_start: Option, - /// `hdlr` name; a sensible default is used when `None`. - pub handler_name: Option, -} - -impl TrackConfig { - pub fn new(entry: SampleEntry, timescale: u32) -> Self { - TrackConfig { entry, timescale, language: "und".into(), edits: Vec::new(), media_start: None, handler_name: None } - } -} - -/// A sample handed to the muxers. -#[derive(Clone, Copy, Debug)] -pub struct WriteSample<'a> { - pub data: &'a [u8], - /// Duration in the media timescale. Ignored for PCM (each frame lasts one tick). - pub duration: u32, - /// pts − dts in the media timescale. - pub composition_offset: i32, - pub is_sync: bool, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Handler { - Video, - Audio, - Timecode, - Other, -} - -fn handler_of(e: &SampleEntry) -> Handler { - if matches!(e.codec, CodecConfig::Timecode(_)) { - Handler::Timecode - } else if e.video.is_some() { - Handler::Video - } else if e.audio.is_some() { - Handler::Audio - } else { - Handler::Other - } -} - -/// Accumulated sample tables for one track. -#[derive(Clone, Debug, Default)] -struct Tables { - stts: Vec<(u32, u32)>, - ctts: Vec<(u32, i32)>, - sizes: Vec, - /// Constant sample size (PCM) — then `sizes` is unused. - const_size: Option, - sample_count: u64, - /// 1-based sync sample numbers. - sync: Vec, - all_sync: bool, - /// (offset relative to file start before shifting, sample count) - chunks: Vec<(u64, u32)>, - media_duration: u64, - /// End of the last sample on the composition timeline (decode time + composition offset + - /// duration, maximum over samples). - comp_end: u64, -} - -impl Tables { - fn new() -> Self { - Tables { all_sync: true, ..Default::default() } - } - fn push_run(runs: &mut Vec<(u32, T)>, count: u32, v: T) { - if let Some(last) = runs.last_mut() - && last.1 == v - && last.0.checked_add(count).is_some() - { - last.0 += count; - return; - } - runs.push((count, v)); - } -} - -struct WTrack { - cfg: TrackConfig, - id: u32, - handler: Handler, - pcm_frame_bytes: Option, - t: Tables, - /// Track id of a linked timecode track (`tref/tmcd`). - tmcd_ref: Option, - /// For timecode tracks: (video track index, start frame). - tmcd_source: Option<(usize, u32)>, -} - -fn pack_language(lang: &str) -> u16 { - let b = lang.as_bytes(); - if b.len() == 3 && b.iter().all(|c| c.is_ascii_lowercase()) { - (((b[0] - 0x60) as u16) << 10) | (((b[1] - 0x60) as u16) << 5) | ((b[2] - 0x60) as u16) - } else { - 0x55C4 // "und" - } -} - -fn rescale(v: u64, from: u32, to: u32) -> u64 { - if from == 0 { - return 0; - } - (v as u128 * to as u128 / from as u128).min(u64::MAX as u128) as u64 -} - -const MATRIX: [i32; 9] = [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x4000_0000]; - -fn write_ftyp(b: &mut BoxBuf, brand: Brand) { - let m = b.start(b"ftyp"); - match brand { - Brand::Mp4 => { - b.bytes(b"isom"); - b.u32(0x200); - for c in [b"isom", b"iso2", b"avc1", b"mp41", b"mp42"] { - b.bytes(c); - } - } - Brand::Mov => { - b.bytes(b"qt "); - b.u32(0x200); - b.bytes(b"qt "); - } - } - b.end(m); -} - -fn movie_duration(tracks: &[WTrack], movie_ts: u32) -> u64 { - tracks.iter().map(|t| presentation_duration(t, movie_ts)).max().unwrap_or(0) -} - -fn effective_edits(t: &WTrack, movie_ts: u32) -> Vec { - if !t.cfg.edits.is_empty() { - return t.cfg.edits.clone(); - } - if let Some(start) = t.cfg.media_start { - // presentation runs from `start` to the end of the last sample in composition order (with - // B-frame reordering that is past the end of the decode timeline) - let rest = t.t.comp_end.max(t.t.media_duration).saturating_sub(start.max(0) as u64); - return vec![Edit { segment_duration: rescale(rest, t.cfg.timescale, movie_ts), media_time: start, media_rate: 0x10000 }]; - } - Vec::new() -} - -fn presentation_duration(t: &WTrack, movie_ts: u32) -> u64 { - let e = effective_edits(t, movie_ts); - if e.is_empty() { rescale(t.t.media_duration, t.cfg.timescale, movie_ts) } else { e.iter().map(|e| e.segment_duration).sum() } -} - -fn write_mvhd(b: &mut BoxBuf, timescale: u32, duration: u64, next_id: u32) { - let v1 = duration > u32::MAX as u64; - let m = b.start_full(b"mvhd", v1 as u8, 0); - if v1 { - b.u64(0); - b.u64(0); - b.u32(timescale); - b.u64(duration); - } else { - b.u32(0); - b.u32(0); - b.u32(timescale); - b.u32(duration as u32); - } - b.u32(0x10000); - b.u16(0x100); - b.zeros(10); - for v in MATRIX { - b.i32(v); - } - b.zeros(24); - b.u32(next_id); - b.end(m); -} - -fn write_trak(b: &mut BoxBuf, t: &WTrack, movie_ts: u32, qt: bool, shift: u64, force_co64: bool) -> Result<()> { - let trak = b.start(b"trak"); - let dur = presentation_duration(t, movie_ts); - let v1 = dur > u32::MAX as u64; - let m = b.start_full(b"tkhd", v1 as u8, 3); - if v1 { - b.u64(0); - b.u64(0); - b.u32(t.id); - b.u32(0); - b.u64(dur); - } else { - b.u32(0); - b.u32(0); - b.u32(t.id); - b.u32(0); - b.u32(dur as u32); - } - b.zeros(8); - b.i16(0); - b.u16(0); - b.i16(if t.handler == Handler::Audio { 0x100 } else { 0 }); - b.u16(0); - for v in MATRIX { - b.i32(v); - } - let (w, h) = match &t.cfg.entry.video { - Some(v) => { - let w = match v.pixel_aspect { - Some((hs, vs)) if hs > 0 && vs > 0 => (v.width as u64 * hs as u64 / vs as u64) as u32, - _ => v.width as u32, - }; - (w, v.height as u32) - } - None => (0, 0), - }; - b.u32(w << 16); - b.u32(h << 16); - b.end(m); - let edits = effective_edits(t, movie_ts); - if !edits.is_empty() { - let e = b.start(b"edts"); - let v1 = edits.iter().any(|e| e.segment_duration > u32::MAX as u64 || e.media_time > i32::MAX as i64 || e.media_time < i32::MIN as i64); - let l = b.start_full(b"elst", v1 as u8, 0); - b.u32(edits.len() as u32); - for ed in &edits { - if v1 { - b.u64(ed.segment_duration); - b.i64(ed.media_time); - } else { - b.u32(ed.segment_duration as u32); - b.i32(ed.media_time as i32); - } - b.i32(ed.media_rate); - } - b.end(l); - b.end(e); - } - if let Some(id) = t.tmcd_ref { - let r = b.start(b"tref"); - let m = b.start(b"tmcd"); - b.u32(id); - b.end(m); - b.end(r); - } - let mdia = b.start(b"mdia"); - let mv1 = t.t.media_duration > u32::MAX as u64; - let m = b.start_full(b"mdhd", mv1 as u8, 0); - if mv1 { - b.u64(0); - b.u64(0); - b.u32(t.cfg.timescale); - b.u64(t.t.media_duration); - } else { - b.u32(0); - b.u32(0); - b.u32(t.cfg.timescale); - b.u32(t.t.media_duration as u32); - } - b.u16(pack_language(&t.cfg.language)); - b.u16(0); - b.end(m); - let (htype, default_name) = match t.handler { - Handler::Video => (b"vide", "VideoHandler"), - Handler::Audio => (b"soun", "SoundHandler"), - Handler::Timecode => (b"tmcd", "TimeCodeHandler"), - Handler::Other => (b"meta", "MetaHandler"), - }; - let name = t.cfg.handler_name.clone().unwrap_or_else(|| default_name.into()); - let m = b.start_full(b"hdlr", 0, 0); - b.bytes(if qt { b"mhlr" } else { &[0, 0, 0, 0] }); - b.bytes(htype); - b.zeros(12); - if qt { - let n = name.len().min(255); - b.u8(n as u8); - b.bytes(&name.as_bytes()[..n]); - } else { - b.bytes(name.as_bytes()); - b.u8(0); - } - b.end(m); - let minf = b.start(b"minf"); - match t.handler { - Handler::Video => { - let m = b.start_full(b"vmhd", 0, 1); - b.zeros(8); - b.end(m); - } - Handler::Audio => { - let m = b.start_full(b"smhd", 0, 0); - b.zeros(4); - b.end(m); - } - Handler::Timecode => { - let g = b.start(b"gmhd"); - let m = b.start_full(b"gmin", 0, 0); - b.u16(0x40); - b.u16(0x8000); - b.u16(0x8000); - b.u16(0x8000); - b.i16(0); - b.u16(0); - b.end(m); - let tm = b.start(b"tmcd"); - let m = b.start_full(b"tcmi", 0, 0); - b.u16(0); - b.u16(0); - b.u16(12); - b.u16(0); - b.bytes(&[0xFF; 6]); - b.zeros(6); - b.u8(0); - b.end(m); - b.end(tm); - b.end(g); - } - Handler::Other => { - let m = b.start_full(b"nmhd", 0, 0); - b.end(m); - } - } - if qt { - // QuickTime data handler reference. - let m = b.start_full(b"hdlr", 0, 0); - b.bytes(b"dhlr"); - b.bytes(b"url "); - b.zeros(12); - b.u8(0); - b.end(m); - } - let d = b.start(b"dinf"); - let r = b.start_full(b"dref", 0, 0); - b.u32(1); - let u = b.start_full(b"url ", 0, 1); - b.end(u); - b.end(r); - b.end(d); - let stbl = b.start(b"stbl"); - let m = b.start_full(b"stsd", 0, 0); - b.u32(1); - t.cfg.entry.write(b, qt)?; - b.end(m); - let tb = &t.t; - let m = b.start_full(b"stts", 0, 0); - b.u32(tb.stts.len() as u32); - for &(c, d) in &tb.stts { - b.u32(c); - b.u32(d); - } - b.end(m); - if tb.ctts.iter().any(|&(_, o)| o != 0) { - let neg = tb.ctts.iter().any(|&(_, o)| o < 0); - let m = b.start_full(b"ctts", neg as u8, 0); - b.u32(tb.ctts.len() as u32); - for &(c, o) in &tb.ctts { - b.u32(c); - b.i32(o); - } - b.end(m); - } - if !tb.all_sync { - let m = b.start_full(b"stss", 0, 0); - b.u32(tb.sync.len() as u32); - for &s in &tb.sync { - b.u32(s); - } - b.end(m); - } - let m = b.start_full(b"stsc", 0, 0); - let mut runs: Vec<(u32, u32)> = Vec::new(); - for (i, &(_, n)) in tb.chunks.iter().enumerate() { - if runs.last().is_none_or(|r| r.1 != n) { - runs.push((i as u32 + 1, n)); - } - } - b.u32(runs.len() as u32); - for (first, n) in runs { - b.u32(first); - b.u32(n); - b.u32(1); - } - b.end(m); - let m = b.start_full(b"stsz", 0, 0); - match tb.const_size { - Some(s) => { - b.u32(s); - b.u32(tb.sample_count.min(u32::MAX as u64) as u32); - } - None => { - let first = tb.sizes.first().copied().unwrap_or(0); - // 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 { - b.u32(0); - b.u32(tb.sizes.len() as u32); - for &s in &tb.sizes { - b.u32(s); - } - } - } - } - b.end(m); - let co64 = force_co64 || tb.chunks.iter().any(|&(o, _)| o + shift > u32::MAX as u64); - let m = b.start_full(if co64 { b"co64" } else { b"stco" }, 0, 0); - b.u32(tb.chunks.len() as u32); - for &(o, _) in &tb.chunks { - if co64 { - b.u64(o + shift); - } else { - b.u32((o + shift) as u32); - } - } - b.end(m); - b.end(stbl); - b.end(minf); - b.end(mdia); - b.end(trak); - Ok(()) -} - -fn write_udta(b: &mut BoxBuf, md: &[(String, String)]) { - let items: Vec<([u8; 4], &str)> = md - .iter() - .filter_map(|(k, v)| { - let mut chars = k.chars(); - if chars.next()? != '©' { - return None; - } - let rest: Vec = chars.map(|c| c as u32 as u8).collect(); - (rest.len() == 3).then(|| ([0xA9, rest[0], rest[1], rest[2]], v.as_str())) - }) - .collect(); - if items.is_empty() { - return; - } - let u = b.start(b"udta"); - for (k, v) in items { - let m = b.start(&k); - b.u16(v.len() as u16); - b.u16(0x55C4); - b.bytes(v.as_bytes()); - b.end(m); - } - b.end(u); -} - -fn validate_track(cfg: &TrackConfig) -> Result> { - if cfg.timescale == 0 { - return Err(Error::Mux("timescale must be non-zero".into())); - } - if let CodecConfig::Pcm(p) = &cfg.entry.codec { - if p.bits == 0 || p.channels == 0 { - return Err(Error::Mux("PCM needs bits and channels".into())); - } - if (p.sample_rate - cfg.timescale as f64).abs() > 0.5 { - return Err(Error::Mux("PCM track timescale must equal the sample rate".into())); - } - return Ok(Some(p.bytes_per_frame())); - } - Ok(None) -} - -/// Progressive MP4/MOV writer: `ftyp`, then one `mdat` streamed to `W`, then `moov` at [`finish`](Self::finish) -/// (or moved in front of `mdat` by [`finish_faststart`](Self::finish_faststart)). -pub struct Mp4Writer { - w: W, - opts: WriterOptions, - tracks: Vec, - /// Offset of the placeholder box (8 bytes) preceding the mdat header. - pad_pos: u64, - mdat_pos: u64, - pos: u64, - last_track: Option, -} - -impl Mp4Writer { - /// Start a file: writes `ftyp`, a placeholder box and the `mdat` header. - pub fn new(mut w: W, opts: WriterOptions) -> Result { - let start = w.stream_position()?; - let mut b = BoxBuf::new(); - write_ftyp(&mut b, opts.brand); - let pad_pos = start + b.len() as u64; - b.leaf(if opts.brand == Brand::Mov { b"wide" } else { b"free" }, &[]); - let mdat_pos = start + b.len() as u64; - b.leaf(b"mdat", &[]); - w.write_all(&b.buf)?; - let pos = start + b.len() as u64; - Ok(Mp4Writer { w, opts, tracks: Vec::new(), pad_pos, mdat_pos, pos, last_track: None }) - } - - /// Add a track; returns its index (track ids are index + 1). - pub fn add_track(&mut self, cfg: TrackConfig) -> Result { - let pcm = validate_track(&cfg)?; - let handler = handler_of(&cfg.entry); - let mut t = Tables::new(); - t.const_size = pcm; - self.tracks.push(WTrack { id: self.tracks.len() as u32 + 1, handler, pcm_frame_bytes: pcm, t, cfg, tmcd_ref: None, tmcd_source: None }); - Ok(self.tracks.len() - 1) - } - - /// Add a QuickTime timecode track referenced by `video_track`. Its single sample (the start - /// frame number) is written at finish, spanning the video track's duration. - pub fn add_timecode_track(&mut self, video_track: usize, tc: TimecodeConfig, start_frame: u32) -> Result { - if video_track >= self.tracks.len() { - return Err(Error::NoSuchTrack(video_track)); - } - if tc.timescale == 0 || tc.frame_duration == 0 || tc.frames_per_second == 0 { - return Err(Error::Mux("timecode needs timescale, frame duration and fps".into())); - } - let entry = SampleEntry { - format: crate::isobmff::bytes::FourCc(*b"tmcd"), - data_reference_index: 1, - codec: CodecConfig::Timecode(tc.clone()), - video: None, - audio: None, - bitrate: None, - }; - let idx = self.add_track(TrackConfig::new(entry, tc.timescale))?; - self.tracks[idx].tmcd_source = Some((video_track, start_frame)); - let id = self.tracks[idx].id; - self.tracks[video_track].tmcd_ref = Some(id); - Ok(idx) - } - - /// Append one sample to track `track` (decode order). For PCM tracks `data` may hold any whole - /// number of audio frames. - pub fn write_sample(&mut self, track: usize, s: WriteSample) -> Result<()> { - let tr = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; - if tr.tmcd_source.is_some() { - return Err(Error::Mux("timecode track samples are written automatically".into())); - } - let (count, dur) = match tr.pcm_frame_bytes { - Some(fb) => { - if !s.data.len().is_multiple_of(fb as usize) { - return Err(Error::Mux("PCM data is not a whole number of frames".into())); - } - ((s.data.len() / fb as usize) as u32, 1u32) - } - None => { - if s.data.len() > u32::MAX as usize { - return Err(Error::Mux("sample larger than 4 GiB".into())); - } - (1, s.duration) - } - }; - if count == 0 { - return Ok(()); - } - self.w.write_all(s.data)?; - let offset = self.pos; - self.pos += s.data.len() as u64; - let contiguous = self.last_track == Some(track); - self.last_track = Some(track); - let tr = &mut self.tracks[track]; - let t = &mut tr.t; - Tables::push_run(&mut t.stts, count, dur); - Tables::push_run(&mut t.ctts, count, if tr.pcm_frame_bytes.is_some() { 0 } else { s.composition_offset }); - if tr.pcm_frame_bytes.is_none() { - t.sizes.push(s.data.len() as u32); - if s.is_sync { - t.sync.push(t.sample_count as u32 + 1); - } else { - t.all_sync = false; - } - } - t.sample_count += count as u64; - let ct = if tr.pcm_frame_bytes.is_some() { 0 } else { s.composition_offset as i64 }; - t.comp_end = t.comp_end.max((t.media_duration as i64 + ct + count as i64 * dur as i64).max(0) as u64); - t.media_duration += count as u64 * dur as u64; - match t.chunks.last_mut() { - Some(c) if contiguous => c.1 += count, - _ => t.chunks.push((offset, count)), - } - Ok(()) - } - - fn finish_mdat(&mut self) -> Result<()> { - // Timecode samples. - for i in 0..self.tracks.len() { - let Some((vi, start)) = self.tracks[i].tmcd_source else { continue }; - let v = &self.tracks[vi]; - let vd = v.t.media_duration; - let vts = v.cfg.timescale; - let tts = self.tracks[i].cfg.timescale; - let tc_dur = match &self.tracks[i].cfg.entry.codec { - CodecConfig::Timecode(tc) => tc.frame_duration, - _ => 1, - }; - let d = rescale(vd, vts, tts).max(tc_dur as u64).min(u32::MAX as u64) as u32; - self.w.write_all(&start.to_be_bytes())?; - let off = self.pos; - self.pos += 4; - self.last_track = None; - let t = &mut self.tracks[i].t; - t.stts.push((1, d)); - t.sizes.push(4); - t.sync.push(1); - t.sample_count = 1; - t.media_duration = d as u64; - t.chunks.push((off, 1)); - } - let size = self.pos - self.mdat_pos; - if size <= u32::MAX as u64 { - self.w.seek(SeekFrom::Start(self.mdat_pos))?; - self.w.write_all(&(size as u32).to_be_bytes())?; - } else { - // Merge the placeholder into a 16-byte large-size mdat header. - let mut h = [0u8; 16]; - h[..4].copy_from_slice(&1u32.to_be_bytes()); - h[4..8].copy_from_slice(b"mdat"); - h[8..].copy_from_slice(&(self.pos - self.pad_pos).to_be_bytes()); - self.w.seek(SeekFrom::Start(self.pad_pos))?; - self.w.write_all(&h)?; - } - self.w.seek(SeekFrom::Start(self.pos))?; - Ok(()) - } - - fn build_moov(&self, shift: u64, force_co64: bool) -> Result> { - let qt = self.opts.brand == Brand::Mov; - let ts = self.opts.movie_timescale.max(1); - let mut b = BoxBuf::new(); - let m = b.start(b"moov"); - write_mvhd(&mut b, ts, movie_duration(&self.tracks, ts), self.tracks.len() as u32 + 1); - for t in &self.tracks { - write_trak(&mut b, t, ts, qt, shift, force_co64)?; - } - write_udta(&mut b, &self.opts.metadata); - b.end(m); - Ok(b.buf) - } - - /// Finish the file with `moov` after `mdat`. Returns the underlying writer. - pub fn finish(mut self) -> Result { - self.finish_mdat()?; - let moov = self.build_moov(0, false)?; - self.w.write_all(&moov)?; - self.w.flush()?; - Ok(self.w) - } -} - -impl Mp4Writer { - /// Finish with `moov` in front of `mdat` ("fast start"): media data is shifted forward in place. - pub fn finish_faststart(mut self) -> Result { - self.finish_mdat()?; - let mut moov = self.build_moov(0, false)?; - let mut force = false; - loop { - let shift = moov.len() as u64; - let next = self.build_moov(shift, force)?; - if next.len() == moov.len() { - moov = next; - break; - } - // Offsets crossed the 32-bit boundary: switch to co64 (size only grows, so this converges). - force = true; - moov = next; - } - let shift = moov.len() as u64; - let start = self.pad_pos; - let end = self.pos; - let mut buf = vec![0u8; 1 << 20]; - let mut cur = end; - while cur > start { - let n = (cur - start).min(buf.len() as u64) as usize; - cur -= n as u64; - self.w.seek(SeekFrom::Start(cur))?; - self.w.read_exact(&mut buf[..n])?; - self.w.seek(SeekFrom::Start(cur + shift))?; - self.w.write_all(&buf[..n])?; - } - self.w.seek(SeekFrom::Start(start))?; - self.w.write_all(&moov)?; - self.w.seek(SeekFrom::Start(end + shift))?; - self.w.flush()?; - Ok(self.w) - } -} - -/// Buffered fragment sample: (data, duration, composition offset, sync). -type PendingSample = (Vec, u32, i32, bool); - -/// Fragmented MP4 writer: an init segment (`ftyp` + `moov` with `mvex`) followed by -/// `moof`+`mdat` fragments. Samples are buffered until [`flush_fragment`](Self::flush_fragment). -pub struct FragmentedWriter { - w: W, - opts: WriterOptions, - tracks: Vec, - pending: Vec>, - next_dts: Vec, - seq: u32, - init_written: bool, -} - -impl FragmentedWriter { - pub fn new(w: W, opts: WriterOptions) -> Self { - FragmentedWriter { w, opts, tracks: Vec::new(), pending: Vec::new(), next_dts: Vec::new(), seq: 0, init_written: false } - } - - /// Add a track (before the first fragment). PCM is not supported in fragments. - pub fn add_track(&mut self, cfg: TrackConfig) -> Result { - if self.init_written { - return Err(Error::Mux("tracks must be added before the first fragment".into())); - } - if validate_track(&cfg)?.is_some() { - return Err(Error::Mux("PCM is not supported by the fragmented writer".into())); - } - let handler = handler_of(&cfg.entry); - self.tracks.push(WTrack { - id: self.tracks.len() as u32 + 1, - handler, - pcm_frame_bytes: None, - t: Tables::new(), - cfg, - tmcd_ref: None, - tmcd_source: None, - }); - self.pending.push(Vec::new()); - self.next_dts.push(0); - Ok(self.tracks.len() - 1) - } - - fn write_init(&mut self) -> Result<()> { - let qt = self.opts.brand == Brand::Mov; - let ts = self.opts.movie_timescale.max(1); - let mut b = BoxBuf::new(); - write_ftyp(&mut b, self.opts.brand); - let m = b.start(b"moov"); - write_mvhd(&mut b, ts, 0, self.tracks.len() as u32 + 1); - for t in &self.tracks { - write_trak(&mut b, t, ts, qt, 0, false)?; - } - let mv = b.start(b"mvex"); - for t in &self.tracks { - let x = b.start_full(b"trex", 0, 0); - b.u32(t.id); - b.u32(1); - b.u32(0); - b.u32(0); - b.u32(0); - b.end(x); - } - b.end(mv); - write_udta(&mut b, &self.opts.metadata); - b.end(m); - self.w.write_all(&b.buf)?; - self.init_written = true; - Ok(()) - } - - /// Buffer a sample for the next fragment. - pub fn write_sample(&mut self, track: usize, s: WriteSample) -> Result<()> { - let p = self.pending.get_mut(track).ok_or(Error::NoSuchTrack(track))?; - if s.data.len() > u32::MAX as usize { - return Err(Error::Mux("sample larger than 4 GiB".into())); - } - p.push((s.data.to_vec(), s.duration, s.composition_offset, s.is_sync)); - Ok(()) - } - - fn build_moof(&self, data_offsets: &[i32]) -> Vec { - let mut b = BoxBuf::new(); - let m = b.start(b"moof"); - let h = b.start_full(b"mfhd", 0, 0); - b.u32(self.seq); - b.end(h); - for (i, t) in self.tracks.iter().enumerate() { - let p = &self.pending[i]; - if p.is_empty() { - continue; - } - let tf = b.start(b"traf"); - let h = b.start_full(b"tfhd", 0, 0x20000); - b.u32(t.id); - b.end(h); - let h = b.start_full(b"tfdt", 1, 0); - b.u64(self.next_dts[i]); - b.end(h); - let neg = p.iter().any(|s| s.2 < 0); - let h = b.start_full(b"trun", neg as u8, 0x1 | 0x100 | 0x200 | 0x400 | 0x800); - b.u32(p.len() as u32); - b.i32(data_offsets[i]); - for (d, dur, cto, sync) in p { - b.u32(*dur); - b.u32(d.len() as u32); - b.u32(if *sync { 0x0200_0000 } else { 0x0101_0000 }); - b.i32(*cto); - } - b.end(h); - b.end(tf); - } - b.end(m); - b.buf - } - - /// Write all buffered samples as one `moof` + `mdat` fragment (no-op if nothing is buffered). - pub fn flush_fragment(&mut self) -> Result<()> { - if !self.init_written { - self.write_init()?; - } - if self.pending.iter().all(|p| p.is_empty()) { - return Ok(()); - } - self.seq += 1; - let probe = self.build_moof(&vec![0; self.tracks.len()]); - let data_len: u64 = self.pending.iter().flatten().map(|s| s.0.len() as u64).sum(); - let large = data_len + 8 > u32::MAX as u64; - let hdr = if large { 16 } else { 8 }; - let mut offs = Vec::with_capacity(self.tracks.len()); - let mut acc = probe.len() as u64 + hdr; - for p in &self.pending { - offs.push(i32::try_from(acc).map_err(|_| Error::Mux("fragment too large".into()))?); - acc += p.iter().map(|s| s.0.len() as u64).sum::(); - } - let moof = self.build_moof(&offs); - self.w.write_all(&moof)?; - if large { - self.w.write_all(&1u32.to_be_bytes())?; - self.w.write_all(b"mdat")?; - self.w.write_all(&(data_len + 16).to_be_bytes())?; - } else { - self.w.write_all(&((data_len + 8) as u32).to_be_bytes())?; - self.w.write_all(b"mdat")?; - } - for (i, p) in self.pending.iter_mut().enumerate() { - for (d, dur, _, _) in p.drain(..) { - self.w.write_all(&d)?; - self.next_dts[i] += dur as u64; - } - } - Ok(()) - } - - /// Flush remaining samples and return the writer. - pub fn finish(mut self) -> Result { - self.flush_fragment()?; - self.w.flush()?; - Ok(self.w) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::isobmff::codec::{AvcConfig, PcmConfig}; - use std::io::Cursor; - - fn avc_entry() -> SampleEntry { - SampleEntry::avc(AvcConfig::new(vec![vec![0x67, 0x42, 0, 0x1E]], vec![vec![0x68, 0xCE]], 4), 64, 48) - } - - #[test] - fn language_pack() { - assert_eq!(pack_language("und"), 0x55C4); - assert_eq!(pack_language("eng"), 0x15C7); - assert_eq!(pack_language("EN"), 0x55C4); - } - - #[test] - fn simple_roundtrip_and_faststart() { - for fast in [false, true] { - let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap(); - let v = w.add_track(TrackConfig::new(avc_entry(), 12800)).unwrap(); - let pcm = PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: 2, sample_rate: 48000.0 }; - let a = w.add_track(TrackConfig::new(SampleEntry::pcm(pcm), 48000)).unwrap(); - for i in 0..10u8 { - let data = vec![i; 100 + i as usize]; - w.write_sample( - v, - WriteSample { data: &data, duration: 512, composition_offset: if i % 2 == 0 { 1024 } else { 0 }, is_sync: i % 5 == 0 }, - ) - .unwrap(); - w.write_sample(a, WriteSample { data: &[i; 400], duration: 0, composition_offset: 0, is_sync: true }).unwrap(); - } - let out = if fast { w.finish_faststart() } else { w.finish() }.unwrap().into_inner(); - let f = crate::isobmff::open(out.as_slice()).unwrap(); - assert_eq!(f.tracks.len(), 2); - let t = &f.tracks[0]; - assert_eq!(t.samples.len(), 10); - for (i, s) in t.samples.iter().enumerate() { - assert_eq!(s.size as usize, 100 + i); - assert_eq!(s.dts, 512 * i as i64); - assert_eq!(s.is_sync, i % 5 == 0); - assert_eq!(f.read_sample(out.as_slice(), 0, i).unwrap(), vec![i as u8; 100 + i]); - } - let at = &f.tracks[1]; - assert!(at.pcm_chunked); - assert_eq!(at.samples.len(), 10); - assert_eq!(at.samples[3].size, 400); - assert_eq!(at.samples[3].duration, 100); - assert_eq!(f.read_sample(out.as_slice(), 1, 3).unwrap(), vec![3u8; 400]); - if fast { - assert!(f.moov_offset < t.samples[0].offset); - } - } - } - - #[test] - fn fragmented_roundtrip() { - let mut w = FragmentedWriter::new(Vec::new(), WriterOptions::new(Brand::Mp4)); - let v = w.add_track(TrackConfig::new(avc_entry(), 1000)).unwrap(); - for i in 0..6u8 { - w.write_sample(v, WriteSample { data: &[i; 10], duration: 40, composition_offset: -(i as i32 % 2), is_sync: i % 3 == 0 }).unwrap(); - if i % 3 == 2 { - w.flush_fragment().unwrap(); - } - } - let out = w.finish().unwrap(); - let f = crate::isobmff::open(out.as_slice()).unwrap(); - assert!(f.fragmented); - assert_eq!(f.fragment_count, 2); - let t = &f.tracks[0]; - assert_eq!(t.samples.len(), 6); - for (i, s) in t.samples.iter().enumerate() { - assert_eq!(s.dts, 40 * i as i64); - assert_eq!(s.pts, s.dts - (i as i64 % 2)); - assert_eq!(s.is_sync, i % 3 == 0); - assert_eq!(f.read_sample(out.as_slice(), 0, i).unwrap(), vec![i as u8; 10]); - } - assert_eq!(t.sync_sample_before(5), 3); - } -} diff --git a/crates/video/src/isobmff/source.rs b/crates/video/src/isobmff/source.rs deleted file mode 100644 index 0dad82b2..00000000 --- a/crates/video/src/isobmff/source.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Random-access byte sources for the demuxer. - -use std::io; -use std::sync::Arc; - -/// Random-access, read-only byte source (file, in-memory buffer, web Blob…). -/// -/// Implementations must be able to serve reads at arbitrary offsets through `&self`. -pub trait ByteSource { - /// Total length in bytes. - fn len(&self) -> u64; - /// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end. - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; - /// True if the source holds no bytes. - fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -fn eof() -> io::Error { - io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source") -} - -impl ByteSource for [u8] { - fn len(&self) -> u64 { - <[u8]>::len(self) as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - let start = usize::try_from(offset).map_err(|_| eof())?; - let end = start.checked_add(buf.len()).ok_or_else(eof)?; - let src = self.get(start..end).ok_or_else(eof)?; - buf.copy_from_slice(src); - Ok(()) - } -} - -impl ByteSource for Vec { - fn len(&self) -> u64 { - self.as_slice().len() as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - self.as_slice().read_at(offset, buf) - } -} - -impl ByteSource for &T { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Arc { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Box { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -#[cfg(not(target_arch = "wasm32"))] -impl ByteSource for std::fs::File { - fn len(&self) -> u64 { - self.metadata().map(|m| m.len()).unwrap_or(0) - } - #[cfg(unix)] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - std::os::unix::fs::FileExt::read_exact_at(self, buf, offset) - } - #[cfg(windows)] - fn read_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> { - use std::os::windows::fs::FileExt; - while !buf.is_empty() { - let n = self.seek_read(buf, offset)?; - if n == 0 { - return Err(eof()); - } - offset += n as u64; - buf = &mut buf[n..]; - } - Ok(()) - } - #[cfg(not(any(unix, windows)))] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - use std::io::{Read, Seek, SeekFrom}; - let mut f = self; - f.seek(SeekFrom::Start(offset))?; - f.read_exact(buf) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slice_reads() { - let v = vec![1u8, 2, 3, 4]; - let mut b = [0u8; 2]; - v.read_at(1, &mut b).unwrap(); - assert_eq!(b, [2, 3]); - assert!(v.read_at(3, &mut b).is_err()); - assert!(v.read_at(u64::MAX, &mut b).is_err()); - let a: Arc<[u8]> = Arc::from(v.clone()); - a.read_at(2, &mut b).unwrap(); - assert_eq!(b, [3, 4]); - assert_eq!(ByteSource::len(&a), 4); - let s: &[u8] = &v; - assert_eq!(ByteSource::len(&s), 4); - } -} diff --git a/crates/video/src/lib.rs b/crates/video/src/lib.rs index 17a732a1..4409b8cb 100644 --- a/crates/video/src/lib.rs +++ b/crates/video/src/lib.rs @@ -14,25 +14,25 @@ //! //! ## Origin //! -//! The container, H.264 and ProRes code is a copy of the clean-room crates of our sibling app -//! FilmCraft (`storytold/filmcraft`, same organisation, MIT OR Apache-2.0): `filmcraft-bitstream` -//! → [`bitstream`], `filmcraft-isobmff` → [`isobmff`], `filmcraft-h264` → [`h264`] (without its -//! test-only synthetic stream module), `filmcraft-prores` → [`prores`]. The crafting apps share no -//! code as dependencies; each keeps its own copy. Only the module paths were changed; fixes found -//! here should be offered back upstream. [`Movie`], the pixel conversion and the frame caches are -//! SoundCraft's own. +//! [`bitstream`] and [`isobmff`] are re-exports of `craft_media::bitstream` and +//! `craft_media::isobmff` from craft-libs (`storytold/craft-libs`, the shared crate FilmCraft uses +//! too). The H.264 and ProRes code is a copy of the clean-room crates of our sibling app FilmCraft +//! (`storytold/filmcraft`, same organisation, MIT OR Apache-2.0): `filmcraft-h264` → [`h264`] +//! (without its test-only synthetic stream module), `filmcraft-prores` → [`prores`]. Only the +//! module paths were changed; fixes found here should be offered back upstream. [`Movie`], the +//! pixel conversion and the frame caches are SoundCraft's own. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] // Codec code indexes fixed-size blocks; index loops read more clearly than iterator chains there. #![allow(clippy::needless_range_loop, clippy::too_many_arguments)] -pub mod bitstream; pub mod convert; pub mod h264; -pub mod isobmff; mod movie; pub mod prores; pub mod synth; +pub use craft_media::{bitstream, isobmff}; + pub use isobmff::ByteSource; pub use movie::{Frame, Movie, MovieInfo, SharedSource, fit_size}; From 6b84a4f0461f2467402eafe41dcc708c369fb045 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 17:00:27 -0400 Subject: [PATCH 32/33] ci(sonar): don't cancel superseded runs on main, so coverage is published (#992) --- .github/workflows/sonarcloud.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ecee45a6..e78fe1ea 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -11,7 +11,8 @@ permissions: concurrency: group: sonarcloud-${{ github.ref }} - cancel-in-progress: true + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: sonarcloud: From 64669e9a7c90e3bc24cd0589e034c1150723cee1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 22:33:59 -0400 Subject: [PATCH 33/33] chore: resolve SonarCloud maintainability issues (#1003) * chore: resolve SonarCloud maintainability issues * fix(packaging): extract verify_sha256 helper in package.sh * style: apply rustfmt formatting * fix(video): ensure prefix is i32 in cabac_mb --- apps/soundcraft-cli/src/main.rs | 6 +- apps/soundcraft-web/index.html | 2 +- apps/soundcraft/src/main.rs | 2 +- crates/clap-host/src/scan.rs | 2 +- crates/engine/src/clip_group_file.rs | 50 +++++++++------ crates/engine/src/cmd/file.rs | 4 +- crates/engine/src/cmd/health.rs | 92 ++++++++++++++++------------ crates/engine/src/cmd/mod.rs | 2 +- crates/engine/src/cmd/sampler.rs | 4 +- crates/engine/src/io.rs | 31 +++++----- crates/engine/src/lib.rs | 2 +- crates/model/src/mixer.rs | 2 +- crates/model/src/session.rs | 2 +- crates/time/src/format.rs | 2 +- crates/ui-egui/src/credits.rs | 32 ++++++---- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/panels.rs | 48 ++++++++------- crates/ui-egui/src/recent.rs | 5 +- crates/ui-egui/src/sampler_window.rs | 3 +- crates/ui-egui/src/spot.rs | 68 +++++++++++--------- crates/ui-egui/src/toolbar.rs | 53 ++++++++++------ crates/ui-egui/src/video_window.rs | 70 +++++++++++---------- crates/ui-egui/src/windows.rs | 2 +- crates/video/src/h264/cabac_mb.rs | 6 +- crates/video/src/h264/params.rs | 2 +- crates/video/src/prores/decode.rs | 2 +- crates/vst3-host/src/scan.rs | 4 +- packaging/linux/package.sh | 12 ++-- xtask/src/main.rs | 2 +- 30 files changed, 296 insertions(+), 222 deletions(-) diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 9a51cc54..58cc0098 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -110,7 +110,7 @@ fn info(args: &[String]) -> ExitCode { Ok(b) => b, Err(e) => return fail(format!("{path}: {e}")), }; - let ext = std::path::Path::new(path).extension().and_then(|x| x.to_str()); + let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str); match soundcraft_audio_io::probe(&bytes, ext) { Ok(i) => { outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default()); @@ -134,7 +134,7 @@ fn convert(args: &[String]) -> ExitCode { Ok(b) => b, Err(e) => return fail(format!("{input}: {e}")), }; - let ext = std::path::Path::new(input).extension().and_then(|x| x.to_str()); + let ext = std::path::Path::new(input).extension().and_then(std::ffi::OsStr::to_str); let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) { Ok(x) => x, Err(e) => return fail(format!("{input}: {e}")), @@ -146,7 +146,7 @@ fn convert(args: &[String]) -> ExitCode { if args.iter().any(|a| a == "--normalize") { soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false); } - let out_ext = std::path::Path::new(output).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase(); + let out_ext = std::path::Path::new(output).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase(); let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else { return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS)); }; diff --git a/apps/soundcraft-web/index.html b/apps/soundcraft-web/index.html index af72b21b..124e0b71 100644 --- a/apps/soundcraft-web/index.html +++ b/apps/soundcraft-web/index.html @@ -2,7 +2,7 @@ - + SoundCraft diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 808cdef8..cd27ec44 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -182,7 +182,7 @@ fn services() -> Services { if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) { d = d.set_directory(dir); } - if let Some(n) = path.file_name().and_then(|n| n.to_str()) { + if let Some(n) = path.file_name().and_then(std::ffi::OsStr::to_str) { d = d.set_file_name(n); } d.save_file().map(|p| p.to_string_lossy().into_owned()) diff --git a/crates/clap-host/src/scan.rs b/crates/clap-host/src/scan.rs index 92a6f6d3..6bfbadd6 100644 --- a/crates/clap-host/src/scan.rs +++ b/crates/clap-host/src/scan.rs @@ -51,7 +51,7 @@ pub fn os_search_paths(home: Option, common_program_files: Option bool { - p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("clap")) + p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("clap")) } /// Recursively collects `.clap` files and bundles under `dirs` (missing directories are skipped). diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index 35bb2df3..9a44cca5 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -50,7 +50,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result { }; let n = clips.len(); lanes.sort_by_key(|(i, _)| *i); - let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("Clip Group").to_string(); + let stem = p.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Clip Group").to_string(); let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem)); let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); let mut audio = BTreeMap::new(); @@ -80,26 +80,17 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result { Ok(n) } -/// Import a clip group file at `at`, one lane per track starting at `first` (new audio tracks -/// are made when the session runs out). Returns the ids of the placed clips. -pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) -> Result> { - let id = "file.import_clip_groups"; - let text = crate::io::read_text(path)?; - let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?; - if file.format != FORMAT { - return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into())); - } - if file.lanes.iter().map(|l| l.len().max(1)).sum::() > MAX_CLIPS || file.audio.len() > MAX_CLIPS { - return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into())); - } - let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); - // Decode audio first so a bad file changes nothing, and each file once however many ids name it. +fn decode_group_audio( + audio_entries: &BTreeMap, + dir: &Path, + rate: u32, + cmd_id: &str, +) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap)> { let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new()); - let rate = e.session().sample_rate.hz(); - for (old, rel) in &file.audio { + for (old, rel) in audio_entries { let full = dir.join(rel); - if !full.starts_with(&dir) || rel.contains("..") { - return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}"))); + if !full.starts_with(dir) || rel.contains("..") { + return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}"))); } let key = crate::io::file_key(&full); let n = match index.get(&key) { @@ -116,6 +107,25 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) - }; file_of.insert(*old, n); } + Ok((decoded, file_of)) +} + +/// Import a clip group file at `at`, one lane per track starting at `first` (new audio tracks +/// are made when the session runs out). Returns the ids of the placed clips. +pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) -> Result> { + let id = "file.import_clip_groups"; + let text = crate::io::read_text(path)?; + let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?; + if file.format != FORMAT { + return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into())); + } + if file.lanes.iter().map(|l| l.len().max(1)).sum::() > MAX_CLIPS || file.audio.len() > MAX_CLIPS { + return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into())); + } + let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); + // Decode audio first so a bad file changes nothing, and each file once however many ids name it. + let rate = e.session().sample_rate.hz(); + let (decoded, file_of) = decode_group_audio(&file.audio, &dir, rate, id)?; let src_rate = f64::from(file.sample_rate.max(1)); let s = e.session_mut(); let ratio = f64::from(s.sample_rate.hz()) / src_rate; @@ -123,7 +133,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) - let sources: Vec = decoded .into_iter() .map(|(rel, buf)| { - let name = Path::new(&rel).file_name().and_then(|x| x.to_str()).unwrap_or("clip group").to_string(); + let name = Path::new(&rel).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("clip group").to_string(); crate::io::add_source(s, &name, buf, None, FileFormat::Wav) }) .collect(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 40e3d3db..d5b18db1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -241,7 +241,7 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result { let mut out = Vec::new(); for path in &paths { let bytes = crate::io::read_file(path)?; - let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("Audio").to_string(); + let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Audio").to_string(); let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?; out.push(r); } @@ -302,7 +302,7 @@ pub fn bounce_job(e: &Engine, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string(); let r = range_param(e, "file.bounce_mix", p)?; let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r }; - let ext = std::path::Path::new(&path).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase(); + let ext = std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase(); let format_name = str_param(p, "format").unwrap_or(ext.as_str()); let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| { bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS)) diff --git a/crates/engine/src/cmd/health.rs b/crates/engine/src/cmd/health.rs index 260503ec..36819c99 100644 --- a/crates/engine/src/cmd/health.rs +++ b/crates/engine/src/cmd/health.rs @@ -12,6 +12,55 @@ pub fn specs() -> Vec { ] } +struct ClipContext<'a> { + track: &'a soundcraft_model::Track, + playlist: &'a soundcraft_model::Playlist, + active: bool, + session_sample_rate: u32, +} + +fn check_audio_clip( + ctx: &ClipContext<'_>, + clip: &soundcraft_model::Clip, + source: soundcraft_model::SourceId, + offset: i64, + metadata: Option<&soundcraft_model::Source>, + audio: Option<&soundcraft_model::SourceAudio>, + issues: &mut Vec, +) { + let mut add = |code: &str, message: &str| { + issues.push(json!({"code": code, "message": message, "track": ctx.track.name, "track_id": ctx.track.id.0, + "playlist": ctx.playlist.name, "active": ctx.active, "clip": clip.name, "clip_id": clip.id.0, + "source_id": source.0, "path": metadata.map(|m| m.path.as_str())})); + }; + if metadata.is_none() { + add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip."); + } + let Some(audio) = audio else { + add("unavailable_audio", "Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it."); + return; + }; + if audio.buffer.sample_rate != ctx.session_sample_rate { + add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate."); + } + if audio.buffer.channels.is_empty() || audio.frames() == 0 { + add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file."); + return; + } + if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) { + add("uneven_channels", "Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel."); + } + if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 { + add("invalid_clip", "Clip length or stretch is invalid. Replace this clip."); + } + let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 }; + // Match the mixer's last requested source sample, including elastic playback. + let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch; + if offset < 0 || last >= audio.frames() as f64 { + add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source."); + } +} + fn report(e: &mut Engine, _: &Value) -> Result { let s = e.session(); let sources: BTreeMap<_, _> = s.sources.iter().map(|source| (source.id, source)).collect(); @@ -24,51 +73,16 @@ fn report(e: &mut Engine, _: &Value) -> Result { for track in &s.tracks { for (index, playlist) in track.playlists.iter().enumerate() { let active = index == track.active_playlist; + let ctx = ClipContext { track, playlist, active, session_sample_rate: s.sample_rate.hz() }; for clip in &playlist.clips { let ClipContent::Audio { source, offset } = clip.content else { continue }; clips += 1; if active { active_end = active_end.max(clip.end()); } - let metadata = sources.get(&source); - let audio = s.pool.get(source); - let mut add = |code: &str, message: &str| { - issues.push(json!({"code": code, "message": message, "track": track.name, "track_id": track.id.0, - "playlist": playlist.name, "active": active, "clip": clip.name, "clip_id": clip.id.0, - "source_id": source.0, "path": metadata.map(|m| m.path.as_str())})); - }; - if metadata.is_none() { - add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip."); - } - let Some(audio) = audio else { - add( - "unavailable_audio", - "Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it.", - ); - continue; - }; - if audio.buffer.sample_rate != s.sample_rate.hz() { - add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate."); - } - if audio.buffer.channels.is_empty() || audio.frames() == 0 { - add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file."); - continue; - } - if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) { - add( - "uneven_channels", - "Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel.", - ); - } - if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 { - add("invalid_clip", "Clip length or stretch is invalid. Replace this clip."); - } - let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 }; - // Match the mixer's last requested source sample, including elastic playback. - let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch; - if offset < 0 || last >= audio.frames() as f64 { - add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source."); - } + let metadata = sources.get(&source).copied(); + let audio = s.pool.get(source).map(|a| a.as_ref()); + check_audio_clip(&ctx, clip, source, offset, metadata, audio, &mut issues); } } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b6f379bd..9a984d62 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -81,7 +81,7 @@ impl CommandSpec { CommandInfo { id: self.id.into(), label: self.label.into(), - menu: self.menu.iter().map(|s| s.to_string()).collect(), + menu: self.menu.iter().map(ToString::to_string).collect(), shortcut: self.shortcut.map(str::to_string), params: self.params.into(), enabled: en.is_ok(), diff --git a/crates/engine/src/cmd/sampler.rs b/crates/engine/src/cmd/sampler.rs index eb401bbd..f4634e3d 100644 --- a/crates/engine/src/cmd/sampler.rs +++ b/crates/engine/src/cmd/sampler.rs @@ -56,7 +56,7 @@ fn load(e: &mut Engine, p: &Value) -> Result { } } let bytes = crate::io::read_file(&path)?; - let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string(); + let name = Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Sample").to_string(); attach(e, t, &name, &bytes, Some(&path), values) } @@ -69,7 +69,7 @@ pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path: return Err(bad(id, "only instrument tracks have a sampler")); } let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?; - let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); + let ext = Path::new(name).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase); let (format, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; if buf.frames() == 0 || buf.num_channels() == 0 { return Err(EngineError::Io(format!("{name}: no audio"))); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 4b6601b6..5d85a162 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -5,6 +5,7 @@ use serde_json::{Value, json}; use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat}; use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind}; use soundcraft_time::{Range, Samples}; +use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -114,7 +115,7 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio buf.sample_rate = sr; } let id = SourceId(s.alloc()); - let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string(); + let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or(name).to_string(); let stored = match path { Some(p) => p.to_string(), None => unique_audio_rel(s, &stem), @@ -145,7 +146,7 @@ fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 { /// Decode and place an audio file. With no target track, a new track of matching width is made. pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option, at: Samples) -> Result { - let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); + let ext = Path::new(name).extension().and_then(OsStr::to_str).map(str::to_ascii_lowercase); let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; let buf = at_rate(buf, e.session().sample_rate.hz(), MAX_IMPORT_SAMPLES) .ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?; @@ -164,7 +165,7 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option let track = match target { Some(t) => t, None => { - let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or("Audio").to_string(); + let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or("Audio").to_string(); s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem)) } }; @@ -188,8 +189,8 @@ pub fn load_sampler_bytes(e: &mut Engine, track: TrackId, name: &str, bytes: &[u /// never copied) plus its audio on a new audio track when it has a usable audio stream. Fails /// only when the file has neither a readable picture nor audio. pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec, at: Samples) -> Result { - let stem = Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Video").to_string(); - let ext = Path::new(path).extension().and_then(|x| x.to_str()).unwrap_or("mp4").to_ascii_lowercase(); + let stem = Path::new(path).file_stem().and_then(OsStr::to_str).unwrap_or("Video").to_string(); + let ext = Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("mp4").to_ascii_lowercase(); let bytes: Arc<[u8]> = bytes.into(); let movie = soundcraft_video::Movie::open(Arc::clone(&bytes)).map(|m| m.info().clone()); let audio_name = format!("{stem} audio.{}", if ext == "mov" { "mp4" } else { ext.as_str() }); @@ -210,7 +211,7 @@ pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec, at: Sample let vid = SourceId(s.alloc()); s.videos.push(soundcraft_model::VideoSource { id: vid, - name: Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("movie").to_string(), + name: Path::new(path).file_name().and_then(OsStr::to_str).unwrap_or("movie").to_string(), path: abs, width: info.width, height: info.height, @@ -992,7 +993,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess sync_dir(&audio_dir); } s.folder = Some(dir.clone()); - if !overwrite && let Some(stem) = p.file_stem().and_then(|x| x.to_str()) { + if !overwrite && let Some(stem) = p.file_stem().and_then(OsStr::to_str) { s.name = stem.to_string(); } let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; @@ -1052,7 +1053,7 @@ fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> { /// Copy the session file about to be replaced to `Backups/ backup .`, keeping the /// newest ten. fn back_up(file: &Path) { - let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(|x| x.to_str()), file.extension().and_then(|x| x.to_str())) + let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(OsStr::to_str), file.extension().and_then(OsStr::to_str)) else { return; }; @@ -1195,7 +1196,7 @@ pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std: .entry(file_key(p)) .or_insert_with(|| { let bytes = read_file(p).map_err(|_| p.display().to_string())?; - decode_source(&bytes, p.extension().and_then(|x| x.to_str()), rate).map_err(|err| format!("{}: {err}", p.display())) + decode_source(&bytes, p.extension().and_then(OsStr::to_str), rate).map_err(|err| format!("{}: {err}", p.display())) }) .clone() } @@ -1227,7 +1228,7 @@ pub fn set_deliver(h: DeliverHook) { pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { if cfg!(target_arch = "wasm32") { let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?; - let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled"); + let name = Path::new(path).file_name().and_then(OsStr::to_str).filter(|n| !n.is_empty()).unwrap_or("Untitled"); return deliver(name, bytes).map_err(EngineError::Io); } crate::file_access::check_write(path).map_err(EngineError::Io)?; @@ -1272,7 +1273,7 @@ pub fn session_bundle(session: &Session) -> Result<(Vec, Session)> { /// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]). fn save_session_bundle(e: &mut Engine, path: &str) -> Result { - let stem = Path::new(path).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).map(str::to_string); + let stem = Path::new(path).file_stem().and_then(OsStr::to_str).filter(|x| !x.is_empty()).map(str::to_string); let mut s = e.session().clone(); if let Some(stem) = stem { s.name = stem; @@ -1311,7 +1312,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result match decode_source(&bytes, Path::new(&src.path).extension().and_then(|x| x.to_str()), s.sample_rate.hz()) { + Some(bytes) => match decode_source(&bytes, Path::new(&src.path).extension().and_then(OsStr::to_str), s.sample_rate.hz()) { Ok(audio) => Some(audio), Err(err) => { missing.push(format!("{}: {err}", src.path)); @@ -1330,7 +1331,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result Result<(String, std::collections::HashMap String { - p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()) + p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()) } #[cfg(test)] diff --git a/crates/model/src/mixer.rs b/crates/model/src/mixer.rs index bef493e8..7f0a24e9 100644 --- a/crates/model/src/mixer.rs +++ b/crates/model/src/mixer.rs @@ -196,7 +196,7 @@ impl ChannelFormat { pub fn channel_label(self, ch: usize) -> String { match self.speakers().get(ch) { Some(s) => s.label().to_string(), - None if self.is_ambisonic() => ["W", "Y", "Z", "X"].get(ch).map_or_else(|| format!("{}", ch + 1), |s| s.to_string()), + None if self.is_ambisonic() => ["W", "Y", "Z", "X"].get(ch).map_or_else(|| format!("{}", ch + 1), ToString::to_string), None => format!("{}", ch + 1), } } diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index b72818dc..cac69d99 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -288,7 +288,7 @@ impl Default for EditState { timeline_selection: Range::default(), flags: ["view.clip.name", "view.clip.gain_line", "waveform.peak", "view.clip.overlap_shadows", "view.marker.ruler_lines"] .iter() - .map(|s| s.to_string()) + .map(ToString::to_string) .collect(), values: std::collections::BTreeMap::new(), } diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index 3c11c844..1244db86 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -124,7 +124,7 @@ pub fn parse_position( } TimeFormat::BarsBeats => { let parts: Vec = - t.split(['|', '.', ' ']).filter(|p| !p.is_empty()).map(|p| p.parse::()).collect::>().map_err(|_| err())?; + t.split(['|', '.', ' ']).filter(|p| !p.is_empty()).map(str::parse::).collect::>().map_err(|_| err())?; let bb = BarBeat { bar: *parts.first().ok_or_else(err)?, beat: parts.get(1).copied().unwrap_or(1).max(1), diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index bd58c366..a53128cb 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -210,10 +210,7 @@ impl Default for View { } } -/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. -pub fn contributors_ui(ui: &mut egui::Ui) { - let id = egui::Id::new("credits_view"); - let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); +fn contributors_toolbar(ui: &mut egui::Ui, v: &mut View) { ui.horizontal_wrapped(|ui| { ui.label("Show"); for m in NameMode::ALL { @@ -242,6 +239,24 @@ pub fn contributors_ui(ui: &mut egui::Ui) { v.table = true; } }); +} + +fn contributors_grab_bag(ui: &mut egui::Ui, list: &[&Contributor], names: NameMode) { + ui.horizontal_wrapped(|ui| { + for (i, c) in list.iter().enumerate() { + if i > 0 { + ui.label(RichText::new("·").weak()); + } + ui.hyperlink_to(c.name(names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); + } + }); +} + +/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. +pub fn contributors_ui(ui: &mut egui::Ui) { + let id = egui::Id::new("credits_view"); + let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); + contributors_toolbar(ui, &mut v); let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak()); ui.separator(); @@ -251,14 +266,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) { } else if v.table { table(ui, &list, &mut v); } else { - ui.horizontal_wrapped(|ui| { - for (i, c) in list.iter().enumerate() { - if i > 0 { - ui.label(RichText::new("·").weak()); - } - ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); - } - }); + contributors_grab_bag(ui, &list, v.names); } }); ui.data_mut(|d| d.insert_temp(id, v)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3fdd9892..8b532400 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -776,7 +776,7 @@ impl SoundApp { for d in std::fs::read_dir(dir).ok()?.flatten() { for f in std::fs::read_dir(d.path()).into_iter().flatten().flatten() { let p = f.path(); - if p.extension().and_then(|x| x.to_str()) == Some("scraft") + if p.extension().and_then(std::ffi::OsStr::to_str) == Some("scraft") && let Ok(m) = f.metadata().and_then(|m| m.modified()) && best.as_ref().is_none_or(|(t, _)| m > *t) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 99e4b503..9ba795ca 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -86,7 +86,7 @@ pub fn ui_aliases() -> Vec<(&'static str, &'static str)> { /// Mix-view menu paths, allocated once. Rebuilding them on every menu draw leaked a copy per frame. fn mix_view_aliases() -> &'static [(&'static str, &'static str)] { - static ALIASES: std::sync::OnceLock> = std::sync::OnceLock::new(); + static ALIASES: std::sync::OnceLock> = std::sync::OnceLock::new(); ALIASES.get_or_init(|| { [ "Inserts A-E", @@ -633,7 +633,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option { app.ui.mix_views = - ["inserts_ae", "inserts_fj", "sends_ae", "sends_fj", "io", "comments", "color"].iter().map(|s| s.to_string()).collect() + ["inserts_ae", "inserts_fj", "sends_ae", "sends_fj", "io", "comments", "color"].iter().map(ToString::to_string).collect() } "minimal" => app.ui.mix_views.clear(), "" => return Some(Err("`section` required".into())), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a208511f..07664994 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -371,6 +371,29 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_big_counter = open; } +fn memory_location_row(app: &mut SoundApp, ui: &mut Ui, n: u32, name: &str, loc: &str, len: &str, kind: &str) { + if ui.button(n.to_string()).on_hover_text("Recall").clicked() { + let _ = app.run("markers.recall", json!({"number": n})); + } + let key = egui::Id::new(("mem_name", n)); + let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.to_string()); + let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0)); + if r.changed() { + ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone())); + } + if r.lost_focus() && edit != name { + let _ = app.run("markers.edit", json!({"number": n, "rename": edit})); + ui.ctx().memory_mut(|m| m.data.remove::(key)); + } + ui.label(egui::RichText::new(loc).font(mono(11.0))); + ui.label(egui::RichText::new(len).font(mono(11.0))); + ui.label(kind); + if ui.small_button("✕").on_hover_text("Delete").clicked() { + let _ = app.run("markers.delete", json!({"number": n})); + } + ui.end_row(); +} + pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) { ui.horizontal(|ui| { if ui.button("+ Marker").clicked() { @@ -402,27 +425,8 @@ pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) { ui.label(egui::RichText::new(h).strong()); } ui.end_row(); - for (n, name, loc, len, kind) in rows { - if ui.button(n.to_string()).on_hover_text("Recall").clicked() { - let _ = app.run("markers.recall", json!({"number": n})); - } - let key = egui::Id::new(("mem_name", n)); - let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0)); - if r.changed() { - ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone())); - } - if r.lost_focus() && edit != name { - let _ = app.run("markers.edit", json!({"number": n, "rename": edit})); - ui.ctx().memory_mut(|m| m.data.remove::(key)); - } - ui.label(egui::RichText::new(loc).font(mono(11.0))); - ui.label(egui::RichText::new(len).font(mono(11.0))); - ui.label(kind); - if ui.small_button("✕").on_hover_text("Delete").clicked() { - let _ = app.run("markers.delete", json!({"number": n})); - } - ui.end_row(); + for (n, name, loc, len, kind) in &rows { + memory_location_row(app, ui, *n, name, loc, len, kind); } }); }); @@ -669,7 +673,7 @@ fn preset_menu( return; }; let mut names: Vec = std::fs::read_dir(&dir) - .map(|rd| rd.flatten().filter_map(|e| e.path().file_stem().and_then(|x| x.to_str()).map(str::to_string)).collect()) + .map(|rd| rd.flatten().filter_map(|e| e.path().file_stem().and_then(std::ffi::OsStr::to_str).map(str::to_string)).collect()) .unwrap_or_default(); names.sort(); for n in &names { diff --git a/crates/ui-egui/src/recent.rs b/crates/ui-egui/src/recent.rs index 12be3980..597cc99c 100644 --- a/crates/ui-egui/src/recent.rs +++ b/crates/ui-egui/src/recent.rs @@ -13,6 +13,7 @@ use crate::SoundApp; use serde_json::json; +use std::ffi::OsStr; use std::path::Path; /// How many sessions the menu keeps. @@ -45,8 +46,8 @@ pub fn note(list: &mut Vec, path: &str) -> bool { /// "name — folder" for a session path. fn label(path: &str) -> String { let p = Path::new(path); - let name = p.file_stem().and_then(|n| n.to_str()).unwrap_or(path); - match p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str()) { + let name = p.file_stem().and_then(OsStr::to_str).unwrap_or(path); + match p.parent().and_then(Path::file_name).and_then(OsStr::to_str) { Some(folder) => format!("{name} — {folder}"), None => name.to_string(), } diff --git a/crates/ui-egui/src/sampler_window.rs b/crates/ui-egui/src/sampler_window.rs index b259343d..45f9697b 100644 --- a/crates/ui-egui/src/sampler_window.rs +++ b/crates/ui-egui/src/sampler_window.rs @@ -8,6 +8,7 @@ use crate::theme::{Tokens, mono, regular}; use egui::{Align2, Color32, CursorIcon, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{Insert, TrackId}; +use std::ffi::OsStr; use std::path::Path; /// The built-in Sampler's plugin id. @@ -21,7 +22,7 @@ const MARKERS: [(&str, bool); 3] = [("start", false), ("loop_start", true), ("lo /// Whether a file name looks like audio the sampler can load. pub fn is_audio_name(name: &str) -> bool { - Path::new(name).extension().and_then(|x| x.to_str()).is_some_and(|x| AUDIO_EXTS.contains(&x.to_ascii_lowercase().as_str())) + Path::new(name).extension().and_then(OsStr::to_str).is_some_and(|x| AUDIO_EXTS.contains(&x.to_ascii_lowercase().as_str())) } /// Sampler parameters that are MIDI notes. diff --git a/crates/ui-egui/src/spot.rs b/crates/ui-egui/src/spot.rs index c9b4102b..e92798fe 100644 --- a/crates/ui-egui/src/spot.rs +++ b/crates/ui-egui/src/spot.rs @@ -113,9 +113,44 @@ fn recorded_at(s: &Session, time_reference: u64) -> Samples { i64::try_from(time_reference).unwrap_or(0).saturating_sub(s.timecode_start) } +fn spot_field_row(ui: &mut Ui, s: &Session, d: &mut Spot, i: usize, label: &str) { + let show = |at: Samples, f: TimeFormat| s.format_position(at, f); + ui.label(label); + if d.editable(i) { + let id = Id::new(("spot_field", i)); + if !ui.memory(|m| m.has_focus(id)) { + d.text[i] = show(d.value(i), d.format); + } + let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0)); + if r.changed() + && let Ok(v) = s.parse_position(&d.text[i], d.format) + && (-MAX_POSITION..=MAX_POSITION).contains(&v) + { + d.set(i, v); + } + if std::mem::take(&mut d.fresh) { + r.request_focus(); + select_all(ui, id, &d.text[i]); + } + } else { + ui.label(RichText::new(show(d.value(i), d.format)).font(mono(13.0))); + } + ui.end_row(); +} + +fn original_time_stamp_row(ui: &mut Ui, s: &Session, d: &mut Spot, at: Samples) { + ui.label("Original Time Stamp"); + ui.horizontal(|ui| { + ui.label(RichText::new(s.format_position(at, TimeFormat::Timecode)).font(mono(13.0))); + if ui.button("Use").on_hover_text("Spot to where it was recorded").clicked() { + d.start = at; + } + }); + ui.end_row(); +} + /// The dialog's fields; the caller adds OK and Cancel. pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) { - let show = |at: Samples, f: TimeFormat| s.format_position(at, f); egui::Grid::new("spot").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { ui.label("Time Scale"); egui::ComboBox::from_id_salt("spot_scale").selected_text(d.format.label()).show_ui(ui, |ui| { @@ -128,40 +163,13 @@ pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) { if i == 1 && matches!(d.target, Target::Trim { .. }) { continue; } - ui.label(label); - if d.editable(i) { - let id = Id::new(("spot_field", i)); - if !ui.memory(|m| m.has_focus(id)) { - d.text[i] = show(d.value(i), d.format); - } - let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0)); - if r.changed() - && let Ok(v) = s.parse_position(&d.text[i], d.format) - && (-MAX_POSITION..=MAX_POSITION).contains(&v) - { - d.set(i, v); - } - if std::mem::take(&mut d.fresh) { - r.request_focus(); - select_all(ui, id, &d.text[i]); - } - } else { - ui.label(RichText::new(show(d.value(i), d.format)).font(mono(13.0))); - } - ui.end_row(); + spot_field_row(ui, s, d, i, label); } ui.label("Duration"); ui.label(RichText::new(format_length(d.start, d.len, d.format, s.sample_rate, &s.tempo, s.frame_rate)).font(mono(13.0))); ui.end_row(); if let Some(at) = d.stamp { - ui.label("Original Time Stamp"); - ui.horizontal(|ui| { - ui.label(RichText::new(show(at, TimeFormat::Timecode)).font(mono(13.0))); - if ui.button("Use").on_hover_text("Spot to where it was recorded").clicked() { - d.start = at; - } - }); - ui.end_row(); + original_time_stamp_row(ui, s, d, at); } }); } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 41d83ac4..c3169790 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -43,10 +43,40 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { }); } +struct ModeCellState { + cur: EditMode, + snap: bool, + locked: bool, +} + +fn edit_mode_tooltip(m: EditMode, locked: bool, cmd: &str, fkey: &str) -> String { + match m { + EditMode::Shuffle if locked => format!("Shuffle is locked ({cmd}-click to unlock)"), + EditMode::Shuffle => format!("Shuffle mode ({fkey}); {cmd}-click locks it out"), + EditMode::Grid => format!("Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes"), + _ => format!("{} mode ({fkey}); Shift-click adds Snap to Grid", if m == EditMode::Slip { "Slip" } else { "Spot" }), + } +} + +fn draw_mode_cell(p: &egui::Painter, cell: Rect, resp: &egui::Response, t: &Tokens, m: EditMode, state: &ModeCellState, label: &str) { + let on = state.cur == m || (m == EditMode::Grid && state.cur == EditMode::GridRelative); + key(p, cell, resp, if on { t.mode_on } else { t.mode_off }); + let fg = if on { t.mode_on_text } else { t.mode_off_text }; + if m == EditMode::Grid && state.snap { + p.rect_stroke(cell, 1.0, Stroke::new(1.5, t.mode_on), StrokeKind::Inside); + } + if m == EditMode::Shuffle && state.locked { + icons::draw(p, Rect::from_center_size(cell.center(), vec2(15.0, 15.0)), "lock", fg); + } else { + let txt = if m == EditMode::Grid && state.cur == EditMode::GridRelative { "REL GRID" } else { label }; + p.text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), fg); + } +} + fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let ed = &app.engine.session().edit; - let (cur, snap, locked) = (ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(SHUFFLE_LOCK)); + let state = ModeCellState { cur: ed.edit_mode, snap: ed.flag(SNAP_TO_GRID), locked: ed.flag(SHUFFLE_LOCK) }; let cmd = crate::shortcuts::shortcut_label("Cmd", ui.ctx().os() == egui::os::OperatingSystem::Mac); let modes = [ (EditMode::Shuffle, 0, 0, "SHUFFLE", "F1"), @@ -63,28 +93,13 @@ fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { for (m, cx, cy, label, fkey) in modes { let cell = Rect::from_min_size(pos2(inner.min.x + cw * cx as f32, inner.min.y + ch * cy as f32), vec2(cw, ch)).shrink(1.0); let resp = ui.interact(cell, ui.id().with(("mode", label)), Sense::click()); - let on = cur == m || (m == EditMode::Grid && cur == EditMode::GridRelative); - key(ui.painter(), cell, &resp, if on { t.mode_on } else { t.mode_off }); - let fg = if on { t.mode_on_text } else { t.mode_off_text }; - if m == EditMode::Grid && snap { - ui.painter().rect_stroke(cell, 1.0, Stroke::new(1.5, t.mode_on), StrokeKind::Inside); - } - if m == EditMode::Shuffle && locked { - icons::draw(ui.painter(), Rect::from_center_size(cell.center(), vec2(15.0, 15.0)), "lock", fg); - } else { - let txt = if m == EditMode::Grid && cur == EditMode::GridRelative { "REL GRID" } else { label }; - ui.painter().text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), fg); - } + draw_mode_cell(ui.painter(), cell, &resp, &t, m, &state, label); + let on = state.cur == m || (m == EditMode::Grid && state.cur == EditMode::GridRelative); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, label)); if resp.clicked() { mode_click(app, m, ui.input(|i| i.modifiers)); } - resp.on_hover_text(match m { - EditMode::Shuffle if locked => format!("Shuffle is locked ({cmd}-click to unlock)"), - EditMode::Shuffle => format!("Shuffle mode ({fkey}); {cmd}-click locks it out"), - EditMode::Grid => format!("Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes"), - _ => format!("{} mode ({fkey}); Shift-click adds Snap to Grid", if m == EditMode::Slip { "Slip" } else { "Spot" }), - }); + resp.on_hover_text(edit_mode_tooltip(m, state.locked, &cmd, fkey)); } }); } diff --git a/crates/ui-egui/src/video_window.rs b/crates/ui-egui/src/video_window.rs index 61809e78..3e66e165 100644 --- a/crates/ui-egui/src/video_window.rs +++ b/crates/ui-egui/src/video_window.rs @@ -18,6 +18,43 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } +fn video_status_text(app: &SoundApp, pic: Option<&(MovieKey, Result, f64, f64)>) -> String { + match pic { + Some((_, Err(why), ..)) => why.clone(), + Some((k, ..)) => match app.video.info(*k) { + Some(Ok(i)) => format!("{}x{} {:.3} fps {}", i.width, i.height, i.frame_rate, i.codec_detail), + Some(Err(e)) => e.clone(), + None => "opening…".into(), + }, + None => String::new(), + } +} + +fn draw_video_surface(app: &SoundApp, painter: &egui::Painter, img: Rect, pic: Option<&(MovieKey, Result, f64, f64)>, on: bool) { + let msg = if !on { + Some("Video Track Offline".to_string()) + } else { + match pic { + None => None, + Some((_, Err(why), ..)) => Some(why.clone()), + Some((k, ..)) => match (app.video.current(*k), app.video.current_error(*k)) { + (Some(tex), _) => { + painter.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + None + } + (None, Some(e)) => Some(e.to_string()), + (None, None) => match app.video.info(*k) { + Some(Err(e)) => Some(e.clone()), + _ => None, + }, + }, + } + }; + if let Some(m) = msg { + painter.text(img.center(), Align2::CENTER_CENTER, m, regular(12.0), Color32::from_gray(170)); + } +} + fn video(app: &mut SoundApp, ctx: &egui::Context) { app.video.poll(ctx); let pos = app.position(); @@ -38,15 +75,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) { egui::Window::new("Video").open(&mut open).default_size(vec2(480.0, 480.0 / aspect + 28.0)).resizable(true).show(ctx, |ui| { ui.horizontal(|ui| { crate::widgets::checkbox(ui, &mut burn, "Timecode burn-in"); - let text = match &pic { - Some((_, Err(why), ..)) => why.clone(), - Some((k, ..)) => match app.video.info(*k) { - Some(Ok(i)) => format!("{}x{} {:.3} fps {}", i.width, i.height, i.frame_rate, i.codec_detail), - Some(Err(e)) => e.clone(), - None => "opening…".into(), - }, - None => String::new(), - }; + let text = video_status_text(app, pic.as_ref()); ui.label(egui::RichText::new(text).font(regular(10.5)).color(Color32::from_gray(150))); }); let avail = ui.available_rect_before_wrap(); @@ -60,28 +89,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) { w = h * aspect; } let img = Rect::from_center_size(r.center(), vec2(w, h)); - let msg = if !on { - Some("Video Track Offline".to_string()) - } else { - match &pic { - None => None, - Some((_, Err(why), ..)) => Some(why.clone()), - Some((k, ..)) => match (app.video.current(*k), app.video.current_error(*k)) { - (Some(tex), _) => { - painter.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - None - } - (None, Some(e)) => Some(e.to_string()), - (None, None) => match app.video.info(*k) { - Some(Err(e)) => Some(e.clone()), - _ => None, - }, - }, - } - }; - if let Some(m) = msg { - painter.text(img.center(), Align2::CENTER_CENTER, m, regular(12.0), Color32::from_gray(170)); - } + draw_video_surface(app, &painter, img, pic.as_ref(), on); if burn { let tc = s.format_position(pos, TimeFormat::Timecode); let size = (h / 12.0).clamp(10.0, 36.0); diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 78a91081..62bb3ae2 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -341,7 +341,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) { return None; } let is_dir = e.file_type().is_ok_and(|t| t.is_dir()); - let ext = std::path::Path::new(&name).extension().and_then(|x| x.to_str()).unwrap_or("").to_ascii_lowercase(); + let ext = std::path::Path::new(&name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase(); (is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "m4b", "caf", "mid", "midi", "scraft"].contains(&ext.as_str())) .then_some((name, is_dir)) }) diff --git a/crates/video/src/h264/cabac_mb.rs b/crates/video/src/h264/cabac_mb.rs index 76c9c9a5..3379e193 100644 --- a/crates/video/src/h264/cabac_mb.rs +++ b/crates/video/src/h264/cabac_mb.rs @@ -240,11 +240,11 @@ impl SliceDecoder<'_> { if c.decode_decision(base + inc) == 0 { return 0; } - let mut prefix = 1; - while prefix < 9 && c.decode_decision(base + (prefix + 2).min(6)) == 1 { + let mut prefix: i32 = 1; + while prefix < 9 && c.decode_decision(base + (prefix + 2).min(6) as usize) == 1 { prefix += 1; } - let mut val = prefix as i32; + let mut val = prefix; if prefix >= 9 { // EG3 suffix let mut k = 3; diff --git a/crates/video/src/h264/params.rs b/crates/video/src/h264/params.rs index 99232ac7..0970a106 100644 --- a/crates/video/src/h264/params.rs +++ b/crates/video/src/h264/params.rs @@ -575,7 +575,7 @@ impl Pps { ensure!(sps_id < 32, "seq_parameter_set_id out of range"); let sps = spss .get(sps_id as usize) - .and_then(|s| s.as_ref()) + .and_then(Option::as_ref) .ok_or_else(|| crate::h264::Error::MissingParameterSet(format!("SPS {sps_id} for PPS {id}")))?; let entropy_coding_mode = r.read_flag()?; let bottom_field_pic_order_in_frame_present = r.read_flag()?; diff --git a/crates/video/src/prores/decode.rs b/crates/video/src/prores/decode.rs index fcb8b07f..01b1900f 100644 --- a/crates/video/src/prores/decode.rs +++ b/crates/video/src/prores/decode.rs @@ -200,7 +200,7 @@ pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> let mut ac = alpha.as_mut().map(|a| a.chunks_mut(band_h * width)); while let (Some(y), Some(cb), Some(cr)) = (yc.next(), cbc.next(), crc.next()) { let rows = y.len() / width; - let a = ac.as_mut().and_then(|it| it.next()); + let a = ac.as_mut().and_then(Iterator::next); bands.push(Band { y, cb, cr, a, rows }); } } diff --git a/crates/vst3-host/src/scan.rs b/crates/vst3-host/src/scan.rs index f6a2b211..e9ea2962 100644 --- a/crates/vst3-host/src/scan.rs +++ b/crates/vst3-host/src/scan.rs @@ -79,7 +79,7 @@ pub fn os_search_paths(os: Os, home: Option, common_program_files: Opti /// True when the path names a VST3 plugin (`*.vst3`, case-insensitive). pub fn is_vst3_path(p: &Path) -> bool { - p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("vst3")) + p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("vst3")) } /// Recursively collects `.vst3` bundles and files under `dirs` (missing directories are skipped). @@ -181,7 +181,7 @@ pub fn bundle_binary_for(os: Os, path: &Path) -> Option { .flatten() .map(|e| e.path()) .filter(|p| p.is_file()) - .filter(|p| ext.is_none_or(|e| p.extension().and_then(|x| x.to_str()).is_some_and(|x| x.eq_ignore_ascii_case(e)))) + .filter(|p| ext.is_none_or(|e| p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|x| x.eq_ignore_ascii_case(e)))) .take(64) .collect(); files.sort(); diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 72d95f8b..e23fed92 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -131,6 +131,10 @@ if has appimage; then cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" ln -s "$APP_ID.png" "$APPDIR/.DirIcon" + verify_sha256() { + local sha="$1" file="$2" + printf '%s %s\n' "$sha" "$file" | sha256sum -c - + } TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" if [[ -z "$TOOL" ]]; then if [[ -z "$APPIMAGETOOL_SHA256" ]]; then @@ -142,21 +146,21 @@ if has appimage; then download="$TOOL.download" curl --proto '=https' -fsSL -o "$download" \ "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" - printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c - + verify_sha256 "$APPIMAGETOOL_SHA256" "$download" chmod 755 "$download" mv "$download" "$TOOL" fi - printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c - + verify_sha256 "$APPIMAGETOOL_SHA256" "$TOOL" fi RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH" if [[ ! -f "$RUNTIME" ]]; then download="$RUNTIME.download" curl --proto '=https' -fsSL -o "$download" \ "https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-$ARCH" - printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$download" | sha256sum -c - + verify_sha256 "$APPIMAGE_RUNTIME_SHA256" "$download" mv "$download" "$RUNTIME" fi - printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME" | sha256sum -c - + verify_sha256 "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME" # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be # relative). OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c2da6561..79b67325 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -97,7 +97,7 @@ fn assets() -> Result<(), String> { let attribution = std::fs::read_to_string(root().join("ATTRIBUTION.md")).map_err(|e| format!("ATTRIBUTION.md: {e}"))?; let mut missing = Vec::new(); for f in files.lines() { - let ext = Path::new(f).extension().and_then(|x| x.to_str()).unwrap_or("").to_ascii_lowercase(); + let ext = Path::new(f).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase(); if !ASSET_EXT.contains(&ext.as_str()) { continue; }