Skip to main content

rustmotion_core/
error.rs

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