animsmith-core 0.2.1

Engine-agnostic data model, sampling, measurements, and checks for the animsmith animation-clip linter
Documentation
//! `duration-sanity` — degenerate clip durations, misses against a
//! declared duration pin, and channels within one clip that end at
//! different times (an engine clamp-holds the shorter channels, which
//! usually means a partial export).

use crate::check::{Check, CheckCtx};
use crate::evaluation::CheckOutput;
use crate::finding::{Finding, Severity};

/// Channel end-time spread beyond this is flagged (half a frame at
/// 30 fps).
pub const END_SPREAD_TOLERANCE_S: f32 = 0.017;

pub struct DurationSanity;

impl Check for DurationSanity {
    fn id(&self) -> &'static str {
        "duration-sanity"
    }

    fn evaluate(&self, ctx: &CheckCtx) -> CheckOutput {
        let mut findings = Vec::new();
        let doc = ctx.doc;
        for (index, clip) in doc.clips.iter().enumerate() {
            let duration_pin = ctx.expectations(index).duration_s;
            let valid_duration_pin = duration_pin.filter(|pin| {
                pin.value.is_finite()
                    && pin.value > 0.0
                    && pin.tolerance.is_finite()
                    && pin.tolerance >= 0.0
            });
            if let Some(pin) = duration_pin
                && valid_duration_pin.is_none()
            {
                findings.push(
                    Finding::new(
                        self.id(),
                        Severity::Error,
                        format!(
                            "invalid declared duration pin (value {}s, tolerance {}s) — \
                             value must be finite and positive, and tolerance must be \
                             finite and non-negative",
                            pin.value, pin.tolerance
                        ),
                    )
                    .clip(&clip.name)
                    .measured(clip.duration_s),
                );
            }
            if clip.tracks.is_empty() {
                findings.push(
                    Finding::new(self.id(), Severity::Warning, "clip has no tracks")
                        .clip(&clip.name),
                );
                if valid_duration_pin.is_none() {
                    continue;
                }
            }
            if clip.duration_s <= 0.0 || !clip.duration_s.is_finite() {
                let mut finding = Finding::new(
                    self.id(),
                    Severity::Error,
                    format!("degenerate clip duration ({}s)", clip.duration_s),
                )
                .clip(&clip.name)
                .measured(clip.duration_s);
                if let Some(pin) = valid_duration_pin {
                    finding = finding.expected(pin.value);
                }
                findings.push(finding);
                continue;
            }
            if let Some(pin) = valid_duration_pin
                && (clip.duration_s - pin.value).abs() > pin.tolerance
            {
                findings.push(
                    Finding::new(
                        self.id(),
                        Severity::Error,
                        format!(
                            "measured duration {:.4}s disagrees with the declared \
                             {:.4} ± {:.4}s — the clip may have been sliced or \
                             exported with a dropped frame",
                            clip.duration_s, pin.value, pin.tolerance
                        ),
                    )
                    .clip(&clip.name)
                    .measured(clip.duration_s)
                    .expected(pin.value),
                );
            }
            // Single-key tracks are pinned values, not truncated
            // channels — a common bake idiom — so they don't count
            // toward the end spread.
            let ends: Vec<f32> = clip
                .tracks
                .iter()
                .filter(|t| t.key_count() >= 2)
                .map(|t| t.end_time())
                .filter(|end| end.is_finite())
                .collect();
            if ends.is_empty() {
                continue;
            }
            let max = ends.iter().copied().fold(f32::MIN, f32::max);
            let min = ends.iter().copied().fold(f32::MAX, f32::min);
            if max - min > END_SPREAD_TOLERANCE_S {
                findings.push(
                    Finding::new(
                        self.id(),
                        Severity::Warning,
                        format!(
                            "channels end at different times ({min:.3}s..{max:.3}s) — \
                             shorter channels will be clamp-held"
                        ),
                    )
                    .clip(&clip.name)
                    .measured((max - min) as f64)
                    .expected(0.0f64),
                );
            }
        }
        CheckOutput::from_coverage(findings, Vec::new(), Vec::new())
    }
}