motionloom 0.1.0

MotionLoom DSL parser and renderer for video effects, scene graphs, motion graphics, and world graphs.
Documentation
use std::borrow::Cow;

use crate::scene::render::MotionLoomSceneRenderError;

use super::{PathToken, tokenize_path_data};

#[derive(Debug, Clone)]
struct PathMorphKeyframe {
    time_sec: f32,
    tokens: Vec<PathMorphToken>,
}

#[derive(Debug, Clone, Copy)]
enum PathMorphToken {
    Command(char),
    Number(f32),
}

pub(crate) fn eval_path_d<'a>(
    d: &'a str,
    _time_norm: f32,
    time_sec: f32,
) -> Result<Cow<'a, str>, MotionLoomSceneRenderError> {
    let trimmed = d.trim();
    if !trimmed.starts_with("morph(") {
        return Ok(Cow::Borrowed(d));
    }

    Ok(Cow::Owned(eval_path_morph(trimmed, time_sec)?))
}

fn eval_path_morph(expr: &str, time_sec: f32) -> Result<String, MotionLoomSceneRenderError> {
    let inner = expr
        .strip_prefix("morph(")
        .and_then(|value| value.strip_suffix(')'))
        .ok_or_else(|| invalid_path_morph(expr, "expected morph(\"time:path\", ...)"))?;
    let args = split_path_morph_args(inner, expr)?;
    if args.len() < 2 {
        return Err(invalid_path_morph(
            expr,
            "morph requires at least two keyframes.",
        ));
    }

    let mut keyframes = args
        .iter()
        .map(|arg| parse_path_morph_keyframe(arg, expr))
        .collect::<Result<Vec<_>, _>>()?;
    keyframes.sort_by(|a, b| a.time_sec.total_cmp(&b.time_sec));
    for pair in keyframes.windows(2) {
        if (pair[0].time_sec - pair[1].time_sec).abs() <= f32::EPSILON {
            return Err(invalid_path_morph(
                expr,
                format!(
                    "duplicate morph keyframe time: {}",
                    format_path_morph_number(pair[0].time_sec)
                ),
            ));
        }
    }

    if time_sec <= keyframes[0].time_sec {
        return Ok(path_morph_tokens_to_d(&keyframes[0].tokens));
    }
    let last = keyframes.len() - 1;
    if time_sec >= keyframes[last].time_sec {
        return Ok(path_morph_tokens_to_d(&keyframes[last].tokens));
    }

    for pair in keyframes.windows(2) {
        let from = &pair[0];
        let to = &pair[1];
        if time_sec < from.time_sec || time_sec > to.time_sec {
            continue;
        }
        let t = ((time_sec - from.time_sec) / (to.time_sec - from.time_sec)).clamp(0.0, 1.0);
        return interpolate_path_morph_tokens(&from.tokens, &to.tokens, t, expr);
    }

    Ok(path_morph_tokens_to_d(&keyframes[last].tokens))
}

fn split_path_morph_args(
    inner: &str,
    source: &str,
) -> Result<Vec<String>, MotionLoomSceneRenderError> {
    let mut args = Vec::new();
    let mut start = 0usize;
    let mut quote: Option<char> = None;
    let mut escaped = false;
    let mut depth = 0usize;

    for (index, ch) in inner.char_indices() {
        if let Some(quote_ch) = quote {
            if escaped {
                escaped = false;
                continue;
            }
            if ch == '\\' {
                escaped = true;
                continue;
            }
            if ch == quote_ch {
                quote = None;
            }
            continue;
        }

        match ch {
            '"' | '\'' => quote = Some(ch),
            '(' => depth += 1,
            ')' => {
                if depth == 0 {
                    return Err(invalid_path_morph(
                        source,
                        "unexpected ')' in morph arguments.",
                    ));
                }
                depth -= 1;
            }
            ',' if depth == 0 => {
                let arg = inner[start..index].trim();
                if !arg.is_empty() {
                    args.push(arg.to_string());
                }
                start = index + ch.len_utf8();
            }
            _ => {}
        }
    }

    if quote.is_some() {
        return Err(invalid_path_morph(
            source,
            "unterminated string in morph arguments.",
        ));
    }
    if depth != 0 {
        return Err(invalid_path_morph(
            source,
            "unclosed nested expression in morph arguments.",
        ));
    }

    let tail = inner[start..].trim();
    if !tail.is_empty() {
        args.push(tail.to_string());
    }
    Ok(args)
}

