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