Skip to main content

alpha2_model/
lint.rs

1//! Non-fatal diagnostics: what a valid project probably does not mean to do.
2//!
3//! [`Project::lint`] never refuses a project. Every check is conservative: an
4//! issue it reports is certain, and something it misses is not an error.
5
6use crate::{
7    Animated, ClipId, Generator, MaskShape, MediaKind, Paint, Project, TrackId, TrackKind,
8};
9use alpha2_media::SourceId;
10use alpha2_time::{FrameRate, MediaTime, TICKS_PER_SECOND, TimeRange};
11use serde::Serialize;
12use std::collections::{HashMap, HashSet};
13
14/// Something in a project that is valid but probably not meant.
15#[derive(Clone, Debug, PartialEq, Serialize)]
16#[serde(tag = "kind", rename_all = "snake_case")]
17#[non_exhaustive]
18pub enum LintIssue {
19    /// A keyframe that no time its owner is shown at depends on.
20    UnreachedKeyframe {
21        /// What the keyframe animates.
22        owner: LintOwner,
23        /// The animated property, as a path such as `visual.opacity` or
24        /// `masks[0].feather`.
25        property: String,
26        /// The keyframe's time.
27        time: MediaTime,
28    },
29    /// A clip wholly covered, for its whole time, by an opaque full-frame
30    /// clip on a later track. A matte clip is never reported, as hidden or
31    /// hiding: it is never composited itself.
32    HiddenClip {
33        /// The covered clip.
34        clip: ClipId,
35        /// The clip that covers it.
36        by: ClipId,
37    },
38    /// A transition no project frame shows between its two clips: shorter
39    /// than a frame, or about a frame long and starting between frames, so
40    /// each frame shows one clip or the other, never a mix.
41    ShortTransition {
42        /// Its track.
43        track: TrackId,
44        /// Its first clip.
45        from_clip: ClipId,
46        /// Its second clip.
47        to_clip: ClipId,
48    },
49    /// A boundary that is not where a project frame starts: the change it
50    /// marks shows at the next frame. A frame starts at the tick its exact
51    /// start rounds down to, as the engine renders it.
52    OffFrameBoundary {
53        /// Whose boundary.
54        owner: LintOwner,
55        /// Which end.
56        edge: LintEdge,
57        /// The boundary's time.
58        time: MediaTime,
59    },
60    /// A source no render reads: no clip shows it, and no `media` node of a
61    /// composition a clip shows reads it.
62    UnusedSource {
63        /// The source.
64        source: SourceId,
65    },
66    /// A high-dynamic-range source in a project whose color pipeline refuses
67    /// HDR, so rendering it fails.
68    HdrSource {
69        /// The source.
70        source: SourceId,
71    },
72}
73
74/// What a [`LintIssue`] is about.
75#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
76#[serde(tag = "type", rename_all = "snake_case")]
77#[non_exhaustive]
78pub enum LintOwner {
79    /// A clip.
80    Clip {
81        /// The clip.
82        clip: ClipId,
83    },
84    /// A transition.
85    Transition {
86        /// Its track.
87        track: TrackId,
88        /// Its first clip.
89        from_clip: ClipId,
90        /// Its second clip.
91        to_clip: ClipId,
92    },
93    /// A caption cue.
94    Cue {
95        /// Its caption track.
96        track: TrackId,
97        /// Its position among the track's cues.
98        index: usize,
99    },
100}
101
102/// The end of a range a [`LintIssue`] is about.
103#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize)]
104#[serde(rename_all = "snake_case")]
105pub enum LintEdge {
106    /// The start.
107    Start,
108    /// The end.
109    End,
110}
111
112impl Project {
113    /// Diagnostics that do not stop the project rendering, in a fixed order:
114    /// sources, then tracks in order and each track's clips, transitions, and
115    /// cues in order.
116    #[must_use]
117    pub fn lint(&self) -> Vec<LintIssue> {
118        let mut issues = Vec::new();
119        let used = self
120            .used_sources()
121            .into_iter()
122            .map(|source| &source.id)
123            .collect::<std::collections::HashSet<_>>();
124        for source in &self.sources {
125            if !used.contains(&source.id) {
126                issues.push(LintIssue::UnusedSource {
127                    source: source.id.clone(),
128                });
129            }
130        }
131        let refused = self.settings.color.hdr == crate::HdrSources::Reject;
132        for source in self.hdr_sources().into_iter().filter(|_| refused) {
133            issues.push(LintIssue::HdrSource {
134                source: source.clone(),
135            });
136        }
137        let mattes = self
138            .timeline
139            .tracks
140            .iter()
141            .flat_map(|track| &track.clips)
142            .filter_map(|clip| clip.matte.as_ref().map(|matte| &matte.source))
143            .collect::<HashSet<_>>();
144        let grid = FrameGrid::new(self);
145        let covers = self.covering_clips(&mattes);
146        for (order, track) in self.timeline.tracks.iter().enumerate() {
147            for clip in &track.clips {
148                let owner = LintOwner::Clip {
149                    clip: clip.id.clone(),
150                };
151                grid.check(&owner, clip.timeline_range, &mut issues);
152                let mut keyframes = Keyframes {
153                    owner: &owner,
154                    active: clip.timeline_range,
155                    issues: &mut issues,
156                };
157                keyframes.check_clip(clip);
158                if track.kind == TrackKind::Video
159                    && !mattes.contains(&clip.id)
160                    && let Some(by) = covering_clip(&covers, order, clip.timeline_range)
161                {
162                    issues.push(LintIssue::HiddenClip {
163                        clip: clip.id.clone(),
164                        by: by.clone(),
165                    });
166                }
167            }
168            for transition in &track.transitions {
169                let owner = LintOwner::Transition {
170                    track: track.id.clone(),
171                    from_clip: transition.from_clip.clone(),
172                    to_clip: transition.to_clip.clone(),
173                };
174                if !grid.shows_mixed(transition.timeline_range) {
175                    issues.push(LintIssue::ShortTransition {
176                        track: track.id.clone(),
177                        from_clip: transition.from_clip.clone(),
178                        to_clip: transition.to_clip.clone(),
179                    });
180                }
181                grid.check(&owner, transition.timeline_range, &mut issues);
182                let mut keyframes = Keyframes {
183                    owner: &owner,
184                    active: transition.timeline_range,
185                    issues: &mut issues,
186                };
187                for (name, value) in &transition.effect.params {
188                    keyframes.check(&format!("effect.params.{name}"), value);
189                }
190            }
191            for (index, cue) in track
192                .captions
193                .iter()
194                .flat_map(|captions| captions.cues.iter())
195                .enumerate()
196            {
197                let owner = LintOwner::Cue {
198                    track: track.id.clone(),
199                    index,
200                };
201                grid.check(&owner, cue.timeline_range, &mut issues);
202            }
203        }
204        issues
205    }
206
207    /// The clips on video tracks that certainly cover the whole frame,
208    /// opaquely, outside their transitions: composited (not a matte), and
209    /// [`is_opaque_full_frame`]. In track order, then clip order.
210    fn covering_clips<'a>(&'a self, mattes: &HashSet<&ClipId>) -> Vec<Cover<'a>> {
211        let sources = self
212            .sources
213            .iter()
214            .map(|source| (&source.id, source))
215            .collect::<HashMap<_, _>>();
216        let mut covers = Vec::new();
217        for (order, track) in self.timeline.tracks.iter().enumerate() {
218            if track.kind != TrackKind::Video {
219                continue;
220            }
221            for clip in &track.clips {
222                if mattes.contains(&clip.id)
223                    || !sources
224                        .get(&clip.source_id)
225                        .is_some_and(|source| is_opaque_full_frame(source, clip))
226                {
227                    continue;
228                }
229                let mixed = track
230                    .transitions
231                    .iter()
232                    .filter(|transition| {
233                        transition.from_clip == clip.id || transition.to_clip == clip.id
234                    })
235                    .map(|transition| transition.timeline_range)
236                    .collect();
237                covers.push(Cover { order, clip, mixed });
238            }
239        }
240        covers
241    }
242}
243
244/// A clip that covers the whole frame opaquely, except while it is in one
245/// of its transitions.
246struct Cover<'a> {
247    /// Its track's position.
248    order: usize,
249    clip: &'a crate::Clip,
250    /// Its transitions' ranges, where it is mixed with another clip.
251    mixed: Vec<TimeRange>,
252}
253
254/// A clip on a track after `order` that covers the frame for all of `range`.
255/// Only the few clips that cover the frame at all are looked at.
256fn covering_clip<'a>(covers: &[Cover<'a>], order: usize, range: TimeRange) -> Option<&'a ClipId> {
257    covers
258        .iter()
259        .filter(|cover| cover.order > order)
260        .find(|cover| {
261            cover.clip.timeline_range.start() <= range.start()
262                && cover.clip.timeline_range.end() >= range.end()
263                && !cover
264                    .mixed
265                    .iter()
266                    .any(|mixed| mixed.intersection(range).is_some())
267        })
268        .map(|cover| &cover.clip.id)
269}
270
271/// Whether a clip draws an opaque fill over the whole frame: a generated
272/// fill of opaque paint, shown untransformed, unblended, unmasked, with no
273/// composition.
274fn is_opaque_full_frame(source: &crate::MediaSource, clip: &crate::Clip) -> bool {
275    let opaque = source.kind == MediaKind::Generated
276        && matches!(&source.generator, Some(Generator::Fill { paint }) if paint_is_opaque(paint));
277    opaque
278        && clip.visual == crate::VisualProperties::default()
279        && clip.blend_mode == crate::ClipBlendMode::Normal
280        && clip.composition.is_none()
281        && clip.masks.is_empty()
282        && clip.matte.is_none()
283}
284
285fn paint_is_opaque(paint: &Paint) -> bool {
286    match paint {
287        Paint::Solid { color } => color.alpha >= 1.0,
288        Paint::LinearGradient { stops, .. }
289        | Paint::RadialGradient { stops, .. }
290        | Paint::ConicGradient { stops, .. } => stops.iter().all(|stop| stop.color.alpha >= 1.0),
291    }
292}
293
294/// The project's frames, starting where the engine renders them: frame `k`
295/// at [`FrameRate::frame_start`], its exact start rounded down to a tick.
296struct FrameGrid {
297    rate: FrameRate,
298}
299
300impl FrameGrid {
301    fn new(project: &Project) -> Self {
302        Self {
303            rate: project.settings.frame_rate,
304        }
305    }
306
307    fn on_grid(&self, time: MediaTime) -> bool {
308        self.rate
309            .frame_at_or_before(time)
310            .and_then(|frame| self.rate.frame_start(frame))
311            == Some(time)
312    }
313
314    /// Whether a frame shows a transition over `range` between its two
315    /// clips, as the evaluator's progress `clamp((t - start + F/2) / D, 0, 1)`
316    /// below 1 at a frame start `t` in the range. Progress grows with `t`, so
317    /// the first frame start in the range decides. Unsure is shown.
318    fn shows_mixed(&self, range: TimeRange) -> bool {
319        let first = self
320            .rate
321            .frame_at_or_before(range.start())
322            .and_then(|frame| {
323                let start = self.rate.frame_start(frame)?;
324                if start == range.start() {
325                    Some(start)
326                } else {
327                    self.rate.frame_start(frame.checked_add(1)?)
328                }
329            });
330        let Some(first) = first else {
331            return true;
332        };
333        let num = i128::from(self.rate.numerator());
334        let elapsed = i128::from(first.ticks()) - i128::from(range.start().ticks());
335        let duration = i128::from(range.end().ticks()) - i128::from(range.start().ticks());
336        // Scaled by 2 * num, as the evaluator's `centred_progress` is.
337        first < range.end()
338            && 2 * num * elapsed
339                + i128::from(TICKS_PER_SECOND) * i128::from(self.rate.denominator())
340                < 2 * num * duration
341    }
342
343    fn check(&self, owner: &LintOwner, range: TimeRange, issues: &mut Vec<LintIssue>) {
344        for (edge, time) in [
345            (LintEdge::Start, range.start()),
346            (LintEdge::End, range.end()),
347        ] {
348            if !self.on_grid(time) {
349                issues.push(LintIssue::OffFrameBoundary {
350                    owner: owner.clone(),
351                    edge,
352                    time,
353                });
354            }
355        }
356    }
357}
358
359/// Finds keyframes no time in `active` depends on.
360struct Keyframes<'a> {
361    owner: &'a LintOwner,
362    active: TimeRange,
363    issues: &'a mut Vec<LintIssue>,
364}
365
366impl Keyframes<'_> {
367    fn check_clip(&mut self, clip: &crate::Clip) {
368        let visual = &clip.visual;
369        self.check("visual.position", &visual.position);
370        self.check("visual.scale", &visual.scale);
371        self.check("visual.rotation_degrees", &visual.rotation_degrees);
372        self.check("visual.opacity", &visual.opacity);
373        for (index, mask) in clip.masks.iter().enumerate() {
374            self.check(&format!("masks[{index}].center"), &mask.center);
375            self.check(&format!("masks[{index}].size"), &mask.size);
376            self.check(
377                &format!("masks[{index}].rotation_degrees"),
378                &mask.rotation_degrees,
379            );
380            self.check(&format!("masks[{index}].feather"), &mask.feather);
381            if let MaskShape::Rectangle { corner_radius } = &mask.shape {
382                self.check(
383                    &format!("masks[{index}].shape.corner_radius"),
384                    corner_radius,
385                );
386            }
387        }
388        self.check("audio.gain", &clip.audio.gain);
389        self.check("audio.pan", &clip.audio.pan);
390    }
391
392    /// Keyframe `i` shapes values only strictly between its neighbours'
393    /// times (the first one back to the start of time, the last one on to
394    /// its end); outside every shown tick of `active`, it has no effect.
395    fn check<T>(&mut self, property: &str, animated: &Animated<T>) {
396        let Animated::Keyframes { keyframes } = animated else {
397            return;
398        };
399        let (start, last) = (self.active.start().ticks(), self.active.end().ticks() - 1);
400        for (index, keyframe) in keyframes.iter().enumerate() {
401            let after = index.checked_sub(1).map_or(i64::MIN, |previous| {
402                keyframes[previous].time.ticks().saturating_add(1)
403            });
404            let before = keyframes
405                .get(index + 1)
406                .map_or(i64::MAX, |next| next.time.ticks().saturating_sub(1));
407            if after.max(start) > before.min(last) {
408                self.issues.push(LintIssue::UnreachedKeyframe {
409                    owner: self.owner.clone(),
410                    property: property.to_owned(),
411                    time: keyframe.time,
412                });
413            }
414        }
415    }
416}