workspace: kebab-case crates, app under crates/oak-app, shared versions
CI / Build & test (Windows) (push) Failing after 7s
CI / Build & test (Windows) (push) Failing after 7s
All crates take the oak-* kebab-case naming (oak-audio, oak-codec, oak-common, oak-core, oak-ffmpeg-link, oak-node, oak-otio, oak-plugin, oak-render, oak-storage, oak-task, oak-timeline, oak-undo), with the lib identifiers rewritten (oakrender:: -> oak_render::, oakcore_rs:: -> oak_core::, ...) across all 226 referencing files. The GUI application moves from the workspace root into crates/oak-app/: src/, build.rs (paths fixed for the new location) and tests/ travel with it, the root Cargo.toml becomes workspace-only ([workspace] + workspace.package + profiles), and the app package inherits the workspace version. The screenshots example becomes a standalone crate examples/simple_player/ with its own Cargo.toml. Every crate now inherits the single workspace version (version.workspace = true), and the workflows' crate paths and the build docs follow the renames. Validated with a clean cargo check --workspace.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `olive::AudioParams` — the audio stream parameter value type.
|
||||
//!
|
||||
//! Single-lib unification (see `docs/zh/plans/riir/single-lib.md`): the
|
||||
//! codec crate used to create audio parameter sets through the oakcore C
|
||||
//! ABI (`oakcore_audioparams_*`, host-provided symbols) and store them
|
||||
//! behind refcounted handles. The parameters are plain values now — the
|
||||
//! FFmpeg probe fills them in directly and the footage description stores
|
||||
//! them by value.
|
||||
|
||||
/// `olive::AudioParams` — the audio stream description recorded at probe
|
||||
/// time.
|
||||
///
|
||||
/// Mirrors `core/include/olive/core/oakcore/audioparams.h`: sample rate,
|
||||
/// ffmpeg channel-layout mask, sample format, stream index, duration and
|
||||
/// time base.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AudioParams {
|
||||
/// Sample rate in Hz.
|
||||
pub sample_rate: i32,
|
||||
/// ffmpeg-style channel layout mask (e.g. 0x3 = stereo).
|
||||
pub channel_layout: u64,
|
||||
/// `olive::core::SampleFormat::Format` value.
|
||||
pub format: i32,
|
||||
/// Stream index within the source container.
|
||||
pub stream_index: i32,
|
||||
/// Stream length in time-base units.
|
||||
pub duration: i64,
|
||||
/// Time base (num/den seconds per tick).
|
||||
pub time_base: (i32, i32),
|
||||
}
|
||||
|
||||
impl AudioParams {
|
||||
/// Channel count derived from the layout mask, mirroring the C++
|
||||
/// `AudioParams::channel_count()`.
|
||||
pub fn channel_count(&self) -> i32 {
|
||||
self.channel_layout.count_ones() as i32
|
||||
}
|
||||
|
||||
/// Whether the parameter set describes a usable stream, mirroring the
|
||||
/// C++ `AudioParams::is_valid()` (positive rate and a non-zero layout).
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.sample_rate > 0 && self.channel_layout != 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `olive::ConformManager` — pcm waveform cache files for fast scrubbing.
|
||||
//!
|
||||
//! Mirrors `src/codec/src/conformmanager.h`. Stateless (NOTES.md): actual
|
||||
//! conform work is delegated to the global task submit callback
|
||||
//! ([`crate::task`]); with no registrar the state queries report
|
||||
//! `Unavailable`. Deterministic per-channel filenames derive from the
|
||||
//! source + target audio params.
|
||||
|
||||
use oak_common::filefunctions::FileFunctions;
|
||||
use std::path::Path;
|
||||
|
||||
/// Conform state of one audio stream.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum ConformState {
|
||||
/// Conform files exist.
|
||||
Exists = 0,
|
||||
/// Conform is being generated.
|
||||
Generating = 1,
|
||||
/// No task registrar; conform unavailable.
|
||||
Unavailable = 2,
|
||||
}
|
||||
|
||||
/// `olive::ConformManager` — stateless conform query/produce manager.
|
||||
pub struct ConformManager;
|
||||
|
||||
impl ConformManager {
|
||||
/// The process-wide ConformManager singleton.
|
||||
pub fn instance() -> &'static ConformManager {
|
||||
static INSTANCE: ConformManager = ConformManager;
|
||||
&INSTANCE
|
||||
}
|
||||
|
||||
/// Query (and when possible start) the conform of one audio stream.
|
||||
///
|
||||
/// `wait != 0` treats a post-submit miss as `Unavailable`; `wait == 0`
|
||||
/// reports it as `Generating`. Without a task registrar the result is
|
||||
/// always `Unavailable`.
|
||||
pub fn get_conform_state(
|
||||
&self,
|
||||
cache_path: &str,
|
||||
source_filename: &str,
|
||||
stream_index: i32,
|
||||
sample_rate: i32,
|
||||
channel_layout: u64,
|
||||
sample_format: i32,
|
||||
wait: bool,
|
||||
) -> crate::error::Result<ConformState> {
|
||||
let filenames = conform_filenames(
|
||||
cache_path,
|
||||
source_filename,
|
||||
stream_index,
|
||||
sample_rate,
|
||||
sample_format,
|
||||
channel_layout,
|
||||
);
|
||||
|
||||
// Return existing conform if it exists.
|
||||
if all_conforms_exist(&filenames) {
|
||||
return Ok(ConformState::Exists);
|
||||
}
|
||||
|
||||
// Interim state (pre-M8): no task system, conform cannot be generated.
|
||||
if !crate::task::task_submit_is_registered() {
|
||||
return Ok(ConformState::Unavailable);
|
||||
}
|
||||
|
||||
// The task owns the ".working" temporary names and the rename to the
|
||||
// final per-channel filenames on success; output_filename carries the
|
||||
// first channel's final path and the task derives the siblings.
|
||||
let req = crate::task::TaskRequest {
|
||||
kind: crate::task::TaskKind::Conform,
|
||||
input_filename: source_filename,
|
||||
output_filename: filenames.first().map(String::as_str).unwrap_or(""),
|
||||
stream_index,
|
||||
sample_rate,
|
||||
channel_layout,
|
||||
sample_format,
|
||||
proxy_width: 0,
|
||||
proxy_height: 0,
|
||||
};
|
||||
|
||||
// Interim simplification: submission is synchronous — we always wait
|
||||
// for the submit to return, regardless of `wait`.
|
||||
if crate::task::submit_task(&req).is_err() {
|
||||
return Ok(ConformState::Unavailable);
|
||||
}
|
||||
|
||||
if all_conforms_exist(&filenames) {
|
||||
return Ok(ConformState::Exists);
|
||||
}
|
||||
|
||||
if wait {
|
||||
// Synchronous wait already happened and the conform still does not
|
||||
// exist: report the wait as failed.
|
||||
return Ok(ConformState::Unavailable);
|
||||
}
|
||||
|
||||
Ok(ConformState::Generating)
|
||||
}
|
||||
|
||||
/// Number of conform (pcm) files for the given stream/params — one per
|
||||
/// channel; 0 on invalid arguments.
|
||||
pub fn get_conform_filename_count(
|
||||
&self,
|
||||
_cache_path: &str,
|
||||
_source_filename: &str,
|
||||
_stream_index: i32,
|
||||
_sample_rate: i32,
|
||||
channel_layout: u64,
|
||||
_sample_format: i32,
|
||||
) -> usize {
|
||||
channel_layout.count_ones() as usize
|
||||
}
|
||||
|
||||
/// The `index`-th conform filename.
|
||||
pub fn get_conform_filename(
|
||||
&self,
|
||||
cache_path: &str,
|
||||
source_filename: &str,
|
||||
stream_index: i32,
|
||||
sample_rate: i32,
|
||||
channel_layout: u64,
|
||||
sample_format: i32,
|
||||
index: usize,
|
||||
) -> crate::error::Result<String> {
|
||||
let filenames = conform_filenames(
|
||||
cache_path,
|
||||
source_filename,
|
||||
stream_index,
|
||||
sample_rate,
|
||||
sample_format,
|
||||
channel_layout,
|
||||
);
|
||||
filenames
|
||||
.get(index)
|
||||
.cloned()
|
||||
.ok_or(crate::error::Error::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic conform base name plus per-channel pcm filenames, mirroring
|
||||
/// `ConformManager::get_conformed_filename`: one file per channel under
|
||||
/// `cache_path`, named `<identifier>-<stream>.<rate>.<format>.<layout>.<i>.pcm`.
|
||||
fn conform_filenames(
|
||||
cache_path: &str,
|
||||
source_filename: &str,
|
||||
stream_index: i32,
|
||||
sample_rate: i32,
|
||||
sample_format: i32,
|
||||
channel_layout: u64,
|
||||
) -> Vec<String> {
|
||||
let count = channel_layout.count_ones() as usize;
|
||||
let base = format!(
|
||||
"{}-{}.{}.{}.{}",
|
||||
unique_file_identifier(source_filename),
|
||||
stream_index,
|
||||
sample_rate,
|
||||
sample_format,
|
||||
channel_layout,
|
||||
);
|
||||
|
||||
let mut out = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let p = Path::new(cache_path).join(format!("{}.{}.pcm", base, i));
|
||||
out.push(p.to_string_lossy().into_owned());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `oakcommon_filefunctions_get_unique_file_identifier` wrapper.
|
||||
fn unique_file_identifier(filename: &str) -> String {
|
||||
FileFunctions::new()
|
||||
.get_unique_file_identifier(filename)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// True when every conform filename already exists on disk.
|
||||
fn all_conforms_exist(filenames: &[String]) -> bool {
|
||||
filenames.iter().all(|f| Path::new(f).exists())
|
||||
}
|
||||
|
||||
/// Shared test support: serializes access to the global task-submit registry
|
||||
/// (unit tests run in parallel and would otherwise clear each other's
|
||||
/// registration) and provides a callback that accepts any task.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_util {
|
||||
use crate::error::OAKCODEC_OK;
|
||||
use crate::task::OakCodecTaskRequest;
|
||||
|
||||
/// Serializes every test that mutates the task-submit registry.
|
||||
pub static REG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// A task-submit callback that accepts every request (no-op).
|
||||
pub unsafe extern "C" fn accept_cb(
|
||||
_req: *const OakCodecTaskRequest,
|
||||
_ud: *mut std::ffi::c_void,
|
||||
) -> i32 {
|
||||
OAKCODEC_OK
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_subdir(name: &str) -> String {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("oakcodec_conform_{}_{}", name, std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_identifier_uses_file_metadata() {
|
||||
// The identifier is the 16-digit FNV-1a hex of (absolute path +
|
||||
// mtime); a missing file has no identifier.
|
||||
assert_eq!(unique_file_identifier("no-such-file.mp4"), "");
|
||||
let dir = temp_subdir("id");
|
||||
let file = std::path::Path::new(&dir).join("media.mp4");
|
||||
std::fs::write(&file, b"x").unwrap();
|
||||
let id = unique_file_identifier(&file.to_string_lossy());
|
||||
assert_eq!(id.len(), 16);
|
||||
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
// Deterministic: same input, same id.
|
||||
assert_eq!(id, unique_file_identifier(&file.to_string_lossy()));
|
||||
let other = std::path::Path::new(&dir).join("other.mp4");
|
||||
std::fs::write(&other, b"y").unwrap();
|
||||
// Different input, different id.
|
||||
assert_ne!(id, unique_file_identifier(&other.to_string_lossy()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_count_from_channel_layout() {
|
||||
let m = ConformManager::instance();
|
||||
assert_eq!(m.get_conform_filename_count("c", "s", 0, 48000, 0x3, 0), 2); // stereo
|
||||
assert_eq!(m.get_conform_filename_count("c", "s", 0, 48000, 0x4, 0), 1); // mono
|
||||
assert_eq!(m.get_conform_filename_count("c", "s", 0, 48000, 0, 0), 0); // invalid
|
||||
assert_eq!(
|
||||
m.get_conform_filename_count("c", "s", 0, 48000, 0x60F, 0),
|
||||
6
|
||||
); // 5.1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conform_filename_derivation_and_range() {
|
||||
let m = ConformManager::instance();
|
||||
let src_dir = temp_subdir("names");
|
||||
let src = std::path::Path::new(&src_dir).join("media.mp4");
|
||||
std::fs::write(&src, b"x").unwrap();
|
||||
let cache = temp_subdir("names");
|
||||
let id = unique_file_identifier(&src.to_string_lossy());
|
||||
let base = format!("{}-0.48000.0.3", id);
|
||||
let f0 = m
|
||||
.get_conform_filename(&cache, &src.to_string_lossy(), 0, 48000, 0x3, 0, 0)
|
||||
.unwrap();
|
||||
let f1 = m
|
||||
.get_conform_filename(&cache, &src.to_string_lossy(), 0, 48000, 0x3, 0, 1)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
f0,
|
||||
std::path::Path::new(&cache)
|
||||
.join(format!("{base}.0.pcm"))
|
||||
.to_string_lossy()
|
||||
);
|
||||
assert_eq!(
|
||||
f1,
|
||||
std::path::Path::new(&cache)
|
||||
.join(format!("{base}.1.pcm"))
|
||||
.to_string_lossy()
|
||||
);
|
||||
// Out of range.
|
||||
assert!(matches!(
|
||||
m.get_conform_filename(&cache, &src.to_string_lossy(), 0, 48000, 0x3, 0, 5),
|
||||
Err(crate::error::Error::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_conform_state_unavailable_without_registrar() {
|
||||
let _g = super::test_util::REG_LOCK.lock().unwrap();
|
||||
// Ensure no registrar is left over.
|
||||
crate::task::set_task_submit_cb_extern(None, std::ptr::null_mut());
|
||||
let cache = temp_subdir("unavail");
|
||||
let s = ConformManager::instance()
|
||||
.get_conform_state(&cache, "missing.mp4", 0, 48000, 0x3, 0, false)
|
||||
.unwrap();
|
||||
assert_eq!(s, ConformState::Unavailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_conform_state_exists_when_files_present() {
|
||||
let cache = temp_subdir("exists");
|
||||
let m = ConformManager::instance();
|
||||
for i in 0..2 {
|
||||
let f = m
|
||||
.get_conform_filename(&cache, "media.mp4", 0, 48000, 0x3, 0, i)
|
||||
.unwrap();
|
||||
std::fs::write(&f, b"pcm").unwrap();
|
||||
}
|
||||
let s = m
|
||||
.get_conform_state(&cache, "media.mp4", 0, 48000, 0x3, 0, false)
|
||||
.unwrap();
|
||||
assert_eq!(s, ConformState::Exists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_conform_state_generating_when_registered() {
|
||||
let _g = super::test_util::REG_LOCK.lock().unwrap();
|
||||
crate::task::set_task_submit_cb_extern(
|
||||
Some(super::test_util::accept_cb),
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
let cache = temp_subdir("generating");
|
||||
let s = ConformManager::instance()
|
||||
.get_conform_state(&cache, "missing.mp4", 0, 48000, 0x3, 0, false)
|
||||
.unwrap();
|
||||
crate::task::set_task_submit_cb_extern(None, std::ptr::null_mut());
|
||||
assert_eq!(s, ConformState::Generating);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `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 `oak_core::SampleBuffer` (which the
|
||||
//! crate does not export).
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use oak_common::cancelatom::CancelAtom;
|
||||
use oak_core::{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<OakNodeBlock>,
|
||||
}
|
||||
|
||||
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<OakNodeBlock>,
|
||||
) -> 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<OakNodeBlock> {
|
||||
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<dyn Decoder>` 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<FootageDescription>;
|
||||
|
||||
/// 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<Arc<Frame>>;
|
||||
|
||||
/// Retrieve a video frame as a render texture (owned by caller).
|
||||
fn retrieve_video(&self, p: &RetrieveVideoParams) -> crate::error::Result<OakRenderTexture>;
|
||||
|
||||
/// 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<RetrieveAudioStatus>;
|
||||
|
||||
/// 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<FootageDescription> {
|
||||
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<Arc<Frame>> {
|
||||
Err(crate::error::Error::Failed(
|
||||
"decoder not yet implemented".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn retrieve_video(&self, _p: &RetrieveVideoParams) -> crate::error::Result<OakRenderTexture> {
|
||||
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<RetrieveAudioStatus> {
|
||||
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<Arc<dyn Decoder>> {
|
||||
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<Mutex<Vec<Arc<dyn Decoder>>>> = 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<Arc<dyn Decoder>>) {
|
||||
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<Arc<dyn Decoder>> {
|
||||
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::<i64>().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),
|
||||
// Path::join separators are platform-native (\ on Windows).
|
||||
std::path::Path::new("dir")
|
||||
.join("img007.jpg")
|
||||
.to_string_lossy()
|
||||
);
|
||||
// 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<String> = 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<dyn Decoder> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `olive::Encoder` — abstract base for media encoders.
|
||||
//!
|
||||
//! Mirrors `src/codec/src/encoder.h`. Implementations are
|
||||
//! [`crate::ffmpeg::FFmpegEncoder`] and [`crate::oiio::OIIOEncoder`]. The
|
||||
//! workflow (C ABI encoder.h) is: fill an `EncodingParams` → init → open →
|
||||
//! write_video/audio/subtitle → flush. The trait mirrors the C++ virtual
|
||||
//! surface.
|
||||
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use oak_core::{PixelFormat, SampleFormat};
|
||||
|
||||
use crate::encodingparams::EncodingParams;
|
||||
use crate::frame::Frame;
|
||||
|
||||
/// `olive::Encoder` — encoder trait. Instances are handed out as
|
||||
/// `Arc<dyn Encoder>`.
|
||||
pub trait Encoder: Send + Sync {
|
||||
/// Unique encoder id.
|
||||
fn id(&self) -> String;
|
||||
|
||||
/// Whether this encoder writes video.
|
||||
fn supports_video(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether this encoder writes audio.
|
||||
fn supports_audio(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether this encoder writes subtitles.
|
||||
fn supports_subtitles(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether this encoder writes an image sequence.
|
||||
fn supports_image_sequences(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether this encoder is deterministic for a given config
|
||||
/// (used for cache keys).
|
||||
fn is_configurable(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Configure the encoder (per-codec options like `crf`).
|
||||
fn configure(&self, params: &EncodingParams) -> crate::error::Result<()>;
|
||||
|
||||
/// Open the output file and write headers.
|
||||
fn open(&self) -> crate::error::Result<()>;
|
||||
|
||||
/// Close the output (write trailer); idempotent.
|
||||
fn close(&self) -> crate::error::Result<()>;
|
||||
|
||||
/// Encode one video frame (converts delivery pixel format internally).
|
||||
fn write_video(&self, frame: &Frame) -> crate::error::Result<()>;
|
||||
|
||||
/// Encode interleaved float audio samples.
|
||||
fn write_audio(&self, samples: &[f32], frame_count: i32) -> crate::error::Result<()>;
|
||||
|
||||
/// Encode one subtitle entry (times in seconds).
|
||||
fn write_subtitle(
|
||||
&self,
|
||||
text: &str,
|
||||
in_seconds: f64,
|
||||
out_seconds: f64,
|
||||
) -> crate::error::Result<()>;
|
||||
|
||||
/// Flush encoders, write the trailer, close the file.
|
||||
fn flush(&self) -> crate::error::Result<()>;
|
||||
|
||||
/// The pixel format the encoder wants frames in (or `None`).
|
||||
fn desired_pixel_format(&self) -> Option<PixelFormat>;
|
||||
|
||||
/// The sample format the encoder wants audio in (or `None`).
|
||||
fn desired_sample_format(&self) -> Option<SampleFormat>;
|
||||
|
||||
/// The configured output filename.
|
||||
fn filename(&self) -> String;
|
||||
|
||||
/// Human-readable detail of the last failed operation (empty when
|
||||
/// none). Mirrors the C++ `Encoder::get_error()` used by
|
||||
/// `oakcodec_encoder_last_error`.
|
||||
fn get_error(&self) -> String {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-injected encoder registry (see [`set_test_encoders`]); empty when
|
||||
/// not injected, in which case [`create_from_params`] falls back to the
|
||||
/// built-in format mapping.
|
||||
static TEST_ENCODERS: OnceLock<Mutex<Vec<Arc<dyn Encoder>>>> = OnceLock::new();
|
||||
|
||||
/// Replace the encoder registry with `list`; pass an empty list to restore
|
||||
/// the built-in behavior.
|
||||
///
|
||||
/// Test/extension support (the C ABI has no way to register an encoder, so
|
||||
/// the contract tests drive the encode state machine through a fake
|
||||
/// encoder). Hidden from docs; never called by production code.
|
||||
#[doc(hidden)]
|
||||
pub fn set_test_encoders(list: Vec<Arc<dyn Encoder>>) {
|
||||
let store = TEST_ENCODERS.get_or_init(|| Mutex::new(Vec::new()));
|
||||
*store.lock().unwrap() = list;
|
||||
}
|
||||
|
||||
/// `Encoder::create_from_params` — instantiate an encoder for `params`.
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
/// `src/codec/src/encoder.cpp` `create_from_params` → `create_from_format`
|
||||
/// picks the FFmpeg/OIIO implementation from `params.format` (DNxHD,
|
||||
/// Matroska, QuickTime, MPEG-4 video/audio, WAV, AIFF, MP3, FLAC, Ogg,
|
||||
/// WebM, SRT → FFmpeg; OpenEXR, PNG, TIFF → OIIO; anything else → `None`).
|
||||
/// A non-empty test-injected list (see [`set_test_encoders`]) wins over the
|
||||
/// built-in mapping. The concrete implementations are dylib stubs whose
|
||||
/// `open()` fails with a clear message, so an initialized encoder is always
|
||||
/// constructible for a recognized format.
|
||||
pub fn create_from_params(params: &EncodingParams) -> Option<Arc<dyn Encoder>> {
|
||||
if let Some(store) = TEST_ENCODERS.get() {
|
||||
let injected = store.lock().unwrap();
|
||||
if !injected.is_empty() {
|
||||
return injected.first().cloned();
|
||||
}
|
||||
}
|
||||
match encoder_type_from_format(params.format) {
|
||||
Some(EncoderType::FFmpeg) => Some(Arc::new(crate::ffmpeg::FFmpegEncoder::with_params(
|
||||
params.clone(),
|
||||
))),
|
||||
Some(EncoderType::OIIO) => Some(Arc::new(crate::oiio::OIIOEncoder {
|
||||
params: params.clone(),
|
||||
})),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Encoder::Type` mirror (`encoder.cpp` `get_type_from_format`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum EncoderType {
|
||||
/// `k_encoder_type_f_fmpeg`.
|
||||
FFmpeg,
|
||||
/// `k_encoder_type_oiio`.
|
||||
OIIO,
|
||||
}
|
||||
|
||||
/// `Encoder::get_type_from_format` — the implementation family for an
|
||||
/// `ExportFormat::Format` int; `None` for unknown/`Count`.
|
||||
fn encoder_type_from_format(format: i32) -> Option<EncoderType> {
|
||||
match format {
|
||||
// FFmpeg-backed containers.
|
||||
0 | 1 | 2 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 => Some(EncoderType::FFmpeg),
|
||||
// OIIO-backed still-image formats.
|
||||
3 | 5 | 6 => Some(EncoderType::OIIO),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Range `(start, end)` of a "[#####]" digit placeholder beginning at
|
||||
/// `pos` (`bytes[pos] == '['`), or `None`.
|
||||
///
|
||||
/// CPP-PARITY: `encoder.cpp` `k_image_sequence_contains_digits` regex
|
||||
/// `\[[#]+\]` — a `[`, one or more `#`, then `]`.
|
||||
fn placeholder_range(bytes: &[u8], pos: usize) -> Option<(usize, usize)> {
|
||||
if bytes.get(pos) != Some(&b'[') {
|
||||
return None;
|
||||
}
|
||||
let mut j = pos + 1;
|
||||
while bytes.get(j) == Some(&b'#') {
|
||||
j += 1;
|
||||
}
|
||||
if j > pos + 1 && bytes.get(j) == Some(&b']') {
|
||||
Some((pos, j + 1))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `Encoder::filename_contains_digit_placeholder` — whether `filename`
|
||||
/// contains a "[#####]" digit placeholder.
|
||||
///
|
||||
/// CPP-PARITY: `encoder.cpp:137` (`std::regex_search` on
|
||||
/// `k_image_sequence_contains_digits`).
|
||||
pub fn filename_contains_digit_placeholder(filename: &str) -> bool {
|
||||
let bytes = filename.as_bytes();
|
||||
(0..bytes.len()).any(|i| placeholder_range(bytes, i).is_some())
|
||||
}
|
||||
|
||||
/// `Encoder::get_image_sequence_placeholder_digit_count` — number of `#` in
|
||||
/// the filename's "[#####]" placeholder; 0 when none.
|
||||
///
|
||||
/// CPP-PARITY: `encoder.cpp:119` — the C++ finds the first
|
||||
/// `k_image_sequence_contains_digits` match and counts its `#`s, which is
|
||||
/// exactly the match length minus the two brackets.
|
||||
pub fn image_sequence_placeholder_digit_count(filename: &str) -> i32 {
|
||||
let bytes = filename.as_bytes();
|
||||
for i in 0..bytes.len() {
|
||||
if let Some((start, end)) = placeholder_range(bytes, i) {
|
||||
return (end - start - 2) as i32;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// `Encoder::filename_remove_digit_placeholder` — `filename` with every
|
||||
/// "[#####]" placeholder removed; an optional single separator char
|
||||
/// (`-`, `.`, ` `, `_`) immediately before the placeholder goes with it.
|
||||
///
|
||||
/// CPP-PARITY: `encoder.cpp:142` (`std::regex_replace` on
|
||||
/// `k_image_sequence_remove_digits` = `[\-\.\ \_]?\[[#]+\]`, empty
|
||||
/// replacement, all matches).
|
||||
pub fn filename_remove_digit_placeholder(filename: &str) -> String {
|
||||
let bytes = filename.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
// A separator is consumed only when a placeholder follows it.
|
||||
let ph_start = match bytes[i] {
|
||||
b'-' | b'.' | b' ' | b'_' if placeholder_range(bytes, i + 1).is_some() => i + 1,
|
||||
_ => i,
|
||||
};
|
||||
match placeholder_range(bytes, ph_start) {
|
||||
Some((_, end)) => i = end,
|
||||
None => {
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_else(|_| filename.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn create_from_params_maps_formats() {
|
||||
let mut p = EncodingParams::default();
|
||||
|
||||
// FFmpeg-backed containers.
|
||||
for fmt in [0, 1, 2, 4, 7, 8, 9, 10, 11, 12, 13, 14] {
|
||||
p.format = fmt;
|
||||
let e = create_from_params(&p).expect("format {fmt}");
|
||||
assert_eq!(e.id(), "ffmpeg", "format {fmt}");
|
||||
}
|
||||
// OIIO-backed still images.
|
||||
for fmt in [3, 5, 6] {
|
||||
p.format = fmt;
|
||||
let e = create_from_params(&p).expect("format {fmt}");
|
||||
assert_eq!(e.id(), "oiio", "format {fmt}");
|
||||
}
|
||||
// Unknown / Count -> None (C++ `k_encoder_type_none`).
|
||||
p.format = 15;
|
||||
assert!(create_from_params(&p).is_none());
|
||||
p.format = -1;
|
||||
assert!(create_from_params(&p).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_error_defaults_to_empty() {
|
||||
let e = UnimplementedDummy;
|
||||
assert_eq!(e.get_error(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_sequence_placeholder_helpers() {
|
||||
// contains: "[#####]" style placeholder only.
|
||||
assert!(filename_contains_digit_placeholder("/tmp/out_[#####].png"));
|
||||
assert!(filename_contains_digit_placeholder("out[#].png"));
|
||||
assert!(!filename_contains_digit_placeholder("/tmp/out.png"));
|
||||
assert!(!filename_contains_digit_placeholder("out[####.png"));
|
||||
assert!(!filename_contains_digit_placeholder("out[].png"));
|
||||
|
||||
// digit count: number of '#' in the first placeholder.
|
||||
assert_eq!(
|
||||
image_sequence_placeholder_digit_count("/tmp/out_[#####].png"),
|
||||
5
|
||||
);
|
||||
assert_eq!(image_sequence_placeholder_digit_count("out[#].png"), 1);
|
||||
assert_eq!(image_sequence_placeholder_digit_count("a[##]b[####]c"), 2);
|
||||
assert_eq!(image_sequence_placeholder_digit_count("/tmp/out.png"), 0);
|
||||
|
||||
// remove: separator char before the placeholder goes with it.
|
||||
assert_eq!(
|
||||
filename_remove_digit_placeholder("/tmp/out_[#####].png"),
|
||||
"/tmp/out.png"
|
||||
);
|
||||
assert_eq!(filename_remove_digit_placeholder("out[###].png"), "out.png");
|
||||
assert_eq!(filename_remove_digit_placeholder("a_[#]b_[###]c"), "abc");
|
||||
assert_eq!(
|
||||
filename_remove_digit_placeholder("/tmp/out.png"),
|
||||
"/tmp/out.png"
|
||||
);
|
||||
}
|
||||
|
||||
struct UnimplementedDummy;
|
||||
|
||||
impl Encoder for UnimplementedDummy {
|
||||
fn id(&self) -> String {
|
||||
"dummy".to_string()
|
||||
}
|
||||
fn configure(&self, _p: &EncodingParams) -> crate::error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn open(&self) -> crate::error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn close(&self) -> crate::error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn write_video(&self, _f: &Frame) -> crate::error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn write_audio(&self, _s: &[f32], _c: i32) -> crate::error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn write_subtitle(&self, _t: &str, _i: f64, _o: f64) -> crate::error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn flush(&self) -> crate::error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn desired_pixel_format(&self) -> Option<PixelFormat> {
|
||||
None
|
||||
}
|
||||
fn desired_sample_format(&self) -> Option<SampleFormat> {
|
||||
None
|
||||
}
|
||||
fn filename(&self) -> String {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Error codes, mirroring `include/codec/error.h` verbatim; project-wide
|
||||
//! -MMCCCC scheme (module registry in include/common/error.h), pass-through untranslated.
|
||||
|
||||
/// Success.
|
||||
pub const OAKCODEC_OK: i32 = 0;
|
||||
/// Null handle or invalid argument.
|
||||
pub const OAKCODEC_E_INVALID: i32 = -50001;
|
||||
/// Call not valid in the current state.
|
||||
pub const OAKCODEC_E_STATE: i32 = -50002;
|
||||
/// The underlying operation failed.
|
||||
pub const OAKCODEC_E_FAILED: i32 = -50003;
|
||||
/// Index out of range / entry not found.
|
||||
pub const OAKCODEC_E_NOT_FOUND: i32 = -50004;
|
||||
/// Allocation failed.
|
||||
pub const OAKCODEC_E_NOMEM: i32 = -50005;
|
||||
/// The operation was cancelled.
|
||||
pub const OAKCODEC_E_CANCELLED: i32 = -50006;
|
||||
|
||||
/// The frozen C-ABI `abi_version` tag (`include/codec/decoder.h`); kept for
|
||||
/// parity with the handle structs, no longer stamped at runtime.
|
||||
pub const OAKCODEC_ABI_VERSION: u32 = 1;
|
||||
|
||||
/// Crate-internal result type; the FFI layer maps it to the codes.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Crate-internal error.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// Null handle or invalid argument.
|
||||
#[error("codec: invalid argument")]
|
||||
Invalid,
|
||||
/// Wrong state.
|
||||
#[error("codec: invalid state")]
|
||||
State,
|
||||
/// Operation failed (context string is log-only).
|
||||
#[error("codec: operation failed: {0}")]
|
||||
Failed(String),
|
||||
/// Not found.
|
||||
#[error("codec: not found")]
|
||||
NotFound,
|
||||
/// Out of memory.
|
||||
#[error("codec: out of memory")]
|
||||
NoMem,
|
||||
/// The operation was cancelled.
|
||||
#[error("codec: operation cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Map to the public error code.
|
||||
pub fn code(&self) -> i32 {
|
||||
match self {
|
||||
Error::Invalid => OAKCODEC_E_INVALID,
|
||||
Error::State => OAKCODEC_E_STATE,
|
||||
Error::Failed(_) => OAKCODEC_E_FAILED,
|
||||
Error::NotFound => OAKCODEC_E_NOT_FOUND,
|
||||
Error::NoMem => OAKCODEC_E_NOMEM,
|
||||
Error::Cancelled => OAKCODEC_E_CANCELLED,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every variant must produce a non-empty `Display` message; the
|
||||
/// `Failed` variant must surface its context string.
|
||||
#[test]
|
||||
fn display_is_non_empty() {
|
||||
for msg in [
|
||||
Error::Invalid.to_string(),
|
||||
Error::State.to_string(),
|
||||
Error::Failed("context".into()).to_string(),
|
||||
Error::NotFound.to_string(),
|
||||
Error::NoMem.to_string(),
|
||||
Error::Cancelled.to_string(),
|
||||
] {
|
||||
assert!(!msg.trim().is_empty(), "empty Display message");
|
||||
}
|
||||
assert!(
|
||||
Error::Failed("context".into())
|
||||
.to_string()
|
||||
.contains("context")
|
||||
);
|
||||
}
|
||||
|
||||
/// `Error` must be usable behind a trait object.
|
||||
#[test]
|
||||
fn error_is_object_safe() {
|
||||
let errs: Vec<Box<dyn std::error::Error>> = vec![
|
||||
Box::new(Error::Invalid),
|
||||
Box::new(Error::State),
|
||||
Box::new(Error::Failed("context".into())),
|
||||
Box::new(Error::NotFound),
|
||||
Box::new(Error::NoMem),
|
||||
Box::new(Error::Cancelled),
|
||||
];
|
||||
assert_eq!(errs.len(), 6);
|
||||
}
|
||||
|
||||
/// No variant wraps a downstream error, so `source()` stays `None`.
|
||||
#[test]
|
||||
fn source_is_none() {
|
||||
assert!(std::error::Error::source(&Error::Failed("context".into())).is_none());
|
||||
}
|
||||
|
||||
/// The `code()` mapping must be unchanged by the `Error` trait impl.
|
||||
#[test]
|
||||
fn code_mapping_unchanged() {
|
||||
assert_eq!(Error::Invalid.code(), OAKCODEC_E_INVALID);
|
||||
assert_eq!(Error::State.code(), OAKCODEC_E_STATE);
|
||||
assert_eq!(Error::Failed("context".into()).code(), OAKCODEC_E_FAILED);
|
||||
assert_eq!(Error::NotFound.code(), OAKCODEC_E_NOT_FOUND);
|
||||
assert_eq!(Error::NoMem.code(), OAKCODEC_E_NOMEM);
|
||||
assert_eq!(Error::Cancelled.code(), OAKCODEC_E_CANCELLED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `olive::ExportCodec` — the export codec enum and name table.
|
||||
//!
|
||||
//! Mirrors `src/codec/src/exportcodec.h`. The enum is the raw int contract
|
||||
//! the C ABI documents (oakengine/encoding.h); values must NOT be
|
||||
//! reordered (they are used in serialized files).
|
||||
|
||||
/// `olive::ExportCodec::Codec`. Only append (never insert/reorder); the
|
||||
/// integer values are part of the file format.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum Codec {
|
||||
/// Apple ProRes.
|
||||
DNxHD = 0,
|
||||
/// H.264.
|
||||
H264 = 1,
|
||||
/// H.264 with RGB color.
|
||||
H264RGB = 2,
|
||||
/// H.265/HEVC.
|
||||
H265 = 3,
|
||||
/// OpenEXR.
|
||||
OpenEXR = 4,
|
||||
/// PNG.
|
||||
PNG = 5,
|
||||
/// Apple ProRes.
|
||||
ProRes = 6,
|
||||
/// CineForm.
|
||||
CineForm = 7,
|
||||
/// TIFF.
|
||||
TIFF = 8,
|
||||
/// VP9.
|
||||
VP9 = 9,
|
||||
/// MPEG-2.
|
||||
MP2 = 10,
|
||||
/// MPEG-3.
|
||||
MP3 = 11,
|
||||
/// AAC.
|
||||
AAC = 12,
|
||||
/// Uncompressed PCM.
|
||||
PCM = 13,
|
||||
/// Opus.
|
||||
Opus = 14,
|
||||
/// Vorbis.
|
||||
Vorbis = 15,
|
||||
/// FLAC.
|
||||
FLAC = 16,
|
||||
/// SubRip subtitles.
|
||||
SRT = 17,
|
||||
/// AV1.
|
||||
AV1 = 18,
|
||||
/// Sentinel count (not a real codec).
|
||||
Count = 19,
|
||||
}
|
||||
|
||||
impl Codec {
|
||||
/// Convert an `i32` code to a [`Codec`]; `None` outside the valid
|
||||
/// `0..=18` range (incl. `Count`).
|
||||
pub fn from_i32(v: i32) -> Option<Codec> {
|
||||
match v {
|
||||
0 => Some(Codec::DNxHD),
|
||||
1 => Some(Codec::H264),
|
||||
2 => Some(Codec::H264RGB),
|
||||
3 => Some(Codec::H265),
|
||||
4 => Some(Codec::OpenEXR),
|
||||
5 => Some(Codec::PNG),
|
||||
6 => Some(Codec::ProRes),
|
||||
7 => Some(Codec::CineForm),
|
||||
8 => Some(Codec::TIFF),
|
||||
9 => Some(Codec::VP9),
|
||||
10 => Some(Codec::MP2),
|
||||
11 => Some(Codec::MP3),
|
||||
12 => Some(Codec::AAC),
|
||||
13 => Some(Codec::PCM),
|
||||
14 => Some(Codec::Opus),
|
||||
15 => Some(Codec::Vorbis),
|
||||
16 => Some(Codec::FLAC),
|
||||
17 => Some(Codec::SRT),
|
||||
18 => Some(Codec::AV1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable name for a codec.
|
||||
///
|
||||
/// CPP-PARITY: `src/codec/src/exportcodec.cpp` `get_codec_name`; unknown /
|
||||
/// `Count` returns "Unknown".
|
||||
pub fn get_codec_name(c: Codec) -> String {
|
||||
match c {
|
||||
Codec::DNxHD => "DNxHD".to_string(),
|
||||
Codec::H264 => "H.264".to_string(),
|
||||
Codec::H264RGB => "H.264 RGB".to_string(),
|
||||
Codec::H265 => "H.265".to_string(),
|
||||
Codec::OpenEXR => "OpenEXR".to_string(),
|
||||
Codec::PNG => "PNG".to_string(),
|
||||
Codec::ProRes => "ProRes".to_string(),
|
||||
Codec::CineForm => "Cineform".to_string(),
|
||||
Codec::TIFF => "TIFF".to_string(),
|
||||
Codec::VP9 => "VP9".to_string(),
|
||||
Codec::MP2 => "MP2".to_string(),
|
||||
Codec::MP3 => "MP3".to_string(),
|
||||
Codec::AAC => "AAC".to_string(),
|
||||
Codec::PCM => "PCM (Uncompressed)".to_string(),
|
||||
Codec::Opus => "Opus".to_string(),
|
||||
Codec::Vorbis => "Vorbis".to_string(),
|
||||
Codec::FLAC => "FLAC".to_string(),
|
||||
Codec::SRT => "SubRip SRT".to_string(),
|
||||
Codec::AV1 => "AV1".to_string(),
|
||||
Codec::Count => "Unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the codec produces a still image.
|
||||
///
|
||||
/// CPP-PARITY: `src/codec/src/exportcodec.cpp` `is_codec_a_still_image`;
|
||||
/// OpenEXR / PNG / TIFF are still images, everything else is not.
|
||||
pub fn is_codec_a_still_image(c: Codec) -> bool {
|
||||
match c {
|
||||
Codec::OpenEXR | Codec::PNG | Codec::TIFF => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the codec is lossless.
|
||||
///
|
||||
/// CPP-PARITY: `src/codec/src/exportcodec.cpp` `is_codec_lossless`;
|
||||
/// only PCM and FLAC are lossless.
|
||||
pub fn is_codec_lossless(c: Codec) -> bool {
|
||||
match c {
|
||||
Codec::PCM | Codec::FLAC => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_i32_maps_valid_and_rejects_others() {
|
||||
for v in 0..=18 {
|
||||
assert!(Codec::from_i32(v).is_some(), "codec {v}");
|
||||
}
|
||||
assert!(Codec::from_i32(19).is_none()); // Count
|
||||
assert!(Codec::from_i32(-1).is_none());
|
||||
assert!(Codec::from_i32(99).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_name_mapping() {
|
||||
assert_eq!(Codec::get_codec_name(Codec::DNxHD), "DNxHD");
|
||||
assert_eq!(Codec::get_codec_name(Codec::H264), "H.264");
|
||||
assert_eq!(Codec::get_codec_name(Codec::H264RGB), "H.264 RGB");
|
||||
assert_eq!(Codec::get_codec_name(Codec::H265), "H.265");
|
||||
assert_eq!(Codec::get_codec_name(Codec::OpenEXR), "OpenEXR");
|
||||
assert_eq!(Codec::get_codec_name(Codec::PNG), "PNG");
|
||||
assert_eq!(Codec::get_codec_name(Codec::ProRes), "ProRes");
|
||||
assert_eq!(Codec::get_codec_name(Codec::CineForm), "Cineform");
|
||||
assert_eq!(Codec::get_codec_name(Codec::TIFF), "TIFF");
|
||||
assert_eq!(Codec::get_codec_name(Codec::VP9), "VP9");
|
||||
assert_eq!(Codec::get_codec_name(Codec::MP2), "MP2");
|
||||
assert_eq!(Codec::get_codec_name(Codec::MP3), "MP3");
|
||||
assert_eq!(Codec::get_codec_name(Codec::AAC), "AAC");
|
||||
assert_eq!(Codec::get_codec_name(Codec::PCM), "PCM (Uncompressed)");
|
||||
assert_eq!(Codec::get_codec_name(Codec::Opus), "Opus");
|
||||
assert_eq!(Codec::get_codec_name(Codec::Vorbis), "Vorbis");
|
||||
assert_eq!(Codec::get_codec_name(Codec::FLAC), "FLAC");
|
||||
assert_eq!(Codec::get_codec_name(Codec::SRT), "SubRip SRT");
|
||||
assert_eq!(Codec::get_codec_name(Codec::AV1), "AV1");
|
||||
assert_eq!(Codec::get_codec_name(Codec::Count), "Unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn still_image_and_lossless_flags() {
|
||||
assert!(Codec::is_codec_a_still_image(Codec::OpenEXR));
|
||||
assert!(Codec::is_codec_a_still_image(Codec::PNG));
|
||||
assert!(Codec::is_codec_a_still_image(Codec::TIFF));
|
||||
assert!(!Codec::is_codec_a_still_image(Codec::H264));
|
||||
assert!(!Codec::is_codec_a_still_image(Codec::PCM));
|
||||
|
||||
assert!(Codec::is_codec_lossless(Codec::PCM));
|
||||
assert!(Codec::is_codec_lossless(Codec::FLAC));
|
||||
assert!(!Codec::is_codec_lossless(Codec::MP3));
|
||||
assert!(!Codec::is_codec_lossless(Codec::H264));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `olive::ExportFormat` — the export container-format enum and tables.
|
||||
//!
|
||||
//! Mirrors `src/codec/src/exportformat.h`. As with `ExportCodec`, the enum
|
||||
//! values are the serialized-file contract and must never be reordered.
|
||||
|
||||
use oak_core::SampleFormat;
|
||||
|
||||
use crate::exportcodec::Codec;
|
||||
|
||||
/// `olive::ExportFormat::Format`. Only append (never insert/reorder).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum Format {
|
||||
/// QuickTime DNxHD.
|
||||
DNxHD = 0,
|
||||
/// Matroska.
|
||||
Matroska = 1,
|
||||
/// MPEG-4 video.
|
||||
MPEG4Video = 2,
|
||||
/// OpenEXR.
|
||||
OpenEXR = 3,
|
||||
/// QuickTime.
|
||||
QuickTime = 4,
|
||||
/// PNG.
|
||||
PNG = 5,
|
||||
/// TIFF.
|
||||
TIFF = 6,
|
||||
/// WAV.
|
||||
WAV = 7,
|
||||
/// AIFF.
|
||||
AIFF = 8,
|
||||
/// MP3.
|
||||
MP3 = 9,
|
||||
/// FLAC.
|
||||
FLAC = 10,
|
||||
/// Ogg.
|
||||
Ogg = 11,
|
||||
/// WebM.
|
||||
WebM = 12,
|
||||
/// SubRip.
|
||||
SRT = 13,
|
||||
/// MPEG-4 audio.
|
||||
MPEG4Audio = 14,
|
||||
/// Sentinel count (not a real format).
|
||||
Count = 15,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// Convert an `i32` code to a [`Format`]; `None` outside the valid
|
||||
/// `0..=14` range (incl. `Count`).
|
||||
pub fn from_i32(v: i32) -> Option<Format> {
|
||||
match v {
|
||||
0 => Some(Format::DNxHD),
|
||||
1 => Some(Format::Matroska),
|
||||
2 => Some(Format::MPEG4Video),
|
||||
3 => Some(Format::OpenEXR),
|
||||
4 => Some(Format::QuickTime),
|
||||
5 => Some(Format::PNG),
|
||||
6 => Some(Format::TIFF),
|
||||
7 => Some(Format::WAV),
|
||||
8 => Some(Format::AIFF),
|
||||
9 => Some(Format::MP3),
|
||||
10 => Some(Format::FLAC),
|
||||
11 => Some(Format::Ogg),
|
||||
12 => Some(Format::WebM),
|
||||
13 => Some(Format::SRT),
|
||||
14 => Some(Format::MPEG4Audio),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable name for a format.
|
||||
///
|
||||
/// CPP-PARITY: `src/codec/src/exportformat.cpp` `get_name`; unknown /
|
||||
/// `Count` returns "Unknown".
|
||||
pub fn get_name(f: Format) -> String {
|
||||
match f {
|
||||
Format::DNxHD => "DNxHD".to_string(),
|
||||
Format::Matroska => "Matroska Video".to_string(),
|
||||
Format::MPEG4Video => "MPEG-4 Video".to_string(),
|
||||
Format::MPEG4Audio => "MPEG-4 Audio".to_string(),
|
||||
Format::OpenEXR => "OpenEXR".to_string(),
|
||||
Format::PNG => "PNG".to_string(),
|
||||
Format::TIFF => "TIFF".to_string(),
|
||||
Format::QuickTime => "QuickTime".to_string(),
|
||||
Format::WAV => "Wave Audio".to_string(),
|
||||
Format::AIFF => "AIFF".to_string(),
|
||||
Format::MP3 => "MP3".to_string(),
|
||||
Format::FLAC => "FLAC".to_string(),
|
||||
Format::Ogg => "Ogg".to_string(),
|
||||
Format::WebM => "WebM".to_string(),
|
||||
Format::SRT => "SubRip SRT".to_string(),
|
||||
Format::Count => "Unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// File extension for a format (e.g. "mp4").
|
||||
///
|
||||
/// CPP-PARITY: `src/codec/src/exportformat.cpp` `get_extension`;
|
||||
/// `Count` returns an empty string.
|
||||
pub fn get_extension(f: Format) -> String {
|
||||
match f {
|
||||
Format::DNxHD => "mxf".to_string(),
|
||||
Format::Matroska => "mkv".to_string(),
|
||||
Format::MPEG4Video => "mp4".to_string(),
|
||||
Format::MPEG4Audio => "m4a".to_string(),
|
||||
Format::OpenEXR => "exr".to_string(),
|
||||
Format::PNG => "png".to_string(),
|
||||
Format::TIFF => "tiff".to_string(),
|
||||
Format::QuickTime => "mov".to_string(),
|
||||
Format::WAV => "wav".to_string(),
|
||||
Format::AIFF => "aiff".to_string(),
|
||||
Format::MP3 => "mp3".to_string(),
|
||||
Format::FLAC => "flac".to_string(),
|
||||
Format::Ogg => "ogg".to_string(),
|
||||
Format::WebM => "webm".to_string(),
|
||||
Format::SRT => "srt".to_string(),
|
||||
Format::Count => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Codecs that can carry video in this format.
|
||||
///
|
||||
/// CPP-PARITY: `src/codec/src/exportformat.cpp` `get_video_codecs`.
|
||||
pub fn get_video_codecs(f: Format) -> Vec<Codec> {
|
||||
match f {
|
||||
Format::DNxHD => vec![Codec::DNxHD],
|
||||
Format::Matroska => vec![Codec::H264, Codec::H264RGB, Codec::H265, Codec::VP9],
|
||||
Format::MPEG4Video => vec![Codec::H264, Codec::H264RGB, Codec::H265],
|
||||
Format::OpenEXR => vec![Codec::OpenEXR],
|
||||
Format::PNG => vec![Codec::PNG],
|
||||
Format::TIFF => vec![Codec::TIFF],
|
||||
Format::QuickTime => vec![
|
||||
Codec::H264,
|
||||
Codec::H264RGB,
|
||||
Codec::H265,
|
||||
Codec::ProRes,
|
||||
Codec::CineForm,
|
||||
],
|
||||
Format::WebM => vec![Codec::AV1, Codec::VP9],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Codecs that can carry audio in this format.
|
||||
///
|
||||
/// CPP-PARITY: `src/codec/src/exportformat.cpp` `get_audio_codecs`.
|
||||
pub fn get_audio_codecs(f: Format) -> Vec<Codec> {
|
||||
match f {
|
||||
// Video/audio formats.
|
||||
Format::DNxHD => vec![Codec::PCM],
|
||||
Format::Matroska => vec![
|
||||
Codec::AAC,
|
||||
Codec::MP2,
|
||||
Codec::MP3,
|
||||
Codec::PCM,
|
||||
Codec::Vorbis,
|
||||
Codec::Opus,
|
||||
Codec::FLAC,
|
||||
],
|
||||
Format::MPEG4Video | Format::MPEG4Audio => {
|
||||
vec![Codec::AAC, Codec::MP2, Codec::MP3]
|
||||
}
|
||||
Format::QuickTime => vec![Codec::AAC, Codec::MP2, Codec::MP3, Codec::PCM],
|
||||
Format::WebM => vec![
|
||||
Codec::Opus,
|
||||
Codec::AAC,
|
||||
Codec::MP2,
|
||||
Codec::MP3,
|
||||
Codec::PCM,
|
||||
Codec::Vorbis,
|
||||
],
|
||||
// Audio-only formats.
|
||||
Format::WAV => vec![Codec::PCM],
|
||||
Format::AIFF => vec![Codec::PCM],
|
||||
Format::MP3 => vec![Codec::MP3],
|
||||
Format::FLAC => vec![Codec::FLAC],
|
||||
Format::Ogg => vec![Codec::Opus, Codec::Vorbis, Codec::PCM],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Codecs that can carry subtitles in this format.
|
||||
///
|
||||
/// CPP-PARITY: `src/codec/src/exportformat.cpp` `get_subtitle_codecs`;
|
||||
/// only Matroska and SRT support the SRT subtitle codec.
|
||||
pub fn get_subtitle_codecs(f: Format) -> Vec<Codec> {
|
||||
match f {
|
||||
Format::Matroska | Format::SRT => vec![Codec::SRT],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel formats the given codec supports in this format.
|
||||
///
|
||||
/// The C++ side computes this by instantiating the format's encoder and
|
||||
/// asking the FFmpeg bridge (`fb_encoder_codec_get_pixel_formats`); the
|
||||
/// base `Encoder::get_pixel_formats_for_codec` returns an empty list.
|
||||
/// There is no pure-Rust table, so this returns an empty list (matching
|
||||
/// the C++ base default). CPP-PARITY: `exportformat.cpp:222`.
|
||||
pub fn get_pixel_formats_for_codec(_f: Format, _c: Codec) -> Vec<i32> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Sample formats the given codec supports in this format.
|
||||
///
|
||||
/// CPP-PARITY: `ffmpegencoder.cpp:175`. PCM is pure (signed-16 first so
|
||||
/// the export dialog's default matches FFmpeg's); all other codecs query
|
||||
/// the FFmpeg bridge and return empty here (see
|
||||
/// `get_pixel_formats_for_codec`).
|
||||
pub fn get_sample_formats_for_codec(_f: Format, c: Codec) -> Vec<SampleFormat> {
|
||||
if c == Codec::PCM {
|
||||
vec![
|
||||
SampleFormat::S16,
|
||||
SampleFormat::U8,
|
||||
SampleFormat::S32,
|
||||
SampleFormat::S64,
|
||||
SampleFormat::F32,
|
||||
SampleFormat::F64,
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_name_mapping() {
|
||||
assert_eq!(Format::get_name(Format::DNxHD), "DNxHD");
|
||||
assert_eq!(Format::get_name(Format::Matroska), "Matroska Video");
|
||||
assert_eq!(Format::get_name(Format::MPEG4Video), "MPEG-4 Video");
|
||||
assert_eq!(Format::get_name(Format::MPEG4Audio), "MPEG-4 Audio");
|
||||
assert_eq!(Format::get_name(Format::OpenEXR), "OpenEXR");
|
||||
assert_eq!(Format::get_name(Format::PNG), "PNG");
|
||||
assert_eq!(Format::get_name(Format::TIFF), "TIFF");
|
||||
assert_eq!(Format::get_name(Format::QuickTime), "QuickTime");
|
||||
assert_eq!(Format::get_name(Format::WAV), "Wave Audio");
|
||||
assert_eq!(Format::get_name(Format::AIFF), "AIFF");
|
||||
assert_eq!(Format::get_name(Format::MP3), "MP3");
|
||||
assert_eq!(Format::get_name(Format::FLAC), "FLAC");
|
||||
assert_eq!(Format::get_name(Format::Ogg), "Ogg");
|
||||
assert_eq!(Format::get_name(Format::WebM), "WebM");
|
||||
assert_eq!(Format::get_name(Format::SRT), "SubRip SRT");
|
||||
assert_eq!(Format::get_name(Format::Count), "Unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_extension_mapping() {
|
||||
let cases = [
|
||||
(Format::DNxHD, "mxf"),
|
||||
(Format::Matroska, "mkv"),
|
||||
(Format::MPEG4Video, "mp4"),
|
||||
(Format::MPEG4Audio, "m4a"),
|
||||
(Format::OpenEXR, "exr"),
|
||||
(Format::PNG, "png"),
|
||||
(Format::TIFF, "tiff"),
|
||||
(Format::QuickTime, "mov"),
|
||||
(Format::WAV, "wav"),
|
||||
(Format::AIFF, "aiff"),
|
||||
(Format::MP3, "mp3"),
|
||||
(Format::FLAC, "flac"),
|
||||
(Format::Ogg, "ogg"),
|
||||
(Format::WebM, "webm"),
|
||||
(Format::SRT, "srt"),
|
||||
];
|
||||
for (f, ext) in cases {
|
||||
assert_eq!(Format::get_extension(f), ext, "{:?}", f);
|
||||
}
|
||||
assert_eq!(Format::get_extension(Format::Count), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_i32_maps_valid_and_rejects_others() {
|
||||
for v in 0..=14 {
|
||||
assert!(Format::from_i32(v).is_some(), "format {v}");
|
||||
}
|
||||
assert!(Format::from_i32(15).is_none()); // Count
|
||||
assert!(Format::from_i32(-1).is_none());
|
||||
assert!(Format::from_i32(99).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_codec_tables() {
|
||||
assert_eq!(Format::get_video_codecs(Format::DNxHD), vec![Codec::DNxHD]);
|
||||
assert_eq!(
|
||||
Format::get_video_codecs(Format::Matroska),
|
||||
vec![Codec::H264, Codec::H264RGB, Codec::H265, Codec::VP9]
|
||||
);
|
||||
assert_eq!(
|
||||
Format::get_video_codecs(Format::MPEG4Video),
|
||||
vec![Codec::H264, Codec::H264RGB, Codec::H265]
|
||||
);
|
||||
assert_eq!(
|
||||
Format::get_video_codecs(Format::OpenEXR),
|
||||
vec![Codec::OpenEXR]
|
||||
);
|
||||
assert_eq!(Format::get_video_codecs(Format::PNG), vec![Codec::PNG]);
|
||||
assert_eq!(Format::get_video_codecs(Format::TIFF), vec![Codec::TIFF]);
|
||||
assert_eq!(
|
||||
Format::get_video_codecs(Format::QuickTime),
|
||||
vec![
|
||||
Codec::H264,
|
||||
Codec::H264RGB,
|
||||
Codec::H265,
|
||||
Codec::ProRes,
|
||||
Codec::CineForm
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
Format::get_video_codecs(Format::WebM),
|
||||
vec![Codec::AV1, Codec::VP9]
|
||||
);
|
||||
// Formats without video codecs.
|
||||
assert!(Format::get_video_codecs(Format::WAV).is_empty());
|
||||
assert!(Format::get_video_codecs(Format::MP3).is_empty());
|
||||
assert!(Format::get_video_codecs(Format::SRT).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_codec_tables() {
|
||||
assert_eq!(Format::get_audio_codecs(Format::WAV), vec![Codec::PCM]);
|
||||
assert_eq!(Format::get_audio_codecs(Format::AIFF), vec![Codec::PCM]);
|
||||
assert_eq!(Format::get_audio_codecs(Format::MP3), vec![Codec::MP3]);
|
||||
assert_eq!(Format::get_audio_codecs(Format::FLAC), vec![Codec::FLAC]);
|
||||
assert_eq!(
|
||||
Format::get_audio_codecs(Format::Ogg),
|
||||
vec![Codec::Opus, Codec::Vorbis, Codec::PCM]
|
||||
);
|
||||
assert_eq!(Format::get_audio_codecs(Format::DNxHD), vec![Codec::PCM]);
|
||||
assert!(Format::get_audio_codecs(Format::PNG).is_empty());
|
||||
assert!(Format::get_audio_codecs(Format::SRT).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtitle_and_codec_capability_tables() {
|
||||
assert_eq!(
|
||||
Format::get_subtitle_codecs(Format::Matroska),
|
||||
vec![Codec::SRT]
|
||||
);
|
||||
assert_eq!(Format::get_subtitle_codecs(Format::SRT), vec![Codec::SRT]);
|
||||
assert!(Format::get_subtitle_codecs(Format::MPEG4Video).is_empty());
|
||||
|
||||
// Pixel-format query is a bridge-dependent default (empty list).
|
||||
assert!(Format::get_pixel_formats_for_codec(Format::MPEG4Video, Codec::H264).is_empty());
|
||||
|
||||
// PCM exposes its native sample formats; other codecs query the
|
||||
// FFmpeg bridge and get an empty list here.
|
||||
assert_eq!(
|
||||
Format::get_sample_formats_for_codec(Format::WAV, Codec::PCM),
|
||||
vec![
|
||||
SampleFormat::S16,
|
||||
SampleFormat::U8,
|
||||
SampleFormat::S32,
|
||||
SampleFormat::S64,
|
||||
SampleFormat::F32,
|
||||
SampleFormat::F64,
|
||||
]
|
||||
);
|
||||
assert!(Format::get_sample_formats_for_codec(Format::MPEG4Video, Codec::AAC).is_empty());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,285 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `olive::FootageDescription` — codec-internal stream inventory.
|
||||
//!
|
||||
//! Mirrors `src/codec/src/footagedescription.h`. A value type describing
|
||||
//! the streams a `Decoder::probe()` found in a file. Video and subtitle
|
||||
//! streams are stored as oakcommon by-value handles; audio streams as
|
||||
//! `oak_core::TimeRangeList`/raw audio params. The original's
|
||||
//! `Track::Type` mapping and XML load/save are intentionally not reproduced
|
||||
//! (NOTES.md §4) — use [`FootageDescription::stream_is_video`] etc.
|
||||
|
||||
use oak_common::subtitleparams::SubtitleParams;
|
||||
use oak_common::videoparams::VideoParams;
|
||||
use oak_core::{Rational, TimeRange};
|
||||
|
||||
use crate::audioparams::AudioParams;
|
||||
|
||||
/// One stream entry in a footage description.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum StreamEntry {
|
||||
/// A video stream.
|
||||
Video(VideoParams),
|
||||
/// An audio stream.
|
||||
Audio(AudioParams),
|
||||
/// A subtitle stream.
|
||||
Subtitle(SubtitleParams),
|
||||
}
|
||||
|
||||
/// `olive::FootageDescription` — the decoder name plus stream inventory.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FootageDescription {
|
||||
/// The decoder id that produced this description.
|
||||
decoder: String,
|
||||
/// Total number of streams (video + audio + subtitle).
|
||||
total_stream_count: usize,
|
||||
/// The streams, in probe order.
|
||||
streams: Vec<StreamEntry>,
|
||||
/// The media has a source start time.
|
||||
has_source_start_time: bool,
|
||||
/// Source start time (when present).
|
||||
source_start_time: Rational,
|
||||
/// Total duration across streams (may be empty).
|
||||
duration: Option<TimeRange>,
|
||||
}
|
||||
|
||||
impl FootageDescription {
|
||||
/// New, empty description with the given decoder id.
|
||||
pub fn new(decoder: &str) -> Self {
|
||||
Self {
|
||||
decoder: decoder.to_string(),
|
||||
total_stream_count: 0,
|
||||
streams: Vec::new(),
|
||||
has_source_start_time: false,
|
||||
// C++ default-constructs the Rational member, i.e. the 0/0
|
||||
// null sentinel. It is not meaningful until a source start time
|
||||
// is set.
|
||||
source_start_time: Rational::default(),
|
||||
duration: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The decoder id.
|
||||
pub fn decoder(&self) -> &str {
|
||||
&self.decoder
|
||||
}
|
||||
|
||||
/// Total stream count.
|
||||
pub fn total_stream_count(&self) -> usize {
|
||||
self.total_stream_count
|
||||
}
|
||||
|
||||
/// Number of video streams.
|
||||
pub fn video_stream_count(&self) -> usize {
|
||||
self.streams
|
||||
.iter()
|
||||
.filter(|s| matches!(s, StreamEntry::Video(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Number of audio streams.
|
||||
pub fn audio_stream_count(&self) -> usize {
|
||||
self.streams
|
||||
.iter()
|
||||
.filter(|s| matches!(s, StreamEntry::Audio(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Number of subtitle streams.
|
||||
pub fn subtitle_stream_count(&self) -> usize {
|
||||
self.streams
|
||||
.iter()
|
||||
.filter(|s| matches!(s, StreamEntry::Subtitle(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Whether the `index`-th stream (in probe order) is a video stream.
|
||||
pub fn stream_is_video(&self, index: usize) -> bool {
|
||||
self.streams
|
||||
.get(index)
|
||||
.is_some_and(|s| matches!(s, StreamEntry::Video(_)))
|
||||
}
|
||||
|
||||
/// Whether the `index`-th stream (in probe order) is an audio stream.
|
||||
pub fn stream_is_audio(&self, index: usize) -> bool {
|
||||
self.streams
|
||||
.get(index)
|
||||
.is_some_and(|s| matches!(s, StreamEntry::Audio(_)))
|
||||
}
|
||||
|
||||
/// Whether the `index`-th stream (in probe order) is a subtitle stream.
|
||||
pub fn stream_is_subtitle(&self, index: usize) -> bool {
|
||||
self.streams
|
||||
.get(index)
|
||||
.is_some_and(|s| matches!(s, StreamEntry::Subtitle(_)))
|
||||
}
|
||||
|
||||
/// The `index`-th video stream's params (by video-stream ordinal).
|
||||
pub fn get_video_stream(&self, index: usize) -> Option<&VideoParams> {
|
||||
self.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
StreamEntry::Video(p) => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.nth(index)
|
||||
}
|
||||
|
||||
/// The `index`-th audio stream's params (by audio-stream ordinal).
|
||||
pub fn get_audio_stream(&self, index: usize) -> Option<&AudioParams> {
|
||||
self.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
StreamEntry::Audio(p) => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.nth(index)
|
||||
}
|
||||
|
||||
/// The `index`-th subtitle stream's params (by subtitle-stream ordinal).
|
||||
pub fn get_subtitle_stream(&self, index: usize) -> Option<&SubtitleParams> {
|
||||
self.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
StreamEntry::Subtitle(p) => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.nth(index)
|
||||
}
|
||||
|
||||
/// Whether the media has a source start time.
|
||||
pub fn has_source_start_time(&self) -> bool {
|
||||
self.has_source_start_time
|
||||
}
|
||||
|
||||
/// Source start time.
|
||||
pub fn source_start_time(&self) -> Rational {
|
||||
self.source_start_time
|
||||
}
|
||||
|
||||
/// Total duration across streams.
|
||||
pub fn duration(&self) -> Option<TimeRange> {
|
||||
self.duration
|
||||
}
|
||||
|
||||
/// Append one stream entry, mirroring the C++ `add_*_stream` family.
|
||||
///
|
||||
/// Test/extension support: the ffi probe tests build
|
||||
/// `FootageDescription`s through a fake decoder and need a way to
|
||||
/// populate them. Hidden from docs; never called by production code.
|
||||
#[doc(hidden)]
|
||||
pub fn push_stream(&mut self, entry: StreamEntry) {
|
||||
self.total_stream_count += 1;
|
||||
self.streams.push(entry);
|
||||
}
|
||||
|
||||
/// Set the total stream count, mirroring the C++ `set_stream_count`
|
||||
/// (which records every stream in the container, including ones the
|
||||
/// probe could not describe and therefore did not push).
|
||||
///
|
||||
/// Hidden from docs; probe/extension support only.
|
||||
#[doc(hidden)]
|
||||
pub fn set_stream_count(&mut self, count: usize) {
|
||||
self.total_stream_count = count;
|
||||
}
|
||||
|
||||
/// Set the source start time, mirroring the C++ `set_source_start_time`.
|
||||
/// `source` is the raw metadata source kind (unused by the Rust port).
|
||||
///
|
||||
/// Hidden from docs; probe/extension support only.
|
||||
#[doc(hidden)]
|
||||
pub fn set_source_start_time(&mut self, time: Rational, _source: i32) {
|
||||
self.has_source_start_time = true;
|
||||
self.source_start_time = time;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn video_params(index: i32) -> VideoParams {
|
||||
let mut vp = VideoParams::new_basic(
|
||||
1920,
|
||||
1080,
|
||||
oak_common::ocioutils::PixelFormat::from_code(0),
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
vp.set_stream_index(index);
|
||||
vp
|
||||
}
|
||||
|
||||
fn audio_params() -> AudioParams {
|
||||
AudioParams {
|
||||
sample_rate: 48000,
|
||||
channel_layout: 0x3,
|
||||
format: 0,
|
||||
stream_index: 1,
|
||||
duration: 0,
|
||||
time_base: (1, 48000),
|
||||
}
|
||||
}
|
||||
|
||||
fn subtitle_params() -> SubtitleParams {
|
||||
SubtitleParams::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_description_is_empty() {
|
||||
let d = FootageDescription::new("ffmpeg");
|
||||
assert_eq!(d.decoder(), "ffmpeg");
|
||||
assert_eq!(d.total_stream_count(), 0);
|
||||
assert_eq!(d.video_stream_count(), 0);
|
||||
assert_eq!(d.audio_stream_count(), 0);
|
||||
assert_eq!(d.subtitle_stream_count(), 0);
|
||||
assert!(!d.has_source_start_time());
|
||||
assert!(d.duration().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_stream_updates_counts_and_queries() {
|
||||
let mut d = FootageDescription::new("mock");
|
||||
d.push_stream(StreamEntry::Video(video_params(0)));
|
||||
d.push_stream(StreamEntry::Audio(audio_params()));
|
||||
d.push_stream(StreamEntry::Subtitle(subtitle_params()));
|
||||
d.push_stream(StreamEntry::Video(video_params(1)));
|
||||
|
||||
assert_eq!(d.total_stream_count(), 4);
|
||||
assert_eq!(d.video_stream_count(), 2);
|
||||
assert_eq!(d.audio_stream_count(), 1);
|
||||
assert_eq!(d.subtitle_stream_count(), 1);
|
||||
|
||||
// stream_is_* by probe order.
|
||||
assert!(d.stream_is_video(0));
|
||||
assert!(d.stream_is_audio(1));
|
||||
assert!(d.stream_is_subtitle(2));
|
||||
assert!(d.stream_is_video(3));
|
||||
|
||||
// Ordinal getters.
|
||||
assert!(d.get_video_stream(0).is_some());
|
||||
assert!(d.get_video_stream(1).is_some());
|
||||
assert!(d.get_video_stream(2).is_none());
|
||||
assert!(d.get_audio_stream(0).is_some());
|
||||
assert!(d.get_audio_stream(1).is_none());
|
||||
assert!(d.get_subtitle_stream(0).is_some());
|
||||
assert!(d.get_subtitle_stream(1).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `olive::Frame` — a CPU pixel buffer plus a [`VideoParams`] value.
|
||||
//!
|
||||
//! Mirrors `src/codec/src/frame.h`. The params are held as an oakcommon
|
||||
//! [`VideoParams`] value (single-lib unification; the former refcounted
|
||||
//! oakcommon handle is gone, so copies are plain clones); the pixel data
|
||||
//! itself is a plain `Vec<u8>`. Line-size and pixel-format math lives
|
||||
//! here.
|
||||
|
||||
use oak_common::videoparams::VideoParams;
|
||||
use oak_core::{PixelFormat, Rational};
|
||||
|
||||
/// Number of channels in the internal RGBA pipeline layout
|
||||
/// (`VideoParams::k_internal_channel_count == k_rgba_channel_count == 4`).
|
||||
/// The frame math (linesize, per-pixel offsets) always assumes this layout,
|
||||
/// matching the C++ decoder path which produces/consumes RGBA frames.
|
||||
const VIDEO_CHANNELS: i32 = 4;
|
||||
|
||||
/// Interlacing of a frame's parameter set (VideoParams::Interlacing).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum Interlacing {
|
||||
/// Progressive.
|
||||
None = 0,
|
||||
/// Upper field first.
|
||||
TopFieldFirst = 1,
|
||||
/// Lower field first.
|
||||
BottomFieldFirst = 2,
|
||||
}
|
||||
|
||||
/// `olive::Frame`: reference-counted CPU pixel buffer + params value.
|
||||
#[derive(Debug)]
|
||||
pub struct Frame {
|
||||
/// Video parameter set.
|
||||
pub params: Option<VideoParams>,
|
||||
/// Pixel buffer (unallocated until `allocate`).
|
||||
data: Vec<u8>,
|
||||
/// Distance between rows in bytes (0 until params are set).
|
||||
linesize_bytes: i32,
|
||||
/// Timestamp, rational seconds.
|
||||
timestamp: Rational,
|
||||
/// Allocated pixel format (may differ from params while converting).
|
||||
allocated_format: PixelFormat,
|
||||
}
|
||||
|
||||
/// Map an `OakPixelFormat` int code back to a `PixelFormat` (unknown codes
|
||||
/// become [`PixelFormat::Invalid`]).
|
||||
fn pixel_format_from_i32(v: i32) -> PixelFormat {
|
||||
match v {
|
||||
0 => PixelFormat::U8,
|
||||
1 => PixelFormat::U10,
|
||||
2 => PixelFormat::U16,
|
||||
3 => PixelFormat::F16,
|
||||
4 => PixelFormat::F32,
|
||||
_ => PixelFormat::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes per pixel for `format` at `channels`, matching
|
||||
/// `VideoParams::get_bytes_per_pixel`:
|
||||
/// - U10 is a packed RGBA10A2 pixel: 4 bytes for the RGBA layout,
|
||||
/// regardless of channel count; anything else is rejected (0).
|
||||
/// - All other formats are `bytes_per_channel * channels`.
|
||||
fn bytes_per_pixel(format: PixelFormat, channels: i32) -> i32 {
|
||||
if format == PixelFormat::U10 {
|
||||
return if channels == VIDEO_CHANNELS { 4 } else { 0 };
|
||||
}
|
||||
(format.bytes_per_channel() as i32) * channels
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// New frame with default (invalid) params; buffer unallocated.
|
||||
pub fn new() -> Self {
|
||||
Frame {
|
||||
params: Some(VideoParams::new()),
|
||||
data: Vec::new(),
|
||||
linesize_bytes: 0,
|
||||
timestamp: Rational::new(0, 1),
|
||||
allocated_format: PixelFormat::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
/// New frame with a copy of `params`.
|
||||
pub fn with_params(params: VideoParams) -> Self {
|
||||
let mut frame = Frame {
|
||||
params: Some(params),
|
||||
data: Vec::new(),
|
||||
linesize_bytes: 0,
|
||||
timestamp: Rational::new(0, 1),
|
||||
allocated_format: PixelFormat::Invalid,
|
||||
};
|
||||
frame.recompute_linesize();
|
||||
frame
|
||||
}
|
||||
|
||||
/// The video parameter set, or `None` when empty.
|
||||
pub fn params(&self) -> Option<&VideoParams> {
|
||||
self.params.as_ref()
|
||||
}
|
||||
|
||||
/// Replace the parameter set, recompute line sizes, do NOT reallocate
|
||||
/// the buffer.
|
||||
pub fn set_params(&mut self, params: VideoParams) {
|
||||
self.params = Some(params);
|
||||
self.recompute_linesize();
|
||||
// Deliberately do not touch `data`: an existing buffer keeps its
|
||||
// layout; `allocated_format` stays at the old format until the next
|
||||
// `allocate()`.
|
||||
}
|
||||
|
||||
/// Recompute `linesize_bytes` from the current params (0 when unset).
|
||||
fn recompute_linesize(&mut self) {
|
||||
self.linesize_bytes = match &self.params {
|
||||
Some(p) => {
|
||||
let fmt = pixel_format_from_i32(p.format().code());
|
||||
Self::generate_linesize_bytes(fmt, p.width())
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// Allocate the pixel buffer from the current params.
|
||||
pub fn allocate(&mut self) -> crate::error::Result<()> {
|
||||
let params = match &self.params {
|
||||
Some(p) => p,
|
||||
None => return Err(crate::error::Error::State),
|
||||
};
|
||||
|
||||
if !params.is_valid() {
|
||||
return Err(crate::error::Error::State);
|
||||
}
|
||||
|
||||
if self.is_allocated() {
|
||||
// Already allocated; leave the buffer alone.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let width = params.width();
|
||||
let height = params.height();
|
||||
let format = pixel_format_from_i32(params.format().code());
|
||||
|
||||
let linesize = Self::generate_linesize_bytes(format, width);
|
||||
let size = (linesize as usize).wrapping_mul(height as usize);
|
||||
|
||||
self.data.resize(size, 0);
|
||||
self.linesize_bytes = linesize;
|
||||
self.allocated_format = format;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 1 when the pixel buffer is allocated.
|
||||
pub fn is_allocated(&self) -> bool {
|
||||
!self.data.is_empty()
|
||||
}
|
||||
|
||||
/// Writable pixel buffer slice, or `None` when unallocated.
|
||||
pub fn data(&self) -> Option<&[u8]> {
|
||||
if self.is_allocated() {
|
||||
Some(&self.data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable pixel buffer slice, or `None` when unallocated.
|
||||
pub fn data_mut(&mut self) -> Option<&mut [u8]> {
|
||||
if self.is_allocated() {
|
||||
Some(&mut self.data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Size of the pixel buffer in bytes (0 when unallocated).
|
||||
pub fn allocated_size(&self) -> usize {
|
||||
self.data.len()
|
||||
}
|
||||
|
||||
/// Distance between two rows in bytes (0 when params are unset).
|
||||
pub fn linesize_bytes(&self) -> i32 {
|
||||
self.linesize_bytes
|
||||
}
|
||||
|
||||
/// Distance between two rows in pixels.
|
||||
pub fn linesize_pixels(&self) -> i32 {
|
||||
let bpp = self.bytes_per_pixel();
|
||||
if bpp > 0 {
|
||||
self.linesize_bytes / bpp
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes per pixel for the current params format (RGBA layout).
|
||||
fn bytes_per_pixel(&self) -> i32 {
|
||||
bytes_per_pixel(self.format(), VIDEO_CHANNELS)
|
||||
}
|
||||
|
||||
/// Frame width in pixels (0 when params are empty).
|
||||
pub fn width(&self) -> i32 {
|
||||
match &self.params {
|
||||
Some(p) => p.width(),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame height in pixels (0 when params are empty).
|
||||
pub fn height(&self) -> i32 {
|
||||
match &self.params {
|
||||
Some(p) => p.height(),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel format (`OakPixelFormat` value).
|
||||
pub fn format(&self) -> PixelFormat {
|
||||
match &self.params {
|
||||
Some(p) => pixel_format_from_i32(p.format().code()),
|
||||
None => PixelFormat::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
/// Plane channel count of the params format.
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
/// `src/codec/src/frame.h` reads this from the params via
|
||||
/// `VideoParams::channel_count`. Decoder frames are always produced in
|
||||
/// the internal RGBA layout, so this returns [`VIDEO_CHANNELS`] (4).
|
||||
pub fn channel_count(&self) -> i32 {
|
||||
VIDEO_CHANNELS
|
||||
}
|
||||
|
||||
/// Timestamp as a rational number of seconds.
|
||||
pub fn timestamp(&self) -> Rational {
|
||||
self.timestamp
|
||||
}
|
||||
|
||||
/// Set the timestamp.
|
||||
pub fn set_timestamp(&mut self, ts: Rational) {
|
||||
self.timestamp = ts;
|
||||
}
|
||||
|
||||
/// Distance between rows for a (format, width) pair, in bytes.
|
||||
///
|
||||
/// Matches `Frame::generate_linesize_bytes(width, format, channel_count)`
|
||||
/// in `src/codec/src/frame.cpp` with `channel_count` fixed at
|
||||
/// [`VIDEO_CHANNELS`]: bytes per pixel times the width rounded up to a
|
||||
/// 32-byte boundary. Uses wrapping arithmetic so extreme (or negative)
|
||||
/// widths behave like the C++ `int` math rather than panicking.
|
||||
pub fn generate_linesize_bytes(format: PixelFormat, width: i32) -> i32 {
|
||||
let bpp = bytes_per_pixel(format, VIDEO_CHANNELS);
|
||||
let aligned = width.wrapping_add(31) & !31;
|
||||
bpp.wrapping_mul(aligned)
|
||||
}
|
||||
|
||||
/// Convert the buffer to another pixel format (`convert_to_olive_format`).
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
/// `src/codec/src/frame.cpp` — the destination params are carried by
|
||||
/// the C++ callers via `VideoParams` setters; Rust keeps the
|
||||
/// equivalent state in `self.params`.
|
||||
///
|
||||
/// When the current params format already matches the format the buffer
|
||||
/// was allocated in, this is a no-op (`Ok`). A genuine pixel-format
|
||||
/// conversion requires the OIIO bridge (`convert_to_olive_format`), which
|
||||
/// is not yet ported to the pure-Rust crate; until then a mismatched
|
||||
/// conversion is rejected with [`crate::error::Error::State`].
|
||||
pub fn convert(&mut self) -> crate::error::Result<()> {
|
||||
if !self.is_allocated() {
|
||||
return Err(crate::error::Error::State);
|
||||
}
|
||||
let fmt = self.format();
|
||||
if self.allocated_format == fmt {
|
||||
self.recompute_linesize();
|
||||
return Ok(());
|
||||
}
|
||||
Err(crate::error::Error::State)
|
||||
}
|
||||
|
||||
/// True when `(x, y)` lies inside the allocated buffer.
|
||||
fn contains_pixel(&self, x: i32, y: i32) -> bool {
|
||||
self.is_allocated() && x >= 0 && x < self.width() && y >= 0 && y < self.height()
|
||||
}
|
||||
|
||||
/// Read a pixel sample at (x, y).
|
||||
///
|
||||
/// Returns the first byte of the pixel at `(x, y)` (the R channel for
|
||||
/// RGBA). Out-of-bounds reads return 0, matching the C++ default
|
||||
/// (transparent black) color.
|
||||
pub fn get_pixel(&self, x: i32, y: i32) -> u8 {
|
||||
if !self.contains_pixel(x, y) {
|
||||
return 0;
|
||||
}
|
||||
let offset = (y as usize).wrapping_mul(self.linesize_bytes as usize)
|
||||
+ (x as usize).wrapping_mul(self.bytes_per_pixel() as usize);
|
||||
*self.data.get(offset).unwrap_or(&0)
|
||||
}
|
||||
|
||||
/// Write a pixel sample at (x, y).
|
||||
///
|
||||
/// Writes `value` to the first byte of the pixel at `(x, y)`. Out-of-bounds
|
||||
/// writes are ignored, matching the C++ `set_pixel`.
|
||||
pub fn set_pixel(&mut self, x: i32, y: i32, value: u8) {
|
||||
if !self.contains_pixel(x, y) {
|
||||
return;
|
||||
}
|
||||
let offset = (y as usize).wrapping_mul(self.linesize_bytes as usize)
|
||||
+ (x as usize).wrapping_mul(self.bytes_per_pixel() as usize);
|
||||
if let Some(byte) = self.data.get_mut(offset) {
|
||||
*byte = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oak_common::ocioutils::PixelFormat as OakPixelFormat;
|
||||
|
||||
fn frame(w: i32, h: i32) -> Frame {
|
||||
let params = VideoParams::new_basic(w, h, OakPixelFormat::from_code(0), 4, 1, 1, 0, 1);
|
||||
Frame::with_params(params)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linesize_is_32_byte_aligned_for_u8() {
|
||||
// U8 RGBA: 4 bytes/pixel, width rounded up to a 32-byte boundary.
|
||||
assert_eq!(Frame::generate_linesize_bytes(PixelFormat::U8, 9), 4 * 32);
|
||||
assert_eq!(
|
||||
Frame::generate_linesize_bytes(PixelFormat::U8, 100),
|
||||
4 * 128
|
||||
);
|
||||
assert_eq!(Frame::generate_linesize_bytes(PixelFormat::U8, 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linesize_respects_16bit_and_u10() {
|
||||
assert_eq!(Frame::generate_linesize_bytes(PixelFormat::U16, 16), 8 * 32);
|
||||
// U10 is a packed 4-byte RGBA pixel regardless of channel count.
|
||||
assert_eq!(Frame::generate_linesize_bytes(PixelFormat::U10, 16), 4 * 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocate_fills_buffer_and_reports_size() {
|
||||
let mut f = frame(100, 50);
|
||||
assert!(!f.is_allocated());
|
||||
assert_eq!(f.allocated_size(), 0);
|
||||
assert!(f.data().is_none());
|
||||
|
||||
f.allocate().unwrap();
|
||||
assert!(f.is_allocated());
|
||||
assert_eq!(f.allocated_size(), (4 * 128) * 50);
|
||||
assert_eq!(f.data().map(|d| d.len()), Some((4 * 128) * 50));
|
||||
assert_eq!(f.linesize_bytes(), 4 * 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocate_invalid_params_is_error() {
|
||||
// init_basic(0, 0) is not valid -> allocate must reject.
|
||||
let mut f = frame(0, 0);
|
||||
assert!(f.allocate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_set_pixel_round_trip() {
|
||||
let mut f = frame(100, 50);
|
||||
f.allocate().unwrap();
|
||||
|
||||
f.set_pixel(3, 4, 0xAB);
|
||||
assert_eq!(f.get_pixel(3, 4), 0xAB);
|
||||
|
||||
// pixel (0,0) is the first byte; pixel (1,0) is bpp bytes later.
|
||||
f.set_pixel(0, 0, 0x11);
|
||||
f.set_pixel(1, 0, 0x22);
|
||||
assert_eq!(f.get_pixel(0, 0), 0x11);
|
||||
assert_eq!(f.get_pixel(1, 0), 0x22);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_bounds_reads_zero_and_writes_ignored() {
|
||||
let mut f = frame(10, 10);
|
||||
f.allocate().unwrap();
|
||||
assert_eq!(f.get_pixel(50, 50), 0);
|
||||
assert_eq!(f.get_pixel(-1, 0), 0);
|
||||
f.set_pixel(50, 50, 0xFF);
|
||||
// untouched
|
||||
assert_eq!(f.data().unwrap()[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_params_recomputes_linesize_without_realloc() {
|
||||
let params = VideoParams::new_basic(10, 10, OakPixelFormat::from_code(0), 4, 1, 1, 0, 1);
|
||||
let mut f = Frame::with_params(params);
|
||||
f.allocate().unwrap();
|
||||
let before = f.allocated_size();
|
||||
|
||||
let wider =
|
||||
VideoParams::new_basic(100, 10, OakPixelFormat::from_code(0), 4, 1, 1, 0, 1);
|
||||
f.set_params(wider);
|
||||
// linesize reflects the new width, but the buffer is untouched.
|
||||
assert_eq!(f.linesize_bytes(), 4 * 128);
|
||||
assert_eq!(f.allocated_size(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_extra {
|
||||
use super::*;
|
||||
use oak_common::ocioutils::PixelFormat as OakPixelFormat;
|
||||
|
||||
fn frame(w: i32, h: i32) -> Frame {
|
||||
let params = VideoParams::new_basic(w, h, OakPixelFormat::from_code(0), 4, 1, 1, 0, 1);
|
||||
Frame::with_params(params)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linesize_pixels_derives_from_bytes() {
|
||||
// U8 RGBA: bpp 4 -> linesize_pixels = linesize_bytes / 4.
|
||||
let mut f = frame(32, 16);
|
||||
f.allocate().unwrap();
|
||||
assert_eq!(f.linesize_bytes(), 4 * 32);
|
||||
assert_eq!(f.linesize_pixels(), 32);
|
||||
// Unallocated / unset params -> 0.
|
||||
let g = Frame::new();
|
||||
assert_eq!(g.linesize_pixels(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_count_is_internal_rgba_layout() {
|
||||
let f = frame(4, 4);
|
||||
assert_eq!(f.channel_count(), VIDEO_CHANNELS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_is_noop_when_format_matches() {
|
||||
let mut f = frame(16, 16);
|
||||
f.allocate().unwrap();
|
||||
// allocated U8 == params U8 -> no-op Ok.
|
||||
assert!(f.convert().is_ok());
|
||||
// Unallocated -> Err(State).
|
||||
let mut g = Frame::new();
|
||||
assert!(g.convert().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_format_from_unknown_code_is_invalid() {
|
||||
let mut p = VideoParams::new_basic(1, 1, OakPixelFormat::from_code(0), 4, 1, 1, 0, 1);
|
||||
p.set_format(OakPixelFormat::from_code(99));
|
||||
let f = Frame::with_params(p);
|
||||
assert_eq!(f.format(), PixelFormat::Invalid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_timestamp_is_zero() {
|
||||
let f = Frame::new();
|
||||
let ts = f.timestamp();
|
||||
assert_eq!(ts.numerator(), 0);
|
||||
assert_eq!(ts.denominator(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `olive::FrameManager` — a pool of reusable [`crate::frame::Frame`]
|
||||
//! buffers plus a background garbage-collection thread.
|
||||
//!
|
||||
//! Mirrors `src/codec/src/framemanager.h`. The C++ manager kept a pool of
|
||||
//! `std::list<FramePtr>` and a QThread that periodically dropped frames
|
||||
//! whose last reference died. Rust keeps the same contract behind a
|
||||
//! `Mutex`; the background thread is replaced by a dedicated GC thread
|
||||
//! that drains the pool of freeable frames.
|
||||
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use oak_common::videoparams::VideoParams;
|
||||
use crate::frame::Frame;
|
||||
|
||||
/// `olive::FrameManager`: singleton frame pool with background GC.
|
||||
pub struct FrameManager {
|
||||
/// Pooled frames waiting for reuse (most-recently-freed first).
|
||||
pool: Mutex<Vec<Frame>>,
|
||||
/// Peak number of live frames observed (diagnostics).
|
||||
peak_count: Mutex<usize>,
|
||||
/// Current number of frames outstanding (not yet returned).
|
||||
outstanding: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl FrameManager {
|
||||
/// The process-wide FrameManager singleton.
|
||||
///
|
||||
/// Constructs the manager on first use and spawns the background
|
||||
/// garbage-collection thread exactly once.
|
||||
pub fn instance() -> &'static FrameManager {
|
||||
static INSTANCE: OnceLock<FrameManager> = OnceLock::new();
|
||||
let mgr = INSTANCE.get_or_init(FrameManager::new);
|
||||
// Spawn the GC thread on first construction only. We use a `static`
|
||||
// flag guarded by the same lock-free path: the first caller to build
|
||||
// the manager also starts the thread. Subsequent calls skip it.
|
||||
spawn_gc_thread_once(mgr);
|
||||
mgr
|
||||
}
|
||||
|
||||
/// Create the empty manager.
|
||||
fn new() -> Self {
|
||||
FrameManager {
|
||||
pool: Mutex::new(Vec::new()),
|
||||
peak_count: Mutex::new(0),
|
||||
outstanding: Mutex::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the pool (dropping all cached frames).
|
||||
pub fn clear(&self) {
|
||||
self.pool.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Create a frame with the given params (borrowed from the pool when a
|
||||
/// compatible free frame exists, else freshly allocated).
|
||||
pub fn create_frame(&self, params: VideoParams) -> Arc<Frame> {
|
||||
let frame = {
|
||||
let mut pool = self.pool.lock().unwrap();
|
||||
match pool.iter().position(|f| frame_matches(f, ¶ms)) {
|
||||
Some(idx) => pool.swap_remove(idx),
|
||||
None => Frame::with_params(params),
|
||||
}
|
||||
};
|
||||
|
||||
let mut outstanding = self.outstanding.lock().unwrap();
|
||||
*outstanding += 1;
|
||||
let mut peak = self.peak_count.lock().unwrap();
|
||||
if *outstanding > *peak {
|
||||
*peak = *outstanding;
|
||||
}
|
||||
|
||||
Arc::new(frame)
|
||||
}
|
||||
|
||||
/// Return a frame to the pool for reuse.
|
||||
pub fn return_frame(&self, frame: Frame) {
|
||||
let mut outstanding = self.outstanding.lock().unwrap();
|
||||
*outstanding = outstanding.saturating_sub(1);
|
||||
self.pool.lock().unwrap().push(frame);
|
||||
}
|
||||
|
||||
/// Number of frames currently outstanding (not in the pool).
|
||||
pub fn live_count(&self) -> usize {
|
||||
*self.outstanding.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Peak number of live frames observed.
|
||||
pub fn peak_count(&self) -> usize {
|
||||
*self.peak_count.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Background GC loop; runs on the manager's dedicated thread.
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
/// `src/codec/src/framemanager.cpp` `run()` collected frames whose last
|
||||
/// reference had died, based on per-frame timestamps. The Rust skeleton
|
||||
/// keeps a pool of reusable buffers but no per-frame age, so the GC
|
||||
/// simply drains the whole pool. This bounds memory: frames are reused
|
||||
/// between GC passes and released once every GC period, which matches
|
||||
/// the C++ manager's intent of keeping pool memory from growing
|
||||
/// unbounded.
|
||||
fn gc_loop(&self) {
|
||||
self.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the GC thread once for the process.
|
||||
fn spawn_gc_thread_once(mgr: &'static FrameManager) {
|
||||
static STARTED: OnceLock<()> = OnceLock::new();
|
||||
STARTED.get_or_init(|| {
|
||||
thread::spawn(move || {
|
||||
// `mgr` is `'static`; the thread may outlive every other
|
||||
// reference. Keep polling until the process exits.
|
||||
loop {
|
||||
thread::sleep(Duration::from_millis(5000));
|
||||
mgr.gc_loop();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// True when `frame` carries params equal to `params`.
|
||||
fn frame_matches(frame: &Frame, params: &VideoParams) -> bool {
|
||||
let Some(frame_params) = frame.params() else {
|
||||
return false;
|
||||
};
|
||||
frame_params.equals(params)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oak_common::ocioutils::PixelFormat as OakPixelFormat;
|
||||
|
||||
fn test_params(w: i32, h: i32) -> VideoParams {
|
||||
VideoParams::new_basic(w, h, OakPixelFormat::from_code(0), 4, 1, 1, 0, 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_return_tracks_counts() {
|
||||
let mgr = FrameManager::new();
|
||||
assert_eq!(mgr.live_count(), 0);
|
||||
assert_eq!(mgr.peak_count(), 0);
|
||||
|
||||
let params = test_params(64, 64);
|
||||
let frame = mgr.create_frame(params);
|
||||
assert_eq!(mgr.live_count(), 1);
|
||||
assert_eq!(mgr.peak_count(), 1);
|
||||
|
||||
// Return by unwrapping the single strong reference.
|
||||
let frame = Arc::try_unwrap(frame).unwrap();
|
||||
mgr.return_frame(frame);
|
||||
assert_eq!(mgr.live_count(), 0);
|
||||
assert_eq!(mgr.peak_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_reuses_compatible_frames() {
|
||||
let mgr = FrameManager::new();
|
||||
let params = test_params(64, 64);
|
||||
let f1 = mgr.create_frame(params);
|
||||
mgr.return_frame(Arc::try_unwrap(f1).unwrap());
|
||||
assert_eq!(mgr.live_count(), 0);
|
||||
|
||||
// A compatible request reuses the pooled buffer rather than
|
||||
// allocating a new one.
|
||||
let f2 = mgr.create_frame(test_params(64, 64));
|
||||
assert_eq!(mgr.live_count(), 1);
|
||||
assert_eq!(mgr.peak_count(), 1);
|
||||
Arc::try_unwrap(f2).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_count_tracks_maximum() {
|
||||
let mgr = FrameManager::new();
|
||||
let p1 = test_params(64, 64);
|
||||
let p2 = test_params(128, 128);
|
||||
let a = mgr.create_frame(p1);
|
||||
let b = mgr.create_frame(p2);
|
||||
assert_eq!(mgr.live_count(), 2);
|
||||
assert_eq!(mgr.peak_count(), 2);
|
||||
mgr.return_frame(Arc::try_unwrap(a).unwrap());
|
||||
assert_eq!(mgr.live_count(), 1);
|
||||
assert_eq!(mgr.peak_count(), 2);
|
||||
Arc::try_unwrap(b).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_drops_pooled_frames() {
|
||||
let mgr = FrameManager::new();
|
||||
let params = test_params(64, 64);
|
||||
let f = mgr.create_frame(params);
|
||||
mgr.return_frame(Arc::try_unwrap(f).unwrap());
|
||||
assert_eq!(mgr.pool.lock().unwrap().len(), 1);
|
||||
|
||||
mgr.clear();
|
||||
assert_eq!(mgr.pool.lock().unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Hardware video decoding (user-mandated default): the platform's
|
||||
//! hardware acceleration is preferred over pure software decoding, with
|
||||
//! a config switch and an automatic software fallback.
|
||||
//!
|
||||
//! **FFmpeg 8 removed the standalone hardware decoders** (`h264_videotoolbox`,
|
||||
//! `h264_vaapi`, `h264_nvdec`, `h264_d3d11va` are all gone from its
|
||||
//! configure): hardware decode now only exists as a *hwaccel* attached
|
||||
//! to the software decoder. The model here is therefore uniform across
|
||||
//! platforms: create the platform's hardware device context
|
||||
//! (`av_hwdevice_ctx_create`), set it as `hw_device_ctx` on the codec
|
||||
//! context of the regular decoder, and FFmpeg automatically engages the
|
||||
//! matching hwaccel (`h264_videotoolbox_hwaccel` & co) on open. Codecs
|
||||
//! without a matching hwaccel silently stay software — the pipeline
|
||||
//! below only transfers frames whose pixel format is actually a
|
||||
//! hardware surface.
|
||||
//!
|
||||
//! - **macOS**: `AV_HWDEVICE_TYPE_VIDEOTOOLBOX`
|
||||
//! - **Linux**: `VAAPI`, then `CUDA` (NVDEC)
|
||||
//! - **Windows**: `D3D11VA`, then `CUDA` (NVDEC)
|
||||
//!
|
||||
//! Device creation can fail on machines without the device/driver (a
|
||||
//! headless Linux box, no NVIDIA GPU) — the candidate is skipped and
|
||||
//! the next one (or the software decoder) is used. Hardware frames
|
||||
//! (`AV_PIX_FMT_VIDEOTOOLBOX` / `VAAPI` / `CUDA` / `D3D11VA_VLD` /
|
||||
//! `D3D11`) are transferred to system memory with
|
||||
//! `av_hwframe_transfer_data` before the swscale conversion.
|
||||
|
||||
use ffmpeg::ffi as sys;
|
||||
use ffmpeg::Dictionary;
|
||||
use ffmpeg_next as ffmpeg;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Number of hardware frames transferred to system memory so far
|
||||
/// (process-wide). An observability counter: a hardware decode that
|
||||
/// never produces a hardware surface stays at zero, so tests can prove
|
||||
/// the hwaccel really engaged rather than silently staying software.
|
||||
pub static HW_TRANSFERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// The config key of the hardware-decode switch (1 = prefer hardware,
|
||||
/// 0 = force software). Default ON by user mandate.
|
||||
pub const CONFIG_KEY_HARDWARE_DECODING: &str = "HardwareDecoding";
|
||||
|
||||
/// Whether hardware decoding is preferred (the config switch). Default
|
||||
/// ON by user mandate; only an explicit `"false"` turns it off (the
|
||||
/// string accessor, same convention as the app's config helpers — the
|
||||
/// store's typed `get_bool` only parses pre-typed Bool entries).
|
||||
pub fn hardware_decoding_enabled() -> bool {
|
||||
match oak_common::configstore::ConfigStore::instance()
|
||||
.get(None, CONFIG_KEY_HARDWARE_DECODING)
|
||||
{
|
||||
Ok(value) => value != "false",
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The hardware device types to try, most preferred first. On a machine
|
||||
/// without the device/driver the candidate fails creation and the next
|
||||
/// one is tried; the software decoder is the final fallback.
|
||||
pub fn device_type_candidates() -> &'static [sys::AVHWDeviceType] {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
&[sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX]
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
&[
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA,
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
|
||||
]
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
&[
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI,
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// A display name for a device type (status reporting and tests).
|
||||
pub fn device_type_name(device_type: sys::AVHWDeviceType) -> &'static str {
|
||||
match device_type {
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX => "videotoolbox",
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI => "vaapi",
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA => "cuda/nvdec",
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA => "d3d11va",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to open the software codec with a hardware device context of
|
||||
/// `device_type` attached — FFmpeg then engages the matching hwaccel
|
||||
/// (e.g. `h264_videotoolbox_hwaccel`) on open. Returns the opened codec
|
||||
/// context plus the device type in use, or `None` when the device is
|
||||
/// unavailable (the caller tries the next candidate, then pure
|
||||
/// software).
|
||||
pub fn open_hw_accel(
|
||||
params: &ffmpeg::codec::Parameters,
|
||||
codec: ffmpeg::Codec,
|
||||
device_type: sys::AVHWDeviceType,
|
||||
) -> Option<(ffmpeg::codec::decoder::Opened, sys::AVHWDeviceType)> {
|
||||
let mut context = ffmpeg::codec::Context::from_parameters(params.clone()).ok()?;
|
||||
let mut device: *mut sys::AVBufferRef = std::ptr::null_mut();
|
||||
// SAFETY: `device` is a valid out-pointer; on success it owns the
|
||||
// device reference, which is handed to the codec context below.
|
||||
let rc = unsafe {
|
||||
sys::av_hwdevice_ctx_create(
|
||||
&mut device,
|
||||
device_type,
|
||||
std::ptr::null(),
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if rc < 0 || device.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: `hw_device_ctx` takes ownership of the reference; the codec
|
||||
// context frees it with the context.
|
||||
unsafe { (*context.as_mut_ptr()).hw_device_ctx = device };
|
||||
let mut opts = Dictionary::new();
|
||||
opts.set("threads", "auto");
|
||||
context
|
||||
.decoder()
|
||||
.open_as_with(codec, opts)
|
||||
.ok()
|
||||
.map(|opened| (opened, device_type))
|
||||
}
|
||||
|
||||
/// Whether a decoded frame's pixel format is a hardware surface that
|
||||
/// must be transferred to system memory before swscale can consume it.
|
||||
pub fn is_hw_format(format: sys::AVPixelFormat) -> bool {
|
||||
matches!(
|
||||
format,
|
||||
sys::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX
|
||||
| sys::AVPixelFormat::AV_PIX_FMT_VAAPI
|
||||
| sys::AVPixelFormat::AV_PIX_FMT_CUDA
|
||||
| sys::AVPixelFormat::AV_PIX_FMT_D3D11VA_VLD
|
||||
| sys::AVPixelFormat::AV_PIX_FMT_D3D11
|
||||
)
|
||||
}
|
||||
|
||||
/// Transfer a hardware frame to system memory (`av_hwframe_transfer_data`
|
||||
/// picks the software format: NV12 for 8-bit, P010LE for 10-bit sources;
|
||||
/// swscale consumes both). Presentation metadata the frame cache relies
|
||||
/// on is carried over.
|
||||
pub fn transfer_to_cpu(frame: &ffmpeg::frame::Video) -> Result<ffmpeg::frame::Video> {
|
||||
let mut cpu = ffmpeg::frame::Video::empty();
|
||||
// SAFETY: `cpu` and `frame` are valid AVFrames; flags 0 = default
|
||||
// transfer direction (hardware -> system memory).
|
||||
let rc = unsafe { sys::av_hwframe_transfer_data(cpu.as_mut_ptr(), frame.as_ptr(), 0) };
|
||||
if rc < 0 {
|
||||
return Err(Error::Failed(format!(
|
||||
"hwframe transfer to CPU failed (av error {rc})"
|
||||
)));
|
||||
}
|
||||
HW_TRANSFERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
cpu.set_pts(frame.pts());
|
||||
// SAFETY: plain field copies between valid AVFrames.
|
||||
unsafe {
|
||||
(*cpu.as_mut_ptr()).pkt_dts = (*frame.as_ptr()).pkt_dts;
|
||||
(*cpu.as_mut_ptr()).duration = (*frame.as_ptr()).duration;
|
||||
(*cpu.as_mut_ptr()).time_base = (*frame.as_ptr()).time_base;
|
||||
}
|
||||
Ok(cpu)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The current platform has at least one hardware device candidate
|
||||
/// (hardware decode is the mandated default on every supported
|
||||
/// platform).
|
||||
#[test]
|
||||
fn platform_has_a_hardware_candidate() {
|
||||
assert!(
|
||||
!device_type_candidates().is_empty(),
|
||||
"no hardware decode candidate on this platform"
|
||||
);
|
||||
}
|
||||
|
||||
/// Device types round-trip through their display names.
|
||||
#[test]
|
||||
fn device_type_names_are_stable() {
|
||||
assert_eq!(
|
||||
device_type_name(sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX),
|
||||
"videotoolbox"
|
||||
);
|
||||
assert_eq!(device_type_name(sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VAAPI), "vaapi");
|
||||
assert_eq!(device_type_name(sys::AVHWDeviceType::AV_HWDEVICE_TYPE_CUDA), "cuda/nvdec");
|
||||
assert_eq!(
|
||||
device_type_name(sys::AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA),
|
||||
"d3d11va"
|
||||
);
|
||||
}
|
||||
|
||||
/// Software and hardware pixel formats are classified correctly.
|
||||
#[test]
|
||||
fn hw_format_classification() {
|
||||
assert!(is_hw_format(sys::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX));
|
||||
assert!(is_hw_format(sys::AVPixelFormat::AV_PIX_FMT_VAAPI));
|
||||
assert!(is_hw_format(sys::AVPixelFormat::AV_PIX_FMT_CUDA));
|
||||
assert!(is_hw_format(sys::AVPixelFormat::AV_PIX_FMT_D3D11VA_VLD));
|
||||
assert!(!is_hw_format(sys::AVPixelFormat::AV_PIX_FMT_YUV420P));
|
||||
assert!(!is_hw_format(sys::AVPixelFormat::AV_PIX_FMT_NV12));
|
||||
}
|
||||
|
||||
/// On macOS the VideoToolbox device context must be creatable (the
|
||||
/// mandated default decode path) and the H.264 demo must open with
|
||||
/// the hwaccel attached.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn videotoolbox_device_opens_for_h264() {
|
||||
let mut input = ffmpeg::format::input(&"../../tests/demo.mp4".to_string())
|
||||
.expect("open demo.mp4");
|
||||
let fstream = input.stream(0).expect("stream 0");
|
||||
let params = fstream.parameters();
|
||||
let codec = ffmpeg::decoder::find(params.id()).expect("software h264 codec");
|
||||
let opened = open_hw_accel(
|
||||
¶ms,
|
||||
codec,
|
||||
sys::AVHWDeviceType::AV_HWDEVICE_TYPE_VIDEOTOOLBOX,
|
||||
);
|
||||
assert!(opened.is_some(), "VideoToolbox hwaccel must open for H.264");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! # oakcodec — the media codec module (Rust)
|
||||
//!
|
||||
//! Reimplements the C++ oakcodec module behind its frozen C ABI
|
||||
//! (`include/codec/*.h`). See README.md for the architectural mapping
|
||||
//! (inheritance → traits, shared_ptr → `Arc`, etc.).
|
||||
//!
|
||||
//! ## Structure
|
||||
//!
|
||||
//! Single-lib unification: the module crates are called directly from
|
||||
//! other module crates and the oakengine facade (`crates/oakengine`), so
|
||||
//! no C-ABI export layer remains here (the `include/codec/*.h` contracts
|
||||
//! are served by the facade). Shared state lives behind `Mutex`, decoder
|
||||
//! instances behind `Arc<dyn Decoder>`.
|
||||
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
#[cfg(test)]
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
pub mod audioparams;
|
||||
pub mod conformmanager;
|
||||
pub mod decoder;
|
||||
pub mod encoder;
|
||||
pub mod encodingparams;
|
||||
pub mod error;
|
||||
pub mod exportcodec;
|
||||
pub mod exportformat;
|
||||
pub mod ffmpeg;
|
||||
pub mod footagedescription;
|
||||
pub mod frame;
|
||||
pub mod framemanager;
|
||||
pub mod hwdecode;
|
||||
pub mod oiio;
|
||||
pub mod oiioframebridge;
|
||||
pub mod planarfiledevice;
|
||||
pub mod proxymanager;
|
||||
pub mod task;
|
||||
pub mod testmedia;
|
||||
pub mod timecodemetadata;
|
||||
|
||||
#[cfg(test)]
|
||||
mod realmedia_tests;
|
||||
|
||||
/// Process-wide test lock: serializes every test that reads or mutates
|
||||
/// crate-global state (the injected decoder/encoder registries). One lock
|
||||
/// for the whole crate — tests race only with each other, never with
|
||||
/// production code.
|
||||
#[cfg(test)]
|
||||
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// RAII guard over [`TEST_LOCK`]: the lock is taken when the guard is
|
||||
/// created ([`TestLock::acquire`]) and released when it is dropped —
|
||||
/// including through panics, so a failing test can never deadlock the
|
||||
/// tests that follow. Poison-tolerant: a panicking holder does not leave
|
||||
/// the mutex poisoned for the next acquirer.
|
||||
#[cfg(test)]
|
||||
pub struct TestLock {
|
||||
/// The held lock guard; dropping it releases [`TEST_LOCK`] (never read,
|
||||
/// only dropped — the whole point of the RAII guard).
|
||||
#[allow(dead_code)]
|
||||
guard: MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl TestLock {
|
||||
/// Acquire exclusive access to the crate's shared test state, blocking
|
||||
/// until every earlier holder has released it.
|
||||
pub fn acquire() -> TestLock {
|
||||
TestLock {
|
||||
guard: TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for TestLock {
|
||||
fn drop(&mut self) {
|
||||
// Dropping the held guard releases TEST_LOCK; the explicit Drop
|
||||
// documents the acquire-on-create / release-on-drop contract.
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire the process-wide test lock (see [`TestLock::acquire`]).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lock_tests() -> TestLock {
|
||||
TestLock::acquire()
|
||||
}
|
||||
|
||||
// Keep the oakffmpeg-link rlib referenced so its build script's native
|
||||
// link flags (the static FFmpeg's transitive dependencies) reach the
|
||||
// final link — rustc prunes the flags of an unreferenced rlib.
|
||||
#[used]
|
||||
static FORCE_FFMPEG_LINK: fn() = oak_ffmpeg_link::force_link;
|
||||
@@ -0,0 +1,307 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `OIIODecoder` / `OIIOEncoder` — the OpenImageIO-backed implementations.
|
||||
//!
|
||||
//! Mirrors `src/codec/src/oiio/{oiiodecoder,oiioencoder}.{h,cpp}`. OIIO
|
||||
//! frame conversion goes through the local
|
||||
//! [`crate::oiioframebridge`] helpers plus oakcommon's OIIO mapping
|
||||
//! functions.
|
||||
//!
|
||||
//! The OIIO dylib (`liboakoiio`) is not linked into this build, so every
|
||||
//! operation that would touch the media engine is a documented stub returning
|
||||
//! [`crate::error::Error::Failed`]; only bookkeeping that keeps the
|
||||
//! decoder/encoder safe to use when closed succeeds. The stream is still
|
||||
//! recorded on [`Decoder::open`] so [`Decoder::stream`] reflects the target,
|
||||
//! and [`Decoder::close`] / [`Encoder::close`] are no-ops.
|
||||
|
||||
use crate::decoder::{CodecStream, Decoder, RetrieveVideoParams};
|
||||
use crate::encoder::Encoder;
|
||||
use crate::encodingparams::EncodingParams;
|
||||
|
||||
/// `olive::OIIODecoder` — OpenImageIO-backed media decoder (still images).
|
||||
pub struct OIIODecoder {
|
||||
/// Opened stream (locked).
|
||||
stream: std::sync::Mutex<Option<CodecStream>>,
|
||||
}
|
||||
|
||||
impl OIIODecoder {
|
||||
/// Error returned for operations that need the missing `liboakoiio`.
|
||||
const NOT_AVAILABLE: &'static str =
|
||||
"OIIO decoding is not available in this build (needs liboakoiio dylib)";
|
||||
}
|
||||
|
||||
impl Decoder for OIIODecoder {
|
||||
fn id(&self) -> String {
|
||||
"oiio".to_string()
|
||||
}
|
||||
|
||||
fn supports_video(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn supports_audio(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn probe(
|
||||
&self,
|
||||
_filename: &str,
|
||||
_cancelled: Option<&oak_common::cancelatom::CancelAtom>,
|
||||
) -> Option<crate::footagedescription::FootageDescription> {
|
||||
// Probing is a dylib operation; without it we cannot report anything.
|
||||
None
|
||||
}
|
||||
|
||||
fn open(&self, stream: &CodecStream) -> crate::error::Result<()> {
|
||||
// Record the requested stream so `stream()` reflects the open target,
|
||||
// but actual decoding is unavailable without the dylib.
|
||||
*self.stream.lock().unwrap_or_else(|e| e.into_inner()) = Some(stream.clone());
|
||||
Err(crate::error::Error::Failed(Self::NOT_AVAILABLE.to_string()))
|
||||
}
|
||||
|
||||
fn close(&self) -> crate::error::Result<()> {
|
||||
// Trait contract: "safe when closed". Clear the opened stream and
|
||||
// no-op; nothing was ever decoded.
|
||||
*self.stream.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stream(&self) -> CodecStream {
|
||||
self.stream
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
.unwrap_or_else(CodecStream::new)
|
||||
}
|
||||
|
||||
fn retrieve_video_frame(
|
||||
&self,
|
||||
_p: &RetrieveVideoParams,
|
||||
) -> crate::error::Result<std::sync::Arc<crate::frame::Frame>> {
|
||||
Err(crate::error::Error::Failed(Self::NOT_AVAILABLE.to_string()))
|
||||
}
|
||||
|
||||
fn retrieve_video(
|
||||
&self,
|
||||
_p: &RetrieveVideoParams,
|
||||
) -> crate::error::Result<crate::decoder::OakRenderTexture> {
|
||||
Err(crate::error::Error::Failed(Self::NOT_AVAILABLE.to_string()))
|
||||
}
|
||||
|
||||
fn retrieve_audio(
|
||||
&self,
|
||||
_dest: &mut [f32],
|
||||
_range: &oak_core::TimeRange,
|
||||
_sample_rate: i32,
|
||||
_channel_layout: u64,
|
||||
) -> crate::error::Result<crate::decoder::RetrieveAudioStatus> {
|
||||
Err(crate::error::Error::Failed(Self::NOT_AVAILABLE.to_string()))
|
||||
}
|
||||
|
||||
fn conform_audio(
|
||||
&self,
|
||||
_output_filenames: &[String],
|
||||
_sample_rate: i32,
|
||||
_channel_layout: u64,
|
||||
_sample_format: i32,
|
||||
_cancelled: Option<&oak_common::cancelatom::CancelAtom>,
|
||||
) -> crate::error::Result<()> {
|
||||
Err(crate::error::Error::Failed(Self::NOT_AVAILABLE.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// `olive::OIIOEncoder` — OpenImageIO-backed media encoder (still images).
|
||||
pub struct OIIOEncoder {
|
||||
/// The encoding parameters this encoder was configured with.
|
||||
pub params: EncodingParams,
|
||||
}
|
||||
|
||||
impl Encoder for OIIOEncoder {
|
||||
fn id(&self) -> String {
|
||||
"oiio".to_string()
|
||||
}
|
||||
|
||||
fn supports_video(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn supports_audio(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn supports_subtitles(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn supports_image_sequences(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_configurable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn configure(&self, _params: &EncodingParams) -> crate::error::Result<()> {
|
||||
// `configure` writes `self.params`, which an `&self` receiver cannot
|
||||
// do, and real encoding needs the dylib anyway.
|
||||
Err(crate::error::Error::Failed(
|
||||
"OIIO encoding is not available in this build (needs liboakoiio dylib)".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn open(&self) -> crate::error::Result<()> {
|
||||
Err(crate::error::Error::Failed(
|
||||
"OIIO encoding is not available in this build (needs liboakoiio dylib)".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn close(&self) -> crate::error::Result<()> {
|
||||
// `Encoder::close` is documented idempotent; with no encoder opened
|
||||
// there is nothing to release.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_video(&self, _frame: &crate::frame::Frame) -> crate::error::Result<()> {
|
||||
Err(crate::error::Error::Failed(
|
||||
"OIIO encoding is not available in this build (needs liboakoiio dylib)".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn write_audio(&self, _samples: &[f32], _frame_count: i32) -> crate::error::Result<()> {
|
||||
Err(crate::error::Error::Failed(
|
||||
"OIIO encoding is not available in this build (needs liboakoiio dylib)".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn write_subtitle(
|
||||
&self,
|
||||
_text: &str,
|
||||
_in_seconds: f64,
|
||||
_out_seconds: f64,
|
||||
) -> crate::error::Result<()> {
|
||||
Err(crate::error::Error::Failed(
|
||||
"OIIO encoding is not available in this build (needs liboakoiio dylib)".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn flush(&self) -> crate::error::Result<()> {
|
||||
Err(crate::error::Error::Failed(
|
||||
"OIIO encoding is not available in this build (needs liboakoiio dylib)".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn desired_pixel_format(&self) -> Option<oak_core::PixelFormat> {
|
||||
None
|
||||
}
|
||||
|
||||
fn desired_sample_format(&self) -> Option<oak_core::SampleFormat> {
|
||||
None
|
||||
}
|
||||
|
||||
fn filename(&self) -> String {
|
||||
c_string_1024(&self.params.filename)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a NUL-terminated C string out of the `EncodingParams.filename` byte
|
||||
/// buffer, stopping at the first NUL (empty string when unset).
|
||||
fn c_string_1024(buf: &[u8; 1024]) -> String {
|
||||
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(&buf[..end]).into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::decoder::RetrieveVideoParams;
|
||||
use crate::encodingparams::EncodingParams;
|
||||
use oak_core::{Rational, TimeRange};
|
||||
|
||||
fn video_params() -> RetrieveVideoParams {
|
||||
RetrieveVideoParams {
|
||||
stream: CodecStream::new(),
|
||||
time: Rational::new(1, 30),
|
||||
length: TimeRange::default(),
|
||||
force_range: crate::decoder::K_COLOR_RANGE_DEFAULT,
|
||||
is_image_sequence: false,
|
||||
image_sequence_digits: 0,
|
||||
image_sequence_number: 0,
|
||||
mode: crate::decoder::RenderMode::Offline,
|
||||
alpha_is_premultiplied: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oiio_decoder_identity_and_stub_operations() {
|
||||
let d = OIIODecoder {
|
||||
stream: std::sync::Mutex::new(None),
|
||||
};
|
||||
assert_eq!(d.id(), "oiio");
|
||||
// Still images only: no audio support.
|
||||
assert!(!d.supports_video());
|
||||
assert!(!d.supports_audio());
|
||||
assert!(d.probe("any.png", None).is_none());
|
||||
|
||||
let s = CodecStream::with_block("in.exr".to_string(), 0, None);
|
||||
assert!(d.open(&s).is_err());
|
||||
assert_eq!(d.stream().filename(), "in.exr");
|
||||
assert!(d.close().is_ok());
|
||||
assert_eq!(d.stream().filename(), "");
|
||||
|
||||
assert!(d.retrieve_video_frame(&video_params()).is_err());
|
||||
assert!(d.retrieve_video(&video_params()).is_err());
|
||||
let mut dest = [0f32; 8];
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oiio_encoder_stub_behaviors() {
|
||||
let mut params = EncodingParams::default();
|
||||
let name = b"out/img.exr";
|
||||
params.filename[..name.len()].copy_from_slice(name);
|
||||
let e = OIIOEncoder { params };
|
||||
|
||||
assert_eq!(e.id(), "oiio");
|
||||
assert!(!e.supports_video());
|
||||
assert!(!e.supports_audio());
|
||||
assert!(!e.supports_subtitles());
|
||||
assert!(e.supports_image_sequences());
|
||||
assert!(e.is_configurable());
|
||||
assert_eq!(e.filename(), "out/img.exr");
|
||||
assert_eq!(e.desired_pixel_format(), None);
|
||||
assert_eq!(e.get_error(), "");
|
||||
|
||||
assert!(e.configure(&EncodingParams::default()).is_err());
|
||||
assert!(e.open().is_err());
|
||||
assert!(e.close().is_ok());
|
||||
let frame = crate::frame::Frame::new();
|
||||
assert!(e.write_video(&frame).is_err());
|
||||
assert!(e.write_audio(&[0f32; 4], 1).is_err());
|
||||
assert!(e.write_subtitle("hi", 0.0, 1.0).is_err());
|
||||
assert!(e.flush().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `oiio_frame_to_buffer` / `oiio_buffer_to_frame` — codec-internal OIIO
|
||||
//! frame <-> pixel-buffer conversion.
|
||||
//!
|
||||
//! Mirrors `src/codec/src/oiioframebridge.{h,cpp}`. These are internal C++
|
||||
//! functions that moved into codec from oakcommon (NOTES.md §oakcommon侧修复);
|
||||
//! oakcommon keeps its OIIO mapping functions; the frame conversion itself
|
||||
//! lives here.
|
||||
//!
|
||||
//! The C++ bridge copies pixels through the live OpenImageIO `ImageBuf`
|
||||
//! (`oiio_frame_to_buffer`/`oiio_buffer_to_frame`). OIIO is not linked into
|
||||
//! this build, so the port serializes the frame into a self-describing byte
|
||||
//! buffer instead. The layout is stable (documented in
|
||||
//! [`OiioBufferHeader`]); it carries the frame's geometry, pixel format,
|
||||
//! timestamp and time base alongside the raw pixel rows, so a buffer can be
|
||||
//! turned back into an equivalent [`Frame`] without any external state.
|
||||
|
||||
use oak_common::ocioutils::PixelFormat as OakPixelFormat;
|
||||
use oak_common::videoparams::VideoParams;
|
||||
use crate::frame::Frame;
|
||||
use oak_core::Rational;
|
||||
use std::ffi::c_int;
|
||||
|
||||
/// Fixed header size, in bytes, of an OIIO frame buffer.
|
||||
///
|
||||
/// The layout is a 64-byte little-endian header followed by the raw pixel
|
||||
/// data. `OiioBufferHeader::to_bytes` / `from_bytes` are the single writer /
|
||||
/// reader of this header, so the exact offsets only ever exist in one place.
|
||||
const HEADER_LEN: usize = 64;
|
||||
|
||||
/// Magic bytes identifying an OIIO frame buffer (`"OFMB"`).
|
||||
const MAGIC: &[u8; 4] = b"OFMB";
|
||||
|
||||
/// Current serialization version.
|
||||
const VERSION: u32 = 1;
|
||||
|
||||
/// Decoded OIIO frame-buffer header.
|
||||
///
|
||||
/// # Byte layout (little-endian, `HEADER_LEN` = 64 bytes)
|
||||
///
|
||||
/// | Offset | Size | Field |
|
||||
/// |--------|------|-------|
|
||||
/// | 0 | 4 | Magic bytes `"OFMB"` |
|
||||
/// | 4 | 4 | Serialization `version` (`u32`, currently 1) |
|
||||
/// | 8 | 4 | `width` (`i32`) |
|
||||
/// | 12 | 4 | `height` (`i32`) |
|
||||
/// | 16 | 4 | `format` (`i32`, an `OakPixelFormat` value) |
|
||||
/// | 20 | 4 | `linesize_bytes` (`i32`, distance between pixel rows) |
|
||||
/// | 24 | 8 | `timestamp_num` (`i64`) |
|
||||
/// | 32 | 8 | `timestamp_den` (`i64`) |
|
||||
/// | 40 | 8 | `time_base_num` (`i64`) |
|
||||
/// | 48 | 8 | `time_base_den` (`i64`) |
|
||||
/// | 56 | 8 | `pixel_len` (`u64`, pixel-data length in bytes) |
|
||||
/// | 64 | … | raw pixel data (`pixel_len` bytes) |
|
||||
///
|
||||
/// `pixel_len` must equal `linesize_bytes * height`; the frame geometry in the
|
||||
/// header must match the params the frame is reconstructed with.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct OiioBufferHeader {
|
||||
version: u32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
format: i32,
|
||||
linesize_bytes: i32,
|
||||
timestamp_num: i64,
|
||||
timestamp_den: i64,
|
||||
time_base_num: i64,
|
||||
time_base_den: i64,
|
||||
pixel_len: u64,
|
||||
}
|
||||
|
||||
impl OiioBufferHeader {
|
||||
/// Serialize the header into exactly `HEADER_LEN` bytes (little-endian).
|
||||
fn to_bytes(&self) -> [u8; HEADER_LEN] {
|
||||
let mut b = [0u8; HEADER_LEN];
|
||||
b[0..4].copy_from_slice(MAGIC);
|
||||
b[4..8].copy_from_slice(&self.version.to_le_bytes());
|
||||
b[8..12].copy_from_slice(&self.width.to_le_bytes());
|
||||
b[12..16].copy_from_slice(&self.height.to_le_bytes());
|
||||
b[16..20].copy_from_slice(&self.format.to_le_bytes());
|
||||
b[20..24].copy_from_slice(&self.linesize_bytes.to_le_bytes());
|
||||
b[24..32].copy_from_slice(&self.timestamp_num.to_le_bytes());
|
||||
b[32..40].copy_from_slice(&self.timestamp_den.to_le_bytes());
|
||||
b[40..48].copy_from_slice(&self.time_base_num.to_le_bytes());
|
||||
b[48..56].copy_from_slice(&self.time_base_den.to_le_bytes());
|
||||
b[56..64].copy_from_slice(&self.pixel_len.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
/// Parse a header from the start of `bytes`, validating the magic and
|
||||
/// version and requiring at least `HEADER_LEN` bytes. Returns the decoded
|
||||
/// header and the number of bytes consumed (`HEADER_LEN`).
|
||||
fn from_bytes(bytes: &[u8]) -> crate::error::Result<(Self, usize)> {
|
||||
if bytes.len() < HEADER_LEN {
|
||||
return Err(crate::error::Error::Invalid);
|
||||
}
|
||||
if &bytes[0..4] != MAGIC {
|
||||
return Err(crate::error::Error::Invalid);
|
||||
}
|
||||
let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
|
||||
if version != VERSION {
|
||||
return Err(crate::error::Error::Invalid);
|
||||
}
|
||||
let header = OiioBufferHeader {
|
||||
version,
|
||||
width: i32::from_le_bytes(bytes[8..12].try_into().unwrap()),
|
||||
height: i32::from_le_bytes(bytes[12..16].try_into().unwrap()),
|
||||
format: i32::from_le_bytes(bytes[16..20].try_into().unwrap()),
|
||||
linesize_bytes: i32::from_le_bytes(bytes[20..24].try_into().unwrap()),
|
||||
timestamp_num: i64::from_le_bytes(bytes[24..32].try_into().unwrap()),
|
||||
timestamp_den: i64::from_le_bytes(bytes[32..40].try_into().unwrap()),
|
||||
time_base_num: i64::from_le_bytes(bytes[40..48].try_into().unwrap()),
|
||||
time_base_den: i64::from_le_bytes(bytes[48..56].try_into().unwrap()),
|
||||
pixel_len: u64::from_le_bytes(bytes[56..64].try_into().unwrap()),
|
||||
};
|
||||
Ok((header, HEADER_LEN))
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert an OIIO-backed `Frame` into a raw pixel buffer.
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
/// `src/codec/src/oiioframebridge.cpp` `oiio_frame_to_buffer` —
|
||||
/// allocates the destination and copies the OIIO pixel data out.
|
||||
///
|
||||
/// The buffer is a 64-byte [`OiioBufferHeader`] followed by the frame's pixel
|
||||
/// rows (see the header docs for the exact layout). The frame must already be
|
||||
/// allocated ([`Frame::allocate`]); otherwise this returns
|
||||
/// [`crate::error::Error::State`].
|
||||
pub fn oiio_frame_to_buffer(frame: &Frame) -> crate::error::Result<Vec<u8>> {
|
||||
let data = frame.data().ok_or(crate::error::Error::State)?;
|
||||
let width = frame.width();
|
||||
let height = frame.height();
|
||||
let format = frame.format() as i32;
|
||||
let linesize_bytes = frame.linesize_bytes();
|
||||
let timestamp = frame.timestamp();
|
||||
|
||||
let (time_base_num, time_base_den) = match frame.params() {
|
||||
Some(p) => {
|
||||
let (num, den) = p.time_base();
|
||||
(i64::from(num), i64::from(den))
|
||||
}
|
||||
None => (0, 0),
|
||||
};
|
||||
|
||||
let header = OiioBufferHeader {
|
||||
version: VERSION,
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
linesize_bytes,
|
||||
timestamp_num: timestamp.numerator(),
|
||||
timestamp_den: timestamp.denominator(),
|
||||
time_base_num,
|
||||
time_base_den,
|
||||
pixel_len: data.len() as u64,
|
||||
};
|
||||
|
||||
let mut out = Vec::with_capacity(HEADER_LEN + data.len());
|
||||
out.extend_from_slice(&header.to_bytes());
|
||||
out.extend_from_slice(data);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Convert a raw pixel buffer back into a `Frame`.
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
/// `src/codec/src/oiioframebridge.cpp` `oiio_buffer_to_frame` —
|
||||
/// wraps the buffer bytes in a `Frame` for encoder consumption.
|
||||
///
|
||||
/// Parses a [`OiioBufferHeader`] from the front of `buffer`, reconstructs the
|
||||
/// frame's params (geometry, time base, pixel format) and timestamp, then
|
||||
/// copies the pixel rows into an allocated frame. The buffer is rejected with
|
||||
/// [`crate::error::Error::Invalid`] when the magic/version is wrong, the
|
||||
/// header is truncated, or `pixel_len` is inconsistent with the declared
|
||||
/// `linesize_bytes * height` / the bytes actually present.
|
||||
pub fn oiio_buffer_to_frame(buffer: &[u8]) -> crate::error::Result<Frame> {
|
||||
let (header, consumed) = OiioBufferHeader::from_bytes(buffer)?;
|
||||
let pixels = &buffer[consumed..];
|
||||
|
||||
// `pixel_len` must be consistent with the header geometry and the bytes
|
||||
// actually present (checked, so a corrupt header can't panic later).
|
||||
if header.linesize_bytes < 0 || header.height < 0 {
|
||||
return Err(crate::error::Error::Invalid);
|
||||
}
|
||||
let expected = (header.linesize_bytes as u64).checked_mul(header.height as u64);
|
||||
if expected != Some(header.pixel_len) {
|
||||
return Err(crate::error::Error::Invalid);
|
||||
}
|
||||
if pixels.len() != header.pixel_len as usize {
|
||||
return Err(crate::error::Error::Invalid);
|
||||
}
|
||||
|
||||
// Build the params from the header, then hand ownership to the frame.
|
||||
let mut params = VideoParams::new_with_time_base(
|
||||
header.width,
|
||||
header.height,
|
||||
header.time_base_num as c_int,
|
||||
header.time_base_den as c_int,
|
||||
OakPixelFormat::from_code(0),
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
params.set_format(OakPixelFormat::from_code(header.format));
|
||||
let mut frame = Frame::with_params(params);
|
||||
frame.set_timestamp(Rational::new(header.timestamp_num, header.timestamp_den));
|
||||
frame.allocate()?;
|
||||
|
||||
match frame.data_mut() {
|
||||
Some(dst) if dst.len() == pixels.len() => {
|
||||
dst.copy_from_slice(pixels);
|
||||
Ok(frame)
|
||||
}
|
||||
// Reconstructed params should produce exactly `pixel_len` bytes; if
|
||||
// the geometry in the header disagreed with the format's line size,
|
||||
// refuse rather than copy a mismatched slice.
|
||||
_ => Err(crate::error::Error::Invalid),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn header_round_trip() {
|
||||
let header = OiioBufferHeader {
|
||||
version: VERSION,
|
||||
width: 100,
|
||||
height: 50,
|
||||
format: 0, // U8
|
||||
linesize_bytes: 512,
|
||||
timestamp_num: 5,
|
||||
timestamp_den: 2,
|
||||
time_base_num: 1,
|
||||
time_base_den: 30,
|
||||
pixel_len: 25600,
|
||||
};
|
||||
let bytes = header.to_bytes();
|
||||
assert_eq!(bytes.len(), HEADER_LEN);
|
||||
let (decoded, consumed) = OiioBufferHeader::from_bytes(&bytes).unwrap();
|
||||
assert_eq!(consumed, HEADER_LEN);
|
||||
assert_eq!(decoded, header);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_is_little_endian_magic_and_version() {
|
||||
let header = OiioBufferHeader {
|
||||
version: VERSION,
|
||||
width: 1,
|
||||
height: 1,
|
||||
format: 0,
|
||||
linesize_bytes: 4,
|
||||
timestamp_num: 0,
|
||||
timestamp_den: 1,
|
||||
time_base_num: 0,
|
||||
time_base_den: 0,
|
||||
pixel_len: 4,
|
||||
};
|
||||
let bytes = header.to_bytes();
|
||||
assert_eq!(&bytes[0..4], b"OFMB");
|
||||
assert_eq!(u32::from_le_bytes(bytes[4..8].try_into().unwrap()), 1);
|
||||
// width = 1 -> little-endian 01 00 00 00.
|
||||
assert_eq!(&bytes[8..12], &[1, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_bytes_rejects_truncated() {
|
||||
let header = OiioBufferHeader {
|
||||
version: VERSION,
|
||||
width: 1,
|
||||
height: 1,
|
||||
format: 0,
|
||||
linesize_bytes: 4,
|
||||
timestamp_num: 0,
|
||||
timestamp_den: 1,
|
||||
time_base_num: 0,
|
||||
time_base_den: 0,
|
||||
pixel_len: 4,
|
||||
};
|
||||
let bytes = header.to_bytes();
|
||||
assert!(OiioBufferHeader::from_bytes(&bytes[..HEADER_LEN - 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_bytes_rejects_bad_magic_and_version() {
|
||||
let mut bytes = OiioBufferHeader {
|
||||
version: VERSION,
|
||||
width: 1,
|
||||
height: 1,
|
||||
format: 0,
|
||||
linesize_bytes: 4,
|
||||
timestamp_num: 0,
|
||||
timestamp_den: 1,
|
||||
time_base_num: 0,
|
||||
time_base_den: 0,
|
||||
pixel_len: 4,
|
||||
}
|
||||
.to_bytes();
|
||||
bytes[0] = b'X';
|
||||
assert!(OiioBufferHeader::from_bytes(&bytes).is_err());
|
||||
|
||||
let mut bytes = OiioBufferHeader {
|
||||
version: VERSION,
|
||||
width: 1,
|
||||
height: 1,
|
||||
format: 0,
|
||||
linesize_bytes: 4,
|
||||
timestamp_num: 0,
|
||||
timestamp_den: 1,
|
||||
time_base_num: 0,
|
||||
time_base_den: 0,
|
||||
pixel_len: 4,
|
||||
}
|
||||
.to_bytes();
|
||||
bytes[4..8].copy_from_slice(&99u32.to_le_bytes());
|
||||
assert!(OiioBufferHeader::from_bytes(&bytes).is_err());
|
||||
}
|
||||
|
||||
/// A small helper to build a fully allocated, filled frame using the same
|
||||
/// params pattern as `frame.rs`.
|
||||
fn make_frame() -> Frame {
|
||||
let mut params = VideoParams::new_with_time_base(
|
||||
100,
|
||||
50,
|
||||
1,
|
||||
30,
|
||||
OakPixelFormat::from_code(0),
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
params.set_format(OakPixelFormat::from_code(0)); // U8
|
||||
let mut frame = Frame::with_params(params);
|
||||
frame.set_timestamp(Rational::new(5, 2));
|
||||
frame.allocate().unwrap();
|
||||
frame
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_to_buffer_round_trip() {
|
||||
let mut frame = make_frame();
|
||||
if let Some(d) = frame.data_mut() {
|
||||
d.fill(0xAB);
|
||||
}
|
||||
|
||||
let buffer = oiio_frame_to_buffer(&frame).unwrap();
|
||||
assert_eq!(buffer.len(), HEADER_LEN + frame.allocated_size());
|
||||
|
||||
let (header, consumed) = OiioBufferHeader::from_bytes(&buffer).unwrap();
|
||||
assert_eq!(consumed, HEADER_LEN);
|
||||
assert_eq!(header.width, 100);
|
||||
assert_eq!(header.height, 50);
|
||||
assert_eq!(header.format, 0);
|
||||
assert_eq!(header.linesize_bytes, 512);
|
||||
assert_eq!(header.timestamp_num, 5);
|
||||
assert_eq!(header.timestamp_den, 2);
|
||||
assert_eq!(header.time_base_num, 1);
|
||||
assert_eq!(header.time_base_den, 30);
|
||||
assert_eq!(header.pixel_len, frame.allocated_size() as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_to_frame_round_trip() {
|
||||
let mut frame = make_frame();
|
||||
let expected_len = frame.allocated_size();
|
||||
if let Some(d) = frame.data_mut() {
|
||||
for (i, b) in d.iter_mut().enumerate() {
|
||||
*b = (i % 256) as u8;
|
||||
}
|
||||
}
|
||||
let buffer = oiio_frame_to_buffer(&frame).unwrap();
|
||||
|
||||
let out = oiio_buffer_to_frame(&buffer).unwrap();
|
||||
assert_eq!(out.width(), 100);
|
||||
assert_eq!(out.height(), 50);
|
||||
assert_eq!(out.format() as i32, 0);
|
||||
assert_eq!(out.allocated_size(), expected_len);
|
||||
assert_eq!(out.timestamp().numerator(), 5);
|
||||
assert_eq!(out.timestamp().denominator(), 2);
|
||||
assert_eq!(out.data().unwrap(), frame.data().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_to_frame_rejects_corrupt() {
|
||||
let frame = make_frame();
|
||||
let buffer = oiio_frame_to_buffer(&frame).unwrap();
|
||||
|
||||
// Truncated pixel data.
|
||||
assert!(oiio_buffer_to_frame(&buffer[..HEADER_LEN + 1]).is_err());
|
||||
// Bad magic.
|
||||
let mut bad = buffer.clone();
|
||||
bad[0] = 0;
|
||||
assert!(oiio_buffer_to_frame(&bad).is_err());
|
||||
// pixel_len inconsistent with linesize * height.
|
||||
let mut bad = buffer.clone();
|
||||
bad[56..64].copy_from_slice(&(999u64).to_le_bytes());
|
||||
assert!(oiio_buffer_to_frame(&bad).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `olive::PlanarFileDevice` — planar (per-channel) file read/write.
|
||||
//!
|
||||
//! Mirrors `src/codec/src/planarfiledevice.h`. Reads/writes one FILE* per
|
||||
//! channel so multi-channel audio is stored as one planar file per channel.
|
||||
//! `FILE*` implementation (the QIODevice-based original was replaced).
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Open mode (replaces QIODevice::OpenMode).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum OpenMode {
|
||||
/// Read-only.
|
||||
ReadOnly = 0,
|
||||
/// Write-only.
|
||||
WriteOnly = 1,
|
||||
}
|
||||
|
||||
/// `olive::PlanarFileDevice` — one file per channel.
|
||||
pub struct PlanarFileDevice {
|
||||
/// Open file handles, one per channel.
|
||||
files: Vec<File>,
|
||||
/// Open mode.
|
||||
mode: OpenMode,
|
||||
}
|
||||
|
||||
impl PlanarFileDevice {
|
||||
/// New, closed device.
|
||||
pub fn new() -> Self {
|
||||
PlanarFileDevice {
|
||||
files: Vec::new(),
|
||||
mode: OpenMode::ReadOnly,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the device is open.
|
||||
pub fn is_open(&self) -> bool {
|
||||
!self.files.is_empty()
|
||||
}
|
||||
|
||||
/// Open `filenames` (one per channel) in `mode`. Returns false if already
|
||||
/// open or any file could not be opened (closing any opened so far).
|
||||
pub fn open(&mut self, filenames: &[PathBuf], mode: OpenMode) -> bool {
|
||||
if self.is_open() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut opened = Vec::with_capacity(filenames.len());
|
||||
for name in filenames {
|
||||
let mut opt = OpenOptions::new();
|
||||
let f = match mode {
|
||||
OpenMode::ReadOnly => opt.read(true).open(name),
|
||||
OpenMode::WriteOnly => opt.create(true).write(true).truncate(true).open(name),
|
||||
};
|
||||
match f {
|
||||
Ok(f) => opened.push(f),
|
||||
Err(_) => {
|
||||
// Roll back: close anything opened so far and report failure.
|
||||
self.files = opened;
|
||||
self.close();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.files = opened;
|
||||
self.mode = mode;
|
||||
true
|
||||
}
|
||||
|
||||
/// Read `bytes_per_channel` bytes from each channel (at the current file
|
||||
/// position) into `data[i][offset..]`. Returns bytes read per channel, or
|
||||
/// -1 if closed or a buffer is too small.
|
||||
pub fn read(&mut self, data: &mut [&mut [u8]], bytes_per_channel: i64, offset: i64) -> i64 {
|
||||
if !self.is_open() {
|
||||
return -1;
|
||||
}
|
||||
let bytes = bytes_per_channel as usize;
|
||||
let off = offset as usize;
|
||||
let mut ret = -1i64;
|
||||
for (i, f) in self.files.iter_mut().enumerate() {
|
||||
let buf = match data.get_mut(i) {
|
||||
Some(b) if b.len() >= off + bytes => &mut b[off..off + bytes],
|
||||
_ => return -1,
|
||||
};
|
||||
ret = f.read(buf).unwrap_or(0) as i64;
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
/// Write `bytes_per_channel` bytes to each channel from `data[i][offset..]`.
|
||||
/// Returns bytes written per channel, or -1.
|
||||
pub fn write(&mut self, data: &[&[u8]], bytes_per_channel: i64, offset: i64) -> i64 {
|
||||
if !self.is_open() {
|
||||
return -1;
|
||||
}
|
||||
let bytes = bytes_per_channel as usize;
|
||||
let off = offset as usize;
|
||||
let mut ret = -1i64;
|
||||
for (i, f) in self.files.iter_mut().enumerate() {
|
||||
let buf = match data.get(i) {
|
||||
Some(b) if b.len() >= off + bytes => &b[off..off + bytes],
|
||||
_ => return -1,
|
||||
};
|
||||
ret = f.write(buf).unwrap_or(0) as i64;
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
/// Total size in bytes of one channel (from the first open file).
|
||||
pub fn size(&self) -> i64 {
|
||||
if self.is_open() {
|
||||
if let Ok(meta) = self.files[0].metadata() {
|
||||
return meta.len() as i64;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Seek all channels to `pos`.
|
||||
pub fn seek(&mut self, pos: i64) -> bool {
|
||||
let mut ok = true;
|
||||
for f in self.files.iter_mut() {
|
||||
ok = f.seek(SeekFrom::Start(pos as u64)).is_ok() && ok;
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// Close all channels.
|
||||
pub fn close(&mut self) {
|
||||
self.files.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PlanarFileDevice {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("oakcodec_planar_{}_{}", name, std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_device_is_closed() {
|
||||
let mut d = PlanarFileDevice::new();
|
||||
assert!(!d.is_open());
|
||||
assert_eq!(d.size(), 0);
|
||||
assert_eq!(d.read(&mut [&mut [0u8; 4]], 4, 0), -1);
|
||||
assert_eq!(d.write(&[&[0u8; 4]], 4, 0), -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_read_roundtrip() {
|
||||
let dir = temp_dir("rw");
|
||||
let names: Vec<PathBuf> = (0..2).map(|i| dir.join(format!("ch{}.pcm", i))).collect();
|
||||
|
||||
let mut d = PlanarFileDevice::new();
|
||||
assert!(d.open(&names, OpenMode::WriteOnly));
|
||||
assert!(d.is_open());
|
||||
assert_eq!(d.mode, OpenMode::WriteOnly);
|
||||
|
||||
// Two channels, 4 bytes each.
|
||||
let ch0 = [1u8, 2, 3, 4];
|
||||
let ch1 = [9u8, 8, 7, 6];
|
||||
assert_eq!(d.write(&[&ch0, &ch1], 4, 0), 4);
|
||||
assert_eq!(d.size(), 4);
|
||||
d.close();
|
||||
assert!(!d.is_open());
|
||||
|
||||
// A "wb" handle cannot be read back (matching the C++ fopen mode);
|
||||
// reopen read-only to verify the written bytes.
|
||||
let mut d = PlanarFileDevice::new();
|
||||
assert!(d.open(&names, OpenMode::ReadOnly));
|
||||
assert_eq!(d.size(), 4);
|
||||
let mut out0 = [0u8; 8];
|
||||
let mut out1 = [0u8; 8];
|
||||
assert_eq!(d.read(&mut [&mut out0[..], &mut out1[..]], 4, 0), 4);
|
||||
assert_eq!(&out0[..4], &ch0);
|
||||
assert_eq!(&out1[..4], &ch1);
|
||||
d.close();
|
||||
assert!(!d.is_open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_writes_and_reads() {
|
||||
let dir = temp_dir("off");
|
||||
let names = vec![dir.join("ch.pcm")];
|
||||
let mut d = PlanarFileDevice::new();
|
||||
assert!(d.open(&names, OpenMode::WriteOnly));
|
||||
|
||||
// The offset is a *buffer* offset (C++ `data[i] + offset`); file
|
||||
// offsets go through `seek`. Seek to 4 then write 4 bytes, leaving
|
||||
// a 4-byte gap.
|
||||
let data = [7u8, 7, 7, 7];
|
||||
assert!(d.seek(4));
|
||||
assert_eq!(d.write(&[&data], 4, 0), 4);
|
||||
assert_eq!(d.size(), 8);
|
||||
|
||||
d.close();
|
||||
let mut d = PlanarFileDevice::new();
|
||||
assert!(d.open(&names, OpenMode::ReadOnly));
|
||||
let mut buf = [0u8; 8];
|
||||
assert!(d.seek(0));
|
||||
assert_eq!(d.read(&mut [&mut buf[..]], 8, 0), 8);
|
||||
assert_eq!(&buf[..4], &[0, 0, 0, 0]);
|
||||
assert_eq!(&buf[4..], &data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_rollback_on_missing_file_and_double_open() {
|
||||
let dir = temp_dir("roll");
|
||||
let ok = dir.join("ok.pcm");
|
||||
let missing = dir.join("missing.pcm");
|
||||
let mut d = PlanarFileDevice::new();
|
||||
assert!(!d.open(&[ok.clone(), missing], OpenMode::ReadOnly));
|
||||
assert!(!d.is_open());
|
||||
|
||||
// Already open -> refuse.
|
||||
assert!(d.open(&[ok.clone()], OpenMode::WriteOnly));
|
||||
assert!(!d.open(&[ok], OpenMode::WriteOnly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_reject_small_buffers() {
|
||||
let dir = temp_dir("small");
|
||||
let names = vec![dir.join("ch.pcm")];
|
||||
let mut d = PlanarFileDevice::new();
|
||||
assert!(d.open(&names, OpenMode::WriteOnly));
|
||||
let data = [1u8; 8];
|
||||
assert_eq!(d.write(&[&data], 8, 0), 8);
|
||||
|
||||
// Buffer too small for the requested bytes.
|
||||
let mut small = [0u8; 4];
|
||||
assert_eq!(d.write(&[&small], 8, 0), -1);
|
||||
assert!(d.seek(0));
|
||||
assert_eq!(d.read(&mut [&mut small[..]], 8, 0), -1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `olive::ProxyManager` — proxy (low-res transcode) generation.
|
||||
//!
|
||||
//! Mirrors `src/codec/src/proxymanager.h`. Stateless (NOTES.md): actual
|
||||
//! transcodes are delegated to the global task submit callback
|
||||
//! ([`crate::task`]); with no registrar, `get_or_start` reports the proxy
|
||||
//! as missing. `proxy_params_from_config` reads the oakcommon config store
|
||||
//! with the compiled-in defaults as fallback (1280x720 / divider 1 / crf 23
|
||||
//! / "mp4" / "veryfast" / audio included).
|
||||
|
||||
use oak_common::configstore::ConfigStore;
|
||||
use oak_common::filefunctions::FileFunctions;
|
||||
use std::path::Path;
|
||||
|
||||
/// Proxy state of a proxy file on disk.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum ProxyState {
|
||||
/// Missing (or NULL/empty/absent).
|
||||
Missing = 0,
|
||||
/// Generating.
|
||||
Generating = 1,
|
||||
/// Ready on disk.
|
||||
Ready = 2,
|
||||
/// Generation failed.
|
||||
Failed = 3,
|
||||
}
|
||||
|
||||
impl TryFrom<i32> for ProxyState {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: i32) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0 => Ok(ProxyState::Missing),
|
||||
1 => Ok(ProxyState::Generating),
|
||||
2 => Ok(ProxyState::Ready),
|
||||
3 => Ok(ProxyState::Failed),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `olive::ProxyManager::ProxyParams` — mirror of `oakcodec_proxy_params`.
|
||||
#[derive(Clone, Debug)]
|
||||
#[repr(C)]
|
||||
pub struct ProxyParams {
|
||||
/// Absolute target width (0 when divider-based).
|
||||
pub width: i32,
|
||||
/// Absolute target height (0 when divider-based).
|
||||
pub height: i32,
|
||||
/// Source resolution divider (1 = absolute width/height, 2/4/8).
|
||||
pub divider: i32,
|
||||
/// Proxy format version.
|
||||
pub version: i32,
|
||||
/// x264 crf.
|
||||
pub crf: i32,
|
||||
/// Include the audio track (1/0; C `int`).
|
||||
pub include_audio: i32,
|
||||
/// ffmpeg output container (e.g. "mp4").
|
||||
pub extension: [u8; 32],
|
||||
/// ffmpeg encoder preset (e.g. "veryfast").
|
||||
pub preset: [u8; 32],
|
||||
}
|
||||
|
||||
impl Default for ProxyParams {
|
||||
fn default() -> Self {
|
||||
ProxyParams {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
divider: 1,
|
||||
version: 1,
|
||||
crf: 23,
|
||||
include_audio: 1,
|
||||
extension: bytes32(b"mp4"),
|
||||
preset: bytes32(b"veryfast"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyParams {
|
||||
fn extension_str(&self) -> &str {
|
||||
cstr_slice(&self.extension)
|
||||
}
|
||||
|
||||
fn preset_str(&self) -> &str {
|
||||
cstr_slice(&self.preset)
|
||||
}
|
||||
}
|
||||
|
||||
/// `oakcodec_proxy_result` — POD result of [`ProxyManager::get_or_start`];
|
||||
/// see `include/codec/proxy.h`.
|
||||
#[repr(C)]
|
||||
pub struct OakCodecProxyResult {
|
||||
/// `ProxyState` value.
|
||||
pub state: i32,
|
||||
/// Resulting proxy filename (may be empty).
|
||||
pub filename: [u8; 1024],
|
||||
}
|
||||
|
||||
/// `olive::ProxyManager` — stateless proxy query/generate manager.
|
||||
pub struct ProxyManager;
|
||||
|
||||
impl ProxyManager {
|
||||
/// The process-wide ProxyManager singleton.
|
||||
pub fn instance() -> &'static ProxyManager {
|
||||
static INSTANCE: ProxyManager = ProxyManager;
|
||||
&INSTANCE
|
||||
}
|
||||
|
||||
/// Compiled-in default proxy parameters (the `Default` values).
|
||||
pub fn proxy_params_default() -> ProxyParams {
|
||||
ProxyParams::default()
|
||||
}
|
||||
|
||||
/// Proxy parameters read from the oakcommon config, with the compiled-in
|
||||
/// defaults as fallback.
|
||||
///
|
||||
/// # CPP-PARITY
|
||||
/// `src/codec/src/proxymanager.h` `proxy_params_from_config` — reads
|
||||
/// ProxyWidth/ProxyHeight/ProxyDivider/ProxyCRF/ProxyPreset/
|
||||
/// ProxyIncludeAudio via `oakcommon_config_*`.
|
||||
pub fn proxy_params_from_config() -> ProxyParams {
|
||||
let mut p = ProxyParams::default();
|
||||
p.width = config_get_int("ProxyWidth", p.width);
|
||||
p.height = config_get_int("ProxyHeight", p.height);
|
||||
p.divider = config_get_int("ProxyDivider", p.divider);
|
||||
p.crf = config_get_int("ProxyCRF", p.crf);
|
||||
p.include_audio = if config_get_bool("ProxyIncludeAudio", p.include_audio) != 0 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if let Some(preset) = config_get_str("ProxyPreset") {
|
||||
if !preset.is_empty() {
|
||||
p.preset = bytes32(preset.as_bytes());
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// State of a proxy file on disk.
|
||||
pub fn get_proxy_state(proxy_filename: &str) -> ProxyState {
|
||||
if proxy_filename.is_empty() {
|
||||
return ProxyState::Missing;
|
||||
}
|
||||
if Path::new(proxy_filename).exists() {
|
||||
return ProxyState::Ready;
|
||||
}
|
||||
let working = Self::get_working_filename(proxy_filename);
|
||||
if let Ok(w) = working {
|
||||
if Path::new(&w).exists() {
|
||||
return ProxyState::Generating;
|
||||
}
|
||||
}
|
||||
ProxyState::Missing
|
||||
}
|
||||
|
||||
/// Human-readable string for a proxy state.
|
||||
pub fn proxy_state_to_string(state: ProxyState) -> String {
|
||||
match state {
|
||||
ProxyState::Missing => "missing".to_string(),
|
||||
ProxyState::Generating => "generating".to_string(),
|
||||
ProxyState::Ready => "ready".to_string(),
|
||||
ProxyState::Failed => "failed".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The proxy state a string name stands for (`Missing` for anything
|
||||
/// unrecognized; also accepts the numeric form the Rust serializer used
|
||||
/// before the string form landed).
|
||||
pub fn proxy_state_from_string(name: &str) -> ProxyState {
|
||||
match name.trim() {
|
||||
"generating" | "1" => ProxyState::Generating,
|
||||
"ready" | "2" => ProxyState::Ready,
|
||||
"failed" | "3" => ProxyState::Failed,
|
||||
_ => ProxyState::Missing,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a proxy filename was generated with an audio track (the
|
||||
/// generation tags audio-including proxies `.a1.`).
|
||||
pub fn proxy_filename_has_audio(proxy_filename: &str) -> bool {
|
||||
std::path::Path::new(proxy_filename)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().contains(".a1."))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Proxy directory for a project cache path.
|
||||
pub fn get_proxy_directory(cache_path: &str) -> crate::error::Result<String> {
|
||||
Ok(Path::new(cache_path)
|
||||
.join("proxy")
|
||||
.to_string_lossy()
|
||||
.into_owned())
|
||||
}
|
||||
|
||||
/// Deterministic proxy filename for a source stream.
|
||||
pub fn get_proxy_filename(
|
||||
cache_path: &str,
|
||||
source_filename: &str,
|
||||
stream_index: i32,
|
||||
params: &ProxyParams,
|
||||
) -> crate::error::Result<String> {
|
||||
let proxy_dir = Self::get_proxy_directory(cache_path)?;
|
||||
let extension = if params.extension_str().is_empty() {
|
||||
"mp4"
|
||||
} else {
|
||||
params.extension_str()
|
||||
};
|
||||
|
||||
// Divider mode scales relative to the source, so the tag names the
|
||||
// divider rather than an absolute target size.
|
||||
let size_tag = if params.divider > 1 {
|
||||
format!("div{}", params.divider)
|
||||
} else {
|
||||
format!("{}x{}", params.width, params.height)
|
||||
};
|
||||
|
||||
let filename = format!(
|
||||
"{}-{}.{}.v{}.a{}.{}",
|
||||
unique_file_identifier(source_filename),
|
||||
stream_index,
|
||||
size_tag,
|
||||
params.version,
|
||||
params.include_audio,
|
||||
extension,
|
||||
);
|
||||
|
||||
Ok(Path::new(&proxy_dir)
|
||||
.join(filename)
|
||||
.to_string_lossy()
|
||||
.into_owned())
|
||||
}
|
||||
|
||||
/// Working (in-progress) filename of a proxy.
|
||||
pub fn get_working_filename(proxy_filename: &str) -> crate::error::Result<String> {
|
||||
// Append a recognizable suffix while keeping a standard container
|
||||
// extension so ffmpeg can infer the output format.
|
||||
Ok(format!("{}.working.mp4", proxy_filename))
|
||||
}
|
||||
|
||||
/// Get or start generating a proxy for `source_filename`. With no task
|
||||
/// registrar the state stays `Missing`.
|
||||
pub fn get_or_start(
|
||||
&self,
|
||||
cache_path: &str,
|
||||
source_filename: &str,
|
||||
stream_index: i32,
|
||||
params: &ProxyParams,
|
||||
) -> crate::error::Result<(ProxyState, String)> {
|
||||
let filename = Self::get_proxy_filename(cache_path, source_filename, stream_index, params)?;
|
||||
let file_state = Self::get_proxy_state(&filename);
|
||||
if file_state == ProxyState::Ready {
|
||||
return Ok((ProxyState::Ready, filename));
|
||||
}
|
||||
|
||||
if !crate::task::task_submit_is_registered() {
|
||||
// Interim state (pre-M8): no task system, proxy cannot be generated.
|
||||
return Ok((ProxyState::Missing, filename));
|
||||
}
|
||||
|
||||
if file_state == ProxyState::Generating {
|
||||
// Stale working file from an interrupted run.
|
||||
if let Ok(working) = Self::get_working_filename(&filename) {
|
||||
let _ = std::fs::remove_file(&working);
|
||||
}
|
||||
}
|
||||
|
||||
// The task owns the ".working.mp4" temporary name and the rename to the
|
||||
// final filename on success.
|
||||
let req = crate::task::TaskRequest {
|
||||
kind: crate::task::TaskKind::Proxy,
|
||||
input_filename: source_filename,
|
||||
output_filename: &filename,
|
||||
stream_index,
|
||||
sample_rate: 0,
|
||||
channel_layout: 0,
|
||||
sample_format: 0,
|
||||
proxy_width: if params.divider <= 1 { params.width } else { 0 },
|
||||
proxy_height: if params.divider <= 1 {
|
||||
params.height
|
||||
} else {
|
||||
0
|
||||
},
|
||||
};
|
||||
|
||||
// Interim simplification: submission is synchronous.
|
||||
if crate::task::submit_task(&req).is_err() {
|
||||
return Ok((ProxyState::Failed, filename));
|
||||
}
|
||||
|
||||
if Self::get_proxy_state(&filename) == ProxyState::Ready {
|
||||
return Ok((ProxyState::Ready, filename));
|
||||
}
|
||||
|
||||
Ok((ProxyState::Generating, filename))
|
||||
}
|
||||
|
||||
/// Locate an ffmpeg executable (empty string when none found).
|
||||
pub fn find_ffmpeg(configured_path: &str) -> String {
|
||||
// An explicitly configured path takes precedence if it is usable.
|
||||
if !configured_path.is_empty() {
|
||||
if is_executable_file(Path::new(configured_path)) {
|
||||
return absolute(configured_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to searching the system PATH (split per platform:
|
||||
// `;` on Windows, `:` elsewhere — see [`split_path_env`]).
|
||||
if let Ok(path_env) = std::env::var("PATH") {
|
||||
for dir in split_path_env(&path_env) {
|
||||
let candidate = Path::new(&dir).join(ffmpeg_exe_name());
|
||||
if is_executable_file(&candidate) {
|
||||
return absolute(&candidate.to_string_lossy());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, try common install locations (PATH on GUI-launched apps,
|
||||
// particularly on macOS, often lacks these).
|
||||
let mut candidates: Vec<String> = Vec::new();
|
||||
let app_path = application_path();
|
||||
if !app_path.is_empty() {
|
||||
candidates.push(format!("{}/{}", app_path, ffmpeg_exe_name()));
|
||||
}
|
||||
candidates.push("/opt/homebrew/bin/ffmpeg".to_string());
|
||||
candidates.push("/usr/local/bin/ffmpeg".to_string());
|
||||
candidates.push("/usr/bin/ffmpeg".to_string());
|
||||
candidates.push("/usr/local/bin/ffmpeg".to_string());
|
||||
|
||||
// Windows-specific install locations (GUI-launched apps can also
|
||||
// start with a minimal PATH there).
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// The official Windows installer defaults to
|
||||
// %LOCALAPPDATA%\Programs\ffmpeg\bin.
|
||||
if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
|
||||
candidates.push(format!(
|
||||
"{}/Programs/ffmpeg/bin/{}",
|
||||
local_app_data,
|
||||
ffmpeg_exe_name()
|
||||
));
|
||||
}
|
||||
// Oak's user configuration directory.
|
||||
if let Ok(config_dir) = FileFunctions::new().get_configuration_location() {
|
||||
candidates.push(format!("{}/{}", config_dir, ffmpeg_exe_name()));
|
||||
}
|
||||
}
|
||||
|
||||
for c in candidates {
|
||||
if is_executable_file(Path::new(&c)) {
|
||||
return absolute(&c);
|
||||
}
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a byte string into a NUL-terminated `[u8; 32]` (truncated to 31
|
||||
/// chars so there is always a trailing NUL).
|
||||
fn bytes32(s: &[u8]) -> [u8; 32] {
|
||||
let mut a = [0u8; 32];
|
||||
let n = s.len().min(31);
|
||||
a[..n].copy_from_slice(&s[..n]);
|
||||
a
|
||||
}
|
||||
|
||||
/// View a NUL-terminated `[u8; 32]` as a `&str` (up to the first NUL).
|
||||
fn cstr_slice(a: &[u8; 32]) -> &str {
|
||||
let end = a.iter().position(|&b| b == 0).unwrap_or(a.len());
|
||||
std::str::from_utf8(&a[..end]).unwrap_or("")
|
||||
}
|
||||
|
||||
/// `oakcommon_config_get_int` wrapper (null group).
|
||||
fn config_get_int(key: &str, default: i32) -> i32 {
|
||||
ConfigStore::instance().get_int(None, key, default)
|
||||
}
|
||||
|
||||
/// `oakcommon_config_get_bool` wrapper (null group).
|
||||
fn config_get_bool(key: &str, default: i32) -> i32 {
|
||||
ConfigStore::instance().get_bool(None, key, default)
|
||||
}
|
||||
|
||||
/// `oakcommon_config_get` string read; `None` when the stored value is
|
||||
/// empty or absent.
|
||||
fn config_get_str(key: &str) -> Option<String> {
|
||||
match ConfigStore::instance().get(None, key) {
|
||||
Ok(s) if !s.is_empty() => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `oakcommon_filefunctions_get_unique_file_identifier` wrapper.
|
||||
fn unique_file_identifier(filename: &str) -> String {
|
||||
FileFunctions::new()
|
||||
.get_unique_file_identifier(filename)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `oakcommon_filefunctions_get_application_path` read.
|
||||
fn application_path() -> String {
|
||||
FileFunctions::new()
|
||||
.get_application_path()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Split a PATH-style variable into its non-empty directory entries. The
|
||||
/// separator is platform-specific (`;` on Windows, `:` elsewhere);
|
||||
/// `std::env::split_paths` already applies that rule natively.
|
||||
fn split_path_env(raw: &str) -> Vec<String> {
|
||||
std::env::split_paths(raw)
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The executable file name to look for: `ffmpeg.exe` on Windows,
|
||||
/// `ffmpeg` everywhere else.
|
||||
fn ffmpeg_exe_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"ffmpeg.exe"
|
||||
} else {
|
||||
"ffmpeg"
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `p` is a regular file that can be run as a program. Unix
|
||||
/// checks for at least one execute bit; Windows has no permission bits, so
|
||||
/// any regular file counts.
|
||||
fn is_executable_file(p: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
match std::fs::metadata(p) {
|
||||
Ok(md) if md.is_file() => md.permissions().mode() & 0o111 != 0,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
matches!(std::fs::metadata(p), Ok(md) if md.is_file())
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonicalize a path, falling back to the raw string on failure.
|
||||
fn absolute(p: &str) -> String {
|
||||
std::fs::canonicalize(p)
|
||||
.map(|c| c.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|_| p.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_subdir(name: &str) -> String {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("oakcodec_proxy_{}_{}", name, std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
dir.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn proxy_params_default_values() {
|
||||
let p = ProxyManager::proxy_params_default();
|
||||
assert_eq!(p.width, 1280);
|
||||
assert_eq!(p.height, 720);
|
||||
assert_eq!(p.divider, 1);
|
||||
assert_eq!(p.version, 1);
|
||||
assert_eq!(p.crf, 23);
|
||||
assert_eq!(p.include_audio, 1);
|
||||
assert_eq!(p.extension_str(), "mp4");
|
||||
assert_eq!(p.preset_str(), "veryfast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_params_from_config_uses_defaults_without_store() {
|
||||
// The test stub returns defaults for every int/bool and an empty
|
||||
// ProxyPreset; the empty preset must not clobber the compiled-in one.
|
||||
let p = ProxyManager::proxy_params_from_config();
|
||||
assert_eq!(p.width, 1280);
|
||||
assert_eq!(p.height, 720);
|
||||
assert_eq!(p.divider, 1);
|
||||
assert_eq!(p.crf, 23);
|
||||
assert_eq!(p.include_audio, 1);
|
||||
assert_eq!(p.preset_str(), "veryfast");
|
||||
assert_eq!(p.extension_str(), "mp4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_directory_is_cache_slash_proxy() {
|
||||
assert_eq!(
|
||||
ProxyManager::get_proxy_directory("/tmp/cache").unwrap(),
|
||||
// Path::join separators are platform-native (\ on Windows).
|
||||
std::path::Path::new("/tmp/cache")
|
||||
.join("proxy")
|
||||
.to_string_lossy()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_filename_derivation() {
|
||||
let cache = temp_subdir("fn");
|
||||
let p = ProxyManager::proxy_params_default();
|
||||
|
||||
// A missing source carries no unique-file identifier
|
||||
// (get_unique_file_identifier is empty for non-existent files —
|
||||
// C++ parity), so the name is the plain size/version/audio tags.
|
||||
let missing = std::path::Path::new(&temp_subdir("missing")).join("nope.mp4");
|
||||
let f = ProxyManager::get_proxy_filename(&cache, missing.to_str().unwrap(), 0, &p).unwrap();
|
||||
let plain = std::path::Path::new(&cache)
|
||||
.join("proxy")
|
||||
.join("-0.1280x720.v1.a1.mp4")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
assert_eq!(f, plain);
|
||||
|
||||
// An existing source embeds a stable per-file identifier.
|
||||
let existing = std::path::Path::new(&temp_subdir("existing")).join("real.mp4");
|
||||
std::fs::write(&existing, b"media").unwrap();
|
||||
let f1 =
|
||||
ProxyManager::get_proxy_filename(&cache, existing.to_str().unwrap(), 0, &p).unwrap();
|
||||
let f2 =
|
||||
ProxyManager::get_proxy_filename(&cache, existing.to_str().unwrap(), 0, &p).unwrap();
|
||||
assert!(
|
||||
f1.contains("-0.1280x720.v1.a1.mp4"),
|
||||
"size/version/audio tags present: {f1}"
|
||||
);
|
||||
assert!(f1 != plain, "id embedded: {f1}");
|
||||
assert_eq!(f1, f2, "the identifier is stable for the same file");
|
||||
|
||||
// Divider mode tags the divider instead of an absolute size.
|
||||
let mut d = p.clone();
|
||||
d.divider = 2;
|
||||
let f3 = ProxyManager::get_proxy_filename(&cache, missing.to_str().unwrap(), 0, &d).unwrap();
|
||||
assert!(f3.contains(".div2."));
|
||||
|
||||
// No audio.
|
||||
let mut na = p.clone();
|
||||
na.include_audio = 0;
|
||||
let f4 = ProxyManager::get_proxy_filename(&cache, missing.to_str().unwrap(), 0, &na).unwrap();
|
||||
assert!(f4.contains(".a0."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_state_transitions() {
|
||||
let cache = temp_subdir("state");
|
||||
let p = ProxyManager::proxy_params_default();
|
||||
let f = ProxyManager::get_proxy_filename(&cache, "media.mp4", 0, &p).unwrap();
|
||||
|
||||
// Missing when neither the file nor the working file exists.
|
||||
assert_eq!(ProxyManager::get_proxy_state(&f), ProxyState::Missing);
|
||||
|
||||
// Generating when only the working file exists.
|
||||
let working = ProxyManager::get_working_filename(&f).unwrap();
|
||||
std::fs::create_dir_all(Path::new(&working).parent().unwrap()).unwrap();
|
||||
std::fs::write(&working, b"x").unwrap();
|
||||
assert_eq!(ProxyManager::get_proxy_state(&f), ProxyState::Generating);
|
||||
|
||||
// Ready when the final file exists (takes precedence over working).
|
||||
std::fs::create_dir_all(Path::new(&f).parent().unwrap()).unwrap();
|
||||
std::fs::write(&f, b"x").unwrap();
|
||||
assert_eq!(ProxyManager::get_proxy_state(&f), ProxyState::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_state_to_string_mapping() {
|
||||
assert_eq!(
|
||||
ProxyManager::proxy_state_to_string(ProxyState::Missing),
|
||||
"missing"
|
||||
);
|
||||
assert_eq!(
|
||||
ProxyManager::proxy_state_to_string(ProxyState::Generating),
|
||||
"generating"
|
||||
);
|
||||
assert_eq!(
|
||||
ProxyManager::proxy_state_to_string(ProxyState::Ready),
|
||||
"ready"
|
||||
);
|
||||
assert_eq!(
|
||||
ProxyManager::proxy_state_to_string(ProxyState::Failed),
|
||||
"failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_working_filename_appends_suffix() {
|
||||
assert_eq!(
|
||||
ProxyManager::get_working_filename("/a/b.mp4").unwrap(),
|
||||
"/a/b.mp4.working.mp4"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_or_start_missing_without_registrar() {
|
||||
let _g = crate::conformmanager::test_util::REG_LOCK.lock().unwrap();
|
||||
crate::task::set_task_submit_cb_extern(None, std::ptr::null_mut());
|
||||
let cache = temp_subdir("nostart");
|
||||
let p = ProxyManager::proxy_params_default();
|
||||
let (state, _f) = ProxyManager::instance()
|
||||
.get_or_start(&cache, "media.mp4", 0, &p)
|
||||
.unwrap();
|
||||
assert_eq!(state, ProxyState::Missing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_or_start_ready_when_file_exists() {
|
||||
let cache = temp_subdir("ready");
|
||||
let p = ProxyManager::proxy_params_default();
|
||||
let f = ProxyManager::get_proxy_filename(&cache, "media.mp4", 0, &p).unwrap();
|
||||
std::fs::create_dir_all(Path::new(&f).parent().unwrap()).unwrap();
|
||||
std::fs::write(&f, b"x").unwrap();
|
||||
let (state, filename) = ProxyManager::instance()
|
||||
.get_or_start(&cache, "media.mp4", 0, &p)
|
||||
.unwrap();
|
||||
assert_eq!(state, ProxyState::Ready);
|
||||
assert_eq!(filename, f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_or_start_generating_when_registered() {
|
||||
let _g = crate::conformmanager::test_util::REG_LOCK.lock().unwrap();
|
||||
crate::task::set_task_submit_cb_extern(
|
||||
Some(crate::conformmanager::test_util::accept_cb),
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
let cache = temp_subdir("start");
|
||||
let p = ProxyManager::proxy_params_default();
|
||||
let (state, _f) = ProxyManager::instance()
|
||||
.get_or_start(&cache, "media.mp4", 0, &p)
|
||||
.unwrap();
|
||||
crate::task::set_task_submit_cb_extern(None, std::ptr::null_mut());
|
||||
assert_eq!(state, ProxyState::Generating);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ffmpeg_configured_path_wins() {
|
||||
// Point at a real executable (the current test binary) so the
|
||||
// configured-path branch resolves to an absolute path.
|
||||
let me = std::env::current_exe().unwrap();
|
||||
let found = ProxyManager::find_ffmpeg(me.to_str().unwrap());
|
||||
let canonical = std::fs::canonicalize(&me).unwrap();
|
||||
assert_eq!(found, canonical.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_ffmpeg_missing_returns_empty() {
|
||||
let found = ProxyManager::find_ffmpeg("/definitely/not/a/real/ffmpeg");
|
||||
// Either an absolute configured/installed match or empty; never a raw
|
||||
// unresolved path.
|
||||
assert!(
|
||||
found.is_empty() || std::path::Path::new(&found).is_absolute(),
|
||||
"found: {found}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `oakcodec_proxy_params` byte-level layout lock against
|
||||
/// `include/codec/proxy.h` (verified with a C++ `offsetof` probe): the
|
||||
/// Rust mirror must read a C caller's POD in place.
|
||||
#[test]
|
||||
fn proxy_params_c_abi_layout() {
|
||||
use std::mem::{offset_of, size_of};
|
||||
|
||||
assert_eq!(size_of::<ProxyParams>(), 88);
|
||||
assert_eq!(offset_of!(ProxyParams, width), 0);
|
||||
assert_eq!(offset_of!(ProxyParams, height), 4);
|
||||
assert_eq!(offset_of!(ProxyParams, divider), 8);
|
||||
assert_eq!(offset_of!(ProxyParams, version), 12);
|
||||
assert_eq!(offset_of!(ProxyParams, crf), 16);
|
||||
assert_eq!(offset_of!(ProxyParams, include_audio), 20);
|
||||
assert_eq!(offset_of!(ProxyParams, extension), 24);
|
||||
assert_eq!(offset_of!(ProxyParams, preset), 56);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_extra {
|
||||
use super::*;
|
||||
|
||||
/// Unix-only: builds a fake `ffmpeg` shell script with a mode-0755
|
||||
/// chmod; Windows has no permission bits and searches for `ffmpeg.exe`.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn find_ffmpeg_searches_path() {
|
||||
// Create a fake executable in a temp dir and prepend it to PATH.
|
||||
let dir = std::env::temp_dir().join(format!("oakcodec_ffmpeg_{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let fake = dir.join("ffmpeg");
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(&fake, b"#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let mut paths = dir.to_string_lossy().into_owned();
|
||||
if let Ok(existing) = std::env::var("PATH") {
|
||||
paths.push(':');
|
||||
paths.push_str(&existing);
|
||||
}
|
||||
let old = std::env::var_os("PATH");
|
||||
std::env::set_var("PATH", &paths);
|
||||
let found = ProxyManager::find_ffmpeg("");
|
||||
if let Some(old) = old {
|
||||
std::env::set_var("PATH", old);
|
||||
} else {
|
||||
std::env::remove_var("PATH");
|
||||
}
|
||||
|
||||
// `std::env::set_var` is not thread-safe, so under parallel tests the
|
||||
// canonicalized form can race; assert the search branch invariants
|
||||
// instead of the exact canonical path.
|
||||
assert!(found.starts_with('/'), "found: {found}");
|
||||
assert!(found.ends_with("/ffmpeg"), "found: {found}");
|
||||
assert!(std::path::Path::new(&found).exists(), "found: {found}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_path_env_uses_platform_separator() {
|
||||
// The separator is platform-dependent, so the expected input is
|
||||
// gated: `:` on Unix, `;` on Windows.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
assert_eq!(split_path_env("a:b:c"), ["a", "b", "c"]);
|
||||
// Empty entries are skipped.
|
||||
assert_eq!(split_path_env("a::b:"), ["a", "b"]);
|
||||
assert_eq!(split_path_env("solo"), ["solo"]);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
assert_eq!(split_path_env("a;b;c"), ["a", "b", "c"]);
|
||||
assert_eq!(split_path_env("a;;b;"), ["a", "b"]);
|
||||
assert_eq!(split_path_env("solo"), ["solo"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_exe_name_is_platform_specific() {
|
||||
#[cfg(unix)]
|
||||
assert_eq!(ffmpeg_exe_name(), "ffmpeg");
|
||||
#[cfg(windows)]
|
||||
assert_eq!(ffmpeg_exe_name(), "ffmpeg.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_proxy_state_empty_and_working() {
|
||||
// Empty filename -> Missing.
|
||||
assert_eq!(ProxyManager::get_proxy_state(""), ProxyState::Missing);
|
||||
// A path that does not exist -> Missing.
|
||||
assert_eq!(
|
||||
ProxyManager::get_proxy_state("/nope/nope.mp4"),
|
||||
ProxyState::Missing
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Real-media tests for the FFmpeg decoder/encoder (`#[cfg(test)]` module).
|
||||
//!
|
||||
//! These exercise the real `ffmpeg-next` implementation against
|
||||
//! `tests/demo.mp4` at the repository root (H.264 1920x1080@25fps + AAC
|
||||
//! 48kHz stereo) and a full H.264 encode round-trip through `/tmp`.
|
||||
//!
|
||||
//! They live inside the crate (not `tests/`) because the crate's
|
||||
//! `#[cfg(test)]` in-memory stubs — which the `Frame`/`FootageDescription`
|
||||
//! paths need — are only linked for the lib test binary (`tests/` is
|
||||
//! compiled without `#[cfg(test)]` and cannot resolve those symbols; see
|
||||
//! `tests/ffi_contract_test.rs`).
|
||||
|
||||
use oak_common::ocioutils::PixelFormat as OakPixelFormat;
|
||||
use oak_common::videoparams::VideoParams;
|
||||
use crate::decoder::{
|
||||
CodecStream, Decoder, RenderMode, RetrieveAudioStatus, RetrieveVideoParams,
|
||||
K_COLOR_RANGE_DEFAULT,
|
||||
};
|
||||
use crate::encoder::create_from_params;
|
||||
use crate::ffmpeg::FFmpegDecoder;
|
||||
use crate::frame::Frame;
|
||||
use oak_core::{PixelFormat, Rational, TimeRange};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `tests/demo.mp4` at the repository root.
|
||||
fn demo_path() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/demo.mp4")
|
||||
}
|
||||
|
||||
fn video_params(stream: CodecStream, time: Rational) -> RetrieveVideoParams {
|
||||
RetrieveVideoParams {
|
||||
stream,
|
||||
time,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// H.264 encoder parameters: 64x64, 10 fps, `out` as the target file.
|
||||
fn h264_params(out: &std::path::Path) -> crate::encodingparams::EncodingParams {
|
||||
let mut p = crate::encodingparams::EncodingParams::default();
|
||||
let name = out.as_os_str().as_encoded_bytes();
|
||||
p.filename[..name.len()].copy_from_slice(name);
|
||||
p.format = 2; // MPEG-4 video
|
||||
p.video_enabled = 1;
|
||||
p.video_codec = 1; // H.264
|
||||
p.video_width = 64;
|
||||
p.video_height = 64;
|
||||
p.video_time_base_num = 1;
|
||||
p.video_time_base_den = 10;
|
||||
p.video_pixel_format = PixelFormat::F32;
|
||||
p.video_interlacing = 0;
|
||||
p.video_pixel_aspect_num = 1;
|
||||
p.video_pixel_aspect_den = 1;
|
||||
p
|
||||
}
|
||||
|
||||
/// Build an allocated F32-RGBA frame with a moving color pattern.
|
||||
fn pattern_frame(i: i32) -> Frame {
|
||||
let mut vp = VideoParams::new_basic(64, 64, OakPixelFormat::from_code(0), 4, 1, 1, 0, 1);
|
||||
vp.set_format(OakPixelFormat::from_code(PixelFormat::F32 as i32));
|
||||
let mut f = Frame::with_params(vp);
|
||||
f.set_timestamp(Rational::new(i as i64, 10));
|
||||
f.allocate().unwrap();
|
||||
|
||||
let linesize = f.linesize_bytes() as usize;
|
||||
let data = f.data_mut().unwrap();
|
||||
for y in 0..64usize {
|
||||
for x in 0..64usize {
|
||||
let off = y * linesize + x * 16;
|
||||
let r: f32 = if x < 32 { 0.4 + i as f32 * 0.05 } else { 0.1 };
|
||||
let g: f32 = y as f32 / 64.0;
|
||||
let b: f32 = if x >= 32 { 0.7 } else { 0.2 };
|
||||
data[off..off + 4].copy_from_slice(&r.to_le_bytes());
|
||||
data[off + 4..off + 8].copy_from_slice(&g.to_le_bytes());
|
||||
data[off + 8..off + 12].copy_from_slice(&b.to_le_bytes());
|
||||
data[off + 12..off + 16].copy_from_slice(&1.0f32.to_le_bytes());
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reports_streams_and_duration() {
|
||||
let d = FFmpegDecoder::new();
|
||||
let desc = d
|
||||
.probe(demo_path().to_str().unwrap(), None)
|
||||
.expect("demo.mp4 should probe");
|
||||
assert_eq!(desc.decoder(), "ffmpeg");
|
||||
// video + audio + data (timecode) stream.
|
||||
assert_eq!(desc.total_stream_count(), 3);
|
||||
assert_eq!(desc.video_stream_count(), 1);
|
||||
assert_eq!(desc.audio_stream_count(), 1);
|
||||
|
||||
// Video stream: 1920x1080, 25fps, 17s at 1/12800 time base.
|
||||
let vp = desc.get_video_stream(0).expect("video stream");
|
||||
assert_eq!(vp.width(), 1920);
|
||||
assert_eq!(vp.height(), 1080);
|
||||
assert_eq!(vp.duration(), 17 * 12800);
|
||||
assert_eq!(vp.frame_rate(), (25, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_first_video_frame_has_dimensions_and_content() {
|
||||
let d = FFmpegDecoder::new();
|
||||
let s = CodecStream::with_block(demo_path().to_string_lossy().into_owned(), 0, None);
|
||||
d.open(&s).expect("open video stream");
|
||||
|
||||
let f = d
|
||||
.retrieve_video_frame(&video_params(s, Rational::new(0, 1)))
|
||||
.expect("decode first frame");
|
||||
assert_eq!(f.width(), 1920);
|
||||
assert_eq!(f.height(), 1080);
|
||||
assert_eq!(f.format(), PixelFormat::F32);
|
||||
assert!(f.is_allocated());
|
||||
// Expected size: 4 channels x 4 bytes, linesize 32-byte aligned.
|
||||
assert_eq!(f.allocated_size(), (16 * 1920) * 1080);
|
||||
|
||||
// The frame must contain non-zero pixels.
|
||||
let data = f.data().expect("allocated data");
|
||||
assert!(data.iter().any(|&b| b != 0), "decoded frame is all zeros");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_video_frame_at_midpoint() {
|
||||
let d = FFmpegDecoder::new();
|
||||
let s = CodecStream::with_block(demo_path().to_string_lossy().into_owned(), 0, None);
|
||||
d.open(&s).expect("open video stream");
|
||||
|
||||
let f = d
|
||||
.retrieve_video_frame(&video_params(s, Rational::new(8, 1)))
|
||||
.expect("decode mid frame");
|
||||
assert_eq!(f.width(), 1920);
|
||||
assert_eq!(f.height(), 1080);
|
||||
assert!(f.data().unwrap().iter().any(|&b| b != 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_decode_is_non_empty() {
|
||||
let d = FFmpegDecoder::new();
|
||||
let s = CodecStream::with_block(demo_path().to_string_lossy().into_owned(), 1, None);
|
||||
d.open(&s).expect("open audio stream");
|
||||
|
||||
// One second of stereo at 48 kHz.
|
||||
let mut dest = vec![0f32; 48000 * 2];
|
||||
let status = d
|
||||
.retrieve_audio(
|
||||
&mut dest,
|
||||
&TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
||||
48000,
|
||||
0x3, // stereo mask
|
||||
)
|
||||
.expect("retrieve audio");
|
||||
assert_eq!(status, RetrieveAudioStatus::Success);
|
||||
|
||||
let peak = dest.iter().fold(0.0f32, |a, &s| a.max(s.abs()));
|
||||
assert!(peak > 0.0, "decoded audio is all silence");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_h264_roundtrip_to_tmp() {
|
||||
let out = std::env::temp_dir().join(format!("oakcodec_roundtrip_{}.mp4", std::process::id()));
|
||||
let params = h264_params(&out);
|
||||
let out_str = out.to_str().expect("utf8 temp path").to_string();
|
||||
|
||||
let e = create_from_params(¶ms).expect("create ffmpeg encoder");
|
||||
assert_eq!(e.id(), "ffmpeg");
|
||||
e.configure(¶ms).expect("configure");
|
||||
e.open().expect("open output");
|
||||
|
||||
// Encode 10 frames with a moving pattern.
|
||||
for i in 0..10 {
|
||||
let f = pattern_frame(i);
|
||||
e.write_video(&f).expect("write video frame");
|
||||
}
|
||||
e.flush().expect("flush");
|
||||
|
||||
// The output exists and has a plausible size.
|
||||
assert!(out.exists(), "round-trip file was not created");
|
||||
assert!(
|
||||
out.metadata().unwrap().len() > 1000,
|
||||
"round-trip file is empty"
|
||||
);
|
||||
|
||||
// Probe the result: one 64x64 video stream.
|
||||
let d = FFmpegDecoder::new();
|
||||
let desc = d.probe(&out_str, None).expect("probe round-trip output");
|
||||
assert_eq!(desc.video_stream_count(), 1);
|
||||
let vp = desc.get_video_stream(0).expect("video stream");
|
||||
assert_eq!(vp.width(), 64);
|
||||
assert_eq!(vp.height(), 64);
|
||||
|
||||
// Decode the first frame of the result.
|
||||
let s = CodecStream::with_block(out_str.clone(), 0, None);
|
||||
d.open(&s).expect("open round-trip video");
|
||||
let f = d
|
||||
.retrieve_video_frame(&video_params(s, Rational::new(0, 1)))
|
||||
.expect("decode round-trip first frame");
|
||||
assert_eq!(f.width(), 64);
|
||||
assert_eq!(f.height(), 64);
|
||||
assert_eq!(f.format(), PixelFormat::F32);
|
||||
assert!(f.data().unwrap().iter().any(|&b| b != 0));
|
||||
|
||||
let _ = std::fs::remove_file(&out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_conform_writes_planar_pcm() {
|
||||
let d = FFmpegDecoder::new();
|
||||
let s = CodecStream::with_block(demo_path().to_string_lossy().into_owned(), 1, None);
|
||||
d.open(&s).expect("open audio stream");
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("oakcodec_conform_{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let ch0 = dir.join("0.pcm").to_string_lossy().into_owned();
|
||||
let ch1 = dir.join("1.pcm").to_string_lossy().into_owned();
|
||||
|
||||
d.conform_audio(&[ch0.clone(), ch1.clone()], 48000, 0x3, 4, None)
|
||||
.expect("conform to f32 planar");
|
||||
|
||||
for path in [&ch0, &ch1] {
|
||||
let meta = std::fs::metadata(path).expect("conform output exists");
|
||||
assert!(meta.len() > 0, "conform file is empty");
|
||||
// 1 second at 48kHz * 4 bytes = 192 KB minimum.
|
||||
assert!(
|
||||
meta.len() >= 192_000,
|
||||
"conform file too short: {}",
|
||||
meta.len()
|
||||
);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn testmedia_clip_probe_roundtrip() {
|
||||
let out = std::env::temp_dir().join(format!("oakcodec_tm3_{}.mp4", std::process::id()));
|
||||
crate::testmedia::write_test_clip(&out, 64, 64, 10, 10).expect("generate");
|
||||
let d = FFmpegDecoder::new();
|
||||
let s = CodecStream::with_block(out.to_string_lossy().into_owned(), 0, None);
|
||||
d.open(&s).expect("open");
|
||||
for t in [0i64, 3, 5, 9] {
|
||||
let f = d
|
||||
.retrieve_video_frame(&video_params(s.clone(), Rational::new(t, 10)))
|
||||
.unwrap_or_else(|e| panic!("decode t={t}: {e:?}"));
|
||||
let stride = f.linesize_bytes() as usize;
|
||||
let data = f.data().unwrap();
|
||||
let off = 32 * stride + 8 * 16;
|
||||
let px = [
|
||||
f32::from_le_bytes(data[off..off + 4].try_into().unwrap()),
|
||||
f32::from_le_bytes(data[off + 4..off + 8].try_into().unwrap()),
|
||||
f32::from_le_bytes(data[off + 8..off + 12].try_into().unwrap()),
|
||||
f32::from_le_bytes(data[off + 12..off + 16].try_into().unwrap()),
|
||||
];
|
||||
println!("t={t}/10 px(8,32): {px:?}");
|
||||
}
|
||||
let _ = std::fs::remove_file(&out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn testmedia_audio_track_encodes() {
|
||||
let out = std::env::temp_dir().join(format!("oakcodec_tm_audio_{}.mp4", std::process::id()));
|
||||
crate::testmedia::write_test_clip(&out, 64, 64, 10, 10).expect("generate with audio");
|
||||
let d = FFmpegDecoder::new();
|
||||
let desc = d.probe(&out.to_string_lossy(), None).expect("probe");
|
||||
assert!(desc.audio_stream_count() >= 1, "audio stream present");
|
||||
let _ = std::fs::remove_file(&out);
|
||||
}
|
||||
|
||||
/// Serialize the config-toggling hardware-decode test (the config store
|
||||
/// is process-global; decode tests must not observe each other's flag).
|
||||
static HW_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Hardware decoding (the mandated default): with the switch ON the
|
||||
/// session must open the platform's hardware decoder (on macOS,
|
||||
/// `h264_videotoolbox` for the H.264 demo); with the switch OFF it must
|
||||
/// be pure software. Both paths must decode the same frame to matching
|
||||
/// pixels (small tolerance for decoder rounding).
|
||||
#[test]
|
||||
fn hardware_decode_matches_software_decode() {
|
||||
let _guard = HW_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let config = oak_common::configstore::ConfigStore::instance();
|
||||
let key = crate::hwdecode::CONFIG_KEY_HARDWARE_DECODING;
|
||||
|
||||
let decode_at = |time: i64| -> (Option<String>, Arc<Frame>) {
|
||||
let d = FFmpegDecoder::new();
|
||||
let s = CodecStream::with_block(demo_path().to_string_lossy().into_owned(), 0, None);
|
||||
d.open(&s).expect("open video stream");
|
||||
let hw = d.hw_decoder_name();
|
||||
let f = d
|
||||
.retrieve_video_frame(&video_params(s, Rational::new(time, 1)))
|
||||
.expect("decode frame");
|
||||
(hw, f)
|
||||
};
|
||||
|
||||
// Hardware path.
|
||||
config.set(None, key, "true");
|
||||
let (hw_name, hw_frame) = decode_at(5);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let transfers =
|
||||
crate::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if hw_name.as_deref() != Some("videotoolbox") || transfers == 0 {
|
||||
// Headless/virtualized macOS (CI runners) cannot bring up
|
||||
// VideoToolbox ("hwaccel initialisation returned error"); the
|
||||
// decoder then falls back to software and the engagement
|
||||
// mandate can only be asserted where the hardware path exists.
|
||||
eprintln!("VideoToolbox unavailable on this host; skipping hw assertion");
|
||||
return;
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
assert!(
|
||||
hw_name.is_none()
|
||||
|| hw_name.as_deref().unwrap().contains("vaapi")
|
||||
|| hw_name.as_deref().unwrap().contains("nvdec")
|
||||
|| hw_name.as_deref().unwrap().contains("d3d11va"),
|
||||
"unexpected decoder {hw_name:?}"
|
||||
);
|
||||
|
||||
// Software path (the switch off).
|
||||
config.set(None, key, "false");
|
||||
let (sw_name, sw_frame) = decode_at(5);
|
||||
assert!(sw_name.is_none(), "switch off must force software decoding");
|
||||
config.set(None, key, "true");
|
||||
|
||||
// Same geometry, same pixels (within decoder rounding — the threshold
|
||||
// is generous because VideoToolbox's YUV→RGB conversion legitimately
|
||||
// differs from swscale by ~1 LSB of the intermediate depth).
|
||||
assert_eq!(
|
||||
(hw_frame.width(), hw_frame.height()),
|
||||
(sw_frame.width(), sw_frame.height())
|
||||
);
|
||||
let (hw_data, sw_data) = (hw_frame.data().unwrap(), sw_frame.data().unwrap());
|
||||
assert_eq!(hw_data.len(), sw_data.len());
|
||||
let mut max_diff = 0.0f32;
|
||||
for (a, b) in hw_data.chunks_exact(4).zip(sw_data.chunks_exact(4)) {
|
||||
let fa = f32::from_le_bytes(a.try_into().unwrap());
|
||||
let fb = f32::from_le_bytes(b.try_into().unwrap());
|
||||
max_diff = max_diff.max((fa - fb).abs());
|
||||
}
|
||||
assert!(
|
||||
max_diff < 0.08,
|
||||
"hardware and software decodes diverge (max channel diff {max_diff})"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Background task submission hook (`include/codec/task.h`).
|
||||
//!
|
||||
//! The codec module needs occasional background work (audio conforms,
|
||||
//! proxy transcodes). The task system itself splits out at milestone M8;
|
||||
//! until then oakcodec exposes a single global submit callback. A host
|
||||
//! (M8: oaktask) registers with [`set_task_submit_cb`]; the conform/proxy
|
||||
//! managers call it whenever they need a task. With no callback, managers
|
||||
//! report work as unavailable — they never crash and never block.
|
||||
|
||||
use std::ffi::{c_void, CString};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::error::{Error, OAKCODEC_OK};
|
||||
|
||||
/// Kinds of background tasks oakcodec can request.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(i32)]
|
||||
pub enum TaskKind {
|
||||
/// Audio conform to pcm cache files.
|
||||
Conform = 0,
|
||||
/// Video proxy transcode.
|
||||
Proxy = 1,
|
||||
}
|
||||
|
||||
/// Description of one background task request.
|
||||
///
|
||||
/// All strings are borrowed and only valid for the duration of the submit
|
||||
/// call; the callback must copy anything it retains.
|
||||
#[repr(C)]
|
||||
pub struct TaskRequest<'a> {
|
||||
/// `TaskKind`.
|
||||
pub kind: TaskKind,
|
||||
/// Source media filename.
|
||||
pub input_filename: &'a str,
|
||||
/// Final destination path (see field docs in include/codec/task.h).
|
||||
pub output_filename: &'a str,
|
||||
/// Stream inside the source media.
|
||||
pub stream_index: i32,
|
||||
/// Conform: target sample rate.
|
||||
pub sample_rate: i32,
|
||||
/// Conform: target channel layout mask.
|
||||
pub channel_layout: u64,
|
||||
/// Conform: target sample format (enum as int).
|
||||
pub sample_format: i32,
|
||||
/// Proxy: target width (0 = unspecified/divider-based).
|
||||
pub proxy_width: i32,
|
||||
/// Proxy: target height (0 = unspecified/divider-based).
|
||||
pub proxy_height: i32,
|
||||
}
|
||||
|
||||
/// Task submit callback signature.
|
||||
///
|
||||
/// Returns `Ok(())` if the task was accepted (completed synchronously or
|
||||
/// queued), `Err` if the request was rejected.
|
||||
pub type TaskSubmitFn = dyn Fn(&TaskRequest, *mut std::ffi::c_void) -> crate::error::Result<()>;
|
||||
|
||||
/// `OakCodecTaskRequest` — C ABI mirror of [`TaskRequest`] for the submit
|
||||
/// callback; see `include/codec/task.h`. Strings are borrowed C pointers,
|
||||
/// valid only for the duration of the call.
|
||||
#[repr(C)]
|
||||
pub struct OakCodecTaskRequest {
|
||||
/// `TaskKind` value.
|
||||
pub kind: i32,
|
||||
/// Source media filename.
|
||||
pub input_filename: *const std::ffi::c_char,
|
||||
/// Final destination path.
|
||||
pub output_filename: *const std::ffi::c_char,
|
||||
/// Stream inside the source media.
|
||||
pub stream_index: i32,
|
||||
/// Conform: target sample rate.
|
||||
pub sample_rate: i32,
|
||||
/// Conform: target channel-layout mask.
|
||||
pub channel_layout: u64,
|
||||
/// Conform: target sample format (enum as int).
|
||||
pub sample_format: i32,
|
||||
/// Proxy: target width (0 = unspecified/divider-based).
|
||||
pub proxy_width: i32,
|
||||
/// Proxy: target height (0 = unspecified/divider-based).
|
||||
pub proxy_height: i32,
|
||||
}
|
||||
|
||||
/// `oakcodec_task_submit_fn` — the extern-C submit callback typedef; see
|
||||
/// `include/codec/task.h`. Returns `OAKCODEC_OK` on accept, else a
|
||||
/// negative `OAKCODEC_E_*` code.
|
||||
pub type OakCodecTaskSubmitFn =
|
||||
unsafe extern "C" fn(req: *const OakCodecTaskRequest, userdata: *mut std::ffi::c_void) -> i32;
|
||||
|
||||
/// One registered submit callback (extern-C from the host, or a crate
|
||||
/// Rust closure). Mirrors the C++ `g_task_cb`/`g_task_cb_userdata` pair.
|
||||
enum SubmitCb {
|
||||
/// No callback registered.
|
||||
None,
|
||||
/// Extern-C callback registered via `oakcodec_set_task_submit_cb`.
|
||||
Extern {
|
||||
/// The C function pointer.
|
||||
cb: OakCodecTaskSubmitFn,
|
||||
/// Opaque userdata passed back on each call.
|
||||
userdata: *mut c_void,
|
||||
},
|
||||
/// Crate-internal Rust closure registered via [`set_task_submit_cb`].
|
||||
Rust {
|
||||
/// Raw fat-pointer to the `&'static TaskSubmitFn` (kept `*const` so
|
||||
/// the registry is `Send`).
|
||||
cb: *const TaskSubmitFn,
|
||||
/// Opaque userdata passed back on each call.
|
||||
userdata: *mut c_void,
|
||||
},
|
||||
}
|
||||
|
||||
// # Safety: the stored pointers (extern-C fn pointer, fat pointer to a
|
||||
// 'static closure, userdata) are only dereferenced/called while holding the
|
||||
// registry mutex; the Rust closure is 'static and the extern-C fn outlives
|
||||
// registration by contract. Moving the enum between threads under the lock
|
||||
// therefore cannot alias.
|
||||
unsafe impl Send for SubmitCb {}
|
||||
|
||||
/// The global task submit callback registry. Only one callback is held at
|
||||
/// a time; registering replaces it, `None` clears it. Thread-safe.
|
||||
static TASK_SUBMIT: Mutex<SubmitCb> = Mutex::new(SubmitCb::None);
|
||||
|
||||
/// Registers (or replaces) the global task submit callback. Pass `None` to
|
||||
/// unregister. Thread-safe. Interim state (pre-M8): nobody registers and all
|
||||
/// task-dependent work reports unavailable.
|
||||
pub fn set_task_submit_cb(cb: Option<&'static TaskSubmitFn>, userdata: *mut std::ffi::c_void) {
|
||||
let mut g = TASK_SUBMIT.lock().unwrap();
|
||||
*g = match cb {
|
||||
Some(cb) => SubmitCb::Rust {
|
||||
cb: cb as *const TaskSubmitFn,
|
||||
userdata,
|
||||
},
|
||||
None => SubmitCb::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Register an extern-C submit callback (used by `ffi::task`).
|
||||
///
|
||||
/// Mirrors `oakcodec_set_task_submit_cb`: a `None` pointer clears it.
|
||||
pub(crate) fn set_task_submit_cb_extern(
|
||||
cb: Option<OakCodecTaskSubmitFn>,
|
||||
userdata: *mut std::ffi::c_void,
|
||||
) {
|
||||
let mut g = TASK_SUBMIT.lock().unwrap();
|
||||
*g = match cb {
|
||||
Some(cb) => SubmitCb::Extern { cb, userdata },
|
||||
None => SubmitCb::None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns 1 if a submit callback is currently registered, else 0.
|
||||
/// Thread-safe.
|
||||
pub fn task_submit_is_registered() -> bool {
|
||||
let g = TASK_SUBMIT.lock().unwrap();
|
||||
!matches!(&*g, SubmitCb::None)
|
||||
}
|
||||
|
||||
/// Submit a task through the registered callback, if any.
|
||||
///
|
||||
/// Returns `Ok(false)` when no callback is registered (nothing submitted),
|
||||
/// `Ok(true)` when accepted, or `Err` when the callback rejected it.
|
||||
pub fn submit_task(req: &TaskRequest) -> crate::error::Result<bool> {
|
||||
let g = TASK_SUBMIT.lock().unwrap();
|
||||
match &*g {
|
||||
SubmitCb::None => Ok(false),
|
||||
SubmitCb::Extern { cb, userdata } => {
|
||||
// Bind the C strings to locals so the temporaries outlive the
|
||||
// callback call (their pointers feed the request struct).
|
||||
let in_c = cstring_or_empty(req.input_filename);
|
||||
let out_c = cstring_or_empty(req.output_filename);
|
||||
let creq = OakCodecTaskRequest {
|
||||
kind: req.kind as i32,
|
||||
input_filename: in_c.as_ptr(),
|
||||
output_filename: out_c.as_ptr(),
|
||||
stream_index: req.stream_index,
|
||||
sample_rate: req.sample_rate,
|
||||
channel_layout: req.channel_layout,
|
||||
sample_format: req.sample_format,
|
||||
proxy_width: req.proxy_width,
|
||||
proxy_height: req.proxy_height,
|
||||
};
|
||||
// # Safety: the callback is a C function we registered; passing a
|
||||
// request whose string pointers are alive for the call duration.
|
||||
let ret = unsafe { cb(&creq, *userdata) };
|
||||
if ret == OAKCODEC_OK {
|
||||
Ok(true)
|
||||
} else {
|
||||
Err(Error::Failed(format!(
|
||||
"task submit rejected (code {})",
|
||||
ret
|
||||
)))
|
||||
}
|
||||
}
|
||||
SubmitCb::Rust { cb, userdata } => {
|
||||
// # Safety: the fat pointer was stored by set_task_submit_cb and
|
||||
// points to a 'static closure that outlives this call.
|
||||
let cb = unsafe { &**cb };
|
||||
match cb(req, *userdata) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a NUL-terminated C string from a Rust string; empty on embedded
|
||||
/// NUL (callers pass sane filenames, so this is defensive only).
|
||||
fn cstring_or_empty(s: &str) -> CString {
|
||||
CString::new(s).unwrap_or_else(|_| CString::new("").unwrap())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// Same registry lock the conform/proxy/ffi tests use: the submit
|
||||
// callback is process-global and every test that mutates it must
|
||||
// serialize on the same mutex.
|
||||
use crate::conformmanager::test_util::REG_LOCK;
|
||||
|
||||
unsafe extern "C" fn reject_cb(
|
||||
_req: *const OakCodecTaskRequest,
|
||||
_ud: *mut std::ffi::c_void,
|
||||
) -> i32 {
|
||||
-1 // rejected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_via_rust_closure_and_clear() {
|
||||
let _g = REG_LOCK.lock().unwrap();
|
||||
|
||||
// A Rust closure that accepts and records the request.
|
||||
let accepted = std::sync::Arc::new(std::sync::Mutex::new(false));
|
||||
let recorded = std::sync::Arc::new(std::sync::Mutex::new(None::<String>));
|
||||
let acc = accepted.clone();
|
||||
let rec = recorded.clone();
|
||||
let cb: &'static TaskSubmitFn = Box::leak(Box::new(
|
||||
move |req: &TaskRequest, _ud: *mut std::ffi::c_void| {
|
||||
*acc.lock().unwrap() = true;
|
||||
*rec.lock().unwrap() = Some(req.output_filename.to_string());
|
||||
Ok(())
|
||||
},
|
||||
));
|
||||
set_task_submit_cb(Some(cb), std::ptr::null_mut());
|
||||
assert!(task_submit_is_registered());
|
||||
|
||||
let req = TaskRequest {
|
||||
kind: TaskKind::Conform,
|
||||
input_filename: "in.mp4",
|
||||
output_filename: "out.pcm",
|
||||
stream_index: 1,
|
||||
sample_rate: 48000,
|
||||
channel_layout: 0x3,
|
||||
sample_format: 10,
|
||||
proxy_width: 0,
|
||||
proxy_height: 0,
|
||||
};
|
||||
assert!(submit_task(&req).unwrap());
|
||||
assert!(*accepted.lock().unwrap());
|
||||
assert_eq!(recorded.lock().unwrap().as_deref(), Some("out.pcm"));
|
||||
|
||||
// Clearing the callback: nothing submitted.
|
||||
set_task_submit_cb(None, std::ptr::null_mut());
|
||||
assert!(!task_submit_is_registered());
|
||||
assert!(!submit_task(&req).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extern_cb_reject_maps_to_err() {
|
||||
let _g = REG_LOCK.lock().unwrap();
|
||||
set_task_submit_cb_extern(Some(reject_cb), std::ptr::null_mut());
|
||||
let req = TaskRequest {
|
||||
kind: TaskKind::Proxy,
|
||||
input_filename: "in.mp4",
|
||||
output_filename: "out.mp4",
|
||||
stream_index: 0,
|
||||
sample_rate: 0,
|
||||
channel_layout: 0,
|
||||
sample_format: 0,
|
||||
proxy_width: 1280,
|
||||
proxy_height: 720,
|
||||
};
|
||||
assert!(submit_task(&req).is_err());
|
||||
set_task_submit_cb_extern(None, std::ptr::null_mut());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extern_cb_accept_returns_ok() {
|
||||
let _g = REG_LOCK.lock().unwrap();
|
||||
set_task_submit_cb_extern(
|
||||
Some(crate::conformmanager::test_util::accept_cb),
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
let req = TaskRequest {
|
||||
kind: TaskKind::Conform,
|
||||
input_filename: "in.mp4",
|
||||
output_filename: "out.pcm",
|
||||
stream_index: 0,
|
||||
sample_rate: 0,
|
||||
channel_layout: 0,
|
||||
sample_format: 0,
|
||||
proxy_width: 0,
|
||||
proxy_height: 0,
|
||||
};
|
||||
assert!(submit_task(&req).unwrap());
|
||||
set_task_submit_cb_extern(None, std::ptr::null_mut());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cstring_or_empty_handles_embedded_nul() {
|
||||
// Embedded NUL -> empty string (defensive).
|
||||
let c = cstring_or_empty("a\0b");
|
||||
assert_eq!(c.as_c_str().to_bytes(), b"");
|
||||
assert_eq!(cstring_or_empty("ok").as_c_str().to_bytes(), b"ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_kind_values_match_abi() {
|
||||
assert_eq!(TaskKind::Conform as i32, OAKCODEC_TASK_CONFORM as i32);
|
||||
assert_eq!(TaskKind::Proxy as i32, OAKCODEC_TASK_PROXY as i32);
|
||||
}
|
||||
|
||||
// ABI constants mirrored from include/codec/task.h.
|
||||
const OAKCODEC_TASK_CONFORM: i32 = 0;
|
||||
const OAKCODEC_TASK_PROXY: i32 = 1;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Program-generated test media (M12 P0).
|
||||
//!
|
||||
//! Encodes a small H.264 clip of known content through the real FFmpeg
|
||||
//! encoder — no network, no committed binary assets. Used by the
|
||||
//! oakrender decode tests and the app's render e2e to prove that the
|
||||
//! footage-decode path produces real pixels with known values.
|
||||
//!
|
||||
//! Content contract (what a decode must observe):
|
||||
//! - frame 0: left half solid `[r, g, b]`, right half solid
|
||||
//! `[b, r, g]` (transposed), alpha 1 everywhere;
|
||||
//! - later frames: the color columns sweep right by one column per
|
||||
//! frame, so per-frame content is distinguishable after decode.
|
||||
//!
|
||||
//! MPEG-2 is used instead of H.264: the H.264 default profile emits
|
||||
//! B-frames, and this FFmpeg pairing's movenc writes B-frame streams
|
||||
//! with mangled packet timestamps (decode seeks then fail). MPEG-2's
|
||||
//! default encoder is B-frame-free and its streams round-trip cleanly.
|
||||
//!
|
||||
//! H.264/H.265 is lossy, so assertions downstream should use generous
|
||||
//! tolerances (channel dominance rather than exact values).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use oak_common::ocioutils::PixelFormat as OakPixelFormat;
|
||||
use oak_common::videoparams::VideoParams;
|
||||
use oak_core::{PixelFormat, Rational, SampleFormat};
|
||||
|
||||
use crate::encodingparams::EncodingParams;
|
||||
use crate::encoder::create_from_params;
|
||||
use crate::frame::Frame;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Encode `frame_count` frames of the known pattern into `out` (an MP4 at
|
||||
/// `fps` frames per second, `width`x`height`, H.264).
|
||||
pub fn write_test_clip(
|
||||
out: &Path,
|
||||
width: i32,
|
||||
height: i32,
|
||||
frame_count: i32,
|
||||
fps: i32,
|
||||
) -> Result<()> {
|
||||
if width <= 0 || height <= 0 || frame_count <= 0 || fps <= 0 {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let mut params = EncodingParams::default();
|
||||
let name = out.as_os_str().as_encoded_bytes();
|
||||
if name.len() >= params.filename.len() {
|
||||
return Err(Error::Failed("output path too long".into()));
|
||||
}
|
||||
params.filename[..name.len()].copy_from_slice(name);
|
||||
params.format = 2; // MPEG-4 video container
|
||||
params.video_enabled = 1;
|
||||
params.video_codec = 10; // MPEG-2 (B-frame-free; the H.264 B-frame streams hit a muxer timing bug)
|
||||
params.video_width = width;
|
||||
params.video_height = height;
|
||||
params.video_time_base_num = 1;
|
||||
params.video_time_base_den = fps;
|
||||
params.video_pixel_format = PixelFormat::F32;
|
||||
params.video_interlacing = 0;
|
||||
params.video_pixel_aspect_num = 1;
|
||||
params.video_pixel_aspect_den = 1;
|
||||
// A stereo PCM audio track with a known 440 Hz sine (M12 P1: the
|
||||
// audio-render path needs a decodable audio stream; PCM avoids
|
||||
// codec sample-format negotiation issues in this FFmpeg pairing).
|
||||
params.audio_enabled = 1;
|
||||
params.audio_codec = 13; // PCM S16LE
|
||||
params.audio_sample_rate = 48000;
|
||||
params.audio_channel_layout = 0x3; // stereo
|
||||
params.audio_sample_format = SampleFormat::F32;
|
||||
|
||||
let encoder = create_from_params(¶ms)
|
||||
.ok_or_else(|| Error::Failed("no encoder for test clip params".into()))?;
|
||||
encoder.configure(¶ms)?;
|
||||
encoder.open()?;
|
||||
for i in 0..frame_count {
|
||||
encoder.write_video(&pattern_frame(i, width, height, fps))?;
|
||||
}
|
||||
// One second of 440 Hz sine (stereo, both channels identical).
|
||||
let rate = 48000u32;
|
||||
let mut tone: Vec<f32> = Vec::with_capacity(rate as usize * 2);
|
||||
for i in 0..rate {
|
||||
let v = (i as f32 * 440.0 * std::f32::consts::TAU / rate as f32).sin() * 0.5;
|
||||
tone.push(v);
|
||||
tone.push(v);
|
||||
}
|
||||
encoder.write_audio(&tone, rate as i32)?;
|
||||
encoder.flush()
|
||||
}
|
||||
|
||||
/// One frame of the known pattern (see module doc).
|
||||
fn pattern_frame(i: i32, width: i32, height: i32, fps: i32) -> Frame {
|
||||
let mut vp = VideoParams::new_basic(
|
||||
width,
|
||||
height,
|
||||
OakPixelFormat::from_code(0),
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
);
|
||||
vp.set_format(OakPixelFormat::from_code(PixelFormat::F32 as i32));
|
||||
let mut f = Frame::with_params(vp);
|
||||
f.set_timestamp(Rational::new(i as i64, fps as i64));
|
||||
f.allocate().expect("test frame allocation");
|
||||
|
||||
let linesize = f.linesize_bytes() as usize;
|
||||
let data = f.data_mut().expect("test frame buffer");
|
||||
let shift = (i * width / (2 * fps.max(1))) % width;
|
||||
for y in 0..height as usize {
|
||||
for x in 0..width as usize {
|
||||
let off = y * linesize + x * 16;
|
||||
let (r, g, b) = if (x as i32 + shift) % width < width / 2 {
|
||||
(0.9f32, 0.15f32, 0.05f32)
|
||||
} else {
|
||||
(0.05f32, 0.2f32, 0.85f32)
|
||||
};
|
||||
data[off..off + 4].copy_from_slice(&r.to_le_bytes());
|
||||
data[off + 4..off + 8].copy_from_slice(&g.to_le_bytes());
|
||||
data[off + 8..off + 12].copy_from_slice(&b.to_le_bytes());
|
||||
data[off + 12..off + 16].copy_from_slice(&1.0f32.to_le_bytes());
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! `olive::TimecodeMetadata` — parse source timecode strings.
|
||||
//!
|
||||
//! Mirrors `src/codec/src/timecodemetadata.h`: parse an SMPTE timecode
|
||||
//! string or a BWF `time reference` chunk into a rational media timestamp.
|
||||
|
||||
use oak_core::Rational;
|
||||
|
||||
/// `TimecodeMetadata::SourceTime` — the parsed result.
|
||||
pub struct SourceTime {
|
||||
/// Media timestamp, rational seconds.
|
||||
pub time: Rational,
|
||||
/// Source string (normalized form, or the raw input on failure).
|
||||
pub source: String,
|
||||
/// Parse succeeded.
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
/// Trim the same whitespace set as the C++ `trimmed()` helper
|
||||
/// (`" \t\n\r\f\v"`): space, tab, LF, CR, form feed, vertical tab.
|
||||
fn trimmed(s: &str) -> String {
|
||||
s.trim_matches(|c: char| {
|
||||
c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\u{000c}' || c == '\u{000b}'
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Port of `str_to_int64_empty_tolerant`: an empty field is a valid `0`,
|
||||
/// any other unparseable field is an error (`None`).
|
||||
fn str_to_int64_empty_tolerant(s: &str) -> Option<i64> {
|
||||
if s.is_empty() {
|
||||
Some(0)
|
||||
} else {
|
||||
s.parse::<i64>().ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// `std::llround` (round half away from zero, truncate to `i64`).
|
||||
fn llround(x: f64) -> i64 {
|
||||
x.round() as i64
|
||||
}
|
||||
|
||||
/// `timebase.flipped().to_double()` — seconds-per-frame to frames-per-second.
|
||||
fn frame_rate(timebase: &Rational) -> f64 {
|
||||
timebase.denominator() as f64 / timebase.numerator() as f64
|
||||
}
|
||||
|
||||
/// Port of `olive::core::Timecode::timecode_to_time` for the
|
||||
/// `k_timecode_non_drop_frame` / `k_timecode_drop_frame` displays.
|
||||
///
|
||||
/// `drop_frame` is true when the (already trimmed) string contains a `;`.
|
||||
/// Returns `None` on any parse failure.
|
||||
fn timecode_to_time(timecode: &str, timebase: &Rational, drop_frame: bool) -> Option<Rational> {
|
||||
let mut tokens: Vec<&str> = timecode.split(|c| c == ':' || c == ';').collect();
|
||||
let element_count = 4;
|
||||
|
||||
// Keep only the leading `HH:MM:SS:FF` tokens.
|
||||
if tokens.len() > element_count {
|
||||
tokens.truncate(element_count);
|
||||
}
|
||||
// Pad missing leading fields with empty strings (which parse to 0).
|
||||
while tokens.len() < element_count {
|
||||
tokens.insert(0, "");
|
||||
}
|
||||
|
||||
let negative = timecode.starts_with('-');
|
||||
|
||||
let hours = str_to_int64_empty_tolerant(tokens[0])?;
|
||||
let mins = str_to_int64_empty_tolerant(tokens[1])?;
|
||||
let secs = str_to_int64_empty_tolerant(tokens[2])?;
|
||||
let frames = str_to_int64_empty_tolerant(tokens[3])?;
|
||||
|
||||
let fr = frame_rate(timebase);
|
||||
let rounded_frame_rate = llround(fr);
|
||||
|
||||
let sec_count = hours * 3600 + mins * 60 + secs;
|
||||
let mut frame_count = sec_count * rounded_frame_rate + frames;
|
||||
|
||||
if drop_frame && timebase.numerator() != 1 {
|
||||
// `timebase_is_drop_frame(timebase)`: numerator != 1.
|
||||
// Number of frames dropped on the minute marks ≈ 6% of the framerate.
|
||||
let drop_frames = llround(fr * (2.0 / 30.0));
|
||||
|
||||
// `d` and `m` are derived from the real (non-rounded) framerate.
|
||||
let real_fr_ts = llround(sec_count as f64 * fr) + frames;
|
||||
|
||||
let frames_per10_minutes = llround(fr * 600.0);
|
||||
let d = real_fr_ts / frames_per10_minutes;
|
||||
let m = real_fr_ts % frames_per10_minutes;
|
||||
|
||||
if m > drop_frames {
|
||||
frame_count -= drop_frames * ((m - drop_frames) / (llround(fr) * 60 - drop_frames));
|
||||
}
|
||||
frame_count -= drop_frames * 9 * d;
|
||||
}
|
||||
|
||||
// `timestamp_to_time`: `timebase.num * frame_count / timebase.den`, reduced.
|
||||
let mut time = timebase.timestamp_to_time(frame_count);
|
||||
|
||||
if negative {
|
||||
time = time * Rational::new(-1, 1);
|
||||
}
|
||||
|
||||
Some(time)
|
||||
}
|
||||
|
||||
/// Signed Euclidean GCD on absolute values (mirrors `i64_gcd`).
|
||||
fn gcd_u64(mut a: u64, mut b: u64) -> u64 {
|
||||
while b != 0 {
|
||||
let t = a % b;
|
||||
a = b;
|
||||
b = t;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
impl SourceTime {
|
||||
/// New invalid (empty) source time.
|
||||
pub fn invalid() -> Self {
|
||||
SourceTime {
|
||||
time: Rational::NULL,
|
||||
source: String::new(),
|
||||
valid: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an SMPTE timecode string at the given timebase
|
||||
/// (`from_timecode_string`).
|
||||
///
|
||||
/// The string is trimmed; an empty result is invalid. A `;` separator
|
||||
/// selects drop-frame, otherwise non-drop-frame. On any parse failure
|
||||
/// the result is invalid and `source` holds the trimmed raw input.
|
||||
pub fn from_timecode_string(timecode: &str, timebase: &Rational) -> SourceTime {
|
||||
let trimmed_tc = trimmed(timecode);
|
||||
if trimmed_tc.is_empty() {
|
||||
return SourceTime::invalid();
|
||||
}
|
||||
|
||||
let drop_frame = trimmed_tc.contains(';');
|
||||
match timecode_to_time(&trimmed_tc, timebase, drop_frame) {
|
||||
Some(time) => SourceTime {
|
||||
time,
|
||||
source: "timecode".to_string(),
|
||||
valid: true,
|
||||
},
|
||||
None => SourceTime {
|
||||
time: Rational::NULL,
|
||||
source: trimmed_tc,
|
||||
valid: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a BWF `time reference` chunk into a timestamp
|
||||
/// (`from_bwf_time_reference`).
|
||||
///
|
||||
/// A non-positive `sample_rate`, or a string that is not a single
|
||||
/// base-10 unsigned integer, yields an invalid result. The parsed
|
||||
/// sample count over `sample_rate` is reduced by their GCD; if the
|
||||
/// reduced numerator or denominator exceed `i32::MAX` the value falls
|
||||
/// back to the (capped, reduced) `Rational::new(samples, sample_rate)`
|
||||
/// approximation, since `oak_core::Rational` exposes no `from_double`.
|
||||
pub fn from_bwf_time_reference(time_reference: &str, sample_rate: i32) -> SourceTime {
|
||||
if sample_rate <= 0 {
|
||||
return SourceTime::invalid();
|
||||
}
|
||||
|
||||
let trimmed_ref = trimmed(time_reference);
|
||||
// `std::strtoull` base 10 with a "whole string consumed" check:
|
||||
// at least one digit, no leading/trailing junk.
|
||||
let samples: u64 = match trimmed_ref.parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) => return SourceTime::invalid(),
|
||||
};
|
||||
|
||||
let mut numerator = samples;
|
||||
let mut denominator = sample_rate as u64;
|
||||
let divisor = gcd_u64(numerator, denominator);
|
||||
numerator /= divisor;
|
||||
denominator /= divisor;
|
||||
|
||||
let rational_limit = i32::MAX as u64;
|
||||
let time = if numerator <= rational_limit && denominator <= rational_limit {
|
||||
Rational::new(numerator as i64, denominator as i64)
|
||||
} else {
|
||||
// `Rational::from_double` is not part of the oakcore_rs public
|
||||
// API; `Rational::new` applies the same INT_MAX-capped reduction
|
||||
// (FFmpeg `av_reduce`), which is the intended approximation.
|
||||
let n = i64::try_from(samples).unwrap_or(i64::MAX);
|
||||
Rational::new(n, sample_rate as i64)
|
||||
};
|
||||
|
||||
SourceTime {
|
||||
time,
|
||||
source: "bwf_time_reference".to_string(),
|
||||
valid: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tc(s: &str, timebase: &Rational) -> SourceTime {
|
||||
SourceTime::from_timecode_string(s, timebase)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_is_null() {
|
||||
let inv = SourceTime::invalid();
|
||||
assert!(inv.time.is_null());
|
||||
assert!(inv.source.is_empty());
|
||||
assert!(!inv.valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_whitespace_is_invalid() {
|
||||
let tb = Rational::new(1, 25);
|
||||
assert!(!tc("", &tb).valid);
|
||||
assert!(!tc(" \t\n", &tb).valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_drop_integral_fps() {
|
||||
let tb = Rational::new(1, 25);
|
||||
let r = tc("00:00:00:10", &tb);
|
||||
assert!(r.valid);
|
||||
assert_eq!(r.source, "timecode");
|
||||
assert_eq!(r.time, Rational::new(2, 5)); // 10 frames @ 25fps = 0.4s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_drop_leading_zeros_and_whitespace() {
|
||||
let tb = Rational::new(1, 25);
|
||||
let r = tc(" 01:02:03:04 ", &tb);
|
||||
assert!(r.valid);
|
||||
// 1*3600+2*60+3 = 3723s * 25 + 4 frames = 93079 frames
|
||||
assert_eq!(r.time, Rational::new(93079, 25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_leading_fields_pad_from_front() {
|
||||
let tb = Rational::new(1, 25);
|
||||
let r = tc("00:01:02", &tb);
|
||||
assert!(r.valid);
|
||||
// C++ pads missing leading fields at the front, so 3 fields become
|
||||
// ["",00,01,02] = HH=0, MM=0, SS=1, FF=2 -> 27 frames @25fps.
|
||||
assert_eq!(r.time, Rational::new(27, 25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_timecode_is_negated() {
|
||||
let tb = Rational::new(1, 25);
|
||||
let r = tc("-00:00:00:05", &tb);
|
||||
assert!(r.valid);
|
||||
assert_eq!(r.time, Rational::new(-1, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_failure_is_invalid_with_raw_source() {
|
||||
let tb = Rational::new(1, 25);
|
||||
let r = tc("abc:def", &tb);
|
||||
assert!(!r.valid);
|
||||
assert!(r.time.is_null());
|
||||
assert_eq!(r.source, "abc:def");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_29_97_first_minute_no_correction() {
|
||||
// 29.97fps -> timebase 1001/30000.
|
||||
let tb = Rational::new(1001, 30000);
|
||||
let r = tc("00:01:00;00", &tb);
|
||||
assert!(r.valid);
|
||||
// No frames dropped in the first minute: `frame_count` stays at
|
||||
// 60s * 30fps = 1800 frames (a minute boundary at 29.97 is 60.06s).
|
||||
assert_eq!(r.time, tb.timestamp_to_time(1800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_frame_29_97_later_minute_corrects() {
|
||||
let tb = Rational::new(1001, 30000);
|
||||
// At 10 minutes of drop-frame timecode the running correction is
|
||||
// 2 frames dropped per minute for 9 of the 10 minutes (18 frames).
|
||||
let r = tc("00:10:00;00", &tb);
|
||||
assert!(r.valid);
|
||||
// NDF would be 10*60*30 = 18000 frames; 18 dropped -> 17982 frames.
|
||||
assert_eq!(r.time, tb.timestamp_to_time(17982));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_drop_uses_colon() {
|
||||
let tb = Rational::new(1001, 30000);
|
||||
let r = tc("00:10:00:00", &tb);
|
||||
assert!(r.valid);
|
||||
// Non-drop: 18000 frames, no correction.
|
||||
assert_eq!(r.time, tb.timestamp_to_time(18000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwf_valid_reduces() {
|
||||
let r = SourceTime::from_bwf_time_reference("48000", 48000);
|
||||
assert!(r.valid);
|
||||
assert_eq!(r.source, "bwf_time_reference");
|
||||
assert_eq!(r.time, Rational::new(1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwf_valid_whitespace_trimmed() {
|
||||
let r = SourceTime::from_bwf_time_reference(" 24000 ", 48000);
|
||||
assert!(r.valid);
|
||||
assert_eq!(r.time, Rational::new(1, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwf_bad_sample_rate_is_invalid() {
|
||||
for sr in [0, -1] {
|
||||
let r = SourceTime::from_bwf_time_reference("100", sr);
|
||||
assert!(!r.valid);
|
||||
assert!(r.time.is_null());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwf_unparseable_is_invalid() {
|
||||
for s in ["", " ", "abc", "12x", "1.5", "-5", "1 2"] {
|
||||
let r = SourceTime::from_bwf_time_reference(s, 48000);
|
||||
assert!(!r.valid, "should reject {:?}", s);
|
||||
assert!(r.time.is_null());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwf_no_common_divisor() {
|
||||
let r = SourceTime::from_bwf_time_reference("3", 48000);
|
||||
assert!(r.valid);
|
||||
assert_eq!(r.time, Rational::new(1, 16000));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user