// Oak Video Editor - Non-Linear Video Editor // Copyright (C) 2026 Oak Team // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . //! `olive::Decoder` and its supporting types — the media-decoder trait. //! //! Mirrors `src/codec/src/decoder.h`. The C++ abstract base plus its //! FFmpeg/OIIO subclasses become the [`Decoder`] trait (decision 2 in //! README.md); probe/dispatch lives on the registry functions at the //! bottom of this module. Audio is handled in raw interleaved-float //! buffers matching the C ABI, not `oakcore_rs::SampleBuffer` (which the //! crate does not export). use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; use oakcommon::cancelatom::CancelAtom; use oakcore_rs::{Rational, TimeRange}; use crate::footagedescription::FootageDescription; use crate::frame::Frame; /// `OakRenderTexture` — GPU texture token (an oakrender type, opaque to /// oakcodec). The codec crate cannot depend on oakrender (dependency /// cycle), so it never constructs a real texture: [`Decoder::retrieve_video`] /// only reports whether the decode succeeded and returns this unit token /// (the former empty `CHandle`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct OakRenderTexture; /// `OakNodeBlock` — opaque timeline-block token owned elsewhere; the codec /// only stores and forwards it (borrowed, never dereferenced). No module /// ever constructs one (the former `CHandle` was only ever `None`), so the /// type is an empty marker kept for API compatibility. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct OakNodeBlock; /// `oakcodec_video_stream_info` — POD probe output describing one video /// stream; see `include/codec/decoder.h`. #[repr(C)] pub struct OakCodecVideoStreamInfo { /// Stream index. pub stream_index: i32, /// Width in pixels. pub width: i32, /// Height in pixels. pub height: i32, /// Frame-rate numerator. pub frame_rate_num: i32, /// Frame-rate denominator. pub frame_rate_den: i32, /// Stream length in time-base units. pub duration_ts: i64, /// Time-base numerator (seconds per time-base unit). pub time_base_num: i32, /// Time-base denominator. pub time_base_den: i32, /// Native delivery `OakPixelFormat`. pub format: i32, /// Plane channel count. pub channel_count: i32, /// ISO/IEC 23001-8 color-primaries code point (0 = unknown). pub color_primaries: i32, /// ISO/IEC 23001-8 color-transfer code point (0 = unknown). pub color_trc: i32, /// 1 when the stream is interlaced. pub interlaced: i32, } /// `oakcodec_audio_stream_info` — POD probe output describing one audio /// stream; see `include/codec/decoder.h`. #[repr(C)] pub struct OakCodecAudioStreamInfo { /// Stream index. pub stream_index: i32, /// Sample rate (Hz). pub sample_rate: i32, /// ffmpeg-style channel mask (e.g. 0x3 = stereo). pub channel_layout: u64, /// Channel count. pub channel_count: i32, /// Stream length in time-base units. pub duration_ts: i64, /// Time-base numerator. pub time_base_num: i32, /// Time-base denominator. pub time_base_den: i32, } /// Local replacement for `render/rendermodes.h` (oakrender C API has no /// render-mode counterpart). Values mirror engine/render/rendermodes.h: /// k_offline = 0, k_online = 1. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(i32)] pub enum RenderMode { /// Offline / background render. Offline = 0, /// Online / real-time render. Online = 1, } /// "Don't force a color range" sentinel for /// [`RetrieveVideoParams::force_range`] (the actual ranges are the /// `OAKCOMMON_COLOR_RANGE_*` values). pub const K_COLOR_RANGE_DEFAULT: i32 = -1; /// `Decoder::RetrieveVideoParams` — what a video retrieve call needs. pub struct RetrieveVideoParams { /// Stream to read from. pub stream: CodecStream, /// Timestamp, rational seconds. pub time: Rational, /// Length of footage before the start (for early-seek semantics). pub length: TimeRange, /// Color range override; [`K_COLOR_RANGE_DEFAULT`] means "don't force". pub force_range: i32, /// Image sequence: bake the frame number into the filename. pub is_image_sequence: bool, /// Image sequence digit count (derived from the filename). pub image_sequence_digits: i32, /// Image sequence number to substitute. pub image_sequence_number: i64, /// Render mode (drives texture-path choices in the implementations). pub mode: RenderMode, /// Frame alpha channel is premultiplied. pub alpha_is_premultiplied: bool, } /// `Decoder::RetrieveAudioStatus` — outcome of an audio retrieve. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RetrieveAudioStatus { /// Data written to the destination buffer. Success, /// The requested range is outside the footage. InvalidRange, /// The stream does not support audio. Unsupported, /// Media requires a conform that could not be produced. ConformNeeded, /// A decoder-level error occurred. Error, } /// `Decoder::RetrieveState`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RetrieveState { /// Ready to decode. Ready, /// Failed to open the stream. FailedToOpen, /// The stream index could not be located. IndexUnavailable, } /// `Decoder::CodecStream` — identifies one (filename, stream) pair plus an /// optional associated timeline block. /// /// The block is an opaque [`OakNodeBlock`] token that codec only stores and /// compares, never dereferences or retains (borrowed). #[derive(Clone, Debug, PartialEq, Eq)] pub struct CodecStream { filename: String, stream: i32, block: Option, } impl CodecStream { /// Empty, invalid stream. pub fn new() -> Self { CodecStream { filename: String::new(), stream: -1, block: None, } } /// New stream for `(filename, stream)` with an optional block. pub fn with_block( filename: String, stream: i32, block: Option, ) -> Self { CodecStream { filename, stream, block, } } /// Non-empty filename and non-negative stream index. pub fn is_valid(&self) -> bool { !self.filename.is_empty() && self.stream >= 0 } /// The file exists on disk. pub fn exists(&self) -> bool { Path::new(&self.filename).exists() } /// Reset to the empty stream. pub fn reset(&mut self) { self.filename.clear(); self.stream = -1; self.block = None; } /// Source filename. pub fn filename(&self) -> &str { &self.filename } /// Stream index within the source. pub fn stream(&self) -> i32 { self.stream } /// Associated timeline block (borrowed; only compared, never used). pub fn block(&self) -> Option { self.block.clone() } } /// `olive::Decoder` — abstraction over external media decoding. /// /// Implementations are [`crate::ffmpeg::FFmpegDecoder`] and /// [`crate::oiio::OIIODecoder`]. The trait surface mirrors the C++ /// abstract base; the public API hands out `Arc` values. pub trait Decoder: Send + Sync { /// Unique decoder id ("ffmpeg"/"oiio"). fn id(&self) -> String; /// Whether this decoder supports video streams. fn supports_video(&self) -> bool { false } /// Whether this decoder supports audio streams. fn supports_audio(&self) -> bool { false } /// Whether this decoder can read the given file (static probe). fn probe( &self, filename: &str, cancelled: Option<&CancelAtom>, ) -> Option; /// Open `stream` for decoding. Thread-safe. fn open(&self, stream: &CodecStream) -> crate::error::Result<()>; /// Close the currently open stream (safe when closed). fn close(&self) -> crate::error::Result<()>; /// The currently open stream (locked accessor). fn stream(&self) -> CodecStream; /// Retrieve a video frame into CPU memory. fn retrieve_video_frame(&self, p: &RetrieveVideoParams) -> crate::error::Result>; /// Retrieve a video frame as a render texture (owned by caller). fn retrieve_video(&self, p: &RetrieveVideoParams) -> crate::error::Result; /// Retrieve interleaved audio covering `range` into `dest` (floats). fn retrieve_audio( &self, dest: &mut [f32], range: &TimeRange, sample_rate: i32, channel_layout: u64, ) -> crate::error::Result; /// Conform the open stream's audio into per-channel pcm files. /// /// `sample_rate` / `channel_layout` / `sample_format` describe the /// target audio format (`sample_format` is a /// `olive::core::SampleFormat::Format` value). The C++ side builds its /// `core::AudioParams` from these three — mirroring the C ABI /// `oakcodec_decoder_conform_audio` argument list. fn conform_audio( &self, output_filenames: &[String], sample_rate: i32, channel_layout: u64, sample_format: i32, cancelled: Option<&CancelAtom>, ) -> crate::error::Result<()>; /// Offset of the audio start relative to the video (rational seconds). fn get_audio_start_offset(&self) -> Rational { // C++ default `virtual Rational get_audio_start_offset() const { return 0; }` Rational::new(0, 1) } } /// Placeholder decoder used by the built-in probe registry. /// /// Reports the correct id and capability flags so id-based dispatch /// (`create_from_id`) works, but every media operation is unimplemented /// and returns `None` / an error. Used for the OIIO entry, whose Rust /// implementation (`crate::oiio::OIIODecoder`) is still a dylib stub; the /// FFmpeg entry is the real [`crate::ffmpeg::FFmpegDecoder`]. struct UnimplementedDecoder { id: &'static str, video: bool, audio: bool, } impl UnimplementedDecoder { fn new(id: &'static str, video: bool, audio: bool) -> Self { UnimplementedDecoder { id, video, audio } } } impl Decoder for UnimplementedDecoder { fn id(&self) -> String { self.id.to_string() } fn supports_video(&self) -> bool { self.video } fn supports_audio(&self) -> bool { self.audio } fn probe( &self, _filename: &str, _cancelled: Option<&CancelAtom>, ) -> Option { None } fn open(&self, _stream: &CodecStream) -> crate::error::Result<()> { Err(crate::error::Error::Failed( "decoder not yet implemented".to_string(), )) } fn close(&self) -> crate::error::Result<()> { Err(crate::error::Error::Failed( "decoder not yet implemented".to_string(), )) } fn stream(&self) -> CodecStream { CodecStream::new() } fn retrieve_video_frame(&self, _p: &RetrieveVideoParams) -> crate::error::Result> { Err(crate::error::Error::Failed( "decoder not yet implemented".to_string(), )) } fn retrieve_video(&self, _p: &RetrieveVideoParams) -> crate::error::Result { Err(crate::error::Error::Failed( "decoder not yet implemented".to_string(), )) } fn retrieve_audio( &self, _dest: &mut [f32], _range: &TimeRange, _sample_rate: i32, _channel_layout: u64, ) -> crate::error::Result { Err(crate::error::Error::Failed( "decoder not yet implemented".to_string(), )) } fn conform_audio( &self, _output_filenames: &[String], _sample_rate: i32, _channel_layout: u64, _sample_format: i32, _cancelled: Option<&CancelAtom>, ) -> crate::error::Result<()> { Err(crate::error::Error::Failed( "decoder not yet implemented".to_string(), )) } } /// `Decoder::create_from_id` — instantiate a decoder by id, or `None`. pub fn create_from_id(id: &str) -> Option> { if id.is_empty() { return None; } receive_list_of_all_decoders() .into_iter() .find(|d| d.id() == id) } /// Test-injected decoder registry (see [`set_test_decoders`]); empty when /// not injected, in which case the built-in list below is used. static TEST_DECODERS: OnceLock>>> = OnceLock::new(); /// Serializes every test that reads the built-in decoder registry. Tests /// inject through [`set_test_decoders`] under [`crate::lock_tests`] (the /// shared test lock), so the registry assertions below take that same lock /// to never race with an injected list. #[cfg(test)] fn registry_guard() -> crate::TestLock { crate::lock_tests() } /// Replace the decoder registry with `list`; pass an empty list to restore /// the built-in decoders. /// /// Test/extension support (the C ABI has no way to register a decoder, so /// the contract tests drive the probe/dispatch paths through a fake /// decoder). Hidden from docs; never called by production code. #[doc(hidden)] pub fn set_test_decoders(list: Vec>) { let store = TEST_DECODERS.get_or_init(|| Mutex::new(Vec::new())); *store.lock().unwrap() = list; } /// `Decoder::receive_list_of_all_decoders` — all registered decoders. /// /// Order is probe priority, mirroring C++: OIIO (more specific) before /// FFmpeg (format-agnostic fallback). The OIIO entry is an /// [`UnimplementedDecoder`] stub (the OIIO engine is not ported); the /// FFmpeg entry is the real [`crate::ffmpeg::FFmpegDecoder`]. When tests /// injected a non-empty list via [`set_test_decoders`], that list takes /// precedence. pub fn receive_list_of_all_decoders() -> Vec> { if let Some(store) = TEST_DECODERS.get() { let injected = store.lock().unwrap(); if !injected.is_empty() { return injected.clone(); } } vec![ Arc::new(UnimplementedDecoder::new("oiio", false, false)), Arc::new(crate::ffmpeg::FFmpegDecoder::new()), ] } /// Image-sequence filename heuristics (static). /// /// Replace the trailing digit run of the filename stem with the /// zero-padded decimal representation of `number` (keeps the same digit /// count), mirroring `Decoder::transform_image_sequence_file_name`. pub fn transform_image_sequence_file_name(filename: &str, number: i64) -> String { let digit_count = get_image_sequence_digit_count(filename) as usize; let path = Path::new(filename); let file_name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or(filename); // QFileInfo::completeBaseName(): filename up to the first '.'. let original_basename = match file_name.find('.') { Some(dot) => &file_name[..dot], None => file_name, }; // New stem = original stem minus the trailing digit run, plus the // zero-padded number (`snprintf("%0*lld", digit_count, number)`). let cut = original_basename.len().saturating_sub(digit_count); let new_basename = format!( "{}{:0width$}", &original_basename[..cut], number, width = digit_count ); // Replace every occurrence of the original stem in the filename. let mut new_filename = file_name.to_string(); let mut pos = 0; while let Some(rel) = new_filename[pos..].find(original_basename) { let start = pos + rel; let end = start + original_basename.len(); new_filename.replace_range(start..end, &new_basename); pos = start + new_basename.len(); } match path.parent() { Some(parent) if !parent.as_os_str().is_empty() => Path::new(parent) .join(&new_filename) .to_string_lossy() .into_owned(), _ => new_filename, } } /// Number of trailing digits in the filename stem (0 = not a sequence). pub fn get_image_sequence_digit_count(filename: &str) -> i32 { let file_name = Path::new(filename) .file_name() .and_then(|n| n.to_str()) .unwrap_or(filename); // QFileInfo::completeBaseName(): filename up to the first '.'. let stem = match file_name.find('.') { Some(dot) => &file_name[..dot], None => file_name, }; let mut count: i32 = 0; for ch in stem.chars().rev() { if ch.is_ascii_digit() { count += 1; } else { break; } } count } /// Numeric value of the trailing digits (0 when there are none). /// /// Mirrors C++ `Decoder::get_image_sequence_index`, which slices the /// trailing digit run (`basename.substr(basename.size() - digit_count)`) and /// passes it to `strtoll`. Because that slice is empty when there are no /// trailing digits (digit_count == 0) and all-digits otherwise, the value is /// the parsed number, or `0` for a non-sequence. pub fn get_image_sequence_index(filename: &str) -> i64 { let digit_count = get_image_sequence_digit_count(filename) as usize; let file_name = Path::new(filename) .file_name() .and_then(|n| n.to_str()) .unwrap_or(filename); // QFileInfo::completeBaseName(): filename up to the first '.'. let stem = match file_name.find('.') { Some(dot) => &file_name[..dot], None => file_name, }; // Trailing digit run (empty when the stem has no trailing digits). let start = stem.len().saturating_sub(digit_count); let number_only = &stem[start..]; // `strtoll(..., base 10)`: the slice is empty-or-digits, so a plain // decimal parse with 0 on failure reproduces the C++ result. number_only.parse::().unwrap_or(0) } /// The `k_any_timecode` rational constant. /// /// C++ `const Rational Decoder::k_any_timecode = RATIONAL_MIN;`, which the /// i32 reduction cap normalizes to `-2147483647/1`. pub fn k_any_timecode() -> Rational { Rational::new(-2147483647, 1) } #[cfg(test)] mod tests { use super::*; #[test] fn codec_stream_new_is_invalid() { let s = CodecStream::new(); assert!(!s.is_valid()); assert!(s.filename().is_empty()); assert_eq!(s.stream(), -1); assert_eq!(s.block(), None); } #[test] fn codec_stream_with_block_is_valid() { let s = CodecStream::with_block("video.mov".to_string(), 1, None); assert!(s.is_valid()); assert_eq!(s.filename(), "video.mov"); assert_eq!(s.stream(), 1); // Negative stream index is invalid regardless of filename. let bad = CodecStream::with_block("video.mov".to_string(), -1, None); assert!(!bad.is_valid()); } #[test] fn codec_stream_reset_clears() { let mut s = CodecStream::with_block("video.mov".to_string(), 2, None); s.reset(); assert!(!s.is_valid()); assert!(s.filename().is_empty()); assert_eq!(s.stream(), -1); } #[test] fn digit_count_counts_trailing_digits() { assert_eq!(get_image_sequence_digit_count("frame_0001.png"), 4); assert_eq!(get_image_sequence_digit_count("frame.png"), 0); assert_eq!(get_image_sequence_digit_count("img000.jpg"), 3); // Digits before the final char are not trailing digits. assert_eq!(get_image_sequence_digit_count("a1b.png"), 0); } #[test] fn image_sequence_index_parses_number() { assert_eq!(get_image_sequence_index("frame_0001.png"), 1); assert_eq!(get_image_sequence_index("img012.jpg"), 12); assert_eq!(get_image_sequence_index("0009.png"), 9); // No trailing digits: the sliced run is empty, so the value is 0. assert_eq!(get_image_sequence_index("frame.png"), 0); assert_eq!(get_image_sequence_index("12abc.png"), 0); } #[test] fn transform_image_sequence_substitutes_number() { assert_eq!( transform_image_sequence_file_name("frame_0001.png", 5), "frame_0005.png" ); assert_eq!( transform_image_sequence_file_name("dir/img012.jpg", 7), "dir/img007.jpg" ); // No digit run: number appended with no padding (C++ behavior). assert_eq!( transform_image_sequence_file_name("frame.png", 3), "frame3.png" ); // All-digit stem: whole run is replaced. assert_eq!( transform_image_sequence_file_name("0001.png", 7), "0007.png" ); } #[test] fn k_any_timecode_is_rational_min() { let tc = k_any_timecode(); assert_eq!(tc.numerator(), -2147483647); assert_eq!(tc.denominator(), 1); } #[test] fn registry_lists_oiio_then_ffmpeg() { let _g = registry_guard(); let list = receive_list_of_all_decoders(); let ids: Vec = list.iter().map(|d| d.id()).collect(); // Probe priority: OIIO (specific) first, FFmpeg (fallback) last. assert_eq!(ids, vec!["oiio".to_string(), "ffmpeg".to_string()]); } #[test] fn create_from_id_matches_registry() { let _g = registry_guard(); assert!(create_from_id("ffmpeg").is_some()); assert!(create_from_id("oiio").is_some()); assert_eq!(create_from_id("ffmpeg").unwrap().id(), "ffmpeg"); assert_eq!(create_from_id("oiio").unwrap().id(), "oiio"); // Unknown and empty ids return None. assert!(create_from_id("nope").is_none()); assert!(create_from_id("").is_none()); } } #[cfg(test)] mod tests_unimplemented { use super::*; fn builtin(id: &str) -> Arc { create_from_id(id).unwrap() } #[test] fn ffmpeg_builtin_fails_on_missing_media_and_closes() { let _g = registry_guard(); let d = builtin("ffmpeg"); assert!(d.supports_video()); assert!(d.supports_audio()); // A nonexistent file cannot be probed or opened. assert!(d.probe("x.mp4", None).is_none()); let s = CodecStream::with_block("x.mp4".to_string(), 0, None); assert!(d.open(&s).is_err()); // C++ parity: a failed open leaves the decoder closed. assert_eq!(d.stream().filename(), ""); assert!(d.close().is_ok()); let p = RetrieveVideoParams { stream: CodecStream::new(), time: Rational::new(0, 1), length: TimeRange::default(), force_range: K_COLOR_RANGE_DEFAULT, is_image_sequence: false, image_sequence_digits: 0, image_sequence_number: 0, mode: RenderMode::Offline, alpha_is_premultiplied: false, }; assert!(d.retrieve_video_frame(&p).is_err()); assert!(d.retrieve_video(&p).is_err()); let mut dest = [0f32; 4]; assert!(d .retrieve_audio( &mut dest, &TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), 48000, 0x3 ) .is_err()); assert!(d .conform_audio(&["a.pcm".to_string()], 48000, 0x3, 10, None) .is_err()); // OIIO reports no media capabilities. let o = builtin("oiio"); assert!(!o.supports_video()); assert!(!o.supports_audio()); } #[test] fn get_audio_start_offset_defaults_to_zero() { let _g = registry_guard(); let d = builtin("ffmpeg"); let off = d.get_audio_start_offset(); assert_eq!(off.numerator(), 0); assert_eq!(off.denominator(), 1); } }