1use 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#[derive(Clone, Debug, PartialEq, Serialize)]
16#[serde(tag = "kind", rename_all = "snake_case")]
17#[non_exhaustive]
18pub enum LintIssue {
19 UnreachedKeyframe {
21 owner: LintOwner,
23 property: String,
26 time: MediaTime,
28 },
29 HiddenClip {
33 clip: ClipId,
35 by: ClipId,
37 },
38 ShortTransition {
42 track: TrackId,
44 from_clip: ClipId,
46 to_clip: ClipId,
48 },
49 OffFrameBoundary {
53 owner: LintOwner,
55 edge: LintEdge,
57 time: MediaTime,
59 },
60 UnusedSource {
63 source: SourceId,
65 },
66 HdrSource {
69 source: SourceId,
71 },
72}
73
74#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
76#[serde(tag = "type", rename_all = "snake_case")]
77#[non_exhaustive]
78pub enum LintOwner {
79 Clip {
81 clip: ClipId,
83 },
84 Transition {
86 track: TrackId,
88 from_clip: ClipId,
90 to_clip: ClipId,
92 },
93 Cue {
95 track: TrackId,
97 index: usize,
99 },
100}
101
102#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize)]
104#[serde(rename_all = "snake_case")]
105pub enum LintEdge {
106 Start,
108 End,
110}
111
112impl Project {
113 #[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 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
244struct Cover<'a> {
247 order: usize,
249 clip: &'a crate::Clip,
250 mixed: Vec<TimeRange>,
252}
253
254fn 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
271fn 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
294struct 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 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 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
359struct 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 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}