1use crate::{
7 Animated, AnimatedPoint, ClipId, CompositionId, Generator, MediaKind, NodeId, Paint, Project,
8 ShapeGeometry, ShapeItem, TrackId, TrackKind,
9};
10use alpha2_media::SourceId;
11use alpha2_time::{FrameRate, MediaTime, TICKS_PER_SECOND, TimeRange};
12use serde::Serialize;
13use std::collections::{HashMap, HashSet};
14
15#[derive(Clone, Debug, PartialEq, Serialize)]
17#[serde(tag = "kind", rename_all = "snake_case")]
18#[non_exhaustive]
19pub enum LintIssue {
20 UnreachedKeyframe {
22 owner: LintOwner,
24 property: String,
27 time: MediaTime,
29 },
30 HiddenClip {
34 clip: ClipId,
36 by: ClipId,
38 },
39 ShortTransition {
43 track: TrackId,
45 from_clip: ClipId,
47 to_clip: ClipId,
49 },
50 OffFrameBoundary {
54 owner: LintOwner,
56 edge: LintEdge,
58 time: MediaTime,
60 },
61 UnusedSource {
64 source: SourceId,
66 },
67 HdrSource {
70 source: SourceId,
72 },
73 EmptySubpath {
75 owner: ShapeLintOwner,
77 at: String,
79 },
80 PaintWithoutGeometry {
83 owner: ShapeLintOwner,
85 at: String,
87 },
88}
89
90#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
92#[serde(tag = "type", rename_all = "snake_case")]
93#[non_exhaustive]
94pub enum ShapeLintOwner {
95 Source {
97 source: SourceId,
99 },
100 Node {
102 composition: CompositionId,
104 node: NodeId,
106 },
107}
108
109#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
111#[serde(tag = "type", rename_all = "snake_case")]
112#[non_exhaustive]
113pub enum LintOwner {
114 Clip {
116 clip: ClipId,
118 },
119 Transition {
121 track: TrackId,
123 from_clip: ClipId,
125 to_clip: ClipId,
127 },
128 Cue {
130 track: TrackId,
132 index: usize,
134 },
135}
136
137#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize)]
139#[serde(rename_all = "snake_case")]
140pub enum LintEdge {
141 Start,
143 End,
145}
146
147impl Project {
148 #[must_use]
153 pub fn lint(&self) -> Vec<LintIssue> {
154 let mut issues = Vec::new();
155 let used = self
156 .used_sources()
157 .into_iter()
158 .map(|source| &source.id)
159 .collect::<std::collections::HashSet<_>>();
160 for source in &self.sources {
161 if !used.contains(&source.id) {
162 issues.push(LintIssue::UnusedSource {
163 source: source.id.clone(),
164 });
165 }
166 }
167 self.lint_shapes(&mut issues);
168 let refused = self.settings.color.hdr == crate::HdrSources::Reject;
169 for source in self.hdr_sources().into_iter().filter(|_| refused) {
170 issues.push(LintIssue::HdrSource {
171 source: source.clone(),
172 });
173 }
174 let mattes = self
175 .timeline
176 .tracks
177 .iter()
178 .flat_map(|track| &track.clips)
179 .filter_map(|clip| clip.matte.as_ref().map(|matte| &matte.source))
180 .collect::<HashSet<_>>();
181 let grid = FrameGrid::new(self);
182 let covers = self.covering_clips(&mattes);
183 for (order, track) in self.timeline.tracks.iter().enumerate() {
184 for clip in &track.clips {
185 let owner = LintOwner::Clip {
186 clip: clip.id.clone(),
187 };
188 grid.check(&owner, clip.timeline_range, &mut issues);
189 let mut keyframes = Keyframes {
190 owner: &owner,
191 active: clip.timeline_range,
192 issues: &mut issues,
193 };
194 keyframes.check_clip(clip);
195 if track.kind == TrackKind::Video
196 && !mattes.contains(&clip.id)
197 && let Some(by) = covering_clip(&covers, order, clip.timeline_range)
198 {
199 issues.push(LintIssue::HiddenClip {
200 clip: clip.id.clone(),
201 by: by.clone(),
202 });
203 }
204 }
205 for transition in &track.transitions {
206 let owner = LintOwner::Transition {
207 track: track.id.clone(),
208 from_clip: transition.from_clip.clone(),
209 to_clip: transition.to_clip.clone(),
210 };
211 if !grid.shows_mixed(transition.timeline_range) {
212 issues.push(LintIssue::ShortTransition {
213 track: track.id.clone(),
214 from_clip: transition.from_clip.clone(),
215 to_clip: transition.to_clip.clone(),
216 });
217 }
218 grid.check(&owner, transition.timeline_range, &mut issues);
219 let mut keyframes = Keyframes {
220 owner: &owner,
221 active: transition.timeline_range,
222 issues: &mut issues,
223 };
224 for (name, value) in &transition.effect.params {
225 keyframes.check(&format!("effect.params.{name}"), value);
226 }
227 }
228 for (index, cue) in track
229 .captions
230 .iter()
231 .flat_map(|captions| captions.cues.iter())
232 .enumerate()
233 {
234 let owner = LintOwner::Cue {
235 track: track.id.clone(),
236 index,
237 };
238 grid.check(&owner, cue.timeline_range, &mut issues);
239 }
240 }
241 issues
242 }
243
244 fn lint_shapes(&self, issues: &mut Vec<LintIssue>) {
247 for source in &self.sources {
248 if let Some(Generator::Shapes { items }) = &source.generator {
249 let owner = ShapeLintOwner::Source {
250 source: source.id.clone(),
251 };
252 lint_shape_items(&owner, items, "items", issues);
253 }
254 }
255 for composition in &self.compositions {
256 for node in &composition.nodes {
257 if let crate::NodeOp::Generator {
258 generator: Generator::Shapes { items },
259 } = &node.op
260 {
261 let owner = ShapeLintOwner::Node {
262 composition: composition.id.clone(),
263 node: node.id.clone(),
264 };
265 lint_shape_items(&owner, items, "items", issues);
266 }
267 }
268 }
269 }
270
271 fn covering_clips<'a>(&'a self, mattes: &HashSet<&ClipId>) -> Vec<Cover<'a>> {
275 let sources = self
276 .sources
277 .iter()
278 .map(|source| (&source.id, source))
279 .collect::<HashMap<_, _>>();
280 let mut covers = Vec::new();
281 for (order, track) in self.timeline.tracks.iter().enumerate() {
282 if track.kind != TrackKind::Video {
283 continue;
284 }
285 for clip in &track.clips {
286 if mattes.contains(&clip.id)
287 || !sources
288 .get(&clip.source_id)
289 .is_some_and(|source| is_opaque_full_frame(source, clip))
290 {
291 continue;
292 }
293 let mixed = track
294 .transitions
295 .iter()
296 .filter(|transition| {
297 transition.from_clip == clip.id || transition.to_clip == clip.id
298 })
299 .map(|transition| transition.timeline_range)
300 .collect();
301 covers.push(Cover { order, clip, mixed });
302 }
303 }
304 covers
305 }
306}
307
308struct Cover<'a> {
311 order: usize,
313 clip: &'a crate::Clip,
314 mixed: Vec<TimeRange>,
316}
317
318fn covering_clip<'a>(covers: &[Cover<'a>], order: usize, range: TimeRange) -> Option<&'a ClipId> {
321 covers
322 .iter()
323 .filter(|cover| cover.order > order)
324 .find(|cover| {
325 cover.clip.timeline_range.start() <= range.start()
326 && cover.clip.timeline_range.end() >= range.end()
327 && !cover
328 .mixed
329 .iter()
330 .any(|mixed| mixed.intersection(range).is_some())
331 })
332 .map(|cover| &cover.clip.id)
333}
334
335fn is_opaque_full_frame(source: &crate::MediaSource, clip: &crate::Clip) -> bool {
339 let opaque = source.kind == MediaKind::Generated
340 && matches!(&source.generator, Some(Generator::Fill { paint }) if paint_is_opaque(paint));
341 opaque
342 && clip.visual == crate::VisualProperties::default()
343 && clip.blend_mode == crate::ClipBlendMode::Normal
344 && clip.composition.is_none()
345 && clip.masks.is_empty()
346 && clip.matte.is_none()
347}
348
349fn paint_is_opaque(paint: &Paint) -> bool {
350 match paint {
351 Paint::Solid { color } => color.alpha >= 1.0,
352 Paint::LinearGradient { stops, .. }
353 | Paint::RadialGradient { stops, .. }
354 | Paint::ConicGradient { stops, .. } => stops.iter().all(|stop| stop.color.alpha >= 1.0),
355 }
356}
357
358struct FrameGrid {
361 rate: FrameRate,
362}
363
364impl FrameGrid {
365 fn new(project: &Project) -> Self {
366 Self {
367 rate: project.settings.frame_rate,
368 }
369 }
370
371 fn on_grid(&self, time: MediaTime) -> bool {
372 self.rate
373 .frame_at_or_before(time)
374 .and_then(|frame| self.rate.frame_start(frame))
375 == Some(time)
376 }
377
378 fn shows_mixed(&self, range: TimeRange) -> bool {
383 let first = self
384 .rate
385 .frame_at_or_before(range.start())
386 .and_then(|frame| {
387 let start = self.rate.frame_start(frame)?;
388 if start == range.start() {
389 Some(start)
390 } else {
391 self.rate.frame_start(frame.checked_add(1)?)
392 }
393 });
394 let Some(first) = first else {
395 return true;
396 };
397 let num = i128::from(self.rate.numerator());
398 let elapsed = i128::from(first.ticks()) - i128::from(range.start().ticks());
399 let duration = i128::from(range.end().ticks()) - i128::from(range.start().ticks());
400 first < range.end()
402 && 2 * num * elapsed
403 + i128::from(TICKS_PER_SECOND) * i128::from(self.rate.denominator())
404 < 2 * num * duration
405 }
406
407 fn check(&self, owner: &LintOwner, range: TimeRange, issues: &mut Vec<LintIssue>) {
408 for (edge, time) in [
409 (LintEdge::Start, range.start()),
410 (LintEdge::End, range.end()),
411 ] {
412 if !self.on_grid(time) {
413 issues.push(LintIssue::OffFrameBoundary {
414 owner: owner.clone(),
415 edge,
416 time,
417 });
418 }
419 }
420 }
421}
422
423struct Keyframes<'a> {
425 owner: &'a LintOwner,
426 active: TimeRange,
427 issues: &'a mut Vec<LintIssue>,
428}
429
430impl Keyframes<'_> {
431 fn check_clip(&mut self, clip: &crate::Clip) {
432 let visual = &clip.visual;
433 self.check_point("visual.position", &visual.position);
434 self.check("visual.scale", &visual.scale);
435 self.check("visual.rotation_degrees", &visual.rotation_degrees);
436 self.check("visual.opacity", &visual.opacity);
437 for (index, mask) in clip.masks.iter().enumerate() {
438 self.check_geometry(&format!("masks[{index}].shape"), &mask.shape);
439 self.check(&format!("masks[{index}].feather"), &mask.feather);
440 }
441 self.check("audio.gain", &clip.audio.gain);
442 self.check("audio.pan", &clip.audio.pan);
443 }
444
445 fn check_geometry(&mut self, at: &str, geometry: &ShapeGeometry) {
446 match geometry {
447 ShapeGeometry::Rectangle(rectangle) => {
448 self.check_point(&format!("{at}.center"), &rectangle.center);
449 self.check(&format!("{at}.size"), &rectangle.size);
450 self.check(&format!("{at}.corner_radius"), &rectangle.corner_radius);
451 self.check(
452 &format!("{at}.rotation_degrees"),
453 &rectangle.rotation_degrees,
454 );
455 }
456 ShapeGeometry::Ellipse(ellipse) => {
457 self.check_point(&format!("{at}.center"), &ellipse.center);
458 self.check(&format!("{at}.size"), &ellipse.size);
459 self.check(&format!("{at}.rotation_degrees"), &ellipse.rotation_degrees);
460 }
461 ShapeGeometry::Polystar(star) => {
462 self.check_point(&format!("{at}.center"), &star.center);
463 self.check(&format!("{at}.points"), &star.points);
464 self.check(&format!("{at}.outer_radius"), &star.outer_radius);
465 self.check(&format!("{at}.inner_radius"), &star.inner_radius);
466 self.check(&format!("{at}.outer_roundness"), &star.outer_roundness);
467 self.check(&format!("{at}.inner_roundness"), &star.inner_roundness);
468 self.check(&format!("{at}.rotation_degrees"), &star.rotation_degrees);
469 }
470 ShapeGeometry::Path(path) => self.check(&format!("{at}.path"), &path.path),
471 }
472 }
473
474 fn check_point(&mut self, property: &str, animated: &AnimatedPoint) {
475 if let AnimatedPoint::Keyframes { keyframes } = animated {
476 let times: Vec<MediaTime> = keyframes.iter().map(|keyframe| keyframe.time).collect();
477 self.check_times(property, ×);
478 }
479 }
480
481 fn check<T>(&mut self, property: &str, animated: &Animated<T>) {
482 if let Animated::Keyframes { keyframes } = animated {
483 let times: Vec<MediaTime> = keyframes.iter().map(|keyframe| keyframe.time).collect();
484 self.check_times(property, ×);
485 }
486 }
487
488 fn check_times(&mut self, property: &str, times: &[MediaTime]) {
492 let (start, last) = (self.active.start().ticks(), self.active.end().ticks() - 1);
493 for (index, time) in times.iter().enumerate() {
494 let after = index.checked_sub(1).map_or(i64::MIN, |previous| {
495 times[previous].ticks().saturating_add(1)
496 });
497 let before = times
498 .get(index + 1)
499 .map_or(i64::MAX, |next| next.ticks().saturating_sub(1));
500 if after.max(start) > before.min(last) {
501 self.issues.push(LintIssue::UnreachedKeyframe {
502 owner: self.owner.clone(),
503 property: property.to_owned(),
504 time: *time,
505 });
506 }
507 }
508 }
509}
510
511fn lint_shape_items(
515 owner: &ShapeLintOwner,
516 items: &[ShapeItem],
517 at: &str,
518 issues: &mut Vec<LintIssue>,
519) -> bool {
520 let mut geometry = false;
521 for (index, item) in items.iter().enumerate() {
522 let at = format!("{at}[{index}]");
523 match item {
524 ShapeItem::Group { items, .. } => {
525 geometry |= lint_shape_items(owner, items, &format!("{at}.items"), issues);
526 }
527 ShapeItem::Rectangle(_) | ShapeItem::Ellipse(_) | ShapeItem::Polystar(_) => {
528 geometry = true;
529 }
530 ShapeItem::Path(path) => {
531 geometry = true;
532 let paths: Vec<&crate::Path> = match &path.path {
533 Animated::Constant { value } => vec![value],
534 Animated::Keyframes { keyframes } => {
535 keyframes.iter().map(|keyframe| &keyframe.value).collect()
536 }
537 };
538 if let Some(first) = paths.first() {
540 for (subpath, entry) in first.subpaths.iter().enumerate() {
541 if entry.vertices.is_empty() {
542 issues.push(LintIssue::EmptySubpath {
543 owner: owner.clone(),
544 at: format!("{at}.path.subpaths[{subpath}]"),
545 });
546 }
547 }
548 }
549 }
550 ShapeItem::Fill { .. } | ShapeItem::Stroke(_) => {
551 if !geometry {
552 issues.push(LintIssue::PaintWithoutGeometry {
553 owner: owner.clone(),
554 at,
555 });
556 }
557 }
558 ShapeItem::Trim(_) => {}
559 }
560 }
561 geometry
562}