feat(probe): record and print the footage stream inventory

FootageBehavior now keeps the probed stream list (video/audio, per-stream
duration in rationals) instead of dropping it, and the probe CLI walks
that inventory to report real durations, frame rates and stream counts
rather than the previous zero placeholders.
This commit is contained in:
2026-08-18 12:41:55 +08:00
parent 61da70ecf8
commit b8a3beaee4
3 changed files with 245 additions and 58 deletions
+47 -44
View File
@@ -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:
//! <path>` 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: <path>` 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
}
}
+8 -5
View File
@@ -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}");
}
+190 -9
View File
@@ -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<VideoParams>,
/// Audio parameters (when not video).
pub audio: Option<AudioParams>,
/// 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 `<filename>` element (the C++ convention; `<filename>` 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<StreamInfo> {
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);
}
}