rustmotion-core 0.8.1

Core types, traits, and rendering utilities for rustmotion
Documentation
#![allow(clippy::redundant_field_names)]

use thiserror::Error;

pub type Result<T> = std::result::Result<T, RustmotionError>;

#[derive(Debug, Error)]
pub enum RustmotionError {
    #[error("{0}")]
    Generic(String),

    #[error("Failed to read '{path}': {source}")]
    FileRead {
        path: String,
        source: std::io::Error,
    },

    #[error("Failed to parse JSON: {source}")]
    JsonParse {
        #[from]
        source: serde_json::Error,
    },

    #[error("HTML transpile error: {0}")]
    HtmlParse(String),

    #[error("Failed to load image '{path}': {reason}")]
    ImageLoad { path: String, reason: String },

    #[error("Failed to decode image '{path}'")]
    ImageDecode { path: String },

    #[error("SVG component must have either 'src' or 'data'")]
    SvgMissingSrc,

    #[error("Failed to load SVG '{path}': {reason}")]
    SvgLoad { path: String, reason: String },

    #[error("Failed to parse SVG: {reason}")]
    SvgParse { reason: String },

    #[error("Failed to create pixmap for {target}")]
    PixmapCreation { target: String },

    #[error("Invalid icon format: '{icon}' (expected 'prefix:name')")]
    InvalidIconFormat { icon: String },

    #[error("Failed to fetch icon '{icon}': {reason}")]
    IconFetch { icon: String, reason: String },

    #[error("Failed to parse icon SVG '{icon}': {reason}")]
    IconParse { icon: String, reason: String },

