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:
@@ -880,4 +880,314 @@ mod tests {
|
|||||||
assert!(!dir.join(&c.uuid).join("state").exists());
|
assert!(!dir.join(&c.uuid).join("state").exists());
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2436
-1
File diff suppressed because it is too large
Load Diff
@@ -953,14 +953,21 @@ mod tests {
|
|||||||
path
|
path
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pin the working space to the legacy sRGB pass-through: these tests
|
/// Pin the working space to the legacy sRGB pass-through and hold the
|
||||||
/// assert the decoded pattern, not the color transform (the ACEScg
|
/// crate-wide working-space test lock for the entire decoded-pattern
|
||||||
/// default would remap the values).
|
/// section: these tests assert the decoded pattern, not the color
|
||||||
fn pin_legacy_working_space() {
|
/// 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::color::set_pipeline_color_settings(
|
||||||
oak_core::colormath::WorkingColorSpace::SrgbLegacy,
|
oak_core::colormath::WorkingColorSpace::SrgbLegacy,
|
||||||
oak_core::colormath::OutputColorSpec::default(),
|
oak_core::colormath::OutputColorSpec::default(),
|
||||||
);
|
);
|
||||||
|
guard
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request(filename: &std::path::Path, time: Rational) -> DecodeRequest {
|
fn request(filename: &std::path::Path, time: Rational) -> DecodeRequest {
|
||||||
@@ -970,6 +977,7 @@ mod tests {
|
|||||||
time,
|
time,
|
||||||
size: (64, 64),
|
size: (64, 64),
|
||||||
format: PixelFormat::F32,
|
format: PixelFormat::F32,
|
||||||
|
allow_import: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1020,7 +1028,7 @@ mod tests {
|
|||||||
/// The service decodes real media through the real codec path.
|
/// The service decodes real media through the real codec path.
|
||||||
#[test]
|
#[test]
|
||||||
fn request_decodes_real_media() {
|
fn request_decodes_real_media() {
|
||||||
pin_legacy_working_space();
|
let _guard = pin_legacy_working_space();
|
||||||
let path = test_clip("real");
|
let path = test_clip("real");
|
||||||
let service = DecodeService::new(DECODE_LRU_CAP, always());
|
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.
|
/// follows is served from the cache with no decode at all.
|
||||||
#[test]
|
#[test]
|
||||||
fn prefetch_then_request_hits_the_lru() {
|
fn prefetch_then_request_hits_the_lru() {
|
||||||
pin_legacy_working_space();
|
let _guard = pin_legacy_working_space();
|
||||||
let path = test_clip("prefetch");
|
let path = test_clip("prefetch");
|
||||||
let service = DecodeService::new(DECODE_LRU_CAP, always());
|
let service = DecodeService::new(DECODE_LRU_CAP, always());
|
||||||
|
|
||||||
@@ -1084,6 +1092,7 @@ mod tests {
|
|||||||
time: Rational::new(0, 1),
|
time: Rational::new(0, 1),
|
||||||
size: (64, 64),
|
size: (64, 64),
|
||||||
format: PixelFormat::F32,
|
format: PixelFormat::F32,
|
||||||
|
allow_import: true,
|
||||||
};
|
};
|
||||||
let err = service
|
let err = service
|
||||||
.request(req)
|
.request(req)
|
||||||
@@ -1101,7 +1110,7 @@ mod tests {
|
|||||||
/// frame must be decoded again).
|
/// frame must be decoded again).
|
||||||
#[test]
|
#[test]
|
||||||
fn lru_evicts_bounded() {
|
fn lru_evicts_bounded() {
|
||||||
pin_legacy_working_space();
|
let _guard = pin_legacy_working_space();
|
||||||
let path = test_clip("evict");
|
let path = test_clip("evict");
|
||||||
let service = DecodeService::new(2, always());
|
let service = DecodeService::new(2, always());
|
||||||
let time = |n: i64| request(&path, Rational::new(n, 10));
|
let time = |n: i64| request(&path, Rational::new(n, 10));
|
||||||
@@ -1135,7 +1144,7 @@ mod tests {
|
|||||||
/// (and counts) without queueing anything.
|
/// (and counts) without queueing anything.
|
||||||
#[test]
|
#[test]
|
||||||
fn prefetch_gate_refuses_and_recovers() {
|
fn prefetch_gate_refuses_and_recovers() {
|
||||||
pin_legacy_working_space();
|
let _guard = pin_legacy_working_space();
|
||||||
let path = test_clip("gate");
|
let path = test_clip("gate");
|
||||||
let open = Arc::new(AtomicBool::new(false));
|
let open = Arc::new(AtomicBool::new(false));
|
||||||
let gate_open = open.clone();
|
let gate_open = open.clone();
|
||||||
@@ -1166,7 +1175,7 @@ mod tests {
|
|||||||
/// prefetch sent before it has been decoded when it returns.
|
/// prefetch sent before it has been decoded when it returns.
|
||||||
#[test]
|
#[test]
|
||||||
fn wait_idle_barrier_covers_queued_commands() {
|
fn wait_idle_barrier_covers_queued_commands() {
|
||||||
pin_legacy_working_space();
|
let _guard = pin_legacy_working_space();
|
||||||
let path = test_clip("barrier");
|
let path = test_clip("barrier");
|
||||||
// The barrier test needs four live entries; the production
|
// The barrier test needs four live entries; the production
|
||||||
// hand-off capacity is deliberately tiny (see DECODE_LRU_CAP), so
|
// 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.
|
/// eval path falls back to decoding inline instead of failing frames.
|
||||||
#[test]
|
#[test]
|
||||||
fn shutdown_makes_the_service_unavailable() {
|
fn shutdown_makes_the_service_unavailable() {
|
||||||
pin_legacy_working_space();
|
let _guard = pin_legacy_working_space();
|
||||||
let path = test_clip("shutdown");
|
let path = test_clip("shutdown");
|
||||||
let service = DecodeService::new(DECODE_LRU_CAP, always());
|
let service = DecodeService::new(DECODE_LRU_CAP, always());
|
||||||
service.shutdown();
|
service.shutdown();
|
||||||
@@ -1256,6 +1265,7 @@ mod tests {
|
|||||||
time,
|
time,
|
||||||
size: (16, 16),
|
size: (16, 16),
|
||||||
format: PixelFormat::F32,
|
format: PixelFormat::F32,
|
||||||
|
allow_import: true,
|
||||||
};
|
};
|
||||||
{
|
{
|
||||||
let mut queue = lock(&shared.queue);
|
let mut queue = lock(&shared.queue);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
// 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), ¶ms)
|
||||||
|
.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 {
|
fn base_params(time: Rational) -> VideoTicketParams {
|
||||||
VideoTicketParams {
|
VideoTicketParams {
|
||||||
viewer: 0,
|
viewer: 0,
|
||||||
@@ -638,6 +664,7 @@ fn oak_pipeline_env_selects_the_thread_backend() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pipeline_matches_inline_pixels_across_consecutive_frames() {
|
fn pipeline_matches_inline_pixels_across_consecutive_frames() {
|
||||||
let _lock = lock();
|
let _lock = lock();
|
||||||
|
let _software = SoftwareDecodeGuard::set();
|
||||||
pin_legacy_working_space();
|
pin_legacy_working_space();
|
||||||
let inline_path = test_clip("consecutive_inline");
|
let inline_path = test_clip("consecutive_inline");
|
||||||
let pipeline_path = test_clip_copy(&inline_path, "consecutive_pipeline");
|
let pipeline_path = test_clip_copy(&inline_path, "consecutive_pipeline");
|
||||||
@@ -674,6 +701,7 @@ fn pipeline_matches_inline_pixels_across_consecutive_frames() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pipeline_seek_out_of_order_matches_inline() {
|
fn pipeline_seek_out_of_order_matches_inline() {
|
||||||
let _lock = lock();
|
let _lock = lock();
|
||||||
|
let _software = SoftwareDecodeGuard::set();
|
||||||
pin_legacy_working_space();
|
pin_legacy_working_space();
|
||||||
let inline_path = test_clip("seek_inline");
|
let inline_path = test_clip("seek_inline");
|
||||||
let pipeline_path = test_clip_copy(&inline_path, "seek_pipeline");
|
let pipeline_path = test_clip_copy(&inline_path, "seek_pipeline");
|
||||||
@@ -705,6 +733,7 @@ fn pipeline_seek_out_of_order_matches_inline() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pipeline_viewer_ticket_matches_inline_pixels() {
|
fn pipeline_viewer_ticket_matches_inline_pixels() {
|
||||||
let _lock = lock();
|
let _lock = lock();
|
||||||
|
let _software = SoftwareDecodeGuard::set();
|
||||||
let path = test_clip("viewer");
|
let path = test_clip("viewer");
|
||||||
let filename = path.to_string_lossy().to_string();
|
let filename = path.to_string_lossy().to_string();
|
||||||
let clip = (filename.as_str(), Rational::new(0, 1), Rational::new(1, 1));
|
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),
|
time: Rational::new(0, 1),
|
||||||
size: (64, 64),
|
size: (64, 64),
|
||||||
format: PixelFormat::F32,
|
format: PixelFormat::F32,
|
||||||
|
allow_import: true,
|
||||||
});
|
});
|
||||||
assert!(!refused, "a saturated pipeline refuses prefetch");
|
assert!(!refused, "a saturated pipeline refuses prefetch");
|
||||||
let decode = service.stats();
|
let decode = service.stats();
|
||||||
|
|||||||
@@ -480,9 +480,145 @@ pub fn ofx_host_main(args: &[String]) -> i32 {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use oak_core::backend::{BackendKind, GpuContextLike};
|
||||||
|
use oak_core::error::{Error as CoreError, Result as CoreResult};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::sync::atomic::AtomicU32;
|
||||||
|
|
||||||
|
/// The process-global plugin/progress factories are shared with the
|
||||||
|
/// `worker.rs` tests, so both modules serialize on this one lock.
|
||||||
|
fn global_factory_lock() -> MutexGuard<'static, ()> {
|
||||||
|
crate::worker::GLOBAL_FACTORY_TEST_LOCK
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A unique shm key + region holding an initialized frame slot pool.
|
||||||
|
fn test_region(name: &str, slots: u32, slot_bytes: usize) -> (String, SharedMemoryRegion) {
|
||||||
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||||
|
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let key = SharedMemoryRegion::make_key(i64::from(std::process::id()), (n & 0x7FFF) as i32)
|
||||||
|
+ &format!("-ofx-{name}");
|
||||||
|
let bytes = FrameSlotPool::bytes_needed(slots, slot_bytes);
|
||||||
|
let mut region = SharedMemoryRegion::new();
|
||||||
|
assert!(
|
||||||
|
region.open(&key, bytes, ShmMode::Create),
|
||||||
|
"{}",
|
||||||
|
region.error()
|
||||||
|
);
|
||||||
|
// SAFETY: live mapping sized by bytes_needed; the pool view is
|
||||||
|
// discarded — peers re-attach through the region.
|
||||||
|
let _ = unsafe { FrameSlotPool::create(region.data(), slots, slot_bytes) };
|
||||||
|
(key, region)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A handshake for `attach_pools`.
|
||||||
|
fn handshake_json(
|
||||||
|
out_key: &str,
|
||||||
|
out_slots: i32,
|
||||||
|
out_bytes: i64,
|
||||||
|
in_key: &str,
|
||||||
|
in_slots: i32,
|
||||||
|
in_bytes: i64,
|
||||||
|
) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": TYPE_HANDSHAKE,
|
||||||
|
"shm_key": out_key,
|
||||||
|
"output_slots": out_slots,
|
||||||
|
"slot_data_bytes": out_bytes,
|
||||||
|
"input_shm_key": in_key,
|
||||||
|
"input_slots": in_slots,
|
||||||
|
"input_slot_data_bytes": in_bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach a real output pool (and input pool when `in_slots > 0`),
|
||||||
|
/// returning the pools plus the owner regions (kept alive by the caller).
|
||||||
|
fn host_pools(
|
||||||
|
out_slots: u32,
|
||||||
|
out_bytes: usize,
|
||||||
|
in_slots: u32,
|
||||||
|
in_bytes: usize,
|
||||||
|
) -> (HostPools, SharedMemoryRegion, SharedMemoryRegion) {
|
||||||
|
let (out_key, out_region) = test_region("out", out_slots, out_bytes);
|
||||||
|
let (in_key, in_region) = test_region("in", in_slots, in_bytes);
|
||||||
|
let hs = handshake_json(
|
||||||
|
&out_key,
|
||||||
|
out_slots as i32,
|
||||||
|
out_bytes as i64,
|
||||||
|
&in_key,
|
||||||
|
in_slots as i32,
|
||||||
|
in_bytes as i64,
|
||||||
|
);
|
||||||
|
let pools = attach_pools(&hs).expect("handshake attaches the pools");
|
||||||
|
(pools, out_region, in_region)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill and publish `slot` from the parent (producer) side. Pops past
|
||||||
|
/// any other free slots (returning them to the ring) so callers do not
|
||||||
|
/// need to know the free-ring order left by earlier publishes.
|
||||||
|
unsafe fn publish_input(
|
||||||
|
region: &SharedMemoryRegion,
|
||||||
|
slot: u32,
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
data_size: i32,
|
||||||
|
) {
|
||||||
|
// SAFETY: live region created by `test_region`.
|
||||||
|
let pool = unsafe { FrameSlotPool::attach(region.data()) };
|
||||||
|
let mut skipped: Vec<u32> = Vec::new();
|
||||||
|
loop {
|
||||||
|
let mut got = 0u32;
|
||||||
|
assert!(unsafe { pool.acquire(&mut got) }, "free ring is seeded");
|
||||||
|
if got == slot {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
skipped.push(got);
|
||||||
|
}
|
||||||
|
// SAFETY: `slot` was acquired above; meta and data are live.
|
||||||
|
unsafe {
|
||||||
|
let meta = &mut *pool.meta(slot);
|
||||||
|
*meta = Default::default();
|
||||||
|
meta.width = width;
|
||||||
|
meta.height = height;
|
||||||
|
meta.format = PixelFormat::F32 as i32;
|
||||||
|
meta.data_size = data_size;
|
||||||
|
std::ptr::write_bytes(pool.slot_data(slot), 0x5A, pool.slot_data_bytes());
|
||||||
|
for other in skipped {
|
||||||
|
assert!(pool.release(other), "skipped slots go back");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(unsafe { pool.publish(slot) }, "ready ring has room");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A GPU context whose readback always fails, for the output-readback
|
||||||
|
/// error path (a real GPU texture cannot be produced headless).
|
||||||
|
struct FailingDownloadGpu;
|
||||||
|
|
||||||
|
impl GpuContextLike for FailingDownloadGpu {
|
||||||
|
fn kind(&self) -> BackendKind {
|
||||||
|
BackendKind::Gl
|
||||||
|
}
|
||||||
|
fn destroy_texture(&self, _token: u64) {}
|
||||||
|
fn upload(&self, _token: u64, _frame: &Frame) -> CoreResult<()> {
|
||||||
|
Err(CoreError::Failed("fake upload".to_string()))
|
||||||
|
}
|
||||||
|
fn download(&self, _token: u64) -> CoreResult<Frame> {
|
||||||
|
Err(CoreError::Failed("fake download".to_string()))
|
||||||
|
}
|
||||||
|
fn blit(
|
||||||
|
&self,
|
||||||
|
_src: u64,
|
||||||
|
_dst: u64,
|
||||||
|
_processor: Option<&oak_core::color::ColorProcessor>,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
Err(CoreError::Failed("fake blit".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cancel_flag_is_reset_by_progress_start() {
|
fn cancel_flag_is_reset_by_progress_start() {
|
||||||
|
let _guard = global_factory_lock();
|
||||||
// Cancel semantics (protocol parity with the worker): a cancelled
|
// Cancel semantics (protocol parity with the worker): a cancelled
|
||||||
// reporter tells the plugin to abort at its next progressUpdate;
|
// reporter tells the plugin to abort at its next progressUpdate;
|
||||||
// the next progressStart (the factory path below) clears the
|
// the next progressStart (the factory path below) clears the
|
||||||
@@ -496,9 +632,511 @@ mod tests {
|
|||||||
// A cancel after the start makes the next update answer false.
|
// A cancel after the start makes the next update answer false.
|
||||||
OFX_CANCEL.store(true, Ordering::Relaxed);
|
OFX_CANCEL.store(true, Ordering::Relaxed);
|
||||||
assert!(!reporter.update(0.6), "a cancelled reporter answers false");
|
assert!(!reporter.update(0.6), "a cancelled reporter answers false");
|
||||||
|
// progressEnd forwards completion (fraction 1.0); the emit is a
|
||||||
|
// no-op under test, so this just exercises the reporter path.
|
||||||
|
reporter.end();
|
||||||
OFX_CANCEL.store(false, Ordering::Relaxed);
|
OFX_CANCEL.store(false, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_factory_installs_a_working_reporter() {
|
||||||
|
let _guard = global_factory_lock();
|
||||||
|
OFX_CANCEL.store(false, Ordering::Relaxed);
|
||||||
|
install_progress_factory();
|
||||||
|
assert!(
|
||||||
|
oak_plugin::progress::has_reporter_factory(),
|
||||||
|
"the host factory must be installed for the plugin progress suite"
|
||||||
|
);
|
||||||
|
// Drive the installed factory through the progress suite
|
||||||
|
// (progressStart -> host_progress_reporter, update, end).
|
||||||
|
oak_plugin::suites::progress::set_current(Some(
|
||||||
|
oak_plugin::progress::ProgressReporter::silent(),
|
||||||
|
));
|
||||||
|
let v2 = oak_plugin::suites::progress::suite_v2();
|
||||||
|
let label = std::ffi::CString::new("render").unwrap();
|
||||||
|
let message = std::ffi::CString::new("frame 1").unwrap();
|
||||||
|
// SAFETY: the suite takes the null handle by contract; the strings
|
||||||
|
// outlive the calls.
|
||||||
|
unsafe {
|
||||||
|
assert_eq!(
|
||||||
|
(v2.start)(std::ptr::null_mut(), label.as_ptr(), message.as_ptr()),
|
||||||
|
oak_plugin::suites::status::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(v2.update)(std::ptr::null_mut(), 0.5),
|
||||||
|
oak_plugin::suites::status::OK
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(v2.end)(std::ptr::null_mut()),
|
||||||
|
oak_plugin::suites::status::OK
|
||||||
|
);
|
||||||
|
}
|
||||||
|
oak_plugin::suites::progress::set_current(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_recovers_from_a_poisoned_mutex() {
|
||||||
|
// `emit` (the other user) is a no-op under cfg(test); exercise the
|
||||||
|
// poison-recovery helper directly so a panicking reporter can never
|
||||||
|
// wedge the host's stdout lock.
|
||||||
|
let _ = std::thread::spawn(|| {
|
||||||
|
let _guard = OUT_LOCK.lock().unwrap();
|
||||||
|
panic!("poison OUT_LOCK on purpose");
|
||||||
|
})
|
||||||
|
.join();
|
||||||
|
let guard = lock(&OUT_LOCK);
|
||||||
|
drop(guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crash_hooks_maybe_crash_skips_when_marker_exists() {
|
||||||
|
let marker = std::env::temp_dir().join(format!(
|
||||||
|
"oak-ofx-host-marker-{}.txt",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
std::fs::write(&marker, b"already crashed").unwrap();
|
||||||
|
let hooks = CrashHooks {
|
||||||
|
always: false,
|
||||||
|
once_marker: Some(marker.clone()),
|
||||||
|
};
|
||||||
|
// The marker exists: the hook must return without aborting.
|
||||||
|
hooks.maybe_crash();
|
||||||
|
let _ = std::fs::remove_file(&marker);
|
||||||
|
|
||||||
|
// A dangling --ofx-crash-once without a value leaves no marker.
|
||||||
|
let hooks = CrashHooks::from_args(&[
|
||||||
|
"oak-worker".to_string(),
|
||||||
|
"--ofx-host".to_string(),
|
||||||
|
"--ofx-crash-once".to_string(),
|
||||||
|
]);
|
||||||
|
assert!(!hooks.always);
|
||||||
|
assert!(hooks.once_marker.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_pools_rejects_bad_shapes_and_missing_geometry() {
|
||||||
|
// Wrong field types: HandshakeMsg deserialization fails.
|
||||||
|
let err = attach_pools(&json!({
|
||||||
|
"type": TYPE_HANDSHAKE,
|
||||||
|
"protocol_version": "one",
|
||||||
|
}))
|
||||||
|
.err().expect("wrong types are invalid");
|
||||||
|
assert!(err.starts_with("invalid handshake: "), "{err}");
|
||||||
|
|
||||||
|
// Empty geometry (all serde defaults).
|
||||||
|
let err = attach_pools(&json!({ "type": TYPE_HANDSHAKE })).err().expect("empty handshake");
|
||||||
|
assert_eq!(err, "handshake missing output shared-memory geometry");
|
||||||
|
|
||||||
|
// Zero and negative geometry take the same branch.
|
||||||
|
for (slots, bytes) in [(0, 16), (1, 0), (-2, 16), (1, -4)] {
|
||||||
|
let err = attach_pools(&handshake_json("k", slots, bytes, "", 0, 0))
|
||||||
|
.err().expect("degenerate output geometry");
|
||||||
|
assert_eq!(err, "handshake missing output shared-memory geometry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_pools_attaches_real_segments_and_reports_failures() {
|
||||||
|
// Success: both pools attach with the announced geometry.
|
||||||
|
let (pools, _out, _in) = host_pools(2, 256, 3, 128);
|
||||||
|
assert!(pools.output.is_valid());
|
||||||
|
assert_eq!(pools.output.slot_count(), 2);
|
||||||
|
assert_eq!(pools.output.slot_data_bytes(), 256);
|
||||||
|
assert!(pools.input.is_valid());
|
||||||
|
assert_eq!(pools.input.slot_count(), 3);
|
||||||
|
assert_eq!(pools.input.slot_data_bytes(), 128);
|
||||||
|
|
||||||
|
// Output segment missing.
|
||||||
|
let missing = format!("olive-rw-{}-ofx-missing-out", std::process::id());
|
||||||
|
let err = attach_pools(&handshake_json(&missing, 1, 16, "", 0, 0)).err().expect("no segment");
|
||||||
|
assert!(err.starts_with("failed to attach output shared memory: "), "{err}");
|
||||||
|
|
||||||
|
// Output segment present but not a pool (zeroed memory -> bad magic).
|
||||||
|
let raw_key = format!("olive-rw-{}-ofx-raw-out", std::process::id());
|
||||||
|
let bytes = FrameSlotPool::bytes_needed(1, 16);
|
||||||
|
let mut raw = SharedMemoryRegion::new();
|
||||||
|
assert!(raw.open(&raw_key, bytes, ShmMode::Create));
|
||||||
|
let err = attach_pools(&handshake_json(&raw_key, 1, 16, "", 0, 0))
|
||||||
|
.err().expect("zeroed output segment");
|
||||||
|
assert_eq!(err, "output shared memory does not contain a frame slot pool");
|
||||||
|
|
||||||
|
// Output is fine but the announced input geometry is incomplete.
|
||||||
|
let (out_key, _out_region) = test_region("out-for-in", 1, 16);
|
||||||
|
let err = attach_pools(&handshake_json(&out_key, 1, 16, "", 2, 0)).err().expect("input bytes");
|
||||||
|
assert_eq!(err, "handshake missing input shared-memory geometry");
|
||||||
|
let err = attach_pools(&handshake_json(&out_key, 1, 16, "", 2, 32))
|
||||||
|
.err().expect("empty input key");
|
||||||
|
assert_eq!(err, "handshake missing input shared-memory geometry");
|
||||||
|
let err = attach_pools(&handshake_json(&out_key, 1, 16, " ", 2, 32))
|
||||||
|
.err().expect("blank input key attaches nothing");
|
||||||
|
assert!(err.starts_with("failed to attach input shared memory: "), "{err}");
|
||||||
|
|
||||||
|
// Input segment missing.
|
||||||
|
let missing_in = format!("olive-rw-{}-ofx-missing-in", std::process::id());
|
||||||
|
let err = attach_pools(&handshake_json(&out_key, 1, 16, &missing_in, 2, 32))
|
||||||
|
.err().expect("no input segment");
|
||||||
|
assert!(err.starts_with("failed to attach input shared memory: "), "{err}");
|
||||||
|
|
||||||
|
// Input segment present but not a pool.
|
||||||
|
let raw_key = format!("olive-rw-{}-ofx-raw-in", std::process::id());
|
||||||
|
let mut raw_in = SharedMemoryRegion::new();
|
||||||
|
assert!(raw_in.open(&raw_key, FrameSlotPool::bytes_needed(2, 32), ShmMode::Create));
|
||||||
|
let err = attach_pools(&handshake_json(&out_key, 1, 16, &raw_key, 2, 32))
|
||||||
|
.err().expect("zeroed input segment");
|
||||||
|
assert_eq!(err, "input shared memory does not contain a frame slot pool");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_input_frame_reports_missing_mismatch_and_invalid_meta() {
|
||||||
|
let (out_key, _out_region) = test_region("read-out", 4, 64);
|
||||||
|
let (in_key, in_region) = test_region("read-in", 2, 64);
|
||||||
|
let hs = handshake_json(&out_key, 4, 64, &in_key, 2, 64);
|
||||||
|
let pools = attach_pools(&hs).expect("attach");
|
||||||
|
|
||||||
|
// Nothing published yet.
|
||||||
|
let err = read_input_frame(&pools.input, 0).expect_err("empty ready ring");
|
||||||
|
assert_eq!(err, "input slot missing");
|
||||||
|
|
||||||
|
// Publish slot 1 while the job asks for slot 0: a protocol
|
||||||
|
// violation that must release the consumed slot and fail.
|
||||||
|
{
|
||||||
|
// SAFETY: live region; parent (producer) side view.
|
||||||
|
let parent = unsafe { FrameSlotPool::attach(in_region.data()) };
|
||||||
|
let mut first = 0u32;
|
||||||
|
let mut second = 0u32;
|
||||||
|
assert!(unsafe { parent.acquire(&mut first) });
|
||||||
|
assert!(unsafe { parent.acquire(&mut second) });
|
||||||
|
assert_eq!((first, second), (0, 1));
|
||||||
|
assert!(unsafe { parent.release(first) });
|
||||||
|
unsafe {
|
||||||
|
let meta = &mut *parent.meta(second);
|
||||||
|
*meta = Default::default();
|
||||||
|
meta.width = 4;
|
||||||
|
meta.height = 4;
|
||||||
|
meta.data_size = 64;
|
||||||
|
}
|
||||||
|
assert!(unsafe { parent.publish(second) });
|
||||||
|
}
|
||||||
|
let err = read_input_frame(&pools.input, 0).expect_err("slot mismatch");
|
||||||
|
assert_eq!(err, "input slot mismatch: expected 0, got 1");
|
||||||
|
|
||||||
|
// A published frame with degenerate metadata (zero width) is
|
||||||
|
// rejected and released.
|
||||||
|
unsafe { publish_input(&in_region, 0, 0, 4, 64) };
|
||||||
|
let err = read_input_frame(&pools.input, 0).expect_err("zero width");
|
||||||
|
assert_eq!(err, "input frame has invalid metadata");
|
||||||
|
|
||||||
|
// A negative data size is equally invalid.
|
||||||
|
unsafe { publish_input(&in_region, 0, 4, 4, -1) };
|
||||||
|
let err = read_input_frame(&pools.input, 0).expect_err("negative size");
|
||||||
|
assert_eq!(err, "input frame has invalid metadata");
|
||||||
|
|
||||||
|
// A valid frame is copied out; an oversized `data_size` is clamped
|
||||||
|
// to the slot capacity.
|
||||||
|
unsafe { publish_input(&in_region, 0, 2, 2, 9999) };
|
||||||
|
let frame = read_input_frame(&pools.input, 0).expect("valid input frame");
|
||||||
|
assert_eq!((frame.width, frame.height), (2, 2));
|
||||||
|
assert_eq!(frame.format, PixelFormat::F32);
|
||||||
|
assert_eq!(frame.data.len(), 64, "clamped to the slot block");
|
||||||
|
assert!(frame.data.iter().all(|&b| b == 0x5A));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_output_frame_handles_full_oversize_publish_and_success() {
|
||||||
|
let frame = cpu_frame(1, 1, 16);
|
||||||
|
|
||||||
|
// No free slots at all: the pool view is attached directly, since
|
||||||
|
// `attach_pools` rightly rejects a zero-slot handshake.
|
||||||
|
let (_empty_key, empty_region) = test_region("write-empty", 0, 16);
|
||||||
|
// SAFETY: live region created by `test_region`.
|
||||||
|
let empty_pool = unsafe { FrameSlotPool::attach(empty_region.data()) };
|
||||||
|
let err = write_output_frame(&empty_pool, &frame).expect_err("full pool");
|
||||||
|
assert_eq!(err, "output pool is full");
|
||||||
|
|
||||||
|
// `attach_pools` always requires the input geometry too, so
|
||||||
|
// announce a (never used) one-slot input pool.
|
||||||
|
let (pools, out_region, _in_region) = host_pools(1, 16, 1, 16);
|
||||||
|
|
||||||
|
// A frame larger than the slot is rejected.
|
||||||
|
let oversized = cpu_frame(2, 2, 64);
|
||||||
|
let err = write_output_frame(&pools.output, &oversized).expect_err("oversize");
|
||||||
|
assert!(
|
||||||
|
err.starts_with("output frame is 64 bytes, larger than the output slot (16)"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Success: the parent consumes the published slot and sees the meta.
|
||||||
|
let slot = write_output_frame(&pools.output, &frame).expect("publish");
|
||||||
|
assert_eq!(slot, 0);
|
||||||
|
// SAFETY: live region; parent (drainer) side view.
|
||||||
|
let parent = unsafe { FrameSlotPool::attach(out_region.data()) };
|
||||||
|
let mut consumed = 0u32;
|
||||||
|
assert!(unsafe { parent.consume(&mut consumed) });
|
||||||
|
assert_eq!(consumed, 0);
|
||||||
|
// SAFETY: `consumed` was just consumed.
|
||||||
|
let meta = unsafe { &*parent.meta_const(consumed) };
|
||||||
|
assert_eq!((meta.width, meta.height), (1, 1));
|
||||||
|
assert_eq!(meta.format, PixelFormat::F32 as i32);
|
||||||
|
assert_eq!(meta.channel_count, 4);
|
||||||
|
assert_eq!(meta.linesize, 16);
|
||||||
|
assert_eq!(meta.data_size, 16);
|
||||||
|
unsafe { parent.release(consumed) };
|
||||||
|
|
||||||
|
// Ready ring full: a duplicate publish fills the single-slot ring,
|
||||||
|
// the slot is recycled through the free ring, and the next publish
|
||||||
|
// fails.
|
||||||
|
unsafe {
|
||||||
|
let mut slot0 = 0u32;
|
||||||
|
assert!(pools.output.acquire(&mut slot0));
|
||||||
|
assert!(pools.output.publish(slot0));
|
||||||
|
assert!(pools.output.release(slot0));
|
||||||
|
}
|
||||||
|
let err = write_output_frame(&pools.output, &frame).expect_err("ready ring full");
|
||||||
|
assert_eq!(err, "output publish failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cpu_frame(width: i32, height: i32, bytes: usize) -> Frame {
|
||||||
|
let pod = VideoParamsPod {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
format: PixelFormat::F32 as i32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut frame = Frame::new();
|
||||||
|
frame.set_video_params(pod);
|
||||||
|
frame.data = vec![0x7F; bytes];
|
||||||
|
frame
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_job_rejects_malformed_and_unbacked_jobs() {
|
||||||
|
let (pools, _out, _in) = host_pools(2, 64, 2, 64);
|
||||||
|
|
||||||
|
// Wrong wire types: OfxJobMsg deserialization fails.
|
||||||
|
let resp = handle_job(json!({ "job": "five" }), &pools);
|
||||||
|
assert_eq!(resp["type"], crate::ipc::TYPE_ERROR);
|
||||||
|
assert!(
|
||||||
|
resp["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("invalid ofx_job: "),
|
||||||
|
"{resp}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A declared input with nothing published fails with the job id
|
||||||
|
// echoed and slot -1.
|
||||||
|
let resp = handle_job(
|
||||||
|
json!({
|
||||||
|
"job": 9,
|
||||||
|
"type_id": "org.oak.test",
|
||||||
|
"inputs": [{ "name": "Source", "slot": 0 }],
|
||||||
|
}),
|
||||||
|
&pools,
|
||||||
|
);
|
||||||
|
assert_eq!(resp["type"], crate::ipc::TYPE_OFX_RESULT);
|
||||||
|
assert_eq!(resp["job"], 9);
|
||||||
|
assert_eq!(resp["slot"], -1);
|
||||||
|
assert_eq!(resp["error"], "input slot missing");
|
||||||
|
|
||||||
|
// Same for a main-source slot that was never published.
|
||||||
|
let resp = handle_job(
|
||||||
|
json!({ "job": 10, "type_id": "org.oak.test", "src_slot": 0 }),
|
||||||
|
&pools,
|
||||||
|
);
|
||||||
|
assert_eq!(resp["job"], 10);
|
||||||
|
assert_eq!(resp["error"], "input slot missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_job_reports_factory_and_executor_failures() {
|
||||||
|
let _guard = global_factory_lock();
|
||||||
|
let (pools, _out, _in) = host_pools(2, 64, 2, 64);
|
||||||
|
let job = json!({ "job": 11, "type_id": "org.oak.test" });
|
||||||
|
|
||||||
|
// No instance factory installed: the host cannot resolve anything.
|
||||||
|
eval::set_plugin_instance_factory(None);
|
||||||
|
eval::set_plugin_executor(None);
|
||||||
|
let resp = handle_job(job.clone(), &pools);
|
||||||
|
assert_eq!(
|
||||||
|
resp["error"],
|
||||||
|
"no plugin instance factory installed in the OFX host"
|
||||||
|
);
|
||||||
|
assert_eq!(resp["slot"], -1);
|
||||||
|
|
||||||
|
// Factory resolves nothing: unknown plugin.
|
||||||
|
eval::set_plugin_instance_factory(Some(Arc::new(|_type_id: &str| None)));
|
||||||
|
let resp = handle_job(job.clone(), &pools);
|
||||||
|
assert_eq!(
|
||||||
|
resp["error"],
|
||||||
|
"unknown or unavailable OFX plugin: org.oak.test"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Instance resolved but no executor wired in.
|
||||||
|
eval::set_plugin_instance_factory(Some(Arc::new(|_type_id: &str| Some(7))));
|
||||||
|
eval::set_plugin_executor(None);
|
||||||
|
let resp = handle_job(job.clone(), &pools);
|
||||||
|
assert_eq!(
|
||||||
|
resp["error"],
|
||||||
|
"no plugin executor installed in the OFX host"
|
||||||
|
);
|
||||||
|
|
||||||
|
eval::set_plugin_instance_factory(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_job_renders_through_the_executor_and_reports_errors() {
|
||||||
|
let _guard = global_factory_lock();
|
||||||
|
let (pools, _out, in_region) = host_pools(2, 64, 4, 64);
|
||||||
|
// SAFETY: live region created by host_pools.
|
||||||
|
unsafe { publish_input(&in_region, 0, 1, 1, 16) };
|
||||||
|
|
||||||
|
// The custom executor asserts the resolved spec and returns a
|
||||||
|
// 1x1 frame; the host publishes it into the output pool.
|
||||||
|
let exec: Arc<eval::PluginExecutor> = Arc::new(|req: &PluginJobRequest<'_>| {
|
||||||
|
match req.spec {
|
||||||
|
JobSpec::Plugin {
|
||||||
|
instance,
|
||||||
|
type_id,
|
||||||
|
time,
|
||||||
|
effect_input_id,
|
||||||
|
inputs,
|
||||||
|
values,
|
||||||
|
} => {
|
||||||
|
assert_eq!(*instance, 7);
|
||||||
|
assert_eq!(type_id, "org.oak.test");
|
||||||
|
assert!((*time - 0.5).abs() < 1e-9);
|
||||||
|
assert_eq!(effect_input_id.as_deref(), Some("Source"));
|
||||||
|
assert_eq!(inputs.len(), 1);
|
||||||
|
assert_eq!(inputs[0].0, "Source");
|
||||||
|
assert_eq!(values.len(), 1);
|
||||||
|
assert_eq!(values[0].0, "brightness");
|
||||||
|
assert_eq!(values[0].1, oak_node::value::NodeValue::Float(0.5));
|
||||||
|
}
|
||||||
|
other => panic!("expected a plugin spec, got {other:?}"),
|
||||||
|
}
|
||||||
|
let frame = eval::generate_frame(oak_core::Rational::new(0, 1), (1, 1), PixelFormat::F32)
|
||||||
|
.expect("generate");
|
||||||
|
Ok(Texture::wrap_frame(frame))
|
||||||
|
});
|
||||||
|
let factory: Arc<eval::PluginInstanceFactory> = Arc::new(|_type_id: &str| Some(7));
|
||||||
|
eval::set_plugin_instance_factory(Some(factory));
|
||||||
|
eval::set_plugin_executor(Some(exec));
|
||||||
|
|
||||||
|
let resp = handle_job(
|
||||||
|
json!({
|
||||||
|
"job": 21,
|
||||||
|
"type_id": "org.oak.test",
|
||||||
|
"time": 0.5,
|
||||||
|
"effect_input_id": "Source",
|
||||||
|
"inputs": [{ "name": "Source", "slot": 0 }],
|
||||||
|
"values": [{ "input": "brightness", "value": { "t": "float", "v": 0.5 } }],
|
||||||
|
}),
|
||||||
|
&pools,
|
||||||
|
);
|
||||||
|
assert_eq!(resp["type"], crate::ipc::TYPE_OFX_RESULT);
|
||||||
|
assert_eq!(resp["job"], 21);
|
||||||
|
assert!(resp["slot"].as_i64().unwrap() >= 0, "{resp}");
|
||||||
|
assert_eq!(resp["error"], "");
|
||||||
|
|
||||||
|
// Executor failure is reported as a job failure; this one arrives
|
||||||
|
// through the explicit `src_slot` path.
|
||||||
|
// SAFETY: live region created by host_pools.
|
||||||
|
unsafe { publish_input(&in_region, 0, 1, 1, 16) };
|
||||||
|
let failing: Arc<eval::PluginExecutor> =
|
||||||
|
Arc::new(|_req: &PluginJobRequest<'_>| Err(oak_core::error::Error::Failed("boom".into())));
|
||||||
|
eval::set_plugin_executor(Some(failing));
|
||||||
|
let resp = handle_job(
|
||||||
|
json!({ "job": 22, "type_id": "org.oak.test", "src_slot": 0 }),
|
||||||
|
&pools,
|
||||||
|
);
|
||||||
|
assert_eq!(resp["job"], 22);
|
||||||
|
assert!(
|
||||||
|
resp["error"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("plugin render failed: "),
|
||||||
|
"{resp}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A texture that cannot be read back fails before publishing.
|
||||||
|
let gpu_returning: Arc<eval::PluginExecutor> = Arc::new(|_req: &PluginJobRequest<'_>| {
|
||||||
|
Ok(Texture::gpu(
|
||||||
|
Arc::new(FailingDownloadGpu),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
PixelFormat::F32,
|
||||||
|
))
|
||||||
|
});
|
||||||
|
eval::set_plugin_executor(Some(gpu_returning));
|
||||||
|
let resp = handle_job(
|
||||||
|
json!({ "job": 23, "type_id": "org.oak.test" }),
|
||||||
|
&pools,
|
||||||
|
);
|
||||||
|
assert_eq!(resp["job"], 23);
|
||||||
|
assert!(
|
||||||
|
resp["error"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("plugin output readback failed: "),
|
||||||
|
"{resp}"
|
||||||
|
);
|
||||||
|
|
||||||
|
eval::set_plugin_instance_factory(None);
|
||||||
|
eval::set_plugin_executor(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_job_falls_back_to_first_input_then_dummy_source() {
|
||||||
|
let _guard = global_factory_lock();
|
||||||
|
let (pools, _out, in_region) = host_pools(2, 64, 4, 64);
|
||||||
|
// SAFETY: live region created by host_pools.
|
||||||
|
unsafe { publish_input(&in_region, 0, 1, 1, 16) };
|
||||||
|
|
||||||
|
// Capture the src the fallback picked. Values stay empty: the
|
||||||
|
// resolver runs before the executor.
|
||||||
|
let kinds: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let record = kinds.clone();
|
||||||
|
let exec: Arc<eval::PluginExecutor> = Arc::new(move |req: &PluginJobRequest<'_>| {
|
||||||
|
record
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.push(if req.src.is_dummy() { "dummy" } else { "frame" });
|
||||||
|
let frame = eval::generate_frame(oak_core::Rational::new(0, 1), (1, 1), PixelFormat::F32)
|
||||||
|
.expect("generate");
|
||||||
|
Ok(Texture::wrap_frame(frame))
|
||||||
|
});
|
||||||
|
let factory: Arc<eval::PluginInstanceFactory> = Arc::new(|_type_id: &str| Some(1));
|
||||||
|
eval::set_plugin_instance_factory(Some(factory));
|
||||||
|
eval::set_plugin_executor(Some(exec));
|
||||||
|
|
||||||
|
// The declared effect input is absent from `inputs`, so the first
|
||||||
|
// clip is used instead.
|
||||||
|
let resp = handle_job(
|
||||||
|
json!({
|
||||||
|
"job": 31,
|
||||||
|
"type_id": "org.oak.test",
|
||||||
|
"effect_input_id": "Source",
|
||||||
|
"inputs": [{ "name": "Extra", "slot": 0 }],
|
||||||
|
}),
|
||||||
|
&pools,
|
||||||
|
);
|
||||||
|
assert_eq!(resp["error"], "", "{resp}");
|
||||||
|
|
||||||
|
// No clips and no explicit src: the dummy texture is used.
|
||||||
|
let resp = handle_job(
|
||||||
|
json!({ "job": 32, "type_id": "org.oak.test", "effect_input_id": "Source" }),
|
||||||
|
&pools,
|
||||||
|
);
|
||||||
|
assert_eq!(resp["error"], "", "{resp}");
|
||||||
|
|
||||||
|
let seen = kinds.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||||
|
assert_eq!(seen, vec!["frame", "dummy"]);
|
||||||
|
|
||||||
|
eval::set_plugin_instance_factory(None);
|
||||||
|
eval::set_plugin_executor(None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn crash_hooks_parse_args() {
|
fn crash_hooks_parse_args() {
|
||||||
let hooks = CrashHooks::from_args(&[
|
let hooks = CrashHooks::from_args(&[
|
||||||
@@ -513,5 +1151,66 @@ mod tests {
|
|||||||
let hooks = CrashHooks::from_args(&["oak-worker".to_string(), "--ofx-crash-always".to_string()]);
|
let hooks = CrashHooks::from_args(&["oak-worker".to_string(), "--ofx-crash-always".to_string()]);
|
||||||
assert!(hooks.always);
|
assert!(hooks.always);
|
||||||
assert!(hooks.once_marker.is_none());
|
assert!(hooks.once_marker.is_none());
|
||||||
|
|
||||||
|
// Unknown flags and a missing option value are ignored.
|
||||||
|
let hooks = CrashHooks::from_args(&[
|
||||||
|
"oak-worker".to_string(),
|
||||||
|
"--ofx-host".to_string(),
|
||||||
|
"--not-a-flag".to_string(),
|
||||||
|
"--ofx-crash-once".to_string(),
|
||||||
|
]);
|
||||||
|
assert!(!hooks.always);
|
||||||
|
assert!(hooks.once_marker.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- M16 R2 coverage additions ----------------------------------------
|
||||||
|
|
||||||
|
/// Every hook of the fake failing GPU context is callable and reports
|
||||||
|
/// the failure (the executor readback path uses `download`; the other
|
||||||
|
/// hooks document the trait contract).
|
||||||
|
#[test]
|
||||||
|
fn failing_download_gpu_hooks_return_errors() {
|
||||||
|
let gpu = FailingDownloadGpu;
|
||||||
|
assert_eq!(gpu.kind(), BackendKind::Gl);
|
||||||
|
gpu.destroy_texture(1);
|
||||||
|
let frame = cpu_frame(1, 1, 4);
|
||||||
|
assert!(gpu.upload(1, &frame).is_err(), "fake upload always fails");
|
||||||
|
assert!(gpu.download(1).is_err(), "fake download always fails");
|
||||||
|
assert!(gpu.blit(1, 2, None).is_err(), "fake blit always fails");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A successful plugin render whose output pool has no free slot fails
|
||||||
|
/// the job with "output pool is full" (the acquired-but-unpublished
|
||||||
|
/// slot is not leaked into the ready ring).
|
||||||
|
#[test]
|
||||||
|
fn handle_job_reports_output_pool_full() {
|
||||||
|
let _guard = global_factory_lock();
|
||||||
|
let (pools, _out, _in) = host_pools(1, 16, 1, 16);
|
||||||
|
// Drain the only free output slot through the producer side, so the
|
||||||
|
// host's `write_output_frame` cannot acquire one.
|
||||||
|
let mut drained = 0u32;
|
||||||
|
// SAFETY: live attached pools; the test owns both sides and is
|
||||||
|
// single-threaded.
|
||||||
|
assert!(unsafe { pools.output.acquire(&mut drained) });
|
||||||
|
assert_eq!(drained, 0);
|
||||||
|
|
||||||
|
let exec: Arc<eval::PluginExecutor> = Arc::new(|_req: &PluginJobRequest<'_>| {
|
||||||
|
let frame =
|
||||||
|
eval::generate_frame(oak_core::Rational::new(0, 1), (1, 1), PixelFormat::F32)
|
||||||
|
.expect("generate");
|
||||||
|
Ok(Texture::wrap_frame(frame))
|
||||||
|
});
|
||||||
|
eval::set_plugin_instance_factory(Some(Arc::new(|_type_id: &str| Some(1))));
|
||||||
|
eval::set_plugin_executor(Some(exec));
|
||||||
|
let resp = handle_job(json!({ "job": 41, "type_id": "org.oak.test" }), &pools);
|
||||||
|
eval::set_plugin_instance_factory(None);
|
||||||
|
eval::set_plugin_executor(None);
|
||||||
|
|
||||||
|
assert_eq!(resp["type"], crate::ipc::TYPE_OFX_RESULT);
|
||||||
|
assert_eq!(resp["job"], 41);
|
||||||
|
assert_eq!(resp["slot"], -1);
|
||||||
|
assert_eq!(resp["error"], "output pool is full");
|
||||||
|
// Nothing was published.
|
||||||
|
assert!(!unsafe { pools.output.consume(&mut drained) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1157
-22
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user