Skip to main content

eventcv_core/
io.rs

1use std::{error::Error, fmt, io, path::Path};
2
3use crate::{EventStream, EventStreamBuilder};
4
5mod aedat;
6mod bag;
7#[cfg(feature = "hdf5")]
8mod h5;
9mod npz;
10mod prophesee;
11mod text;
12
13pub use aedat::read_aedat;
14pub use bag::{open_bag_slice, read_bag, write_bag, BagSliceSource};
15#[cfg(feature = "hdf5")]
16pub use h5::{
17    open_hdf5_slice, read_hdf5, read_hdf5_frame, write_hdf5_frame, write_hdf5_stream,
18    Hdf5FrameSink, Hdf5SliceSource,
19};
20pub use npz::{read_npz, read_npz_frame, write_npz_frame, write_npz_stream};
21pub use prophesee::read_dat;
22pub use text::{
23    load_rows, open_text_slice, read_text, write_text_stream, ColumnOrder, RawRow, TextOptions,
24    TextReader, TimeUnit,
25};
26
27use crate::representation::EventFrame;
28use crate::viz::{render_frame, Colormap};
29
30/// A single event as produced by a reader, before it is placed on the sensor grid.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct RawEvent {
33    pub x: u16,
34    pub y: u16,
35    pub t: i64,
36    pub p: bool,
37}
38
39/// A bounded-memory source of events. Readers parse on demand so multi-gigabyte
40/// files never need to be resident; the consumer decides what to accumulate.
41pub trait EventSource {
42    fn sensor_size(&self) -> (usize, usize);
43    fn timestamp_scale_ms(&self) -> f64;
44    /// Returns the next event, or `None` at end of stream.
45    fn next_event(&mut self) -> Result<Option<RawEvent>, IoError>;
46}
47
48/// Drains a source into an in-memory [`EventStream`], dropping out-of-bounds events.
49pub fn read_all(source: impl EventSource) -> Result<EventStream, IoError> {
50    read_capped(source, None)
51}
52
53/// Like [`read_all`] but stops after `max` kept events (for previewing huge files).
54pub fn read_capped(
55    mut source: impl EventSource,
56    max: Option<usize>,
57) -> Result<EventStream, IoError> {
58    let (width, height) = source.sensor_size();
59    let mut builder = EventStreamBuilder::new(width, height, source.timestamp_scale_ms());
60    while let Some(event) = source.next_event()? {
61        builder.push(event.x, event.y, event.t, event.p);
62        if max.is_some_and(|max| builder.len() >= max) {
63            break;
64        }
65    }
66    Ok(builder.build())
67}
68
69/// Options for the unified [`load`] entry point. Most fields apply to a single
70/// format; readers ignore the ones they do not need.
71#[derive(Clone, Debug, Default)]
72pub struct LoadOptions {
73    /// `(width, height)`. `None` infers it from the data (coordinate range), or from the
74    /// message for rosbag. An explicit value overrides and, for HDF5, skips the scan.
75    pub sensor_size: Option<(usize, usize)>,
76    /// Timestamp unit. `None` infers it (HDF5/text): a fractional text value means
77    /// seconds, otherwise the unit is chosen from the recording span (see
78    /// `TimeUnit::infer_from_span`). Ignored for rosbag (always ROS `sec`+`nsec`).
79    pub time_unit: Option<TimeUnit>,
80    /// Text column order.
81    pub order: ColumnOrder,
82    /// Rosbag topic to read (defaults to `/davis/left/events`).
83    pub topic: Option<String>,
84    /// Cap on the number of events to read.
85    pub max_events: Option<usize>,
86    /// Absolute timestamp (µs, the file's own time base): events before it are skipped.
87    /// `max_events` then caps the events *after* the offset. `None`/`<= 0` reads from the start.
88    pub offset: Option<i64>,
89    /// Explicit x/y/t/p column names, overriding auto-detection when a file's layout can't
90    /// be guessed. For HDF5 each value is a dataset path (a compound field is `dataset/field`);
91    /// for text/CSV a header column name or 0-based index. `None` auto-detects. Readers that
92    /// don't need it (rosbag, aedat, dat) ignore it.
93    pub keys: Option<EventKeys>,
94}
95
96/// User-supplied names for the four event columns, the escape hatch when auto-detection
97/// can't identify them. Interpreted per format (HDF5 dataset paths, text header
98/// names/indices); see [`LoadOptions::keys`].
99#[derive(Clone, Debug)]
100pub struct EventKeys {
101    pub x: String,
102    pub y: String,
103    pub t: String,
104    pub p: String,
105}
106
107/// Column index of each event field in the `[x, y, t, p]` ordering the readers share.
108pub(crate) const X: usize = 0;
109pub(crate) const Y: usize = 1;
110pub(crate) const T: usize = 2;
111pub(crate) const P: usize = 3;
112
113/// Case-insensitive name synonyms for each event field, used by the HDF5 and text readers
114/// to identify x/y/t/p under varied headings (dataset names, CSV headers). Order within a
115/// list is the tie-break preference — earlier is more canonical.
116pub(crate) const ROLE_KEYS: [&[&str]; 4] = [
117    &[
118        "x",
119        "xs",
120        "x_coordinate",
121        "x_coordinates",
122        "u",
123        "col",
124        "cols",
125        "column",
126        "columns",
127    ],
128    &[
129        "y",
130        "ys",
131        "y_coordinate",
132        "y_coordinates",
133        "v",
134        "row",
135        "rows",
136    ],
137    &[
138        "t",
139        "ts",
140        "time",
141        "times",
142        "timestamp",
143        "timestamps",
144        "time_stamp",
145    ],
146    &[
147        "p",
148        "ps",
149        "pol",
150        "pols",
151        "polarity",
152        "polarities",
153        "polarity_bit",
154        "polarity_bits",
155        "sign",
156    ],
157];
158
159/// The event field (`X`/`Y`/`T`/`P`) a column or dataset base name denotes, matched
160/// case-insensitively against [`ROLE_KEYS`]; `None` if it matches none. The synonym lists
161/// are disjoint, so at most one role matches.
162pub(crate) fn role_of(name: &str) -> Option<usize> {
163    let lower = name.to_ascii_lowercase();
164    ROLE_KEYS
165        .iter()
166        .position(|keys| keys.contains(&lower.as_str()))
167}
168
169/// Rank of `name` within `role`'s synonym list (lower = more canonical), used to choose
170/// between several names in one group that map to the same role; `None` if it isn't one.
171#[cfg_attr(not(feature = "hdf5"), allow(dead_code))]
172pub(crate) fn role_rank(role: usize, name: &str) -> Option<usize> {
173    let lower = name.to_ascii_lowercase();
174    ROLE_KEYS[role].iter().position(|key| *key == lower)
175}
176
177#[derive(Clone, Copy, Debug, PartialEq, Eq)]
178enum Format {
179    Npz,
180    Text,
181    Hdf5,
182    Rosbag,
183    Aedat,
184    Aedat4,
185    PropheseeDat,
186    PropheseeRaw,
187    Png,
188}
189
190fn detect_format(path: &Path) -> Result<Format, IoError> {
191    let extension = path
192        .extension()
193        .and_then(|extension| extension.to_str())
194        .map(str::to_ascii_lowercase);
195    match extension.as_deref() {
196        Some("npz") => Ok(Format::Npz),
197        Some("txt") | Some("csv") => Ok(Format::Text),
198        Some("h5") | Some("hdf5") => Ok(Format::Hdf5),
199        Some("bag") => Ok(Format::Rosbag),
200        Some("aedat") => Ok(Format::Aedat),
201        Some("aedat4") => Ok(Format::Aedat4),
202        Some("dat") => Ok(Format::PropheseeDat),
203        Some("raw") => Ok(Format::PropheseeRaw),
204        Some("png") => Ok(Format::Png),
205        Some(other) => Err(IoError::Unsupported(format!(
206            "unrecognised file extension: .{other}"
207        ))),
208        None => Err(IoError::Unsupported(
209            "file has no extension to detect its format".to_owned(),
210        )),
211    }
212}
213
214/// Loads events from any supported file, detected by extension — the OpenCV-style
215/// single entry point. Supported today: `.npz`, `.txt`/`.csv`, `.bag`, `.h5`/`.hdf5`,
216/// `.aedat` (AEDAT 2.0), and `.dat` (Prophesee CD).
217pub fn load(path: impl AsRef<Path>, options: LoadOptions) -> Result<EventStream, IoError> {
218    let path = path.as_ref();
219    let Some(cutoff) = options.offset.filter(|&offset| offset > 0) else {
220        return load_format(path, &options);
221    };
222    // The offset is an absolute timestamp, so the whole recording must be read before
223    // skipping; the cap then applies to the events at/after the offset.
224    let mut read_options = options.clone();
225    read_options.max_events = None;
226    load_format(path, &read_options).map(|stream| skip_before(stream, cutoff, options.max_events))
227}
228
229/// Drops events before the absolute timestamp `cutoff` (µs), then keeps at most `max` of the rest.
230fn skip_before(stream: EventStream, cutoff: i64, max: Option<usize>) -> EventStream {
231    let (ts, (xs, ys, ps)) = (stream.ts(), (stream.xs(), stream.ys(), stream.ps()));
232    if ts.is_empty() {
233        return stream;
234    }
235    let (width, height) = stream.sensor_size();
236    let mut builder = EventStreamBuilder::new(width, height, stream.timestamp_scale_ms());
237    for index in (0..ts.len()).filter(|&index| ts[index] >= cutoff) {
238        builder.push(xs[index], ys[index], ts[index], ps[index]);
239        if max.is_some_and(|max| builder.len() >= max) {
240            break;
241        }
242    }
243    builder.build()
244}
245
246fn load_format(path: &Path, options: &LoadOptions) -> Result<EventStream, IoError> {
247    match detect_format(path)? {
248        Format::Npz => npz::read_npz(path, options.sensor_size),
249        Format::Text => text::load_text(path, options),
250        Format::Rosbag => bag::read_bag(path, options),
251        Format::Hdf5 => {
252            #[cfg(feature = "hdf5")]
253            {
254                h5::read_hdf5(path, options)
255            }
256            #[cfg(not(feature = "hdf5"))]
257            {
258                Err(IoError::Unsupported(
259                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
260                ))
261            }
262        }
263        Format::Aedat => aedat::read_aedat(path, options),
264        Format::Aedat4 => Err(IoError::Unsupported(
265            "AEDAT4 (.aedat4, iniVation DV FlatBuffer/LZ4) reading is not implemented yet"
266                .to_owned(),
267        )),
268        Format::PropheseeDat => prophesee::read_dat(path, options),
269        Format::PropheseeRaw => Err(IoError::Unsupported(
270            "Prophesee .raw (EVT2/EVT3) reading is not implemented yet".to_owned(),
271        )),
272        Format::Png => Err(IoError::Unsupported(
273            "PNG is a frame export format, not an event stream; use save_frame".to_owned(),
274        )),
275    }
276}
277
278/// Random-access view over a file's events: fetch an arbitrary time or count range
279/// without materialising the whole stream. This backs the lazy [`open`] handle — the
280/// OpenCV `VideoCapture` to [`load`]'s `imread`. Each call returns a new [`EventStream`].
281pub trait SliceSource: Send {
282    fn sensor_size(&self) -> (usize, usize);
283    fn timestamp_scale_ms(&self) -> f64;
284    /// Total events in the file.
285    fn n_events(&self) -> usize;
286    /// `(t_min, t_max)` in microseconds across the whole file; `(0, 0)` when empty.
287    fn time_span(&self) -> (i64, i64);
288    /// Events whose index lies in `[i0, i1)` (clamped to the file).
289    fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError>;
290    /// Events whose timestamp (µs) lies in the half-open window `[t0, t1)`.
291    fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError>;
292
293    /// Per-pixel event counts over the whole file (row-major `width·height`), out-of-bounds
294    /// events dropped — what the reader's hot-pixel pre-scan needs. The default tallies through
295    /// `slice_index` in bounded chunks; a source that can read coordinates alone (HDF5) overrides
296    /// this to skip the `t`/`p` columns and stream construction, which is most of the work.
297    fn pixel_counts(&self) -> Result<Vec<u64>, IoError> {
298        const CHUNK: usize = 8_000_000;
299        let (width, height) = self.sensor_size();
300        let mut counts = vec![0u64; width * height];
301        let total = self.n_events();
302        let mut start = 0;
303        while start < total {
304            let end = (start + CHUNK).min(total);
305            self.slice_index(start, end)?.add_pixel_counts(&mut counts);
306            start = end;
307        }
308        Ok(counts)
309    }
310}
311
312/// A [`SliceSource`] backed by an already-loaded stream — the universal fallback for
313/// formats without native random access (npz/txt/bag today). Slicing is entirely
314/// in-RAM, so `open()` returns a working handle for every supported format from day one.
315pub struct MemorySliceSource {
316    stream: EventStream,
317}
318
319impl MemorySliceSource {
320    pub fn new(stream: EventStream) -> Self {
321        Self { stream }
322    }
323
324    fn rebuild(&self, indices: impl Iterator<Item = usize>) -> EventStream {
325        let (width, height) = self.stream.sensor_size();
326        let mut builder = EventStreamBuilder::new(width, height, self.stream.timestamp_scale_ms());
327        let (xs, ys, ts, ps) = (
328            self.stream.xs(),
329            self.stream.ys(),
330            self.stream.ts(),
331            self.stream.ps(),
332        );
333        for index in indices {
334            builder.push(xs[index], ys[index], ts[index], ps[index]);
335        }
336        builder.build()
337    }
338}
339
340impl SliceSource for MemorySliceSource {
341    fn sensor_size(&self) -> (usize, usize) {
342        self.stream.sensor_size()
343    }
344
345    fn timestamp_scale_ms(&self) -> f64 {
346        self.stream.timestamp_scale_ms()
347    }
348
349    fn n_events(&self) -> usize {
350        self.stream.len()
351    }
352
353    fn time_span(&self) -> (i64, i64) {
354        let ts = self.stream.ts();
355        match (ts.iter().min(), ts.iter().max()) {
356            (Some(&min), Some(&max)) => (min, max),
357            _ => (0, 0),
358        }
359    }
360
361    fn slice_index(&self, i0: usize, i1: usize) -> Result<EventStream, IoError> {
362        let i0 = i0.min(self.stream.len());
363        let i1 = i1.clamp(i0, self.stream.len());
364        Ok(self.rebuild(i0..i1))
365    }
366
367    fn slice_time(&self, t0: i64, t1: i64) -> Result<EventStream, IoError> {
368        let ts = self.stream.ts();
369        Ok(self.rebuild((0..ts.len()).filter(|&index| ts[index] >= t0 && ts[index] < t1)))
370    }
371
372    fn pixel_counts(&self) -> Result<Vec<u64>, IoError> {
373        // Already resident — tally straight from the stream, no chunked rebuilds.
374        let (width, height) = self.stream.sensor_size();
375        let mut counts = vec![0u64; width * height];
376        self.stream.add_pixel_counts(&mut counts);
377        Ok(counts)
378    }
379}
380
381/// A boxed [`SliceSource`] — the handle [`open`] returns.
382pub type Reader = Box<dyn SliceSource>;
383
384/// Opens a file for lazy slicing, detected by extension (the `VideoCapture` analogue to
385/// [`load`]'s `imread`). HDF5 is sliced in place by binary-searching its timestamp
386/// dataset; every other format is loaded once and sliced in memory. Same `LoadOptions`
387/// as [`load`] (`max_events` is ignored — slicing supersedes it).
388pub fn open(path: impl AsRef<Path>, options: LoadOptions) -> Result<Reader, IoError> {
389    let path = path.as_ref();
390    match detect_format(path)? {
391        Format::Hdf5 => {
392            #[cfg(feature = "hdf5")]
393            {
394                Ok(Box::new(h5::open_hdf5_slice(path, &options)?))
395            }
396            #[cfg(not(feature = "hdf5"))]
397            {
398                Err(IoError::Unsupported(
399                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
400                ))
401            }
402        }
403        Format::Text => Ok(Box::new(text::open_text_slice(path, &options)?)),
404        Format::Rosbag => Ok(Box::new(bag::open_bag_slice(path, &options)?)),
405        _ => Ok(Box::new(MemorySliceSource::new(load(path, options)?))),
406    }
407}
408
409/// Options for the [`save_stream`] / [`save_frame`] writers — the symmetric mirror of
410/// [`LoadOptions`]. Most formats ignore every field; readers and writers agree on the rest.
411#[derive(Clone, Debug, Default)]
412pub struct SaveOptions {
413    /// Rosbag topic to write the `dvs_msgs/EventArray` messages on (defaults to
414    /// `/davis/left/events`, matching the reader).
415    pub topic: Option<String>,
416    /// PNG frame export only: the colour map (default [`Colormap::Viridis`]).
417    pub colormap: Colormap,
418    /// PNG frame export only: auto-contrast the field to its data range. `None` = `true`.
419    pub normalize: Option<bool>,
420}
421
422/// Persists an [`EventStream`] to `path`, the format chosen by extension — the symmetric
423/// counterpart of [`load`]. npz/HDF5/rosbag round-trip exactly (metadata stored); txt
424/// stores `t x y p` and recovers sensor size / time unit on load via inference or options.
425pub fn save_stream(
426    path: impl AsRef<Path>,
427    stream: &EventStream,
428    options: &SaveOptions,
429) -> Result<(), IoError> {
430    let path = path.as_ref();
431    match detect_format(path)? {
432        Format::Npz => npz::write_npz_stream(path, stream),
433        Format::Text => text::write_text_stream(path, stream),
434        Format::Rosbag => bag::write_bag(path, stream, options.topic.as_deref()),
435        Format::Hdf5 => {
436            #[cfg(feature = "hdf5")]
437            {
438                h5::write_hdf5_stream(path, stream)
439            }
440            #[cfg(not(feature = "hdf5"))]
441            {
442                Err(IoError::Unsupported(
443                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
444                ))
445            }
446        }
447        other => Err(IoError::Unsupported(format!(
448            "saving an event stream as {other:?} is not supported"
449        ))),
450    }
451}
452
453/// Persists an [`EventFrame`] (a computed representation) to `path`, preserving its shape,
454/// dtype, `kind`, and `channel_names`. Supported: npz (default build) and HDF5.
455pub fn save_frame(
456    path: impl AsRef<Path>,
457    frame: &EventFrame,
458    options: &SaveOptions,
459) -> Result<(), IoError> {
460    let path = path.as_ref();
461    match detect_format(path)? {
462        Format::Npz => npz::write_npz_frame(path, frame),
463        Format::Hdf5 => {
464            #[cfg(feature = "hdf5")]
465            {
466                h5::write_hdf5_frame(path, frame)
467            }
468            #[cfg(not(feature = "hdf5"))]
469            {
470                Err(IoError::Unsupported(
471                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
472                ))
473            }
474        }
475        Format::Png => write_png_frame(path, frame, options),
476        other => Err(IoError::Unsupported(format!(
477            "saving an event frame as {other:?} is not supported"
478        ))),
479    }
480}
481
482/// Renders a frame through [`render_frame`] (colormapped 2-D view) and encodes it as an
483/// 8-bit RGB PNG. Unlike npz/HDF5 this is a *view*, not a round-trippable dump.
484fn write_png_frame(path: &Path, frame: &EventFrame, options: &SaveOptions) -> Result<(), IoError> {
485    let image = render_frame(frame, options.colormap, options.normalize.unwrap_or(true));
486    let file = std::fs::File::create(path)?;
487    let writer = std::io::BufWriter::new(file);
488    let mut encoder = png::Encoder::new(writer, image.width as u32, image.height as u32);
489    encoder.set_color(png::ColorType::Rgb);
490    encoder.set_depth(png::BitDepth::Eight);
491    encoder
492        .write_header()
493        .and_then(|mut writer| writer.write_image_data(&image.pixels))
494        .map_err(|error| match error {
495            png::EncodingError::IoError(error) => IoError::Io(error),
496            other => IoError::Format(other.to_string()),
497        })
498}
499
500/// Reads an [`EventFrame`] previously written by [`save_frame`], reconstructing its dtype,
501/// `kind`, and `channel_names`. Supported: npz (default build) and HDF5.
502pub fn load_frame(path: impl AsRef<Path>) -> Result<EventFrame, IoError> {
503    let path = path.as_ref();
504    match detect_format(path)? {
505        Format::Npz => npz::read_npz_frame(path),
506        Format::Hdf5 => {
507            #[cfg(feature = "hdf5")]
508            {
509                h5::read_hdf5_frame(path)
510            }
511            #[cfg(not(feature = "hdf5"))]
512            {
513                Err(IoError::Unsupported(
514                    "HDF5 support is not built in; rebuild with --features hdf5".to_owned(),
515                ))
516            }
517        }
518        other => Err(IoError::Unsupported(format!(
519            "loading an event frame from {other:?} is not supported"
520        ))),
521    }
522}
523
524#[derive(Debug)]
525pub enum IoError {
526    Io(io::Error),
527    Parse { line: usize, message: String },
528    Format(String),
529    InvalidSensorSize,
530    Unsupported(String),
531}
532
533impl fmt::Display for IoError {
534    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
535        match self {
536            Self::Io(error) => error.fmt(formatter),
537            Self::Parse { line, message } => write!(formatter, "line {line}: {message}"),
538            Self::Format(message) => formatter.write_str(message),
539            Self::InvalidSensorSize => {
540                formatter.write_str("sensor width and height must be positive")
541            }
542            Self::Unsupported(message) => formatter.write_str(message),
543        }
544    }
545}
546
547impl Error for IoError {
548    fn source(&self) -> Option<&(dyn Error + 'static)> {
549        match self {
550            Self::Io(error) => Some(error),
551            _ => None,
552        }
553    }
554}
555
556impl From<io::Error> for IoError {
557    fn from(error: io::Error) -> Self {
558        Self::Io(error)
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::{load, open, IoError, LoadOptions, MemorySliceSource, SliceSource};
565    use crate::EventStreamBuilder;
566
567    #[test]
568    fn unknown_extension_is_unsupported() {
569        let error = load("recording.mp4", LoadOptions::default()).unwrap_err();
570        assert!(matches!(error, IoError::Unsupported(_)));
571    }
572
573    #[test]
574    fn hdf5_extension_dispatches_per_feature() {
575        // `.h5` is always recognised; with the feature it reaches the reader (a missing
576        // file is then an IO error), without it the dispatch reports missing support.
577        let error = load("recording.h5", LoadOptions::default()).unwrap_err();
578        #[cfg(feature = "hdf5")]
579        assert!(matches!(error, IoError::Io(_)));
580        #[cfg(not(feature = "hdf5"))]
581        match error {
582            IoError::Unsupported(message) => assert!(message.contains("HDF5")),
583            other => panic!("expected unsupported error, got {other:?}"),
584        }
585    }
586
587    #[test]
588    fn text_missing_file_is_reported() {
589        // Text no longer requires sensor_size (it infers); a missing file is an IO error.
590        let error = load("events.txt", LoadOptions::default()).unwrap_err();
591        assert!(matches!(error, IoError::Io(_)));
592    }
593
594    #[test]
595    fn aedat_dispatches_to_the_reader() {
596        // `.aedat` reaches the AEDAT 2.0 reader, so a missing file is an IO error.
597        let error = load("recording.aedat", LoadOptions::default()).unwrap_err();
598        assert!(matches!(error, IoError::Io(_)));
599    }
600
601    #[test]
602    fn prophesee_dat_dispatches_to_the_reader() {
603        let error = load("recording.dat", LoadOptions::default()).unwrap_err();
604        assert!(matches!(error, IoError::Io(_)));
605    }
606
607    #[test]
608    fn aedat4_and_prophesee_raw_are_unsupported() {
609        // These extensions are recognised but their formats are not implemented yet.
610        for path in ["recording.aedat4", "recording.raw"] {
611            match load(path, LoadOptions::default()) {
612                Err(IoError::Unsupported(_)) => {}
613                other => panic!("expected unsupported for {path}, got {other:?}"),
614            }
615        }
616    }
617
618    fn sample_source() -> MemorySliceSource {
619        let mut builder = EventStreamBuilder::new(4, 4, 0.001);
620        builder.push(0, 0, 0, true);
621        builder.push(1, 1, 10, false);
622        builder.push(2, 2, 20, true);
623        builder.push(0, 1, 30, false);
624        MemorySliceSource::new(builder.build())
625    }
626
627    #[test]
628    fn memory_source_reports_span_and_count() {
629        let source = sample_source();
630        assert_eq!(source.n_events(), 4);
631        assert_eq!(source.time_span(), (0, 30));
632    }
633
634    #[test]
635    fn memory_source_slices_by_time_and_index() {
636        let source = sample_source();
637
638        // Half-open [10, 30) keeps t = 10 and 20.
639        assert_eq!(source.slice_time(10, 30).unwrap().ts(), &[10, 20]);
640        assert_eq!(source.slice_index(1, 3).unwrap().ts(), &[10, 20]);
641        assert_eq!(source.slice_index(2, 100).unwrap().len(), 2); // hi clamped to len
642        assert!(source.slice_time(100, 200).unwrap().is_empty());
643    }
644
645    #[test]
646    fn open_rejects_unknown_extension() {
647        // `Reader` is a trait object (not `Debug`), so match rather than `unwrap_err`.
648        match open("recording.mp4", LoadOptions::default()) {
649            Err(IoError::Unsupported(_)) => {}
650            Err(other) => panic!("expected unsupported error, got {other:?}"),
651            Ok(_) => panic!("expected an error for an unknown extension"),
652        }
653    }
654}