Skip to main content

rustmotion_core/
error.rs

1use thiserror::Error;
2
3pub type Result<T> = std::result::Result<T, RustmotionError>;
4
5#[derive(Debug, Error)]
6pub enum RustmotionError {
7    #[error("{0}")]
8    Generic(String),
9
10    #[error("Failed to read '{path}': {source}")]
11    FileRead {
12        path: String,
13        source: std::io::Error,
14    },
15
16    #[error("Failed to parse JSON: {source}")]
17    JsonParse {
18        #[from]
19        source: serde_json::Error,
20    },
21
22    #[error("HTML transpile error: {0}")]
23    HtmlParse(String),
24
25    #[error("Failed to load image '{path}': {reason}")]
26    ImageLoad { path: String, reason: String },
27
28    #[error("Failed to decode image '{path}'")]
29    ImageDecode { path: String },
30
31    #[error("SVG component must have either 'src' or 'data'")]
32    SvgMissingSrc,
33
34    #[error("Failed to load SVG '{path}': {reason}")]
35    SvgLoad { path: String, reason: String },
36
37    #[error("Failed to parse SVG: {reason}")]
38    SvgParse { reason: String },
39
40    #[error("Failed to create pixmap for {target}")]
41    PixmapCreation { target: String },
42
43    #[error("Invalid icon format: '{icon}' (expected 'prefix:name')")]
44    InvalidIconFormat { icon: String },
45
46    #[error("Failed to fetch icon '{icon}': {reason}")]
47    IconFetch { icon: String, reason: String },
48
49    #[error("Failed to parse icon SVG '{icon}': {reason}")]
50    IconParse { icon: String, reason: String },
51
52    #[error(
53        "{count} icon(s) could not be loaded — checked the disk cache at {cache_dir} and the \
54         network, both failed:\n  - {details}\nA render must not silently omit an icon: fix \
55         the identifier(s), or run `rustmotion icons prefetch -f <scenario>` once to fill the \
56         cache for offline use."
57    )]
58    IconsUnresolved {
59        count: usize,
60        cache_dir: String,
61        details: String,
62    },
63
64    #[error("Failed to create Skia image from {target}")]
65    SkiaImageCreation { target: String },
66
67    #[error("Failed to open GIF '{path}': {reason}")]
68    GifOpen { path: String, reason: String },
69
70    #[error("Failed to decode GIF '{path}': {reason}")]
71    GifDecode { path: String, reason: String },
72
73    #[error("QR code generation failed: {reason}")]
74    QrCodeGeneration { reason: String },
75
76    #[error("No fonts available on this system")]
77    FontNotFound,
78
79    #[error(
80        "FontEntry for '{family}' has source=\"google\" but also sets 'path' — use one or the other"
81    )]
82    FontSourceAndPathConflict { family: String },
83
84    #[error(
85        "FontEntry for '{family}' requires either 'path' (local file) or 'source' (e.g. \"google\")"
86    )]
87    FontMissingPath { family: String },
88
89    #[error(
90        "Google Fonts: failed to fetch CSS for '{family}' (url: {url}): {reason}\n\
91         Tip: if you are offline, manually place a TTF for each weight at:\n  {cache_hint}"
92    )]
93    GoogleFontsFetch {
94        family: String,
95        url: String,
96        reason: String,
97        cache_hint: String,
98    },
99
100    #[error(
101        "Google Fonts: '{family}' (weights {weights}) is not in the font cache, and fetching \
102         it would reach {url} — a scenario chooses that target, so it is denied by default.\n\
103         Pass --allow-remote-fonts to opt in, or place a TTF for each weight at:\n  {cache_hint}"
104    )]
105    RemoteFontDenied {
106        family: String,
107        weights: String,
108        url: String,
109        cache_hint: String,
110    },
111
112    #[error(
113        "Icons: '{icon}' is not in the icon cache, and fetching it would reach {url} — a \
114         scenario chooses that target, so it is denied by default.\n\
115         Run `rustmotion icons prefetch -f <scenario>` once to fill the cache, pass \
116         --allow-remote-icons to opt in for this run, or place the SVG at:\n  {cache_hint}"
117    )]
118    RemoteIconDenied {
119        icon: String,
120        url: String,
121        cache_hint: String,
122    },
123
124    #[error(
125        "Google Fonts: no font URLs found in CSS response for '{family}' — \
126         the family name may be misspelled or unavailable"
127    )]
128    GoogleFontsNoUrls { family: String },
129
130    #[error("Google Fonts: failed to download TTF for '{family}' weight {weight}: {reason}")]
131    GoogleFontsTtfFetch {
132        family: String,
133        weight: u16,
134        reason: String,
135    },
136
137    #[error("Include depth limit ({limit}) exceeded while resolving '{path}'")]
138    IncludeDepthExceeded { limit: u8, path: String },
139
140    #[error("Include: scenes[{index}] is out of bounds in '{path}' (file has {total} scenes)")]
141    IncludeSceneOutOfBounds {
142        index: usize,
143        path: String,
144        total: usize,
145    },
146
147    #[error("Include: cannot resolve relative path '{path}' from inline JSON (use a file path or URL instead)")]
148    IncludeInlinePath { path: String },
149
150    #[error("Include: failed to fetch '{url}': {reason}")]
151    IncludeRemoteFetch { url: String, reason: String },
152
153    #[error("Include: file not found '{path}'")]
154    IncludeFileNotFound { path: String },
155
156    #[error(
157        "Scenario cannot have both top-level 'scenes' and 'composition' — use one or the other"
158    )]
159    CompositionAndScenesConflict,
160
161    #[error("Unknown background template '{name}' referenced via $ref")]
162    UnknownBackgroundTemplate { name: String },
163
164    #[error("Unknown heropattern '{name}' — see heropatterns.com for available patterns")]
165    UnknownHeropattern { name: String },
166
167    #[error("Variable '${name}' is not defined in '{path}'")]
168    UndefinedVariable { name: String, path: String },
169
170    #[error("Variable '{name}' in '{path}' is missing a default value")]
171    VariableMissingDefault { name: String, path: String },
172
173    #[error("Unresolved variable reference '${name}' after substitution in '{path}'")]
174    UnresolvedVariable { name: String, path: String },
175
176    #[error("Cannot interpolate non-string variable '${name}' into string in '{path}'")]
177    VariableInterpolationTypeError { name: String, path: String },
178
179    #[error("'components' at '{path}' must be an object mapping names to definitions")]
180    ComponentsBlockNotObject { path: String },
181
182    #[error("Component definition '{name}' at '{path}' is invalid: {reason}")]
183    ComponentDefinitionInvalid {
184        name: String,
185        path: String,
186        reason: String,
187    },
188
189    #[error("'use' directive at '{path}' is invalid: {reason}")]
190    UseDirectiveInvalid { path: String, reason: String },
191
192    #[error(
193        "Unknown component '{name}' referenced via 'use' at '{path}' — no such name in this \
194         file's 'components' block"
195    )]
196    UnknownComponent { name: String, path: String },
197
198    #[error(
199        "Missing required parameter '{param}' for component '{component}' at '{path}' — it has \
200         no default and was not supplied via 'props'"
201    )]
202    ComponentParamMissing {
203        component: String,
204        param: String,
205        path: String,
206    },
207
208    #[error(
209        "Unknown parameter '{param}' passed to component '{component}' at '{path}' — not \
210         declared in its 'params'"
211    )]
212    UnknownComponentParam {
213        component: String,
214        param: String,
215        path: String,
216    },
217
218    #[error("Component instantiation cycle at '{path}': {chain}")]
219    ComponentCycle { chain: String, path: String },
220
221    #[error("'for-each' directive at '{path}' is invalid: {reason}")]
222    ForEachDirectiveInvalid { path: String, reason: String },
223
224    #[error("'for-each' at '{path}' must resolve to an array; found {found}")]
225    ForEachNotArray { path: String, found: String },
226
227    #[error(
228        "Template/component expansion depth limit ({limit}) exceeded at '{path}' — likely a \
229         runaway nested 'use'/'for-each' template"
230    )]
231    ExpansionDepthExceeded { limit: u32, path: String },
232
233    #[error("No frames to render (total duration is 0)")]
234    NoFrames,
235
236    #[error("codec '{codec}' cannot be written into a .{container} file — {fix}")]
237    CodecContainerMismatch {
238        codec: String,
239        container: String,
240        fix: String,
241    },
242
243    #[error("Failed to run ffmpeg: {reason}. Is ffmpeg installed?")]
244    FfmpegSpawn { reason: String },
245
246    #[error("FFmpeg encoding failed{}", .stderr.as_ref().map(|s| format!(": {}", s)).unwrap_or_default())]
247    FfmpegFailed { stderr: Option<String> },
248
249    #[error("Failed to open FFmpeg stdin pipe")]
250    FfmpegPipe,
251
252    #[error("Failed to write to FFmpeg pipe: {reason}{}", .stderr.as_ref().map(|s| format!("\nffmpeg reported:\n{}", s)).unwrap_or_default())]
253    FfmpegWrite {
254        reason: String,
255        stderr: Option<String>,
256    },
257
258    #[error("Failed to wait for FFmpeg: {reason}")]
259    FfmpegWait { reason: String },
260
261    #[error("ffmpeg failed to extract frame from '{src}'")]
262    FfmpegFrameExtract { src: String },
263
264    #[error("Failed to create GIF encoder: {reason}")]
265    GifEncoder { reason: String },
266
267    #[error("Failed to set GIF repeat: {reason}")]
268    GifRepeat { reason: String },
269
270    #[error("Failed to write GIF frame: {reason}")]
271    GifFrame { reason: String },
272
273    #[error("Failed to open audio file '{path}': {reason}")]
274    AudioOpen { path: String, reason: String },
275
276    #[error("Failed to probe audio format for '{path}': {reason}")]
277    AudioProbe { path: String, reason: String },
278
279    #[error("No audio track found in '{path}'")]
280    AudioNoTrack { path: String },
281
282    #[error("Failed to create decoder for '{path}': {reason}")]
283    AudioDecoder { path: String, reason: String },
284
285    #[error("audio.score: {source}")]
286    AudioSynthScore {
287        #[from]
288        source: crate::audio::ScoreError,
289    },
290
291    #[error("failed to write synthesised audio to a temporary file '{path}': {reason}")]
292    AudioSynthWrite { path: String, reason: String },
293
294    #[error("Failed to create Skia surface")]
295    SurfaceCreation,
296
297    #[error("Failed to create image from pixels")]
298    PixelImage,
299
300    #[error("Failed to read pixels from Skia surface")]
301    PixelRead,
302
303    #[error("Failed to create motion blur surface")]
304    MotionBlurSurface,
305
306    #[error("Cannot use both input file and --json")]
307    ConflictingInput,
308
309    #[error("Provide either an input file or --json")]
310    MissingInput,
311
312    #[error("--watch requires an input file path (cannot use --json or stdin)")]
313    WatchRequiresFile,
314
315    #[error("Frame {frame} is out of range (total frames: {total})")]
316    FrameOutOfRange { frame: u32, total: u32 },
317
318    #[error("Frame range {start}-{end} is out of range (total frames: {total})")]
319    FrameRangeOutOfRange { start: u32, end: u32, total: u32 },
320
321    #[error("Time {time:.2}s is beyond video duration")]
322    TimeOutOfRange { time: f64 },
323
324    #[error("File watcher channel closed")]
325    WatcherClosed,
326
327    #[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.")]
328    ValidationFailed {
329        schema_errors: usize,
330        geometry_violations: usize,
331        unresolved_vars: usize,
332    },
333
334    #[error("Incremental encoding unsupported: {reason}")]
335    IncrementalUnsupported { reason: String },
336
337    #[error("Invalid CRF value {value}: must be between 0 and 51")]
338    InvalidCrf { value: u8 },
339
340    #[error(
341        "Unknown codec '{codec}'. Supported: h264, h264_10bit, h265, vp9, prores\n\
342         h264 is 8-bit and plays everywhere; h264_10bit trades that for banding-free \
343         dark gradients."
344    )]
345    UnknownCodec { codec: String },
346
347    #[error("Path is not valid UTF-8: '{path}'")]
348    NonUtf8Path { path: String },
349
350    #[error("Failed to create preview window: {reason}")]
351    PreviewWindow { reason: String },
352
353    #[error("Failed to read Lottie file '{path}': {reason}")]
354    LottieRead { path: String, reason: String },
355
356    #[error("Lottie component requires either 'src' or 'data'")]
357    LottieMissingSrc,
358
359    #[error("Failed to read Lottie frame '{path}': {reason}")]
360    LottieFrameRead { path: String, reason: String },
361
362    #[error("Failed to decode Lottie frame '{path}': {reason}")]
363    LottieFrameDecode { path: String, reason: String },
364
365    #[error("Lottie render failed: {reason}")]
366    LottieRender { reason: String },
367
368    #[error(
369        "Unknown skill or rule: '{name}'. Run `rustmotion skills list` to see available rules."
370    )]
371    UnknownSkill { name: String },
372
373    #[error("{0}")]
374    Io(#[from] std::io::Error),
375
376    #[error("Image processing error: {0}")]
377    Image(#[from] image::ImageError),
378
379    #[error("File watcher error: {0}")]
380    Notify(#[from] notify::Error),
381
382    #[error("{0}")]
383    Other(String),
384}
385
386impl From<String> for RustmotionError {
387    fn from(s: String) -> Self {
388        RustmotionError::Other(s)
389    }
390}