Skip to main content

oxideav_scene/
scene.rs

1//! Root scene type.
2
3use std::collections::BTreeMap;
4
5use oxideav_core::{Rational, TimeBase};
6
7use crate::audio::AudioCue;
8use crate::duration::{SceneDuration, TimeStamp};
9use crate::object::{Canvas, SceneObject};
10use crate::page::Page;
11
12/// Top-level scene — a canvas + a timeline of objects and audio cues.
13///
14/// A scene operates in one of two modes, mutually exclusive at
15/// render-dispatch time:
16///
17/// 1. **Timeline mode** (`pages == None`): the existing model.
18///    [`duration`](Self::duration) bounds the timeline, the renderer
19///    samples objects at [`framerate`](Self::framerate). Used by the
20///    streaming compositor (PNG / MP4 / RTMP), the NLE timeline, and
21///    every raster-target writer.
22/// 2. **Pages mode** (`pages == Some(_)`): the scene is a sequence
23///    of [`Page`]s. Each page is independently sized + carries its
24///    own [`oxideav_core::VectorFrame`]. Used by paged-content
25///    writers (PDF, multi-page TIFF, EPUB). [`duration`](Self::duration)
26///    + [`framerate`](Self::framerate) are ignored in this mode.
27///
28/// The two are NOT additive — a paged writer rejects a scene with
29/// `pages == None`, and a video writer rejects one with
30/// `pages == Some(_)`. Use [`Scene::pages_to_timeline`] /
31/// [`Scene::timeline_to_pages`] to bridge across the modes.
32#[derive(Clone, Debug)]
33pub struct Scene {
34    pub canvas: Canvas,
35    pub duration: SceneDuration,
36    /// Rational time base — all timestamps in the scene are integer
37    /// multiples of this. Matches `oxideav-core`'s `TimeBase`.
38    pub time_base: TimeBase,
39    /// Output framerate. Separate from `time_base`: `time_base` sets
40    /// the tick granularity of every scheduled event (keyframe,
41    /// lifetime, audio cue trigger); `framerate` sets the cadence at
42    /// which the renderer samples the scene and emits frames to a
43    /// sink. A scene at `time_base = 1/1000` (ms) and `framerate =
44    /// 30/1` renders at `t = 0, 33, 66, 100, …` ms. Videos included
45    /// as `ObjectKind::Video` are retimed by the renderer so their
46    /// per-frame PTS aligns with this cadence.
47    pub framerate: Rational,
48    /// Audio mix-bus sample rate.
49    pub sample_rate: u32,
50    pub background: Background,
51    /// Z-ordered object list. Objects are composited in `z_order`
52    /// ascending order; ties break by list position.
53    pub objects: Vec<SceneObject>,
54    pub audio: Vec<AudioCue>,
55    pub metadata: Metadata,
56    /// Paged-content sequence. `Some(...)` puts the scene into pages
57    /// mode (PDF / multi-page TIFF / EPUB writers); `None` keeps it
58    /// in timeline mode (the default). See [`Scene`] for the
59    /// dispatch contract.
60    pub pages: Option<Vec<Page>>,
61}
62
63impl Default for Scene {
64    fn default() -> Self {
65        Scene {
66            canvas: Canvas::raster(1920, 1080),
67            duration: SceneDuration::Finite(0),
68            time_base: TimeBase::new(1, 1_000),
69            framerate: Rational::new(30, 1),
70            sample_rate: 48_000,
71            background: Background::default(),
72            objects: Vec::new(),
73            audio: Vec::new(),
74            metadata: Metadata::default(),
75            pages: None,
76        }
77    }
78}
79
80impl Scene {
81    /// Sort the object list by z-order, preserving insertion order
82    /// within ties. Idempotent — call before rendering if the scene
83    /// was built incrementally.
84    pub fn sort_by_z_order(&mut self) {
85        self.objects.sort_by_key(|o| o.z_order);
86    }
87
88    /// Convert a 0-based frame index to a scene-time timestamp.
89    /// `frame_index / framerate = seconds`; multiplied by
90    /// `time_base.den / time_base.num` to get scene-time ticks.
91    /// Uses wide arithmetic to stay exact for the common rational
92    /// values (24000/1001, 30000/1001, 60/1, …).
93    pub fn frame_to_timestamp(&self, frame_index: u64) -> TimeStamp {
94        let tb = self.time_base.0;
95        let num = frame_index as i128 * self.framerate.den as i128 * tb.den as i128;
96        let den = self.framerate.num as i128 * tb.num as i128;
97        if den == 0 {
98            0
99        } else {
100            (num / den) as TimeStamp
101        }
102    }
103
104    /// Total frame count for finite scenes. `None` for
105    /// `SceneDuration::Indefinite`. Rounds down — a 1000 ms scene
106    /// at 30 fps yields 30 frames (0..=29), not 30.03.
107    pub fn frame_count(&self) -> Option<u64> {
108        let end = self.duration.end()?;
109        let tb = self.time_base.0;
110        if tb.num == 0 || self.framerate.den == 0 {
111            return Some(0);
112        }
113        let num = (end as i128) * self.framerate.num as i128 * tb.num as i128;
114        let den = self.framerate.den as i128 * tb.den as i128;
115        if den == 0 {
116            Some(0)
117        } else {
118            Some((num / den).max(0) as u64)
119        }
120    }
121
122    /// Return objects live at time `t`, in paint order (z-order
123    /// ascending).
124    pub fn visible_at(&self, t: crate::duration::TimeStamp) -> Vec<&SceneObject> {
125        let mut refs: Vec<&SceneObject> = self
126            .objects
127            .iter()
128            .filter(|o| o.lifetime.is_live_at(t))
129            .collect();
130        refs.sort_by_key(|o| o.z_order);
131        refs
132    }
133
134    /// Whether the scene is in pages mode. See the [`Scene`] doc
135    /// comment for the contract.
136    pub fn is_paged(&self) -> bool {
137        self.pages.as_ref().is_some_and(|p| !p.is_empty())
138    }
139
140    /// Adapt a paged scene to a timeline by allotting
141    /// `per_page_duration_ms` to each page sequentially. Returns a
142    /// list of `(page_index, lifetime)` tuples — one per page —
143    /// suitable for driving a video writer that needs a
144    /// monotonically-advancing PTS axis.
145    ///
146    /// The returned timestamps are in the scene's `time_base`
147    /// units. `per_page_duration_ms` is in milliseconds; the
148    /// converter scales it via `time_base` so callers don't need to
149    /// pre-convert.
150    ///
151    /// Returns an empty `Vec` when the scene is not in pages mode.
152    pub fn pages_to_timeline(&self, per_page_duration_ms: u64) -> Vec<(usize, TimeStamp)> {
153        let Some(ref pages) = self.pages else {
154            return Vec::new();
155        };
156        // Convert ms → time_base ticks. time_base is num/den
157        // seconds-per-tick; ticks per ms = den / (num * 1000).
158        let tb = self.time_base.0;
159        let num = (per_page_duration_ms as i128) * (tb.den as i128);
160        let den = (tb.num as i128) * 1000;
161        let ticks_per_page: TimeStamp = if den == 0 {
162            0
163        } else {
164            (num / den) as TimeStamp
165        };
166        let mut out = Vec::with_capacity(pages.len());
167        let mut t: TimeStamp = 0;
168        for (i, _) in pages.iter().enumerate() {
169            out.push((i, t));
170            t = t.saturating_add(ticks_per_page);
171        }
172        out
173    }
174
175    /// Adapt a timeline-mode scene to discrete page-out points.
176    /// Returns a `Vec<TimeStamp>` echoing `at_pts` filtered to
177    /// in-range timestamps; the consumer renders one page per
178    /// timestamp by sampling the scene at that PTS. Useful for
179    /// "PDF preview every N seconds" workflows.
180    ///
181    /// Out-of-range PTS values (negative, or past the scene's end
182    /// for finite scenes) are dropped — the renderer would refuse
183    /// them anyway. Returns the input unchanged for indefinite
184    /// scenes (modulo the `t >= 0` filter).
185    pub fn timeline_to_pages(&self, at_pts: &[TimeStamp]) -> Vec<TimeStamp> {
186        at_pts
187            .iter()
188            .copied()
189            .filter(|&t| self.duration.contains(t))
190            .collect()
191    }
192}
193
194/// What fills the canvas below the z-ordered object list.
195#[non_exhaustive]
196#[derive(Clone, Debug)]
197pub enum Background {
198    /// Fully transparent. Export paths emit RGBA output.
199    Transparent,
200    /// Solid colour, `0xRRGGBBAA`.
201    Solid(u32),
202    /// Vertical or horizontal gradient between two colours.
203    LinearGradient {
204        from: u32,
205        to: u32,
206        /// Direction in degrees clockwise from 12 o'clock (0° = top,
207        /// 90° = right, …).
208        angle_deg: f32,
209    },
210    /// Bitmap background — cover or contain fit is up to the
211    /// renderer's layout policy.
212    Image(String),
213}
214
215impl Default for Background {
216    fn default() -> Self {
217        Background::Solid(0x000000FF)
218    }
219}
220
221/// Scene-level metadata. Carried through to exports when the target
222/// format supports it (PDF document info dict, MP4 `meta` box, etc).
223///
224/// `producer` and `creator` are intentionally distinct, mirroring
225/// PDF's `/Info` dictionary:
226///
227/// - `creator` — the tool that authored the **source** content (e.g.
228///   the NLE, drawing app, or word processor the user worked in).
229/// - `producer` — the tool that wrote the **output** file (this
230///   crate / oxideav exporter).
231#[derive(Clone, Debug, Default)]
232pub struct Metadata {
233    pub title: Option<String>,
234    pub author: Option<String>,
235    pub subject: Option<String>,
236    pub keywords: Vec<String>,
237    /// Authoring application — the tool used to create the source
238    /// content. Distinct from [`producer`](Self::producer); PDF's
239    /// `/Info` dictionary has separate `/Creator` and `/Producer`
240    /// keys for the same reason.
241    pub creator: Option<String>,
242    /// Producing tool name — the writer that emitted the output
243    /// file.
244    pub producer: Option<String>,
245    /// ISO-8601 string; not parsed here so exporters can pass it
246    /// through unchanged.
247    pub created_at: Option<String>,
248    /// ISO-8601 modification timestamp. Mirrors `created_at`; PDF
249    /// `/Info` has both `/CreationDate` and `/ModDate`, mp4 `mvhd`
250    /// has both creation_time and modification_time, etc.
251    pub modified_at: Option<String>,
252    /// Extensible per-format extras. Lets callers carry metadata
253    /// the standard fields don't cover: PDF `/Info` custom keys,
254    /// Matroska `ContentTrack` tags, RDF properties, mp4 `udta`
255    /// items not covered by the standard fields, ID3 frames, and
256    /// so on. Keys are case-sensitive; uniqueness is the caller's
257    /// responsibility (the map enforces it).
258    pub custom: BTreeMap<String, String>,
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::{animation::Animation, duration::Lifetime, id::ObjectId, object::SceneObject};
265
266    #[test]
267    fn visible_at_respects_lifetime() {
268        let mut scene = Scene {
269            duration: SceneDuration::Finite(1000),
270            ..Scene::default()
271        };
272        scene.objects.push(SceneObject {
273            id: ObjectId::new(1),
274            lifetime: Lifetime {
275                start: 100,
276                end: Some(200),
277            },
278            ..SceneObject::default()
279        });
280        scene.objects.push(SceneObject {
281            id: ObjectId::new(2),
282            lifetime: Lifetime::default(),
283            ..SceneObject::default()
284        });
285        let v = scene.visible_at(50);
286        assert_eq!(v.len(), 1);
287        assert_eq!(v[0].id, ObjectId::new(2));
288
289        let v = scene.visible_at(150);
290        assert_eq!(v.len(), 2);
291    }
292
293    #[test]
294    fn frame_to_timestamp_30fps_1ms_tb() {
295        let scene = Scene::default(); // 1/1000 tb, 30/1 fps
296        assert_eq!(scene.frame_to_timestamp(0), 0);
297        // 1 frame at 30 fps = 33.333 ms → floor = 33
298        assert_eq!(scene.frame_to_timestamp(1), 33);
299        assert_eq!(scene.frame_to_timestamp(30), 1000);
300    }
301
302    #[test]
303    fn frame_to_timestamp_23_976_fps() {
304        let scene = Scene {
305            time_base: TimeBase::new(1, 90_000),
306            framerate: Rational::new(24_000, 1001),
307            ..Scene::default()
308        };
309        // Frame 24000 at 24000/1001 fps = 1001 seconds = 90090000 ticks.
310        assert_eq!(scene.frame_to_timestamp(24_000), 90_090_000);
311    }
312
313    #[test]
314    fn frame_count_finite() {
315        let scene = Scene {
316            duration: SceneDuration::Finite(1000), // 1 second at 1/1000 tb
317            ..Scene::default()
318        };
319        assert_eq!(scene.frame_count(), Some(30));
320    }
321
322    #[test]
323    fn frame_count_indefinite_is_none() {
324        let scene = Scene {
325            duration: SceneDuration::Indefinite,
326            ..Scene::default()
327        };
328        assert_eq!(scene.frame_count(), None);
329    }
330
331    #[test]
332    fn default_scene_is_timeline_mode() {
333        let s = Scene::default();
334        assert!(!s.is_paged());
335        assert!(s.pages.is_none());
336    }
337
338    #[test]
339    fn scene_in_pages_mode_reports_paged() {
340        let s = Scene {
341            pages: Some(vec![Page::new(595.0, 842.0), Page::new(842.0, 595.0)]),
342            ..Scene::default()
343        };
344        assert!(s.is_paged());
345        assert_eq!(s.pages.as_ref().unwrap().len(), 2);
346    }
347
348    #[test]
349    fn empty_pages_vec_is_not_paged() {
350        let s = Scene {
351            pages: Some(Vec::new()),
352            ..Scene::default()
353        };
354        assert!(!s.is_paged());
355    }
356
357    #[test]
358    fn pages_to_timeline_advances_per_page() {
359        // 3 pages, 100 ms each, 1/1000 tb → ticks = 100 each.
360        let s = Scene {
361            pages: Some(vec![
362                Page::new(595.0, 842.0),
363                Page::new(595.0, 842.0),
364                Page::new(595.0, 842.0),
365            ]),
366            ..Scene::default()
367        };
368        let tl = s.pages_to_timeline(100);
369        assert_eq!(tl, vec![(0, 0), (1, 100), (2, 200)]);
370    }
371
372    #[test]
373    fn pages_to_timeline_empty_for_timeline_scene() {
374        let s = Scene::default();
375        assert!(s.pages_to_timeline(100).is_empty());
376    }
377
378    #[test]
379    fn pages_to_timeline_scales_for_90khz_tb() {
380        // 1/90000 tb → 1 ms = 90 ticks; 100 ms = 9000 ticks.
381        let s = Scene {
382            time_base: TimeBase::new(1, 90_000),
383            pages: Some(vec![Page::new(100.0, 100.0); 2]),
384            ..Scene::default()
385        };
386        let tl = s.pages_to_timeline(100);
387        assert_eq!(tl, vec![(0, 0), (1, 9_000)]);
388    }
389
390    #[test]
391    fn timeline_to_pages_filters_out_of_range() {
392        let s = Scene {
393            duration: SceneDuration::Finite(1000),
394            ..Scene::default()
395        };
396        let pts = vec![-1, 0, 500, 999, 1000, 5000];
397        let kept = s.timeline_to_pages(&pts);
398        assert_eq!(kept, vec![0, 500, 999]);
399    }
400
401    #[test]
402    fn timeline_to_pages_indefinite_keeps_nonneg() {
403        let s = Scene {
404            duration: SceneDuration::Indefinite,
405            ..Scene::default()
406        };
407        let pts = vec![-1, 0, i64::MAX];
408        let kept = s.timeline_to_pages(&pts);
409        assert_eq!(kept, vec![0, i64::MAX]);
410    }
411
412    #[test]
413    fn metadata_default_is_empty() {
414        let m = Metadata::default();
415        assert!(m.title.is_none());
416        assert!(m.creator.is_none());
417        assert!(m.producer.is_none());
418        assert!(m.created_at.is_none());
419        assert!(m.modified_at.is_none());
420        assert!(m.custom.is_empty());
421    }
422
423    #[test]
424    fn metadata_custom_carries_extras() {
425        let mut m = Metadata {
426            creator: Some("MyDrawingApp 4.2".into()),
427            producer: Some("oxideav-pdf 0.1".into()),
428            modified_at: Some("2026-05-04T12:00:00Z".into()),
429            ..Metadata::default()
430        };
431        m.custom
432            .insert("dc:rights".into(), "(c) 2026 Karpeles Lab Inc.".into());
433        m.custom.insert("Trapped".into(), "False".into());
434        assert_eq!(m.creator.as_deref(), Some("MyDrawingApp 4.2"));
435        assert_eq!(m.producer.as_deref(), Some("oxideav-pdf 0.1"));
436        assert_eq!(m.modified_at.as_deref(), Some("2026-05-04T12:00:00Z"));
437        assert_eq!(m.custom.get("Trapped").map(String::as_str), Some("False"));
438        assert_eq!(m.custom.len(), 2);
439    }
440
441    #[test]
442    fn sort_by_z_order_stable_ties() {
443        let mut scene = Scene::default();
444        scene.objects.push(SceneObject {
445            id: ObjectId::new(1),
446            z_order: 5,
447            animations: vec![Animation::new(
448                crate::animation::AnimatedProperty::Opacity,
449                Vec::new(),
450                crate::animation::Easing::Linear,
451                crate::animation::Repeat::Once,
452            )],
453            ..SceneObject::default()
454        });
455        scene.objects.push(SceneObject {
456            id: ObjectId::new(2),
457            z_order: 5,
458            ..SceneObject::default()
459        });
460        scene.objects.push(SceneObject {
461            id: ObjectId::new(3),
462            z_order: 1,
463            ..SceneObject::default()
464        });
465        scene.sort_by_z_order();
466        assert_eq!(scene.objects[0].id, ObjectId::new(3));
467        assert_eq!(scene.objects[1].id, ObjectId::new(1));
468        assert_eq!(scene.objects[2].id, ObjectId::new(2));
469    }
470}