diff --git a/Cargo.lock b/Cargo.lock index fc07ab55..80499bdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,6 +60,15 @@ dependencies = [ "equator", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -528,6 +537,9 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "bitstream-io" @@ -1108,7 +1120,7 @@ dependencies = [ "core-graphics2", "io-surface", "libc", - "metal", + "metal 0.29.0", ] [[package]] @@ -1252,6 +1264,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "decode" +version = "0.1.0" +dependencies = [ + "bytemuck", + "decode", + "thiserror 2.0.19", + "time", +] + [[package]] name = "deflate64" version = "0.1.12" @@ -1370,6 +1392,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1978,6 +2009,17 @@ dependencies = [ "weezl", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glob" version = "0.3.4" @@ -2021,6 +2063,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + [[package]] name = "gpu-alloc" version = "0.6.2" @@ -2051,6 +2102,38 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.57.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "gpui" version = "0.2.2" @@ -2101,7 +2184,7 @@ dependencies = [ "libc", "log", "lyon", - "metal", + "metal 0.29.0", "naga", "num_cpus", "objc", @@ -2218,7 +2301,7 @@ dependencies = [ "core-video", "ctor", "foreign-types", - "metal", + "metal 0.29.0", "objc", ] @@ -2802,6 +2885,34 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "jobserver" version = "0.1.35" @@ -2831,8 +2942,15 @@ checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", "libloading", + "pkg-config", ] +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "kurbo" version = "0.11.3" @@ -2942,6 +3060,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -3102,6 +3226,21 @@ dependencies = [ "paste", ] +[[package]] +name = "metal" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types 0.1.3", + "foreign-types", + "log", + "objc", + "paste", +] + [[package]] name = "mime" version = "0.3.17" @@ -3195,6 +3334,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3586,6 +3734,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3773,6 +3930,18 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + [[package]] name = "postage" version = "0.5.0" @@ -3785,7 +3954,7 @@ dependencies = [ "log", "parking_lot", "pin-project", - "pollster", + "pollster 0.2.5", "static_assertions", "thiserror 1.0.69", ] @@ -3808,6 +3977,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "prettyplease" version = "0.2.37" @@ -4111,6 +4286,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + [[package]] name = "rangemap" version = "1.7.1" @@ -4309,6 +4490,24 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "render" +version = "0.1.0" +dependencies = [ + "bytemuck", + "decode", + "pollster 0.4.0", + "thiserror 2.0.19", + "time", + "wgpu", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "resvg" version = "0.45.1" @@ -5361,6 +5560,15 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.19", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -6124,6 +6332,153 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wgpu" +version = "25.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8fb398f119472be4d80bc3647339f56eb63b2a331f6a3d16e25d8144197dd9" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "25.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7b882196f8368511d613c6aeec80655160db6646aebddf8328879a88d54e500" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.19", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd488b3239b6b7b185c3b045c39ca6bf8af34467a4c5de4e0b1a564135d093d" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09ad7aceb3818e52539acc679f049d3475775586f3f4e311c30165cf2c00445" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba5fb5f7f9c98baa7c889d444f63ace25574833df56f5b817985f641af58e46" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "25.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f968767fe4d3d33747bbd1473ccd55bf0f6451f55d733b5597e67b5deab4ad17" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.1", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.1.3", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.15.5", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal 0.31.0", + "naga", + "ndk-sys", + "objc", + "ordered-float", + "parking_lot", + "portable-atomic", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.19", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa49460c2a8ee8edba3fca54325540d904dd85b2e086ada762767e17d06e8bc" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.19", + "web-sys", +] + [[package]] name = "which" version = "6.0.3" @@ -6177,6 +6532,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -6224,6 +6589,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -6259,6 +6637,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6281,6 +6670,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -6345,6 +6745,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -6354,6 +6763,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.3.1" @@ -6761,6 +7180,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "xmlwriter" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 386e14d7..34468609 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ resolver = "3" members = [ 'apps/desktop', - # 'crates/*', + 'crates/*', ] [workspace.package] @@ -12,3 +12,9 @@ license = "MIT" [workspace.dependencies] gpui = "0.2.2" +serde = { version = "1", features = ["derive"] } +thiserror = "2" +bytemuck = { version = "1", features = ["derive"] } +wgpu = "25.0.2" +time = { path = "crates/time" } +decode = { path = "crates/decode" } diff --git a/crates/decode/Cargo.toml b/crates/decode/Cargo.toml new file mode 100644 index 00000000..7df3096a --- /dev/null +++ b/crates/decode/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "decode" +version.workspace = true +edition.workspace = true +license.workspace = true + +[features] +default = [] +fake = [] + +[dependencies] +time = { workspace = true } +thiserror = { workspace = true } +bytemuck = { workspace = true } + +[dev-dependencies] +decode = { path = ".", features = ["fake"] } diff --git a/crates/decode/src/driver.rs b/crates/decode/src/driver.rs new file mode 100644 index 00000000..b342b91e --- /dev/null +++ b/crates/decode/src/driver.rs @@ -0,0 +1,28 @@ +use crate::{Frame, Source, SourceId, SourceStream, SourceStreamId}; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum DecodeError { + #[error("source not found")] + SourceNotFound, + #[error("stream not found")] + StreamNotFound, + #[error("unsupported format")] + UnsupportedFormat, + #[error("decoding failed: {0}")] + DecodingFailed(String), + #[error("end of stream")] + EndOfStream, +} + +pub trait Decoder { + fn open(&mut self, source: &Source) -> Result; + fn streams(&self, source: SourceId) -> Result, DecodeError>; + fn read_frame( + &mut self, + source: SourceId, + stream: SourceStreamId, + time: time::RationalTime, + ) -> Result; + fn close(&mut self, source: SourceId) -> Result<(), DecodeError>; +} diff --git a/crates/decode/src/fake.rs b/crates/decode/src/fake.rs new file mode 100644 index 00000000..7344ea64 --- /dev/null +++ b/crates/decode/src/fake.rs @@ -0,0 +1,260 @@ +use std::collections::HashMap; + +use crate::{ + DecodeError, Decoder, Frame, PixelFormat, Source, SourceId, SourceStream, SourceStreamId, +}; +use time::{FrameRate, RationalTime}; + +/// Configuration applied to each source opened by [`FakeDecoder`]. +#[derive(Clone, Debug)] +pub struct FakeDecoderConfig { + pub frame_rate: FrameRate, + pub width: u32, + pub height: u32, + pub duration: RationalTime, + pub format: PixelFormat, + pub color: [u8; 4], +} + +impl Default for FakeDecoderConfig { + fn default() -> Self { + Self { + frame_rate: FrameRate::new(30, 1).expect("valid default frame rate"), + width: 64, + height: 64, + duration: RationalTime::new(1, 1).expect("valid default duration"), + format: PixelFormat::Rgba8, + color: [255, 0, 0, 255], + } + } +} + +struct OpenSource { + stream: SourceStream, + config: FakeDecoderConfig, +} + +pub struct FakeDecoder { + next_id: u64, + default_config: FakeDecoderConfig, + sources: HashMap, +} + +impl FakeDecoder { + pub fn new(config: FakeDecoderConfig) -> Self { + Self { + next_id: 1, + default_config: config, + sources: HashMap::new(), + } + } + + fn lookup(&self, source: SourceId) -> Result<&OpenSource, DecodeError> { + self.sources + .get(&source.raw()) + .ok_or(DecodeError::SourceNotFound) + } + + fn lookup_mut(&mut self, source: SourceId) -> Result<&mut OpenSource, DecodeError> { + self.sources + .get_mut(&source.raw()) + .ok_or(DecodeError::SourceNotFound) + } +} + +impl Decoder for FakeDecoder { + fn open(&mut self, _source: &Source) -> Result { + let id = SourceId::new(self.next_id); + self.next_id += 1; + + let config = self.default_config.clone(); + let stream = SourceStream { + id: SourceStreamId::new(1), + frame_rate: config.frame_rate, + width: config.width, + height: config.height, + duration: config.duration, + }; + + self.sources.insert( + id.raw(), + OpenSource { + stream, + config, + }, + ); + + Ok(id) + } + + fn streams(&self, source: SourceId) -> Result, DecodeError> { + Ok(vec![self.lookup(source)?.stream.clone()]) + } + + fn read_frame( + &mut self, + source: SourceId, + stream: SourceStreamId, + time: RationalTime, + ) -> Result { + let open = self.lookup_mut(source)?; + if open.stream.id != stream { + return Err(DecodeError::StreamNotFound); + } + + if !time.lt(&open.stream.duration) { + return Err(DecodeError::EndOfStream); + } + + let frame_index = open + .stream + .frame_rate + .frame_index_floor(time) + .map_err(|_| DecodeError::DecodingFailed("frame index overflow".into()))?; + let frame_time = open + .stream + .frame_rate + .frame_start(frame_index) + .map_err(|_| DecodeError::DecodingFailed("frame time overflow".into()))?; + + let len = Frame::expected_byte_len(open.stream.width, open.stream.height, open.config.format); + let mut pixels = vec![0u8; len].into_boxed_slice(); + fill_solid(&mut pixels, open.config.format, open.config.color); + + Ok(Frame { + source, + stream, + time: frame_time, + width: open.stream.width, + height: open.stream.height, + format: open.config.format, + pixels, + }) + } + + fn close(&mut self, source: SourceId) -> Result<(), DecodeError> { + self.sources + .remove(&source.raw()) + .map(|_| ()) + .ok_or(DecodeError::SourceNotFound) + } +} + +fn fill_solid(pixels: &mut [u8], format: PixelFormat, color: [u8; 4]) { + let (r, g, b, a) = (color[0], color[1], color[2], color[3]); + match format { + PixelFormat::Rgba8 => { + for chunk in pixels.chunks_exact_mut(4) { + chunk.copy_from_slice(&[r, g, b, a]); + } + } + PixelFormat::Bgra8 => { + for chunk in pixels.chunks_exact_mut(4) { + chunk.copy_from_slice(&[b, g, r, a]); + } + } + PixelFormat::Rgb8 => { + for chunk in pixels.chunks_exact_mut(3) { + chunk.copy_from_slice(&[r, g, b]); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + fn decoder() -> FakeDecoder { + FakeDecoder::new(FakeDecoderConfig { + frame_rate: FrameRate::new(30, 1).unwrap(), + width: 4, + height: 4, + duration: RationalTime::new(1, 1).unwrap(), + format: PixelFormat::Rgba8, + color: [10, 20, 30, 255], + }) + } + + #[test] + fn read_frame_time_is_floor_of_requested_time() { + let mut decoder = decoder(); + let source = decoder + .open(&Source::Bytes(Arc::from([0u8; 0]))) + .unwrap(); + let stream = decoder.streams(source).unwrap()[0].id; + let requested = RationalTime::new(1, 30).unwrap(); + let frame = decoder.read_frame(source, stream, requested).unwrap(); + assert_eq!(frame.time, RationalTime::new(1, 30).unwrap()); + } + + #[test] + fn streams_report_declared_metadata() { + let mut decoder = decoder(); + let source = decoder + .open(&Source::Bytes(Arc::from([0u8; 0]))) + .unwrap(); + let streams = decoder.streams(source).unwrap(); + assert_eq!(streams.len(), 1); + assert_eq!(streams[0].width, 4); + assert_eq!(streams[0].height, 4); + assert_eq!(streams[0].frame_rate, FrameRate::new(30, 1).unwrap()); + assert_eq!(streams[0].duration, RationalTime::new(1, 1).unwrap()); + } + + #[test] + fn unknown_source_errors() { + let mut decoder = decoder(); + let missing = SourceId::new(999); + assert_eq!(decoder.streams(missing), Err(DecodeError::SourceNotFound)); + assert_eq!( + decoder.read_frame(missing, SourceStreamId::new(1), RationalTime::new(0, 1).unwrap()), + Err(DecodeError::SourceNotFound) + ); + } + + #[test] + fn unknown_stream_errors() { + let mut decoder = decoder(); + let source = decoder + .open(&Source::Bytes(Arc::from([0u8; 0]))) + .unwrap(); + assert_eq!( + decoder.read_frame( + source, + SourceStreamId::new(99), + RationalTime::new(0, 1).unwrap() + ), + Err(DecodeError::StreamNotFound) + ); + } + + #[test] + fn read_frame_after_close_errors() { + let mut decoder = decoder(); + let source = decoder + .open(&Source::Bytes(Arc::from([0u8; 0]))) + .unwrap(); + let stream = decoder.streams(source).unwrap()[0].id; + decoder.close(source).unwrap(); + assert_eq!( + decoder.read_frame(source, stream, RationalTime::new(0, 1).unwrap()), + Err(DecodeError::SourceNotFound) + ); + } + + #[test] + fn read_frame_at_or_past_duration_errors() { + let mut decoder = decoder(); + let source = decoder + .open(&Source::Bytes(Arc::from([0u8; 0]))) + .unwrap(); + let stream = decoder.streams(source).unwrap()[0].id; + let duration = RationalTime::new(1, 1).unwrap(); + assert_eq!( + decoder.read_frame(source, stream, duration), + Err(DecodeError::EndOfStream) + ); + } +} diff --git a/crates/decode/src/frame.rs b/crates/decode/src/frame.rs new file mode 100644 index 00000000..b6a71862 --- /dev/null +++ b/crates/decode/src/frame.rs @@ -0,0 +1,31 @@ +use crate::{SourceId, SourceStreamId}; + +/// Pixel layout the decoder can hand to the renderer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PixelFormat { + Rgba8, + Bgra8, + Rgb8, +} + +/// A decoded video frame. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Frame { + pub source: SourceId, + pub stream: SourceStreamId, + pub time: time::RationalTime, + pub width: u32, + pub height: u32, + pub format: PixelFormat, + pub pixels: Box<[u8]>, +} + +impl Frame { + pub fn expected_byte_len(width: u32, height: u32, format: PixelFormat) -> usize { + let bpp = match format { + PixelFormat::Rgba8 | PixelFormat::Bgra8 => 4, + PixelFormat::Rgb8 => 3, + }; + width as usize * height as usize * bpp + } +} diff --git a/crates/decode/src/lib.rs b/crates/decode/src/lib.rs new file mode 100644 index 00000000..a2331e56 --- /dev/null +++ b/crates/decode/src/lib.rs @@ -0,0 +1,15 @@ +//! Portable media decode boundary: sources, frames, and the decoder driver socket. + +mod driver; +mod frame; +mod source; + +#[cfg(feature = "fake")] +mod fake; + +pub use driver::{DecodeError, Decoder}; +pub use frame::{Frame, PixelFormat}; +pub use source::{Source, SourceId, SourceStream, SourceStreamId}; + +#[cfg(feature = "fake")] +pub use fake::{FakeDecoder, FakeDecoderConfig}; diff --git a/crates/decode/src/source.rs b/crates/decode/src/source.rs new file mode 100644 index 00000000..4d8ccbe6 --- /dev/null +++ b/crates/decode/src/source.rs @@ -0,0 +1,47 @@ +use std::path::PathBuf; +use std::sync::Arc; + +/// A decodable media input. +#[derive(Clone, Debug)] +pub enum Source { + Path(PathBuf), + Bytes(Arc<[u8]>), +} + +/// Opaque id assigned when a source is opened. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SourceId(u64); + +impl SourceId { + pub fn new(id: u64) -> Self { + Self(id) + } + + pub fn raw(&self) -> u64 { + self.0 + } +} + +/// Opaque id for one stream within a source. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SourceStreamId(u64); + +impl SourceStreamId { + pub fn new(id: u64) -> Self { + Self(id) + } + + pub fn raw(&self) -> u64 { + self.0 + } +} + +/// One video stream of a decoded source. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SourceStream { + pub id: SourceStreamId, + pub frame_rate: time::FrameRate, + pub width: u32, + pub height: u32, + pub duration: time::RationalTime, +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml new file mode 100644 index 00000000..8478726a --- /dev/null +++ b/crates/render/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "render" +version.workspace = true +edition.workspace = true +license.workspace = true + +[features] +default = [] +wgpu-tests = [] + +[dependencies] +time = { workspace = true } +decode = { workspace = true } +wgpu = { workspace = true } +thiserror = { workspace = true } +bytemuck = { workspace = true } + +[dev-dependencies] +decode = { workspace = true, features = ["fake"] } +pollster = "0.4" + +[[test]] +name = "compose" +required-features = ["wgpu-tests"] diff --git a/crates/render/src/compose.rs b/crates/render/src/compose.rs new file mode 100644 index 00000000..2f641730 --- /dev/null +++ b/crates/render/src/compose.rs @@ -0,0 +1,196 @@ +use crate::plan::{Affine, Blend, Crop, GpuError, Opacity, Output, OutputFormat, OutputTexture, RenderError}; +use crate::pixels::output_format_to_wgpu; + +#[derive(Debug, Clone)] +pub struct Layer { + pub width: u32, + pub height: u32, + pub pixels: Vec, + pub crop: Crop, + pub transform: Affine, + pub opacity: Opacity, + pub blend: Blend, +} + +#[derive(Default)] +pub struct Composer; + +impl Composer { + pub fn new() -> Self { + Self + } + + pub fn compose( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + output: &Output, + layers: &[Layer], + ) -> Result { + let mut buffer = vec![0u8; output_pixel_len(output)]; + + for layer in layers { + composite_layer(&mut buffer, output, layer); + } + + reorder_for_output_format(&mut buffer, output.format); + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("render_output"), + size: wgpu::Extent3d { + width: output.width, + height: output.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: output_format_to_wgpu(output.format), + usage: wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &buffer, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(output.width * 4), + rows_per_image: Some(output.height), + }, + wgpu::Extent3d { + width: output.width, + height: output.height, + depth_or_array_layers: 1, + }, + ); + + Ok(OutputTexture { + texture, + output: *output, + }) + } +} + +fn output_pixel_len(output: &Output) -> usize { + output.width as usize * output.height as usize * 4 +} + +fn reorder_for_output_format(buffer: &mut [u8], format: OutputFormat) { + if matches!(format, OutputFormat::Rgba8Premultiplied) { + return; + } + for px in buffer.chunks_exact_mut(4) { + px.swap(0, 2); + } +} + +fn composite_layer(dst: &mut [u8], output: &Output, layer: &Layer) { + let out_w = output.width as i32; + let out_h = output.height as i32; + let inv = match invert_affine(layer.transform) { + Ok(v) => v, + Err(()) => return, + }; + + for oy in 0..out_h { + for ox in 0..out_w { + let (sx, sy) = apply_affine(inv, ox as f32, oy as f32); + if sx < layer.crop.x as f32 + || sy < layer.crop.y as f32 + || sx >= (layer.crop.x + layer.crop.w) as f32 + || sy >= (layer.crop.y + layer.crop.h) as f32 + { + continue; + } + + let src_x = sx.floor() as u32; + let src_y = sy.floor() as u32; + if src_x >= layer.width || src_y >= layer.height { + continue; + } + + let src = sample_premul(&layer.pixels, layer.width, src_x, src_y); + let src = scale_premul(src, layer.opacity.value()); + + let dst_idx = ((oy * out_w + ox) * 4) as usize; + let dst_px = [ + dst[dst_idx], + dst[dst_idx + 1], + dst[dst_idx + 2], + dst[dst_idx + 3], + ]; + + let out = match layer.blend { + Blend::SourceOver => source_over_premul(dst_px, src), + }; + + dst[dst_idx..dst_idx + 4].copy_from_slice(&out); + } + } +} + +fn apply_affine(affine: Affine, x: f32, y: f32) -> (f32, f32) { + let [a, b, c, d, e, f] = affine.m; + (a * x + c * y + e, b * x + d * y + f) +} + +fn invert_affine(affine: Affine) -> Result { + let [a, b, c, d, e, f] = affine.m; + let det = a * d - b * c; + if det.abs() < f32::EPSILON { + return Err(()); + } + let inv_det = 1.0 / det; + let ia = d * inv_det; + let ib = -b * inv_det; + let ic = -c * inv_det; + let id = a * inv_det; + let ie = -(ia * e + ic * f); + let iff = -(ib * e + id * f); + Ok(Affine { + m: [ia, ib, ic, id, ie, iff], + }) +} + +fn sample_premul(pixels: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] { + let idx = ((y * width + x) * 4) as usize; + [ + pixels[idx], + pixels[idx + 1], + pixels[idx + 2], + pixels[idx + 3], + ] +} + +fn scale_premul(px: [u8; 4], opacity: f32) -> [u8; 4] { + [ + (px[0] as f32 * opacity).round() as u8, + (px[1] as f32 * opacity).round() as u8, + (px[2] as f32 * opacity).round() as u8, + (px[3] as f32 * opacity).round() as u8, + ] +} + +fn source_over_premul(dst: [u8; 4], src: [u8; 4]) -> [u8; 4] { + let src_a = src[3] as f32 / 255.0; + let inv = 1.0 - src_a; + [ + (src[0] as f32 + dst[0] as f32 * inv).round() as u8, + (src[1] as f32 + dst[1] as f32 * inv).round() as u8, + (src[2] as f32 + dst[2] as f32 * inv).round() as u8, + (src[3] as f32 + dst[3] as f32 * inv).round().min(255.0) as u8, + ] +} + +#[allow(dead_code)] +fn map_wgpu_err(_: wgpu::Error) -> RenderError { + RenderError::Gpu(GpuError::Internal) +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs new file mode 100644 index 00000000..d9fd8fd7 --- /dev/null +++ b/crates/render/src/lib.rs @@ -0,0 +1,132 @@ +//! Product-neutral visual rendering against wgpu. + +mod compose; +mod pixels; +mod plan; +mod source; +mod validate; + +pub use plan::{ + Affine, Blend, Crop, GpuError, Node, NodeId, Opacity, OpacityError, Output, OutputFormat, + OutputTexture, PlanError, RenderError, RenderPlan, SourceRef, +}; +pub use validate::validate_plan; + +use compose::Composer; +use decode::{DecodeError, Decoder, Source, SourceId}; +use source::{FrameCache, SourceRegistry}; + +/// Offscreen renderer: validates plans, resolves frames through a decoder, composes into textures. +pub struct Renderer<'a> { + decoder: &'a mut dyn Decoder, + device: &'a wgpu::Device, + queue: &'a wgpu::Queue, + registry: SourceRegistry, + cache: FrameCache, + composer: Composer, +} + +impl<'a> Renderer<'a> { + pub fn new( + decoder: &'a mut dyn Decoder, + device: &'a wgpu::Device, + queue: &'a wgpu::Queue, + ) -> Self { + Self { + decoder, + device, + queue, + registry: SourceRegistry::new(), + cache: FrameCache::new(), + composer: Composer::new(), + } + } + + pub fn register_source(&mut self, source: &Source) -> Result { + let id = self.decoder.open(source).map_err(RenderError::Decode)?; + let streams = self + .decoder + .streams(id) + .map_err(RenderError::Decode)?; + self.registry.insert(id, streams); + Ok(id) + } + + pub fn release_source(&mut self, source: SourceId) -> Result<(), RenderError> { + self.cache.flush_source(source); + self.registry.remove(source); + self.decoder.close(source).map_err(RenderError::Decode) + } + + pub fn render(&mut self, plan: &RenderPlan) -> Result { + validate::validate_for_render(plan, &self.registry)?; + + let mut layers = Vec::with_capacity(plan.nodes.len()); + for node in &plan.nodes { + if !self.registry.contains(node.source.source) { + return Err(RenderError::UnknownSource); + } + + let streams = self + .registry + .streams(node.source.source) + .ok_or(RenderError::UnknownSource)?; + let stream = streams + .iter() + .find(|s| s.id == node.source.stream) + .cloned() + .ok_or(RenderError::UnknownStream)?; + + let frame = self.resolve_frame(node, &stream)?; + let premul = pixels::to_linear_premultiplied(&frame)?; + layers.push(compose::Layer { + width: frame.width, + height: frame.height, + pixels: premul, + crop: node.crop, + transform: node.transform, + opacity: node.opacity, + blend: node.blend, + }); + } + + self.composer.compose( + self.device, + self.queue, + &plan.output, + &layers, + ) + } + + fn resolve_frame( + &mut self, + node: &Node, + stream: &decode::SourceStream, + ) -> Result { + let cache_key_time = stream + .frame_rate + .frame_index_floor(node.source.time) + .map_err(|_| RenderError::Decode(DecodeError::DecodingFailed("time overflow".into())))?; + let cache_key = stream + .frame_rate + .frame_start(cache_key_time) + .map_err(|_| RenderError::Decode(DecodeError::DecodingFailed("time overflow".into())))?; + + if let Some(frame) = self.cache.get(node.source.source, node.source.stream, cache_key) { + return Ok(frame.clone()); + } + + let frame = self + .decoder + .read_frame(node.source.source, node.source.stream, node.source.time) + .map_err(RenderError::Decode)?; + + self.cache.insert( + node.source.source, + node.source.stream, + cache_key, + frame.clone(), + ); + Ok(frame) + } +} diff --git a/crates/render/src/pixels.rs b/crates/render/src/pixels.rs new file mode 100644 index 00000000..d4fb0505 --- /dev/null +++ b/crates/render/src/pixels.rs @@ -0,0 +1,121 @@ +use decode::{Frame, PixelFormat}; + +use crate::plan::{GpuError, RenderError}; + +pub fn to_linear_premultiplied(frame: &Frame) -> Result, RenderError> { + let len = frame.width as usize * frame.height as usize * 4; + let mut out = vec![0u8; len]; + + match frame.format { + PixelFormat::Rgba8 => rgba8_to_premul(&frame.pixels, &mut out), + PixelFormat::Bgra8 => bgra8_to_premul(&frame.pixels, &mut out), + PixelFormat::Rgb8 => rgb8_to_premul(&frame.pixels, &mut out), + } + + Ok(out) +} + +pub(crate) fn output_format_to_wgpu(format: crate::plan::OutputFormat) -> wgpu::TextureFormat { + match format { + crate::plan::OutputFormat::Rgba8Premultiplied => wgpu::TextureFormat::Rgba8Unorm, + crate::plan::OutputFormat::Bgra8Premultiplied => wgpu::TextureFormat::Bgra8Unorm, + } +} + +#[cfg(any(test, feature = "wgpu-tests"))] +pub fn readback_rgba8( + texture: &wgpu::Texture, + device: &wgpu::Device, + queue: &wgpu::Queue, + width: u32, + height: u32, +) -> Result, RenderError> { + let bytes_per_row = width * 4; + let padded_bytes_per_row = align_to(bytes_per_row, 256); + let buffer_size = padded_bytes_per_row as u64 * height as u64; + + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback"), + size: buffer_size, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("readback_encoder"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_bytes_per_row), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + queue.submit(Some(encoder.finish())); + + let slice = buffer.slice(..); + slice.map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::PollType::Wait).map_err(map_poll_error)?; + + let data = slice.get_mapped_range(); + let mut pixels = vec![0u8; (width * height * 4) as usize]; + for row in 0..height as usize { + let src_start = row * padded_bytes_per_row as usize; + let dst_start = row * bytes_per_row as usize; + pixels[dst_start..dst_start + bytes_per_row as usize] + .copy_from_slice(&data[src_start..src_start + bytes_per_row as usize]); + } + drop(data); + buffer.unmap(); + + Ok(pixels) +} + +fn rgba8_to_premul(src: &[u8], dst: &mut [u8]) { + for (s, d) in src.chunks_exact(4).zip(dst.chunks_exact_mut(4)) { + premul_pixel(s[0], s[1], s[2], s[3], d); + } +} + +fn bgra8_to_premul(src: &[u8], dst: &mut [u8]) { + for (s, d) in src.chunks_exact(4).zip(dst.chunks_exact_mut(4)) { + premul_pixel(s[2], s[1], s[0], s[3], d); + } +} + +fn rgb8_to_premul(src: &[u8], dst: &mut [u8]) { + for (s, d) in src.chunks_exact(3).zip(dst.chunks_exact_mut(4)) { + premul_pixel(s[0], s[1], s[2], 255, d); + } +} + +fn premul_pixel(r: u8, g: u8, b: u8, a: u8, dst: &mut [u8]) { + let af = a as f32 / 255.0; + dst[0] = (r as f32 * af).round() as u8; + dst[1] = (g as f32 * af).round() as u8; + dst[2] = (b as f32 * af).round() as u8; + dst[3] = a; +} + +fn align_to(value: u32, alignment: u32) -> u32 { + ((value + alignment - 1) / alignment) * alignment +} + +#[allow(dead_code)] +fn map_poll_error(_err: wgpu::PollError) -> RenderError { + RenderError::Gpu(GpuError::Internal) +} diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs new file mode 100644 index 00000000..d2a465b8 --- /dev/null +++ b/crates/render/src/plan.rs @@ -0,0 +1,119 @@ +use decode::{SourceId, SourceStreamId}; +use time::RationalTime; + +/// An immutable, fully-resolved description of one composition. +#[derive(Clone, Debug, PartialEq)] +pub struct RenderPlan { + pub output: Output, + pub nodes: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Output { + pub width: u32, + pub height: u32, + pub format: OutputFormat, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OutputFormat { + Rgba8Premultiplied, + Bgra8Premultiplied, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Node { + pub id: NodeId, + pub source: SourceRef, + pub crop: Crop, + pub transform: Affine, + pub opacity: Opacity, + pub blend: Blend, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct NodeId(u64); + +impl NodeId { + pub fn new(id: u64) -> Self { + Self(id) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SourceRef { + pub source: SourceId, + pub stream: SourceStreamId, + pub time: RationalTime, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Crop { + pub x: u32, + pub y: u32, + pub w: u32, + pub h: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Affine { + pub m: [f32; 6], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Opacity(f32); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum OpacityError { + OutOfRange(f32), +} + +impl Opacity { + pub fn new(value: f32) -> Result { + if !(0.0..=1.0).contains(&value) || !value.is_finite() { + Err(OpacityError::OutOfRange(value)) + } else { + Ok(Self(value)) + } + } + + pub fn value(self) -> f32 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Blend { + SourceOver, +} + +#[derive(Debug, PartialEq)] +pub enum RenderError { + InvalidPlan(PlanError), + UnknownSource, + UnknownStream, + UnsupportedPixelFormat, + Decode(decode::DecodeError), + Gpu(GpuError), +} + +#[derive(Debug, PartialEq, Eq)] +pub enum PlanError { + DanglingSourceRef, + DuplicateNodeId, + DimensionZero, + OutOfRangeCrop, + InvalidAffine, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum GpuError { + OutOfMemory, + Validation, + Internal, +} + +pub struct OutputTexture { + pub texture: wgpu::Texture, + pub output: Output, +} diff --git a/crates/render/src/source.rs b/crates/render/src/source.rs new file mode 100644 index 00000000..4fc15f2e --- /dev/null +++ b/crates/render/src/source.rs @@ -0,0 +1,64 @@ +use std::collections::HashMap; + +use decode::{SourceId, SourceStream, SourceStreamId}; + +#[derive(Debug, Default)] +pub struct SourceRegistry { + sources: HashMap>, +} + +impl SourceRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, id: SourceId, streams: Vec) { + self.sources.insert(id, streams); + } + + pub fn remove(&mut self, id: SourceId) -> bool { + self.sources.remove(&id).is_some() + } + + pub fn contains(&self, id: SourceId) -> bool { + self.sources.contains_key(&id) + } + + pub fn streams(&self, id: SourceId) -> Option<&[SourceStream]> { + self.sources.get(&id).map(Vec::as_slice) + } +} + +#[derive(Debug, Default)] +pub struct FrameCache { + entries: HashMap<(SourceId, SourceStreamId, time::RationalTime), decode::Frame>, +} + +impl FrameCache { + pub fn new() -> Self { + Self::default() + } + + pub fn get( + &self, + source: SourceId, + stream: SourceStreamId, + time: time::RationalTime, + ) -> Option<&decode::Frame> { + self.entries.get(&(source, stream, time)) + } + + pub fn insert( + &mut self, + source: SourceId, + stream: SourceStreamId, + time: time::RationalTime, + frame: decode::Frame, + ) { + self.entries.insert((source, stream, time), frame); + } + + pub fn flush_source(&mut self, source: SourceId) { + self.entries.retain(|(sid, _, _), _| *sid != source); + } +} diff --git a/crates/render/src/validate.rs b/crates/render/src/validate.rs new file mode 100644 index 00000000..8cd7b022 --- /dev/null +++ b/crates/render/src/validate.rs @@ -0,0 +1,288 @@ +use std::collections::HashSet; + +use decode::SourceStream; + +use crate::plan::{Affine, Crop, PlanError, RenderError, RenderPlan}; +use crate::source::SourceRegistry; + +pub fn validate_plan(plan: &RenderPlan, registry: &SourceRegistry) -> Result<(), RenderError> { + validate_topology(plan)?; + + for node in &plan.nodes { + if !registry.contains(node.source.source) { + return Err(RenderError::InvalidPlan(PlanError::DanglingSourceRef)); + } + + let streams = registry + .streams(node.source.source) + .ok_or(RenderError::InvalidPlan(PlanError::DanglingSourceRef))?; + let stream = streams + .iter() + .find(|s| s.id == node.source.stream) + .ok_or(RenderError::InvalidPlan(PlanError::DanglingSourceRef))?; + + validate_crop(node.crop, stream)?; + } + + Ok(()) +} + +pub(crate) fn validate_for_render( + plan: &RenderPlan, + registry: &SourceRegistry, +) -> Result<(), RenderError> { + validate_topology(plan)?; + + for node in &plan.nodes { + let streams = registry + .streams(node.source.source) + .ok_or(RenderError::UnknownSource)?; + let stream = streams + .iter() + .find(|s| s.id == node.source.stream) + .ok_or(RenderError::UnknownStream)?; + + validate_crop(node.crop, stream)?; + } + + Ok(()) +} + +fn validate_topology(plan: &RenderPlan) -> Result<(), RenderError> { + if plan.output.width == 0 || plan.output.height == 0 { + return Err(RenderError::InvalidPlan(PlanError::DimensionZero)); + } + + let mut seen = HashSet::new(); + for node in &plan.nodes { + if !seen.insert(node.id) { + return Err(RenderError::InvalidPlan(PlanError::DuplicateNodeId)); + } + + if !affine_is_finite(node.transform) { + return Err(RenderError::InvalidPlan(PlanError::InvalidAffine)); + } + } + + Ok(()) +} + +fn validate_crop(crop: Crop, stream: &SourceStream) -> Result<(), RenderError> { + let width = stream.width; + let height = stream.height; + + if crop.w == 0 + || crop.h == 0 + || crop.x.saturating_add(crop.w) > width + || crop.y.saturating_add(crop.h) > height + { + return Err(RenderError::InvalidPlan(PlanError::OutOfRangeCrop)); + } + Ok(()) +} + +fn affine_is_finite(affine: Affine) -> bool { + affine.m.iter().all(|v| v.is_finite()) +} + +#[cfg(test)] +mod tests { + use decode::{SourceId, SourceStream, SourceStreamId}; + use time::{FrameRate, RationalTime}; + + use super::*; + use crate::plan::{ + Affine, Blend, Crop, Node, NodeId, Opacity, Output, OutputFormat, RenderPlan, SourceRef, + }; + + fn test_output() -> Output { + Output { + width: 4, + height: 4, + format: OutputFormat::Rgba8Premultiplied, + } + } + + fn registry_with(source: SourceId, streams: Vec) -> SourceRegistry { + let mut registry = SourceRegistry::new(); + registry.insert(source, streams); + registry + } + + fn sample_stream() -> SourceStream { + SourceStream { + id: SourceStreamId::new(1), + frame_rate: FrameRate::new(30, 1).unwrap(), + width: 4, + height: 4, + duration: RationalTime::new(10, 1).unwrap(), + } + } + + #[test] + fn dangling_source_ref_rejected() { + let plan = RenderPlan { + output: test_output(), + nodes: vec![Node { + id: NodeId::new(1), + source: SourceRef { + source: SourceId::new(42), + stream: SourceStreamId::new(1), + time: RationalTime::new(0, 1).unwrap(), + }, + crop: Crop { + x: 0, + y: 0, + w: 4, + h: 4, + }, + transform: Affine { + m: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + }, + opacity: Opacity::new(1.0).unwrap(), + blend: Blend::SourceOver, + }], + }; + + assert_eq!( + validate_plan(&plan, &SourceRegistry::new()), + Err(RenderError::InvalidPlan(PlanError::DanglingSourceRef)) + ); + } + + #[test] + fn duplicate_node_id_rejected() { + let source = SourceId::new(1); + let stream = SourceStreamId::new(1); + let registry = registry_with(source, vec![sample_stream()]); + let node = Node { + id: NodeId::new(1), + source: SourceRef { + source, + stream, + time: RationalTime::new(0, 1).unwrap(), + }, + crop: Crop { + x: 0, + y: 0, + w: 4, + h: 4, + }, + transform: Affine { + m: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + }, + opacity: Opacity::new(1.0).unwrap(), + blend: Blend::SourceOver, + }; + + let plan = RenderPlan { + output: test_output(), + nodes: vec![node.clone(), node], + }; + + assert_eq!( + validate_plan(&plan, ®istry), + Err(RenderError::InvalidPlan(PlanError::DuplicateNodeId)) + ); + } + + #[test] + fn out_of_range_crop_rejected() { + let source = SourceId::new(1); + let stream = SourceStreamId::new(1); + let registry = registry_with(source, vec![sample_stream()]); + let plan = RenderPlan { + output: test_output(), + nodes: vec![Node { + id: NodeId::new(1), + source: SourceRef { + source, + stream, + time: RationalTime::new(0, 1).unwrap(), + }, + crop: Crop { + x: 2, + y: 2, + w: 4, + h: 4, + }, + transform: Affine { + m: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + }, + opacity: Opacity::new(1.0).unwrap(), + blend: Blend::SourceOver, + }], + }; + + assert_eq!( + validate_plan(&plan, ®istry), + Err(RenderError::InvalidPlan(PlanError::OutOfRangeCrop)) + ); + } + + #[test] + fn non_finite_affine_rejected() { + let source = SourceId::new(1); + let stream = SourceStreamId::new(1); + let registry = registry_with(source, vec![sample_stream()]); + + for bad in [ + [f32::NAN, 0.0, 0.0, 1.0, 0.0, 0.0], + [1.0, f32::INFINITY, 0.0, 1.0, 0.0, 0.0], + [1.0, 0.0, 0.0, f32::NEG_INFINITY, 0.0, 0.0], + ] { + let plan = RenderPlan { + output: test_output(), + nodes: vec![Node { + id: NodeId::new(1), + source: SourceRef { + source, + stream, + time: RationalTime::new(0, 1).unwrap(), + }, + crop: Crop { + x: 0, + y: 0, + w: 4, + h: 4, + }, + transform: Affine { m: bad }, + opacity: Opacity::new(1.0).unwrap(), + blend: Blend::SourceOver, + }], + }; + + assert_eq!( + validate_plan(&plan, ®istry), + Err(RenderError::InvalidPlan(PlanError::InvalidAffine)) + ); + } + } + + #[test] + fn dimension_zero_rejected() { + let plan = RenderPlan { + output: Output { + width: 0, + height: 4, + format: OutputFormat::Rgba8Premultiplied, + }, + nodes: vec![], + }; + + assert_eq!( + validate_plan(&plan, &SourceRegistry::new()), + Err(RenderError::InvalidPlan(PlanError::DimensionZero)) + ); + } + + #[test] + fn empty_node_list_accepted() { + let plan = RenderPlan { + output: test_output(), + nodes: vec![], + }; + + assert!(validate_plan(&plan, &SourceRegistry::new()).is_ok()); + } +} diff --git a/crates/render/tests/compose.rs b/crates/render/tests/compose.rs new file mode 100644 index 00000000..f7a729c4 --- /dev/null +++ b/crates/render/tests/compose.rs @@ -0,0 +1,330 @@ +#![cfg(feature = "wgpu-tests")] + +use std::sync::Arc; + +use decode::{ + FakeDecoder, FakeDecoderConfig, PixelFormat, Source, SourceStreamId, +}; +use render::pixels::readback_rgba8; +use render::{ + Affine, Blend, Crop, Node, NodeId, Opacity, Output, OutputFormat, RenderPlan, Renderer, + SourceRef, +}; +use time::{FrameRate, RationalTime}; + +fn headless_gpu() -> (wgpu::Device, wgpu::Queue) { + pollster::block_on(async { + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN | wgpu::Backends::GL, + ..Default::default() + }); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + force_fallback_adapter: true, + compatible_surface: None, + }) + .await + .expect("adapter"); + adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("compose_test_device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_defaults(), + memory_hints: wgpu::MemoryHints::Performance, + trace: wgpu::Trace::Off, + }) + .await + .expect("device") + }) +} + +fn output_8x8() -> Output { + Output { + width: 8, + height: 8, + format: OutputFormat::Rgba8Premultiplied, + } +} + +fn fill_affine(src_w: u32, src_h: u32, out_w: u32, out_h: u32) -> Affine { + Affine { + m: [ + out_w as f32 / src_w as f32, + 0.0, + 0.0, + out_h as f32 / src_h as f32, + 0.0, + 0.0, + ], + } +} + +fn decoder_with_color(color: [u8; 4], size: u32) -> FakeDecoder { + FakeDecoder::new(FakeDecoderConfig { + frame_rate: FrameRate::new(30, 1).unwrap(), + width: size, + height: size, + duration: RationalTime::new(10, 1).unwrap(), + format: PixelFormat::Rgba8, + color, + }) +} + +fn center_pixel(pixels: &[u8], width: u32, height: u32) -> [u8; 4] { + let x = width / 2; + let y = height / 2; + let idx = ((y * width + x) * 4) as usize; + [ + pixels[idx], + pixels[idx + 1], + pixels[idx + 2], + pixels[idx + 3], + ] +} + +#[test] +#[ignore = "requires GPU; run with --features wgpu-tests -- --ignored"] +fn single_opaque_node_fills_output_with_source_color() { + let (device, queue) = headless_gpu(); + let mut decoder = decoder_with_color([200, 40, 60, 255], 8); + let mut renderer = Renderer::new(&mut decoder, &device, &queue); + let source = renderer + .register_source(&Source::Bytes(Arc::from([0u8; 0]))) + .unwrap(); + let stream = decoder.streams(source).unwrap()[0].id; + + let plan = RenderPlan { + output: output_8x8(), + nodes: vec![Node { + id: NodeId::new(1), + source: SourceRef { + source, + stream, + time: RationalTime::new(0, 1).unwrap(), + }, + crop: Crop { + x: 0, + y: 0, + w: 8, + h: 8, + }, + transform: fill_affine(8, 8, 8, 8), + opacity: Opacity::new(1.0).unwrap(), + blend: Blend::SourceOver, + }], + }; + + let out = renderer.render(&plan).unwrap(); + let pixels = readback_rgba8(&out.texture, &device, &queue, 8, 8).unwrap(); + let px = center_pixel(&pixels, 8, 8); + assert_eq!(px, [200, 40, 60, 255]); +} + +#[test] +#[ignore = "requires GPU; run with --features wgpu-tests -- --ignored"] +fn two_stacked_nodes_blend_with_half_opacity() { + let (device, queue) = headless_gpu(); + let mut bottom = decoder_with_color([100, 0, 0, 255], 8); + let mut top = decoder_with_color([0, 0, 200, 255], 8); + + struct TwoDecoder { + bottom: FakeDecoder, + top: FakeDecoder, + mapping: std::collections::HashMap, + } + + impl decode::Decoder for TwoDecoder { + fn open(&mut self, source: &Source) -> Result { + if self.mapping.is_empty() { + let id = self.bottom.open(source)?; + self.mapping.insert(id.raw(), false); + Ok(id) + } else { + let id = self.top.open(source)?; + self.mapping.insert(id.raw(), true); + Ok(id) + } + } + + fn streams( + &self, + source: decode::SourceId, + ) -> Result, decode::DecodeError> { + if *self.mapping.get(&source.raw()).unwrap_or(&false) { + self.top.streams(source) + } else { + self.bottom.streams(source) + } + } + + fn read_frame( + &mut self, + source: decode::SourceId, + stream: decode::SourceStreamId, + time: time::RationalTime, + ) -> Result { + if *self.mapping.get(&source.raw()).unwrap_or(&false) { + self.top.read_frame(source, stream, time) + } else { + self.bottom.read_frame(source, stream, time) + } + } + + fn close(&mut self, source: decode::SourceId) -> Result<(), decode::DecodeError> { + if *self.mapping.get(&source.raw()).unwrap_or(&false) { + self.top.close(source) + } else { + self.bottom.close(source) + } + } + } + + let mut decoder = TwoDecoder { + bottom, + top, + mapping: std::collections::HashMap::new(), + }; + let mut renderer = Renderer::new(&mut decoder, &device, &queue); + let bottom_source = renderer + .register_source(&Source::Bytes(Arc::from([1u8; 0]))) + .unwrap(); + let top_source = renderer + .register_source(&Source::Bytes(Arc::from([2u8; 0]))) + .unwrap(); + let bottom_stream = decoder.streams(bottom_source).unwrap()[0].id; + let top_stream = decoder.streams(top_source).unwrap()[0].id; + + let plan = RenderPlan { + output: output_8x8(), + nodes: vec![ + Node { + id: NodeId::new(1), + source: SourceRef { + source: bottom_source, + stream: bottom_stream, + time: RationalTime::new(0, 1).unwrap(), + }, + crop: Crop { + x: 0, + y: 0, + w: 8, + h: 8, + }, + transform: fill_affine(8, 8, 8, 8), + opacity: Opacity::new(1.0).unwrap(), + blend: Blend::SourceOver, + }, + Node { + id: NodeId::new(2), + source: SourceRef { + source: top_source, + stream: top_stream, + time: RationalTime::new(0, 1).unwrap(), + }, + crop: Crop { + x: 0, + y: 0, + w: 8, + h: 8, + }, + transform: fill_affine(8, 8, 8, 8), + opacity: Opacity::new(0.5).unwrap(), + blend: Blend::SourceOver, + }, + ], + }; + + let out = renderer.render(&plan).unwrap(); + let pixels = readback_rgba8(&out.texture, &device, &queue, 8, 8).unwrap(); + let px = center_pixel(&pixels, 8, 8); + + // premul source-over: top(0,0,200,255)*0.5 over bottom(100,0,0,255) + assert_eq!(px[0], 50); + assert_eq!(px[2], 100); + assert_eq!(px[3], 255); +} + +#[test] +#[ignore = "requires GPU; run with --features wgpu-tests -- --ignored"] +fn cropped_node_renders_only_cropped_region() { + let (device, queue) = headless_gpu(); + let mut decoder = decoder_with_color([0, 180, 0, 255], 8); + let mut renderer = Renderer::new(&mut decoder, &device, &queue); + let source = renderer + .register_source(&Source::Bytes(Arc::from([0u8; 0]))) + .unwrap(); + let stream = decoder.streams(source).unwrap()[0].id; + + let plan = RenderPlan { + output: output_8x8(), + nodes: vec![Node { + id: NodeId::new(1), + source: SourceRef { + source, + stream, + time: RationalTime::new(0, 1).unwrap(), + }, + crop: Crop { + x: 4, + y: 4, + w: 4, + h: 4, + }, + transform: fill_affine(4, 4, 8, 8), + opacity: Opacity::new(1.0).unwrap(), + blend: Blend::SourceOver, + }], + }; + + let out = renderer.render(&plan).unwrap(); + let pixels = readback_rgba8(&out.texture, &device, &queue, 8, 8).unwrap(); + + let corner = pixels[0..4].try_into().unwrap(); + let center = center_pixel(&pixels, 8, 8); + assert_eq!(corner, [0, 0, 0, 0]); + assert_eq!(center, [0, 180, 0, 255]); +} + +#[test] +#[ignore = "requires GPU; run with --features wgpu-tests -- --ignored"] +fn affine_transform_maps_source_to_expected_output_rectangle() { + let (device, queue) = headless_gpu(); + let mut decoder = decoder_with_color([10, 20, 30, 255], 8); + let mut renderer = Renderer::new(&mut decoder, &device, &queue); + let source = renderer + .register_source(&Source::Bytes(Arc::from([0u8; 0]))) + .unwrap(); + let stream = decoder.streams(source).unwrap()[0].id; + + let plan = RenderPlan { + output: output_8x8(), + nodes: vec![Node { + id: NodeId::new(1), + source: SourceRef { + source, + stream, + time: RationalTime::new(0, 1).unwrap(), + }, + crop: Crop { + x: 0, + y: 0, + w: 8, + h: 8, + }, + transform: Affine { + m: [0.5, 0.0, 0.0, 0.5, 2.0, 2.0], + }, + opacity: Opacity::new(1.0).unwrap(), + blend: Blend::SourceOver, + }], + }; + + let out = renderer.render(&plan).unwrap(); + let pixels = readback_rgba8(&out.texture, &device, &queue, 8, 8).unwrap(); + + let inside = center_pixel(&pixels, 8, 8); + let outside_idx = (0 * 8 + 0) * 4; + assert_eq!(inside, [10, 20, 30, 255]); + assert_eq!(pixels[outside_idx..outside_idx + 4], [0, 0, 0, 0]); +} diff --git a/crates/render/tests/plan.rs b/crates/render/tests/plan.rs new file mode 100644 index 00000000..dd0b80a5 --- /dev/null +++ b/crates/render/tests/plan.rs @@ -0,0 +1,208 @@ +use std::sync::Arc; + +use decode::{ + DecodeError, Decoder, FakeDecoder, FakeDecoderConfig, PixelFormat, Source, SourceId, + SourceStreamId, +}; +use render::{ + Affine, Blend, Crop, Node, NodeId, Opacity, Output, OutputFormat, RenderError, RenderPlan, + Renderer, SourceRef, +}; +use time::{FrameRate, RationalTime}; + +fn test_decoder(color: [u8; 4]) -> FakeDecoder { + FakeDecoder::new(FakeDecoderConfig { + frame_rate: FrameRate::new(30, 1).unwrap(), + width: 4, + height: 4, + duration: RationalTime::new(10, 1).unwrap(), + format: PixelFormat::Rgba8, + color, + }) +} + +fn test_output() -> Output { + Output { + width: 4, + height: 4, + format: OutputFormat::Rgba8Premultiplied, + } +} + +fn scale_to_output_affine(crop_w: u32, crop_h: u32, out_w: u32, out_h: u32) -> Affine { + Affine { + m: [ + out_w as f32 / crop_w as f32, + 0.0, + 0.0, + out_h as f32 / crop_h as f32, + 0.0, + 0.0, + ], + } +} + +struct HeadlessGpu { + device: wgpu::Device, + queue: wgpu::Queue, +} + +fn headless_gpu() -> HeadlessGpu { + pollster::block_on(async { + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN | wgpu::Backends::GL, + ..Default::default() + }); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + force_fallback_adapter: true, + compatible_surface: None, + }) + .await + .expect("adapter"); + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("test_device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_defaults(), + memory_hints: wgpu::MemoryHints::Performance, + trace: wgpu::Trace::Off, + }) + .await + .expect("device"); + HeadlessGpu { device, queue } + }) +} + +fn node(id: u64, source: SourceId, stream: SourceStreamId, color_time: RationalTime) -> Node { + Node { + id: NodeId::new(id), + source: SourceRef { + source, + stream, + time: color_time, + }, + crop: Crop { + x: 0, + y: 0, + w: 4, + h: 4, + }, + transform: scale_to_output_affine(4, 4, 4, 4), + opacity: Opacity::new(1.0).unwrap(), + blend: Blend::SourceOver, + } +} + +#[test] +fn unregistered_source_fails_unknown_source() { + let gpu = headless_gpu(); + let mut decoder = test_decoder([255, 0, 0, 255]); + let mut renderer = Renderer::new(&mut decoder, &gpu.device, &gpu.queue); + + let plan = RenderPlan { + output: test_output(), + nodes: vec![Node { + id: NodeId::new(1), + source: SourceRef { + source: SourceId::new(99), + stream: SourceStreamId::new(1), + time: RationalTime::new(0, 1).unwrap(), + }, + crop: Crop { + x: 0, + y: 0, + w: 4, + h: 4, + }, + transform: scale_to_output_affine(4, 4, 4, 4), + opacity: Opacity::new(1.0).unwrap(), + blend: Blend::SourceOver, + }], + }; + + assert!(matches!( + renderer.render(&plan), + Err(RenderError::UnknownSource) + )); +} + +#[test] +fn registered_source_resolves_with_floored_frame_time() { + let gpu = headless_gpu(); + let mut decoder = test_decoder([255, 0, 0, 255]); + let mut renderer = Renderer::new(&mut decoder, &gpu.device, &gpu.queue); + let source = renderer + .register_source(&Source::Bytes(Arc::from([0u8; 0]))) + .unwrap(); + let stream = SourceStreamId::new(1); + + let requested = RationalTime::new(1, 30).unwrap(); + let plan = RenderPlan { + output: test_output(), + nodes: vec![node(1, source, stream, requested)], + }; + + renderer.render(&plan).unwrap(); + drop(renderer); + + let frame = decoder.read_frame(source, stream, requested).unwrap(); + assert_eq!(frame.time, RationalTime::new(1, 30).unwrap()); +} + +#[test] +fn release_source_invalidates_cache_and_fails_unknown_source() { + let gpu = headless_gpu(); + let mut decoder = test_decoder([255, 0, 0, 255]); + let mut renderer = Renderer::new(&mut decoder, &gpu.device, &gpu.queue); + let source = renderer + .register_source(&Source::Bytes(Arc::from([0u8; 0]))) + .unwrap(); + let stream = SourceStreamId::new(1); + + let plan = RenderPlan { + output: test_output(), + nodes: vec![node(1, source, stream, RationalTime::new(0, 1).unwrap())], + }; + renderer.render(&plan).unwrap(); + renderer.release_source(source).unwrap(); + + assert!(matches!( + renderer.render(&plan), + Err(RenderError::UnknownSource) + )); +} + +#[test] +fn end_of_stream_propagates_as_decode_error() { + let gpu = headless_gpu(); + let mut decoder = FakeDecoder::new(FakeDecoderConfig { + frame_rate: FrameRate::new(30, 1).unwrap(), + width: 4, + height: 4, + duration: RationalTime::new(1, 30).unwrap(), + format: PixelFormat::Rgba8, + color: [255, 0, 0, 255], + }); + let mut renderer = Renderer::new(&mut decoder, &gpu.device, &gpu.queue); + let source = renderer + .register_source(&Source::Bytes(Arc::from([0u8; 0]))) + .unwrap(); + let stream = SourceStreamId::new(1); + + let plan = RenderPlan { + output: test_output(), + nodes: vec![node( + 1, + source, + stream, + RationalTime::new(1, 1).unwrap(), + )], + }; + + assert!(matches!( + renderer.render(&plan), + Err(RenderError::Decode(DecodeError::EndOfStream)) + )); +} diff --git a/crates/time/Cargo.toml b/crates/time/Cargo.toml new file mode 100644 index 00000000..f78ec862 --- /dev/null +++ b/crates/time/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "time" +version.workspace = true +edition.workspace = true +license.workspace = true + +[features] +default = [] +floats = [] + +[dependencies] +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +serde_json = "1" + +[[test]] +name = "floats" +required-features = ["floats"] diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs new file mode 100644 index 00000000..209265c0 --- /dev/null +++ b/crates/time/src/lib.rs @@ -0,0 +1,348 @@ +//! Exact rational time and frame rate primitives. +//! +//! ## Serialization stability +//! +//! `RationalTime` and `FrameRate` serialize as objects with fixed field names: +//! - `RationalTime`: `{ "n": i64, "d": u64 }` +//! - `FrameRate`: `{ "n": u64, "d": u64 }` +//! +//! Field names and types are additive-only; renames or removals are breaking. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Exact rational time as a reduced fraction. +/// +/// Invariant: `d > 0`, `gcd(|n|, d) == 1`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct RationalTime { + n: i64, + d: u64, +} + +/// Frames per second as an exact positive rational (e.g. 29.97 = 30000/1001). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct FrameRate { + n: u64, + d: u64, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum TimeError { + #[error("denominator must be non-zero")] + ZeroDenominator, + #[error("arithmetic overflow")] + Overflow, +} + +impl RationalTime { + pub fn new(n: i64, d: u64) -> Result { + if d == 0 { + return Err(TimeError::ZeroDenominator); + } + Ok(Self::from_parts_unchecked(n, d)) + } + + pub fn numer(&self) -> i64 { + self.n + } + + pub fn denom(&self) -> u64 { + self.d + } + + pub fn is_zero(&self) -> bool { + self.n == 0 + } + + pub fn add(self, other: Self) -> Result { + let lcm = lcm_u64(self.d, other.d).ok_or(TimeError::Overflow)?; + let left = (self.n as i128) + .checked_mul((lcm / self.d) as i128) + .ok_or(TimeError::Overflow)?; + let right = (other.n as i128) + .checked_mul((lcm / other.d) as i128) + .ok_or(TimeError::Overflow)?; + let sum = left.checked_add(right).ok_or(TimeError::Overflow)?; + Self::from_i128(sum, lcm as u128) + } + + pub fn sub(self, other: Self) -> Result { + let lcm = lcm_u64(self.d, other.d).ok_or(TimeError::Overflow)?; + let left = (self.n as i128) + .checked_mul((lcm / self.d) as i128) + .ok_or(TimeError::Overflow)?; + let right = (other.n as i128) + .checked_mul((lcm / other.d) as i128) + .ok_or(TimeError::Overflow)?; + let diff = left.checked_sub(right).ok_or(TimeError::Overflow)?; + Self::from_i128(diff, lcm as u128) + } + + pub fn mul(self, scalar: i64) -> Result { + if scalar == 0 || self.n == 0 { + return Ok(Self { n: 0, d: 1 }); + } + let n = (self.n as i128) + .checked_mul(scalar as i128) + .ok_or(TimeError::Overflow)?; + Self::from_i128(n, self.d as u128) + } + + pub fn cmp(&self, other: &Self) -> std::cmp::Ordering { + (self.n as i128 * other.d as i128).cmp(&(other.n as i128 * self.d as i128)) + } + + pub fn ge(&self, other: &Self) -> bool { + self.cmp(other) != std::cmp::Ordering::Less + } + + pub fn lt(&self, other: &Self) -> bool { + self.cmp(other) == std::cmp::Ordering::Less + } + + fn from_parts_unchecked(n: i64, d: u64) -> Self { + if n == 0 { + return Self { n: 0, d: 1 }; + } + + let g = gcd_u64(n.unsigned_abs(), d); + let n = n / g as i64; + let d = d / g; + + // Keep sign on the numerator; denominator stays positive. + if n == 0 { + Self { n: 0, d: 1 } + } else { + Self { n, d } + } + } + + fn from_i128(n: i128, d: u128) -> Result { + if d == 0 { + return Err(TimeError::ZeroDenominator); + } + if n == 0 { + return Ok(Self { n: 0, d: 1 }); + } + + let g = gcd_u128(n.unsigned_abs(), d); + let n = n / g as i128; + let d = (d / g) as u64; + + if n < i64::MIN as i128 || n > i64::MAX as i128 { + return Err(TimeError::Overflow); + } + if d == 0 { + return Err(TimeError::Overflow); + } + + Ok(Self::from_parts_unchecked(n as i64, d)) + } +} + +impl FrameRate { + pub fn new(n: u64, d: u64) -> Result { + if n == 0 || d == 0 { + return Err(TimeError::ZeroDenominator); + } + Ok(Self { n, d }) + } + + pub fn numer(&self) -> u64 { + self.n + } + + pub fn denom(&self) -> u64 { + self.d + } + + /// Duration of one frame as an exact rational time. + pub fn period(&self) -> RationalTime { + RationalTime::from_parts_unchecked(self.d as i64, self.n) + } + + pub fn frame_index_floor(&self, time: RationalTime) -> Result { + frame_index_at(time, self.n, self.d, FrameSnap::Floor) + } + + pub fn frame_index_ceil(&self, time: RationalTime) -> Result { + frame_index_at(time, self.n, self.d, FrameSnap::Ceil) + } + + /// Start time of `index` under this frame rate. + pub fn frame_start(&self, index: i64) -> Result { + self.period().mul(index) + } +} + +#[cfg(feature = "floats")] +pub mod floats { + use super::{FrameRate, RationalTime, TimeError}; + + impl RationalTime { + pub fn from_seconds_floor(seconds: f64, max_denominator: u64) -> Result { + from_seconds(seconds, max_denominator, SecondsSnap::Floor) + } + + pub fn from_seconds_ceil(seconds: f64, max_denominator: u64) -> Result { + from_seconds(seconds, max_denominator, SecondsSnap::Ceil) + } + + pub fn from_seconds_round(seconds: f64, max_denominator: u64) -> Result { + from_seconds(seconds, max_denominator, SecondsSnap::Round) + } + + pub fn to_seconds(self) -> f64 { + self.n as f64 / self.d as f64 + } + } + + #[allow(dead_code)] + impl FrameRate { + pub fn from_fps_float(fps: f64, max_denominator: u64) -> Result { + let seconds = RationalTime::from_seconds_round(1.0 / fps, max_denominator)?; + Ok(Self { + n: seconds.denom(), + d: seconds.numer().unsigned_abs(), + }) + } + } + + enum SecondsSnap { + Floor, + Ceil, + Round, + } + + fn from_seconds(seconds: f64, max_denominator: u64, snap: SecondsSnap) -> Result { + if !seconds.is_finite() { + return Err(TimeError::Overflow); + } + if max_denominator == 0 { + return Err(TimeError::ZeroDenominator); + } + + let scaled = match snap { + SecondsSnap::Floor => (seconds * max_denominator as f64).floor(), + SecondsSnap::Ceil => (seconds * max_denominator as f64).ceil(), + SecondsSnap::Round => (seconds * max_denominator as f64).round(), + }; + + if !scaled.is_finite() { + return Err(TimeError::Overflow); + } + + RationalTime::new(scaled as i64, max_denominator) + } +} + +#[derive(Copy, Clone)] +enum FrameSnap { + Floor, + Ceil, +} + +fn frame_index_at(time: RationalTime, rate_n: u64, rate_d: u64, snap: FrameSnap) -> Result { + if time.n == 0 { + return Ok(0); + } + + let numerator = (time.n as i128) + .checked_mul(rate_n as i128) + .ok_or(TimeError::Overflow)?; + let denominator = (time.d as i128) + .checked_mul(rate_d as i128) + .ok_or(TimeError::Overflow)?; + if denominator == 0 { + return Err(TimeError::ZeroDenominator); + } + + let (mut q, r) = (numerator / denominator, numerator % denominator); + + match snap { + FrameSnap::Floor => { + if r != 0 && numerator < 0 { + q -= 1; + } + } + FrameSnap::Ceil => { + if r != 0 && numerator > 0 { + q += 1; + } + } + } + + if q < i64::MIN as i128 || q > i64::MAX as i128 { + return Err(TimeError::Overflow); + } + Ok(q as i64) +} + +fn gcd_u64(mut a: u64, mut b: u64) -> u64 { + while b != 0 { + a %= b; + std::mem::swap(&mut a, &mut b); + } + a +} + +fn gcd_u128(mut a: u128, mut b: u128) -> u128 { + while b != 0 { + a %= b; + std::mem::swap(&mut a, &mut b); + } + a +} + +fn lcm_u64(a: u64, b: u64) -> Option { + let g = gcd_u64(a, b); + a.checked_div(g)?.checked_mul(b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reduction_is_canonical() { + let a = RationalTime::new(2, 4).unwrap(); + let b = RationalTime::new(1, 2).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn negative_numerator_reduces() { + let a = RationalTime::new(-4, 8).unwrap(); + let b = RationalTime::new(-1, 2).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn zero_denominator_rejected() { + assert_eq!(RationalTime::new(1, 0), Err(TimeError::ZeroDenominator)); + } + + #[test] + fn add_sub_mul_exactness() { + let a = RationalTime::new(1, 3).unwrap(); + let b = RationalTime::new(1, 6).unwrap(); + assert_eq!(a.add(b).unwrap(), RationalTime::new(1, 2).unwrap()); + assert_eq!(a.sub(b).unwrap(), RationalTime::new(1, 6).unwrap()); + assert_eq!(a.mul(2).unwrap(), RationalTime::new(2, 3).unwrap()); + } + + #[test] + fn overflow_surfaces() { + let large = RationalTime::new(i64::MAX, 1).unwrap(); + assert_eq!(large.add(large), Err(TimeError::Overflow)); + } + + #[test] + fn frame_index_floor_matches_media_semantics() { + let fps = FrameRate::new(30, 1).unwrap(); + let t = RationalTime::new(1, 30).unwrap(); + assert_eq!(fps.frame_index_floor(t).unwrap(), 1); + assert_eq!(fps.frame_index_floor(RationalTime::new(0, 1).unwrap()).unwrap(), 0); + } +} diff --git a/crates/time/tests/floats.rs b/crates/time/tests/floats.rs new file mode 100644 index 00000000..fba0d539 --- /dev/null +++ b/crates/time/tests/floats.rs @@ -0,0 +1,37 @@ +use time::RationalTime; + +#[test] +fn ntsc_period_from_seconds_exact() { + let t = RationalTime::from_seconds_round(1001.0 / 30000.0, 30000).unwrap(); + assert_eq!(t, RationalTime::new(1001, 30000).unwrap()); +} + +#[test] +fn film_period_from_seconds_exact() { + let t = RationalTime::from_seconds_round(1001.0 / 24000.0, 24000).unwrap(); + assert_eq!(t, RationalTime::new(1001, 24000).unwrap()); +} + +#[test] +fn to_seconds_within_tolerance() { + let t = RationalTime::new(30000, 1001).unwrap(); + let seconds = t.to_seconds(); + assert!((seconds - 29.97002997002997).abs() < 1e-12); +} + +#[test] +fn from_seconds_floor_ceil_round() { + let max_d = 1000; + assert_eq!( + RationalTime::from_seconds_floor(0.3334, max_d).unwrap(), + RationalTime::new(333, 1000).unwrap() + ); + assert_eq!( + RationalTime::from_seconds_ceil(0.3334, max_d).unwrap(), + RationalTime::new(334, 1000).unwrap() + ); + assert_eq!( + RationalTime::from_seconds_round(0.3335, max_d).unwrap(), + RationalTime::new(334, 1000).unwrap() + ); +} diff --git a/crates/time/tests/rational_time.rs b/crates/time/tests/rational_time.rs new file mode 100644 index 00000000..69c42dc4 --- /dev/null +++ b/crates/time/tests/rational_time.rs @@ -0,0 +1,66 @@ +use time::{FrameRate, RationalTime, TimeError}; + +#[test] +fn reduction_to_canonical_form() { + assert_eq!( + RationalTime::new(2, 4).unwrap(), + RationalTime::new(1, 2).unwrap() + ); +} + +#[test] +fn negative_denominator_normalization() { + assert_eq!( + RationalTime::new(-2, 4).unwrap(), + RationalTime::new(-1, 2).unwrap() + ); +} + +#[test] +fn zero_denominator_rejected() { + assert_eq!(RationalTime::new(1, 0), Err(TimeError::ZeroDenominator)); +} + +#[test] +fn ntsc_frame_rate_round_trips_exactly() { + let fps = FrameRate::new(30_000, 1001).unwrap(); + assert_eq!(fps.numer(), 30_000); + assert_eq!(fps.denom(), 1001); +} + +#[test] +fn film_frame_rate_round_trips_exactly() { + let fps = FrameRate::new(24000, 1001).unwrap(); + assert_eq!(fps.numer(), 24000); + assert_eq!(fps.denom(), 1001); +} + +#[test] +fn add_sub_mul_exactness_and_overflow() { + let a = RationalTime::new(1, 3).unwrap(); + let b = RationalTime::new(1, 6).unwrap(); + assert_eq!(a.add(b).unwrap(), RationalTime::new(1, 2).unwrap()); + assert_eq!(a.sub(b).unwrap(), RationalTime::new(1, 6).unwrap()); + assert_eq!(a.mul(2).unwrap(), RationalTime::new(2, 3).unwrap()); + + let large = RationalTime::new(i64::MAX, 1).unwrap(); + assert_eq!(large.add(large), Err(TimeError::Overflow)); +} + +#[test] +fn frame_index_floor_for_known_fps() { + let fps = FrameRate::new(24, 1).unwrap(); + let half_second = RationalTime::new(1, 2).unwrap(); + assert_eq!(fps.frame_index_floor(half_second).unwrap(), 12); + + let fps2997 = FrameRate::new(30000, 1001).unwrap(); + let one_second = RationalTime::new(1001, 1000).unwrap(); + assert_eq!(fps2997.frame_index_floor(one_second).unwrap(), 30); +} + +#[test] +fn serialization_stability_fixture() { + let json = r#"{"n":30000,"d":1001}"#; + let t: RationalTime = serde_json::from_str(json).unwrap(); + assert_eq!(t, RationalTime::new(30000, 1001).unwrap()); +}