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)
368 lines
9.5 KiB
Rust
368 lines
9.5 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/>.
|
||
|
||
//! Textures and CPU frames.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use oakcore_rs::{PixelFormat, Rational};
|
||
|
||
use crate::backend::{BackendKind, GpuContextLike};
|
||
use crate::error::Result;
|
||
use crate::frame::VideoParamsPod;
|
||
|
||
/// A CPU frame (the payload oakcodec frames bridge into, and the value
|
||
/// `OakCodecFrame` handles box).
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Frame {
|
||
/// Width of the pixel buffer (effective resolution).
|
||
pub width: i32,
|
||
/// Height of the pixel buffer (effective resolution).
|
||
pub height: i32,
|
||
/// Pixel format (F32 on the main pipeline).
|
||
pub format: PixelFormat,
|
||
/// Channel count (4 on the main pipeline).
|
||
pub channels: i32,
|
||
/// Timestamp in the sequence timebase.
|
||
pub timestamp: Rational,
|
||
/// Pixel payload (row-major, tightly packed).
|
||
pub data: Vec<u8>,
|
||
/// Full video metadata (divider/aspect/interlacing etc.).
|
||
pub params: VideoParamsPod,
|
||
}
|
||
|
||
impl Default for Frame {
|
||
fn default() -> Self {
|
||
Self {
|
||
width: 0,
|
||
height: 0,
|
||
format: PixelFormat::Invalid,
|
||
channels: 0,
|
||
timestamp: Rational::NULL,
|
||
data: Vec::new(),
|
||
params: VideoParamsPod::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Frame {
|
||
/// An empty frame (C++ `Frame::create()` before allocation).
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
/// The dummy frame: 0×0, transparent black, never uploaded
|
||
/// (`Texture::dummy` semantics).
|
||
pub fn dummy() -> Self {
|
||
Self {
|
||
width: 0,
|
||
height: 0,
|
||
format: PixelFormat::F32,
|
||
channels: VideoParamsPod::INTERNAL_CHANNEL_COUNT,
|
||
timestamp: Rational::new(0, 1),
|
||
data: Vec::new(),
|
||
params: VideoParamsPod::default(),
|
||
}
|
||
}
|
||
|
||
/// Bytes per channel for the frame's format.
|
||
pub fn bytes_per_channel(&self) -> usize {
|
||
self.format.bytes_per_channel()
|
||
}
|
||
|
||
/// Line stride in bytes (tightly packed rows: `width * channels * bpc`).
|
||
pub fn linesize_bytes(&self) -> usize {
|
||
(self.width as usize)
|
||
.saturating_mul(self.channels as usize)
|
||
.saturating_mul(self.bytes_per_channel())
|
||
}
|
||
|
||
/// Total pixel payload size.
|
||
pub fn allocated_size(&self) -> usize {
|
||
(self.height as usize).saturating_mul(self.linesize_bytes())
|
||
}
|
||
|
||
/// True when the pixel buffer is allocated.
|
||
pub fn is_allocated(&self) -> bool {
|
||
!self.data.is_empty()
|
||
}
|
||
|
||
/// Set the frame's video metadata (dims, format, divider, aspect…).
|
||
/// The channel count stays at the pipeline constant (4).
|
||
pub fn set_video_params(&mut self, pod: VideoParamsPod) {
|
||
self.params = pod;
|
||
self.width = pod.effective_width();
|
||
self.height = pod.effective_height();
|
||
self.format = match pod.format {
|
||
f if f == PixelFormat::U8 as i32 => PixelFormat::U8,
|
||
f if f == PixelFormat::U10 as i32 => PixelFormat::U10,
|
||
f if f == PixelFormat::U16 as i32 => PixelFormat::U16,
|
||
f if f == PixelFormat::F16 as i32 => PixelFormat::F16,
|
||
f if f == PixelFormat::F32 as i32 => PixelFormat::F32,
|
||
_ => PixelFormat::Invalid,
|
||
};
|
||
self.channels = VideoParamsPod::INTERNAL_CHANNEL_COUNT;
|
||
}
|
||
|
||
/// The frame's video metadata as the public POD.
|
||
pub fn video_params(&self) -> VideoParamsPod {
|
||
let mut p = self.params;
|
||
p.width = self.width;
|
||
p.height = self.height;
|
||
p.format = self.format as i32;
|
||
p
|
||
}
|
||
|
||
/// Allocate (or re-allocate) the pixel buffer per the current metadata,
|
||
/// zeroed. Returns false when the metadata is invalid
|
||
/// (C++ `Frame::allocate`).
|
||
pub fn allocate(&mut self) -> bool {
|
||
if self.width <= 0 || self.height <= 0 || self.channels <= 0 {
|
||
return false;
|
||
}
|
||
let size = self.allocated_size();
|
||
if size == 0 {
|
||
return false;
|
||
}
|
||
if self.data.len() != size {
|
||
self.data = vec![0u8; size];
|
||
} else {
|
||
self.data.fill(0);
|
||
}
|
||
true
|
||
}
|
||
|
||
/// Borrowed pixel data pointer (empty when not allocated).
|
||
pub fn data(&self) -> *const u8 {
|
||
self.data.as_ptr()
|
||
}
|
||
|
||
/// Mutable pixel data pointer (empty when not allocated).
|
||
pub fn data_mut(&mut self) -> *mut u8 {
|
||
self.data.as_mut_ptr()
|
||
}
|
||
|
||
/// True for the dummy frame.
|
||
pub fn is_dummy(&self) -> bool {
|
||
self.width == 0 && self.height == 0 && self.data.is_empty()
|
||
}
|
||
|
||
/// True when the pixel format is a float type.
|
||
pub fn is_float(&self) -> bool {
|
||
matches!(self.format, PixelFormat::F16 | PixelFormat::F32)
|
||
}
|
||
|
||
/// Number of pixels.
|
||
pub fn pixel_count(&self) -> usize {
|
||
(self.width as usize).saturating_mul(self.height as usize)
|
||
}
|
||
}
|
||
|
||
/// A texture: either backend-resident (GPU) or a CPU-frame wrapper.
|
||
/// `Clone` is safe: the GPU token destroy is idempotent (registry
|
||
/// lookup), so two clones both release safely at their own drop.
|
||
///
|
||
/// GPU textures carry an `Arc` to their [`GpuContext`] (the C++ `TexturePtr`
|
||
/// keeps its renderer alive the same way), so a texture value can upload/
|
||
/// download/blit without a separate renderer handle. `Drop` releases the
|
||
/// backend token; destroying a token twice is harmless (registry lookup).
|
||
#[derive(Clone)]
|
||
pub enum Texture {
|
||
/// Backend GPU texture.
|
||
Gpu {
|
||
/// Backend token (wgpu texture registry key).
|
||
token: u64,
|
||
/// Owning backend.
|
||
backend: BackendKind,
|
||
/// Width.
|
||
width: i32,
|
||
/// Height.
|
||
height: i32,
|
||
/// Pixel format.
|
||
format: PixelFormat,
|
||
/// The context owning the texture (trait object so tests can fake
|
||
/// the GPU side; `GpuContext` is the only production implementor).
|
||
ctx: Arc<dyn GpuContextLike>,
|
||
},
|
||
/// CPU-frame wrapper (uploaded lazily by the backend).
|
||
Cpu(Frame),
|
||
}
|
||
|
||
impl std::fmt::Debug for Texture {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
Texture::Gpu {
|
||
token,
|
||
backend,
|
||
width,
|
||
height,
|
||
format,
|
||
..
|
||
} => f
|
||
.debug_struct("Texture::Gpu")
|
||
.field("token", token)
|
||
.field("backend", backend)
|
||
.field("width", width)
|
||
.field("height", height)
|
||
.field("format", format)
|
||
.finish(),
|
||
Texture::Cpu(frame) => f.debug_tuple("Texture::Cpu").field(frame).finish(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for Texture {
|
||
fn drop(&mut self) {
|
||
if let Texture::Gpu { token, ctx, .. } = self {
|
||
ctx.destroy_texture(*token);
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Texture {
|
||
/// A dummy/empty texture (C++ `Texture::dummy` semantics): reads as
|
||
/// transparent black, never uploaded.
|
||
pub fn dummy() -> Self {
|
||
Texture::Cpu(Frame::dummy())
|
||
}
|
||
|
||
/// True for dummy textures.
|
||
pub fn is_dummy(&self) -> bool {
|
||
match self {
|
||
Texture::Gpu { .. } => false,
|
||
Texture::Cpu(f) => f.is_dummy(),
|
||
}
|
||
}
|
||
|
||
/// Wrap a CPU frame (no copy).
|
||
pub fn wrap_frame(frame: Frame) -> Self {
|
||
Texture::Cpu(frame)
|
||
}
|
||
|
||
/// Read back into a CPU frame (downloads for GPU textures).
|
||
pub fn to_frame(&self) -> Result<Frame> {
|
||
match self {
|
||
Texture::Cpu(f) => Ok(f.clone()),
|
||
Texture::Gpu { token, ctx, .. } => ctx.download(*token),
|
||
}
|
||
}
|
||
|
||
/// Dimensions (0x0 for dummy).
|
||
pub fn size(&self) -> (i32, i32) {
|
||
match self {
|
||
Texture::Gpu { width, height, .. } => (*width, *height),
|
||
Texture::Cpu(f) => (f.width, f.height),
|
||
}
|
||
}
|
||
|
||
/// The texture's pixel format.
|
||
pub fn format(&self) -> PixelFormat {
|
||
match self {
|
||
Texture::Gpu { format, .. } => *format,
|
||
Texture::Cpu(f) => f.format,
|
||
}
|
||
}
|
||
|
||
/// The backend kind hosting the texture.
|
||
pub fn backend(&self) -> BackendKind {
|
||
match self {
|
||
Texture::Gpu { backend, .. } => *backend,
|
||
Texture::Cpu(_) => BackendKind::Cpu,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn frame_allocate_and_linesize() {
|
||
let mut f = Frame::new();
|
||
let mut p = VideoParamsPod::default();
|
||
p.width = 4;
|
||
p.height = 3;
|
||
f.set_video_params(p);
|
||
assert_eq!(f.width, 4);
|
||
assert_eq!(f.height, 3);
|
||
assert_eq!(f.channels, 4);
|
||
assert_eq!(f.linesize_bytes(), 4 * 4 * 4);
|
||
assert!(f.allocate());
|
||
assert_eq!(f.data.len(), 4 * 3 * 4 * 4);
|
||
assert!(f.data.iter().all(|&b| b == 0));
|
||
}
|
||
|
||
#[test]
|
||
fn allocate_rejects_invalid() {
|
||
let mut f = Frame::new();
|
||
assert!(!f.allocate());
|
||
f.width = 0;
|
||
f.height = 10;
|
||
f.channels = 4;
|
||
f.format = PixelFormat::F32;
|
||
assert!(!f.allocate());
|
||
}
|
||
|
||
#[test]
|
||
fn dummy_frame_semantics() {
|
||
let d = Frame::dummy();
|
||
assert!(d.is_dummy());
|
||
assert_eq!(d.width, 0);
|
||
let t = Texture::dummy();
|
||
assert!(t.is_dummy());
|
||
assert_eq!(t.size(), (0, 0));
|
||
assert_eq!(t.backend(), BackendKind::Cpu);
|
||
}
|
||
|
||
#[test]
|
||
fn video_params_roundtrip_and_pointers() {
|
||
let mut f = Frame::new();
|
||
let mut p = VideoParamsPod::default();
|
||
p.width = 6;
|
||
p.height = 4;
|
||
p.divider = 2;
|
||
p.pixel_aspect_num = 2;
|
||
p.pixel_aspect_den = 1;
|
||
f.set_video_params(p);
|
||
// Divider shrinks the buffer dims (effective resolution).
|
||
assert_eq!(f.width, 3);
|
||
assert_eq!(f.height, 2);
|
||
let pod = f.video_params();
|
||
assert_eq!(pod.width, 3);
|
||
assert_eq!(pod.pixel_aspect_num, 2);
|
||
assert_eq!(f.is_float(), true);
|
||
assert_eq!(f.pixel_count(), 6);
|
||
f.allocate();
|
||
assert!(!f.data().is_null());
|
||
assert!(!f.data_mut().is_null());
|
||
// timestamp default null.
|
||
assert!(f.timestamp.is_null());
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_and_to_frame_roundtrip() {
|
||
let mut f = Frame::new();
|
||
let mut p = VideoParamsPod::default();
|
||
p.width = 2;
|
||
p.height = 2;
|
||
f.set_video_params(p);
|
||
f.allocate();
|
||
f.data[0] = 0xAB;
|
||
let t = Texture::wrap_frame(f.clone());
|
||
assert_eq!(t.to_frame().unwrap(), f);
|
||
}
|
||
}
|