fn parse_path_morph_keyframe(
    arg: &str,
    source: &str,
) -> Result<PathMorphKeyframe, MotionLoomSceneRenderError> {
    let arg = unquote_path_morph_arg(arg, source)?;
    let Some((time_raw, path_raw)) = arg.split_once(':') else {
        return Err(invalid_path_morph(
            source,
            "each morph keyframe must be \"seconds:path data\".",
        ));
    };
    let time_sec = time_raw
        .trim()
        .parse::<f32>()
        .map_err(|_| invalid_path_morph(source, format!("invalid keyframe time: {time_raw}")))?;
    if !time_sec.is_finite() {
        return Err(invalid_path_morph(
            source,
            format!("invalid keyframe time: {time_raw}"),
        ));
    }
    let path = path_raw.trim();
    if path.is_empty() {
        return Err(invalid_path_morph(
            source,
            "morph keyframe path data is empty.",
        ));
    }

    let tokens = tokenize_path_data(path)?
        .into_iter()
        .map(|token| match token {
            PathToken::Command(command) => PathMorphToken::Command(command),
            PathToken::Number(value) => PathMorphToken::Number(value),
        })
        .collect();

    Ok(PathMorphKeyframe { time_sec, tokens })
}

fn unquote_path_morph_arg(arg: &str, source: &str) -> Result<String, MotionLoomSceneRenderError> {
    let arg = arg.trim();
    let Some(first) = arg.chars().next() else {
        return Err(invalid_path_morph(source, "empty morph keyframe."));
    };
    if first != '"' && first != '\'' {
        return Ok(arg.to_string());
    }
    if !arg.ends_with(first) || arg.len() < 2 {
        return Err(invalid_path_morph(
            source,
            "unterminated morph keyframe string.",
        ));
    }

    let body = &arg[first.len_utf8()..arg.len() - first.len_utf8()];
    let mut out = String::new();
    let mut escaped = false;
    for ch in body.chars() {
        if escaped {
            out.push(ch);
            escaped = false;
            continue;
        }
        if ch == '\\' {
            escaped = true;
            continue;
        }
        out.push(ch);
    }
    if escaped {
        out.push('\\');
    }
    Ok(out)
}

fn interpolate_path_morph_tokens(
    from: &[PathMorphToken],
    to: &[PathMorphToken],
    t: f32,
    source: &str,
) -> Result<String, MotionLoomSceneRenderError> {
    if from.len() != to.len() {
        return Err(invalid_path_morph(
            source,
            "incompatible path data: keyframes have different token counts.",
        ));
    }

    let mut out = Vec::with_capacity(from.len());
    for (from_token, to_token) in from.iter().zip(to.iter()) {
        match (*from_token, *to_token) {
            (PathMorphToken::Command(a), PathMorphToken::Command(b)) if a == b => {
                out.push(PathMorphToken::Command(a));
            }
            (PathMorphToken::Number(a), PathMorphToken::Number(b)) => {
                out.push(PathMorphToken::Number(a + (b - a) * t));
            }
            (PathMorphToken::Command(a), PathMorphToken::Command(b)) => {
                return Err(invalid_path_morph(
                    source,
                    format!("incompatible path data: command '{a}' does not match '{b}'."),
                ));
            }
            _ => {
                return Err(invalid_path_morph(
                    source,
                    "incompatible path data: command/number layout differs.",
                ));
            }
        }
    }

    Ok(path_morph_tokens_to_d(&out))
}

fn path_morph_tokens_to_d(tokens: &[PathMorphToken]) -> String {
    let mut out = String::new();
    for token in tokens {
        if !out.is_empty() {
            out.push(' ');
        }
        match *token {
            PathMorphToken::Command(command) => out.push(command),
            PathMorphToken::Number(value) => out.push_str(&format_path_morph_number(value)),
        }
    }
    out
}

fn format_path_morph_number(value: f32) -> String {
    let mut text = format!("{value:.3}");
    while text.contains('.') && text.ends_with('0') {
        text.pop();
    }
    if text.ends_with('.') {
        text.pop();
    }
    if text == "-0" { "0".to_string() } else { text }
}

fn invalid_path_morph(source: &str, message: impl Into<String>) -> MotionLoomSceneRenderError {
    MotionLoomSceneRenderError::InvalidPathData {
        value: source.to_string(),
        message: message.into(),
    }
}