Module-internal object references are Rust types now (values, Arc, Mutex); CHandle remains only at the oakengine C-ABI boundary: - oakundo: the global stack holds UndoStack/UndoCommand values directly (stack token is the static's address) - oaktimeline: marker/workarea boxes carry Arc<Mutex<T>>; commands share the same allocation through Arc clones (readers in oakengine stubs and the app's graphops updated to lock) - oaktask/oakstorage: sessions, write-through bindings and the database backend pass ProjectArc; the Session drops its manual release bookkeeping; nodeutil keeps the CHandle<->Arc boundary conversion (release_project restored for the app) - oakcodec: handle.rs deleted outright (no facade entry needed it); texture/block placeholders are unit structs - oakrender: copier's project handle is an identity u64; alive-count machinery removed; handle.rs is make_owned/get/get_mut only - oakplugin: the instance registry is gone (its unregister key never matched, leaking weak entries); handle.rs is the RefBox boundary type - oaknode/oakcommon: only dead guard/borrow helpers removed; external payload handles (texture/processor) documented as the boundary Flake hunts landed along the way: the audio recording test serializes on the shared manager lock with a normalized state; the autocacher cancel test uses a slow producer so cancellation is deterministic.
350 lines
11 KiB
Rust
350 lines
11 KiB
Rust
// 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. Instances are handed out as
|
|
/// `Arc<dyn Encoder>`.
|
|
pub trait Encoder: Send + Sync {
|
|
/// Unique encoder id.
|
|
fn id(&self) -> String;
|
|
|
|
/// Whether this encoder writes video.
|
|
fn supports_video(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether this encoder writes audio.
|
|
fn supports_audio(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether this encoder writes subtitles.
|
|
fn supports_subtitles(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether this encoder writes an image sequence.
|
|
fn supports_image_sequences(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Whether this encoder is deterministic for a given config
|
|
/// (used for cache keys).
|
|
fn is_configurable(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Configure the encoder (per-codec options like `crf`).
|
|
fn configure(&self, params: &EncodingParams) -> crate::error::Result<()>;
|
|
|
|
/// Open the output file and write headers.
|
|
fn open(&self) -> crate::error::Result<()>;
|
|
|
|
/// Close the output (write trailer); idempotent.
|
|
fn close(&self) -> crate::error::Result<()>;
|
|
|
|
/// Encode one video frame (converts delivery pixel format internally).
|
|
fn write_video(&self, frame: &Frame) -> crate::error::Result<()>;
|
|
|
|
/// Encode interleaved float audio samples.
|
|
fn write_audio(&self, samples: &[f32], frame_count: i32) -> crate::error::Result<()>;
|
|
|
|
/// Encode one subtitle entry (times in seconds).
|
|
fn write_subtitle(
|
|
&self,
|
|
text: &str,
|
|
in_seconds: f64,
|
|
out_seconds: f64,
|
|
) -> crate::error::Result<()>;
|
|
|
|
/// Flush encoders, write the trailer, close the file.
|
|
fn flush(&self) -> crate::error::Result<()>;
|
|
|
|
/// The pixel format the encoder wants frames in (or `None`).
|
|
fn desired_pixel_format(&self) -> Option<PixelFormat>;
|
|
|
|
/// The sample format the encoder wants audio in (or `None`).
|
|
fn desired_sample_format(&self) -> Option<SampleFormat>;
|
|
|
|
/// The configured output filename.
|
|
fn filename(&self) -> String;
|
|
|
|
/// Human-readable detail of the last failed operation (empty when
|
|
/// none). Mirrors the C++ `Encoder::get_error()` used by
|
|
/// `oakcodec_encoder_last_error`.
|
|
fn get_error(&self) -> String {
|
|
String::new()
|
|
}
|
|
}
|
|
|
|
/// Test-injected encoder registry (see [`set_test_encoders`]); empty when
|
|
/// not injected, in which case [`create_from_params`] falls back to the
|
|
/// built-in format mapping.
|
|
static TEST_ENCODERS: OnceLock<Mutex<Vec<Arc<dyn Encoder>>>> = OnceLock::new();
|
|
|
|
/// Replace the encoder registry with `list`; pass an empty list to restore
|
|
/// the built-in behavior.
|
|
///
|
|
/// Test/extension support (the C ABI has no way to register an encoder, so
|
|
/// the contract tests drive the encode state machine through a fake
|
|
/// encoder). Hidden from docs; never called by production code.
|
|
#[doc(hidden)]
|
|
pub fn set_test_encoders(list: Vec<Arc<dyn Encoder>>) {
|
|
let store = TEST_ENCODERS.get_or_init(|| Mutex::new(Vec::new()));
|
|
*store.lock().unwrap() = list;
|
|
}
|
|
|
|
/// `Encoder::create_from_params` — instantiate an encoder for `params`.
|
|
///
|
|
/// # CPP-PARITY
|
|
/// `src/codec/src/encoder.cpp` `create_from_params` → `create_from_format`
|
|
/// picks the FFmpeg/OIIO implementation from `params.format` (DNxHD,
|
|
/// Matroska, QuickTime, MPEG-4 video/audio, WAV, AIFF, MP3, FLAC, Ogg,
|
|
/// WebM, SRT → FFmpeg; OpenEXR, PNG, TIFF → OIIO; anything else → `None`).
|
|
/// A non-empty test-injected list (see [`set_test_encoders`]) wins over the
|
|
/// built-in mapping. The concrete implementations are dylib stubs whose
|
|
/// `open()` fails with a clear message, so an initialized encoder is always
|
|
/// constructible for a recognized format.
|
|
pub fn create_from_params(params: &EncodingParams) -> Option<Arc<dyn Encoder>> {
|
|
if let Some(store) = TEST_ENCODERS.get() {
|
|
let injected = store.lock().unwrap();
|
|
if !injected.is_empty() {
|
|
return injected.first().cloned();
|
|
}
|
|
}
|
|
match encoder_type_from_format(params.format) {
|
|
Some(EncoderType::FFmpeg) => Some(Arc::new(crate::ffmpeg::FFmpegEncoder::with_params(
|
|
params.clone(),
|
|
))),
|
|
Some(EncoderType::OIIO) => Some(Arc::new(crate::oiio::OIIOEncoder {
|
|
params: params.clone(),
|
|
})),
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
/// `Encoder::Type` mirror (`encoder.cpp` `get_type_from_format`).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum EncoderType {
|
|
/// `k_encoder_type_f_fmpeg`.
|
|
FFmpeg,
|
|
/// `k_encoder_type_oiio`.
|
|
OIIO,
|
|
}
|
|
|
|
/// `Encoder::get_type_from_format` — the implementation family for an
|
|
/// `ExportFormat::Format` int; `None` for unknown/`Count`.
|
|
fn encoder_type_from_format(format: i32) -> Option<EncoderType> {
|
|
match format {
|
|
// FFmpeg-backed containers.
|
|
0 | 1 | 2 | 4 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 => Some(EncoderType::FFmpeg),
|
|
// OIIO-backed still-image formats.
|
|
3 | 5 | 6 => Some(EncoderType::OIIO),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Range `(start, end)` of a "[#####]" digit placeholder beginning at
|
|
/// `pos` (`bytes[pos] == '['`), or `None`.
|
|
///
|
|
/// CPP-PARITY: `encoder.cpp` `k_image_sequence_contains_digits` regex
|
|
/// `\[[#]+\]` — a `[`, one or more `#`, then `]`.
|
|
fn placeholder_range(bytes: &[u8], pos: usize) -> Option<(usize, usize)> {
|
|
if bytes.get(pos) != Some(&b'[') {
|
|
return None;
|
|
}
|
|
let mut j = pos + 1;
|
|
while bytes.get(j) == Some(&b'#') {
|
|
j += 1;
|
|
}
|
|
if j > pos + 1 && bytes.get(j) == Some(&b']') {
|
|
Some((pos, j + 1))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// `Encoder::filename_contains_digit_placeholder` — whether `filename`
|
|
/// contains a "[#####]" digit placeholder.
|
|
///
|
|
/// CPP-PARITY: `encoder.cpp:137` (`std::regex_search` on
|
|
/// `k_image_sequence_contains_digits`).
|
|
pub fn filename_contains_digit_placeholder(filename: &str) -> bool {
|
|
let bytes = filename.as_bytes();
|
|
(0..bytes.len()).any(|i| placeholder_range(bytes, i).is_some())
|
|
}
|
|
|
|
/// `Encoder::get_image_sequence_placeholder_digit_count` — number of `#` in
|
|
/// the filename's "[#####]" placeholder; 0 when none.
|
|
///
|
|
/// CPP-PARITY: `encoder.cpp:119` — the C++ finds the first
|
|
/// `k_image_sequence_contains_digits` match and counts its `#`s, which is
|
|
/// exactly the match length minus the two brackets.
|
|
pub fn image_sequence_placeholder_digit_count(filename: &str) -> i32 {
|
|
let bytes = filename.as_bytes();
|
|
for i in 0..bytes.len() {
|
|
if let Some((start, end)) = placeholder_range(bytes, i) {
|
|
return (end - start - 2) as i32;
|
|
}
|
|
}
|
|
0
|
|
}
|
|
|
|
/// `Encoder::filename_remove_digit_placeholder` — `filename` with every
|
|
/// "[#####]" placeholder removed; an optional single separator char
|
|
/// (`-`, `.`, ` `, `_`) immediately before the placeholder goes with it.
|
|
///
|
|
/// CPP-PARITY: `encoder.cpp:142` (`std::regex_replace` on
|
|
/// `k_image_sequence_remove_digits` = `[\-\.\ \_]?\[[#]+\]`, empty
|
|
/// replacement, all matches).
|
|
pub fn filename_remove_digit_placeholder(filename: &str) -> String {
|
|
let bytes = filename.as_bytes();
|
|
let mut out = Vec::with_capacity(bytes.len());
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
// A separator is consumed only when a placeholder follows it.
|
|
let ph_start = match bytes[i] {
|
|
b'-' | b'.' | b' ' | b'_' if placeholder_range(bytes, i + 1).is_some() => i + 1,
|
|
_ => i,
|
|
};
|
|
match placeholder_range(bytes, ph_start) {
|
|
Some((_, end)) => i = end,
|
|
None => {
|
|
out.push(bytes[i]);
|
|
i += 1;
|
|
}
|
|
}
|
|
}
|
|
String::from_utf8(out).unwrap_or_else(|_| filename.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn create_from_params_maps_formats() {
|
|
let mut p = EncodingParams::default();
|
|
|
|
// FFmpeg-backed containers.
|
|
for fmt in [0, 1, 2, 4, 7, 8, 9, 10, 11, 12, 13, 14] {
|
|
p.format = fmt;
|
|
let e = create_from_params(&p).expect("format {fmt}");
|
|
assert_eq!(e.id(), "ffmpeg", "format {fmt}");
|
|
}
|
|
// OIIO-backed still images.
|
|
for fmt in [3, 5, 6] {
|
|
p.format = fmt;
|
|
let e = create_from_params(&p).expect("format {fmt}");
|
|
assert_eq!(e.id(), "oiio", "format {fmt}");
|
|
}
|
|
// Unknown / Count -> None (C++ `k_encoder_type_none`).
|
|
p.format = 15;
|
|
assert!(create_from_params(&p).is_none());
|
|
p.format = -1;
|
|
assert!(create_from_params(&p).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn get_error_defaults_to_empty() {
|
|
let e = UnimplementedDummy;
|
|
assert_eq!(e.get_error(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn image_sequence_placeholder_helpers() {
|
|
// contains: "[#####]" style placeholder only.
|
|
assert!(filename_contains_digit_placeholder("/tmp/out_[#####].png"));
|
|
assert!(filename_contains_digit_placeholder("out[#].png"));
|
|
assert!(!filename_contains_digit_placeholder("/tmp/out.png"));
|
|
assert!(!filename_contains_digit_placeholder("out[####.png"));
|
|
assert!(!filename_contains_digit_placeholder("out[].png"));
|
|
|
|
// digit count: number of '#' in the first placeholder.
|
|
assert_eq!(
|
|
image_sequence_placeholder_digit_count("/tmp/out_[#####].png"),
|
|
5
|
|
);
|
|
assert_eq!(image_sequence_placeholder_digit_count("out[#].png"), 1);
|
|
assert_eq!(image_sequence_placeholder_digit_count("a[##]b[####]c"), 2);
|
|
assert_eq!(image_sequence_placeholder_digit_count("/tmp/out.png"), 0);
|
|
|
|
// remove: separator char before the placeholder goes with it.
|
|
assert_eq!(
|
|
filename_remove_digit_placeholder("/tmp/out_[#####].png"),
|
|
"/tmp/out.png"
|
|
);
|
|
assert_eq!(filename_remove_digit_placeholder("out[###].png"), "out.png");
|
|
assert_eq!(filename_remove_digit_placeholder("a_[#]b_[###]c"), "abc");
|
|
assert_eq!(
|
|
filename_remove_digit_placeholder("/tmp/out.png"),
|
|
"/tmp/out.png"
|
|
);
|
|
}
|
|
|
|
struct UnimplementedDummy;
|
|
|
|
impl Encoder for UnimplementedDummy {
|
|
fn id(&self) -> String {
|
|
"dummy".to_string()
|
|
}
|
|
fn configure(&self, _p: &EncodingParams) -> crate::error::Result<()> {
|
|
Ok(())
|
|
}
|
|
fn open(&self) -> crate::error::Result<()> {
|
|
Ok(())
|
|
}
|
|
fn close(&self) -> crate::error::Result<()> {
|
|
Ok(())
|
|
}
|
|
fn write_video(&self, _f: &Frame) -> crate::error::Result<()> {
|
|
Ok(())
|
|
}
|
|
fn write_audio(&self, _s: &[f32], _c: i32) -> crate::error::Result<()> {
|
|
Ok(())
|
|
}
|
|
fn write_subtitle(&self, _t: &str, _i: f64, _o: f64) -> crate::error::Result<()> {
|
|
Ok(())
|
|
}
|
|
fn flush(&self) -> crate::error::Result<()> {
|
|
Ok(())
|
|
}
|
|
fn desired_pixel_format(&self) -> Option<PixelFormat> {
|
|
None
|
|
}
|
|
fn desired_sample_format(&self) -> Option<SampleFormat> {
|
|
None
|
|
}
|
|
fn filename(&self) -> String {
|
|
String::new()
|
|
}
|
|
}
|
|
}
|