refactor: workspace layout — crates/, app at root, legacy C++ removed

Single mechanical restructure commit:
- root Cargo.toml = oakapp bin + workspace; one cargo build produces
  oakapp, oak-cli, oak-worker, liboakengine.dylib
- app/rust/src -> src/ (app at repo root, no rust/ nesting)
- src/<mod>/rust -> crates/oak<mod>; src/oakcore-rs -> crates/oakcore;
  src/bindings/oakotio -> crates/oakotio; src/engine/rust ->
  crates/oakengine (keeps cdylib+staticlib+rlib)
- public C headers include/<mod>/ -> crates/oakengine/include/<mod>/
- OFX SDK headers vendored into crates/oakplugin/ofx/ (HostSupport gone)
- legacy deleted: old src/ C++ modules, engine/, core/, ffmpeg_bridge/,
  app/ (Qt), cli/worker C++, root CMakeLists, third_party/KDDockWidgets
  submodule, otio-install, all build-* output (~40GB)
- oakstorage kept but excluded from the workspace (skeleton w/ todos);
  gpui excluded (own workspace)
- verified: cargo build green, cargo test --workspace 1845/0
  (with the documented OCIO_RS_* env override for the homebrew OCIO)
This commit is contained in:
2026-08-10 20:24:25 +08:00
parent f8540e3892
commit 013a175707
4212 changed files with 8331 additions and 2274987 deletions
+295
View File
@@ -0,0 +1,295 @@
// 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/>.
//! oakcommon / oakcore C ABI imports (videoparams, audioparams, rational,
//! subtitleparams, config, filefunctions, ffmpegutils, oiioutils,
//! colortransform).
//!
//! The by-value handle structs (`OakVideoParams`, `OakAudioParams`,
//! `OakSubtitleParams`, `OakNodeBlock`) mirror the `{ctx, addref,
//! release, abi_version}` layout from `include/common/handle.h`, so the
//! codec module can store them by value and pass them straight across
//! the FFI boundary. Function signatures match the public headers
//! verbatim; symbols resolve at link time.
//!
//! The oakcore audio parameters use a pointer-based C ABI instead of the
//! by-value handle convention: `oakcore_audioparams_*` take and return
//! `OakAudioParams *` / `OakRational *` pointers (`core/include/olive/
//! core/oakcore/audioparams.h`, `rational.h`). Those are bridged as raw
//! pointers to the crate's handle structs; `oakcore_audioparams_time_base`
//! returns a newly allocated rational the caller must release with
//! `oakcore_rational_free`.
use std::ffi::{c_char, c_int, c_void};
use crate::handle::CHandle;
/// `OakVideoParams` — refcounted video-parameter handle.
pub type OakVideoParams = CHandle;
/// `OakAudioParams` — refcounted audio-parameter handle.
pub type OakAudioParams = CHandle;
/// `OakSubtitleParams` — refcounted subtitle-parameter handle.
pub type OakSubtitleParams = CHandle;
/// `OakNodeBlock` — opaque node-block handle (owned elsewhere; codec
/// only stores and forwards it).
pub type OakNodeBlock = CHandle;
// The handle structs are opaque refcounted handles pointing into a C
// library; the boxed objects are independently synchronized there, so
// moving a handle between threads is sound.
extern "C" {
/// `oakcommon_videoparams_init`.
pub fn oakcommon_videoparams_init() -> OakVideoParams;
/// `oakcommon_videoparams_init_basic`.
pub fn oakcommon_videoparams_init_basic(width: c_int, height: c_int) -> OakVideoParams;
/// `oakcommon_videoparams_init_with_time_base`.
pub fn oakcommon_videoparams_init_with_time_base(
width: c_int,
height: c_int,
time_base_num: i64,
time_base_den: i64,
) -> OakVideoParams;
/// `oakcommon_videoparams_free` (NULL/empty no-op).
pub fn oakcommon_videoparams_free(params: *mut OakVideoParams);
/// `oakcommon_videoparams_get_width`.
pub fn oakcommon_videoparams_get_width(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_get_height`.
pub fn oakcommon_videoparams_get_height(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_get_format`.
pub fn oakcommon_videoparams_get_format(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_get_time_base` (num/den out).
pub fn oakcommon_videoparams_get_time_base(
params: OakVideoParams,
out_num: *mut i64,
out_den: *mut i64,
) -> c_int;
/// `oakcommon_videoparams_set_width`.
pub fn oakcommon_videoparams_set_width(params: OakVideoParams, width: c_int);
/// `oakcommon_videoparams_set_height`.
pub fn oakcommon_videoparams_set_height(params: OakVideoParams, height: c_int);
/// `oakcommon_videoparams_set_format`.
pub fn oakcommon_videoparams_set_format(params: OakVideoParams, format: c_int);
/// `oakcommon_videoparams_get_is_valid`.
pub fn oakcommon_videoparams_get_is_valid(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_equals`.
pub fn oakcommon_videoparams_equals(a: OakVideoParams, b: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_set_time_base`.
pub fn oakcommon_videoparams_set_time_base(
params: OakVideoParams,
num: i64,
den: i64,
);
/// `oakcommon_videoparams_set_frame_rate`.
pub fn oakcommon_videoparams_set_frame_rate(
params: OakVideoParams,
num: i64,
den: i64,
);
/// `oakcommon_videoparams_set_pixel_aspect_ratio`.
pub fn oakcommon_videoparams_set_pixel_aspect_ratio(
params: OakVideoParams,
num: i64,
den: i64,
);
/// `oakcommon_videoparams_set_interlacing`.
pub fn oakcommon_videoparams_set_interlacing(params: OakVideoParams, interlacing: c_int);
/// `oakcommon_videoparams_set_duration`.
pub fn oakcommon_videoparams_set_duration(params: OakVideoParams, duration: i64);
/// `oakcommon_videoparams_set_start_time`.
pub fn oakcommon_videoparams_set_start_time(params: OakVideoParams, start_time: i64);
/// `oakcommon_videoparams_set_color_range`.
pub fn oakcommon_videoparams_set_color_range(params: OakVideoParams, color_range: c_int);
/// `oakcommon_videoparams_set_video_type`.
pub fn oakcommon_videoparams_set_video_type(params: OakVideoParams, video_type: c_int);
/// `oakcommon_videoparams_set_channel_count`.
pub fn oakcommon_videoparams_set_channel_count(params: OakVideoParams, channels: c_int);
/// `oakcommon_videoparams_set_color_primaries`.
pub fn oakcommon_videoparams_set_color_primaries(params: OakVideoParams, primaries: c_int);
/// `oakcommon_videoparams_set_color_transfer`.
pub fn oakcommon_videoparams_set_color_transfer(params: OakVideoParams, transfer: c_int);
/// `oakcommon_videoparams_set_premultiplied_alpha`.
pub fn oakcommon_videoparams_set_premultiplied_alpha(params: OakVideoParams, premultiplied: c_int);
/// `oakcommon_videoparams_set_enabled`.
pub fn oakcommon_videoparams_set_enabled(params: OakVideoParams, enabled: c_int);
/// `oakcommon_videoparams_static_get_bytes_per_pixel`.
pub fn oakcommon_videoparams_static_get_bytes_per_pixel(format: c_int) -> c_int;
/// `oakcommon_videoparams_frame_rate_as_time_base`.
pub fn oakcommon_videoparams_frame_rate_as_time_base(
frame_rate_num: i64,
frame_rate_den: i64,
out_num: *mut i64,
out_den: *mut i64,
);
/// `oakcommon_videoparams_get_stream_index`.
pub fn oakcommon_videoparams_get_stream_index(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_set_stream_index`.
pub fn oakcommon_videoparams_set_stream_index(params: OakVideoParams, index: c_int);
/// `oakcommon_videoparams_get_divider`.
pub fn oakcommon_videoparams_get_divider(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_set_divider`.
pub fn oakcommon_videoparams_set_divider(params: OakVideoParams, divider: c_int);
// NOTE: the remaining video getters below take the value-style form the
// crate's existing bridge uses (the real oakcommon headers use out-pointer
// args); `get_frame_rate` needs both values so it keeps the out pair.
/// `oakcommon_videoparams_get_frame_rate` (frame-rate num/den out).
pub fn oakcommon_videoparams_get_frame_rate(
params: OakVideoParams,
out_num: *mut c_int,
out_den: *mut c_int,
) -> c_int;
/// `oakcommon_videoparams_get_duration` (time-base units).
pub fn oakcommon_videoparams_get_duration(params: OakVideoParams) -> i64;
/// `oakcommon_videoparams_get_channel_count`.
pub fn oakcommon_videoparams_get_channel_count(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_get_color_primaries`.
pub fn oakcommon_videoparams_get_color_primaries(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_get_color_transfer`.
pub fn oakcommon_videoparams_get_color_transfer(params: OakVideoParams) -> c_int;
/// `oakcommon_videoparams_get_interlacing` (`Interlacing` value).
pub fn oakcommon_videoparams_get_interlacing(params: OakVideoParams) -> c_int;
/// `oakcore_audioparams_create` (pointer-based; timebase 1/sample_rate).
pub fn oakcore_audioparams_create(
sample_rate: c_int,
channel_layout: u64,
format: c_int,
) -> *mut OakAudioParams;
/// `oakcore_audioparams_free` (NULL no-op).
pub fn oakcore_audioparams_free(params: *mut OakAudioParams);
/// `oakcore_audioparams_sample_rate`.
pub fn oakcore_audioparams_sample_rate(params: *const OakAudioParams) -> c_int;
/// `oakcore_audioparams_set_sample_rate`.
pub fn oakcore_audioparams_set_sample_rate(params: *mut OakAudioParams, sample_rate: c_int);
/// `oakcore_audioparams_channel_layout`.
pub fn oakcore_audioparams_channel_layout(params: *const OakAudioParams) -> u64;
/// `oakcore_audioparams_set_channel_layout`.
pub fn oakcore_audioparams_set_channel_layout(params: *mut OakAudioParams, layout: u64);
/// `oakcore_audioparams_set_time_base`.
pub fn oakcore_audioparams_set_time_base(
params: *mut OakAudioParams,
num: c_int,
den: c_int,
);
/// `oakcore_audioparams_set_format`.
pub fn oakcore_audioparams_set_format(params: *mut OakAudioParams, format: c_int);
/// `oakcore_audioparams_set_stream_index`.
pub fn oakcore_audioparams_set_stream_index(params: *mut OakAudioParams, index: c_int);
/// `oakcore_audioparams_set_duration`.
pub fn oakcore_audioparams_set_duration(params: *mut OakAudioParams, duration: i64);
/// `oakcore_audioparams_channel_count`.
pub fn oakcore_audioparams_channel_count(params: *const OakAudioParams) -> c_int;
/// `oakcore_audioparams_format`.
pub fn oakcore_audioparams_format(params: *const OakAudioParams) -> c_int;
/// `oakcore_audioparams_stream_index`.
pub fn oakcore_audioparams_stream_index(params: *const OakAudioParams) -> c_int;
/// `oakcore_audioparams_duration`.
pub fn oakcore_audioparams_duration(params: *const OakAudioParams) -> i64;
/// `oakcore_audioparams_is_valid`.
pub fn oakcore_audioparams_is_valid(params: *const OakAudioParams) -> c_int;
/// `oakcore_audioparams_time_base` (newly allocated rational; caller
/// releases with `oakcore_rational_free`).
pub fn oakcore_audioparams_time_base(params: *const OakAudioParams) -> *mut c_void;
/// `oakcore_rational_numerator`.
pub fn oakcore_rational_numerator(rational: *const c_void) -> c_int;
/// `oakcore_rational_denominator`.
pub fn oakcore_rational_denominator(rational: *const c_void) -> c_int;
/// `oakcore_rational_free` (NULL no-op).
pub fn oakcore_rational_free(rational: *mut c_void);
/// `oakcommon_subtitleparams_get_stream_index`.
pub fn oakcommon_subtitleparams_get_stream_index(params: OakSubtitleParams) -> c_int;
/// `oakcommon_subtitleparams_generate_ass_header`.
pub fn oakcommon_subtitleparams_generate_ass_header(
params: OakSubtitleParams,
width: c_int,
height: c_int,
);
/// `oakcommon_subtitleparams_add_subtitle`.
pub fn oakcommon_subtitleparams_add_subtitle(params: OakSubtitleParams, text: *const c_char);
/// `oakcommon_config_get_int`.
pub fn oakcommon_config_get_int(group: *const c_char, key: *const c_char, default: c_int) -> c_int;
/// `oakcommon_config_get_bool`.
pub fn oakcommon_config_get_bool(group: *const c_char, key: *const c_char, default: c_int) -> c_int;
/// `oakcommon_config_get` (two-stage string access).
pub fn oakcommon_config_get(
group: *const c_char,
key: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakcommon_filefunctions_init`.
pub fn oakcommon_filefunctions_init();
/// `oakcommon_filefunctions_get_configuration_location` (two-stage).
pub fn oakcommon_filefunctions_get_configuration_location(
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakcommon_filefunctions_get_unique_file_identifier`.
pub fn oakcommon_filefunctions_get_unique_file_identifier(path: *const c_char) -> i64;
/// `oakcommon_filefunctions_get_application_path` (two-stage).
pub fn oakcommon_filefunctions_get_application_path(
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakcommon_filefunctions_free` (frees an internally cached string).
pub fn oakcommon_filefunctions_free(ptr: *mut c_void);
/// `oakcommon_colortransform_init_output`.
pub fn oakcommon_colortransform_init_output(
src_colorspace: c_int,
src_trc: c_int,
dst_colorspace: c_int,
dst_trc: c_int,
premultiplied: c_int,
chroma_coeffs: *const c_void,
) -> OakVideoParams;
/// `oakcommon_colortransform_get_output`.
pub fn oakcommon_colortransform_get_output(params: OakVideoParams, out: *mut OakVideoParams);
/// `oakcommon_colortransform_free`.
pub fn oakcommon_colortransform_free(params: *mut OakVideoParams);
/// `oakcommon_ffmpegutils_get_native_sample_format`.
pub fn oakcommon_ffmpegutils_get_native_sample_format(sample_format: c_int) -> c_int;
/// `oakcommon_ffmpegutils_get_compatible_pixel_format`.
pub fn oakcommon_ffmpegutils_get_compatible_pixel_format(format: c_int) -> c_int;
/// `oakcommon_ffmpegutils_get_ffmpeg_pixel_format`.
pub fn oakcommon_ffmpegutils_get_ffmpeg_pixel_format(format: c_int) -> c_int;
/// `oakcommon_ffmpegutils_get_ffmpeg_sample_format`.
pub fn oakcommon_ffmpegutils_get_ffmpeg_sample_format(format: c_int) -> c_int;
/// `oakcommon_ffmpegutils_get_compatible_bridge_pixel_format`.
pub fn oakcommon_ffmpegutils_get_compatible_bridge_pixel_format(format: c_int) -> c_int;
/// `oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space`.
pub fn oakcommon_ffmpegutils_convert_jpeg_space_to_regular_space(format: c_int) -> c_int;
/// `oakcommon_oiioutils_init`.
pub fn oakcommon_oiioutils_init();
/// `oakcommon_oiioutils_get_oiio_base_type_from_format`.
pub fn oakcommon_oiioutils_get_oiio_base_type_from_format(format: c_int) -> c_int;
/// `oakcommon_oiioutils_get_format_from_oiio_basetype`.
pub fn oakcommon_oiioutils_get_format_from_oiio_basetype(basetype: c_int) -> c_int;
/// `oakcommon_oiioutils_get_pixel_aspect_ratio` (num/den out).
pub fn oakcommon_oiioutils_get_pixel_aspect_ratio(
width: c_int,
height: c_int,
out_num: *mut c_int,
out_den: *mut c_int,
) -> c_int;
/// `oakcommon_oiioutils_free`.
pub fn oakcommon_oiioutils_free();
}
+35
View File
@@ -0,0 +1,35 @@
// 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/>.
//! C ABI imports from the other oak modules.
//!
//! The codec module links against oakcommon and oakrender at the C ABI.
//! Every signature below mirrors the corresponding public header
//! verbatim and is resolved at link time. The by-value handle structs
//! (`OakVideoParams`, `OakRenderTexture`, …) are `#[repr(C)]` mirrors of
//! the `{ctx, addref, release, abi_version}` layout so the codec crate
//! can hold and hand them across the FFI boundary without translation.
pub mod common;
pub mod render;
// In-memory mocks for the oakcommon/oakrender C ABI so the crate links
// and is testable under `cargo test` (where those dylibs are absent).
// The `test-stubs` feature additionally compiles the oakcore_*/oakrender_*
// host-mocks for consumer test binaries (e.g. oaknode's) that link this
// crate directly — those symbols are not provided by any Rust crate.
#[cfg(any(test, feature = "test-stubs"))]
pub mod test_stubs;
+144
View File
@@ -0,0 +1,144 @@
// 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/>.
//! oakrender C ABI imports (display textures, renderers, cancel atoms).
//!
//! The OIIO/FFmpeg decoders push frames to a `DisplayTexture` and poll a
//! `CancelAtom`; both are oakrender refcounted handles with the standard
//! `{ctx, addref, release, abi_version}` layout. `oakrender_video_params`
//! is a flattened POD the decoders construct to describe the frame.
use std::ffi::{c_char, c_int, c_void};
use crate::handle::CHandle;
/// `OakRenderTexture` — refcounted GPU texture handle.
pub type OakRenderTexture = CHandle;
/// `OakCancelAtom` — refcounted cancellation atom handle.
pub type OakCancelAtom = CHandle;
/// `OakRenderRenderer` — refcounted display-renderer handle.
pub type OakRenderRenderer = CHandle;
/// `OakCodecFrame` — refcounted CPU-frame handle shared with oakrender.
pub type OakCodecFrame = CHandle;
// Refcounted opaque handles; thread-safe in the C library.
/// `oakrender_video_params` — flattened POD of `olive::VideoParams`
/// passed into oakrender; see `include/render/renderer.h`.
#[repr(C)]
pub struct oakrender_video_params {
/// Width in pixels.
pub width: c_int,
/// Height in pixels.
pub height: c_int,
/// Frame-duration numerator (e.g. 1001/30000 s).
pub time_base_num: c_int,
/// Frame-duration denominator.
pub time_base_den: c_int,
/// `olive::PixelFormat::Format`.
pub format: c_int,
/// Pixel-aspect numerator.
pub pixel_aspect_num: c_int,
/// Pixel-aspect denominator.
pub pixel_aspect_den: c_int,
/// `olive::VideoParams::Interlacing`.
pub interlacing: c_int,
/// `olive::VideoParams::ColorRange`.
pub color_range: c_int,
/// Preview-resolution divider (1 = full).
pub divider: c_int,
/// `olive::VideoParams::Type` (0 = video).
pub video_type: c_int,
/// 0/1 premultiplied alpha.
pub premultiplied_alpha: c_int,
}
extern "C" {
/// `oakrender_cancelatom_init`.
pub fn oakrender_cancelatom_init() -> OakCancelAtom;
/// `oakrender_cancelatom_free` (NULL/empty no-op).
pub fn oakrender_cancelatom_free(atom: *mut OakCancelAtom);
/// `oakrender_cancelatom_is_cancelled`.
pub fn oakrender_cancelatom_is_cancelled(atom: OakCancelAtom) -> c_int;
/// `oakrender_cancelatom_heard_cancel`.
pub fn oakrender_cancelatom_heard_cancel(atom: OakCancelAtom) -> c_int;
/// `oakrender_cancelatom_cancel`.
pub fn oakrender_cancelatom_cancel(atom: OakCancelAtom);
/// `oakrender_cancelatom_get_native`.
pub fn oakrender_cancelatom_get_native(atom: OakCancelAtom) -> *mut c_void;
/// `oakrender_display_texture_create`.
pub fn oakrender_display_texture_create(
renderer: OakRenderRenderer,
params: *const oakrender_video_params,
data: *const c_void,
linesize: c_int,
) -> OakRenderTexture;
/// `oakrender_display_texture_retain`.
pub fn oakrender_display_texture_retain(texture: OakRenderTexture) -> OakRenderTexture;
/// `oakrender_display_texture_free` (NULL/empty no-op).
pub fn oakrender_display_texture_free(texture: *mut OakRenderTexture);
/// `oakrender_display_texture_upload`.
pub fn oakrender_display_texture_upload(texture: OakRenderTexture) -> c_int;
/// `oakrender_display_texture_download`.
pub fn oakrender_display_texture_download(
texture: OakRenderTexture,
pixels: *mut c_void,
linesize: c_int,
) -> c_int;
/// `oakrender_display_texture_get_params`.
pub fn oakrender_display_texture_get_params(
texture: OakRenderTexture,
out: *mut oakrender_video_params,
) -> c_int;
/// `oakrender_display_texture_id`.
pub fn oakrender_display_texture_id(texture: OakRenderTexture) -> c_int;
/// `oakrender_display_texture_is_dummy`.
pub fn oakrender_display_texture_is_dummy(texture: OakRenderTexture) -> c_int;
/// `oakrender_display_texture_get_frame` (two-stage frame access).
pub fn oakrender_display_texture_get_frame(
texture: OakRenderTexture,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
/// `oakrender_codec_frame_width`.
pub fn oakrender_codec_frame_width(frame: OakCodecFrame) -> c_int;
/// `oakrender_codec_frame_height`.
pub fn oakrender_codec_frame_height(frame: OakCodecFrame) -> c_int;
/// `oakrender_codec_frame_fb_format`.
pub fn oakrender_codec_frame_fb_format(frame: OakCodecFrame) -> c_int;
/// `oakrender_codec_frame_free` (NULL/empty no-op).
pub fn oakrender_codec_frame_free(frame: *mut OakCodecFrame);
/// `oakrender_codec_frame_allocate`.
pub fn oakrender_codec_frame_allocate(frame: OakCodecFrame) -> c_int;
/// `oakrender_codec_frame_linesize_bytes`.
pub fn oakrender_codec_frame_linesize_bytes(frame: OakCodecFrame) -> c_int;
/// `oakrender_codec_frame_is_allocated`.
pub fn oakrender_codec_frame_is_allocated(frame: OakCodecFrame) -> c_int;
/// `oakrender_display_renderer_blit_color_managed`.
pub fn oakrender_display_renderer_blit_color_managed(
renderer: OakRenderRenderer,
job: *const c_void,
dst_texture: OakRenderTexture,
params: *const oakrender_video_params,
) -> c_int;
}
File diff suppressed because it is too large Load Diff
+341
View File
@@ -0,0 +1,341 @@
// 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 std::ffi::CString;
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 (the bridge
/// returns a 64-bit id directly).
fn unique_file_identifier(filename: &str) -> String {
let c = match CString::new(filename) {
Ok(c) => c,
Err(_) => return String::new(),
};
// # Safety: `c` is a valid NUL-terminated C string alive for the call.
let id = unsafe {
crate::bridge::common::oakcommon_filefunctions_get_unique_file_identifier(c.as_ptr())
};
format!("{}", id)
}
/// 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()
}
fn fnv1a64(bytes: &[u8]) -> u64 {
let mut h: u64 = 14695981039346656037;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(1099511628211);
}
h
}
#[test]
fn unique_identifier_matches_bridge_hash() {
// The test stub computes an FNV-1a-64 of the path bytes.
let expected = format!("{}", fnv1a64(b"media.mp4") as i64);
assert_eq!(unique_file_identifier("media.mp4"), expected);
// Deterministic: same input, same id.
assert_eq!(
unique_file_identifier("media.mp4"),
unique_file_identifier("media.mp4")
);
// Different input, different id.
assert_ne!(
unique_file_identifier("media.mp4"),
unique_file_identifier("other.mp4")
);
}
#[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 cache = temp_subdir("names");
let id = fnv1a64(b"media.mp4") as i64;
let base = format!("{}-0.48000.0.3", id);
let f0 = m
.get_conform_filename(&cache, "media.mp4", 0, 48000, 0x3, 0, 0)
.unwrap();
let f1 = m
.get_conform_filename(&cache, "media.mp4", 0, 48000, 0x3, 0, 1)
.unwrap();
assert_eq!(f0, format!("{}/{}.0.pcm", cache, base));
assert_eq!(f1, format!("{}/{}.1.pcm", cache, base));
// Out of range.
assert!(matches!(
m.get_conform_filename(&cache, "media.mp4", 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);
}
}
+720
View File
@@ -0,0 +1,720 @@
// 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 `oakcore_rs::SampleBuffer` (which the
//! crate does not export).
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use oakcore_rs::{Rational, TimeRange};
use crate::bridge::render::{OakCancelAtom, OakRenderTexture};
use crate::footagedescription::FootageDescription;
use crate::frame::Frame;
/// `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` handle that codec only stores and
/// compares, never dereferences or retains (borrowed pointer).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CodecStream {
filename: String,
stream: i32,
block: Option<crate::bridge::common::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<crate::bridge::common::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<crate::bridge::common::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 refcounted handle that backs the public API wraps an
/// `Arc<dyn Decoder>`.
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<&OakCancelAtom>,
) -> 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<&OakCancelAtom>,
) -> 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<&OakCancelAtom>,
) -> 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<&OakCancelAtom>,
) -> 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. The ffi
/// decoder tests inject through `crate::ffi::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() -> std::sync::MutexGuard<'static, ()> {
crate::ffi::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),
"dir/img007.jpg"
);
// No digit run: number appended with no padding (C++ behavior).
assert_eq!(
transform_image_sequence_file_name("frame.png", 3),
"frame3.png"
);
// All-digit stem: whole run is replaced.
assert_eq!(
transform_image_sequence_file_name("0001.png", 7),
"0007.png"
);
}
#[test]
fn k_any_timecode_is_rational_min() {
let tc = k_any_timecode();
assert_eq!(tc.numerator(), -2147483647);
assert_eq!(tc.denominator(), 1);
}
#[test]
fn registry_lists_oiio_then_ffmpeg() {
let _g = registry_guard();
let list = receive_list_of_all_decoders();
let ids: Vec<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);
}
}
+341
View File
@@ -0,0 +1,341 @@
// 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 oakcore_rs::{PixelFormat, SampleFormat};
use crate::encodingparams::EncodingParams;
use crate::frame::Frame;
/// `olive::Encoder` — encoder trait. Backs the refcounted encoder handle.
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 handle 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
+70
View File
@@ -0,0 +1,70 @@
// 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;
/// Current ABI version stamped into every oakcodec handle.
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)]
pub enum Error {
/// Null handle or invalid argument.
Invalid,
/// Wrong state.
State,
/// Operation failed (context string is log-only).
Failed(String),
/// Not found.
NotFound,
/// Out of memory.
NoMem,
/// The operation was 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,
}
}
}
+201
View File
@@ -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));
}
}
+366
View File
@@ -0,0 +1,366 @@
// 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 oakcore_rs::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());
}
}
+250
View File
@@ -0,0 +1,250 @@
// 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/>.
//! `include/codec/conform.h` exports.
//!
//! Complete inventory: create_instance / destroy_instance / get_state /
//! filename_count / filename_at. `OAKCODEC_CONFORM_*` macros are the
//! states.
//!
//! # CPP-PARITY
//! The C++ `c_api/conform.cpp` reports `OAKCODEC_E_STATE` when the
//! singleton is absent (`!ConformManager::instance()`); the Rust
//! [`crate::conformmanager::ConformManager::instance`] is a lazy
//! `'static` singleton that can never be absent, so that branch cannot
//! trigger.
use std::ffi::{c_char, c_int};
use crate::conformmanager::{ConformManager, ConformState};
use crate::handle;
/// `OAKCODEC_CONFORM_EXISTS`.
pub const OAKCODEC_CONFORM_EXISTS: c_int = 0;
/// `OAKCODEC_CONFORM_GENERATING`.
pub const OAKCODEC_CONFORM_GENERATING: c_int = 1;
/// `OAKCODEC_CONFORM_UNAVAILABLE`.
pub const OAKCODEC_CONFORM_UNAVAILABLE: c_int = 2;
/// `oakcodec_conform_create_instance`: create the singleton (always
/// present here, so a no-op).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_conform_create_instance() -> c_int {
handle::guard_raw(|| {
let _ = ConformManager::instance();
crate::error::OAKCODEC_OK
})
}
/// `oakcodec_conform_destroy_instance`: destroy the singleton (the Rust
/// manager is stateless, so a no-op).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_conform_destroy_instance() -> c_int {
handle::guard_raw(|| crate::error::OAKCODEC_OK)
}
/// `oakcodec_conform_get_state`: query the conform state of one audio
/// stream.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_conform_get_state(
cache_path: *const c_char,
source_filename: *const c_char,
stream_index: c_int,
sample_rate: c_int,
channel_layout: u64,
sample_format: c_int,
wait: c_int,
) -> c_int {
handle::guard_raw(|| {
let cache = match crate::ffi::c_str(cache_path) {
Some(c) if !c.is_empty() => c,
_ => return crate::error::OAKCODEC_E_INVALID,
};
let source = match crate::ffi::c_str(source_filename) {
Some(s) if !s.is_empty() => s,
_ => return crate::error::OAKCODEC_E_INVALID,
};
let m = ConformManager::instance();
match m.get_conform_state(
&cache,
&source,
stream_index,
sample_rate,
channel_layout,
sample_format,
wait != 0,
) {
Ok(ConformState::Exists) => OAKCODEC_CONFORM_EXISTS,
Ok(ConformState::Generating) => OAKCODEC_CONFORM_GENERATING,
Ok(ConformState::Unavailable) | Err(_) => OAKCODEC_CONFORM_UNAVAILABLE,
}
})
}
/// `oakcodec_conform_filename_count`: number of conform files for the
/// given stream/params; 0 on invalid arguments.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_conform_filename_count(
cache_path: *const c_char,
source_filename: *const c_char,
stream_index: c_int,
sample_rate: c_int,
channel_layout: u64,
sample_format: c_int,
) -> c_int {
handle::guard_raw(|| {
let cache = match crate::ffi::c_str(cache_path) {
Some(c) if !c.is_empty() => c,
_ => return 0,
};
let source = match crate::ffi::c_str(source_filename) {
Some(s) if !s.is_empty() => s,
_ => return 0,
};
let m = ConformManager::instance();
m.get_conform_filename_count(
&cache,
&source,
stream_index,
sample_rate,
channel_layout,
sample_format,
) as c_int
})
}
/// `oakcodec_conform_filename_at`: the `index`-th conform filename
/// (two-stage string).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_conform_filename_at(
cache_path: *const c_char,
source_filename: *const c_char,
stream_index: c_int,
sample_rate: c_int,
channel_layout: u64,
sample_format: c_int,
index: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
handle::guard_raw(|| {
let cache = match crate::ffi::c_str(cache_path) {
Some(c) if !c.is_empty() => c,
_ => return crate::error::OAKCODEC_E_INVALID,
};
let source = match crate::ffi::c_str(source_filename) {
Some(s) if !s.is_empty() => s,
_ => return crate::error::OAKCODEC_E_INVALID,
};
let m = ConformManager::instance();
match m.get_conform_filename(
&cache,
&source,
stream_index,
sample_rate,
channel_layout,
sample_format,
index as usize,
) {
Ok(f) => super::string_out(&f, buf, buf_size),
Err(crate::error::Error::NotFound) => crate::error::OAKCODEC_E_NOT_FOUND,
Err(_) => crate::error::OAKCODEC_E_FAILED,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conformmanager::test_util::REG_LOCK;
use crate::error::{OAKCODEC_E_INVALID, OAKCODEC_E_NOT_FOUND};
fn cstr(s: &str) -> std::ffi::CString {
std::ffi::CString::new(s).unwrap()
}
fn temp_cache(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("oakcodec_ffi_conform_{}_{}", name, std::process::id()));
let _ = std::fs::create_dir_all(&dir);
dir.to_string_lossy().into_owned()
}
#[test]
fn create_destroy_instance_ok() {
let _g = crate::ffi::lock_tests();
assert_eq!(unsafe { oakcodec_conform_create_instance() }, crate::error::OAKCODEC_OK);
assert_eq!(unsafe { oakcodec_conform_destroy_instance() }, crate::error::OAKCODEC_OK);
}
#[test]
fn get_state_maps_states() {
let _g = crate::ffi::lock_tests();
let _g = REG_LOCK.lock().unwrap();
// No registrar and no files -> UNAVAILABLE.
let cache = cstr(&temp_cache("state"));
let src = cstr("media.mp4");
let rc = unsafe { oakcodec_conform_get_state(cache.as_ptr(), src.as_ptr(), 0, 48000, 0x3, 0, 0) };
assert_eq!(rc, OAKCODEC_CONFORM_UNAVAILABLE);
// Invalid arguments -> E_INVALID.
let rc = unsafe { oakcodec_conform_get_state(std::ptr::null(), src.as_ptr(), 0, 48000, 0x3, 0, 0) };
assert_eq!(rc, OAKCODEC_E_INVALID);
let empty = cstr("");
let rc = unsafe { oakcodec_conform_get_state(empty.as_ptr(), src.as_ptr(), 0, 48000, 0x3, 0, 0) };
assert_eq!(rc, OAKCODEC_E_INVALID);
// Write the conform files -> EXISTS.
let m = ConformManager::instance();
for i in 0..2 {
let f = m
.get_conform_filename(&temp_cache("state"), "media.mp4", 0, 48000, 0x3, 0, i)
.unwrap();
std::fs::write(&f, b"pcm").unwrap();
}
let rc = unsafe { oakcodec_conform_get_state(cache.as_ptr(), src.as_ptr(), 0, 48000, 0x3, 0, 0) };
assert_eq!(rc, OAKCODEC_CONFORM_EXISTS);
}
#[test]
fn filename_count_and_at() {
let _g = crate::ffi::lock_tests();
let cache = cstr(&temp_cache("names"));
let src = cstr("media.mp4");
// Stereo -> 2 files.
let rc = unsafe { oakcodec_conform_filename_count(cache.as_ptr(), src.as_ptr(), 0, 48000, 0x3, 0) };
assert_eq!(rc, 2);
// Invalid args -> 0 (not an error).
let rc = unsafe { oakcodec_conform_filename_count(std::ptr::null(), src.as_ptr(), 0, 48000, 0x3, 0) };
assert_eq!(rc, 0);
// filename_at round-trips the deterministic name.
let mut buf = [0i8; 512];
let rc = unsafe { oakcodec_conform_filename_at(cache.as_ptr(), src.as_ptr(), 0, 48000, 0x3, 0, 0, buf.as_mut_ptr(), 512) };
assert!(rc > 0);
let name = crate::ffi::c_str(buf.as_ptr()).unwrap();
assert!(name.ends_with(".0.pcm"));
// Out-of-range index -> E_NOT_FOUND.
let rc = unsafe { oakcodec_conform_filename_at(cache.as_ptr(), src.as_ptr(), 0, 48000, 0x3, 0, 5, buf.as_mut_ptr(), 512) };
assert_eq!(rc, OAKCODEC_E_NOT_FOUND);
// Invalid args -> E_INVALID.
let rc = unsafe { oakcodec_conform_filename_at(std::ptr::null(), src.as_ptr(), 0, 48000, 0x3, 0, 0, buf.as_mut_ptr(), 512) };
assert_eq!(rc, OAKCODEC_E_INVALID);
}
}
File diff suppressed because it is too large Load Diff
+781
View File
@@ -0,0 +1,781 @@
// 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/>.
//! `include/codec/encoder.h` exports.
//!
//! Complete inventory: init / free / set_video_option / open / write_video
//! / write_audio / write_subtitle / flush / last_error /
//! get_desired_pixel_format / export_format_get_extension /
//! encoding_generate_matrix.
//!
//! # CPP-PARITY
//! The C++ `c_api/encoder.cpp` box holds an `olive::Encoder` plus the
//! flattened `EncodingParams`; the Rust equivalent boxes `Mutex<EncoderBox>`
//! and carries an extra `last_error` field because the Rust `Encoder` trait
//! has no `get_error()` (the C++ reads the message off the encoder). The
//! `oakcodec_encoding_params` POD below mirrors the header verbatim; note
//! that the crate's `EncodingParams::video_pixel_format` / `audio_sample_format`
//! are typed enums, so `to_native` converts the int fields.
use std::ffi::{c_char, c_int};
use std::sync::{Arc, Mutex};
use oakcore_rs::PixelFormat;
use crate::encodingparams::EncodingParams;
use crate::encoder::Encoder;
use crate::handle::{self, CHandle};
/// `oakcodec_encoding_params` — flattened POD mirror of `include/codec/
/// encoder.h` (all fields; a zeroed struct describes an all-tracks-disabled
/// configuration). Field names and order mirror the header verbatim.
#[allow(missing_docs)]
#[repr(C)]
pub struct oakcodec_encoding_params {
pub filename: [u8; 1024],
pub format: c_int,
pub video_enabled: c_int,
pub video_codec: c_int,
pub video_width: c_int,
pub video_height: c_int,
pub video_time_base_num: c_int,
pub video_time_base_den: c_int,
pub video_pixel_format: c_int,
pub video_interlacing: c_int,
pub video_pixel_aspect_num: c_int,
pub video_pixel_aspect_den: c_int,
pub video_bit_rate: i64,
pub video_min_bit_rate: i64,
pub video_max_bit_rate: i64,
pub video_buffer_size: i64,
pub video_threads: c_int,
pub video_pix_fmt: [u8; 64],
pub video_is_image_sequence: c_int,
pub video_scaling_method: c_int,
pub audio_enabled: c_int,
pub audio_codec: c_int,
pub audio_sample_rate: c_int,
pub audio_channel_layout: u64,
pub audio_sample_format: c_int,
pub audio_bit_rate: i64,
pub subtitles_enabled: c_int,
pub subtitles_codec: c_int,
pub subtitles_are_sidecar: c_int,
pub subtitles_sidecar_format: c_int,
pub color_transform_output: [u8; 256],
pub export_length_num: c_int,
pub export_length_den: c_int,
pub has_custom_range: c_int,
pub custom_range_in_num: i64,
pub custom_range_in_den: i64,
pub custom_range_out_num: i64,
pub custom_range_out_den: i64,
}
/// Box behind an encoder handle (`EncoderBox` in `c_api/encoder.cpp`).
struct EncoderBox {
encoder: Option<Arc<dyn Encoder>>,
params: EncodingParams,
/// Per-codec video options set via `oakcodec_encoder_set_video_option`
/// between init and open. Kept here (not in [`EncodingParams`], which
/// is a byte-exact mirror of the C POD).
video_opts: Vec<(String, String)>,
open: bool,
flushed: bool,
/// Last error detail (the C++ reads it off the encoder's `get_error`).
last_error: String,
}
/// Convert an `OakPixelFormat` int code to a [`PixelFormat`].
fn pixel_format_from_i32(v: c_int) -> PixelFormat {
match v {
0 => PixelFormat::U8,
1 => PixelFormat::U10,
2 => PixelFormat::U16,
3 => PixelFormat::F16,
4 => PixelFormat::F32,
_ => PixelFormat::Invalid,
}
}
/// Flatten the C POD into the crate's [`EncodingParams`]
/// (`to_native` in `c_api/encoder.cpp`).
fn to_native(p: &oakcodec_encoding_params) -> EncodingParams {
let mut n = EncodingParams::default();
n.filename = p.filename;
n.format = p.format;
n.video_enabled = p.video_enabled;
n.video_codec = p.video_codec;
n.video_width = p.video_width;
n.video_height = p.video_height;
n.video_time_base_num = p.video_time_base_num;
n.video_time_base_den = p.video_time_base_den;
n.video_pixel_format = pixel_format_from_i32(p.video_pixel_format);
n.video_interlacing = p.video_interlacing;
n.video_pixel_aspect_num = p.video_pixel_aspect_num;
n.video_pixel_aspect_den = p.video_pixel_aspect_den;
n.video_bit_rate = p.video_bit_rate;
n.video_min_bit_rate = p.video_min_bit_rate;
n.video_max_bit_rate = p.video_max_bit_rate;
n.video_buffer_size = p.video_buffer_size;
n.video_threads = p.video_threads;
n.video_pix_fmt = p.video_pix_fmt;
n.video_is_image_sequence = p.video_is_image_sequence;
n.video_scaling_method = crate::encodingparams::scaling_from_i32(p.video_scaling_method);
n.audio_enabled = p.audio_enabled;
n.audio_codec = p.audio_codec;
n.audio_sample_rate = p.audio_sample_rate;
n.audio_channel_layout = p.audio_channel_layout;
n.audio_sample_format = crate::encodingparams::sample_format_from_i32(p.audio_sample_format);
n.audio_bit_rate = p.audio_bit_rate;
n.subtitles_enabled = p.subtitles_enabled;
n.subtitles_codec = p.subtitles_codec;
n.subtitles_are_sidecar = p.subtitles_are_sidecar;
n.subtitles_sidecar_format = p.subtitles_sidecar_format;
n.color_transform_output = p.color_transform_output;
n.export_length_num = p.export_length_num;
n.export_length_den = p.export_length_den;
n.has_custom_range = p.has_custom_range;
n.custom_range_in_num = p.custom_range_in_num;
n.custom_range_in_den = p.custom_range_in_den;
n.custom_range_out_num = p.custom_range_out_num;
n.custom_range_out_den = p.custom_range_out_den;
n
}
/// `EncodingParams::is_valid` — the C++ `src/codec/src/encoder.h` checks
/// only that at least one track is enabled.
fn is_valid(p: &EncodingParams) -> bool {
p.video_enabled != 0 || p.audio_enabled != 0 || p.subtitles_enabled != 0
}
/// `oakcodec_encoder_init`: create an encoder for `params` (count 1);
/// empty handle when the configuration is invalid.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoder_init(params: *const oakcodec_encoding_params) -> CHandle {
handle::guard_handle(|| {
if params.is_null() {
return Ok(CHandle::null());
}
let native = to_native(unsafe { &*params });
if !is_valid(&native) {
return Ok(CHandle::null());
}
Ok(handle::make_owned(Mutex::new(EncoderBox {
encoder: None,
params: native,
video_opts: Vec::new(),
open: false,
flushed: false,
last_error: String::new(),
})))
})
}
/// `oakcodec_encoder_free`: NULL/empty no-op; nulls `ctx` afterwards.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoder_free(encoder: *mut CHandle) {
handle::guard_void(|| super::free_handle(encoder));
}
/// `oakcodec_encoder_set_video_option`: set a per-codec video option
/// (e.g. "crf"); only valid between init and open.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoder_set_video_option(
encoder: CHandle,
key: *const c_char,
value: *const c_char,
) -> c_int {
handle::guard(|| {
let b = super::get_box::<Mutex<EncoderBox>>(&encoder).ok_or(crate::error::Error::Invalid)?;
let key = match crate::ffi::c_str(key) {
Some(k) => k,
None => return Err(crate::error::Error::Invalid),
};
let mut b = b.lock().unwrap();
if b.open {
return Err(crate::error::Error::State);
}
let value = crate::ffi::c_str(value).unwrap_or_default();
// `EncodingParams::set_video_option` replaces an existing key.
b.video_opts.retain(|(k, _)| k != &key);
b.video_opts.push((key, value));
Ok(())
})
}
/// `oakcodec_encoder_open`: create the encoder for the configured params,
/// apply the video options and open the output.
///
/// # CPP-PARITY
/// The C++ `open()` calls `create_from_params` (which applies the options
/// internally) then `open()`. The Rust trait separates `configure`, so it
/// is invoked between the two; the box's `video_opts` (set between init
/// and open) are stored for future wiring but not yet passed to
/// `configure` (no trait channel carries them in the interim).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoder_open(encoder: CHandle) -> c_int {
handle::guard(|| {
let b = super::get_box::<Mutex<EncoderBox>>(&encoder).ok_or(crate::error::Error::Invalid)?;
let mut b = b.lock().unwrap();
if b.open {
return Err(crate::error::Error::State);
}
let e = match crate::encoder::create_from_params(&b.params) {
Some(e) => e,
None => {
b.last_error = "failed to create encoder".to_string();
return Err(crate::error::Error::Failed("failed to create encoder".to_string()));
}
};
if e.configure(&b.params).is_err() {
b.last_error = "failed to configure encoder".to_string();
return Err(crate::error::Error::Failed("failed to configure encoder".to_string()));
}
if e.open().is_err() {
b.last_error = "failed to open stream".to_string();
return Err(crate::error::Error::Failed("failed to open stream".to_string()));
}
b.encoder = Some(e);
b.open = true;
Ok(())
})
}
/// `oakcodec_encoder_write_video`: encode one video frame.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoder_write_video(encoder: CHandle, frame: CHandle) -> c_int {
handle::guard(|| {
let b = super::get_box::<Mutex<EncoderBox>>(&encoder).ok_or(crate::error::Error::Invalid)?;
let f = super::get_box::<Mutex<crate::frame::Frame>>(&frame)
.ok_or(crate::error::Error::Invalid)?;
let e = {
let b = b.lock().unwrap();
if !b.open || b.flushed || b.encoder.is_none() {
return Err(crate::error::Error::State);
}
b.encoder.as_ref().unwrap().clone()
};
let f = f.lock().unwrap();
e.write_video(&f)
.map_err(|_| crate::error::Error::Failed("write_video failed".to_string()))
})
}
/// `oakcodec_encoder_write_audio`: encode interleaved float audio samples.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoder_write_audio(
encoder: CHandle,
samples: *const f32,
frame_count: c_int,
) -> c_int {
handle::guard(|| {
let b = super::get_box::<Mutex<EncoderBox>>(&encoder).ok_or(crate::error::Error::Invalid)?;
if (samples.is_null() && frame_count > 0) || frame_count < 0 {
return Err(crate::error::Error::Invalid);
}
let (slice, enc) = {
let b = b.lock().unwrap();
if !b.open || b.flushed || b.encoder.is_none() {
return Err(crate::error::Error::State);
}
let channels = b.params.audio_channel_layout.count_ones();
if channels == 0 {
return Err(crate::error::Error::State);
}
let sample_count = (frame_count as usize).wrapping_mul(channels as usize);
let slice: &[f32] = if samples.is_null() {
&[]
} else {
// SAFETY: the caller guarantees `samples` holds
// `frame_count * channels` floats.
unsafe { std::slice::from_raw_parts(samples, sample_count) }
};
let e = b.encoder.as_ref().unwrap().clone();
(slice, e)
};
enc.write_audio(slice, frame_count)
.map_err(|_| crate::error::Error::Failed("write_audio failed".to_string()))
})
}
/// `oakcodec_encoder_write_subtitle`: encode one subtitle entry.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoder_write_subtitle(
encoder: CHandle,
text: *const c_char,
in_seconds: f64,
out_seconds: f64,
) -> c_int {
handle::guard(|| {
let b = super::get_box::<Mutex<EncoderBox>>(&encoder).ok_or(crate::error::Error::Invalid)?;
let text = match crate::ffi::c_str(text) {
Some(t) => t,
None => return Err(crate::error::Error::Invalid),
};
let e = {
let b = b.lock().unwrap();
if !b.open || b.flushed || b.encoder.is_none() {
return Err(crate::error::Error::State);
}
b.encoder.as_ref().unwrap().clone()
};
e.write_subtitle(&text, in_seconds, out_seconds)
.map_err(|_| crate::error::Error::Failed("write_subtitle failed".to_string()))
})
}
/// `oakcodec_encoder_flush`: flush the encoders, write the trailer and
/// close the file. Idempotent.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoder_flush(encoder: CHandle) -> c_int {
handle::guard(|| {
let b = super::get_box::<Mutex<EncoderBox>>(&encoder).ok_or(crate::error::Error::Invalid)?;
let mut b = b.lock().unwrap();
if !b.open {
return Err(crate::error::Error::State);
}
if b.flushed {
return Ok(());
}
let e = b.encoder.as_ref().unwrap().clone();
// The C++ ignores the close() result; the Rust interim surfaces it.
e.close()
.map_err(|_| crate::error::Error::Failed("close failed".to_string()))?;
b.flushed = true;
Ok(())
})
}
/// `oakcodec_encoder_last_error` (two-stage).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoder_last_error(
encoder: CHandle,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
handle::guard_raw(|| match super::get_box::<Mutex<EncoderBox>>(&encoder) {
Some(b) => {
let b = b.lock().unwrap();
super::string_out(&b.last_error, buf, buf_size)
}
None => super::string_out("", buf, buf_size),
})
}
/// `oakcodec_encoder_get_desired_pixel_format`: the pixel format the
/// encoder wants frames in, or -1 when unknown; `OAKCODEC_E_INVALID` for
/// an empty/invalid encoder.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoder_get_desired_pixel_format(encoder: CHandle) -> c_int {
handle::guard_raw(|| {
let b = match super::get_box::<Mutex<EncoderBox>>(&encoder) {
Some(b) => b,
None => return crate::error::OAKCODEC_E_INVALID,
};
let e = match &b.lock().unwrap().encoder {
Some(e) => e.clone(),
None => return crate::error::OAKCODEC_E_INVALID,
};
match e.desired_pixel_format() {
Some(p) => p as c_int,
None => -1,
}
})
}
/// `oakcodec_export_format_get_extension` (two-stage); unknown formats
/// yield the empty string.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_export_format_get_extension(
format: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
handle::guard_raw(|| {
let ext = match crate::exportformat::Format::from_i32(format) {
Some(f) => crate::exportformat::Format::get_extension(f),
None => String::new(),
};
super::string_out(&ext, buf, buf_size)
})
}
/// `oakcodec_encoding_generate_matrix`: scaling matrix for a scaling
/// method, row-major 4x4 `double` into `out_matrix[16]`.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_generate_matrix(
method: c_int,
src_width: c_int,
src_height: c_int,
dst_width: c_int,
dst_height: c_int,
out_matrix: *mut f64,
) -> c_int {
handle::guard(|| {
if out_matrix.is_null() {
return Err(crate::error::Error::Invalid);
}
let mut m = [0.0f64; 16];
crate::encodingparams::EncodingParams::generate_matrix(
crate::encodingparams::scaling_from_i32(method),
src_width,
src_height,
dst_width,
dst_height,
&mut m,
);
// SAFETY: the caller guarantees `out_matrix` holds 16 doubles.
unsafe { std::ptr::copy_nonoverlapping(m.as_ptr(), out_matrix, 16) };
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::common::oakcommon_videoparams_init_basic;
use crate::encoder::set_test_encoders;
use crate::error::{OAKCODEC_E_INVALID, OAKCODEC_E_STATE};
use crate::ffi::frame::{oakcodec_frame_allocate, oakcodec_frame_free, oakcodec_frame_init_with_params};
fn cstr(s: &str) -> std::ffi::CString {
std::ffi::CString::new(s).unwrap()
}
fn zeroed_params() -> oakcodec_encoding_params {
unsafe { std::mem::zeroed() }
}
fn valid_params() -> oakcodec_encoding_params {
let mut p = zeroed_params();
p.filename = {
let mut f = [0u8; 1024];
let name = b"out.mp4";
f[..name.len()].copy_from_slice(name);
f
};
p.format = 2; // MPEG-4
p.video_enabled = 1;
p.video_codec = 3;
p.video_width = 1920;
p.video_height = 1080;
p.video_time_base_num = 1;
p.video_time_base_den = 30;
p.video_pixel_format = 0; // U8
p.audio_enabled = 1;
p.audio_codec = 4;
p.audio_sample_rate = 48000;
p.audio_channel_layout = 0x3;
p.audio_sample_format = 10; // f32 packed
p
}
/// Fake encoder that accepts every operation.
struct FakeEncoder {
id: &'static str,
}
impl Encoder for FakeEncoder {
fn id(&self) -> String {
self.id.to_string()
}
fn supports_video(&self) -> bool {
true
}
fn supports_audio(&self) -> bool {
true
}
fn supports_subtitles(&self) -> bool {
true
}
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, _frame: &crate::frame::Frame) -> crate::error::Result<()> {
Ok(())
}
fn write_audio(&self, _samples: &[f32], _frame_count: i32) -> crate::error::Result<()> {
Ok(())
}
fn write_subtitle(
&self,
_text: &str,
_in_seconds: f64,
_out_seconds: f64,
) -> crate::error::Result<()> {
Ok(())
}
fn flush(&self) -> crate::error::Result<()> {
Ok(())
}
fn desired_pixel_format(&self) -> Option<PixelFormat> {
Some(PixelFormat::U8)
}
fn desired_sample_format(&self) -> Option<oakcore_rs::SampleFormat> {
None
}
fn filename(&self) -> String {
"out.mp4".to_string()
}
}
#[test]
fn init_open_write_flush_golden() {
let _g = crate::ffi::lock_tests();
set_test_encoders(vec![std::sync::Arc::new(FakeEncoder { id: "fake" })]);
let p = valid_params();
let before = handle::alive_count();
let mut h = unsafe { oakcodec_encoder_init(&p) };
assert!(!h.is_null());
assert_eq!(handle::alive_count(), before + 1);
// set_video_option between init and open.
let key = cstr("crf");
let val = cstr("18");
let rc = unsafe { oakcodec_encoder_set_video_option(h, key.as_ptr(), val.as_ptr()) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
let rc = unsafe { oakcodec_encoder_open(h) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
// write_video with a real frame handle.
let params = unsafe { oakcommon_videoparams_init_basic(16, 16) };
let mut fh = unsafe { oakcodec_frame_init_with_params(params) };
assert_eq!(unsafe { oakcodec_frame_allocate(fh) }, crate::error::OAKCODEC_OK);
let rc = unsafe { oakcodec_encoder_write_video(h, fh) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
// write_audio: stereo interleaved floats.
let mut samples = [0f32; 64];
let rc = unsafe { oakcodec_encoder_write_audio(h, samples.as_ptr(), 32) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
// write_subtitle.
let text = cstr("hello");
let rc = unsafe { oakcodec_encoder_write_subtitle(h, text.as_ptr(), 0.0, 2.5) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
// desired pixel format from the fake.
assert_eq!(unsafe { oakcodec_encoder_get_desired_pixel_format(h) }, 0); // U8
// flush is idempotent.
let rc = unsafe { oakcodec_encoder_flush(h) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
let rc = unsafe { oakcodec_encoder_flush(h) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
// Writes after flush -> E_STATE.
let rc = unsafe { oakcodec_encoder_write_video(h, fh) };
assert_eq!(rc, OAKCODEC_E_STATE);
let rc = unsafe { oakcodec_encoder_write_audio(h, samples.as_ptr(), 32) };
assert_eq!(rc, OAKCODEC_E_STATE);
let rc = unsafe { oakcodec_encoder_write_subtitle(h, text.as_ptr(), 0.0, 1.0) };
assert_eq!(rc, OAKCODEC_E_STATE);
unsafe { oakcodec_frame_free(&mut fh) };
unsafe { oakcodec_encoder_free(&mut h) };
assert!(h.is_null());
assert_eq!(handle::alive_count(), before);
set_test_encoders(Vec::new());
}
#[test]
fn init_invalid_config_and_null_params() {
let _g = crate::ffi::lock_tests();
set_test_encoders(Vec::new());
// NULL params -> empty handle.
let mut h = unsafe { oakcodec_encoder_init(std::ptr::null()) };
assert!(h.is_null());
// All tracks disabled -> empty handle (is_valid).
let p = zeroed_params();
let mut h = unsafe { oakcodec_encoder_init(&p) };
assert!(h.is_null());
// Audio-only config is valid.
let mut p = zeroed_params();
p.format = 7; // WAV
p.audio_enabled = 1;
p.audio_sample_rate = 44100;
p.audio_channel_layout = 0x4;
p.audio_sample_format = 10;
let before = handle::alive_count();
let mut h = unsafe { oakcodec_encoder_init(&p) };
assert!(!h.is_null());
assert_eq!(handle::alive_count(), before + 1);
unsafe { oakcodec_encoder_free(&mut h) };
assert_eq!(handle::alive_count(), before);
}
#[test]
fn open_errors_and_state_machine() {
let _g = crate::ffi::lock_tests();
// Production path: an unknown export format cannot create an encoder.
set_test_encoders(Vec::new());
let mut p = valid_params();
p.format = 99; // unknown format -> create_from_params returns None
let mut h = unsafe { oakcodec_encoder_init(&p) };
assert!(!h.is_null());
let rc = unsafe { oakcodec_encoder_open(h) };
assert_eq!(rc, crate::error::OAKCODEC_E_FAILED);
let mut err = [0i8; 128];
unsafe { oakcodec_encoder_last_error(h, err.as_mut_ptr(), 128) };
assert_eq!(crate::ffi::c_str(err.as_ptr()).as_deref(), Some("failed to create encoder"));
// Empty handle -> E_INVALID; last_error empty.
let empty = CHandle::null();
assert_eq!(unsafe { oakcodec_encoder_open(empty) }, OAKCODEC_E_INVALID);
assert_eq!(unsafe { oakcodec_encoder_set_video_option(empty, cstr("crf").as_ptr(), cstr("18").as_ptr()) }, OAKCODEC_E_INVALID);
assert_eq!(unsafe { oakcodec_encoder_get_desired_pixel_format(empty) }, OAKCODEC_E_INVALID);
let rc = unsafe { oakcodec_encoder_last_error(empty, err.as_mut_ptr(), 128) };
assert_eq!(rc, 1);
assert_eq!(crate::ffi::c_str(err.as_ptr()).as_deref(), Some(""));
unsafe { oakcodec_encoder_free(&mut h) };
}
#[test]
fn state_and_argument_errors_with_fake() {
let _g = crate::ffi::lock_tests();
set_test_encoders(vec![std::sync::Arc::new(FakeEncoder { id: "fake" })]);
let p = valid_params();
let mut h = unsafe { oakcodec_encoder_init(&p) };
assert!(!h.is_null());
// set_video_option with a NULL key -> E_INVALID.
let rc = unsafe { oakcodec_encoder_set_video_option(h, std::ptr::null(), std::ptr::null()) };
assert_eq!(rc, OAKCODEC_E_INVALID);
// Writes before open -> E_STATE.
let params = unsafe { oakcommon_videoparams_init_basic(4, 4) };
let mut fh = unsafe { oakcodec_frame_init_with_params(params) };
let rc = unsafe { oakcodec_encoder_write_video(h, fh) };
assert_eq!(rc, OAKCODEC_E_STATE);
let rc = unsafe { oakcodec_encoder_flush(h) };
assert_eq!(rc, OAKCODEC_E_STATE);
// Null samples with a positive count is an argument error (checked
// before the state, matching the C++ validation order).
let rc = unsafe { oakcodec_encoder_write_audio(h, std::ptr::null(), 8) };
assert_eq!(rc, OAKCODEC_E_INVALID);
// Valid args before open -> E_STATE.
let mut pre = [0f32; 8];
let rc = unsafe { oakcodec_encoder_write_audio(h, pre.as_ptr(), 4) };
assert_eq!(rc, OAKCODEC_E_STATE);
unsafe { oakcodec_frame_free(&mut fh) };
unsafe { oakcodec_encoder_free(&mut h) };
set_test_encoders(Vec::new());
}
#[test]
fn write_audio_argument_validation() {
let _g = crate::ffi::lock_tests();
set_test_encoders(vec![std::sync::Arc::new(FakeEncoder { id: "fake" })]);
let p = valid_params();
let mut h = unsafe { oakcodec_encoder_init(&p) };
assert_eq!(unsafe { oakcodec_encoder_open(h) }, crate::error::OAKCODEC_OK);
// NULL samples with a positive frame count -> E_INVALID.
let rc = unsafe { oakcodec_encoder_write_audio(h, std::ptr::null(), 8) };
assert_eq!(rc, OAKCODEC_E_INVALID);
// Negative frame count -> E_INVALID.
let samples = [0f32; 8];
let rc = unsafe { oakcodec_encoder_write_audio(h, samples.as_ptr(), -1) };
assert_eq!(rc, OAKCODEC_E_INVALID);
unsafe { oakcodec_encoder_free(&mut h) };
set_test_encoders(Vec::new());
}
#[test]
fn write_audio_zero_channels_is_state() {
let _g = crate::ffi::lock_tests();
set_test_encoders(vec![std::sync::Arc::new(FakeEncoder { id: "fake" })]);
// Audio enabled but empty channel layout -> E_STATE at write time.
let mut p = zeroed_params();
p.format = 7;
p.audio_enabled = 1;
p.audio_sample_rate = 44100;
p.audio_channel_layout = 0;
p.audio_sample_format = 10;
let mut h = unsafe { oakcodec_encoder_init(&p) };
assert!(!h.is_null());
assert_eq!(unsafe { oakcodec_encoder_open(h) }, crate::error::OAKCODEC_OK);
let mut samples = [0f32; 8];
let rc = unsafe { oakcodec_encoder_write_audio(h, samples.as_ptr(), 4) };
assert_eq!(rc, OAKCODEC_E_STATE);
unsafe { oakcodec_encoder_free(&mut h) };
set_test_encoders(Vec::new());
}
#[test]
fn export_format_extension_and_generate_matrix() {
let mut buf = [0i8; 64];
let rc = unsafe { oakcodec_export_format_get_extension(2, buf.as_mut_ptr(), 64) };
assert_eq!(rc, 4); // "mp4" + NUL
assert_eq!(crate::ffi::c_str(buf.as_ptr()).as_deref(), Some("mp4"));
// Unknown format -> empty string (size 1 for the NUL).
let rc = unsafe { oakcodec_export_format_get_extension(99, buf.as_mut_ptr(), 64) };
assert_eq!(rc, 1);
assert_eq!(crate::ffi::c_str(buf.as_ptr()).as_deref(), Some(""));
// Truncation rule: small buffer writes buf_size-1 chars + NUL.
let rc = unsafe { oakcodec_export_format_get_extension(2, buf.as_mut_ptr(), 3) };
assert_eq!(rc, 4); // required size unchanged
assert_eq!(crate::ffi::c_str(buf.as_ptr()).as_deref(), Some("mp"));
// generate_matrix: Stretch (1) is the identity.
let mut m = [9.0f64; 16];
let rc = unsafe { oakcodec_encoding_generate_matrix(1, 1920, 1080, 1280, 720, m.as_mut_ptr()) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
assert_eq!(m[0], 1.0);
assert_eq!(m[5], 1.0);
assert_eq!(m[10], 1.0);
assert_eq!(m[15], 1.0);
// Fit (0) with a square source into a 2:1 destination scales x.
let mut m = [0.0f64; 16];
let rc = unsafe { oakcodec_encoding_generate_matrix(0, 1000, 1000, 2000, 1000, m.as_mut_ptr()) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
assert!((m[0] - 0.5).abs() < 1e-9);
assert_eq!(m[5], 1.0);
// NULL out_matrix -> E_INVALID.
let rc = unsafe { oakcodec_encoding_generate_matrix(0, 1, 1, 2, 2, std::ptr::null_mut()) };
assert_eq!(rc, OAKCODEC_E_INVALID);
}
}
+547
View File
@@ -0,0 +1,547 @@
// 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/>.
//! `include/codec/format.h` exports.
//!
//! Complete inventory: format_count / format_name / format_extension /
//! format_{video,audio,subtitle}_codec_{count,at} / codec_name /
//! codec_is_still_image / codec_is_lossless / pix_fmt_{count,at,index} /
//! sample_format_{count,at} / filename_contains_digit_placeholder /
//! image_sequence_digit_count / filename_remove_digit_placeholder.
//!
//! # CPP-PARITY
//! The C++ `c_api/format.cpp` mirrors the facade (oakengine/encoding.h)
//! against the `olive::ExportFormat` / `olive::ExportCodec` / `olive::Encoder`
//! statics. The Rust tables live in [`crate::exportformat`] /
//! [`crate::exportcodec`]; the encoder pixel-format query is bridge-dependent
//! on the C++ side and returns empty here (see
//! `Format::get_pixel_formats_for_codec`), so `oakcodec_encoding_pix_fmt_*`
//! report 0/`E_NOT_FOUND`/0 (the preferred-format fallback) like the C++
//! base `Encoder` default. The filename helpers mirror the `Encoder`
//! statics in [`crate::encoder`].
use std::ffi::{c_char, c_int};
use crate::error::{OAKCODEC_E_INVALID, OAKCODEC_E_NOT_FOUND};
use crate::exportcodec::Codec;
use crate::exportformat::Format;
use crate::handle;
/// `oakcodec_encoding_format_count` — `ExportFormat::k_format_count`.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_format_count() -> c_int {
handle::guard_raw(|| Format::Count as c_int)
}
/// `oakcodec_encoding_format_name` (two-stage).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_format_name(
format: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
handle::guard_raw(|| match Format::from_i32(format) {
Some(f) => super::string_out(&Format::get_name(f), buf, buf_size),
None => OAKCODEC_E_INVALID,
})
}
/// `oakcodec_encoding_format_extension` (two-stage).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_format_extension(
format: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
handle::guard_raw(|| match Format::from_i32(format) {
Some(f) => super::string_out(&Format::get_extension(f), buf, buf_size),
None => OAKCODEC_E_INVALID,
})
}
/// `oakcodec_encoding_format_video_codec_count`.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_format_video_codec_count(
format: c_int,
) -> c_int {
handle::guard_raw(|| match Format::from_i32(format) {
Some(f) => Format::get_video_codecs(f).len() as c_int,
None => OAKCODEC_E_INVALID,
})
}
/// `oakcodec_encoding_format_video_codec_at`.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_format_video_codec_at(
format: c_int,
index: c_int,
) -> c_int {
handle::guard_raw(|| {
let f = match Format::from_i32(format) {
Some(f) => f,
None => return OAKCODEC_E_INVALID,
};
let list = Format::get_video_codecs(f);
if index < 0 || index as usize >= list.len() {
return OAKCODEC_E_NOT_FOUND;
}
list[index as usize] as c_int
})
}
/// `oakcodec_encoding_format_audio_codec_count`.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_format_audio_codec_count(
format: c_int,
) -> c_int {
handle::guard_raw(|| match Format::from_i32(format) {
Some(f) => Format::get_audio_codecs(f).len() as c_int,
None => OAKCODEC_E_INVALID,
})
}
/// `oakcodec_encoding_format_audio_codec_at`.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_format_audio_codec_at(
format: c_int,
index: c_int,
) -> c_int {
handle::guard_raw(|| {
let f = match Format::from_i32(format) {
Some(f) => f,
None => return OAKCODEC_E_INVALID,
};
let list = Format::get_audio_codecs(f);
if index < 0 || index as usize >= list.len() {
return OAKCODEC_E_NOT_FOUND;
}
list[index as usize] as c_int
})
}
/// `oakcodec_encoding_format_subtitle_codec_count`.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_format_subtitle_codec_count(
format: c_int,
) -> c_int {
handle::guard_raw(|| match Format::from_i32(format) {
Some(f) => Format::get_subtitle_codecs(f).len() as c_int,
None => OAKCODEC_E_INVALID,
})
}
/// `oakcodec_encoding_format_subtitle_codec_at`.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_format_subtitle_codec_at(
format: c_int,
index: c_int,
) -> c_int {
handle::guard_raw(|| {
let f = match Format::from_i32(format) {
Some(f) => f,
None => return OAKCODEC_E_INVALID,
};
let list = Format::get_subtitle_codecs(f);
if index < 0 || index as usize >= list.len() {
return OAKCODEC_E_NOT_FOUND;
}
list[index as usize] as c_int
})
}
/// `oakcodec_encoding_codec_name` (two-stage).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_codec_name(
codec: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
handle::guard_raw(|| match Codec::from_i32(codec) {
Some(c) => super::string_out(&Codec::get_codec_name(c), buf, buf_size),
None => OAKCODEC_E_INVALID,
})
}
/// `oakcodec_encoding_codec_is_still_image` (0 for an invalid codec).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_codec_is_still_image(codec: c_int) -> c_int {
handle::guard_raw(|| match Codec::from_i32(codec) {
Some(c) => Codec::is_codec_a_still_image(c) as c_int,
None => 0,
})
}
/// `oakcodec_encoding_codec_is_lossless` (0 for an invalid codec).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_codec_is_lossless(codec: c_int) -> c_int {
handle::guard_raw(|| match Codec::from_i32(codec) {
Some(c) => Codec::is_codec_lossless(c) as c_int,
None => 0,
})
}
/// `oakcodec_encoding_pix_fmt_count`.
///
/// # CPP-PARITY
/// The C++ side instantiates the format's encoder and asks it for the codec's
/// pixel formats; the Rust table is empty (see [`Format::get_pixel_formats_for_codec`]),
/// so the count is 0 — the same as the C++ base `Encoder` default and the
/// C++ result for encoder-less codecs.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_pix_fmt_count(
format: c_int,
codec: c_int,
) -> c_int {
handle::guard_raw(|| {
let (f, c) = match (Format::from_i32(format), Codec::from_i32(codec)) {
(Some(f), Some(c)) => (f, c),
_ => return OAKCODEC_E_INVALID,
};
Format::get_pixel_formats_for_codec(f, c).len() as c_int
})
}
/// `oakcodec_encoding_pix_fmt_at` (two-stage).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_pix_fmt_at(
format: c_int,
codec: c_int,
index: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
handle::guard_raw(|| {
let (f, c) = match (Format::from_i32(format), Codec::from_i32(codec)) {
(Some(f), Some(c)) => (f, c),
_ => return OAKCODEC_E_INVALID,
};
let list = Format::get_pixel_formats_for_codec(f, c);
if index < 0 || index as usize >= list.len() {
return OAKCODEC_E_NOT_FOUND;
}
// Interim: the Rust list carries no names yet, so this arm is
// unreachable while the list is empty (C++ queries the FFmpeg bridge).
super::string_out(&list[index as usize].to_string(), buf, buf_size)
})
}
/// `oakcodec_encoding_pix_fmt_index` — index of `pix_fmt` in `codec`'s
/// supported pixel formats; 0 (the preferred format) for an invalid codec,
/// a NULL/empty `pix_fmt`, or when not found.
///
/// # CPP-PARITY
/// The C++ side searches the FFmpeg encoder's list. The Rust table is empty,
/// so every lookup falls back to 0 — the documented behavior for "absent".
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_pix_fmt_index(
codec: c_int,
pix_fmt: *const c_char,
) -> c_int {
handle::guard_raw(|| {
if Codec::from_i32(codec).is_none() {
return 0;
}
match crate::ffi::c_str(pix_fmt) {
Some(s) if !s.is_empty() => {
// Interim: empty table (see module doc) -> preferred index 0.
let _ = s;
0
}
_ => 0,
}
})
}
/// `oakcodec_encoding_sample_format_count`.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_sample_format_count(
format: c_int,
codec: c_int,
) -> c_int {
handle::guard_raw(|| {
let (f, c) = match (Format::from_i32(format), Codec::from_i32(codec)) {
(Some(f), Some(c)) => (f, c),
_ => return OAKCODEC_E_INVALID,
};
Format::get_sample_formats_for_codec(f, c).len() as c_int
})
}
/// `oakcodec_encoding_sample_format_at` — an
/// `olive::core::SampleFormat::Format` value.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_sample_format_at(
format: c_int,
codec: c_int,
index: c_int,
) -> c_int {
handle::guard_raw(|| {
let (f, c) = match (Format::from_i32(format), Codec::from_i32(codec)) {
(Some(f), Some(c)) => (f, c),
_ => return OAKCODEC_E_INVALID,
};
let list = Format::get_sample_formats_for_codec(f, c);
if index < 0 || index as usize >= list.len() {
return OAKCODEC_E_NOT_FOUND;
}
list[index as usize] as c_int
})
}
/// `oakcodec_encoding_filename_contains_digit_placeholder` (0 for NULL).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_filename_contains_digit_placeholder(
filename: *const c_char,
) -> c_int {
handle::guard_raw(|| match crate::ffi::c_str(filename) {
Some(f) => crate::encoder::filename_contains_digit_placeholder(&f) as c_int,
None => 0,
})
}
/// `oakcodec_encoding_image_sequence_digit_count` (0 for NULL).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_image_sequence_digit_count(
filename: *const c_char,
) -> c_int {
handle::guard_raw(|| match crate::ffi::c_str(filename) {
Some(f) => crate::encoder::image_sequence_placeholder_digit_count(&f),
None => 0,
})
}
/// `oakcodec_encoding_filename_remove_digit_placeholder` (two-stage).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_encoding_filename_remove_digit_placeholder(
filename: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
handle::guard_raw(|| match crate::ffi::c_str(filename) {
Some(f) => super::string_out(
&crate::encoder::filename_remove_digit_placeholder(&f),
buf,
buf_size,
),
None => OAKCODEC_E_INVALID,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn cstr(s: &str) -> std::ffi::CString {
std::ffi::CString::new(s).unwrap()
}
#[test]
fn format_metadata_exports() {
let _g = crate::ffi::lock_tests();
let mut buf = [0i8; 64];
// Count matches the 15-entry table (0..=14, Count = 15).
assert_eq!(unsafe { oakcodec_encoding_format_count() }, 15);
// Matroska (1): "Matroska Video" / "mkv".
let rc = unsafe { oakcodec_encoding_format_name(1, buf.as_mut_ptr(), 64) };
assert_eq!(rc, 15); // "Matroska Video" (14) + NUL
assert_eq!(crate::ffi::c_str(buf.as_ptr()).as_deref(), Some("Matroska Video"));
let rc = unsafe { oakcodec_encoding_format_extension(1, buf.as_mut_ptr(), 64) };
assert_eq!(rc, 4); // "mkv" + NUL
assert_eq!(crate::ffi::c_str(buf.as_ptr()).as_deref(), Some("mkv"));
// Truncation rule on a two-stage getter.
let rc = unsafe { oakcodec_encoding_format_name(1, buf.as_mut_ptr(), 4) };
assert_eq!(rc, 15); // required size unchanged
assert_eq!(crate::ffi::c_str(buf.as_ptr()).as_deref(), Some("Mat"));
// Invalid format -> E_INVALID.
assert_eq!(
unsafe { oakcodec_encoding_format_name(-1, buf.as_mut_ptr(), 64) },
OAKCODEC_E_INVALID
);
assert_eq!(
unsafe { oakcodec_encoding_format_extension(15, buf.as_mut_ptr(), 64) },
OAKCODEC_E_INVALID
); // Count is not a real format
assert_eq!(
unsafe { oakcodec_encoding_format_extension(99, buf.as_mut_ptr(), 64) },
OAKCODEC_E_INVALID
);
}
#[test]
fn format_codec_lists_exports() {
let _g = crate::ffi::lock_tests();
// MPEG-4 video (2) carries H.264/H.264RGB/H.265.
assert_eq!(unsafe { oakcodec_encoding_format_video_codec_count(2) }, 3);
assert_eq!(
unsafe { oakcodec_encoding_format_video_codec_at(2, 0) },
1 // H.264
);
// WAV (7) has no video codecs but PCM (13) audio.
assert_eq!(unsafe { oakcodec_encoding_format_video_codec_count(7) }, 0);
assert_eq!(unsafe { oakcodec_encoding_format_audio_codec_count(7) }, 1);
assert_eq!(
unsafe { oakcodec_encoding_format_audio_codec_at(7, 0) },
13 // PCM
);
// SRT (13): subtitle-only, with the SRT (17) codec.
assert_eq!(unsafe { oakcodec_encoding_format_audio_codec_count(13) }, 0);
assert_eq!(unsafe { oakcodec_encoding_format_subtitle_codec_count(13) }, 1);
assert_eq!(
unsafe { oakcodec_encoding_format_subtitle_codec_at(13, 0) },
17 // SRT
);
// Failure paths.
assert_eq!(
unsafe { oakcodec_encoding_format_video_codec_count(-1) },
OAKCODEC_E_INVALID
);
assert_eq!(
unsafe { oakcodec_encoding_format_video_codec_at(2, -1) },
OAKCODEC_E_NOT_FOUND
);
assert_eq!(
unsafe { oakcodec_encoding_format_video_codec_at(2, 3) },
OAKCODEC_E_NOT_FOUND
);
assert_eq!(
unsafe { oakcodec_encoding_format_audio_codec_at(7, 1) },
OAKCODEC_E_NOT_FOUND
);
assert_eq!(
unsafe { oakcodec_encoding_format_subtitle_codec_at(13, 1) },
OAKCODEC_E_NOT_FOUND
);
}
#[test]
fn codec_metadata_exports() {
let _g = crate::ffi::lock_tests();
let mut buf = [0i8; 64];
let rc = unsafe { oakcodec_encoding_codec_name(1, buf.as_mut_ptr(), 64) };
assert_eq!(rc, 6); // "H.264" (5) + NUL
assert_eq!(crate::ffi::c_str(buf.as_ptr()).as_deref(), Some("H.264"));
assert_eq!(unsafe { oakcodec_encoding_codec_name(-1, buf.as_mut_ptr(), 64) }, OAKCODEC_E_INVALID);
// Still images: PNG (5) yes, H.264 (1) no.
assert_eq!(unsafe { oakcodec_encoding_codec_is_still_image(5) }, 1);
assert_eq!(unsafe { oakcodec_encoding_codec_is_still_image(1) }, 0);
// Lossless: PCM (13) yes, AAC (12) no.
assert_eq!(unsafe { oakcodec_encoding_codec_is_lossless(13) }, 1);
assert_eq!(unsafe { oakcodec_encoding_codec_is_lossless(12) }, 0);
// Invalid codec -> 0 (not an error) for both flags.
assert_eq!(unsafe { oakcodec_encoding_codec_is_still_image(99) }, 0);
assert_eq!(unsafe { oakcodec_encoding_codec_is_lossless(99) }, 0);
}
#[test]
fn pixel_and_sample_format_exports() {
let _g = crate::ffi::lock_tests();
let mut buf = [0i8; 64];
// Bad arguments -> E_INVALID.
assert_eq!(
unsafe { oakcodec_encoding_pix_fmt_count(-1, 1) },
OAKCODEC_E_INVALID
);
assert_eq!(
unsafe { oakcodec_encoding_pix_fmt_count(2, 99) },
OAKCODEC_E_INVALID
);
// Interim: the Rust pixel-format table is empty, so the count is 0
// and any index is E_NOT_FOUND (see the module doc).
assert_eq!(unsafe { oakcodec_encoding_pix_fmt_count(2, 1) }, 0);
assert_eq!(
unsafe { oakcodec_encoding_pix_fmt_at(-1, 1, 0, buf.as_mut_ptr(), 64) },
OAKCODEC_E_INVALID
);
assert_eq!(
unsafe { oakcodec_encoding_pix_fmt_at(2, 1, 0, buf.as_mut_ptr(), 64) },
OAKCODEC_E_NOT_FOUND
);
// pix_fmt_index: absent/empty/NULL/invalid codec all yield 0.
assert_eq!(unsafe { oakcodec_encoding_pix_fmt_index(1, cstr("yuv420p").as_ptr()) }, 0);
assert_eq!(unsafe { oakcodec_encoding_pix_fmt_index(1, std::ptr::null()) }, 0);
assert_eq!(unsafe { oakcodec_encoding_pix_fmt_index(99, cstr("yuv420p").as_ptr()) }, 0);
// PCM (13) in WAV (7) exposes its native sample formats.
assert_eq!(unsafe { oakcodec_encoding_sample_format_count(7, 13) }, 6);
// f32 packed = 10 (oakcore SampleFormat values match the C++).
assert_eq!(unsafe { oakcodec_encoding_sample_format_at(7, 13, 4) }, 10);
// Out-of-range index -> E_NOT_FOUND; bad args -> E_INVALID.
assert_eq!(
unsafe { oakcodec_encoding_sample_format_at(7, 13, 6) },
OAKCODEC_E_NOT_FOUND
);
assert_eq!(
unsafe { oakcodec_encoding_sample_format_at(-1, 13, 0) },
OAKCODEC_E_INVALID
);
// Non-PCM codecs query the bridge on the C++ side; empty here.
assert_eq!(unsafe { oakcodec_encoding_sample_format_count(2, 12) }, 0);
}
#[test]
fn filename_helper_exports() {
let _g = crate::ffi::lock_tests();
let mut buf = [0i8; 128];
assert_eq!(
unsafe { oakcodec_encoding_filename_contains_digit_placeholder(cstr("/tmp/out_[#####].png").as_ptr()) },
1
);
assert_eq!(
unsafe { oakcodec_encoding_filename_contains_digit_placeholder(cstr("/tmp/out.png").as_ptr()) },
0
);
assert_eq!(unsafe { oakcodec_encoding_filename_contains_digit_placeholder(std::ptr::null()) }, 0);
assert_eq!(
unsafe { oakcodec_encoding_image_sequence_digit_count(cstr("/tmp/out_[#####].png").as_ptr()) },
5
);
assert_eq!(
unsafe { oakcodec_encoding_image_sequence_digit_count(cstr("/tmp/out.png").as_ptr()) },
0
);
assert_eq!(unsafe { oakcodec_encoding_image_sequence_digit_count(std::ptr::null()) }, 0);
let rc = unsafe {
oakcodec_encoding_filename_remove_digit_placeholder(
cstr("/tmp/out_[#####].png").as_ptr(),
buf.as_mut_ptr(),
128,
)
};
assert_eq!(rc, 13); // "/tmp/out.png" (12) + NUL
assert_eq!(crate::ffi::c_str(buf.as_ptr()).as_deref(), Some("/tmp/out.png"));
assert_eq!(
unsafe {
oakcodec_encoding_filename_remove_digit_placeholder(
std::ptr::null(),
buf.as_mut_ptr(),
128,
)
},
OAKCODEC_E_INVALID
);
}
}
+452
View File
@@ -0,0 +1,452 @@
// 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/>.
//! `include/codec/frame.h` exports.
//!
//! Complete inventory: frame_init / init_with_params / free / get_params /
//! set_params / allocate / is_allocated / data / const_data /
//! allocated_size / linesize_bytes / linesize_pixels / width / height /
//! format / channel_count / get_timestamp / set_timestamp /
//! debug_alive_count.
//!
//! # CPP-PARITY
//! The C++ `c_api/frame.cpp` boxes every `OakFrame` handle with an
//! `olive::FramePtr` (a shared pointer), so decoder-produced frames may
//! alias the decoder's internal cache. The Rust equivalent boxes
//! `Mutex<Frame>`; a decode that hands out a still-shared `Arc<Frame>`
//! therefore cannot be aliased here and reports an empty handle instead
//! (see `ffi::decoder`).
use std::ffi::{c_int, c_void};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::Mutex;
use oakcore_rs::Rational;
use crate::bridge::common::OakVideoParams;
use crate::frame::Frame;
use crate::handle::{self, CHandle};
/// `OAKCOMMON_PIXEL_FORMAT_INVALID` (oakcommon `common/videoparams.h`).
const OAKCOMMON_PIXEL_FORMAT_INVALID: c_int = -1;
/// `oakcodec_frame_init`: new frame with default (invalid) params,
/// refcount 1.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_init() -> CHandle {
handle::guard_handle(|| Ok(handle::make_owned(Mutex::new(Frame::new()))))
}
/// `oakcodec_frame_init_with_params`: new frame holding a copy of `params`
/// (the handle is addref'd internally); buffer unallocated.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_init_with_params(params: OakVideoParams) -> CHandle {
handle::guard_handle(|| {
Ok(handle::make_owned(Mutex::new(Frame::with_params(params))))
})
}
/// `oakcodec_frame_free`: NULL/empty no-op; nulls `ctx` afterwards.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_free(frame: *mut CHandle) {
handle::guard_void(|| super::free_handle(frame));
}
/// `oakcodec_frame_get_params`: copy of the frame's parameter set.
///
/// The copy is addref'd: the caller must release it with
/// `oakcommon_videoparams_free` (see the header contract). Test-stub
/// handles carry no `addref`, so the caller must not free them.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_get_params(
frame: CHandle,
out: *mut OakVideoParams,
) -> c_int {
handle::guard(|| {
if out.is_null() {
return Err(crate::error::Error::Invalid);
}
let f = super::get_box::<Mutex<Frame>>(&frame).ok_or(crate::error::Error::Invalid)?;
let f = f.lock().unwrap();
let p = f.params().cloned().ok_or(crate::error::Error::Invalid)?;
crate::frame::params_addref(&p);
// SAFETY: the caller guarantees `out` points to a writable
// `OakVideoParams`.
unsafe { *out = p };
Ok(())
})
}
/// `oakcodec_frame_set_params`: replace the parameter set (addref'd
/// internally); recomputes line sizes, does not reallocate.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_set_params(
frame: CHandle,
params: OakVideoParams,
) -> c_int {
handle::guard(|| {
let f = super::get_box::<Mutex<Frame>>(&frame).ok_or(crate::error::Error::Invalid)?;
f.lock().unwrap().set_params(params);
Ok(())
})
}
/// `oakcodec_frame_allocate`: allocate the pixel buffer from the current
/// params; `OAKCODEC_E_STATE` when the params are invalid.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_allocate(frame: CHandle) -> c_int {
handle::guard(|| {
let f = super::get_box::<Mutex<Frame>>(&frame).ok_or(crate::error::Error::Invalid)?;
f.lock().unwrap().allocate()
})
}
/// `oakcodec_frame_is_allocated`: 1 when the buffer is allocated.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_is_allocated(frame: CHandle) -> c_int {
handle::guard_raw(|| {
let f = match super::get_box::<Mutex<Frame>>(&frame) {
Some(f) => f,
None => return 0,
};
let f = f.lock().unwrap();
if f.is_allocated() {
1
} else {
0
}
})
}
/// `oakcodec_frame_data`: writable pixel buffer, NULL when
/// unallocated/empty.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_data(frame: CHandle) -> *mut c_void {
match catch_unwind(AssertUnwindSafe(|| unsafe { frame_data_inner(&frame) })) {
Ok(p) => p,
Err(_) => std::ptr::null_mut(),
}
}
/// `oakcodec_frame_const_data`: const variant of `oakcodec_frame_data`.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_const_data(frame: CHandle) -> *const c_void {
match catch_unwind(AssertUnwindSafe(|| unsafe { frame_const_data_inner(&frame) })) {
Ok(p) => p,
Err(_) => std::ptr::null_mut(),
}
}
unsafe fn frame_data_inner(frame: &CHandle) -> *mut c_void {
let f = match super::get_box::<Mutex<Frame>>(frame) {
Some(f) => f,
None => return std::ptr::null_mut(),
};
match f.lock().unwrap().data_mut() {
Some(d) => d.as_mut_ptr() as *mut c_void,
None => std::ptr::null_mut(),
}
}
unsafe fn frame_const_data_inner(frame: &CHandle) -> *const c_void {
let f = match super::get_box::<Mutex<Frame>>(frame) {
Some(f) => f,
None => return std::ptr::null(),
};
match f.lock().unwrap().data() {
Some(d) => d.as_ptr() as *const c_void,
None => std::ptr::null(),
}
}
/// `oakcodec_frame_allocated_size`: size of the pixel buffer in bytes
/// (0 when unallocated).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_allocated_size(frame: CHandle) -> c_int {
handle::guard_raw(|| {
let f = match super::get_box::<Mutex<Frame>>(&frame) {
Some(f) => f,
None => return 0,
};
f.lock().unwrap().allocated_size() as c_int
})
}
/// `oakcodec_frame_linesize_bytes`: distance between two rows in bytes
/// (0 when params are unset).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_linesize_bytes(frame: CHandle) -> c_int {
handle::guard_raw(|| {
let f = match super::get_box::<Mutex<Frame>>(&frame) {
Some(f) => f,
None => return 0,
};
f.lock().unwrap().linesize_bytes()
})
}
/// `oakcodec_frame_linesize_pixels`: distance between two rows in pixels.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_linesize_pixels(frame: CHandle) -> c_int {
handle::guard_raw(|| {
let f = match super::get_box::<Mutex<Frame>>(&frame) {
Some(f) => f,
None => return 0,
};
f.lock().unwrap().linesize_pixels()
})
}
/// `oakcodec_frame_width`: frame width (0 when params are empty).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_width(frame: CHandle) -> c_int {
handle::guard_raw(|| {
let f = match super::get_box::<Mutex<Frame>>(&frame) {
Some(f) => f,
None => return 0,
};
f.lock().unwrap().width()
})
}
/// `oakcodec_frame_height`: frame height (0 when params are empty).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_height(frame: CHandle) -> c_int {
handle::guard_raw(|| {
let f = match super::get_box::<Mutex<Frame>>(&frame) {
Some(f) => f,
None => return 0,
};
f.lock().unwrap().height()
})
}
/// `oakcodec_frame_format`: pixel format as an `OakPixelFormat` value;
/// `OAKCOMMON_PIXEL_FORMAT_INVALID` on an empty handle.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_format(frame: CHandle) -> c_int {
handle::guard_raw(|| {
let f = match super::get_box::<Mutex<Frame>>(&frame) {
Some(f) => f,
None => return OAKCOMMON_PIXEL_FORMAT_INVALID,
};
f.lock().unwrap().format() as c_int
})
}
/// `oakcodec_frame_channel_count`: plane channel count of the params
/// format (0 on an empty handle).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_channel_count(frame: CHandle) -> c_int {
handle::guard_raw(|| {
let f = match super::get_box::<Mutex<Frame>>(&frame) {
Some(f) => f,
None => return 0,
};
f.lock().unwrap().channel_count()
})
}
/// `oakcodec_frame_get_timestamp`: frame timestamp as a rational number
/// of seconds, written through `numerator`/`denominator`.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_get_timestamp(
frame: CHandle,
numerator: *mut c_int,
denominator: *mut c_int,
) -> c_int {
handle::guard(|| {
if numerator.is_null() || denominator.is_null() {
return Err(crate::error::Error::Invalid);
}
let f = super::get_box::<Mutex<Frame>>(&frame).ok_or(crate::error::Error::Invalid)?;
let f = f.lock().unwrap();
let ts = f.timestamp();
// SAFETY: both pointers were range-checked above.
unsafe {
*numerator = ts.numerator() as c_int;
*denominator = ts.denominator() as c_int;
}
Ok(())
})
}
/// `oakcodec_frame_set_timestamp`: replace the frame timestamp.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_frame_set_timestamp(
frame: CHandle,
numerator: c_int,
denominator: c_int,
) -> c_int {
handle::guard(|| {
let f = super::get_box::<Mutex<Frame>>(&frame).ok_or(crate::error::Error::Invalid)?;
f.lock()
.unwrap()
.set_timestamp(Rational::new(numerator as i64, denominator as i64));
Ok(())
})
}
/// `oakcodec_debug_alive_count`: number of live boxed handle objects
/// across all families (see `crate::handle::alive_count`).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_debug_alive_count() -> c_int {
handle::guard_raw(handle::alive_count)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::common::{
oakcommon_videoparams_get_height, oakcommon_videoparams_get_width,
oakcommon_videoparams_init_basic,
};
use crate::error::OAKCODEC_E_INVALID;
#[test]
fn frame_lifecycle_golden() {
let _g = crate::ffi::lock_tests();
let params = unsafe { oakcommon_videoparams_init_basic(100, 50) };
let before = handle::alive_count();
let mut h = unsafe { oakcodec_frame_init_with_params(params) };
assert!(!h.is_null());
// init -> exactly one more live box.
assert_eq!(handle::alive_count(), before + 1);
// get_params round-trips width/height through the stub.
let mut out = empty_params();
let rc = unsafe { oakcodec_frame_get_params(h, &mut out) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
// NOTE: stub handles carry no addref and `oakcommon_videoparams_free`
// would drop the shared box; the test keeps the copy alive for the
// frame's lifetime and does not free it.
assert_eq!(unsafe { oakcommon_videoparams_get_width(out.clone()) }, 100);
assert_eq!(unsafe { oakcommon_videoparams_get_height(out.clone()) }, 50);
assert_eq!(unsafe { oakcodec_frame_width(h) }, 100);
assert_eq!(unsafe { oakcodec_frame_height(h) }, 50);
assert_eq!(unsafe { oakcodec_frame_is_allocated(h) }, 0);
assert_eq!(unsafe { oakcodec_frame_data(h) }, std::ptr::null_mut());
let rc = unsafe { oakcodec_frame_allocate(h) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
assert_eq!(unsafe { oakcodec_frame_is_allocated(h) }, 1);
assert!(!unsafe { oakcodec_frame_data(h) }.is_null());
// U8 RGBA: 100px -> 4*128 bytes linesize.
assert_eq!(unsafe { oakcodec_frame_linesize_bytes(h) }, 4 * 128);
assert_eq!(unsafe { oakcodec_frame_allocated_size(h) }, (4 * 128) * 50);
// set_timestamp round-trip.
assert_eq!(unsafe { oakcodec_frame_set_timestamp(h, 1, 30) }, crate::error::OAKCODEC_OK);
let (mut num, mut den) = (0, 0);
assert_eq!(unsafe { oakcodec_frame_get_timestamp(h, &mut num, &mut den) }, crate::error::OAKCODEC_OK);
assert_eq!((num, den), (1, 30));
unsafe { oakcodec_frame_free(&mut h) };
assert!(h.is_null());
assert_eq!(handle::alive_count(), before);
}
#[test]
fn frame_errors_and_empty_handles() {
let _g = crate::ffi::lock_tests();
let empty = CHandle::null();
assert_eq!(unsafe { oakcodec_frame_width(empty) }, 0);
assert_eq!(unsafe { oakcodec_frame_format(empty) }, OAKCOMMON_PIXEL_FORMAT_INVALID);
assert_eq!(unsafe { oakcodec_frame_allocate(empty) }, OAKCODEC_E_INVALID);
assert_eq!(unsafe { oakcodec_frame_get_params(empty, std::ptr::null_mut()) }, OAKCODEC_E_INVALID);
// init_basic(0, 0) is not valid -> allocate rejects with E_STATE.
let params = unsafe { oakcommon_videoparams_init_basic(0, 0) };
let mut h = unsafe { oakcodec_frame_init_with_params(params) };
assert!(!h.is_null());
assert_eq!(unsafe { oakcodec_frame_allocate(h) }, crate::error::OAKCODEC_E_STATE);
assert_eq!(unsafe { oakcodec_frame_is_allocated(h) }, 0);
unsafe { oakcodec_frame_free(&mut h) };
}
#[test]
fn free_null_and_empty_are_noops() {
let _g = crate::ffi::lock_tests();
let before = handle::alive_count();
unsafe { oakcodec_frame_free(std::ptr::null_mut()) };
let mut empty = CHandle::null();
unsafe { oakcodec_frame_free(&mut empty) };
assert!(empty.is_null());
assert_eq!(handle::alive_count(), before);
}
#[test]
fn init_set_params_and_query_helpers() {
let _g = crate::ffi::lock_tests();
let before = handle::alive_count();
// Bare init (no params): invalid params, not allocated. The stub's
// default MockParams carries format 0 (U8); the empty-handle -1
// case is covered in `frame_errors_and_empty_handles`.
let mut h = unsafe { oakcodec_frame_init() };
assert!(!h.is_null());
assert_eq!(handle::alive_count(), before + 1);
assert_eq!(unsafe { oakcodec_frame_width(h) }, 0);
assert_eq!(unsafe { oakcodec_frame_height(h) }, 0);
assert_eq!(unsafe { oakcodec_frame_channel_count(h) }, 4);
assert_eq!(unsafe { oakcodec_frame_is_allocated(h) }, 0);
assert_eq!(unsafe { oakcodec_frame_allocated_size(h) }, 0);
assert_eq!(unsafe { oakcodec_frame_linesize_bytes(h) }, 0);
assert_eq!(unsafe { oakcodec_frame_linesize_pixels(h) }, 0);
assert_eq!(unsafe { oakcodec_frame_data(h) }, std::ptr::null_mut());
assert_eq!(unsafe { oakcodec_frame_const_data(h) }, std::ptr::null());
assert_eq!(unsafe { oakcodec_frame_allocate(h) }, crate::error::OAKCODEC_E_STATE);
// set_params replaces the parameter set and recomputes line sizes.
let params = unsafe { oakcommon_videoparams_init_basic(100, 50) };
let rc = unsafe { oakcodec_frame_set_params(h, params) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
assert_eq!(unsafe { oakcodec_frame_width(h) }, 100);
assert_eq!(unsafe { oakcodec_frame_height(h) }, 50);
assert_eq!(unsafe { oakcodec_frame_format(h) }, 0); // U8
assert_eq!(unsafe { oakcodec_frame_channel_count(h) }, 4);
assert_eq!(unsafe { oakcodec_frame_linesize_bytes(h) }, 4 * 128);
assert_eq!(unsafe { oakcodec_frame_linesize_pixels(h) }, 128);
// allocate -> data and const_data point at the buffer.
assert_eq!(unsafe { oakcodec_frame_allocate(h) }, crate::error::OAKCODEC_OK);
assert!(!unsafe { oakcodec_frame_data(h) }.is_null());
assert!(!unsafe { oakcodec_frame_const_data(h) }.is_null());
assert_eq!(unsafe { oakcodec_frame_allocated_size(h) }, (4 * 128) * 50);
// get_timestamp rejects NULL out pointers.
assert_eq!(
unsafe { oakcodec_frame_get_timestamp(h, std::ptr::null_mut(), std::ptr::null_mut()) },
OAKCODEC_E_INVALID
);
// debug_alive_count reports the live boxes (>= our own).
assert!(unsafe { oakcodec_debug_alive_count() } >= before + 1);
unsafe { oakcodec_frame_free(&mut h) };
assert_eq!(handle::alive_count(), before);
}
fn empty_params() -> OakVideoParams {
OakVideoParams {
ctx: std::ptr::null_mut(),
addref: None,
release: None,
abi_version: 0,
}
}
}
+119
View File
@@ -0,0 +1,119 @@
// 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/>.
//! C ABI export layer: implements `include/codec/*.h` verbatim.
//!
//! Organization: one submodule per public header (frame / decoder /
//! encoder / format / conform / proxy / task). The authoritative function
//! list is the header itself; each export only unwraps handles, calls the
//! safe Rust domains, and maps results through [`crate::handle::guard*`].
//! `include/codec/error.h` exports macros only, so it is folded into the
//! preamble below instead of getting its own submodule.
//!
//! Shared helpers live here: the two-stage string convention
//! ([`string_out`]), C-string decoding ([`c_str`]) and in-place handle
//! release ([`free_handle`]) — all mirroring the `c_api/*.cpp` helpers.
/// `include/codec/error.h` — macros only, no exported functions.
///
/// `OAKCODEC_OK` and the `OAKCODEC_E_*` codes are mirrored as
/// [`crate::error`] constants; `OAKCODEC_ABI_VERSION` lives in
/// [`crate::handle`].
pub mod conform;
pub mod decoder;
pub mod encoder;
pub mod format;
pub mod frame;
pub mod proxy;
pub mod task;
use std::ffi::{c_char, c_int};
#[cfg(test)]
use std::sync::Mutex;
use crate::handle::CHandle;
/// Serializes every ffi unit test: they share the global handle ALIVE
/// counter, the injected decoder/encoder registries and the probe error,
/// so exact `alive_count` assertions and registry injection require
/// serial execution. Held poison-tolerant (`into_inner`) so one failing
/// test cannot cascade-fail the rest.
#[cfg(test)]
pub(crate) static TEST_LOCK: Mutex<()> = Mutex::new(());
/// Poison-tolerant lock helper for the ffi tests.
#[cfg(test)]
pub(crate) fn lock_tests() -> std::sync::MutexGuard<'static, ()> {
TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// Two-stage string copy helper (`string_out` in every `c_api/*.cpp`).
///
/// Returns the required buffer size including the trailing NUL; when
/// `buf` is non-NULL and `buf_size > 0` the string is copied truncated to
/// `buf_size - 1` bytes and NUL-terminated.
pub(crate) fn string_out(s: &str, buf: *mut c_char, buf_size: c_int) -> c_int {
let need = s.len() as c_int + 1;
if !buf.is_null() && buf_size > 0 {
let n = (s.len() as c_int).min(buf_size - 1);
// SAFETY: the caller guarantees `buf` holds `buf_size` bytes.
unsafe {
std::ptr::copy_nonoverlapping(s.as_ptr() as *const c_char, buf, n as usize);
*buf.add(n as usize) = 0;
}
}
need
}
/// Read a NUL-terminated C string; `None` on NULL pointers.
pub(crate) fn c_str(ptr: *const c_char) -> Option<String> {
if ptr.is_null() {
return None;
}
// SAFETY: `ptr` must be a valid NUL-terminated C string by contract.
let s = unsafe { std::ffi::CStr::from_ptr(ptr) };
Some(s.to_string_lossy().into_owned())
}
/// Release a handle in place and null its `ctx` (`free_handle` in
/// `c_api/refcounted.h`); NULL pointer and empty handle are no-ops.
pub(crate) fn free_handle(h: *mut CHandle) {
if h.is_null() {
return;
}
let handle = unsafe { &mut *h };
if handle.ctx.is_null() {
return;
}
if let Some(release) = handle.release {
// SAFETY: `release` targets the box behind `ctx`.
unsafe { release(handle.ctx) };
}
handle.ctx = std::ptr::null_mut();
}
/// Safe view into a handle's boxed value; `None` for empty handles.
///
/// Thin wrapper over [`crate::handle::get`] so the export bodies can call
/// it without `unsafe` blocks everywhere.
///
/// # Safety
/// `T` must be the boxed type; each export asserts it via the handle
/// contract (the same typed box is used by its `make_owned` call).
pub(crate) fn get_box<T: 'static>(h: &CHandle) -> Option<&T> {
unsafe { crate::handle::get::<T>(h) }
}
+439
View File
@@ -0,0 +1,439 @@
// 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/>.
//! `include/codec/proxy.h` exports.
//!
//! Complete inventory: create_instance / destroy_instance / params_default
//! / get_state / state_to_string / get_proxy_directory / get_proxy_filename
//! / get_working_filename / get_or_start / find_ffmpeg.
//!
//! # CPP-PARITY
//! The C++ `oakcodec_proxy_params.include_audio` is an `int`, mirrored
//! byte-for-byte by [`crate::proxymanager::ProxyParams`]; the POD structs
//! are still defined here (rather than reused) so the ffi layer never
//! depends on the crate-internal type's layout. A NULL `params` maps to
//! the crate's compiled-in defaults (`ProxyParams::default`), matching
//! `to_native` in `c_api/proxy.cpp`.
use std::ffi::{c_char, c_int};
use crate::handle;
use crate::proxymanager::{ProxyManager, ProxyParams, ProxyState};
/// `OAKCODEC_PROXY_STATE_MISSING`.
pub const OAKCODEC_PROXY_STATE_MISSING: c_int = 0;
/// `OAKCODEC_PROXY_STATE_GENERATING`.
pub const OAKCODEC_PROXY_STATE_GENERATING: c_int = 1;
/// `OAKCODEC_PROXY_STATE_READY`.
pub const OAKCODEC_PROXY_STATE_READY: c_int = 2;
/// `OAKCODEC_PROXY_STATE_FAILED`.
pub const OAKCODEC_PROXY_STATE_FAILED: c_int = 3;
/// `oakcodec_proxy_params` — POD mirror of `include/codec/proxy.h`.
#[allow(missing_docs)]
#[repr(C)]
pub struct oakcodec_proxy_params {
pub width: c_int,
pub height: c_int,
pub divider: c_int,
pub version: c_int,
pub crf: c_int,
pub include_audio: c_int,
pub extension: [u8; 32],
pub preset: [u8; 32],
}
/// `oakcodec_proxy_result` — POD result of `oakcodec_proxy_get_or_start`.
#[allow(missing_docs)]
#[repr(C)]
pub struct oakcodec_proxy_result {
pub state: c_int,
pub filename: [u8; 1024],
}
/// Convert the C POD to the crate's [`ProxyParams`] (`to_native` in
/// `c_api/proxy.cpp`); NULL maps to the compiled-in defaults.
fn to_native(p: *const oakcodec_proxy_params) -> ProxyParams {
if p.is_null() {
return ProxyParams::default();
}
let p = unsafe { &*p };
ProxyParams {
width: p.width,
height: p.height,
divider: p.divider,
version: p.version,
crf: p.crf,
include_audio: p.include_audio,
extension: p.extension,
preset: p.preset,
}
}
/// Copy a NUL-terminated byte array into a C buffer (truncated).
fn copy_cstr(src: &[u8], dst: &mut [u8]) {
dst.fill(0);
let n = src.len().min(dst.len().saturating_sub(1));
dst[..n].copy_from_slice(&src[..n]);
}
/// `oakcodec_proxy_create_instance`: create the singleton (always present
/// here, so a no-op).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_proxy_create_instance() -> c_int {
handle::guard_raw(|| {
let _ = ProxyManager::instance();
crate::error::OAKCODEC_OK
})
}
/// `oakcodec_proxy_destroy_instance`: destroy the singleton (the Rust
/// manager is stateless, so a no-op).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_proxy_destroy_instance() -> c_int {
handle::guard_raw(|| crate::error::OAKCODEC_OK)
}
/// `oakcodec_proxy_params_default`: fill `out` with the compiled-in
/// default proxy parameters.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_proxy_params_default(out: *mut oakcodec_proxy_params) -> c_int {
handle::guard(|| {
if out.is_null() {
return Err(crate::error::Error::Invalid);
}
let n = ProxyManager::proxy_params_from_config();
unsafe {
(*out).width = n.width;
(*out).height = n.height;
(*out).divider = n.divider;
(*out).version = n.version;
(*out).crf = n.crf;
(*out).include_audio = n.include_audio;
copy_cstr(&n.extension, &mut (*out).extension);
copy_cstr(&n.preset, &mut (*out).preset);
}
Ok(())
})
}
/// `oakcodec_proxy_get_state`: state of a proxy file on disk
/// (`OAKCODEC_PROXY_STATE_MISSING` for NULL/empty/absent).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_proxy_get_state(proxy_filename: *const c_char) -> c_int {
handle::guard_raw(|| {
let f = match crate::ffi::c_str(proxy_filename) {
Some(f) if !f.is_empty() => f,
_ => return OAKCODEC_PROXY_STATE_MISSING,
};
ProxyManager::get_proxy_state(&f) as c_int
})
}
/// `oakcodec_proxy_state_to_string` (two-stage).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_proxy_state_to_string(
state: c_int,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
handle::guard_raw(|| {
let s = match state {
0 => ProxyManager::proxy_state_to_string(ProxyState::Missing),
1 => ProxyManager::proxy_state_to_string(ProxyState::Generating),
2 => ProxyManager::proxy_state_to_string(ProxyState::Ready),
3 => ProxyManager::proxy_state_to_string(ProxyState::Failed),
_ => return crate::error::OAKCODEC_E_INVALID,
};
super::string_out(&s, buf, buf_size)
})
}
/// `oakcodec_proxy_get_proxy_directory` (two-stage).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_proxy_get_proxy_directory(
cache_path: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
handle::guard_raw(|| {
let cache = match crate::ffi::c_str(cache_path) {
Some(c) => c,
None => return crate::error::OAKCODEC_E_INVALID,
};
match ProxyManager::get_proxy_directory(&cache) {
Ok(s) => super::string_out(&s, buf, buf_size),
Err(_) => crate::error::OAKCODEC_E_FAILED,
}
})
}
/// `oakcodec_proxy_get_proxy_filename` (two-stage).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_proxy_get_proxy_filename(
cache_path: *const c_char,
source_filename: *const c_char,
stream_index: c_int,
params: *const oakcodec_proxy_params,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
handle::guard_raw(|| {
let cache = match crate::ffi::c_str(cache_path) {
Some(c) => c,
None => return crate::error::OAKCODEC_E_INVALID,
};
let source = match crate::ffi::c_str(source_filename) {
Some(s) => s,
None => return crate::error::OAKCODEC_E_INVALID,
};
let native = to_native(params);
match ProxyManager::get_proxy_filename(&cache, &source, stream_index, &native) {
Ok(s) => super::string_out(&s, buf, buf_size),
Err(_) => crate::error::OAKCODEC_E_FAILED,
}
})
}
/// `oakcodec_proxy_get_working_filename` (two-stage).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_proxy_get_working_filename(
proxy_filename: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
handle::guard_raw(|| {
let proxy = match crate::ffi::c_str(proxy_filename) {
Some(p) => p,
None => return crate::error::OAKCODEC_E_INVALID,
};
match ProxyManager::get_working_filename(&proxy) {
Ok(s) => super::string_out(&s, buf, buf_size),
Err(_) => crate::error::OAKCODEC_E_FAILED,
}
})
}
/// `oakcodec_proxy_get_or_start`: get or start generating a proxy for
/// `source_filename`.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_proxy_get_or_start(
cache_path: *const c_char,
source_filename: *const c_char,
stream_index: c_int,
params: *const oakcodec_proxy_params,
out: *mut oakcodec_proxy_result,
) -> c_int {
handle::guard(|| {
if out.is_null() {
return Err(crate::error::Error::Invalid);
}
let cache = match crate::ffi::c_str(cache_path) {
Some(c) => c,
None => return Err(crate::error::Error::Invalid),
};
let source = match crate::ffi::c_str(source_filename) {
Some(s) => s,
None => return Err(crate::error::Error::Invalid),
};
let native = to_native(params);
let (state, filename) = ProxyManager::instance()
.get_or_start(&cache, &source, stream_index, &native)
.map_err(|_| crate::error::Error::Failed("get_or_start failed".to_string()))?;
unsafe {
let out_ref = &mut *out;
out_ref.state = state as c_int;
// Truncate to 1023 chars + NUL, matching `snprintf` in
// `c_api/proxy.cpp`.
let n = filename.len().min(1023);
out_ref.filename[..n].copy_from_slice(&filename.as_bytes()[..n]);
out_ref.filename[n] = 0;
}
Ok(())
})
}
/// `oakcodec_proxy_find_ffmpeg` (two-stage; empty string when none found).
#[no_mangle]
pub unsafe extern "C" fn oakcodec_proxy_find_ffmpeg(
configured_path: *const c_char,
buf: *mut c_char,
buf_size: c_int,
) -> c_int {
handle::guard_raw(|| {
let configured = crate::ffi::c_str(configured_path).unwrap_or_default();
let s = ProxyManager::find_ffmpeg(&configured);
super::string_out(&s, buf, buf_size)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conformmanager::test_util::{accept_cb, REG_LOCK};
use crate::error::{OAKCODEC_E_INVALID, OAKCODEC_E_STATE};
use crate::task::set_task_submit_cb_extern;
fn cstr(s: &str) -> std::ffi::CString {
std::ffi::CString::new(s).unwrap()
}
fn temp_cache(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("oakcodec_ffi_proxy_{}_{}", name, std::process::id()));
let _ = std::fs::create_dir_all(&dir);
dir.to_string_lossy().into_owned()
}
fn defaults() -> oakcodec_proxy_params {
let mut p: oakcodec_proxy_params = unsafe { std::mem::zeroed() };
let rc = unsafe { oakcodec_proxy_params_default(&mut p) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
p
}
#[test]
fn create_destroy_and_params_default() {
let _g = crate::ffi::lock_tests();
assert_eq!(unsafe { oakcodec_proxy_create_instance() }, crate::error::OAKCODEC_OK);
assert_eq!(unsafe { oakcodec_proxy_destroy_instance() }, crate::error::OAKCODEC_OK);
let p = defaults();
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[..3], b"mp4");
assert_eq!(&p.preset[..8], b"veryfast");
let rc = unsafe { oakcodec_proxy_params_default(std::ptr::null_mut()) };
assert_eq!(rc, OAKCODEC_E_INVALID);
}
#[test]
fn get_state_and_state_to_string() {
let _g = crate::ffi::lock_tests();
let cache = temp_cache("state");
let p = defaults();
let src = cstr("media.mp4");
let cache_c = cstr(&cache);
// Resolve the proxy filename, then query its state.
let mut name = [0i8; 1024];
let rc = unsafe { oakcodec_proxy_get_proxy_filename(cache_c.as_ptr(), src.as_ptr(), 0, &p, name.as_mut_ptr(), 1024) };
assert!(rc > 0);
let proxy = crate::ffi::c_str(name.as_ptr()).unwrap();
assert!(proxy.contains("1280x720"));
// Missing by default.
let pc = cstr(&proxy);
assert_eq!(unsafe { oakcodec_proxy_get_state(pc.as_ptr()) }, OAKCODEC_PROXY_STATE_MISSING);
assert_eq!(unsafe { oakcodec_proxy_get_state(std::ptr::null()) }, OAKCODEC_PROXY_STATE_MISSING);
// Ready once the file exists.
std::fs::create_dir_all(std::path::Path::new(&proxy).parent().unwrap()).unwrap();
std::fs::write(&proxy, b"x").unwrap();
assert_eq!(unsafe { oakcodec_proxy_get_state(pc.as_ptr()) }, OAKCODEC_PROXY_STATE_READY);
// state_to_string mapping + invalid range.
let mut buf = [0i8; 64];
let rc = unsafe { oakcodec_proxy_state_to_string(2, buf.as_mut_ptr(), 64) };
assert_eq!(rc, 6); // "ready" + NUL
assert_eq!(crate::ffi::c_str(buf.as_ptr()).as_deref(), Some("ready"));
let rc = unsafe { oakcodec_proxy_state_to_string(7, buf.as_mut_ptr(), 64) };
assert_eq!(rc, OAKCODEC_E_INVALID);
}
#[test]
fn directory_working_and_get_or_start() {
let _g = crate::ffi::lock_tests();
let cache = temp_cache("getorstart");
let cache_c = cstr(&cache);
let src = cstr("media.mp4");
let p = defaults();
// get_proxy_directory.
let mut buf = [0i8; 512];
let rc = unsafe { oakcodec_proxy_get_proxy_directory(cache_c.as_ptr(), buf.as_mut_ptr(), 512) };
assert!(rc > 0);
assert_eq!(
crate::ffi::c_str(buf.as_ptr()).as_deref(),
Some(format!("{}/proxy", cache).as_str())
);
// get_working_filename appends ".working.mp4".
let proxy = format!("{}/proxy/{}-0.1280x720.v1.a1.mp4", cache, 12345);
let pc = cstr(&proxy);
let rc = unsafe { oakcodec_proxy_get_working_filename(pc.as_ptr(), buf.as_mut_ptr(), 512) };
assert!(rc > 0);
assert_eq!(
crate::ffi::c_str(buf.as_ptr()).as_deref(),
Some(format!("{}.working.mp4", proxy).as_str())
);
// get_or_start without a registrar -> Missing.
let _g = REG_LOCK.lock().unwrap();
set_task_submit_cb_extern(None, std::ptr::null_mut());
let mut out: oakcodec_proxy_result = unsafe { std::mem::zeroed() };
let rc = unsafe { oakcodec_proxy_get_or_start(cache_c.as_ptr(), src.as_ptr(), 0, &p, &mut out) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
assert_eq!(out.state, OAKCODEC_PROXY_STATE_MISSING);
// With a registrar and no files -> Generating.
set_task_submit_cb_extern(Some(accept_cb), std::ptr::null_mut());
let rc = unsafe { oakcodec_proxy_get_or_start(cache_c.as_ptr(), src.as_ptr(), 0, &p, &mut out) };
assert_eq!(rc, crate::error::OAKCODEC_OK);
assert_eq!(out.state, OAKCODEC_PROXY_STATE_GENERATING);
// Invalid args.
let rc = unsafe { oakcodec_proxy_get_or_start(std::ptr::null(), src.as_ptr(), 0, &p, &mut out) };
assert_eq!(rc, OAKCODEC_E_INVALID);
let rc = unsafe { oakcodec_proxy_get_or_start(cache_c.as_ptr(), src.as_ptr(), 0, &p, std::ptr::null_mut()) };
assert_eq!(rc, OAKCODEC_E_INVALID);
// get_proxy_directory / get_proxy_filename / get_working_filename
// argument validation.
let rc = unsafe { oakcodec_proxy_get_proxy_directory(std::ptr::null(), buf.as_mut_ptr(), 512) };
assert_eq!(rc, OAKCODEC_E_INVALID);
let rc = unsafe { oakcodec_proxy_get_proxy_filename(std::ptr::null(), src.as_ptr(), 0, &p, buf.as_mut_ptr(), 512) };
assert_eq!(rc, OAKCODEC_E_INVALID);
let rc = unsafe { oakcodec_proxy_get_working_filename(std::ptr::null(), buf.as_mut_ptr(), 512) };
assert_eq!(rc, OAKCODEC_E_INVALID);
set_task_submit_cb_extern(None, std::ptr::null_mut());
}
#[test]
fn find_ffmpeg_uses_configured_path() {
let _g = crate::ffi::lock_tests();
// The current test binary is a real executable: the configured
// path resolves to an absolute path.
let me = std::env::current_exe().unwrap();
let mc = cstr(me.to_str().unwrap());
let mut buf = [0i8; 1024];
let rc = unsafe { oakcodec_proxy_find_ffmpeg(mc.as_ptr(), buf.as_mut_ptr(), 1024) };
assert!(rc > 0);
let found = crate::ffi::c_str(buf.as_ptr()).unwrap();
assert!(found.starts_with('/'));
// NULL configured path falls back to the search (empty or absolute).
let rc = unsafe { oakcodec_proxy_find_ffmpeg(std::ptr::null(), buf.as_mut_ptr(), 1024) };
assert!(rc > 0);
let found = crate::ffi::c_str(buf.as_ptr()).unwrap();
assert!(found.is_empty() || found.starts_with('/'));
}
}
+73
View File
@@ -0,0 +1,73 @@
// 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/>.
//! `include/codec/task.h` exports.
//!
//! Complete inventory: set_task_submit_cb / task_submit_is_registered.
//! The callback typedef and request struct are mirrored in
//! [`crate::task`].
use std::ffi::c_int;
use crate::handle;
/// `oakcodec_set_task_submit_cb`: register (or replace, or clear with
/// NULL) the global task submit callback.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_set_task_submit_cb(
cb: Option<crate::task::OakCodecTaskSubmitFn>,
userdata: *mut std::ffi::c_void,
) {
handle::guard_void(|| {
crate::task::set_task_submit_cb_extern(cb, userdata);
})
}
/// `oakcodec_task_submit_is_registered`: 1 when a callback is set.
#[no_mangle]
pub unsafe extern "C" fn oakcodec_task_submit_is_registered() -> c_int {
handle::guard_raw(|| {
if crate::task::task_submit_is_registered() {
1
} else {
0
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conformmanager::test_util::{accept_cb, REG_LOCK};
use crate::task::set_task_submit_cb_extern;
#[test]
fn register_query_clear() {
let _g = crate::ffi::lock_tests();
let _g = REG_LOCK.lock().unwrap();
assert_eq!(unsafe { oakcodec_task_submit_is_registered() }, 0);
unsafe { oakcodec_set_task_submit_cb(Some(accept_cb), std::ptr::null_mut()) };
assert_eq!(unsafe { oakcodec_task_submit_is_registered() }, 1);
unsafe { oakcodec_set_task_submit_cb(None, std::ptr::null_mut()) };
assert_eq!(unsafe { oakcodec_task_submit_is_registered() }, 0);
// Restore a clean slate for the other modules.
set_task_submit_cb_extern(None, std::ptr::null_mut());
}
}
File diff suppressed because it is too large Load Diff
+280
View File
@@ -0,0 +1,280 @@
// 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
//! `oakcore_rs::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 oakcore_rs::{Rational, TimeRange};
use crate::bridge::common::{OakAudioParams, OakSubtitleParams, OakVideoParams};
/// One stream entry in a footage description.
#[derive(Clone, Debug)]
pub enum StreamEntry {
/// A video stream (params handle, addref'd).
Video(OakVideoParams),
/// An audio stream.
Audio(OakAudioParams),
/// A subtitle stream (params handle, addref'd).
Subtitle(OakSubtitleParams),
}
/// `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<&OakVideoParams> {
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<&OakAudioParams> {
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<&OakSubtitleParams> {
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) -> OakVideoParams {
OakVideoParams {
ctx: index as usize as *mut std::ffi::c_void,
addref: None,
release: None,
abi_version: crate::handle::OAKCODEC_ABI_VERSION,
}
}
fn audio_params() -> OakAudioParams {
OakAudioParams {
ctx: std::ptr::null_mut(),
addref: None,
release: None,
abi_version: crate::handle::OAKCODEC_ABI_VERSION,
}
}
fn subtitle_params() -> OakSubtitleParams {
OakSubtitleParams {
ctx: std::ptr::null_mut(),
addref: None,
release: None,
abi_version: crate::handle::OAKCODEC_ABI_VERSION,
}
}
#[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());
}
}
+529
View File
@@ -0,0 +1,529 @@
// 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 an `OakVideoParams` handle.
//!
//! Mirrors `src/codec/src/frame.h`. The params are held as an oakcommon
//! by-value handle (`bridge::common::OakVideoParams`, refcounted) so the
//! byte-level ABI of `oakcodec_frame_get_params`/`_set_params` is
//! unchanged; the pixel data itself is a plain `Vec<u8>`. Line-size and
//! pixel-format math lives here.
use crate::bridge::common::{
oakcommon_videoparams_free, oakcommon_videoparams_get_format,
oakcommon_videoparams_get_height, oakcommon_videoparams_get_is_valid,
oakcommon_videoparams_get_width, oakcommon_videoparams_init, OakVideoParams,
};
use oakcore_rs::{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 handle.
#[derive(Debug)]
pub struct Frame {
/// Video parameter set (oakcommon handle, refcounted).
pub params: Option<OakVideoParams>,
/// 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
}
/// Increment the refcount of a params handle (a no-op for test-stub handles
/// whose `addref` is `None`). `pub(crate)` so the ffi layer can hand out
/// addref'd copies (`oakcodec_frame_get_params`).
pub(crate) fn params_addref(p: &OakVideoParams) {
if let Some(addref) = p.addref {
// SAFETY: `addref` is a valid C function pointer targeting `ctx`.
unsafe { addref(p.ctx) };
}
}
/// Release a params handle (prefers the `release` function pointer; the
/// test stubs use `oakcommon_videoparams_free` instead). Nulls `ctx` so the
/// handle cannot be released twice.
pub(crate) fn params_release(p: &mut OakVideoParams) {
if p.ctx.is_null() {
return;
}
if let Some(release) = p.release {
// SAFETY: `release` is a valid C function pointer targeting `ctx`.
unsafe { release(p.ctx) };
} else {
// SAFETY: `p` points at a live handle; `oakcommon_videoparams_free`
// is a no-op for the null ctx we leave behind.
unsafe { oakcommon_videoparams_free(p) };
}
p.ctx = std::ptr::null_mut();
}
impl Frame {
/// New frame with default (invalid) params; buffer unallocated.
pub fn new() -> Self {
let params = unsafe { oakcommon_videoparams_init() };
Frame {
params: Some(params),
data: Vec::new(),
linesize_bytes: 0,
timestamp: Rational::new(0, 1),
allocated_format: PixelFormat::Invalid,
}
}
/// New frame with a copy of `params` (handle addref'd internally).
pub fn with_params(params: OakVideoParams) -> Self {
params_addref(&params);
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<&OakVideoParams> {
self.params.as_ref()
}
/// Replace the parameter set (handle addref'd), recompute line sizes,
/// do NOT reallocate the buffer.
pub fn set_params(&mut self, params: OakVideoParams) {
if let Some(mut old) = self.params.take() {
params_release(&mut old);
}
params_addref(&params);
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 w = unsafe { oakcommon_videoparams_get_width(p.clone()) };
let fmt = pixel_format_from_i32(unsafe {
oakcommon_videoparams_get_format(p.clone())
});
Self::generate_linesize_bytes(fmt, w)
}
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.clone(),
None => return Err(crate::error::Error::State),
};
let is_valid = unsafe { oakcommon_videoparams_get_is_valid(params.clone()) };
if is_valid == 0 {
return Err(crate::error::Error::State);
}
if self.is_allocated() {
// Already allocated; leave the buffer alone.
return Ok(());
}
let width = unsafe { oakcommon_videoparams_get_width(params.clone()) };
let height = unsafe { oakcommon_videoparams_get_height(params.clone()) };
let format = pixel_format_from_i32(unsafe {
oakcommon_videoparams_get_format(params)
});
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) => unsafe { oakcommon_videoparams_get_width(p.clone()) },
None => 0,
}
}
/// Frame height in pixels (0 when params are empty).
pub fn height(&self) -> i32 {
match &self.params {
Some(p) => unsafe { oakcommon_videoparams_get_height(p.clone()) },
None => 0,
}
}
/// Pixel format (`OakPixelFormat` value).
pub fn format(&self) -> PixelFormat {
match &self.params {
Some(p) => {
pixel_format_from_i32(unsafe { oakcommon_videoparams_get_format(p.clone()) })
}
None => PixelFormat::Invalid,
}
}
/// Plane channel count of the params format.
///
/// # CPP-PARITY
/// `src/codec/src/frame.h` reads this from the params handle via
/// `oakcommon_videoparams_get_channel_count`, which is not exposed in the
/// Rust bridge. 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 `oakcommon_videoparams_*`; 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;
}
}
}
impl Drop for Frame {
/// Release the owned params handle when the last reference dies,
/// mirroring the C++ `Frame::~Frame`.
fn drop(&mut self) {
if let Some(mut p) = self.params.take() {
params_release(&mut p);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::common::oakcommon_videoparams_init_basic;
fn frame(w: i32, h: i32) -> Frame {
let params = unsafe { oakcommon_videoparams_init_basic(w, h) };
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 = unsafe { oakcommon_videoparams_init_basic(10, 10) };
let mut f = Frame::with_params(params);
f.allocate().unwrap();
let before = f.allocated_size();
let wider = unsafe { oakcommon_videoparams_init_basic(100, 10) };
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 crate::bridge::common::oakcommon_videoparams_init_basic;
fn frame(w: i32, h: i32) -> Frame {
let params = unsafe { oakcommon_videoparams_init_basic(w, h) };
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 p = unsafe { oakcommon_videoparams_init_basic(1, 1) };
unsafe { crate::bridge::common::oakcommon_videoparams_set_format(p.clone(), 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);
}
}
+223
View File
@@ -0,0 +1,223 @@
// 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 crate::bridge::common::{
oakcommon_videoparams_equals, OakVideoParams,
};
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: OakVideoParams) -> Arc<Frame> {
let frame = {
let mut pool = self.pool.lock().unwrap();
match pool
.iter()
.position(|f| frame_matches(f, &params))
{
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: &OakVideoParams) -> bool {
let Some(frame_params) = frame.params() else {
return false;
};
let eq = unsafe {
oakcommon_videoparams_equals(frame_params.clone(), params.clone())
};
eq != 0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::common::oakcommon_videoparams_init_basic;
#[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 = unsafe { oakcommon_videoparams_init_basic(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 = unsafe { oakcommon_videoparams_init_basic(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(unsafe {
oakcommon_videoparams_init_basic(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 = unsafe { oakcommon_videoparams_init_basic(64, 64) };
let p2 = unsafe { oakcommon_videoparams_init_basic(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 = unsafe { oakcommon_videoparams_init_basic(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);
}
}
+274
View File
@@ -0,0 +1,274 @@
// 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/>.
//! Refcounted-handle scaffolding. Same pattern as the oaknode/oakplugin
//! crates (`src/node/rust/src/handle.rs`); intentionally duplicated rather
//! than shared — each module DLL must run its own addref/release code
//! (the function pointers in a handle always point into the DLL that
//! created the object).
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::ptr;
use std::sync::atomic::{AtomicI32, AtomicU32, Ordering};
use crate::error::{self, OAKCODEC_E_FAILED};
/// Number of boxed handle objects currently alive (leak/debug checking).
///
/// Mirrors `oakcodec::g_alive_count` in `src/codec/c_api/frame.cpp`: every
/// `make_owned` box increments it and `box_release` decrements it when the
/// last reference drops. `oakcodec_debug_alive_count` reports it.
static ALIVE: AtomicI32 = AtomicI32::new(0);
/// ABI version stamped into every handle.
pub const OAKCODEC_ABI_VERSION: u32 = 1;
/// Heap box behind a handle's `ctx`.
pub struct RefBox<T: ?Sized> {
/// Atomic reference count.
pub refs: AtomicU32,
/// Boxed value.
pub value: T,
}
/// `#[repr(C)]` mirror of the public handle structs
/// (`{ctx, addref, release, abi_version}`).
/// The shared ABI value-handle type (single-lib unification, see
/// `docs/zh/plans/riir/single-lib.md`): one canonical
/// `{ctx, addref, release, abi_version}` type in `oakcore-rs`, re-exported
/// here so the crate's `ffi.rs` signatures and handle scaffolding stay
/// source-compatible.
pub use oakcore_rs::handle::CHandle;
/// Increment the reference count of a boxed `RefBox<T>`.
///
/// # Safety
/// `ptr` must point to a live `RefBox<T>` previously created by this module.
unsafe extern "C" fn box_addref<T: Send + 'static>(ptr: *mut std::ffi::c_void) {
if ptr.is_null() {
return;
}
let boxed = unsafe { &*(ptr as *const RefBox<T>) };
boxed.refs.fetch_add(1, Ordering::SeqCst);
}
/// Decrement the reference count; destroys the box at zero.
///
/// # Safety
/// `ptr` must point to a live `RefBox<T>` previously created by this module.
unsafe extern "C" fn box_release<T: Send + 'static>(ptr: *mut std::ffi::c_void) {
if ptr.is_null() {
return;
}
let boxed = unsafe { &*(ptr as *const RefBox<T>) };
if boxed.refs.fetch_sub(1, Ordering::SeqCst) == 1 {
// The last reference: the box is destroyed and the alive count
// drops with it (mirrors `alive_dec` in c_api/frame.cpp).
ALIVE.fetch_sub(1, Ordering::SeqCst);
unsafe { drop(Box::from_raw(ptr as *mut RefBox<T>)) };
}
}
/// Owned handle with count 1; empty on allocation failure.
pub fn make_owned<T: Send + 'static>(value: T) -> CHandle {
let boxed = Box::new(RefBox {
refs: AtomicU32::new(1),
value,
});
let ctx = Box::into_raw(boxed) as *mut std::ffi::c_void;
// Every boxed handle counts toward `oakcodec_debug_alive_count`
// (mirrors `alive_inc` in c_api/frame.cpp).
ALIVE.fetch_add(1, Ordering::SeqCst);
CHandle {
ctx,
addref: Some(box_addref::<T>),
release: Some(box_release::<T>),
abi_version: OAKCODEC_ABI_VERSION,
}
}
/// Borrowed handle for an object owned elsewhere.
///
/// Takes ownership of the boxed `T` already allocated at `ptr` (e.g. one
/// passed in from C++). The resulting handle's release drops that box.
///
/// # Safety
/// Caller guarantees `ptr` was allocated with `Box::new` and is not used
/// after this call.
pub unsafe fn make_borrowed<T: Send + 'static>(ptr: *mut T) -> CHandle {
if ptr.is_null() {
return CHandle::null();
}
// Move ownership into a RefBox so addref/release and get() behave
// uniformly with owned handles.
let value = unsafe { *Box::from_raw(ptr) };
make_owned(value)
}
/// Typed view into a handle; `None` for empty handles.
///
/// # Safety
/// `T` must be the boxed type.
pub unsafe fn get<T: 'static>(h: &CHandle) -> Option<&T> {
if h.is_null() {
return None;
}
let boxed = unsafe { &*(h.ctx as *const RefBox<T>) };
Some(&boxed.value)
}
/// Panic-catching FFI wrapper for i32-returning exports.
pub fn guard<F: FnOnce() -> error::Result<()>>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(())) => error::OAKCODEC_OK,
Ok(Err(e)) => e.code(),
Err(_) => OAKCODEC_E_FAILED,
}
}
/// Panic-catching FFI wrapper for handle-returning exports.
pub fn guard_handle<F: FnOnce() -> error::Result<CHandle>>(f: F) -> CHandle {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(Ok(h)) => h,
Ok(Err(_)) | Err(_) => CHandle::null(),
}
}
/// Panic-catching FFI wrapper for void exports.
pub fn guard_void<F: FnOnce()>(f: F) {
let _ = catch_unwind(AssertUnwindSafe(f));
}
/// Panic-catching FFI wrapper for exports that return a raw `i32` code
/// directly (neither `Result` nor a handle). On panic, `OAKCODEC_E_FAILED`.
pub fn guard_raw<F: FnOnce() -> i32>(f: F) -> i32 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(code) => code,
Err(_) => OAKCODEC_E_FAILED,
}
}
/// Panic-catching FFI wrapper for exports that return a raw `i64` directly
/// (e.g. `oakcodec_decoder_get_image_sequence_index`). On panic,
/// `OAKCODEC_E_FAILED`.
pub fn guard_i64<F: FnOnce() -> i64>(f: F) -> i64 {
match catch_unwind(AssertUnwindSafe(f)) {
Ok(v) => v,
Err(_) => OAKCODEC_E_FAILED as i64,
}
}
/// Number of live boxed handle objects (see [`ALIVE`]).
pub fn alive_count() -> i32 {
ALIVE.load(Ordering::SeqCst)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::OAKCODEC_E_INVALID;
#[test]
fn make_owned_lifecycle_tracks_alive_count() {
// The shared ffi test lock serializes the crate's `alive_count`
// assertions against every other test that creates handles.
let _g = crate::ffi::lock_tests();
let before = alive_count();
let h = make_owned(42u32);
assert!(!h.is_null());
assert_eq!(alive_count(), before + 1);
// addref/release cycle keeps the box alive.
let addref = h.addref.unwrap();
let release = h.release.unwrap();
// SAFETY: `h.ctx` is a live RefBox<u32>.
unsafe { addref(h.ctx) };
// SAFETY: second reference released; box stays (refs 2 -> 1).
unsafe { release(h.ctx) };
assert_eq!(alive_count(), before + 1);
// Release the owned reference: box destroyed.
// SAFETY: last reference.
unsafe { release(h.ctx) };
assert_eq!(alive_count(), before);
}
#[test]
fn make_borrowed_null_is_null_handle() {
let h = unsafe { make_borrowed::<u32>(std::ptr::null_mut()) };
assert!(h.is_null());
}
#[test]
fn make_borrowed_takes_ownership() {
let _g = crate::ffi::lock_tests();
let before = alive_count();
let raw = Box::into_raw(Box::new(7u32));
let h = unsafe { make_borrowed(raw) };
assert!(!h.is_null());
assert_eq!(alive_count(), before + 1);
assert_eq!(unsafe { *get::<u32>(&h).unwrap() }, 7);
unsafe { h.release.unwrap()(h.ctx) };
assert_eq!(alive_count(), before);
}
#[test]
fn addref_on_null_ctx_is_noop() {
// A handle with function pointers but a null ctx: both thunks no-op.
let h = CHandle {
ctx: std::ptr::null_mut(),
addref: Some(box_addref::<u32>),
release: Some(box_release::<u32>),
abi_version: OAKCODEC_ABI_VERSION,
};
// SAFETY: ctx is null; the thunks guard on it.
unsafe { h.addref.unwrap()(h.ctx) };
// SAFETY: ctx is null; the thunks guard on it.
unsafe { h.release.unwrap()(h.ctx) };
}
#[test]
fn guard_maps_results_and_panics() {
assert_eq!(guard(|| Ok(())), crate::error::OAKCODEC_OK);
assert_eq!(guard(|| Err(crate::error::Error::Invalid)), OAKCODEC_E_INVALID);
assert_eq!(guard(|| panic!("boom")), crate::error::OAKCODEC_E_FAILED);
let ok = guard_handle(|| Ok(make_owned(1u32)));
assert!(!ok.is_null());
assert!(guard_handle(|| Err::<CHandle, _>(crate::error::Error::Invalid)).is_null());
assert!(guard_handle(|| panic!("boom")).is_null());
assert_eq!(guard_raw(|| 5), 5);
assert_eq!(guard_raw(|| panic!("boom")), crate::error::OAKCODEC_E_FAILED);
assert_eq!(guard_i64(|| 5), 5);
assert_eq!(guard_i64(|| panic!("boom")), crate::error::OAKCODEC_E_FAILED as i64);
let mut called = false;
guard_void(|| called = true);
assert!(called);
guard_void(|| panic!("boom"));
}
#[test]
fn null_handle_helpers() {
let h = CHandle::null();
assert!(h.is_null());
// The shared `null()` stamps no ABI version (single-lib
// unification; `make_owned` stamps the crate version).
assert_eq!(h.abi_version, 0);
}
}
+54
View File
@@ -0,0 +1,54 @@
// 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 → refcounted handles, etc.).
//!
//! ## FFI discipline
//!
//! Identical to the oaknode/oakplugin crates: every export goes through
//! [`handle::guard*`], handles are opaque refcounted boxes, shared
//! state behind `Mutex`.
#![deny(unsafe_op_in_unsafe_fn)]
#![warn(missing_docs)]
pub mod bridge;
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 ffi;
pub mod footagedescription;
pub mod frame;
pub mod framemanager;
pub mod handle;
pub mod oiio;
pub mod oiioframebridge;
pub mod planarfiledevice;
pub mod proxymanager;
pub mod task;
pub mod timecodemetadata;
#[cfg(test)]
mod realmedia_tests;
+307
View File
@@ -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 (`oakcommon_oiioutils_*` via `bridge/common.rs`).
//!
//! 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<&crate::bridge::render::OakCancelAtom>,
) -> 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::bridge::render::OakRenderTexture> {
Err(crate::error::Error::Failed(Self::NOT_AVAILABLE.to_string()))
}
fn retrieve_audio(
&self,
_dest: &mut [f32],
_range: &oakcore_rs::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<&crate::bridge::render::OakCancelAtom>,
) -> 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<oakcore_rs::PixelFormat> {
None
}
fn desired_sample_format(&self) -> Option<oakcore_rs::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 oakcore_rs::{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());
}
}
+419
View File
@@ -0,0 +1,419 @@
// 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 crate::bridge::common::{
oakcommon_videoparams_get_time_base, oakcommon_videoparams_init_with_time_base,
oakcommon_videoparams_set_format,
};
use crate::frame::Frame;
use oakcore_rs::Rational;
/// 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 mut num = 0i64;
let mut den = 0i64;
// SAFETY: `num`/`den` are live mutable i64s and `p` is a valid
// handle; the C function only writes through the two out pointers.
unsafe {
oakcommon_videoparams_get_time_base(p.clone(), &mut num, &mut den);
}
(num, 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.
// SAFETY: the init returns a live handle; the clone for `set_format` is
// only read, and the original is moved into `Frame::with_params` (which
// takes ownership), so there is no double release.
let params = unsafe {
oakcommon_videoparams_init_with_time_base(
header.width,
header.height,
header.time_base_num,
header.time_base_den,
)
};
unsafe {
oakcommon_videoparams_set_format(params.clone(), 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
/// test-stub params pattern as `frame.rs`.
fn make_frame() -> Frame {
// SAFETY: test-stub videoparams; ownership moves into `with_params`.
let params = unsafe { oakcommon_videoparams_init_with_time_base(100, 50, 1, 30) };
unsafe { oakcommon_videoparams_set_format(params.clone(), 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());
}
}
+278
View File
@@ -0,0 +1,278 @@
// 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);
}
}
+703
View File
@@ -0,0 +1,703 @@
// 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 C ABI
//! with the compiled-in defaults as fallback (1280x720 / divider 1 / crf 23
//! / "mp4" / "veryfast" / audio included).
use std::ffi::{c_char, CString};
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,
}
/// `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(),
}
}
/// 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.
if let Ok(path_env) = std::env::var("PATH") {
for dir in path_env.split(':') {
if dir.is_empty() {
continue;
}
let candidate = Path::new(dir).join("ffmpeg");
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!("{}/ffmpeg", app_path));
}
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());
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 {
let ckey = cstring(key);
// # Safety: `ckey` is a valid NUL-terminated C string alive for the call.
unsafe {
crate::bridge::common::oakcommon_config_get_int(
std::ptr::null(),
ckey.as_ptr(),
default,
)
}
}
/// `oakcommon_config_get_bool` wrapper (null group).
fn config_get_bool(key: &str, default: i32) -> i32 {
let ckey = cstring(key);
// # Safety: `ckey` is a valid NUL-terminated C string alive for the call.
unsafe {
crate::bridge::common::oakcommon_config_get_bool(
std::ptr::null(),
ckey.as_ptr(),
default,
)
}
}
/// Two-stage `oakcommon_config_get` string read; `None` when the stored
/// value is empty or absent.
fn config_get_str(key: &str) -> Option<String> {
let ckey = cstring(key);
// # Safety: `ckey` is valid; first call asks only for the required size.
let size = unsafe {
crate::bridge::common::oakcommon_config_get(
std::ptr::null(),
ckey.as_ptr(),
std::ptr::null_mut(),
0,
)
};
if size <= 1 {
return None;
}
let mut buf = vec![0u8; size as usize];
// # Safety: `buf` has `size` bytes; the call fills at most `size` bytes.
unsafe {
crate::bridge::common::oakcommon_config_get(
std::ptr::null(),
ckey.as_ptr(),
buf.as_mut_ptr() as *mut c_char,
size,
);
}
let mut end = buf.len();
while end > 0 && buf[end - 1] == 0 {
end -= 1;
}
Some(String::from_utf8_lossy(&buf[..end]).into_owned())
}
/// `oakcommon_filefunctions_get_unique_file_identifier` wrapper (the bridge
/// returns a 64-bit id directly).
fn unique_file_identifier(filename: &str) -> String {
let c = match CString::new(filename) {
Ok(c) => c,
Err(_) => return String::new(),
};
// # Safety: `c` is a valid NUL-terminated C string alive for the call.
let id = unsafe {
crate::bridge::common::oakcommon_filefunctions_get_unique_file_identifier(c.as_ptr())
};
format!("{}", id)
}
/// Two-stage `oakcommon_filefunctions_get_application_path` read.
fn application_path() -> String {
// # Safety: first call asks only for the required size.
let size = unsafe {
crate::bridge::common::oakcommon_filefunctions_get_application_path(
std::ptr::null_mut(),
0,
)
};
if size <= 1 {
return String::new();
}
let mut buf = vec![0u8; size as usize];
// # Safety: `buf` has `size` bytes; the call fills at most `size` bytes.
unsafe {
crate::bridge::common::oakcommon_filefunctions_get_application_path(
buf.as_mut_ptr() as *mut c_char,
size,
);
}
let mut end = buf.len();
while end > 0 && buf[end - 1] == 0 {
end -= 1;
}
String::from_utf8_lossy(&buf[..end]).into_owned()
}
/// Build a NUL-terminated C string from a Rust string; empty on embedded
/// NUL (defensive only — callers pass sane keys).
fn cstring(s: &str) -> CString {
CString::new(s).unwrap_or_else(|_| CString::new("").unwrap())
}
/// True when `p` is a regular file with at least one execute bit set.
fn is_executable_file(p: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(p) {
Ok(md) if md.is_file() => md.permissions().mode() & 0o111 != 0,
_ => false,
}
}
/// 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()
}
fn fnv1a64(bytes: &[u8]) -> u64 {
let mut h: u64 = 14695981039346656037;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(1099511628211);
}
h
}
#[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(),
"/tmp/cache/proxy"
);
}
#[test]
fn proxy_filename_derivation() {
let cache = temp_subdir("fn");
let id = fnv1a64(b"media.mp4") as i64;
let p = ProxyManager::proxy_params_default();
let f = ProxyManager::get_proxy_filename(&cache, "media.mp4", 0, &p).unwrap();
assert_eq!(
f,
format!("{}/proxy/{}-0.1280x720.v1.a1.mp4", cache, id)
);
// Divider mode tags the divider instead of an absolute size.
let mut d = p.clone();
d.divider = 2;
let f2 = ProxyManager::get_proxy_filename(&cache, "media.mp4", 0, &d).unwrap();
assert!(f2.contains(".div2."));
// No audio.
let mut na = p.clone();
na.include_audio = 0;
let f3 = ProxyManager::get_proxy_filename(&cache, "media.mp4", 0, &na).unwrap();
assert!(f3.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() || found.starts_with('/'));
}
/// `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::*;
#[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 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);
}
}
+253
View File
@@ -0,0 +1,253 @@
// 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 oakcommon/oakrender 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 crate::bridge::common::{
oakcommon_videoparams_get_duration, oakcommon_videoparams_get_frame_rate,
oakcommon_videoparams_get_height, oakcommon_videoparams_get_width,
oakcommon_videoparams_init_basic, oakcommon_videoparams_set_format,
};
use crate::decoder::{
CodecStream, Decoder, K_COLOR_RANGE_DEFAULT, RenderMode, RetrieveAudioStatus,
RetrieveVideoParams,
};
use crate::encoder::create_from_params;
use crate::ffmpeg::FFmpegDecoder;
use crate::frame::Frame;
use oakcore_rs::{PixelFormat, Rational, TimeRange};
/// `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 vp = unsafe { oakcommon_videoparams_init_basic(64, 64) };
unsafe { oakcommon_videoparams_set_format(vp.clone(), 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!(unsafe { oakcommon_videoparams_get_width(vp.clone()) }, 1920);
assert_eq!(unsafe { oakcommon_videoparams_get_height(vp.clone()) }, 1080);
assert_eq!(unsafe { oakcommon_videoparams_get_duration(vp.clone()) }, 17 * 12800);
let mut num: i32 = 0;
let mut den: i32 = 0;
unsafe { oakcommon_videoparams_get_frame_rate(vp.clone(), &mut num, &mut den) };
assert_eq!((num, den), (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(&params).expect("create ffmpeg encoder");
assert_eq!(e.id(), "ffmpeg");
e.configure(&params).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!(unsafe { oakcommon_videoparams_get_width(vp.clone()) }, 64);
assert_eq!(unsafe { oakcommon_videoparams_get_height(vp.clone()) }, 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);
}
+337
View File
@@ -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/>.
//! 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;
}
+355
View File
@@ -0,0 +1,355 @@
// 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 oakcore_rs::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 `oakcore_rs::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));
}
}