Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 3eaf216405 address review: check the sample budget before reading, keep the decoder from skipping a refused sample, stop on a failed read 2026-11-04 01:48:07 +00:00
Dylan M. Taylor 370214f52f fix(video): check MJPEG frame size from its header and budget H.264 frame requests 2026-11-03 15:08:06 +00:00
Dylan M. Taylor 17d3f00700 MIDI: index controller dedup by channel and kind (#1298)
* MIDI: index controller dedup by channel and kind

Remove Duplicate Notes rescanned every controller already kept at the
same tick for each incoming one. A session whose sequence holds one tick
with many distinct channel/controller pairs made that quadratic and
blocked editing. Track where each channel+kind sits at the current tick
in a map instead; the last event per key still wins.

* MIDI: tag dedup index entries with their tick instead of clearing per tick

Clearing the map on every tick change cost O(capacity) per tick after a
dense tick. Store the tick with each entry and treat entries from an
earlier tick as absent. Add an oracle test comparing against the
previous per-tick rescan on mixed-tick input.
2026-11-02 22:51:02 -05:00
Dylan M. Taylor 1c350101bc clap-host tests: load plugins only from a freshly created private temp directory (#1295)
* clap-host tests: scan a freshly created private directory for the garbage-file test

The test reused a PID-named directory under the shared temp dir after ignoring a
failed cleanup, then loaded every .clap candidate in it. Another local account
could pre-create that directory and plant a native library that the test runner
would load. Create the directory exclusively (mode 0700 on Unix) and fail
instead of reusing one that already exists.

* clap-host tests: create the garbage-file test directory beside the test binary, not under TMPDIR
2026-11-02 21:30:40 -05:00
4 changed files with 166 additions and 10 deletions
+22 -6
View File
@@ -1,4 +1,6 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::hash_map::Entry;
/// A MIDI note. Times are in model ticks ([`crate::PPQ`] per quarter note).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
@@ -108,14 +110,28 @@ impl Sequence {
self.ctrls.sort_by_key(|c| c.tick);
let mut kept: Vec<CtrlEvent> = Vec::with_capacity(self.ctrls.len());
// The latest tick and `kept` index of each channel+kind, so a same-tick repeat is found
// without rescanning the tick's events (a tick can hold many thousands of distinct keys).
// An entry from an earlier tick counts as absent; keeping the tick instead of clearing the
// map per tick keeps the work per event constant after a dense tick.
let mut last_at: HashMap<(u8, (u8, u8)), (i64, usize)> = HashMap::new();
for c in self.ctrls.drain(..) {
// Within a tick, replace an earlier event of the same channel+kind.
// Only events at the same tick (the tail of `kept`) can match.
let slot =
kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && kind_key(k.kind) == kind_key(c.kind));
match slot {
Some(slot) => *slot = c,
None => kept.push(c),
match last_at.entry((c.channel, kind_key(c.kind))) {
Entry::Occupied(mut e) => {
let (tick, idx) = *e.get();
match kept.get_mut(idx) {
Some(slot) if tick == c.tick => *slot = c,
_ => {
e.insert((c.tick, kept.len()));
kept.push(c);
}
}
}
Entry::Vacant(e) => {
e.insert((c.tick, kept.len()));
kept.push(c);
}
}
}
self.ctrls = kept;
+69
View File
@@ -222,3 +222,72 @@ fn sequence_serde_round_trip() {
let json = serde_json::to_string(&seq).unwrap();
assert_eq!(serde_json::from_str::<Sequence>(&json).unwrap(), seq);
}
#[test]
fn remove_duplicates_handles_a_large_same_tick_controller_group() {
// Every channel x controller number at one tick, twice: the second copy of each wins. A
// rescan of the tick's kept events per incoming event would make this quadratic.
let keys: Vec<(u8, u8)> = (0..=255u8).flat_map(|ch| (0..=255u8).map(move |cc| (ch, cc))).collect();
let ctrls = [0, 1]
.iter()
.flat_map(|&pass| keys.iter().map(move |&(channel, cc)| CtrlEvent { tick: 7, channel, kind: CtrlKind::Cc(cc), value: pass }))
.collect();
let mut seq = Sequence { notes: Vec::new(), ctrls };
let start = std::time::Instant::now();
assert_eq!(seq.remove_duplicates(), keys.len());
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
assert_eq!(seq.ctrls.len(), keys.len());
assert!(seq.ctrls.iter().all(|c| c.value == 1));
}
#[test]
fn remove_duplicates_matches_a_per_tick_rescan_on_mixed_ticks() {
// The straightforward per-tick rescan the indexed version replaced, kept as an oracle.
fn reference(mut ctrls: Vec<CtrlEvent>) -> Vec<CtrlEvent> {
ctrls.sort_by_key(|c| c.tick);
let key = |k: CtrlKind| match k {
CtrlKind::Cc(n) => (0, n),
CtrlKind::PitchBend => (1, 0),
CtrlKind::ChannelPressure => (2, 0),
CtrlKind::PolyPressure(p) => (3, p),
CtrlKind::Program => (4, 0),
};
let mut kept: Vec<CtrlEvent> = Vec::new();
for c in ctrls {
match kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && key(k.kind) == key(c.kind)) {
Some(slot) => *slot = c,
None => kept.push(c),
}
}
kept
}
// Few ticks, channels and kinds, so the same key recurs both within a tick (interleaved with
// other keys) and across ticks; unsorted input from a fixed xorshift stream.
let mut state = 0x9E37_79B9_7F4A_7C15u64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let kinds = |r: u64| match r % 5 {
0 => CtrlKind::Cc((r >> 8) as u8 % 4),
1 => CtrlKind::PitchBend,
2 => CtrlKind::ChannelPressure,
3 => CtrlKind::PolyPressure((r >> 8) as u8 % 4),
_ => CtrlKind::Program,
};
for round in 0..200 {
let mut ctrls = Vec::new();
for i in 0..(next() % 64) {
let r = next();
ctrls.push(CtrlEvent { tick: (r % 6) as i64 - 2, channel: (r >> 16) as u8 % 3, kind: kinds(r >> 24), value: (round * 100 + i) as i32 });
}
let mut seq = Sequence { notes: Vec::new(), ctrls: ctrls.clone() };
let removed = seq.remove_duplicates();
let expected = reference(ctrls.clone());
assert_eq!(seq.ctrls, expected, "round {round}");
assert_eq!(removed, ctrls.len() - expected.len(), "round {round}");
}
}
+36 -2
View File
@@ -380,6 +380,11 @@ impl Movie {
.collect()
}
/// Size the sample table declares for `sample` (0 when there is none).
fn declared_size(&self, sample: usize) -> usize {
self.mp4.tracks.get(self.track).and_then(|t| t.samples.get(sample)).map_or(0, |s| s.size as usize)
}
fn read(&self, sample: usize) -> Result<Vec<u8>> {
let t = self.mp4.tracks.get(self.track).ok_or(VideoError::NoVideoTrack)?;
let size = t.samples.get(sample).map_or(0, |s| s.size);
@@ -415,6 +420,14 @@ impl Movie {
Ok(Frame { width: f.width, height: f.height, rgba, index, time })
}
Codec::Jpeg => {
// The frame's own size, from its header, before a pixel is decoded: the track's
// size was checked against `MAX_PIXELS` when the movie opened, and a frame that
// differs from it is refused, as a ProRes frame is.
let reader = image::ImageReader::with_format(std::io::Cursor::new(&data), image::ImageFormat::Jpeg);
let (w, h) = reader.into_dimensions().map_err(|e| VideoError::Decode(e.to_string()))?;
if (w, h) != (self.info.width, self.info.height) {
return Err(VideoError::Decode(format!("{w}x{h} frame in a {}x{} track", self.info.width, self.info.height)));
}
let img = image::load_from_memory_with_format(&data, image::ImageFormat::Jpeg).map_err(|e| VideoError::Decode(e.to_string()))?;
let rgba = img.to_rgba8();
let (width, height) = rgba.dimensions();
@@ -457,6 +470,14 @@ impl Movie {
Ok(self.h264_to_frame(p, index))
}
/// Most sample bytes one frame request feeds the decoder (two samples at the 256 MiB cap):
/// a request decodes from the previous keyframe, and a sample table whose entries all name
/// one large payload would otherwise read and parse it once per sample.
const MAX_REQUEST_BYTES: usize = 512 << 20;
/// Most samples one frame request feeds without a picture coming out: the reorder depth is
/// at most 16, so a stream that produces nothing for this long will produce nothing.
const MAX_SAMPLES_WITHOUT_PICTURE: usize = 256;
fn decode_h264(&mut self, index: usize) -> Result<Arc<Frame>> {
let Codec::H264 { avcc } = &self.codec else { return Err(VideoError::Unsupported("not H.264".into())) };
let target = self.order.get(index).copied().ok_or(VideoError::OutOfRange)?;
@@ -471,6 +492,7 @@ impl Movie {
}
let total = self.order.len();
let mut last_err: Option<String> = None;
let (mut fed, mut without_picture) = (0usize, 0usize);
// Bounded: each iteration feeds one sample, or flushes once at the end.
for _ in 0..=total.saturating_add(1) {
let Some(st) = self.h264.as_mut() else { break };
@@ -485,13 +507,21 @@ impl Movie {
let s = st.next;
st.next += 1;
let pts = self.rank.get(s).copied().unwrap_or(0) as i64;
// The declared size is checked before the read allocates it. An error leaves no decoder
// behind that has stepped past this sample: the next request starts from its keyframe.
if fed.saturating_add(self.declared_size(s)) > Self::MAX_REQUEST_BYTES {
self.h264 = None;
return Err(VideoError::Decode(format!("frame {index} needs more than {} MiB of samples", Self::MAX_REQUEST_BYTES >> 20)));
}
let data = match self.read(s) {
Ok(d) => d,
Err(e) => {
last_err = Some(e.to_string());
continue;
// a failing source is not probed sample after sample (each probe may allocate a whole sample)
self.h264 = None;
return Err(e);
}
};
fed = fed.saturating_add(data.len());
let Some(st) = self.h264.as_mut() else { break };
match st.dec.decode(&data, pts) {
Ok(p) => p,
@@ -506,6 +536,10 @@ impl Movie {
st.dec.flush()
};
let Some(st) = self.h264.as_mut() else { break };
without_picture = if pics.is_empty() { without_picture + 1 } else { 0 };
if without_picture > Self::MAX_SAMPLES_WITHOUT_PICTURE {
return Err(VideoError::Decode(format!("no picture in {} samples before frame {index}", Self::MAX_SAMPLES_WITHOUT_PICTURE)));
}
for p in pics {
let q = usize::try_from(p.pts).unwrap_or(usize::MAX);
st.last_out = Some(q);
+39 -2
View File
@@ -121,7 +121,7 @@ fn prores_frame_size_is_checked_before_decoding() {
}
/// In-memory movie bytes, zero-padded up to `len` without storing the padding. Records the
/// largest read asked of it and refuses reads over 1 MiB (the test movies are much smaller).
/// largest read asked of it and refuses reads over 32 MiB (the test movies are much smaller).
struct PaddedSource {
bytes: Vec<u8>,
len: u64,
@@ -134,7 +134,7 @@ impl ByteSource for PaddedSource {
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
self.largest.fetch_max(buf.len(), Ordering::Relaxed);
if buf.len() > 1 << 20 {
if buf.len() > 32 << 20 {
return Err(std::io::Error::other("read too large for the test source"));
}
let start = usize::try_from(offset).unwrap_or(usize::MAX);
@@ -161,6 +161,43 @@ fn h264_sample_reads_are_capped() {
assert!(largest <= 256 << 20, "a {largest}-byte read reached the source");
}
#[test]
fn one_frame_request_reads_a_bounded_amount_of_samples() {
// 17 frames whose `stsz` entries each claim 32 MiB: under the per-sample cap, but together
// past what one request may feed the decoder.
let mut bytes = h264_mp4(17, 17).unwrap();
let at = bytes.windows(4).position(|w| w == b"stsz").unwrap();
if bytes[at + 8..at + 12] == [0; 4] {
// one size per sample
for i in 0..17 {
let field = at + 16 + 4 * i;
bytes[field..field + 4].copy_from_slice(&(32u32 << 20).to_be_bytes());
}
} else {
// the writer stored one size for all the (equally large) samples
bytes[at + 8..at + 12].copy_from_slice(&(32u32 << 20).to_be_bytes());
}
let src = Arc::new(PaddedSource { bytes, len: 1 << 30, largest: AtomicUsize::new(0) });
let mut m = Movie::open_source(src).unwrap();
let r = m.frame(16);
assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("needs more than 512 MiB of samples")), "{r:?}");
// the sample that was refused is not skipped: asking again fails the same way instead of decoding past it
let again = m.frame(16);
assert!(matches!(&again, Err(VideoError::Decode(e)) if e.contains("needs more than 512 MiB of samples")), "{again:?}");
}
#[test]
fn mjpeg_frame_size_is_checked_before_decoding() {
// The first frame's SOF0 header claims 16 000 x 16 000: refused from the header, not decoded.
let mut bytes = mjpeg_mov(2).unwrap();
let sof = bytes.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap();
bytes[sof + 5..sof + 9].copy_from_slice(&[0x3E, 0x80, 0x3E, 0x80]);
let mut m = Movie::open(bytes).unwrap();
let r = m.frame(0);
assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("16000x16000 frame in a 32x24 track")), "{r:?}");
assert!(m.frame(1).is_ok(), "the untouched frame still decodes");
}
#[test]
fn unsupported_codec_opens_but_does_not_decode() {
let mut mw = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();