test(oak-render, oak-worker): eval, procpool and worker coverage

Render evaluation fallbacks, the process pool (dispatch, cancel,
restart, teardown), half-float display packing, and the worker's
shared-memory job paths; includes the M5 footage import acceptance
tests and the software-decode byte-exactness guard.
This commit is contained in:
2026-09-22 20:54:04 +08:00
parent 12ca9d1d7a
commit 666ac9b4d4
9 changed files with 7204 additions and 35 deletions
+310
View File
@@ -880,4 +880,314 @@ mod tests {
assert!(!dir.join(&c.uuid).join("state").exists());
std::fs::remove_dir_all(&dir).ok();
}
// ---- remaining boundary surface --------------------------------------
fn work_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("oakrender-test-{tag}-{}", next_owner_identity()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn accessors_and_null_timebase_defaults() {
let mut c = PlaybackCache::new(CacheKind::AudioPlayback, 42);
assert_eq!(c.uuid().len(), 38);
assert_eq!(c.timebase(), Rational::NULL);
assert!(c.saving_enabled());
assert!(!c.disk_dir().is_empty());
assert!(c.requested_ranges().is_empty());
assert!(c.passthroughs().is_empty());
c.set_timebase(Rational::new(1, 25));
assert_eq!(c.timebase(), Rational::new(1, 25));
c.set_saving_enabled(false);
assert!(!c.saving_enabled());
c.set_disk_dir("/tmp/oak-cache-test");
assert_eq!(c.disk_dir(), "/tmp/oak-cache-test");
c.set_uuid("{00000000-0000-4000-8000-000000000000}");
assert_eq!(c.uuid(), "{00000000-0000-4000-8000-000000000000}");
}
#[test]
fn request_and_clear_ranges() {
let mut c = PlaybackCache::new(CacheKind::VideoFrame, 1);
c.set_saving_enabled(false);
let range = TimeRange::new(Rational::new(1, 1), Rational::new(2, 1));
c.request(range);
assert_eq!(c.requested_ranges().ranges(), &[range]);
c.clear_request_range(range);
assert!(c.requested_ranges().is_empty());
}
#[test]
fn byte_reader_reads_past_the_end_as_zero() {
let data = [0x12u8, 0x34, 0x56, 0x78];
let mut r = ByteReader::new(&data);
assert_eq!(r.read_u32(), 0x1234_5678);
// Past the end: zeroed values, no panic.
assert_eq!(r.read_u32(), 0);
assert_eq!(r.read_i32(), 0);
assert_eq!(r.read_uuid(), [0u8; 16]);
let mut out = [0xFFu8; 4];
assert_eq!(r.take(&mut out), 0);
assert_eq!(out, [0xFFu8; 4]);
// Partial reads copy only the bytes that exist.
let mut r = ByteReader::new(&data);
assert_eq!(r.read_be(2), 0x1234);
// A partial read is left-aligned and zero-padded on the right.
assert_eq!(r.read_be(4), 0x5678_0000);
assert_eq!(r.read_be(8), 0);
let mut short = [0u8; 2];
let mut r = ByteReader::new(&data);
assert_eq!(r.take(&mut short), 2);
assert_eq!(short, [0x12, 0x34]);
}
#[test]
fn uuid_text_conversion_ignores_trailing_nibbles() {
let canonical = "{00112233-4455-6677-8899-aabbccddeeff}";
let bytes = uuid_text_to_bytes(canonical);
assert_eq!(
bytes,
[
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc,
0xdd, 0xee, 0xff
]
);
assert_eq!(bytes_to_uuid_text(&bytes), canonical);
// Anything past the 32nd nibble is ignored (QDataStream parity).
assert_eq!(uuid_text_to_bytes(&format!("{canonical}ffff")), bytes);
}
#[test]
fn modification_time_is_zero_for_missing_paths() {
assert_eq!(
modification_time_msecs(std::path::Path::new("/definitely/not/here")),
0
);
}
#[test]
fn set_uuid_reloads_the_disk_state() {
let dir = work_dir("uuid-reload");
let mut source = tb_cache();
source.set_saving_enabled(true);
source.set_disk_dir(&dir.to_string_lossy());
let uuid = source.uuid.clone();
source.validate(TimeRange::new(Rational::new(3, 1), Rational::new(9, 1)));
source.save_state(&dir).unwrap();
let mut target = PlaybackCache::new(CacheKind::VideoFrame, 2);
target.set_saving_enabled(false);
target.set_disk_dir(&dir.to_string_lossy());
target.set_uuid(&uuid);
assert_eq!(
target.validated_ranges().ranges(),
&[TimeRange::new(Rational::new(3, 1), Rational::new(9, 1))]
);
// A uuid without a state file clears the ranges (missing state).
target.set_uuid("{00000000-0000-4000-8000-000000000000}");
assert!(!target.has_validated_ranges());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn load_state_loads_a_clean_cache_and_skips_unchanged_files() {
let dir = work_dir("load-skip");
// One validated range persisted as `<dir>/<uuid>/state` by a
// separate producer instance.
let mut source = tb_cache();
source.set_saving_enabled(true);
source.set_disk_dir(&dir.to_string_lossy());
let loaded = TimeRange::new(Rational::new(3, 1), Rational::new(9, 1));
source.validate(loaded);
source.save_state(&dir).unwrap();
// The consumer starts with genuinely empty in-memory ranges (and a
// zero `last_loaded_state`): the state has to come from the file.
// `validate` is deliberately not called on this instance — that is
// what made the old construction isomorphic. A no-op load or an
// inverted mtime guard now leaves the assertions below failing.
let mut target = PlaybackCache::new(CacheKind::VideoFrame, 2);
target.set_saving_enabled(false);
target.set_disk_dir(&dir.to_string_lossy());
target.uuid = source.uuid.clone();
assert!(
target.validated_ranges().is_empty(),
"the consumer starts with no ranges"
);
target.load_state(&dir).unwrap();
assert_eq!(
target.validated_ranges().ranges(),
&[loaded],
"the state file loads into empty memory"
);
assert_ne!(
target.last_loaded_state, 0,
"the load records the state file's mtime"
);
// Drop the memory only, then load the unchanged file again: the
// mtime guard must skip it, so the range stays gone. A guard that
// was removed would resurrect the range here (and an inverted one
// would already have failed the load above).
target.validated = TimeRangeList::new();
target.load_state(&dir).unwrap();
assert!(
target.validated_ranges().is_empty(),
"an unchanged state file is not reloaded"
);
// Force the reload path (a rewrite within the same millisecond
// would make the mtime comparison flaky): the now-larger file is
// re-read in full.
source.validate(TimeRange::new(Rational::new(10, 1), Rational::new(11, 1)));
source.save_state(&dir).unwrap();
target.last_loaded_state = 0;
target.load_state(&dir).unwrap();
assert_eq!(
target.validated_ranges().ranges(),
source.validated_ranges().ranges(),
"a forced load re-reads the whole state"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn save_state_reports_directory_creation_errors() {
let dir = work_dir("save-error");
let blocker = dir.join("not-a-dir");
std::fs::write(&blocker, b"file").unwrap();
let mut c = tb_cache();
c.set_saving_enabled(false);
c.set_disk_dir(&blocker.to_string_lossy());
c.validate(TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)));
let err = c.save_state(&blocker).unwrap_err();
assert!(format!("{err}").contains("create cache dir"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn passthrough_snapshot_links_ranges_without_aliasing() {
let mut source = PlaybackCache::new(CacheKind::VideoFrame, 3);
source.set_saving_enabled(false);
source.set_timebase(Rational::new(1, 24));
source.validate(TimeRange::new(Rational::new(2, 1), Rational::new(3, 1)));
let snapshot = PassthroughSnapshot {
validated: source.validated.clone(),
passthroughs: vec![(
TimeRange::new(Rational::new(8, 1), Rational::new(9, 1)),
source.uuid.clone(),
)],
timebase: source.timebase,
uuid: source.uuid.clone(),
};
let mut target = PlaybackCache::new(CacheKind::VideoFrame, 4);
target.set_saving_enabled(false);
target.set_passthrough_snapshot(snapshot.clone());
assert_eq!(target.timebase(), Rational::new(1, 24));
assert_eq!(
target.passthroughs().len(),
2,
"validated source range plus the explicit entry"
);
// Passthroughs cover exactly the linked ranges; the gap between them
// is still reported as invalidated.
let inv = target.invalidated_ranges(TimeRange::new(Rational::new(2, 1), Rational::new(9, 1)));
assert_eq!(
inv.ranges(),
&[TimeRange::new(Rational::new(3, 1), Rational::new(8, 1))]
);
// Audio caches keep their own timebase (frame-hash-only adoption).
let mut audio = PlaybackCache::new(CacheKind::AudioPlayback, 5);
audio.set_saving_enabled(false);
audio.set_passthrough_snapshot(snapshot);
assert_eq!(audio.timebase(), Rational::NULL);
}
#[test]
fn passthrough_with_saving_enabled_persists_the_state() {
let dir = work_dir("passthrough-save");
let mut source = PlaybackCache::new(CacheKind::VideoFrame, 6);
source.set_saving_enabled(false);
source.validate(TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)));
let mut target = PlaybackCache::new(CacheKind::VideoFrame, 7);
target.set_disk_dir(&dir.to_string_lossy());
target.set_passthrough(&source);
let state = dir.join(&target.uuid).join("state");
assert!(state.exists(), "set_passthrough persists when saving is on");
// Snapshot variant takes the same saving path.
let snapshot = PassthroughSnapshot {
validated: source.validated.clone(),
passthroughs: Vec::new(),
timebase: None,
uuid: source.uuid.clone(),
};
target.set_passthrough_snapshot(snapshot);
assert!(state.exists());
assert_eq!(target.passthroughs().len(), 2);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn frame_paths_use_the_timebase_or_whole_seconds() {
// Instance path with a timebase: 15s at 1/30 → frame 450.
let mut with_tb = tb_cache();
with_tb.validate(TimeRange::new(Rational::new(0, 1), Rational::new(16, 1)));
let name = with_tb.frame_filename(Rational::new(15, 1)).expect("cached");
assert!(name.ends_with("/450"), "{name}");
let path = PlaybackCache::frame_cache_path(
"/cache",
"id",
Rational::new(15, 1),
Rational::new(1, 30),
);
assert_eq!(
path,
std::path::Path::new("/cache")
.join("id")
.join("450")
.to_string_lossy()
);
assert_eq!(
PlaybackCache::frame_cache_path(
"/cache",
"id",
Rational::new(1, 10),
Rational::new(1, 1)
),
std::path::Path::new("/cache")
.join("id")
.join("0")
.to_string_lossy()
);
// No timebase: whole seconds (round-half-away-from-zero).
let mut c = PlaybackCache::new(CacheKind::VideoFrame, 8);
c.set_saving_enabled(false);
c.validate(TimeRange::new(Rational::new(0, 1), Rational::new(4, 1)));
let name = c.frame_filename(Rational::new(1, 2)).expect("cached");
assert!(name.ends_with("/1"), "{name}");
assert!(name.contains(c.uuid()), "{name}");
}
#[test]
fn next_owner_identity_is_monotonic() {
let a = next_owner_identity();
let b = next_owner_identity();
assert!(b > a);
}
}
File diff suppressed because it is too large Load Diff
+20 -10
View File
@@ -953,14 +953,21 @@ mod tests {
path
}
/// Pin the working space to the legacy sRGB pass-through: these tests
/// assert the decoded pattern, not the color transform (the ACEScg
/// default would remap the values).
fn pin_legacy_working_space() {
/// Pin the working space to the legacy sRGB pass-through and hold the
/// crate-wide working-space test lock for the entire decoded-pattern
/// section: these tests assert the decoded pattern, not the color
/// transform (the ACEScg default would remap the values), while the
/// eval tests temporarily switch the same process-global settings. The
/// caller must keep the returned guard alive.
fn pin_legacy_working_space() -> std::sync::MutexGuard<'static, ()> {
let guard = crate::eval::working_space_test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
oak_core::color::set_pipeline_color_settings(
oak_core::colormath::WorkingColorSpace::SrgbLegacy,
oak_core::colormath::OutputColorSpec::default(),
);
guard
}
fn request(filename: &std::path::Path, time: Rational) -> DecodeRequest {
@@ -970,6 +977,7 @@ mod tests {
time,
size: (64, 64),
format: PixelFormat::F32,
allow_import: true,
}
}
@@ -1020,7 +1028,7 @@ mod tests {
/// The service decodes real media through the real codec path.
#[test]
fn request_decodes_real_media() {
pin_legacy_working_space();
let _guard = pin_legacy_working_space();
let path = test_clip("real");
let service = DecodeService::new(DECODE_LRU_CAP, always());
@@ -1045,7 +1053,7 @@ mod tests {
/// follows is served from the cache with no decode at all.
#[test]
fn prefetch_then_request_hits_the_lru() {
pin_legacy_working_space();
let _guard = pin_legacy_working_space();
let path = test_clip("prefetch");
let service = DecodeService::new(DECODE_LRU_CAP, always());
@@ -1084,6 +1092,7 @@ mod tests {
time: Rational::new(0, 1),
size: (64, 64),
format: PixelFormat::F32,
allow_import: true,
};
let err = service
.request(req)
@@ -1101,7 +1110,7 @@ mod tests {
/// frame must be decoded again).
#[test]
fn lru_evicts_bounded() {
pin_legacy_working_space();
let _guard = pin_legacy_working_space();
let path = test_clip("evict");
let service = DecodeService::new(2, always());
let time = |n: i64| request(&path, Rational::new(n, 10));
@@ -1135,7 +1144,7 @@ mod tests {
/// (and counts) without queueing anything.
#[test]
fn prefetch_gate_refuses_and_recovers() {
pin_legacy_working_space();
let _guard = pin_legacy_working_space();
let path = test_clip("gate");
let open = Arc::new(AtomicBool::new(false));
let gate_open = open.clone();
@@ -1166,7 +1175,7 @@ mod tests {
/// prefetch sent before it has been decoded when it returns.
#[test]
fn wait_idle_barrier_covers_queued_commands() {
pin_legacy_working_space();
let _guard = pin_legacy_working_space();
let path = test_clip("barrier");
// The barrier test needs four live entries; the production
// hand-off capacity is deliberately tiny (see DECODE_LRU_CAP), so
@@ -1188,7 +1197,7 @@ mod tests {
/// eval path falls back to decoding inline instead of failing frames.
#[test]
fn shutdown_makes_the_service_unavailable() {
pin_legacy_working_space();
let _guard = pin_legacy_working_space();
let path = test_clip("shutdown");
let service = DecodeService::new(DECODE_LRU_CAP, always());
service.shutdown();
@@ -1256,6 +1265,7 @@ mod tests {
time,
size: (16, 16),
format: PixelFormat::F32,
allow_import: true,
};
{
let mut queue = lock(&shared.queue);
File diff suppressed because it is too large Load Diff
+39
View File
@@ -58,6 +58,45 @@ impl Drop for ManagerGuard {
}
}
/// A GPU context suitable for the M5 zero-copy hardware import (Vulkan on
/// Linux/Windows, Metal on macOS), or `None` when no such adapter exists
/// (CI's software Vulkan still qualifies — the *decoder* side decides
/// whether there is an importable hardware surface).
///
/// Missing adapters follow the repo-wide `OAK_REQUIRE_GPU` policy used by
/// `backend::gpu_or_skip`/`shared_gpu_or_skip`: on a job that promises a
/// GPU (the CI runner has lavapipe) a missing adapter panics instead of
/// silently dropping the import signal; elsewhere the skip prints a
/// distinctive `SKIP:` marker so CI logs distinguish skipped from executed
/// tests.
pub fn gpu_context_for_import() -> Option<std::sync::Arc<oak_core::backend::GpuContext>> {
let created = oak_core::backend::GpuContext::create(oak_core::backend::BackendKind::Auto);
let Some(ctx) = created else {
skip_import_gpu("no GPU adapter");
return None;
};
if matches!(
ctx.kind(),
oak_core::backend::BackendKind::Vulkan | oak_core::backend::BackendKind::Metal
) {
return Some(ctx);
}
skip_import_gpu("the adapter is not Vulkan/Metal");
None
}
/// Report (and under `OAK_REQUIRE_GPU`, fail) a missing import-capable
/// adapter. The single `SKIP:` line keeps the skip observable in CI logs.
fn skip_import_gpu(reason: &str) {
if oak_core::backend::require_gpu_adapter() {
panic!(
"no importable GPU context for the M5 hardware import ({reason}); \
OAK_REQUIRE_GPU is set"
);
}
eprintln!("SKIP: footage hardware import: {reason}");
}
// ---------------------------------------------------------------------------
// Host-symbol stand-ins (oakcore_* / fb_find_best_pix_fmt_of_list)
// ---------------------------------------------------------------------------
@@ -0,0 +1,394 @@
// 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/>.
//! M5 acceptance: the zero-copy hardware-decode import.
//!
//! With a shared GPU context installed (the app's render device), a
//! hardware-decoded frame is imported as planar GPU textures and resolved
//! to working-space RGBA on the GPU — `HW_TRANSFERS` (the CPU download
//! counter) must not move. Turning the import switch off must give the
//! same pixels through the CPU staging path (within decoder/swscale
//! rounding; the threshold is documented at the comparison).
//!
//! The assertions need an importable *hardware decoder* (VAAPI/NVDEC/
//! D3D11VA/VideoToolbox), not just a GPU context: the lavapipe CI runner
//! has software Vulkan but no `/dev/dri`, so the decoder never produces
//! an importable surface and every test here logs a `SKIP:` line and
//! returns. The staging fallback is covered by the existing decode tests;
//! the real-hardware acceptance run is recorded in
//! `docs/zh/plans/render-pipeline-threads-m5-branch-coverage.txt` (M5
//! platform rows) and has to be repeated on a VAAPI/D3D11VA/VideoToolbox
//! machine.
use std::sync::Mutex;
use oak_core::texture::Texture;
use oak_core::{PixelFormat, Rational};
mod common;
/// Tests in this binary share the process-wide eval frame cache, the
/// import switch and the shared GPU context slot; serialize them.
static SERIAL: Mutex<()> = Mutex::new(());
/// Forces the CPU staging decode (`OAK_GPU_IMPORT=0`) for the reference
/// frame, and restores the previous value on drop: the variable is
/// process-wide, so a mid-test panic (`expect("staging decode")`) must not
/// leak `"0"` into later tests, and an operator-preset value must survive
/// the test. The tests serialize on `SERIAL`, so the override is
/// race-free here (mirrors `SoftwareDecodeGuard` in
/// `render_threads_test.rs`).
struct StagingDecodeGuard {
prev: Option<String>,
}
impl StagingDecodeGuard {
fn set() -> Self {
let prev = std::env::var("OAK_GPU_IMPORT").ok();
std::env::set_var("OAK_GPU_IMPORT", "0");
Self { prev }
}
}
impl Drop for StagingDecodeGuard {
fn drop(&mut self) {
match &self.prev {
Some(p) => std::env::set_var("OAK_GPU_IMPORT", p),
None => std::env::remove_var("OAK_GPU_IMPORT"),
}
}
}
fn clip_path(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"oakrender_import_{tag}_{}.mp4",
std::process::id()
))
}
fn write_clip(tag: &str) -> std::path::PathBuf {
let path = clip_path(tag);
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip generation");
path
}
fn pin_legacy_working_space() {
oak_core::color::set_pipeline_color_settings(
oak_core::colormath::WorkingColorSpace::SrgbLegacy,
oak_core::colormath::OutputColorSpec::default(),
);
}
/// Sample the decoded F32 frame at a pixel.
fn sample(frame: &oak_core::texture::Frame, x: usize, y: usize) -> [f32; 4] {
assert_eq!(
frame.format,
PixelFormat::F32,
"sample() interprets the frame bytes as f32"
);
let stride = frame.linesize_bytes();
let off = y * stride + x * 16;
let mut out = [0f32; 4];
for (i, channel) in out.iter_mut().enumerate() {
*channel =
f32::from_le_bytes(frame.data[off + i * 4..off + i * 4 + 4].try_into().unwrap());
}
out
}
#[test]
fn hardware_import_is_zero_copy_and_matches_staging() {
let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
pin_legacy_working_space();
// The import needs the render device the app installs; without a GPU
// there is nothing to test (the staging path is the fallback).
let Some(ctx) = common::gpu_context_for_import() else {
// The helper logged `SKIP:` (or panicked under OAK_REQUIRE_GPU).
return;
};
oak_core::backend::GpuContext::install_shared(Some(ctx.clone()));
oak_codec::gpuinterop::reset_import_counters();
let transfers_before = oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed);
let path = write_clip("zero");
let imported = oak_render::eval::render_footage_frame(
&path.to_string_lossy(),
0,
Rational::new(0, 1),
(0, 0), // native size: the import cannot resize
PixelFormat::F32,
)
.expect("decode frame 0");
if oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed) == 0 {
eprintln!(
"SKIP: hardware import unavailable; imports={} fallbacks={} transfers={}",
oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed),
oak_codec::gpuinterop::HW_IMPORT_FALLBACKS.load(std::sync::atomic::Ordering::Relaxed),
oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed),
);
let _ = std::fs::remove_file(&path);
return;
}
eprintln!(
"import took the frame: imports={} transfers={} (before {transfers_before})",
oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed),
oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed),
);
// The import took the frame: the result is GPU-resident and no CPU
// download happened.
assert!(
matches!(imported, Texture::Gpu { .. }),
"imported decode must resolve to a GPU texture, got {imported:?}"
);
assert_eq!(
oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed),
transfers_before,
"the zero-copy path must not call av_hwframe_transfer_data"
);
let imported_frame = imported.to_frame().expect("download resolved frame");
assert_eq!((imported_frame.width, imported_frame.height), (64, 64));
// Staging reference: the same media copied to a second path (a
// distinct eval cache key) decoded with the import switch off.
let staging_path = clip_path("staging");
std::fs::copy(&path, &staging_path).expect("copy clip");
let staging_guard = StagingDecodeGuard::set();
let staging = oak_render::eval::render_footage_frame(
&staging_path.to_string_lossy(),
0,
Rational::new(0, 1),
(0, 0),
PixelFormat::F32,
)
.expect("staging decode");
drop(staging_guard);
let Texture::Cpu(staging_frame) = &staging else {
panic!("staging decode must stay on the CPU: {staging:?}");
};
assert_eq!((staging_frame.width, staging_frame.height), (64, 64));
// Same content (the GPU pass uses the frame's own matrix/range and
// bilinear chroma sampling; the CPU path uses swscale's chroma
// filtering, so the two differ slightly). 0.08 is the real
// hardware-vs-software decode precedent
// (`oak-codec/src/realmedia_tests.rs::hardware_decode_matches_software_decode`);
// the M5 reference hardware (RTX 5070 Ti + nvidia-vaapi-driver)
// measures 0.009 here, so the threshold has an order of magnitude of
// headroom.
let mut max_diff = 0.0f32;
for y in 0..64 {
for x in 0..64 {
let a = sample(&imported_frame, x, y);
let b = sample(staging_frame, x, y);
for c in 0..3 {
max_diff = max_diff.max((a[c] - b[c]).abs());
}
}
}
eprintln!("sRGB import vs staging: max diff {max_diff}");
assert!(
max_diff < 0.08,
"import and staging decodes diverge (max channel diff {max_diff})"
);
// The known test pattern survives the GPU path: left half red, right
// half blue on frame 0.
let [r, g, b, a] = sample(&imported_frame, 8, 32);
assert!(r > 0.5 && g < 0.4 && b < 0.4, "left half red: {r},{g},{b}");
assert!(a > 0.9, "opaque: {a}");
let [r, g, b, _] = sample(&imported_frame, 56, 32);
assert!(b > 0.5 && r < 0.4 && g < 0.4, "right half blue: {r},{g},{b}");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&staging_path);
}
#[test]
fn hardware_import_applies_the_source_to_working_lut() {
let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
// ACEScg working space: the import path must run the source→working
// transform on the GPU (the baked 3D LUT), matching the CPU path's
// exact per-pixel `decode_to_acescg`.
oak_core::color::set_pipeline_color_settings(
oak_core::colormath::WorkingColorSpace::AcesCg,
oak_core::colormath::OutputColorSpec::default(),
);
let Some(ctx) = common::gpu_context_for_import() else {
// The helper logged `SKIP:` (or panicked under OAK_REQUIRE_GPU).
return;
};
oak_core::backend::GpuContext::install_shared(Some(ctx.clone()));
oak_codec::gpuinterop::reset_import_counters();
let path = write_clip("aces");
let imported = oak_render::eval::render_footage_frame(
&path.to_string_lossy(),
0,
Rational::new(0, 1),
(0, 0),
PixelFormat::F32,
)
.expect("decode frame 0");
if oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed) == 0 {
eprintln!("SKIP: hardware import unavailable; skipping assertions");
let _ = std::fs::remove_file(&path);
return;
}
let imported_frame = imported.to_frame().expect("download resolved frame");
let staging_path = clip_path("aces_staging");
std::fs::copy(&path, &staging_path).expect("copy clip");
let staging_guard = StagingDecodeGuard::set();
let staging = oak_render::eval::render_footage_frame(
&staging_path.to_string_lossy(),
0,
Rational::new(0, 1),
(0, 0),
PixelFormat::F32,
)
.expect("staging decode");
drop(staging_guard);
let Texture::Cpu(staging_frame) = &staging else {
panic!("staging decode must stay on the CPU: {staging:?}");
};
// The GPU applies the LUT (interpolated); the CPU runs the exact
// per-pixel transform, so a small interpolation difference is
// expected — far below a visible grade mismatch.
let mut max_diff = 0.0f32;
for y in 0..64 {
for x in 0..64 {
let a = sample(&imported_frame, x, y);
let b = sample(staging_frame, x, y);
for c in 0..3 {
max_diff = max_diff.max((a[c] - b[c]).abs());
}
}
}
eprintln!("ACEScg import vs staging: max diff {max_diff}");
assert!(
max_diff < 0.05,
"working-space LUT diverges from the CPU transform: {max_diff}"
);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&staging_path);
}
#[test]
fn montage_native_size_with_host_gpu_composites_the_clip() {
let _guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
pin_legacy_working_space();
// This is the M5 audit regression: a sequence montage at the clip's
// native size with a host GPU installed used to import the clip and
// then silently skip it in the CPU compositor, producing an
// all-transparent black sequence. The montage path must stage on
// purpose and composite the clip.
let Some(ctx) = common::gpu_context_for_import() else {
// The helper logged `SKIP:` (or panicked under OAK_REQUIRE_GPU).
return;
};
oak_core::backend::GpuContext::install_shared(Some(ctx));
oak_codec::gpuinterop::reset_import_counters();
let path = write_clip("montage_native");
// First import the frame at native size through the normal
// single-footage path: the planar texture is now cached under the
// (64, 64) key that the montage request will use.
let single = oak_render::eval::render_footage_frame(
&path.to_string_lossy(),
0,
Rational::new(0, 1),
(64, 64),
PixelFormat::F32,
)
.expect("single-footage decode");
if oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed) == 0 {
eprintln!("SKIP: hardware import unavailable on this machine; skipping montage assertions");
let _ = std::fs::remove_file(&path);
return;
}
assert!(
matches!(single, Texture::Gpu { .. }),
"the pre-step must produce the imported GPU texture"
);
let imports_after_single =
oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed);
let transfers_after_single =
oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed);
let params = oak_render::ticket::VideoTicketParams {
viewer: 1,
project: String::new(),
time: Rational::new(0, 1),
force_size: Some((64, 64)), // native: the import-triggering shape
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
footage: None,
montage: vec![oak_render::ticket::MontageClip {
filename: path.to_string_lossy().into_owned(),
stream_index: 0,
in_time: Rational::new(0, 1),
out_time: Rational::new(10, 1),
media_in: Rational::new(0, 1),
gain: 1.0,
effects: Vec::new(),
}],
adjustments: Vec::new(),
};
let texture = oak_render::eval::render_produced_frame(Rational::new(0, 1), &params)
.expect("montage render");
let frame = texture.to_frame().expect("montage frame");
assert_eq!((frame.width, frame.height), (64, 64));
assert!(
!frame.data.iter().all(|&b| b == 0),
"montage must not be transparent black"
);
// Known pattern: left half red, right half blue.
let [r, g, b, a] = sample(&frame, 8, 32);
assert!(r > 0.5 && g < 0.4 && b < 0.4, "left half red: {r},{g},{b}");
assert!(a > 0.9, "opaque: {a}");
let [r, g, b, _] = sample(&frame, 56, 32);
assert!(b > 0.5 && r < 0.4 && g < 0.4, "right half blue: {r},{g},{b}");
assert_eq!(
oak_codec::gpuinterop::HW_IMPORTS.load(std::sync::atomic::Ordering::Relaxed),
imports_after_single,
"the CPU montage compositor must stage on purpose (no new imports)"
);
// The staged request must not have been served by the cached planar
// texture: it re-decoded through the CPU scaler (a hardware frame
// transfer happened for the staging path).
assert!(
oak_codec::hwdecode::HW_TRANSFERS.load(std::sync::atomic::Ordering::Relaxed)
> transfers_after_single,
"the staged montage decode must produce CPU pixels"
);
let _ = std::fs::remove_file(&path);
}
@@ -98,6 +98,32 @@ fn pin_legacy_working_space() {
);
}
/// Force software decoding for the inline-vs-pipeline byte-exact
/// comparisons: hardware decoders (NVDEC/VAAPI) may differ from the
/// software decoder by a few LSBs, which is a decode-path property, not a
/// pipeline bug. Every test in this binary takes `lock()`, so the
/// process-wide env override is race-free here.
struct SoftwareDecodeGuard {
prev: Option<String>,
}
impl SoftwareDecodeGuard {
fn set() -> Self {
let prev = std::env::var("OAK_HWACCEL").ok();
std::env::set_var("OAK_HWACCEL", "0");
Self { prev }
}
}
impl Drop for SoftwareDecodeGuard {
fn drop(&mut self) {
match &self.prev {
Some(p) => std::env::set_var("OAK_HWACCEL", p),
None => std::env::remove_var("OAK_HWACCEL"),
}
}
}
fn base_params(time: Rational) -> VideoTicketParams {
VideoTicketParams {
viewer: 0,
@@ -638,6 +664,7 @@ fn oak_pipeline_env_selects_the_thread_backend() {
#[test]
fn pipeline_matches_inline_pixels_across_consecutive_frames() {
let _lock = lock();
let _software = SoftwareDecodeGuard::set();
pin_legacy_working_space();
let inline_path = test_clip("consecutive_inline");
let pipeline_path = test_clip_copy(&inline_path, "consecutive_pipeline");
@@ -674,6 +701,7 @@ fn pipeline_matches_inline_pixels_across_consecutive_frames() {
#[test]
fn pipeline_seek_out_of_order_matches_inline() {
let _lock = lock();
let _software = SoftwareDecodeGuard::set();
pin_legacy_working_space();
let inline_path = test_clip("seek_inline");
let pipeline_path = test_clip_copy(&inline_path, "seek_pipeline");
@@ -705,6 +733,7 @@ fn pipeline_seek_out_of_order_matches_inline() {
#[test]
fn pipeline_viewer_ticket_matches_inline_pixels() {
let _lock = lock();
let _software = SoftwareDecodeGuard::set();
let path = test_clip("viewer");
let filename = path.to_string_lossy().to_string();
let clip = (filename.as_str(), Rational::new(0, 1), Rational::new(1, 1));
@@ -1280,6 +1309,7 @@ fn pipeline_queue_backpressure_closes_the_prefetch_gate() {
time: Rational::new(0, 1),
size: (64, 64),
format: PixelFormat::F32,
allow_import: true,
});
assert!(!refused, "a saturated pipeline refuses prefetch");
let decode = service.stats();