use std::collections::BTreeMap;
use oxideav_core::{Rational, TimeBase};
use crate::audio::AudioCue;
use crate::duration::{SceneDuration, TimeStamp};
use crate::object::{Canvas, SceneObject};
use crate::page::Page;
#[derive(Clone, Debug)]
pub struct Scene {
pub canvas: Canvas,
pub duration: SceneDuration,
pub time_base: TimeBase,
pub framerate: Rational,
pub sample_rate: u32,
pub background: Background,
pub objects: Vec<SceneObject>,
pub audio: Vec<AudioCue>,
pub metadata: Metadata,
pub pages: Option<Vec<Page>>,
}
impl Default for Scene {
fn default() -> Self {
Scene {
canvas: Canvas::raster(1920, 1080),
duration: SceneDuration::Finite(0),
time_base: TimeBase::new(1, 1_000),
framerate: Rational::new(30, 1),
sample_rate: 48_000,
background: Background::default(),
objects: Vec::new(),
audio: Vec::new(),
metadata: Metadata::default(),
pages: None,
}
}
}
impl Scene {
pub fn sort_by_z_order(&mut self) {
self.objects.sort_by_key(|o| o.z_order);
}
pub fn frame_to_timestamp(&self, frame_index: u64) -> TimeStamp {
let tb = self.time_base.0;
let num = frame_index as i128 * self.framerate.den as i128 * tb.den as i128;
let den = self.framerate.num as i128 * tb.num as i128;
if den == 0 {
0
} else {
(num / den) as TimeStamp
}
}
pub fn frame_count(&self) -> Option<u64> {
let end = self.duration.end()?;
let tb = self.time_base.0;
if tb.num == 0 || self.framerate.den == 0 {
return Some(0);
}
let num = (end as i128) * self.framerate.num as i128 * tb.num as i128;
let den = self.framerate.den as i128 * tb.den as i128;
if den == 0 {
Some(0)
} else {
Some((num / den).max(0) as u64)
}
}
pub fn visible_at(&self, t: crate::duration::TimeStamp) -> Vec<&SceneObject> {
let mut refs: Vec<&SceneObject> = self
.objects
.iter()
.filter(|o| o.lifetime.is_live_at(t))
.collect();
refs.sort_by_key(|o| o.z_order);
refs
}
pub fn is_paged(&self) -> bool {
self.pages.as_ref().is_some_and(|p| !p.is_empty())
}
pub fn pages_to_timeline(&self, per_page_duration_ms: u64) -> Vec<(usize, TimeStamp)> {
let Some(ref pages) = self.pages else {
return Vec::new();
};
let tb = self.time_base.0;
let num = (per_page_duration_ms as i128) * (tb.den as i128);
let den = (tb.num as i128) * 1000;
let ticks_per_page: TimeStamp = if den == 0 {
0
} else {
(num / den) as TimeStamp
};
let mut out = Vec::with_capacity(pages.len());
let mut t: TimeStamp = 0;
for (i, _) in pages.iter().enumerate() {
out.push((i, t));
t = t.saturating_add(ticks_per_page);
}
out
}
pub fn timeline_to_pages(&self, at_pts: &[TimeStamp]) -> Vec<TimeStamp> {
at_pts
.iter()
.copied()
.filter(|&t| self.duration.contains(t))
.collect()
}
}
#[non_exhaustive]
#[derive(Clone, Debug)]
pub enum Background {
Transparent,
Solid(u32),
LinearGradient {
from: u32,
to: u32,
angle_deg: f32,
},
Image(String),
}
impl Default for Background {
fn default() -> Self {
Background::Solid(0x000000FF)
}
}
#[derive(Clone, Debug, Default)]
pub struct Metadata {
pub title: Option<String>,
pub author: Option<String>,
pub subject: Option<String>,
pub keywords: Vec<String>,
pub creator: Option<String>,
pub producer: Option<String>,
pub created_at: Option<String>,
pub modified_at: Option<String>,
pub custom: BTreeMap<String, String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{animation::Animation, duration::Lifetime, id::ObjectId, object::SceneObject};
#[test]
fn visible_at_respects_lifetime() {
let mut scene = Scene {
duration: SceneDuration::Finite(1000),
..Scene::default()
};
scene.objects.push(SceneObject {
id: ObjectId::new(1),
lifetime: Lifetime {
start: 100,
end: Some(200),
},
..SceneObject::default()
});
scene.objects.push(SceneObject {
id: ObjectId::new(2),
lifetime: Lifetime::default(),
..SceneObject::default()
});
let v = scene.visible_at(50);
assert_eq!(v.len(), 1);
assert_eq!(v[0].id, ObjectId::new(2));
let v = scene.visible_at(150);
assert_eq!(v.len(), 2);
}
#[test]
fn frame_to_timestamp_30fps_1ms_tb() {
let scene = Scene::default(); assert_eq!(scene.frame_to_timestamp(0), 0);
assert_eq!(scene.frame_to_timestamp(1), 33);
assert_eq!(scene.frame_to_timestamp(30), 1000);
}
#[test]
fn frame_to_timestamp_23_976_fps() {
let scene = Scene {
time_base: TimeBase::new(1, 90_000),
framerate: Rational::new(24_000, 1001),
..Scene::default()
};
assert_eq!(scene.frame_to_timestamp(24_000), 90_090_000);
}
#[test]
fn frame_count_finite() {
let scene = Scene {
duration: SceneDuration::Finite(1000), ..Scene::default()
};
assert_eq!(scene.frame_count(), Some(30));
}
#[test]
fn frame_count_indefinite_is_none() {
let scene = Scene {
duration: SceneDuration::Indefinite,
..Scene::default()
};
assert_eq!(scene.frame_count(), None);
}
#[test]
fn default_scene_is_timeline_mode() {
let s = Scene::default();
assert!(!s.is_paged());
assert!(s.pages.is_none());
}
#[test]
fn scene_in_pages_mode_reports_paged() {
let s = Scene {
pages: Some(vec![Page::new(595.0, 842.0), Page::new(842.0, 595.0)]),
..Scene::default()
};
assert!(s.is_paged());
assert_eq!(s.pages.as_ref().unwrap().len(), 2);
}
#[test]
fn empty_pages_vec_is_not_paged() {
let s = Scene {
pages: Some(Vec::new()),
..Scene::default()
};
assert!(!s.is_paged());
}
#[test]
fn pages_to_timeline_advances_per_page() {
let s = Scene {
pages: Some(vec![
Page::new(595.0, 842.0),
Page::new(595.0, 842.0),
Page::new(595.0, 842.0),
]),
..Scene::default()
};
let tl = s.pages_to_timeline(100);
assert_eq!(tl, vec![(0, 0), (1, 100), (2, 200)]);
}
#[test]
fn pages_to_timeline_empty_for_timeline_scene() {
let s = Scene::default();
assert!(s.pages_to_timeline(100).is_empty());
}
#[test]
fn pages_to_timeline_scales_for_90khz_tb() {
let s = Scene {
time_base: TimeBase::new(1, 90_000),
pages: Some(vec![Page::new(100.0, 100.0); 2]),
..Scene::default()
};
let tl = s.pages_to_timeline(100);
assert_eq!(tl, vec![(0, 0), (1, 9_000)]);
}
#[test]
fn timeline_to_pages_filters_out_of_range() {
let s = Scene {
duration: SceneDuration::Finite(1000),
..Scene::default()
};
let pts = vec![-1, 0, 500, 999, 1000, 5000];
let kept = s.timeline_to_pages(&pts);
assert_eq!(kept, vec![0, 500, 999]);
}
#[test]
fn timeline_to_pages_indefinite_keeps_nonneg() {
let s = Scene {
duration: SceneDuration::Indefinite,
..Scene::default()
};
let pts = vec![-1, 0, i64::MAX];
let kept = s.timeline_to_pages(&pts);
assert_eq!(kept, vec![0, i64::MAX]);
}
#[test]
fn metadata_default_is_empty() {
let m = Metadata::default();
assert!(m.title.is_none());
assert!(m.creator.is_none());
assert!(m.producer.is_none());
assert!(m.created_at.is_none());
assert!(m.modified_at.is_none());
assert!(m.custom.is_empty());
}
#[test]
fn metadata_custom_carries_extras() {
let mut m = Metadata {
creator: Some("MyDrawingApp 4.2".into()),
producer: Some("oxideav-pdf 0.1".into()),
modified_at: Some("2026-05-04T12:00:00Z".into()),
..Metadata::default()
};
m.custom
.insert("dc:rights".into(), "(c) 2026 Karpeles Lab Inc.".into());
m.custom.insert("Trapped".into(), "False".into());
assert_eq!(m.creator.as_deref(), Some("MyDrawingApp 4.2"));
assert_eq!(m.producer.as_deref(), Some("oxideav-pdf 0.1"));
assert_eq!(m.modified_at.as_deref(), Some("2026-05-04T12:00:00Z"));
assert_eq!(m.custom.get("Trapped").map(String::as_str), Some("False"));
assert_eq!(m.custom.len(), 2);
}
#[test]
fn sort_by_z_order_stable_ties() {
let mut scene = Scene::default();
scene.objects.push(SceneObject {
id: ObjectId::new(1),
z_order: 5,
animations: vec![Animation::new(
crate::animation::AnimatedProperty::Opacity,
Vec::new(),
crate::animation::Easing::Linear,
crate::animation::Repeat::Once,
)],
..SceneObject::default()
});
scene.objects.push(SceneObject {
id: ObjectId::new(2),
z_order: 5,
..SceneObject::default()
});
scene.objects.push(SceneObject {
id: ObjectId::new(3),
z_order: 1,
..SceneObject::default()
});
scene.sort_by_z_order();
assert_eq!(scene.objects[0].id, ObjectId::new(3));
assert_eq!(scene.objects[1].id, ObjectId::new(1));
assert_eq!(scene.objects[2].id, ObjectId::new(2));
}
}