Add media foundation crates: time, decode, and render.

Introduce exact rational time, a portable decode boundary with a fake driver, and wgpu offscreen rendering so a future editor core can describe and execute visual composition without platform decode drivers yet.
This commit is contained in:
Maze Winther 2026-08-04 13:23:56 +02:00
parent ded03d609c
commit d5fe49ca8d
21 changed files with 2787 additions and 5 deletions

433
Cargo.lock generated
View File

@ -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"

View File

@ -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" }

17
crates/decode/Cargo.toml Normal file
View File

@ -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"] }

View File

@ -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<SourceId, DecodeError>;
fn streams(&self, source: SourceId) -> Result<Vec<SourceStream>, DecodeError>;
fn read_frame(
&mut self,
source: SourceId,
stream: SourceStreamId,
time: time::RationalTime,
) -> Result<Frame, DecodeError>;
fn close(&mut self, source: SourceId) -> Result<(), DecodeError>;
}

260
crates/decode/src/fake.rs Normal file
View File

@ -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<u64, OpenSource>,
}
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<SourceId, DecodeError> {
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<Vec<SourceStream>, DecodeError> {
Ok(vec![self.lookup(source)?.stream.clone()])
}
fn read_frame(
&mut self,
source: SourceId,
stream: SourceStreamId,
time: RationalTime,
) -> Result<Frame, DecodeError> {
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)
);
}
}

View File

@ -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
}
}

15
crates/decode/src/lib.rs Normal file
View File

@ -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};

View File

@ -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,
}

24
crates/render/Cargo.toml Normal file
View File

@ -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"]

View File

@ -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<u8>,
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<OutputTexture, RenderError> {
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<Affine, ()> {
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)
}

132
crates/render/src/lib.rs Normal file
View File

@ -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<SourceId, RenderError> {
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<OutputTexture, RenderError> {
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<decode::Frame, RenderError> {
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)
}
}

121
crates/render/src/pixels.rs Normal file
View File

@ -0,0 +1,121 @@
use decode::{Frame, PixelFormat};
use crate::plan::{GpuError, RenderError};
pub fn to_linear_premultiplied(frame: &Frame) -> Result<Vec<u8>, 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<Vec<u8>, 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)
}

119
crates/render/src/plan.rs Normal file
View File

@ -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<Node>,
}
#[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<Self, OpacityError> {
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,
}

View File

@ -0,0 +1,64 @@
use std::collections::HashMap;
use decode::{SourceId, SourceStream, SourceStreamId};
#[derive(Debug, Default)]
pub struct SourceRegistry {
sources: HashMap<SourceId, Vec<SourceStream>>,
}
impl SourceRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, id: SourceId, streams: Vec<SourceStream>) {
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);
}
}

View File

@ -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<SourceStream>) -> 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, &registry),
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, &registry),
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, &registry),
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());
}
}

View File

@ -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<u64, bool>,
}
impl decode::Decoder for TwoDecoder {
fn open(&mut self, source: &Source) -> Result<decode::SourceId, decode::DecodeError> {
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<Vec<decode::SourceStream>, 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<decode::Frame, decode::DecodeError> {
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]);
}

208
crates/render/tests/plan.rs Normal file
View File

@ -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))
));
}

20
crates/time/Cargo.toml Normal file
View File

@ -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"]

348
crates/time/src/lib.rs Normal file
View File

@ -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<Self, TimeError> {
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<Self, TimeError> {
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<Self, TimeError> {
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<Self, TimeError> {
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<Self, TimeError> {
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<Self, TimeError> {
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<i64, TimeError> {
frame_index_at(time, self.n, self.d, FrameSnap::Floor)
}
pub fn frame_index_ceil(&self, time: RationalTime) -> Result<i64, TimeError> {
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<RationalTime, TimeError> {
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<Self, TimeError> {
from_seconds(seconds, max_denominator, SecondsSnap::Floor)
}
pub fn from_seconds_ceil(seconds: f64, max_denominator: u64) -> Result<Self, TimeError> {
from_seconds(seconds, max_denominator, SecondsSnap::Ceil)
}
pub fn from_seconds_round(seconds: f64, max_denominator: u64) -> Result<Self, TimeError> {
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<Self, TimeError> {
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<RationalTime, TimeError> {
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<i64, TimeError> {
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<u64> {
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);
}
}

View File

@ -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()
);
}

View File

@ -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());
}