Skip to main content

oximedia_edit/
incremental_render.rs

1//! Incremental / dirty-region rendering for the timeline editor.
2//!
3//! Tracks which frame ranges are "dirty" (need re-rendering) and exposes an
4//! API to query, merge, and clear those regions.  Rendering itself is
5//! delegated to the caller via the returned dirty region list.
6
7use oximedia_core::Rational;
8
9use crate::error::EditResult;
10use crate::render::RenderConfig;
11use crate::timeline::Timeline;
12use std::sync::Arc;
13
14// ─────────────────────────────────────────────────────────────────────────────
15// DirtyRegion
16// ─────────────────────────────────────────────────────────────────────────────
17
18/// A contiguous range of frames that require re-rendering.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct DirtyRegion {
21    /// First dirty frame (inclusive).
22    pub start_frame: u64,
23    /// Last dirty frame (exclusive).
24    pub end_frame: u64,
25}
26
27impl DirtyRegion {
28    /// Create a new dirty region.
29    ///
30    /// If `end_frame <= start_frame` the region is normalised to a single-frame
31    /// region `[start_frame, start_frame + 1)`.
32    #[must_use]
33    pub fn new(start_frame: u64, end_frame: u64) -> Self {
34        let end_frame = end_frame.max(start_frame + 1);
35        Self {
36            start_frame,
37            end_frame,
38        }
39    }
40
41    /// Returns `true` when the two regions overlap or are adjacent.
42    #[must_use]
43    pub fn overlaps(&self, other: &DirtyRegion) -> bool {
44        // Adjacent regions (end == other.start) are also merged
45        self.start_frame <= other.end_frame && other.start_frame <= self.end_frame
46    }
47
48    /// Merge two regions into a bounding region covering both.
49    #[must_use]
50    pub fn merge(&self, other: &DirtyRegion) -> DirtyRegion {
51        DirtyRegion {
52            start_frame: self.start_frame.min(other.start_frame),
53            end_frame: self.end_frame.max(other.end_frame),
54        }
55    }
56
57    /// Number of frames covered by this region.
58    #[must_use]
59    pub fn frame_count(&self) -> u64 {
60        self.end_frame.saturating_sub(self.start_frame)
61    }
62
63    /// Returns `true` when `frame` falls inside this region.
64    #[must_use]
65    pub fn contains(&self, frame: u64) -> bool {
66        frame >= self.start_frame && frame < self.end_frame
67    }
68}
69
70// ─────────────────────────────────────────────────────────────────────────────
71// IncrementalRenderer
72// ─────────────────────────────────────────────────────────────────────────────
73
74/// Tracks dirty frame ranges and drives incremental re-renders.
75///
76/// Overlapping or adjacent dirty regions are automatically coalesced on every
77/// `mark_dirty` call to keep the list compact.
78pub struct IncrementalRenderer {
79    /// Active dirty regions (always sorted by `start_frame`, non-overlapping).
80    dirty_regions: Vec<DirtyRegion>,
81    /// Render configuration used when rendering is triggered.
82    pub config: RenderConfig,
83    /// Frame rate of the associated timeline.
84    pub frame_rate: Rational,
85}
86
87impl IncrementalRenderer {
88    /// Create a new incremental renderer.
89    #[must_use]
90    pub fn new(config: RenderConfig, frame_rate: Rational) -> Self {
91        Self {
92            dirty_regions: Vec::new(),
93            config,
94            frame_rate,
95        }
96    }
97
98    /// Mark the range `[start_frame, end_frame)` as dirty.
99    ///
100    /// The new region is inserted and then the list is coalesced so that it
101    /// always consists of non-overlapping, sorted regions.
102    pub fn mark_dirty(&mut self, start_frame: u64, end_frame: u64) {
103        self.dirty_regions
104            .push(DirtyRegion::new(start_frame, end_frame));
105        self.coalesce();
106    }
107
108    /// Mark every frame in `[0, total_frames)` as dirty.
109    pub fn mark_all_dirty(&mut self, total_frames: u64) {
110        if total_frames == 0 {
111            return;
112        }
113        self.dirty_regions = vec![DirtyRegion::new(0, total_frames)];
114    }
115
116    /// Returns `true` if `frame` falls inside any dirty region.
117    #[must_use]
118    pub fn is_dirty(&self, frame: u64) -> bool {
119        self.dirty_regions.iter().any(|r| r.contains(frame))
120    }
121
122    /// Returns a slice of the current dirty regions (sorted, non-overlapping).
123    #[must_use]
124    pub fn get_dirty_regions(&self) -> &[DirtyRegion] {
125        &self.dirty_regions
126    }
127
128    /// Total number of dirty frames across all regions.
129    #[must_use]
130    pub fn dirty_frame_count(&self) -> u64 {
131        self.dirty_regions
132            .iter()
133            .map(DirtyRegion::frame_count)
134            .sum()
135    }
136
137    /// Clear all dirty regions.
138    pub fn clear_dirty(&mut self) {
139        self.dirty_regions.clear();
140    }
141
142    /// Returns the number of dirty frames that need rendering, then clears
143    /// the dirty list (signalling that rendering has been requested).
144    ///
145    /// The caller is expected to iterate `get_dirty_regions` *before* calling
146    /// this method in order to render the correct frames.
147    ///
148    /// The `_timeline` parameter is accepted for API consistency and future use.
149    pub fn render_incremental(&mut self, _timeline: &Arc<Timeline>) -> EditResult<usize> {
150        let count = self.dirty_frame_count() as usize;
151        self.clear_dirty();
152        Ok(count)
153    }
154
155    // ── Internal helpers ──────────────────────────────────────────────────────
156
157    /// Sort and merge overlapping/adjacent dirty regions.
158    fn coalesce(&mut self) {
159        if self.dirty_regions.len() <= 1 {
160            return;
161        }
162
163        // Sort by start frame
164        self.dirty_regions.sort_by_key(|r| r.start_frame);
165
166        let mut merged: Vec<DirtyRegion> = Vec::with_capacity(self.dirty_regions.len());
167        for region in &self.dirty_regions {
168            if let Some(last) = merged.last_mut() {
169                if last.end_frame >= region.start_frame {
170                    // Overlapping or adjacent — extend the last region
171                    last.end_frame = last.end_frame.max(region.end_frame);
172                    continue;
173                }
174            }
175            merged.push(*region);
176        }
177
178        self.dirty_regions = merged;
179    }
180}
181
182// ─────────────────────────────────────────────────────────────────────────────
183// Tests
184// ─────────────────────────────────────────────────────────────────────────────
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use oximedia_core::Rational;
190
191    fn renderer() -> IncrementalRenderer {
192        IncrementalRenderer::new(RenderConfig::default(), Rational::new(30, 1))
193    }
194
195    #[test]
196    fn test_mark_dirty_and_is_dirty() {
197        let mut r = renderer();
198        assert!(!r.is_dirty(10));
199        r.mark_dirty(5, 20);
200        assert!(r.is_dirty(5));
201        assert!(r.is_dirty(10));
202        assert!(r.is_dirty(19));
203        assert!(!r.is_dirty(20));
204        assert!(!r.is_dirty(4));
205    }
206
207    #[test]
208    fn test_clear_dirty() {
209        let mut r = renderer();
210        r.mark_dirty(0, 100);
211        assert_eq!(r.dirty_frame_count(), 100);
212        r.clear_dirty();
213        assert_eq!(r.dirty_frame_count(), 0);
214        assert!(r.get_dirty_regions().is_empty());
215    }
216
217    #[test]
218    fn test_merge_overlapping_regions() {
219        let mut r = renderer();
220        r.mark_dirty(0, 50);
221        r.mark_dirty(30, 80);
222        // Should coalesce into [0, 80)
223        assert_eq!(r.get_dirty_regions().len(), 1);
224        assert_eq!(r.get_dirty_regions()[0].start_frame, 0);
225        assert_eq!(r.get_dirty_regions()[0].end_frame, 80);
226        assert_eq!(r.dirty_frame_count(), 80);
227    }
228
229    #[test]
230    fn test_merge_adjacent_regions() {
231        let mut r = renderer();
232        r.mark_dirty(0, 10);
233        r.mark_dirty(10, 20);
234        // Adjacent regions should merge
235        assert_eq!(r.get_dirty_regions().len(), 1);
236        assert_eq!(r.get_dirty_regions()[0].end_frame, 20);
237    }
238
239    #[test]
240    fn test_non_overlapping_regions_stay_separate() {
241        let mut r = renderer();
242        r.mark_dirty(0, 10);
243        r.mark_dirty(20, 30);
244        assert_eq!(r.get_dirty_regions().len(), 2);
245        assert_eq!(r.dirty_frame_count(), 20);
246    }
247
248    #[test]
249    fn test_mark_all_dirty() {
250        let mut r = renderer();
251        r.mark_dirty(5, 10);
252        r.mark_all_dirty(1000);
253        assert_eq!(r.get_dirty_regions().len(), 1);
254        assert_eq!(r.dirty_frame_count(), 1000);
255    }
256
257    #[test]
258    fn test_render_incremental_clears_dirty() {
259        let mut r = renderer();
260        r.mark_dirty(0, 60);
261        let timeline = std::sync::Arc::new(crate::timeline::Timeline::default());
262        let count = r
263            .render_incremental(&timeline)
264            .expect("render_incremental ok");
265        assert_eq!(count, 60);
266        assert!(r.get_dirty_regions().is_empty());
267    }
268
269    #[test]
270    fn test_dirty_region_frame_count() {
271        let region = DirtyRegion::new(10, 50);
272        assert_eq!(region.frame_count(), 40);
273    }
274
275    #[test]
276    fn test_dirty_region_merge() {
277        let a = DirtyRegion::new(0, 10);
278        let b = DirtyRegion::new(5, 20);
279        let merged = a.merge(&b);
280        assert_eq!(merged.start_frame, 0);
281        assert_eq!(merged.end_frame, 20);
282    }
283}