1use glam::DVec2;
10use renamite_animation::{Angle, Animated, AnimatedTransform, Frame};
11use renamite_geometry::VectorPath;
12use renamite_io_ren::RenFile;
13use renamite_machine::{
14 Condition, InputKind, ListenerAction, Machine, MachineId, StateKind, Transition,
15};
16use renamite_model::{
17 Asset, Color, CompId, Document, GradientStops, ModifierKind, Node, NodeId, NodeKind, PropRef,
18 ShapeKind, StyleKind, StylePaint, Value,
19};
20use serde::{Deserialize, Serialize};
21use std::collections::HashSet;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
24pub enum Severity {
25 Error,
26 Warning,
27 Info,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
31pub struct Diagnostic {
32 pub severity: Severity,
33 pub path: String,
34 pub message: String,
35}
36
37impl Diagnostic {
38 pub fn error(path: impl Into<String>, message: impl Into<String>) -> Self {
39 Self {
40 severity: Severity::Error,
41 path: path.into(),
42 message: message.into(),
43 }
44 }
45
46 pub fn warning(path: impl Into<String>, message: impl Into<String>) -> Self {
47 Self {
48 severity: Severity::Warning,
49 path: path.into(),
50 message: message.into(),
51 }
52 }
53
54 pub fn info(path: impl Into<String>, message: impl Into<String>) -> Self {
55 Self {
56 severity: Severity::Info,
57 path: path.into(),
58 message: message.into(),
59 }
60 }
61}
62
63#[derive(Clone, Debug, Default, Serialize, Deserialize)]
64pub struct ValidationReport {
65 pub diagnostics: Vec<Diagnostic>,
66}
67
68impl ValidationReport {
69 pub fn has_errors(&self) -> bool {
70 self.diagnostics
71 .iter()
72 .any(|d| d.severity == Severity::Error)
73 }
74
75 pub fn error_count(&self) -> usize {
76 self.diagnostics
77 .iter()
78 .filter(|d| d.severity == Severity::Error)
79 .count()
80 }
81
82 pub fn warning_count(&self) -> usize {
83 self.diagnostics
84 .iter()
85 .filter(|d| d.severity == Severity::Warning)
86 .count()
87 }
88
89 pub fn push(&mut self, d: Diagnostic) {
90 self.diagnostics.push(d);
91 }
92}
93
94pub fn validate(file: &RenFile) -> ValidationReport {
95 let mut v = Validator {
96 file,
97 report: ValidationReport::default(),
98 };
99 v.run();
100 v.report
101}
102
103struct Validator<'a> {
104 file: &'a RenFile,
105 report: ValidationReport,
106}
107
108impl<'a> Validator<'a> {
109 fn run(&mut self) {
110 self.validate_compositions();
111 self.validate_document_tree();
112 self.validate_assets();
113 self.validate_animations();
114 self.validate_scope();
115 self.validate_precomps();
116 self.validate_clips();
117 self.validate_machines();
118 self.validate_export_readiness();
119 }
120
121 fn err(&mut self, path: impl Into<String>, message: impl Into<String>) {
122 self.report.push(Diagnostic::error(path, message));
123 }
124
125 fn warn(&mut self, path: impl Into<String>, message: impl Into<String>) {
126 self.report.push(Diagnostic::warning(path, message));
127 }
128
129 fn validate_compositions(&mut self) {
130 let doc = &self.file.document;
131
132 if !doc.compositions.contains_key(doc.main) {
133 self.err("document.main", "main composition does not exist");
134 }
135
136 for (id, comp) in &doc.compositions {
137 if comp.rate.num == 0 || comp.rate.den == 0 {
138 self.err(format!("composition/{id:?}/rate"), "invalid frame rate");
139 }
140 if comp.range.1 <= comp.range.0 {
141 self.err(
142 format!("composition/{id:?}/range"),
143 "out frame must be after in frame",
144 );
145 }
146 if comp.size.0 == 0 || comp.size.1 == 0 {
147 self.warn(
148 format!("composition/{id:?}/size"),
149 "composition size is zero",
150 );
151 }
152 for (index, child) in comp.children.iter().enumerate() {
153 if !doc.nodes.contains_key(*child) {
154 self.err(
155 format!("composition/{id:?}/children/{index}"),
156 "child node does not exist",
157 );
158 }
159 }
160 }
161 }
162
163 fn validate_document_tree(&mut self) {
164 let doc = &self.file.document;
165 let mut seen = HashSet::new();
166
167 for (comp_id, comp) in &doc.compositions {
168 for &root in &comp.children {
169 self.walk_node_tree(
170 root,
171 format!("composition/{comp_id:?}"),
172 &mut seen,
173 Vec::new(),
174 );
175 }
176 }
177
178 for id in doc.nodes.keys() {
179 if !seen.contains(&id) {
180 self.warn(
181 format!("node/{id:?}"),
182 "detached arena node will be pruned on save",
183 );
184 }
185 }
186 }
187
188 fn walk_node_tree(
189 &mut self,
190 id: NodeId,
191 path: String,
192 seen: &mut HashSet<NodeId>,
193 mut stack: Vec<NodeId>,
194 ) {
195 if stack.contains(&id) {
196 self.err(format!("{path}/node/{id:?}"), "cycle in node tree");
197 return;
198 }
199
200 let Some(node) = self.file.document.nodes.get(id) else {
201 self.err(path, format!("node {id:?} does not exist"));
202 return;
203 };
204
205 seen.insert(id);
206 stack.push(id);
207
208 for (index, &child) in node.children.iter().enumerate() {
209 match self.file.document.nodes.get(child) {
210 Some(child_node) => {
211 if child_node.parent != Some(id) {
212 self.err(
213 format!("node/{id:?}/children/{index}"),
214 "child parent pointer does not point back to this node",
215 );
216 }
217 self.walk_node_tree(
218 child,
219 format!("node/{id:?}/children/{index}"),
220 seen,
221 stack.clone(),
222 );
223 }
224 None => {
225 self.err(
226 format!("node/{id:?}/children/{index}"),
227 "child node does not exist",
228 );
229 }
230 }
231 }
232 }
233
234 fn validate_assets(&mut self) {
235 let doc = &self.file.document;
236
237 let mut seen = HashSet::new();
238 for (i, &id) in doc.asset_order.iter().enumerate() {
239 if !doc.assets.contains_key(id) {
240 self.err(format!("assets/order/{i}"), "asset id does not exist");
241 }
242 if !seen.insert(id) {
243 self.err(
244 format!("assets/order/{i}"),
245 "duplicate asset id in asset_order",
246 );
247 }
248 }
249
250 for id in doc.assets.keys() {
251 if !seen.contains(&id) {
252 self.warn(
253 format!("asset/{id:?}"),
254 "asset exists but is not attached in asset_order",
255 );
256 }
257 }
258
259 for (id, node) in &doc.nodes {
260 match &node.kind {
261 NodeKind::Image(img) => match doc.assets.get(img.asset()) {
262 Some(Asset::Image(img)) => {
263 if img.width == 0 || img.height == 0 {
264 self.err(
265 format!("node/{id:?}/image"),
266 "image dimensions must be nonzero",
267 );
268 }
269 if img.bytes.is_empty() {
270 self.err(format!("node/{id:?}/image"), "image asset has no bytes");
271 }
272 }
273 Some(_) => self.err(
274 format!("node/{id:?}/image"),
275 "referenced asset is not an image",
276 ),
277 None => self.err(format!("node/{id:?}/image"), "image asset is missing"),
278 },
279 NodeKind::Text(text) => {
280 if let Some(family) = &text.font
281 && family != "default"
282 && doc.font_asset_for_family(family).is_none()
283 {
284 self.warn(
285 format!("node/{id:?}/text/font"),
286 format!(
287 "font family `{family}` not found; bundled default will be used"
288 ),
289 );
290 }
291 }
292 _ => {}
293 }
294 }
295
296 for (id, asset) in &doc.assets {
297 match asset {
298 Asset::Image(img) => {
299 if img.width == 0 || img.height == 0 {
300 self.err(format!("asset/{id:?}"), "image dimensions must be nonzero");
301 }
302 if img.bytes.is_empty() {
303 self.warn(format!("asset/{id:?}"), "image asset has empty bytes");
304 }
305 }
306 Asset::Font(font) => {
307 if font.bytes.is_empty() {
308 self.err(format!("asset/{id:?}"), "font has no bytes");
309 }
310 if font.family.trim().is_empty() {
311 self.err(format!("asset/{id:?}"), "font family is empty");
312 }
313 }
314 }
315 }
316
317 self.validate_asset_usage(doc);
318 }
319
320 fn validate_asset_usage(&mut self, doc: &Document) {
321 for (id, asset) in &doc.assets {
322 match asset {
323 Asset::Image(_) => {
324 let used = doc
325 .nodes
326 .values()
327 .any(|n| matches!(&n.kind, NodeKind::Image(img) if img.asset() == id));
328 if !used {
329 self.warn(
330 format!("asset/{id:?}"),
331 "image asset is not used by any image layer",
332 );
333 }
334 }
335 Asset::Font(font) => {
336 let used = doc.nodes.values().any(|n| {
337 matches!(&n.kind, NodeKind::Text(t) if t.font.as_deref() == Some(font.family.as_str()))
338 });
339 if !used {
340 self.warn(
341 format!("asset/{id:?}"),
342 "font asset is not used by any text node",
343 );
344 }
345 }
346 }
347 }
348 }
349
350 fn validate_animations(&mut self) {
351 let doc = &self.file.document;
352 for (id, node) in &doc.nodes {
353 self.validate_node_animations(id, node);
354 }
355 }
356
357 fn validate_node_animations(&mut self, id: NodeId, node: &Node) {
358 let base = format!("node/{id:?}");
359 self.check_transform(&format!("{base}/transform"), &node.transform);
360 self.check_animated(&format!("{base}/opacity"), &node.opacity, finite_f64);
361
362 if node.transform.scale.base == DVec2::ZERO {
363 self.warn(format!("{base}/transform/scale"), "transform scale is zero");
364 }
365
366 match &node.kind {
367 NodeKind::Shape(shape) => self.validate_shape_animations(id, shape),
368 NodeKind::Style(style) => self.validate_style_animations(id, style),
369 NodeKind::Modifier(modifier) => self.validate_modifier_animations(id, modifier),
370 NodeKind::Text(text) => {
371 self.check_animated(&format!("{base}/text/size"), &text.size, finite_f64);
372 self.check_animated(&format!("{base}/text/tracking"), &text.tracking, finite_f64);
373 self.check_animated(&format!("{base}/text/leading"), &text.leading, finite_f64);
374 }
375 NodeKind::Layer(props) => {
376 if !props.time_stretch.is_finite() || props.time_stretch <= 0.0 {
377 self.err(
378 format!("{base}/layer/time_stretch"),
379 "time stretch must be positive and finite",
380 );
381 }
382 if props.out_frame <= props.in_frame {
383 self.warn(
384 format!("{base}/layer/range"),
385 "layer out frame must be after in frame",
386 );
387 }
388 }
389 NodeKind::Mask(mask) => {
390 self.validate_shape_animations(id, &mask.shape);
391 if shape_kind_is_empty(&mask.shape) {
392 self.warn(format!("{base}/mask"), "mask has no geometry");
393 }
394 }
395 NodeKind::Image(img) => {
396 self.check_animated(&format!("{base}/image/tint"), img.tint(), finite_color);
397 let c = img.crop();
398 if !c.x.is_finite() || !c.y.is_finite() || !c.z.is_finite() || !c.w.is_finite() {
399 self.err(format!("{base}/image/crop"), "crop is not finite");
400 } else {
401 if !(0.0..=1.0).contains(&c.x)
402 || !(0.0..=1.0).contains(&c.y)
403 || c.z <= 0.0
404 || c.w <= 0.0
405 || c.z > 1.0
406 || c.w > 1.0
407 {
408 self.err(
409 format!("{base}/image/crop"),
410 "crop must be x,y in [0,1] and w,h in (0,1]",
411 );
412 }
413 if c.x + c.z > 1.0 + 1e-9 || c.y + c.w > 1.0 + 1e-9 {
414 self.err(
415 format!("{base}/image/crop"),
416 "crop rect must be inside [0,1] image bounds (x+w<=1, y+h<=1)",
417 );
418 }
419 }
420 }
421 NodeKind::Group | NodeKind::Precomp { .. } => {}
422 }
423 }
424
425 fn validate_shape_animations(&mut self, id: NodeId, shape: &ShapeKind) {
426 let base = format!("node/{id:?}/shape");
427 match shape {
428 ShapeKind::Path(path) => {
429 self.check_animated(&format!("{base}/path"), path, finite_path);
430 }
431 ShapeKind::Rect { pos, size, rounded } => {
432 self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
433 self.check_animated(&format!("{base}/size"), size, finite_vec2);
434 self.check_animated(&format!("{base}/rounded"), rounded, finite_f64);
435 }
436 ShapeKind::Ellipse { pos, size } => {
437 self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
438 self.check_animated(&format!("{base}/size"), size, finite_vec2);
439 }
440 ShapeKind::Star {
441 pos,
442 points,
443 inner_r,
444 outer_r,
445 roundness,
446 ..
447 } => {
448 self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
449 self.check_animated(&format!("{base}/points"), points, finite_f64);
450 self.check_animated(&format!("{base}/inner_r"), inner_r, finite_f64);
451 self.check_animated(&format!("{base}/outer_r"), outer_r, finite_f64);
452 self.check_animated(&format!("{base}/roundness"), roundness, finite_f64);
453 }
454 ShapeKind::Polygon {
455 pos,
456 points,
457 outer_r,
458 roundness,
459 } => {
460 self.check_animated(&format!("{base}/pos"), pos, finite_vec2);
461 self.check_animated(&format!("{base}/points"), points, finite_f64);
462 self.check_animated(&format!("{base}/outer_r"), outer_r, finite_f64);
463 self.check_animated(&format!("{base}/roundness"), roundness, finite_f64);
464 }
465 ShapeKind::CompoundPath(compound) => {
466 for (i, contour) in compound.contours.iter().enumerate() {
467 self.check_animated(&format!("{base}/contour/{i}"), contour, finite_path);
468 }
469 }
470 }
471 }
472
473 fn validate_style_animations(&mut self, id: NodeId, style: &StyleKind) {
474 let base = format!("node/{id:?}/style");
475 match style {
476 StyleKind::Fill { paint, .. } => {
477 self.validate_paint(&format!("{base}/paint"), paint);
478 }
479 StyleKind::Stroke {
480 paint, width, dash, ..
481 } => {
482 self.validate_paint(&format!("{base}/paint"), paint);
483 self.check_animated(&format!("{base}/width"), width, finite_f64);
484 if let Some(dash) = dash {
485 for (i, d) in dash.dashes.iter().enumerate() {
486 self.check_animated(&format!("{base}/dash/{i}"), d, finite_f64);
487 }
488 self.check_animated(&format!("{base}/dash/offset"), &dash.offset, finite_f64);
489 }
490 }
491 }
492 }
493
494 fn validate_paint(&mut self, path: &str, paint: &StylePaint) {
495 match paint {
496 StylePaint::Solid { color } => self.check_animated(path, color, finite_color),
497 StylePaint::Gradient(gradient) => {
498 self.check_animated(&format!("{path}/start"), &gradient.start, finite_vec2);
499 self.check_animated(&format!("{path}/end"), &gradient.end, finite_vec2);
500 self.check_animated(&format!("{path}/stops"), &gradient.stops, finite_stops);
501 }
502 }
503 }
504
505 fn validate_modifier_animations(&mut self, id: NodeId, modifier: &ModifierKind) {
506 let base = format!("node/{id:?}/modifier");
507 match modifier {
508 ModifierKind::TrimPath {
509 start, end, offset, ..
510 } => {
511 self.check_animated(&format!("{base}/start"), start, finite_f64);
512 self.check_animated(&format!("{base}/end"), end, finite_f64);
513 self.check_animated(&format!("{base}/offset"), offset, finite_f64);
514 }
515 ModifierKind::Repeater {
516 copies,
517 offset,
518 transform,
519 start_opacity,
520 end_opacity,
521 } => {
522 self.check_animated(&format!("{base}/copies"), copies, finite_f64);
523 self.check_animated(&format!("{base}/offset"), offset, finite_f64);
524 self.check_animated(&format!("{base}/start_opacity"), start_opacity, finite_f64);
525 self.check_animated(&format!("{base}/end_opacity"), end_opacity, finite_f64);
526 self.check_transform(&format!("{base}/transform"), transform);
527 }
528 ModifierKind::RoundCorners { radius } => {
529 self.check_animated(&format!("{base}/radius"), radius, finite_f64);
530 }
531 ModifierKind::OffsetPath { amount } => {
532 self.check_animated(&format!("{base}/amount"), amount, finite_f64);
533 }
534 ModifierKind::ZigZag {
535 amplitude,
536 frequency,
537 ..
538 } => {
539 self.check_animated(&format!("{base}/amplitude"), amplitude, finite_f64);
540 self.check_animated(&format!("{base}/frequency"), frequency, finite_f64);
541 }
542 ModifierKind::PuckerBloat { amount } => {
543 self.check_animated(&format!("{base}/amount"), amount, finite_f64);
544 }
545 }
546 }
547
548 fn check_transform(&mut self, path: &str, transform: &AnimatedTransform) {
549 self.check_animated(&format!("{path}/anchor"), &transform.anchor, finite_vec2);
550 self.check_animated(
551 &format!("{path}/position"),
552 &transform.position,
553 finite_vec2,
554 );
555 self.check_animated(&format!("{path}/scale"), &transform.scale, finite_vec2);
556 self.check_animated(
557 &format!("{path}/rotation"),
558 &transform.rotation,
559 finite_angle,
560 );
561 self.check_animated(&format!("{path}/skew"), &transform.skew, finite_f64);
562 self.check_animated(
563 &format!("{path}/skew_axis"),
564 &transform.skew_axis,
565 finite_f64,
566 );
567 }
568
569 fn check_animated<T>(
570 &mut self,
571 path: &str,
572 animated: &Animated<T>,
573 check_value: impl Fn(&T) -> bool,
574 ) {
575 if !check_value(&animated.base) {
576 self.err(format!("{path}/base"), "value is not finite");
577 }
578 let mut prev: Option<Frame> = None;
579 for (i, key) in animated.keyframes.iter().enumerate() {
580 if let Some(p) = prev
581 && key.frame <= p
582 {
583 self.err(
584 format!("{path}/key/{i}"),
585 format!(
586 "keyframes not strictly increasing (duplicate or out of order at frame {})",
587 key.frame.0
588 ),
589 );
590 }
591 if !check_value(&key.value) {
592 self.err(format!("{path}/key/{i}"), "keyframe value is not finite");
593 }
594 if !key.ease_out.x.is_finite()
595 || !key.ease_out.y.is_finite()
596 || !key.ease_in.x.is_finite()
597 || !key.ease_in.y.is_finite()
598 {
599 self.err(
600 format!("{path}/key/{i}/easing"),
601 "easing handle is not finite",
602 );
603 }
604 prev = Some(key.frame);
605 }
606 }
607
608 fn validate_scope(&mut self) {
612 let doc = &self.file.document;
613 let mut visited = HashSet::new();
614 for (comp_id, comp) in &doc.compositions {
615 self.scope_group(
616 comp.children.to_vec(),
617 format!("composition/{comp_id:?}"),
618 &mut visited,
619 );
620 }
621 }
622
623 fn scope_group(&mut self, children: Vec<NodeId>, path: String, visited: &mut HashSet<NodeId>) {
624 let doc = &self.file.document;
625 let mut has_shape = false;
626
627 for (index, &id) in children.iter().enumerate() {
628 let Some(node) = doc.nodes.get(id) else {
629 continue;
630 };
631 match &node.kind {
632 NodeKind::Shape(_) | NodeKind::Text(_) => has_shape = true,
633 NodeKind::Modifier(_) if !has_shape => {
634 self.warn(
635 format!("{path}/children/{index}"),
636 "modifier appears before any shape in scope and will have no effect",
637 );
638 }
639 _ => {}
640 }
641 }
642
643 if !has_shape {
644 for (index, &id) in children.iter().enumerate() {
645 let Some(node) = doc.nodes.get(id) else {
646 continue;
647 };
648 if matches!(node.kind, NodeKind::Style(_)) {
649 self.warn(
650 format!("{path}/children/{index}"),
651 "style node is not paired with any shape in scope",
652 );
653 }
654 }
655 }
656
657 for &id in &children {
658 let Some(node) = doc.nodes.get(id) else {
659 continue;
660 };
661 if matches!(node.kind, NodeKind::Group | NodeKind::Layer(_)) && visited.insert(id) {
662 self.scope_group(
663 node.children.clone(),
664 format!("{path}/node/{id:?}"),
665 visited,
666 );
667 }
668 }
669 }
670
671 fn validate_precomps(&mut self) {
672 let doc = &self.file.document;
673
674 for (id, node) in &doc.nodes {
675 if let NodeKind::Precomp { comp, time_map } = &node.kind {
676 if !doc.compositions.contains_key(*comp) {
677 self.err(
678 format!("node/{id:?}/precomp"),
679 "referenced composition does not exist",
680 );
681 }
682 if !time_map.stretch.is_finite() || time_map.stretch.abs() < 1e-6 {
683 self.err(
684 format!("node/{id:?}/precomp/stretch"),
685 "invalid time stretch",
686 );
687 }
688 }
689 }
690
691 let mut on_stack = HashSet::new();
692 let mut visited = HashSet::new();
693 for comp in doc.compositions.keys() {
694 self.walk_precomp(comp, &mut on_stack, &mut visited);
695 }
696 }
697
698 fn walk_precomp(
699 &mut self,
700 comp: CompId,
701 on_stack: &mut HashSet<CompId>,
702 visited: &mut HashSet<CompId>,
703 ) {
704 if on_stack.contains(&comp) {
705 self.err(
706 format!("precomp/{comp:?}"),
707 "composition is reachable from itself through precomps (cycle)",
708 );
709 return;
710 }
711 if !visited.insert(comp) {
712 return;
713 }
714 on_stack.insert(comp);
715 if let Some(c) = self.file.document.compositions.get(comp) {
716 let mut stack: Vec<NodeId> = c.children.clone();
717 let mut seen_nodes = HashSet::new();
718 while let Some(nid) = stack.pop() {
719 if !seen_nodes.insert(nid) {
720 continue;
721 }
722 let Some(node) = self.file.document.nodes.get(nid) else {
723 continue;
724 };
725 if let NodeKind::Precomp { comp: target, .. } = &node.kind {
726 self.walk_precomp(*target, on_stack, visited);
727 }
728 if matches!(node.kind, NodeKind::Group | NodeKind::Layer(_)) {
729 stack.extend(node.children.iter().copied());
730 }
731 }
732 }
733 on_stack.remove(&comp);
734 }
735
736 fn validate_clips(&mut self) {
737 let doc = &self.file.document;
738
739 let mut seen = HashSet::new();
740 for (i, &id) in self.file.clip_order.iter().enumerate() {
741 if !self.file.clips.contains_key(id) {
742 self.err(format!("clips/order/{i}"), "clip id does not exist");
743 }
744 if !seen.insert(id) {
745 self.err(
746 format!("clips/order/{i}"),
747 "duplicate clip id in clip_order",
748 );
749 }
750 }
751
752 for (clip_id, clip) in &self.file.clips {
753 if clip.range.1 <= clip.range.0 {
754 self.err(format!("clip/{clip_id:?}/range"), "invalid clip range");
755 }
756
757 for (track_index, track) in clip.tracks.iter().enumerate() {
758 let track_path = format!("clip/{clip_id:?}/track/{track_index}");
759 let prop = match doc.nodes.get(track.node) {
760 Some(node) => match node.prop_ref(&track.prop) {
761 Some(prop) => prop,
762 None => {
763 self.err(
764 format!("{track_path}/prop"),
765 "track references missing or incompatible property",
766 );
767 continue;
768 }
769 },
770 None => {
771 self.err(
772 format!("{track_path}/node"),
773 "track references missing node",
774 );
775 continue;
776 }
777 };
778
779 let mut prev: Option<Frame> = None;
780 for (key_index, key) in track.keys.iter().enumerate() {
781 if let Some(p) = prev
782 && key.frame <= p
783 {
784 self.err(
785 format!("{track_path}/key/{key_index}"),
786 "clip keyframes not strictly increasing (duplicate or out of order)",
787 );
788 }
789 if !key_value_matches_prop(&key.value, &prop) {
790 self.err(
791 format!("{track_path}/key/{key_index}/value"),
792 "keyframe value type does not match property",
793 );
794 }
795 prev = Some(key.frame);
796 }
797 }
798 }
799 }
800
801 fn validate_machines(&mut self) {
802 let doc = &self.file.document;
803
804 if let Some(start) = self.file.start_machine {
805 if !self.file.machines.contains_key(start) {
806 self.err("start_machine", "start machine does not exist");
807 }
808 if !self.file.machine_order.contains(&start) {
809 self.warn(
810 "start_machine",
811 "start machine exists but is detached from machine_order",
812 );
813 }
814 }
815
816 let mut seen = HashSet::new();
817 for (i, &id) in self.file.machine_order.iter().enumerate() {
818 if !self.file.machines.contains_key(id) {
819 self.err(format!("machines/order/{i}"), "machine id does not exist");
820 }
821 if !seen.insert(id) {
822 self.err(
823 format!("machines/order/{i}"),
824 "duplicate machine id in machine_order",
825 );
826 }
827 }
828
829 for (machine_id, machine) in &self.file.machines {
830 for (layer_index, layer) in machine.layers.iter().enumerate() {
831 if layer.states.is_empty() {
832 self.err(
833 format!("machine/{machine_id:?}/layer/{layer_index}"),
834 "layer has no states",
835 );
836 continue;
837 }
838
839 if layer.entry >= layer.states.len() {
840 self.err(
841 format!("machine/{machine_id:?}/layer/{layer_index}/entry"),
842 "entry state index is out of range",
843 );
844 }
845
846 for (state_index, state) in layer.states.iter().enumerate() {
847 match &state.kind {
848 StateKind::Clip { clip, speed, .. } => {
849 if !self.file.clips.contains_key(*clip) {
850 self.err(
851 format!("machine/{machine_id:?}/layer/{layer_index}/state/{state_index}/clip"),
852 "state references missing clip",
853 );
854 }
855 if !speed.is_finite() || *speed < 0.0 {
856 self.err(
857 format!("machine/{machine_id:?}/layer/{layer_index}/state/{state_index}/speed"),
858 "clip state speed must be non-negative and finite",
859 );
860 }
861 }
862 StateKind::Blend1D { input, children } => {
863 let base = format!(
864 "machine/{machine_id:?}/layer/{layer_index}/state/{state_index}/blend"
865 );
866 match machine.inputs.get(*input) {
867 Some(input_def) => {
868 if !matches!(input_def.kind, InputKind::Number { .. }) {
869 self.err(
870 format!("{base}/input"),
871 "Blend1D input must be a number input",
872 );
873 }
874 }
875 None => self.err(
876 format!("{base}/input"),
877 "Blend1D input index is out of range",
878 ),
879 }
880 if children.is_empty() {
881 self.err(format!("{base}/children"), "Blend1D has no children");
882 }
883 let mut prev: Option<f64> = None;
884 for (child_index, child) in children.iter().enumerate() {
885 if !self.file.clips.contains_key(child.clip) {
886 self.err(
887 format!("{base}/child/{child_index}"),
888 "blend child references missing clip",
889 );
890 }
891 if !child.threshold.is_finite() {
892 self.err(
893 format!("{base}/child/{child_index}/threshold"),
894 "blend threshold must be finite",
895 );
896 }
897 if let Some(p) = prev
898 && child.threshold <= p
899 {
900 self.warn(
901 format!("{base}/child/{child_index}/threshold"),
902 "blend thresholds are not strictly increasing",
903 );
904 }
905 prev = Some(child.threshold);
906 }
907 }
908 StateKind::Empty => {}
909 }
910
911 self.validate_transitions(
912 machine_id,
913 machine,
914 layer_index,
915 Some(state_index),
916 &state.transitions,
917 );
918 }
919
920 self.validate_transitions(
921 machine_id,
922 machine,
923 layer_index,
924 None,
925 &layer.any_transitions,
926 );
927 }
928
929 for (listener_index, listener) in machine.listeners.iter().enumerate() {
930 if !doc.nodes.contains_key(listener.node) {
931 self.err(
932 format!("machine/{machine_id:?}/listener/{listener_index}/node"),
933 "listener references missing node",
934 );
935 }
936
937 let input = listener_action_input(&listener.action);
938 let base = format!("machine/{machine_id:?}/listener/{listener_index}");
939 match machine.inputs.get(input) {
940 Some(input_def) => {
941 if !listener_matches_input(&listener.action, input_def.kind) {
942 self.err(
943 format!("{base}/input"),
944 "listener action type does not match input type",
945 );
946 }
947 }
948 None => self.err(format!("{base}/input"), "listener references missing input"),
949 }
950 }
951 }
952 }
953
954 #[allow(clippy::too_many_arguments)]
955 fn validate_transitions(
956 &mut self,
957 machine_id: MachineId,
958 machine: &Machine,
959 layer_index: usize,
960 state_index: Option<usize>,
961 transitions: &[Transition],
962 ) {
963 let Some(layer) = machine.layers.get(layer_index) else {
964 return;
965 };
966
967 for (transition_index, transition) in transitions.iter().enumerate() {
968 let base = match state_index {
969 Some(s) => format!(
970 "machine/{machine_id:?}/layer/{layer_index}/state/{s}/transition/{transition_index}"
971 ),
972 None => format!(
973 "machine/{machine_id:?}/layer/{layer_index}/any_transition/{transition_index}"
974 ),
975 };
976
977 if transition.to >= layer.states.len() {
978 self.err(&base, "transition target state is out of range");
979 }
980 if !transition.duration.is_finite() || transition.duration < 0.0 {
981 self.err(&base, "transition duration must be non-negative and finite");
982 }
983 if let Some(exit_time) = transition.exit_time
984 && (!exit_time.is_finite() || !(0.0..=1.0).contains(&exit_time))
985 {
986 self.err(&base, "transition exit_time must be in [0, 1]");
987 }
988
989 for (condition_index, condition) in transition.conditions.iter().enumerate() {
990 let input = condition_input(condition);
991 let condition_path = format!("{base}/condition/{condition_index}");
992 match machine.inputs.get(input) {
993 Some(input_def) => {
994 if !condition_matches_input(condition, input_def.kind) {
995 self.err(&condition_path, "condition type does not match input type");
996 }
997 }
998 None => self.err(&condition_path, "condition references missing input"),
999 }
1000 }
1001 }
1002 }
1003
1004 fn validate_export_readiness(&mut self) {
1005 let doc = &self.file.document;
1006
1007 let direct: HashSet<NodeId> = doc
1011 .compositions
1012 .values()
1013 .flat_map(|c| c.children.iter().copied())
1014 .collect();
1015
1016 for (id, node) in &doc.nodes {
1017 match &node.kind {
1018 NodeKind::Text(_) => {
1019 self.warn(
1020 format!("node/{id:?}/text"),
1021 "Lottie export bakes text to vector outlines",
1022 );
1023 }
1024 NodeKind::Mask(_) => {
1025 self.warn(
1026 format!("node/{id:?}/mask"),
1027 "Lottie mask export is best-effort and may differ from Renamite clip-stack semantics",
1028 );
1029 }
1030 NodeKind::Image(img) => {
1031 if doc.image_asset(img.asset()).is_none() {
1032 self.err(
1033 format!("node/{id:?}/image"),
1034 "image layer references missing image asset",
1035 );
1036 }
1037 if !direct.contains(&id) {
1038 self.warn(
1039 format!("node/{id:?}/image"),
1040 "nested image layer is skipped by Lottie export",
1041 );
1042 }
1043 }
1044 NodeKind::Precomp { .. } if !direct.contains(&id) => {
1045 self.warn(
1046 format!("node/{id:?}/precomp"),
1047 "nested precomp is skipped by Lottie export",
1048 );
1049 }
1050 _ => {}
1051 }
1052 }
1053 }
1054}
1055
1056fn condition_input(condition: &Condition) -> usize {
1057 match condition {
1058 Condition::BoolIs { input, .. }
1059 | Condition::NumberCmp { input, .. }
1060 | Condition::Triggered { input } => *input,
1061 }
1062}
1063
1064fn condition_matches_input(condition: &Condition, input: InputKind) -> bool {
1065 matches!(
1066 (condition, input),
1067 (Condition::BoolIs { .. }, InputKind::Bool { .. })
1068 | (Condition::NumberCmp { .. }, InputKind::Number { .. })
1069 | (Condition::Triggered { .. }, InputKind::Trigger)
1070 )
1071}
1072
1073fn listener_action_input(action: &ListenerAction) -> usize {
1074 match action {
1075 ListenerAction::SetBool { input, .. }
1076 | ListenerAction::ToggleBool { input }
1077 | ListenerAction::SetNumber { input, .. }
1078 | ListenerAction::FireTrigger { input } => *input,
1079 }
1080}
1081
1082fn listener_matches_input(action: &ListenerAction, input: InputKind) -> bool {
1083 matches!(
1084 (action, input),
1085 (ListenerAction::SetBool { .. }, InputKind::Bool { .. })
1086 | (ListenerAction::ToggleBool { .. }, InputKind::Bool { .. })
1087 | (ListenerAction::SetNumber { .. }, InputKind::Number { .. })
1088 | (ListenerAction::FireTrigger { .. }, InputKind::Trigger)
1089 )
1090}
1091
1092fn key_value_matches_prop(value: &Value, prop: &PropRef) -> bool {
1093 matches!(
1094 (value, prop),
1095 (Value::F64(_), PropRef::F64(_))
1096 | (Value::DVec2(_), PropRef::Vec2(_))
1097 | (Value::Angle(_), PropRef::Angle(_))
1098 | (Value::Color(_), PropRef::Color(_))
1099 | (Value::Path(_), PropRef::Path(_))
1100 | (Value::Stops(_), PropRef::Stops(_))
1101 )
1102}
1103
1104fn finite_f64(value: &f64) -> bool {
1105 value.is_finite()
1106}
1107
1108fn finite_vec2(value: &DVec2) -> bool {
1109 value.is_finite()
1110}
1111
1112fn finite_angle(value: &Angle) -> bool {
1113 value.0.is_finite()
1114}
1115
1116fn finite_color(color: &Color) -> bool {
1117 color.r.is_finite() && color.g.is_finite() && color.b.is_finite() && color.a.is_finite()
1118}
1119
1120fn finite_stops(stops: &GradientStops) -> bool {
1121 stops
1122 .0
1123 .iter()
1124 .all(|s| s.offset.is_finite() && finite_color(&s.color))
1125}
1126
1127fn finite_path(path: &VectorPath) -> bool {
1128 path.anchors
1129 .iter()
1130 .all(|a| a.pos.is_finite() && a.tan_in.is_finite() && a.tan_out.is_finite())
1131}
1132
1133fn shape_kind_is_empty(shape: &ShapeKind) -> bool {
1136 match shape {
1137 ShapeKind::Path(path) => path.base.anchors.is_empty(),
1138 ShapeKind::CompoundPath(compound) => compound.contours.is_empty(),
1139 ShapeKind::Rect { size, .. } | ShapeKind::Ellipse { size, .. } => {
1140 size.base.x == 0.0 && size.base.y == 0.0
1141 }
1142 ShapeKind::Star { outer_r, .. } | ShapeKind::Polygon { outer_r, .. } => outer_r.base <= 0.0,
1143 }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148 use super::*;
1149 use renamite_animation::{EasingHandle, Interpolation, Keyframe};
1150 use renamite_model::{AssetId, Parent, TextAlign, TextNode};
1151
1152 fn file(name: &str) -> RenFile {
1153 RenFile::new(Document::empty(), name)
1154 }
1155
1156 #[test]
1157 fn empty_project_is_valid() {
1158 let report = validate(&file("empty"));
1159 assert_eq!(report.error_count(), 0);
1160 assert_eq!(report.warning_count(), 0);
1161 }
1162
1163 #[test]
1164 fn missing_image_asset_is_error() {
1165 let mut file = file("bad");
1166 let fake = AssetId::from(slotmap::KeyData::from_ffi(42));
1167 let node = file.document.create_node(Node::new(
1168 "img",
1169 NodeKind::Image(renamite_model::ImageNode::new(fake)),
1170 ));
1171 file.document
1172 .attach(node, Parent::Comp(file.document.main), 0)
1173 .unwrap();
1174
1175 let report = validate(&file);
1176 assert!(report.has_errors());
1177 assert!(
1178 report
1179 .diagnostics
1180 .iter()
1181 .any(|d| d.message.contains("image asset"))
1182 );
1183 }
1184
1185 #[test]
1186 fn missing_font_family_is_warning() {
1187 let mut file = file("font");
1188 let node = file.document.create_node(Node::new(
1189 "t",
1190 NodeKind::Text(TextNode {
1191 text: String::new(),
1192 size: Animated::new(48.0),
1193 align: TextAlign::Left,
1194 font: Some("Missing".into()),
1195 tracking: Animated::new(0.0),
1196 leading: Animated::new(0.0),
1197 }),
1198 ));
1199 file.document
1200 .attach(node, Parent::Comp(file.document.main), 0)
1201 .unwrap();
1202
1203 let report = validate(&file);
1204 assert_eq!(report.error_count(), 0);
1205 assert!(report.warning_count() > 0);
1206 assert!(
1207 report
1208 .diagnostics
1209 .iter()
1210 .any(|d| d.message.contains("font family"))
1211 );
1212 }
1213
1214 #[test]
1215 fn duplicate_animated_keyframes_are_error() {
1216 use renamite_model::NodeKind;
1217 let mut file = file("keys");
1218 let mut node = Node::new("n", NodeKind::Group);
1219 node.opacity = Animated {
1220 base: 1.0,
1221 keyframes: vec![
1222 Keyframe {
1223 frame: Frame(0),
1224 value: 1.0,
1225 interpolation: Interpolation::Linear,
1226 ease_out: EasingHandle::LINEAR_OUT,
1227 ease_in: EasingHandle::LINEAR_IN,
1228 },
1229 Keyframe {
1230 frame: Frame(0),
1231 value: 0.5,
1232 interpolation: Interpolation::Linear,
1233 ease_out: EasingHandle::LINEAR_OUT,
1234 ease_in: EasingHandle::LINEAR_IN,
1235 },
1236 ],
1237 };
1238 let id = file.document.create_node(node);
1239 file.document
1240 .attach(id, Parent::Comp(file.document.main), 0)
1241 .unwrap();
1242
1243 let report = validate(&file);
1244 assert!(report.has_errors());
1245 assert!(
1246 report
1247 .diagnostics
1248 .iter()
1249 .any(|d| d.message.contains("strictly increasing"))
1250 );
1251 }
1252
1253 #[test]
1254 fn machine_bad_transition_is_error() {
1255 use renamite_machine::{Machine, MachineLayer, State};
1256 let mut file = file("machine");
1257 file.machines.insert(Machine {
1258 name: "m".into(),
1259 inputs: vec![],
1260 layers: vec![MachineLayer {
1261 name: "base".into(),
1262 entry: 0,
1263 any_transitions: vec![],
1264 states: vec![State {
1265 name: "s".into(),
1266 kind: StateKind::Empty,
1267 transitions: vec![Transition {
1268 to: 99,
1269 duration: 0.0,
1270 exit_time: None,
1271 conditions: vec![],
1272 }],
1273 graph_pos: None,
1274 }],
1275 }],
1276 listeners: vec![],
1277 });
1278
1279 let report = validate(&file);
1280 assert!(report.has_errors());
1281 assert!(
1282 report
1283 .diagnostics
1284 .iter()
1285 .any(|d| d.path.contains("transition"))
1286 );
1287 }
1288
1289 #[test]
1290 fn empty_blend1d_children_are_error() {
1291 use renamite_machine::{Machine, MachineLayer, State};
1292 let mut file = file("blend");
1293 file.machines.insert(Machine {
1294 name: "m".into(),
1295 inputs: vec![renamite_machine::InputDef {
1296 name: "n".into(),
1297 kind: InputKind::Number { default: 0.0 },
1298 }],
1299 layers: vec![MachineLayer {
1300 name: "base".into(),
1301 entry: 0,
1302 any_transitions: vec![],
1303 states: vec![State {
1304 name: "s".into(),
1305 kind: StateKind::Blend1D {
1306 input: 0,
1307 children: vec![],
1308 },
1309 transitions: vec![],
1310 graph_pos: None,
1311 }],
1312 }],
1313 listeners: vec![],
1314 });
1315
1316 let report = validate(&file);
1317 assert!(report.has_errors());
1318 assert!(
1319 report
1320 .diagnostics
1321 .iter()
1322 .any(|d| d.message.contains("Blend1D has no children"))
1323 );
1324 }
1325
1326 #[test]
1327 fn style_without_shape_in_scope_warns() {
1328 use renamite_model::{FillRule, StylePaint};
1329 let mut file = file("style");
1330 let fill = file.document.create_node(Node::new(
1331 "Fill",
1332 NodeKind::Style(StyleKind::Fill {
1333 paint: StylePaint::solid(Color::WHITE),
1334 rule: FillRule::NonZero,
1335 }),
1336 ));
1337 file.document
1338 .attach(fill, Parent::Comp(file.document.main), 0)
1339 .unwrap();
1340
1341 let report = validate(&file);
1342 assert_eq!(report.error_count(), 0);
1343 assert!(
1344 report
1345 .diagnostics
1346 .iter()
1347 .any(|d| d.message.contains("not paired with any shape"))
1348 );
1349 }
1350
1351 #[test]
1352 fn precomp_cycle_is_error() {
1353 let mut file = file("cycle");
1354 let comp_a = file.document.main;
1355 let comp_b = file
1356 .document
1357 .compositions
1358 .insert(renamite_model::Composition {
1359 name: "B".into(),
1360 size: (512, 512),
1361 rate: renamite_animation::FrameRate { num: 60, den: 1 },
1362 range: (Frame(0), Frame(60)),
1363 children: vec![],
1364 });
1365 let node_a = file.document.create_node(Node::new(
1366 "to B",
1367 NodeKind::Precomp {
1368 comp: comp_b,
1369 time_map: renamite_model::TimeMap {
1370 offset: Frame(0),
1371 stretch: 1.0,
1372 },
1373 },
1374 ));
1375 file.document
1376 .attach(node_a, Parent::Comp(comp_a), 0)
1377 .unwrap();
1378 let node_b = file.document.create_node(Node::new(
1379 "to A",
1380 NodeKind::Precomp {
1381 comp: comp_a,
1382 time_map: renamite_model::TimeMap {
1383 offset: Frame(0),
1384 stretch: 1.0,
1385 },
1386 },
1387 ));
1388 file.document
1389 .attach(node_b, Parent::Comp(comp_b), 0)
1390 .unwrap();
1391
1392 let report = validate(&file);
1393 assert!(report.has_errors());
1394 assert!(
1395 report
1396 .diagnostics
1397 .iter()
1398 .any(|d| d.message.contains("cycle"))
1399 );
1400 }
1401
1402 #[test]
1403 fn clip_track_missing_node_is_error() {
1404 use renamite_machine::Clip;
1405 let mut file = file("clip");
1406 let missing = NodeId::from(slotmap::KeyData::from_ffi(7));
1407 file.clips.insert(Clip {
1408 name: "c".into(),
1409 range: (Frame(0), Frame(10)),
1410 tracks: vec![renamite_machine::Track {
1411 node: missing,
1412 prop: renamite_model::PropPath::new("opacity"),
1413 keys: vec![],
1414 }],
1415 events: vec![],
1416 });
1417
1418 let report = validate(&file);
1419 assert!(report.has_errors());
1420 assert!(
1421 report
1422 .diagnostics
1423 .iter()
1424 .any(|d| d.message.contains("missing node"))
1425 );
1426 }
1427}