diff --git a/crates/oak-cli/src/cmd/probe.rs b/crates/oak-cli/src/cmd/probe.rs index 79d5e1d44..4ee21a744 100644 --- a/crates/oak-cli/src/cmd/probe.rs +++ b/crates/oak-cli/src/cmd/probe.rs @@ -20,13 +20,10 @@ //! //! Runs entirely through the module crates (M14 R2): an //! [`oaknode::footage::FootageBehavior`] probes the file through the -//! oakcodec decoder registry and the CLI prints what the module records. -//! The module currently records the decoder id but drops the codec's -//! stream descriptions, so `duration` reports 0 and the stream counts -//! report 0 for media the module has not loaded stream metadata for — the -//! CLI prints exactly what the module answers, unchanged from the facade -//! contract. A missing file prints `error: probe: file does not exist: -//! ` on stderr and exits 1. +//! oakcodec decoder registry and the CLI prints what the module records: +//! the decoder id, the footage duration and the probed stream inventory +//! (per-stream duration in rational seconds). A missing file prints +//! `error: probe: file does not exist: ` on stderr and exits 1. use oaknode::footage::FootageBehavior; @@ -45,76 +42,82 @@ fn run_probe(mediafile: &str) -> i32 { return EXIT_ERROR; } - // The module probe records the decoder id (best effort; a failed probe - // leaves the node usable with empty streams, exactly like the facade's - // footage create). + // The module probe records the decoder id and the stream inventory + // (best effort; a failed probe leaves the footage unprobed with empty + // streams, exactly like the facade's footage create). let mut footage = FootageBehavior::new(mediafile); let _ = footage.probe(); println!("{}", fmt::decoder_line(&footage.decoder)); let duration = footage.duration(); - let duration_secs = if duration.denominator() != 0 { - duration.numerator() as f64 / duration.denominator() as f64 - } else { - 0.0 - }; - println!("{}", fmt::duration_line(duration_secs)); + println!("{}", fmt::duration_line(rational_secs(duration))); - let video = footage.video_stream_count(); - println!("{}", fmt::video_streams_line(video as i64)); - for index in 0..video { - if let Some(params) = footage.video_params(index) { + let total = footage.total_stream_count(); + let mut video_index = 0i64; + let mut audio_index = 0i64; + println!("{}", fmt::video_streams_line(footage.video_stream_count() as i64)); + for i in 0..total { + let Some(stream) = footage.stream_at(i) else { continue }; + if !stream.is_video { + continue; + } + if let Some(params) = stream.video { let fr = params.frame_rate; - let secs = if fr.denominator() != 0 { - fr.numerator() as f64 / fr.denominator() as f64 - } else { - 0.0 - }; println!( "{}", fmt::video_stream( - index as i64, - index as i64, + video_index, + stream.index as i64, params.width as i64, params.height as i64, fr.numerator(), fr.denominator(), - 0, - fr.denominator(), - secs, + stream.duration.numerator(), + stream.duration.denominator(), + rational_secs(stream.duration), 0, 0, false, ) ); + video_index += 1; } } - let audio = footage.audio_stream_count(); - println!("{}", fmt::audio_streams_line(audio as i64)); - for index in 0..audio { - // The module's audio stream descriptions are not reachable yet - // (the stream entries are dropped by the probe); streams the module - // cannot describe are counted only. - if let Some(params) = footage.audio_params(index) { + println!("{}", fmt::audio_streams_line(footage.audio_stream_count() as i64)); + for i in 0..total { + let Some(stream) = footage.stream_at(i) else { continue }; + if stream.is_video { + continue; + } + if let Some(params) = stream.audio { println!( "{}", fmt::audio_stream( - index as i64, - index as i64, + audio_index, + stream.index as i64, params.sample_rate as i64, params.channel_layout.count_ones() as i64, - 0, - 1, - 0.0, + stream.duration.numerator(), + stream.duration.denominator(), + rational_secs(stream.duration), ) ); + audio_index += 1; } } - let subtitle = footage.subtitle_stream_count(); - println!("{}", fmt::subtitle_streams_line(subtitle as i64)); + println!("{}", fmt::subtitle_streams_line(footage.subtitle_stream_count() as i64)); EXIT_OK } + +/// A rational as floating-point seconds (0 on a zero denominator). +fn rational_secs(r: oakcore_rs::Rational) -> f64 { + if r.denominator() != 0 { + r.numerator() as f64 / r.denominator() as f64 + } else { + 0.0 + } +} diff --git a/crates/oak-cli/tests/cli.rs b/crates/oak-cli/tests/cli.rs index 19e02dead..6a637b484 100644 --- a/crates/oak-cli/tests/cli.rs +++ b/crates/oak-cli/tests/cli.rs @@ -189,12 +189,15 @@ fn probe_on_the_media_fixture_prints_streams() { let (code, stdout, stderr) = run(&["probe", media.to_str().unwrap()]); assert_eq!(code, 0, "stderr: {stderr}"); // tests/demo.mp4 through the engine: the probe records the ffmpeg - // decoder; the stream descriptions are dropped by the module today, - // so the counts/duration report 0 — the pinned engine contract. + // decoder and the full stream inventory (1920x1080 @ 25 fps video, + // 48 kHz stereo audio, 17 s each). assert!(stdout.contains("Decoder: ffmpeg"), "{stdout}"); - assert!(stdout.contains("Duration: 0.000000 s"), "{stdout}"); - assert!(stdout.contains("Video streams: 0"), "{stdout}"); - assert!(stdout.contains("Audio streams: 0"), "{stdout}"); + assert!(stdout.contains("Duration: 17.000000 s"), "{stdout}"); + assert!(stdout.contains("Video streams: 1"), "{stdout}"); + assert!(stdout.contains("1920x1080"), "{stdout}"); + assert!(stdout.contains("25/1 fps"), "{stdout}"); + assert!(stdout.contains("Audio streams: 1"), "{stdout}"); + assert!(stdout.contains("48000 Hz, 2 channels"), "{stdout}"); assert!(stdout.contains("Subtitle streams: 0"), "{stdout}"); } diff --git a/crates/oaknode/src/footage.rs b/crates/oaknode/src/footage.rs index fccca5e5b..dd7a32501 100644 --- a/crates/oaknode/src/footage.rs +++ b/crates/oaknode/src/footage.rs @@ -21,8 +21,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; -use oakcodec::decoder::Decoder as _; - use crate::input::Input; use crate::node::{Category, NodeBehavior, NodeCore}; use crate::value::{AudioParams, NodeValue, ValueType, VideoParams}; @@ -38,7 +36,8 @@ pub struct StreamInfo { pub video: Option, /// Audio parameters (when not video). pub audio: Option, - /// Duration in stream timebase. + /// Duration in rational seconds (the longest stream wins; see + /// [`FootageBehavior::duration`]). pub duration: oakcore_rs::Rational, } @@ -87,7 +86,8 @@ impl FootageBehavior { } /// Probe the file through oakcodec's decoder registry, recording the - /// recognized decoder id. Error on unreadable/corrupt media; the + /// recognized decoder id, the probed stream inventory and the file's + /// last-modified timestamp. Error on unreadable/corrupt media; the /// prior `streams`/`valid` state is preserved on failure (no partial /// state). pub fn probe(&mut self) -> crate::error::Result<()> { @@ -109,10 +109,20 @@ impl FootageBehavior { Some(desc) }) .ok_or_else(|| Error::Failed("oakcodec probe returned no streams".to_string()))?; + let streams = streams_from_description(&desc); + // File last-modified timestamp (ms since epoch; the C++ probe + // records it for the proxy/reprobe freshness check). Best effort: + // an unreadable stat never fails the probe. + let timestamp = std::fs::metadata(&self.filename) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + // Commit only on full success (no partial state on failure). self.decoder = desc.decoder().to_string(); - // Reading stream entries into `streams` is a follow-up (the - // stream-access surface is pinned when the codec module is - // finalized); the probe result itself is dropped here. + self.streams = streams; + self.timestamp = timestamp; self.valid = true; Ok(()) } @@ -187,6 +197,11 @@ impl FootageBehavior { .and_then(|s| s.audio) } + /// The stream at container order `index` (params plus duration). + pub fn stream_at(&self, index: usize) -> Option<&StreamInfo> { + self.streams.get(index) + } + /// Set all proxy fields at once (C++ `set_proxy`). pub fn set_proxy( &mut self, @@ -320,10 +335,13 @@ impl NodeBehavior for FootageBehavior { } /// Custom project load. C++ segments without a Rust counterpart - /// (`sourcestarttime`, `viewer` workarea/markers) are skipped. + /// (`sourcestarttime`, `viewer` workarea/markers) are skipped. The + /// filename falls back to the `file_in` input when the file carries + /// no `` element (the C++ convention; `` is a + /// Rust addition). fn load_custom( &mut self, - _core: &mut NodeCore, + core: &mut NodeCore, reader: &mut dyn crate::serializer::XmlRead, ) -> bool { while reader.next_start_element() { @@ -435,6 +453,12 @@ impl NodeBehavior for FootageBehavior { _ => reader.skip_current_element(), } } + if self.filename.is_empty() { + let value = core.standard_value("file_in", -1); + if let NodeValue::Text(text) = &value { + self.filename = text.clone(); + } + } true } @@ -446,3 +470,160 @@ impl NodeBehavior for FootageBehavior { Some(self) } } + +// --------------------------------------------------------------------------- +// Probe-result conversion +// --------------------------------------------------------------------------- + +/// The codec-side stream inventory as node-side [`StreamInfo`] rows. +/// +/// Video and audio streams are converted field-by-field (their durations +/// become rational seconds); subtitle streams have no [`StreamInfo`] +/// representation and are skipped (the ffmpeg probe counts but never adds +/// them anyway). The rows are sorted by container index, restoring the +/// probe order the per-kind ordinal loops group away. +fn streams_from_description( + desc: &oakcodec::footagedescription::FootageDescription, +) -> Vec { + let mut streams = Vec::new(); + for i in 0..desc.video_stream_count() { + let Some(vp) = desc.get_video_stream(i) else { + continue; + }; + let (num, den) = vp.frame_rate(); + streams.push(StreamInfo { + index: vp.stream_index(), + is_video: true, + video: Some(VideoParams { + width: vp.width(), + height: vp.height(), + frame_rate: oakcore_rs::Rational::new(num as i64, den as i64), + pixel_format: vp.format().code(), + channels: vp.channel_count(), + }), + audio: None, + duration: stream_duration_seconds(vp.duration(), vp.time_base()), + }); + } + for i in 0..desc.audio_stream_count() { + let Some(ap) = desc.get_audio_stream(i) else { + continue; + }; + streams.push(StreamInfo { + index: ap.stream_index, + is_video: false, + video: None, + audio: Some(AudioParams { + sample_rate: ap.sample_rate, + channel_layout: ap.channel_layout, + format: ap.format, + }), + duration: stream_duration_seconds(ap.duration, ap.time_base), + }); + } + streams.sort_by_key(|s| s.index); + streams +} + +/// A stream duration in timebase ticks as rational seconds. `0/1` when +/// the duration or the timebase is unusable (FFmpeg reports +/// `AV_NOPTS_VALUE` for streams without a duration). +fn stream_duration_seconds(duration: i64, time_base: (i32, i32)) -> oakcore_rs::Rational { + if duration <= 0 || time_base.0 <= 0 || time_base.1 <= 0 { + return oakcore_rs::Rational::new(0, 1); + } + oakcore_rs::Rational::new( + duration.saturating_mul(time_base.0 as i64), + time_base.1 as i64, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use oakcodec::footagedescription::{FootageDescription, StreamEntry}; + + fn video_entry(stream_index: i32, duration: i64) -> StreamEntry { + let mut vp = oakcommon::videoparams::VideoParams::new_basic( + 1920, + 1080, + oakcommon::ocioutils::PixelFormat::F32, + 4, + 1, + 1, + 0, + 1, + ); + vp.set_stream_index(stream_index); + vp.set_frame_rate(30000, 1001); + vp.set_time_base(1, 30000); + vp.set_duration(duration); + StreamEntry::Video(vp) + } + + fn audio_entry(stream_index: i32, duration: i64) -> StreamEntry { + StreamEntry::Audio(oakcodec::audioparams::AudioParams { + sample_rate: 48000, + channel_layout: 0x3, + format: 0, + stream_index, + duration, + time_base: (1, 48000), + }) + } + + #[test] + fn conversion_maps_fields_and_restores_probe_order() { + let mut desc = FootageDescription::new("ffmpeg"); + // Interleaved container order: audio first, then video. + desc.push_stream(audio_entry(0, 480_000)); + desc.push_stream(video_entry(1, 300_000)); + + let streams = streams_from_description(&desc); + assert_eq!(streams.len(), 2); + + // Sorted back into container order. + let audio = &streams[0]; + let video = &streams[1]; + assert!(!audio.is_video); + assert!(video.is_video); + + let a = audio.audio.expect("audio params"); + assert_eq!(a.sample_rate, 48000); + assert_eq!(a.channel_layout, 0x3); + // 480000 ticks at 1/48000 = 10 seconds. + assert_eq!(audio.duration, oakcore_rs::Rational::new(10, 1)); + + let v = video.video.expect("video params"); + assert_eq!(v.width, 1920); + assert_eq!(v.height, 1080); + assert_eq!(v.frame_rate, oakcore_rs::Rational::new(30000, 1001)); + assert_eq!(v.pixel_format, oakcommon::ocioutils::PixelFormat::F32.code()); + // 300000 ticks at 1/30000 = 10 seconds. + assert_eq!(video.duration, oakcore_rs::Rational::new(10, 1)); + } + + #[test] + fn conversion_skips_subtitles_and_unusable_durations() { + let mut desc = FootageDescription::new("ffmpeg"); + desc.push_stream(video_entry(0, i64::MIN)); // AV_NOPTS_VALUE + desc.push_stream(StreamEntry::Subtitle(oakcommon::subtitleparams::SubtitleParams::new())); + + let streams = streams_from_description(&desc); + assert_eq!(streams.len(), 1, "the subtitle stream is skipped"); + assert_eq!( + streams[0].duration, + oakcore_rs::Rational::new(0, 1), + "an unusable duration clamps to zero" + ); + } + + #[test] + fn failed_probe_keeps_prior_state() { + let mut f = FootageBehavior::new("/nonexistent/file.mov"); + assert!(f.probe().is_err()); + assert!(!f.valid); + assert!(f.streams.is_empty()); + assert_eq!(f.timestamp, 0); + } +}