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}