core: merge oak-common into oak-core
oak-common is gone; its modules (configstore, xmlutils, ocioutils,
oiioutils, colormath, colortransform, videoparams, ffmpegutils, ...)
now live in oak-core alongside the value types. The render value/GPU
types moved too: backend (wgpu context + DisplayRenderer), color
(ColorProcessor over ocio-rs), texture, frame, and the commonutil
config helpers.
Fix-ups to make the merged tree build and pass tests:
- oak-core Cargo.toml: wgpu back to 25 (the moved backend code is
written against that API generation); add the toml/quick-xml/image
deps oak-common carried.
- lib.rs: drop the duplicate 'pub mod error;'.
- error.rs: unified OAKCORE_* codes; restore Error::new() and
From<OcioError> from oak-common's error type.
- backend.rs/color.rs: oak_core::/oak_render:: self-references
rewritten to crate::; the shaderfx-dependent GPU effect test moved
to oak-render's shaderfx tests (shaderfx depends on oak-node and
cannot live in oak-core).
- oak-render's error module re-exports oak_core::error::{Error,
Result}; the OAKRENDER_* codes stay as the public-code contract.
- oak-node jobs.rs: ColorProcessor imported from oak_core::color.
- Integration tests repointed at oak_core::{texture, frame, backend,
color, colormath}.
- the display-ICC regression test treats an empty OAK_DISPLAY_ICC as
unset, matching displayicc::env_override_icc.
This commit is contained in:
@@ -297,9 +297,9 @@ mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use crate::frame::VideoParamsPod;
|
||||
use crate::texture::{Frame, Texture};
|
||||
use crate::worker::{InlineDispatcher, JobDispatch};
|
||||
use oak_core::frame::VideoParamsPod;
|
||||
use oak_core::texture::{Frame, Texture};
|
||||
|
||||
fn frame_producer() -> crate::ticket::Producer {
|
||||
Arc::new(|_, _| {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -261,7 +261,7 @@ pub struct PlaybackCache {
|
||||
impl PlaybackCache {
|
||||
/// New cache for `owner` (C++ `PlaybackCache(parent)`).
|
||||
pub fn new(kind: CacheKind, owner: OwnerIdentity) -> Self {
|
||||
let disk_dir = crate::commonutil::default_disk_cache_path();
|
||||
let disk_dir = oak_core::commonutil::default_disk_cache_path();
|
||||
Self {
|
||||
kind,
|
||||
owner,
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
//! flag shared between a render/encode caller and its worker.
|
||||
//!
|
||||
//! Single-lib unification (see `docs/zh/plans/riir/single-lib.md`): the
|
||||
//! implementation moved to oakcommon; this module re-exports it so the
|
||||
//! implementation moved to oak_core; this module re-exports it so the
|
||||
//! render ffi's `oakrender_cancelatom_*` exports (and their C-ABI
|
||||
//! consumers, e.g. oakcodec) keep working unchanged.
|
||||
|
||||
/// Shared cancellation atom (oakcommon).
|
||||
pub use oak_common::cancelatom::CancelAtom;
|
||||
/// Shared cancellation atom (oak_core).
|
||||
pub use oak_core::cancelatom::CancelAtom;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,87 +0,0 @@
|
||||
// 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 helpers (config, file functions) — direct Rust calls into
|
||||
//! the oakcommon crate (single-lib unification; the former
|
||||
//! `bridge/common.rs`). The configuration-location and disk-cache
|
||||
//! helpers delegate to oakcommon's own implementation.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Read a config string via the domain store
|
||||
/// (`ConfigStore::get(group, key)`); `None` when missing or empty.
|
||||
pub fn config_get_string(group: Option<&str>, key: &str) -> Option<String> {
|
||||
oak_common::configstore::ConfigStore::instance()
|
||||
.get(group, key)
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// `oakcommon_config_get_int(group, key, default)`.
|
||||
pub fn config_get_int(group: Option<&str>, key: &str, default: i32) -> i32 {
|
||||
oak_common::configstore::ConfigStore::instance().get_int(group, key, default)
|
||||
}
|
||||
|
||||
/// The configuration directory — oakcommon's implementation
|
||||
/// (`FileFunctions::get_configuration_location`, honoring `OAK_CONFIG_DIR`
|
||||
/// and the platform fallbacks).
|
||||
pub fn configuration_location() -> String {
|
||||
oak_common::filefunctions::FileFunctions::new()
|
||||
.get_configuration_location()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Serializes tests that mutate `OAK_CONFIG_DIR` / `OAK_RENDER_BACKEND`
|
||||
/// (env is process-global; the manager tests share this lock too).
|
||||
pub static ENV_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// The default disk cache directory (C++ `DiskManager::
|
||||
/// get_default_disk_cache_path`): `<configuration_location>/mediacache`.
|
||||
pub fn default_disk_cache_path() -> String {
|
||||
oak_common::filefunctions::default_disk_cache_path()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_missing_key_falls_back() {
|
||||
// The real config store is empty under cargo test: defaults apply.
|
||||
assert_eq!(config_get_int(None, "GraphicsBackend", 7), 7);
|
||||
assert_eq!(config_get_string(None, "missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_location_uses_env_override() {
|
||||
let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join("oakrender-config-test");
|
||||
std::env::set_var("OAK_CONFIG_DIR", &dir);
|
||||
let loc = configuration_location();
|
||||
assert_eq!(loc, dir.to_string_lossy());
|
||||
std::env::remove_var("OAK_CONFIG_DIR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_disk_cache_path_is_under_config() {
|
||||
let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join("oakrender-cache-test");
|
||||
std::env::set_var("OAK_CONFIG_DIR", &dir);
|
||||
let p = default_disk_cache_path();
|
||||
assert!(p.ends_with("/mediacache") || p.ends_with("\\mediacache"));
|
||||
std::env::remove_var("OAK_CONFIG_DIR");
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,13 @@
|
||||
|
||||
//! Error codes, mirroring `include/render/error.h` verbatim; project-wide
|
||||
//! -MMCCCC scheme (module registry in include/common/error.h), pass-through untranslated.
|
||||
//!
|
||||
//! With the oak-common/oak-core merge the render value/GPU types moved into
|
||||
//! `oak-core`, so this crate no longer carries its own error enum:
|
||||
//! [`Error`]/[`Result`] are re-exported from [`oak_core::error`] (identical
|
||||
//! variant shape and messages). The `OAKRENDER_*` codes below remain as the
|
||||
//! module's public-code contract; `Error::code()` reports the unified
|
||||
//! `OAKCORE_*` values.
|
||||
|
||||
/// Success.
|
||||
pub const OAKRENDER_OK: i32 = 0;
|
||||
@@ -30,41 +37,7 @@ pub const OAKRENDER_E_NOT_FOUND: i32 = -70004;
|
||||
/// Allocation failed.
|
||||
pub const OAKRENDER_E_NOMEM: i32 = -70005;
|
||||
|
||||
/// Crate-internal result type.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Crate-internal error.
|
||||
#[derive(Clone, Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// Null handle or invalid argument.
|
||||
#[error("render: invalid argument")]
|
||||
Invalid,
|
||||
/// Wrong state.
|
||||
#[error("render: invalid state")]
|
||||
State,
|
||||
/// Operation failed (context string is log-only).
|
||||
#[error("render: operation failed: {0}")]
|
||||
Failed(String),
|
||||
/// Not found.
|
||||
#[error("render: not found")]
|
||||
NotFound,
|
||||
/// Out of memory.
|
||||
#[error("render: out of memory")]
|
||||
NoMem,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Map to the public error code.
|
||||
pub fn code(&self) -> i32 {
|
||||
match self {
|
||||
Error::Invalid => OAKRENDER_E_INVALID,
|
||||
Error::State => OAKRENDER_E_STATE,
|
||||
Error::Failed(_) => OAKRENDER_E_FAILED,
|
||||
Error::NotFound => OAKRENDER_E_NOT_FOUND,
|
||||
Error::NoMem => OAKRENDER_E_NOMEM,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use oak_core::error::{Error, Result};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -108,14 +81,4 @@ mod tests {
|
||||
fn source_is_none() {
|
||||
assert!(std::error::Error::source(&Error::Failed("context".into())).is_none());
|
||||
}
|
||||
|
||||
/// The `code()` mapping must be unchanged by the `Error` trait impl.
|
||||
#[test]
|
||||
fn code_mapping_unchanged() {
|
||||
assert_eq!(Error::Invalid.code(), OAKRENDER_E_INVALID);
|
||||
assert_eq!(Error::State.code(), OAKRENDER_E_STATE);
|
||||
assert_eq!(Error::Failed("context".into()).code(), OAKRENDER_E_FAILED);
|
||||
assert_eq!(Error::NotFound.code(), OAKRENDER_E_NOT_FOUND);
|
||||
assert_eq!(Error::NoMem.code(), OAKRENDER_E_NOMEM);
|
||||
}
|
||||
}
|
||||
|
||||
+1930
-2005
File diff suppressed because it is too large
Load Diff
@@ -1,146 +0,0 @@
|
||||
// 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/>.
|
||||
|
||||
//! CPU frame payloads and the [`VideoParamsPod`] value (the Rust mirror of
|
||||
//! the `oakrender_video_params` POD in `include/render/renderer.h`).
|
||||
//!
|
||||
//! The C ABI frame functions (`oakrender_codec_frame_*`) marshal this
|
||||
//! type; the FFI layer stores [`Frame`] values in `OakCodecFrame` handles.
|
||||
|
||||
use oak_core::{PixelFormat, Rational};
|
||||
|
||||
/// Mirror of the `oakrender_video_params` POD (include/render/renderer.h,
|
||||
/// field order and semantics verbatim). Stored inside [`crate::texture::Frame`]
|
||||
/// so the ffi `*_get_params` exports can report the full metadata.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct VideoParamsPod {
|
||||
/// Frame width (full resolution).
|
||||
pub width: i32,
|
||||
/// Frame height (full resolution).
|
||||
pub height: i32,
|
||||
/// Frame duration numerator (e.g. 1001/30000 s).
|
||||
pub time_base_num: i32,
|
||||
/// Frame duration denominator.
|
||||
pub time_base_den: i32,
|
||||
/// `olive::PixelFormat::Format` as int.
|
||||
pub format: i32,
|
||||
/// Pixel aspect numerator.
|
||||
pub pixel_aspect_num: i32,
|
||||
/// Pixel aspect denominator.
|
||||
pub pixel_aspect_den: i32,
|
||||
/// `olive::VideoParams::Interlacing` as int.
|
||||
pub interlacing: i32,
|
||||
/// `olive::VideoParams::ColorRange` as int.
|
||||
pub color_range: i32,
|
||||
/// Preview resolution divider (1 = full).
|
||||
pub divider: i32,
|
||||
/// `olive::VideoParams::Type` (0 = video).
|
||||
pub video_type: i32,
|
||||
/// 0/1.
|
||||
pub premultiplied_alpha: i32,
|
||||
}
|
||||
|
||||
impl Default for VideoParamsPod {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: 0,
|
||||
height: 0,
|
||||
time_base_num: 1,
|
||||
time_base_den: 1,
|
||||
format: PixelFormat::F32 as i32,
|
||||
pixel_aspect_num: 1,
|
||||
pixel_aspect_den: 1,
|
||||
interlacing: 0,
|
||||
color_range: 0,
|
||||
divider: 1,
|
||||
video_type: 0,
|
||||
premultiplied_alpha: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VideoParamsPod {
|
||||
/// The default render size used when a ticket carries no force size and
|
||||
/// the output node's video params cannot be queried (oakcommon bridge
|
||||
/// pending).
|
||||
pub const DEFAULT_WIDTH: i32 = 1920;
|
||||
/// See [`VideoParamsPod::DEFAULT_WIDTH`].
|
||||
pub const DEFAULT_HEIGHT: i32 = 1080;
|
||||
|
||||
/// Frame rate as a rational (time base flipped; null for a null time
|
||||
/// base).
|
||||
pub fn frame_rate(&self) -> Rational {
|
||||
if self.time_base_num <= 0 || self.time_base_den <= 0 {
|
||||
return Rational::NULL;
|
||||
}
|
||||
Rational::new(self.time_base_den as i64, self.time_base_num as i64)
|
||||
}
|
||||
|
||||
/// The time base (frame duration).
|
||||
pub fn time_base(&self) -> Rational {
|
||||
if self.time_base_num <= 0 || self.time_base_den <= 0 {
|
||||
return Rational::NULL;
|
||||
}
|
||||
Rational::new(self.time_base_num as i64, self.time_base_den as i64)
|
||||
}
|
||||
|
||||
/// The internal (fixed) channel count — the C++ engine uses 4 for the
|
||||
/// video pipeline; not exposed in the POD.
|
||||
pub const INTERNAL_CHANNEL_COUNT: i32 = 4;
|
||||
|
||||
/// Pixel dimensions of the data buffer honoring the preview divider
|
||||
/// (C++ `VideoParams::effective_width/height` with a divider).
|
||||
pub fn effective_width(&self) -> i32 {
|
||||
(self.width / self.divider.max(1)).max(0)
|
||||
}
|
||||
|
||||
/// See [`VideoParamsPod::effective_width`].
|
||||
pub fn effective_height(&self) -> i32 {
|
||||
(self.height / self.divider.max(1)).max(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pod_defaults() {
|
||||
let p = VideoParamsPod::default();
|
||||
assert_eq!(p.format, PixelFormat::F32 as i32);
|
||||
assert_eq!(p.time_base(), Rational::new(1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_size_honors_divider() {
|
||||
let mut p = VideoParamsPod::default();
|
||||
p.width = 3840;
|
||||
p.height = 2160;
|
||||
p.divider = 2;
|
||||
assert_eq!(p.effective_width(), 1920);
|
||||
assert_eq!(p.effective_height(), 1080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_rate_is_flipped_time_base() {
|
||||
let mut p = VideoParamsPod::default();
|
||||
p.time_base_num = 1001;
|
||||
p.time_base_den = 30000;
|
||||
assert_eq!(p.frame_rate(), Rational::new(30000, 1001));
|
||||
assert_eq!(p.time_base(), Rational::new(1001, 30000));
|
||||
}
|
||||
}
|
||||
@@ -21,43 +21,36 @@
|
||||
//!
|
||||
//! Module map (mirrors `COVERAGE.md`):
|
||||
//! - `handle` — refcounted C-handle scaffolding (C++ internalhandles.h)
|
||||
//! - `texture`/`frame` — Texture + CPU Frame values (C++ texture.h/frame)
|
||||
//! - `cache` — PlaybackCache/FrameHashCache family + disk state
|
||||
//! - `color` — ColorProcessor over `ocio-rs` + LUT library
|
||||
//! - `ticket` — ticket arena with exactly-once completion
|
||||
//! - `worker` — the JobDispatch seam + thread-free inline dispatcher +
|
||||
//! graph snapshot store (the in-process thread pool was deleted in M15 S2)
|
||||
//! - `manager` — RenderManager singleton + disk cache
|
||||
//! - `autocacher` — PreviewAutoCacher
|
||||
//! - `eval` — the evaluation seam (RenderHooks)
|
||||
//! - `backend` — wgpu GPU context + display renderer
|
||||
//! - `shaderfx` — effect GLSL→WGSL translation + uniform packing
|
||||
//! - `copier` — render-side project-copy client (oaknode C ABI)
|
||||
//! - `cancelatom` — the cancellation primitive
|
||||
//! - `ipc` — render-worker NDJSON protocol + shm frame-slot transport
|
||||
//! - `scheduler` — preview frame scheduler (interleaved batch claims)
|
||||
//! - `procpool` — process-isolated render backend (M15)
|
||||
//! - `bridge` — direct-call C ABI bridges (oakcommon/oaknode/oakcodec)
|
||||
//! - `ffi` — the `include/render/*.h` export layer
|
||||
//! - `error` — re-exports `oak_core::error` (the backend/color/texture/
|
||||
//! frame value types moved to `oak-core` in the oak-common merge)
|
||||
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod autocacher;
|
||||
pub mod backend;
|
||||
pub mod cache;
|
||||
pub mod cancelatom;
|
||||
pub mod color;
|
||||
pub mod commonutil;
|
||||
pub mod copier;
|
||||
pub mod error;
|
||||
pub mod eval;
|
||||
pub mod frame;
|
||||
pub mod handle;
|
||||
pub mod ipc;
|
||||
pub mod manager;
|
||||
pub mod procpool;
|
||||
pub mod scheduler;
|
||||
pub mod shaderfx;
|
||||
pub mod texture;
|
||||
pub mod ticket;
|
||||
pub mod worker;
|
||||
|
||||
@@ -28,12 +28,12 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
use crate::autocacher::PreviewAutoCacher;
|
||||
use crate::backend::BackendKind;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::eval;
|
||||
use crate::procpool::{DispatcherConfig, ProcessDispatcher, ShmAudioRef, ShmFrameRef};
|
||||
use crate::ticket::{TicketArena, TicketId};
|
||||
use crate::worker::{GraphSnapshotStore, InlineDispatcher, JobDispatch};
|
||||
use oak_core::backend::BackendKind;
|
||||
|
||||
static MANAGER: Mutex<Option<Arc<RenderManager>>> = Mutex::new(None);
|
||||
|
||||
@@ -421,10 +421,10 @@ impl RenderManager {
|
||||
}
|
||||
|
||||
/// The default disk cache directory (C++ `DiskManager::
|
||||
/// get_default_disk_cache_path`); shared with oaknode via oakcommon
|
||||
/// get_default_disk_cache_path`); shared with oaknode via oak_core
|
||||
/// (single-lib unification).
|
||||
pub fn disk_cache_path() -> String {
|
||||
oak_common::filefunctions::default_disk_cache_path()
|
||||
oak_core::filefunctions::default_disk_cache_path()
|
||||
}
|
||||
|
||||
/// Bytes consumed by the default disk cache folder (direct filesystem
|
||||
@@ -474,10 +474,10 @@ fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use super::*;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
/// Serializes the manager-singleton tests (the singleton is global).
|
||||
/// Serializes the manager-singleton tests (the singleton is global).
|
||||
static MANAGER_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn manager_lock() -> MutexGuard<'static, ()> {
|
||||
@@ -533,7 +533,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn disk_cache_size_and_clear() {
|
||||
let _guard = crate::commonutil::ENV_TEST_LOCK
|
||||
let _guard = oak_core::commonutil::ENV_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join("oakrender-diskcache-test");
|
||||
@@ -550,7 +550,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn disk_cache_size_missing_dir_is_zero() {
|
||||
let _guard = crate::commonutil::ENV_TEST_LOCK
|
||||
let _guard = oak_core::commonutil::ENV_TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let dir = std::env::temp_dir().join("oakrender-diskcache-missing");
|
||||
|
||||
@@ -155,13 +155,13 @@ pub struct CompiledEffect {
|
||||
/// The translation result (uniform layout + texture bindings).
|
||||
pub translated: TranslatedShader,
|
||||
/// The compiled pipeline in the context cache.
|
||||
pub program: std::sync::Arc<crate::backend::ShaderProgram>,
|
||||
pub program: std::sync::Arc<oak_core::backend::ShaderProgram>,
|
||||
}
|
||||
|
||||
/// Translate `glsl` and compile the pipeline on `ctx`. `key` is the
|
||||
/// pipeline cache key (the effect type id plus any shader-variant id).
|
||||
pub fn compile_effect(
|
||||
ctx: &crate::backend::GpuContext,
|
||||
ctx: &oak_core::backend::GpuContext,
|
||||
key: &str,
|
||||
glsl: &str,
|
||||
filtering: bool,
|
||||
@@ -193,7 +193,7 @@ pub fn compile_effect(
|
||||
/// `params`: `resolution_in` (the frame size), `ove_iteration`,
|
||||
/// `ove_mvpmat` (identity).
|
||||
pub fn run_effect(
|
||||
ctx: &crate::backend::GpuContext,
|
||||
ctx: &oak_core::backend::GpuContext,
|
||||
effect: &CompiledEffect,
|
||||
params: &oak_node::value::NodeValueRow,
|
||||
inputs: &[(String, u64)],
|
||||
@@ -780,14 +780,14 @@ void main() { frag_color = texture(tex_in, ove_texcoord); }
|
||||
|
||||
// ---- GPU runner tests (skipped without an adapter) -------------------
|
||||
|
||||
fn gpu() -> Option<std::sync::Arc<crate::backend::GpuContext>> {
|
||||
crate::backend::GpuContext::create(crate::backend::BackendKind::Auto)
|
||||
fn gpu() -> Option<std::sync::Arc<oak_core::backend::GpuContext>> {
|
||||
oak_core::backend::GpuContext::create(oak_core::backend::BackendKind::Auto)
|
||||
}
|
||||
|
||||
fn f32_frame(w: i32, h: i32, fill: impl Fn(usize) -> [f32; 4]) -> crate::texture::Frame {
|
||||
use crate::texture::Frame;
|
||||
fn f32_frame(w: i32, h: i32, fill: impl Fn(usize) -> [f32; 4]) -> oak_core::texture::Frame {
|
||||
use oak_core::texture::Frame;
|
||||
let mut frame = Frame::new();
|
||||
let mut pod = crate::frame::VideoParamsPod::default();
|
||||
let mut pod = oak_core::frame::VideoParamsPod::default();
|
||||
pod.width = w;
|
||||
pod.height = h;
|
||||
pod.format = oak_core::PixelFormat::F32 as i32;
|
||||
@@ -803,7 +803,7 @@ void main() { frag_color = texture(tex_in, ove_texcoord); }
|
||||
frame
|
||||
}
|
||||
|
||||
fn pixel(out: &crate::texture::Frame, x: usize) -> [f32; 4] {
|
||||
fn pixel(out: &oak_core::texture::Frame, x: usize) -> [f32; 4] {
|
||||
let mut rgba = [0.0f32; 4];
|
||||
for (c, v) in rgba.iter_mut().enumerate() {
|
||||
*v = f32::from_le_bytes(out.data[(x * 4 + c) * 4..(x * 4 + c) * 4 + 4].try_into().unwrap());
|
||||
@@ -811,6 +811,83 @@ void main() { frag_color = texture(tex_in, ove_texcoord); }
|
||||
rgba
|
||||
}
|
||||
|
||||
/// End-to-end effect pass: a translated node shader (gain multiply)
|
||||
/// runs through `compile_shader_pass`/`run_shader_pass` and the
|
||||
/// readback matches the expected pixels exactly. (Moved from
|
||||
/// oak-core's backend tests — the GLSL→WGSL translation and uniform
|
||||
/// packing live in this module.)
|
||||
#[test]
|
||||
fn gpu_effect_pass_runs_translated_shader() {
|
||||
use oak_core::frame::VideoParamsPod;
|
||||
use oak_core::texture::Frame;
|
||||
|
||||
let Some(ctx) = gpu() else {
|
||||
eprintln!("no adapter; skipping effect pass");
|
||||
return;
|
||||
};
|
||||
let glsl = r#"
|
||||
uniform sampler2D tex_in;
|
||||
uniform float gain_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
frag_color = texture(tex_in, ove_texcoord) * gain_in;
|
||||
}
|
||||
"#;
|
||||
let translated = translate(glsl).unwrap();
|
||||
let program = ctx
|
||||
.compile_shader_pass(
|
||||
"test-gain",
|
||||
&translated.wgsl,
|
||||
translated.textures.len() as u32,
|
||||
!translated.uniforms.is_empty(),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut row = oak_node::value::NodeValueRow::new();
|
||||
row.insert("gain_in".into(), oak_node::value::NodeValue::Float(0.5));
|
||||
let uniforms = pack_uniforms(&translated, &row);
|
||||
|
||||
let w = 4;
|
||||
let h = 2;
|
||||
let src = ctx.create_texture(w, h).unwrap();
|
||||
let dst = ctx.create_texture(w, h).unwrap();
|
||||
let mut frame = Frame::new();
|
||||
let mut pod = VideoParamsPod::default();
|
||||
pod.width = w;
|
||||
pod.height = h;
|
||||
frame.set_video_params(pod);
|
||||
frame.allocate();
|
||||
// Distinct values per pixel (F32 RGBA): 0.2/0.4/0.6/1.0 shifted
|
||||
// per pixel, so a UV mixup would be visible.
|
||||
for px in 0..(w * h) as usize {
|
||||
for c in 0..4 {
|
||||
let v = 0.2 + 0.1 * (px + c) as f32;
|
||||
frame.data[(px * 4 + c) * 4..(px * 4 + c) * 4 + 4]
|
||||
.copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
ctx.upload(src, &frame).unwrap();
|
||||
ctx.run_shader_pass(&program, &uniforms, &[src], dst).unwrap();
|
||||
let out = ctx.download(dst).unwrap();
|
||||
for px in 0..(w * h) as usize {
|
||||
for c in 0..4 {
|
||||
let at = (px * 4 + c) * 4;
|
||||
let got = f32::from_le_bytes(out.data[at..at + 4].try_into().unwrap());
|
||||
let want = (0.2 + 0.1 * (px + c) as f32) * 0.5;
|
||||
assert!(
|
||||
(got - want).abs() < 1e-6,
|
||||
"px {px} ch {c}: got {got}, want {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.destroy_texture(src);
|
||||
ctx.destroy_texture(dst);
|
||||
}
|
||||
|
||||
/// The real opacity shader through the full runner: pixels are
|
||||
/// multiplied by the factor (and the UV convention is identity —
|
||||
/// a flip would move the non-uniform pixels around).
|
||||
|
||||
@@ -1,367 +0,0 @@
|
||||
// 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 oak_core::{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);
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,8 @@ use oak_core::{Rational, TimeRange};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::eval;
|
||||
use crate::texture::Texture;
|
||||
use crate::worker::{JobDispatch, JobSchedule};
|
||||
use oak_core::texture::Texture;
|
||||
|
||||
/// One effect of a montage clip's effect stack (M14 R3 effect-chain
|
||||
/// wiring into the montage render path). The stack is ordered
|
||||
@@ -135,8 +135,8 @@ impl VideoTicketParams {
|
||||
/// The render size: force_size when set, else the pipeline default.
|
||||
pub fn render_size(&self) -> (i32, i32) {
|
||||
self.force_size.unwrap_or((
|
||||
crate::frame::VideoParamsPod::DEFAULT_WIDTH,
|
||||
crate::frame::VideoParamsPod::DEFAULT_HEIGHT,
|
||||
oak_core::frame::VideoParamsPod::DEFAULT_WIDTH,
|
||||
oak_core::frame::VideoParamsPod::DEFAULT_HEIGHT,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -698,9 +698,9 @@ mod tests {
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::frame::VideoParamsPod;
|
||||
use crate::texture::Frame;
|
||||
use crate::worker::{InlineDispatcher, JobDispatch};
|
||||
use oak_core::frame::VideoParamsPod;
|
||||
use oak_core::texture::Frame;
|
||||
|
||||
fn small_frame() -> Frame {
|
||||
let mut f = Frame::new();
|
||||
|
||||
@@ -299,8 +299,8 @@ impl GraphSnapshotStore {
|
||||
"oakrender-snapshots-{}-{:x}",
|
||||
std::process::id(),
|
||||
{
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
SEQ.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
));
|
||||
@@ -463,13 +463,13 @@ impl Default for GraphSnapshotStore {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use super::*;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::texture::Texture;
|
||||
use oak_core::texture::Texture;
|
||||
|
||||
fn job(tag: u64, tx: mpsc::Sender<u64>, gate: Option<Arc<AtomicBool>>) -> Job {
|
||||
fn job(tag: u64, tx: mpsc::Sender<u64>, gate: Option<Arc<AtomicBool>>) -> Job {
|
||||
let produce: Producer = Arc::new(move |_, _| {
|
||||
if let Some(g) = &gate {
|
||||
if g.load(Ordering::Acquire) {
|
||||
@@ -685,7 +685,7 @@ mod tests {
|
||||
// rewrite the file (the dedup the resync exists to bypass)...
|
||||
{
|
||||
let mut guard = project.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.set_working_color_space(oak_common::colormath::WorkingColorSpace::SrgbLegacy);
|
||||
guard.set_working_color_space(oak_core::colormath::WorkingColorSpace::SrgbLegacy);
|
||||
}
|
||||
let p2 = store.acquire(&project, 1).unwrap();
|
||||
assert_eq!(p1, p2, "same (uuid, revision) key reuses the file");
|
||||
|
||||
Reference in New Issue
Block a user