Skip to main content

ass_renderer/renderer/
event_selector.rs

1//! Event selection and dirty region tracking for incremental rendering
2
3use crate::utils::RenderError;
4use ass_core::parser::{Event, Script, Section};
5
6use super::time_index::TimeIndex;
7
8#[cfg(feature = "nostd")]
9use alloc::{collections::BTreeSet, vec::Vec};
10#[cfg(not(feature = "nostd"))]
11use std::collections::HashSet;
12
13/// Tracks active events and dirty regions for optimized rendering
14#[derive(Debug, Clone)]
15pub struct EventSelector {
16    /// Cache of previously active event indices
17    #[cfg(not(feature = "nostd"))]
18    previous_active: HashSet<usize>,
19    #[cfg(feature = "nostd")]
20    previous_active: BTreeSet<usize>,
21
22    /// Last rendered timestamp
23    last_timestamp: Option<u32>,
24
25    /// Dirty regions that need re-rendering
26    dirty_regions: Vec<DirtyRegion>,
27
28    /// Whether to render comment events (for signs and complex effects)
29    render_comments: bool,
30
31    /// Cached, parsed time index for the current script. Rebuilt only when the
32    /// events change. Avoids re-parsing every event's start/end time string on
33    /// every frame (the dominant per-frame cost for large scripts).
34    time_index: Option<TimeIndex>,
35}
36
37/// A region that needs re-rendering
38#[derive(Debug, Clone)]
39pub struct DirtyRegion {
40    /// X coordinate of the region
41    pub x: u32,
42    /// Y coordinate of the region
43    pub y: u32,
44    /// Width of the region
45    pub width: u32,
46    /// Height of the region
47    pub height: u32,
48}
49
50/// Result of event selection with dirty tracking
51#[derive(Debug)]
52pub struct ActiveEvents<'a> {
53    /// Currently active events
54    pub events: Vec<&'a Event<'a>>,
55    /// Indices of newly activated events
56    pub newly_active: Vec<usize>,
57    /// Indices of newly deactivated events
58    pub newly_inactive: Vec<usize>,
59    /// Whether the frame needs re-rendering
60    pub is_dirty: bool,
61}
62
63impl EventSelector {
64    /// Create a new event selector
65    pub fn new() -> Self {
66        Self {
67            #[cfg(not(feature = "nostd"))]
68            previous_active: HashSet::new(),
69            #[cfg(feature = "nostd")]
70            previous_active: BTreeSet::new(),
71            last_timestamp: None,
72            dirty_regions: Vec::new(),
73            // libass never renders `Comment` events, so default to matching it.
74            // `Comment` lines in real scripts hold source text, karaoke templates
75            // and disabled alternates — rendering them duplicates/overlaps the
76            // real `Dialogue` lines. Opt in via `set_render_comments(true)`.
77            render_comments: false,
78            time_index: None,
79        }
80    }
81
82    /// Set whether to render comment events
83    pub fn set_render_comments(&mut self, render: bool) {
84        self.render_comments = render;
85    }
86
87    /// Select active events and track changes for incremental rendering.
88    ///
89    /// `time_cs` is in centiseconds. An event is active when
90    /// `start <= time_cs < end` — the start is inclusive and the end is
91    /// exclusive, matching libass (`Start <= now < Start + Duration`). The
92    /// exclusive end is what stops two events that share a boundary timestamp
93    /// (one ending exactly as the next begins) from both rendering for a frame
94    /// and stacking on top of each other.
95    pub fn select_active<'a>(
96        &mut self,
97        script: &'a Script<'a>,
98        time_cs: u32,
99    ) -> Result<ActiveEvents<'a>, RenderError> {
100        let mut active_events = Vec::new();
101        #[cfg(not(feature = "nostd"))]
102        let mut current_active = HashSet::new();
103        #[cfg(feature = "nostd")]
104        let mut current_active = BTreeSet::new();
105
106        // Find the events section, then answer the query from the cached, parsed
107        // time index. Active events satisfy `start <= t < end`; entries are
108        // start-sorted, so `partition_point` bounds the scan to events that have
109        // already started, and the original index restores file order.
110        if let Some(events_section) = script.sections().iter().find_map(|section| {
111            if let Section::Events(events) = section {
112                Some(events)
113            } else {
114                None
115            }
116        }) {
117            self.ensure_index(events_section);
118            let index = self
119                .time_index
120                .as_ref()
121                .expect("time index built by ensure_index");
122            let hi = index
123                .by_start
124                .partition_point(|&(start, _, _)| start <= time_cs);
125            let mut active_idx: Vec<usize> = index.by_start[..hi]
126                .iter()
127                .filter(|&&(_, end, _)| end > time_cs)
128                .map(|&(_, _, idx)| idx)
129                .collect();
130            active_idx.sort_unstable();
131            for idx in active_idx {
132                active_events.push(&events_section[idx]);
133                current_active.insert(idx);
134            }
135        }
136
137        // Track changes for incremental rendering
138        let newly_active: Vec<usize> = current_active
139            .iter()
140            .filter(|idx| !self.previous_active.contains(idx))
141            .cloned()
142            .collect();
143
144        let newly_inactive: Vec<usize> = self
145            .previous_active
146            .iter()
147            .filter(|idx| !current_active.contains(idx))
148            .cloned()
149            .collect();
150
151        // Check if re-render is needed
152        let is_dirty = !newly_active.is_empty()
153            || !newly_inactive.is_empty()
154            || self.has_animated_events(&active_events, time_cs)
155            || self
156                .last_timestamp
157                .is_none_or(|last| (time_cs as i32 - last as i32).abs() > 100);
158
159        // Update state
160        self.previous_active = current_active;
161        self.last_timestamp = Some(time_cs);
162
163        Ok(ActiveEvents {
164            events: active_events,
165            newly_active,
166            newly_inactive,
167            is_dirty,
168        })
169    }
170
171    /// Build (or reuse) the parsed time index for `events`.
172    ///
173    /// The index is keyed by the events slice's address, length, and the current
174    /// comment-rendering flag; when that key is unchanged the existing index is
175    /// kept, so each event's start/end time string is parsed only once per script
176    /// rather than on every frame.
177    fn ensure_index(&mut self, events: &[Event]) {
178        let key = (events.as_ptr() as usize, events.len(), self.render_comments);
179        if self
180            .time_index
181            .as_ref()
182            .is_some_and(|index| index.key == key)
183        {
184            return;
185        }
186
187        self.time_index = Some(TimeIndex::build(events, self.render_comments));
188    }
189
190    /// Check if any events have active animations
191    fn has_animated_events(&self, events: &[&Event], time_cs: u32) -> bool {
192        for event in events {
193            let text = event.text;
194            // Check for animation tags
195            if text.contains(r"\t(")
196                || text.contains(r"\move(")
197                || text.contains(r"\fade(")
198                || text.contains(r"\fad(")
199            {
200                return true;
201            }
202            // Check for karaoke
203            if text.contains(r"\k") || text.contains(r"\K") {
204                if let Ok(start) = event.start_time_cs() {
205                    if time_cs > start {
206                        return true;
207                    }
208                }
209            }
210        }
211        false
212    }
213
214    /// Add a dirty region for partial re-rendering
215    pub fn add_dirty_region(&mut self, x: u32, y: u32, width: u32, height: u32) {
216        // Merge overlapping regions for efficiency
217        for region in &mut self.dirty_regions {
218            if Self::regions_overlap(region, x, y, width, height) {
219                // Expand existing region
220                let min_x = region.x.min(x);
221                let min_y = region.y.min(y);
222                let max_x = (region.x + region.width).max(x + width);
223                let max_y = (region.y + region.height).max(y + height);
224                region.x = min_x;
225                region.y = min_y;
226                region.width = max_x - min_x;
227                region.height = max_y - min_y;
228                return;
229            }
230        }
231
232        self.dirty_regions.push(DirtyRegion {
233            x,
234            y,
235            width,
236            height,
237        });
238    }
239
240    /// Check if two regions overlap
241    fn regions_overlap(region: &DirtyRegion, x: u32, y: u32, width: u32, height: u32) -> bool {
242        !(x >= region.x + region.width
243            || x + width <= region.x
244            || y >= region.y + region.height
245            || y + height <= region.y)
246    }
247
248    /// Get current dirty regions
249    pub fn dirty_regions(&self) -> &[DirtyRegion] {
250        &self.dirty_regions
251    }
252
253    /// Clear dirty regions after rendering
254    pub fn clear_dirty_regions(&mut self) {
255        self.dirty_regions.clear();
256    }
257
258    /// Reset selector state
259    pub fn reset(&mut self) {
260        self.previous_active.clear();
261        self.last_timestamp = None;
262        self.dirty_regions.clear();
263    }
264}
265
266impl Default for EventSelector {
267    fn default() -> Self {
268        Self::new()
269    }
270}
271
272/// Legacy function for backward compatibility (`time_cs` in centiseconds).
273#[allow(dead_code)] // Kept for backward compatibility
274pub fn select_active_events<'a>(script: &'a Script<'a>, time_cs: u32) -> Vec<&'a Event<'a>> {
275    let mut selector = EventSelector::new();
276    selector
277        .select_active(script, time_cs)
278        .map(|active| active.events)
279        .unwrap_or_default()
280}