    #[error(
        "{count} icon(s) could not be loaded — checked the disk cache at {cache_dir} and the \
         network, both failed:\n  - {details}\nA render must not silently omit an icon: fix \
         the identifier(s), or run `rustmotion icons prefetch -f <scenario>` once to fill the \
         cache for offline use."
    )]
    IconsUnresolved {
        count: usize,
        cache_dir: String,
        details: String,
    },

    #[error("Failed to create Skia image from {target}")]
    SkiaImageCreation { target: String },

    #[error("Failed to open GIF '{path}': {reason}")]
    GifOpen { path: String, reason: String },

    #[error("Failed to decode GIF '{path}': {reason}")]
    GifDecode { path: String, reason: String },

    #[error("QR code generation failed: {reason}")]
    QrCodeGeneration { reason: String },

    #[error("No fonts available on this system")]
    FontNotFound,

    #[error(
        "FontEntry for '{family}' has source=\"google\" but also sets 'path' — use one or the other"
    )]
    FontSourceAndPathConflict { family: String },

    #[error(
        "FontEntry for '{family}' requires either 'path' (local file) or 'source' (e.g. \"google\")"
    )]
    FontMissingPath { family: String },

    #[error(
        "Google Fonts: failed to fetch CSS for '{family}' (url: {url}): {reason}\n\
         Tip: if you are offline, manually place a TTF for each weight at:\n  {cache_hint}"
    )]
    GoogleFontsFetch {
        family: String,
        url: String,
        reason: String,
        cache_hint: String,
    },

    #[error(
        "Google Fonts: '{family}' (weights {weights}) is not in the font cache, and fetching \
         it would reach {url} — a scenario chooses that target, so it is denied by default.\n\
         Pass --allow-remote-fonts to opt in, or place a TTF for each weight at:\n  {cache_hint}"
    )]
    RemoteFontDenied {
        family: String,
        weights: String,
        url: String,
        cache_hint: String,
    },

    #[error(
        "Icons: '{icon}' is not in the icon cache, and fetching it would reach {url} — a \
         scenario chooses that target, so it is denied by default.\n\
         Run `rustmotion icons prefetch -f <scenario>` once to fill the cache, pass \
         --allow-remote-icons to opt in for this run, or place the SVG at:\n  {cache_hint}"
    )]
    RemoteIconDenied {
        icon: String,
        url: String,
        cache_hint: String,
    },

    #[error(
        "Google Fonts: no font URLs found in CSS response for '{family}' — \
         the family name may be misspelled or unavailable"
    )]
    GoogleFontsNoUrls { family: String },

    #[error("Google Fonts: failed to download TTF for '{family}' weight {weight}: {reason}")]
    GoogleFontsTtfFetch {
        family: String,
        weight: u16,
        reason: String,
    },

    #[error("Include depth limit ({limit}) exceeded while resolving '{path}'")]
    IncludeDepthExceeded { limit: u8, path: String },

    #[error("Include: scenes[{index}] is out of bounds in '{path}' (file has {total} scenes)")]
    IncludeSceneOutOfBounds {
        index: usize,
        path: String,
        total: usize,
    },

    #[error("Include: cannot resolve relative path '{path}' from inline JSON (use a file path or URL instead)")]
    IncludeInlinePath { path: String },

    #[error("Include: failed to fetch '{url}': {reason}")]
    IncludeRemoteFetch { url: String, reason: String },

    #[error("Include: file not found '{path}'")]
    IncludeFileNotFound { path: String },

    #[error(
        "Scenario cannot have both top-level 'scenes' and 'composition' — use one or the other"
    )]
    CompositionAndScenesConflict,

    #[error("Unknown background template '{name}' referenced via $ref")]
    UnknownBackgroundTemplate { name: String },

    #[error("Unknown heropattern '{name}' — see heropatterns.com for available patterns")]
    UnknownHeropattern { name: String },

    #[error("Variable '${name}' is not defined in '{path}'")]
    UndefinedVariable { name: String, path: String },

    #[error("Variable '{name}' in '{path}' is missing a default value")]
    VariableMissingDefault { name: String, path: String },

    #[error("Unresolved variable reference '${name}' after substitution in '{path}'")]
    UnresolvedVariable { name: String, path: String },

    #[error("Cannot interpolate non-string variable '${name}' into string in '{path}'")]
    VariableInterpolationTypeError { name: String, path: String },

    #[error("'components' at '{path}' must be an object mapping names to definitions")]
    ComponentsBlockNotObject { path: String },

    #[error("Component definition '{name}' at '{path}' is invalid: {reason}")]
    ComponentDefinitionInvalid {
        name: String,
        path: String,
        reason: String,
    },

    #[error("'use' directive at '{path}' is invalid: {reason}")]
    UseDirectiveInvalid { path: String, reason: String },

    #[error(
        "Unknown component '{name}' referenced via 'use' at '{path}' — no such name in this \
         file's 'components' block"
    )]
    UnknownComponent { name: String, path: String },

    #[error(
        "Missing required parameter '{param}' for component '{component}' at '{path}' — it has \
         no default and was not supplied via 'props'"
    )]
    ComponentParamMissing {
        component: String,
        param: String,
        path: String,
    },

    #[error(
        "Unknown parameter '{param}' passed to component '{component}' at '{path}' — not \
         declared in its 'params'"
    )]
    UnknownComponentParam {
        component: String,
        param: String,
        path: String,
    },

    #[error("Component instantiation cycle at '{path}': {chain}")]
    ComponentCycle { chain: String, path: String },

    #[error("'for-each' directive at '{path}' is invalid: {reason}")]
    ForEachDirectiveInvalid { path: String, reason: String },

    #[error("'for-each' at '{path}' must resolve to an array; found {found}")]
    ForEachNotArray { path: String, found: String },

    #[error(
        "Template/component expansion depth limit ({limit}) exceeded at '{path}' — likely a \
         runaway nested 'use'/'for-each' template"
    )]
    ExpansionDepthExceeded { limit: u32, path: String },

    #[error("No frames to render (total duration is 0)")]
    NoFrames,

    #[error("codec '{codec}' cannot be written into a .{container} file — {fix}")]
    CodecContainerMismatch {
        codec: String,
        container: String,
        fix: String,
    },

    #[error("Failed to run ffmpeg: {reason}. Is ffmpeg installed?")]
    FfmpegSpawn { reason: String },

    #[error("FFmpeg encoding failed{}", .stderr.as_ref().map(|s| format!(": {}", s)).unwrap_or_default())]
    FfmpegFailed { stderr: Option<String> },

    #[error("Failed to open FFmpeg stdin pipe")]
    FfmpegPipe,

    #[error("Failed to write to FFmpeg pipe: {reason}{}", .stderr.as_ref().map(|s| format!("\nffmpeg reported:\n{}", s)).unwrap_or_default())]
    FfmpegWrite {
        reason: String,
        stderr: Option<String>,
    },

    #[error("Failed to wait for FFmpeg: {reason}")]
    FfmpegWait { reason: String },

    #[error("ffmpeg failed to extract frame from '{src}'")]
    FfmpegFrameExtract { src: String },

    #[error("Failed to create GIF encoder: {reason}")]
    GifEncoder { reason: String },

    #[error("Failed to set GIF repeat: {reason}")]
    GifRepeat { reason: String },

    #[error("Failed to write GIF frame: {reason}")]
    GifFrame { reason: String },

    #[error("Failed to open audio file '{path}': {reason}")]
    AudioOpen { path: String, reason: String },

    #[error("Failed to probe audio format for '{path}': {reason}")]
    AudioProbe { path: String, reason: String },

    #[error("No audio track found in '{path}'")]
    AudioNoTrack { path: String },

    #[error("Failed to create decoder for '{path}': {reason}")]
    AudioDecoder { path: String, reason: String },

    #[error("audio.score: {source}")]
    AudioSynthScore {
        #[from]
        source: crate::audio::ScoreError,
    },

    #[error("failed to write synthesised audio to a temporary file '{path}': {reason}")]
    AudioSynthWrite { path: String, reason: String },

    #[error("Failed to create Skia surface")]
    SurfaceCreation,

    #[error("Failed to create image from pixels")]
    PixelImage,

    #[error("Failed to read pixels from Skia surface")]
    PixelRead,

    #[error("Failed to create motion blur surface")]
    MotionBlurSurface,

    #[error("Cannot use both input file and --json")]
    ConflictingInput,

    #[error("Provide either an input file or --json")]
    MissingInput,

    #[error("--watch requires an input file path (cannot use --json or stdin)")]
    WatchRequiresFile,

    #[error("Frame {frame} is out of range (total frames: {total})")]
    FrameOutOfRange { frame: u32, total: u32 },

    #[error("Frame range {start}-{end} is out of range (total frames: {total})")]
    FrameRangeOutOfRange { start: u32, end: u32, total: u32 },

    #[error("Time {time:.2}s is beyond video duration")]
    TimeOutOfRange { time: f64 },

    #[error("File watcher channel closed")]
    WatcherClosed,

    #[error("Validation failed: {schema_errors} schema error(s), {geometry_violations} geometry violation(s), {unresolved_vars} unresolved variable(s). Run `rustmotion validate -f <file>` to see details.")]
    ValidationFailed {
        schema_errors: usize,
        geometry_violations: usize,
        unresolved_vars: usize,
    },

    #[error("Incremental encoding unsupported: {reason}")]
    IncrementalUnsupported { reason: String },

    #[error("Invalid CRF value {value}: must be between 0 and 51")]
    InvalidCrf { value: u8 },

    #[error(
        "Unknown codec '{codec}'. Supported: h264, h264_10bit, h265, vp9, prores\n\
         h264 is 8-bit and plays everywhere; h264_10bit trades that for banding-free \
         dark gradients."
    )]
    UnknownCodec { codec: String },

    #[error("Path is not valid UTF-8: '{path}'")]
    NonUtf8Path { path: String },

    #[error("Failed to create preview window: {reason}")]
    PreviewWindow { reason: String },

    #[error("Failed to read Lottie file '{path}': {reason}")]
    LottieRead { path: String, reason: String },

    #[error("Lottie component requires either 'src' or 'data'")]
    LottieMissingSrc,

    #[error("Failed to read Lottie frame '{path}': {reason}")]
    LottieFrameRead { path: String, reason: String },

    #[error("Failed to decode Lottie frame '{path}': {reason}")]
    LottieFrameDecode { path: String, reason: String },

    #[error("Lottie render failed: {reason}")]
    LottieRender { reason: String },

    #[error(
        "Unknown skill or rule: '{name}'. Run `rustmotion skills list` to see available rules."
    )]
    UnknownSkill { name: String },

    #[error("{0}")]
    Io(#[from] std::io::Error),

    #[error("Image processing error: {0}")]
    Image(#[from] image::ImageError),

    #[error("File watcher error: {0}")]
    Notify(#[from] notify::Error),

    #[error("{0}")]
    Other(String),
}

impl From<String> for RustmotionError {
    fn from(s: String) -> Self {
        RustmotionError::Other(s)
    }
}