Skip to main content

alpha2_model/
error.rs

1use crate::{ClipId, CompositionId, EffectResourceId, NodeId, TrackId};
2use alpha2_media::SourceId;
3use std::fmt;
4
5/// A document invariant violation.
6#[derive(Clone, Debug, Eq, PartialEq)]
7#[non_exhaustive]
8pub enum ModelError {
9    /// This build cannot interpret the document version.
10    UnsupportedSchemaVersion {
11        /// Version found in the decoded document.
12        found: u32,
13    },
14    /// At least one ID was empty.
15    EmptyIdentifier,
16    /// Two sources share one ID.
17    DuplicateSourceId(SourceId),
18    /// Two effect resources share one ID.
19    DuplicateEffectResourceId(EffectResourceId),
20    /// Two tracks share one ID.
21    DuplicateTrackId(TrackId),
22    /// Two clips share one ID.
23    DuplicateClipId(ClipId),
24    /// A source locator was empty.
25    EmptyLocator {
26        /// Source whose locator was empty.
27        source: SourceId,
28    },
29    /// A still image source says it has audio, which no image holds.
30    AudioOnImage {
31        /// The image source.
32        source: SourceId,
33    },
34    /// A source's provider does not fit it: another kind of source than the
35    /// provider supplies, audio, or incoherent settings; or a generated
36    /// source has one.
37    InvalidProvider {
38        /// The source.
39        source: SourceId,
40    },
41    /// A source's fingerprint is malformed, or a generated source has one.
42    InvalidFingerprint {
43        /// The source.
44        source: SourceId,
45    },
46    /// A source declares a color whose dynamic range its transfer
47    /// contradicts: HDR is exactly a PQ or HLG transfer.
48    ContradictoryColor {
49        /// The source.
50        source: SourceId,
51    },
52    /// A generated source has no valid generator, or has a locator, color
53    /// metadata, or audio; or another source has a generator.
54    InvalidGenerator {
55        /// The source.
56        source: SourceId,
57    },
58    /// An effect resource locator was empty.
59    EmptyEffectResourceLocator {
60        /// Resource whose locator was empty.
61        resource: EffectResourceId,
62    },
63    /// A clip effect references an absent effect resource.
64    UnknownEffectResource {
65        /// Clip containing the reference.
66        clip: ClipId,
67        /// Missing resource identity.
68        resource: EffectResourceId,
69    },
70    /// A clip has an empty source or timeline range.
71    EmptyClipRange {
72        /// Clip with an empty range.
73        clip: ClipId,
74    },
75    /// A clip starts before the project or source timeline origin.
76    NegativeClipRange {
77        /// Clip with a negative range start.
78        clip: ClipId,
79    },
80    /// Two clips overlap without one transition covering their intersection.
81    OverlappingClips {
82        /// Track containing the overlap.
83        track: TrackId,
84        /// First overlapping clip.
85        first: ClipId,
86        /// Second overlapping clip.
87        second: ClipId,
88    },
89    /// A clip refers to an absent source.
90    UnknownClipSource {
91        /// Clip containing the absent source reference.
92        clip: ClipId,
93        /// Referenced source that was not in the project.
94        source: SourceId,
95    },
96    /// A source cannot be placed on the selected track kind.
97    IncompatibleClipSource {
98        /// Clip containing the incompatible reference.
99        clip: ClipId,
100        /// Track that cannot accept the source kind.
101        track: TrackId,
102        /// Source with the incompatible kind.
103        source: SourceId,
104    },
105    /// A visual clip has non-finite values, invalid opacity, or unordered keyframes.
106    InvalidVisualProperties {
107        /// Clip containing the invalid presentation data.
108        clip: ClipId,
109    },
110    /// A clip has an invalid gain, pan, fade, or audio effect parameter.
111    InvalidAudioProperties {
112        /// Clip containing the invalid audio data.
113        clip: ClipId,
114    },
115    /// A track bus has an invalid gain, pan, effect, or ducking parameter.
116    InvalidTrackAudio {
117        /// Track containing the invalid bus data.
118        track: TrackId,
119    },
120    /// The project master bus has an invalid gain, effect, or limiter parameter.
121    InvalidMasterAudio,
122    /// A ducking sidechain names a missing track, itself, or forms a cycle.
123    InvalidDuckingSidechain {
124        /// Track whose ducking reference is unusable.
125        track: TrackId,
126        /// Referenced sidechain track.
127        sidechain: TrackId,
128    },
129    /// A clip names an absent composition.
130    UnknownComposition {
131        /// The clip.
132        clip: ClipId,
133        /// The composition it names.
134        composition: CompositionId,
135    },
136    /// A clip's composition has a non-positive scale, reaches a composition
137    /// time past [`alpha2_time::MAX_SERIALIZED_TICKS`] before the clip ends,
138    /// or is on an audio clip.
139    InvalidClipComposition {
140        /// The clip.
141        clip: ClipId,
142    },
143    /// Two compositions share one ID.
144    DuplicateCompositionId(CompositionId),
145    /// A node ID has a `/`, which joins the IDs of the group and media nodes
146    /// a drawn node was inlined through.
147    InvalidNodeId {
148        /// The composition.
149        composition: CompositionId,
150        /// The node ID.
151        node: NodeId,
152    },
153    /// Two nodes of one composition share an ID.
154    DuplicateNodeId {
155        /// The composition.
156        composition: CompositionId,
157        /// The repeated node ID.
158        node: NodeId,
159    },
160    /// A composition has more nodes than the renderer will schedule.
161    TooManyCompositionNodes {
162        /// The composition.
163        composition: CompositionId,
164        /// Number supplied by the document.
165        found: usize,
166        /// Largest supported count.
167        maximum: usize,
168    },
169    /// A node has an invalid setting: an effect identity, version,
170    /// parameter, or value; a transform, progress, opacity, or mask out of
171    /// range; too many merge layers or masks; or a bypass on a node without
172    /// inputs.
173    InvalidNode {
174        /// The composition.
175        composition: CompositionId,
176        /// The node.
177        node: NodeId,
178    },
179    /// A node input names a node its composition does not have.
180    UnknownNodeInput {
181        /// The composition.
182        composition: CompositionId,
183        /// The node whose input it is.
184        node: NodeId,
185        /// The input: `input`, `from`, `to`, `matte`, `layers[i]`, or
186        /// `inputs[i]`.
187        input: String,
188        /// The node named.
189        target: NodeId,
190    },
191    /// A node depends on its own output.
192    CompositionCycle {
193        /// The composition.
194        composition: CompositionId,
195        /// A node on the cycle.
196        node: NodeId,
197    },
198    /// A composition's output names a node it does not have.
199    UnknownCompositionOutput {
200        /// The composition.
201        composition: CompositionId,
202        /// The node named.
203        node: NodeId,
204    },
205    /// A media node names an absent source or one that is not visual.
206    InvalidNodeSource {
207        /// The composition.
208        composition: CompositionId,
209        /// The node.
210        node: NodeId,
211        /// The source named.
212        source: SourceId,
213    },
214    /// A composition's group inputs are unnamed, repeated, or too many, or
215    /// a control names no effect setting of its nodes or repeats one.
216    InvalidCompositionPorts {
217        /// The composition.
218        composition: CompositionId,
219    },
220    /// A group node names an absent composition.
221    UnknownGroupComposition {
222        /// The composition holding the group node.
223        composition: CompositionId,
224        /// The group node.
225        node: NodeId,
226        /// The composition it names.
227        target: CompositionId,
228    },
229    /// A composition uses itself through group nodes or composition
230    /// sources.
231    CompositionUsesItself {
232        /// A composition on the loop.
233        composition: CompositionId,
234    },
235    /// A composition draws compositions, through group nodes or
236    /// composition sources, nested more than
237    /// [`crate::MAX_COMPOSITION_DEPTH`] deep.
238    CompositionNestedTooDeep {
239        /// The first such composition, in document order.
240        composition: CompositionId,
241        /// Deepest supported nesting.
242        maximum: usize,
243    },
244    /// The compositions draw more nodes than
245    /// [`crate::MAX_INLINED_NODES`], each group node and composition-source
246    /// media node counted as the nodes of the composition it draws.
247    TooManyInlinedNodes {
248        /// Largest supported count.
249        maximum: usize,
250    },
251    /// The clips on the timeline at one instant draw more than
252    /// [`crate::MAX_FRAME_INLINED_NODES`] composition nodes together.
253    FrameDrawsTooManyNodes {
254        /// The first clip, in document order, starting at the first such
255        /// instant.
256        clip: ClipId,
257        /// The nodes the clips at that instant draw.
258        found: u64,
259        /// Largest supported count.
260        maximum: usize,
261    },
262    /// A composition source names an absent composition, or has what only a
263    /// host-supplied source has.
264    InvalidCompositionSource {
265        /// The source.
266        source: SourceId,
267    },
268    /// A node's effect references an absent effect resource.
269    UnknownNodeResource {
270        /// The composition.
271        composition: CompositionId,
272        /// The node.
273        node: NodeId,
274        /// The resource named.
275        resource: EffectResourceId,
276    },
277    /// A clip has more than [`crate::MAX_CLIP_MASKS`] masks, a mask with a
278    /// negative or non-finite value, or masks on an audio track.
279    InvalidClipMask {
280        /// Clip carrying the invalid mask.
281        clip: ClipId,
282    },
283    /// A caption track has no captions, or has clips or transitions, or its
284    /// cues or style are invalid; or another track has captions.
285    InvalidCaptions {
286        /// The track.
287        track: TrackId,
288    },
289    /// A clip freezes on an instant outside its source range.
290    InvalidTimeMap {
291        /// The clip.
292        clip: ClipId,
293    },
294    /// A clip's matte names no visual clip, itself, a clip with a matte of its
295    /// own, or a clip in a transition; or an audio clip has a matte.
296    InvalidMatte {
297        /// Clip whose matte is invalid.
298        clip: ClipId,
299    },
300    /// A transition is not contained in two distinct visual clips on its track.
301    InvalidClipTransition {
302        /// Track containing the invalid transition.
303        track: TrackId,
304    },
305    /// Transitions active at one instant lead from clip to clip in a circle,
306    /// which leaves no clip to show.
307    TransitionCycle {
308        /// Track containing the transitions.
309        track: TrackId,
310        /// An instant they are all active.
311        at: alpha2_time::MediaTime,
312    },
313    /// Two clips have more than one transition between them, in either
314    /// direction: each overlap shows exactly one.
315    DuplicateTransition {
316        /// Track containing the transitions.
317        track: TrackId,
318        /// The transition's first clip.
319        first: ClipId,
320        /// The transition's second clip.
321        second: ClipId,
322    },
323    /// The project contains too many compositions.
324    TooManyCompositions {
325        /// Number supplied by the document.
326        found: usize,
327        /// Largest supported count.
328        maximum: usize,
329    },
330    /// The source table exceeds the bounded project resource budget.
331    TooManySources {
332        /// Number supplied by the document.
333        found: usize,
334        /// Largest supported count.
335        maximum: usize,
336    },
337    /// The timeline contains too many tracks.
338    TooManyTracks {
339        /// Number supplied by the document.
340        found: usize,
341        /// Largest supported count.
342        maximum: usize,
343    },
344    /// The timeline contains too many clips in total.
345    TooManyClips {
346        /// Number supplied by the document.
347        found: usize,
348        /// Largest supported count.
349        maximum: usize,
350    },
351    /// More of the project's audio effects hold state at once than
352    /// [`crate::MAX_AUDIO_EFFECTS`] (see [`crate::Project::audio_effect_load`]).
353    TooManyAudioEffects {
354        /// The most effects holding state at once.
355        found: usize,
356        /// Largest supported count.
357        maximum: usize,
358        /// The earliest timeline position, in ticks, at which `found` hold
359        /// state.
360        at_ticks: i64,
361    },
362    /// The project's audio effects hold more than
363    /// [`crate::MAX_AUDIO_DELAY_MS`] of delay at once (see
364    /// [`crate::Project::audio_effect_load`]).
365    TooMuchAudioDelay {
366        /// The most milliseconds held at once, rounded up.
367        found_ms: u64,
368        /// Largest supported total.
369        maximum_ms: u32,
370        /// The earliest timeline position, in ticks, at which `found_ms` is
371        /// held.
372        at_ticks: i64,
373    },
374    /// One track contains too many clips.
375    TooManyClipsOnTrack {
376        /// Track containing the excessive clip list.
377        track: TrackId,
378        /// Number supplied by the document.
379        found: usize,
380        /// Largest supported count.
381        maximum: usize,
382    },
383    /// A caption track holds more than [`crate::MAX_CLIPS_PER_TRACK`] cues.
384    TooManyCaptionCues {
385        /// The caption track.
386        track: TrackId,
387        /// Number supplied by the document.
388        found: usize,
389        /// Largest supported count.
390        maximum: usize,
391    },
392    /// One track contains too many transitions.
393    TooManyTransitions {
394        /// Track containing the excessive transition list.
395        track: TrackId,
396        /// Number supplied by the document.
397        found: usize,
398        /// Largest supported count.
399        maximum: usize,
400    },
401    /// Width or height was zero.
402    ZeroCanvasDimension,
403    /// A canvas edge exceeds the portable GPU-independent limit.
404    CanvasDimensionTooLarge {
405        /// Supplied width.
406        width: u32,
407        /// Supplied height.
408        height: u32,
409        /// Largest supported edge.
410        maximum: u32,
411    },
412    /// The canvas area exceeds the bounded pixel budget.
413    CanvasPixelBudgetExceeded {
414        /// Supplied pixel area.
415        pixels: u64,
416        /// Largest supported pixel area.
417        maximum: u64,
418    },
419    /// A background component is non-finite or outside `0..=1`.
420    InvalidBackground,
421    /// The color pipeline's HDR peak is outside
422    /// [`crate::HDR_PEAK_NITS_RANGE`].
423    InvalidColorPipeline,
424    /// The project sample rate is outside the supported DSP interval.
425    UnsupportedSampleRate {
426        /// Supplied rate.
427        found: u32,
428        /// Smallest supported rate.
429        minimum: u32,
430        /// Largest supported rate.
431        maximum: u32,
432    },
433}
434
435impl fmt::Display for ModelError {
436    #[allow(clippy::too_many_lines)]
437    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
438        match self {
439            Self::UnsupportedSchemaVersion { found } => {
440                write!(formatter, "unsupported schema version {found}")
441            }
442            Self::EmptyIdentifier => formatter.write_str("document contains an empty identifier"),
443            Self::DuplicateSourceId(id) => write!(formatter, "duplicate source ID {id}"),
444            Self::DuplicateEffectResourceId(id) => {
445                write!(formatter, "duplicate effect resource ID {id}")
446            }
447            Self::DuplicateTrackId(id) => write!(formatter, "duplicate track ID {id}"),
448            Self::DuplicateClipId(id) => write!(formatter, "duplicate clip ID {id}"),
449            Self::EmptyLocator { source } => {
450                write!(formatter, "source {source} has an empty locator")
451            }
452            Self::AudioOnImage { source } => {
453                write!(
454                    formatter,
455                    "source {source} is a still image and has no audio"
456                )
457            }
458            Self::InvalidGenerator { source } => {
459                write!(formatter, "source {source} has an invalid generator")
460            }
461            Self::ContradictoryColor { source } => write!(
462                formatter,
463                "source {source} declares a dynamic range its transfer contradicts"
464            ),
465            Self::InvalidFingerprint { source } => {
466                write!(formatter, "source {source} has an invalid fingerprint")
467            }
468            Self::InvalidProvider { source } => {
469                write!(formatter, "source {source} has an invalid provider")
470            }
471            Self::EmptyEffectResourceLocator { resource } => {
472                write!(formatter, "effect resource {resource} has an empty locator")
473            }
474            Self::UnknownEffectResource { clip, resource } => {
475                write!(
476                    formatter,
477                    "clip {clip} references unknown effect resource {resource}"
478                )
479            }
480            Self::EmptyClipRange { clip } => write!(formatter, "clip {clip} has an empty range"),
481            Self::NegativeClipRange { clip } => {
482                write!(formatter, "clip {clip} has a range before timeline origin")
483            }
484            Self::OverlappingClips {
485                track,
486                first,
487                second,
488            } => write!(
489                formatter,
490                "clips {first} and {second} overlap without a covering transition on track {track}"
491            ),
492            Self::UnknownClipSource { clip, source } => {
493                write!(formatter, "clip {clip} references unknown source {source}")
494            }
495            Self::IncompatibleClipSource {
496                clip,
497                track,
498                source,
499            } => {
500                write!(
501                    formatter,
502                    "clip {clip} cannot use source {source} on track {track}"
503                )
504            }
505            Self::InvalidVisualProperties { clip } => {
506                write!(formatter, "clip {clip} has invalid visual properties")
507            }
508            Self::InvalidAudioProperties { clip } => {
509                write!(formatter, "clip {clip} has invalid audio properties")
510            }
511            Self::InvalidTrackAudio { track } => {
512                write!(formatter, "track {track} has invalid audio bus settings")
513            }
514            Self::InvalidMasterAudio => {
515                write!(formatter, "the project master bus is invalid")
516            }
517            Self::InvalidDuckingSidechain { track, sidechain } => write!(
518                formatter,
519                "track {track} cannot duck from sidechain track {sidechain}"
520            ),
521            Self::UnknownComposition { clip, composition } => {
522                write!(
523                    formatter,
524                    "clip {clip} references unknown composition {composition}"
525                )
526            }
527            Self::InvalidClipComposition { clip } => {
528                write!(
529                    formatter,
530                    "clip {clip} has an invalid composition reference"
531                )
532            }
533            Self::DuplicateCompositionId(id) => write!(formatter, "duplicate composition ID {id}"),
534            Self::InvalidNodeId { composition, node } => {
535                write!(
536                    formatter,
537                    "composition {composition} has node ID {node}, which has a `/`"
538                )
539            }
540            Self::DuplicateNodeId { composition, node } => {
541                write!(
542                    formatter,
543                    "composition {composition} has duplicate node ID {node}"
544                )
545            }
546            Self::TooManyCompositionNodes {
547                composition,
548                found,
549                maximum,
550            } => write!(
551                formatter,
552                "composition {composition} has {found} nodes; the maximum is {maximum}"
553            ),
554            Self::InvalidNode { composition, node } => {
555                write!(
556                    formatter,
557                    "node {node} of composition {composition} has an invalid setting"
558                )
559            }
560            Self::UnknownNodeInput {
561                composition,
562                node,
563                input,
564                target,
565            } => write!(
566                formatter,
567                "input {input} of node {node} in composition {composition} names unknown node {target}"
568            ),
569            Self::CompositionCycle { composition, node } => write!(
570                formatter,
571                "node {node} of composition {composition} depends on its own output"
572            ),
573            Self::UnknownCompositionOutput { composition, node } => write!(
574                formatter,
575                "composition {composition} outputs unknown node {node}"
576            ),
577            Self::InvalidNodeSource {
578                composition,
579                node,
580                source,
581            } => write!(
582                formatter,
583                "node {node} of composition {composition} cannot show source {source}"
584            ),
585            Self::InvalidCompositionPorts { composition } => write!(
586                formatter,
587                "composition {composition} has invalid group inputs or controls"
588            ),
589            Self::UnknownGroupComposition {
590                composition,
591                node,
592                target,
593            } => write!(
594                formatter,
595                "group node {node} of composition {composition} names unknown composition {target}"
596            ),
597            Self::CompositionUsesItself { composition } => {
598                write!(formatter, "composition {composition} uses itself")
599            }
600            Self::CompositionNestedTooDeep {
601                composition,
602                maximum,
603            } => write!(
604                formatter,
605                "composition {composition} nests compositions more than {maximum} deep"
606            ),
607            Self::TooManyInlinedNodes { maximum } => write!(
608                formatter,
609                "compositions draw more than {maximum} nodes, counting each group and composition source as the nodes it draws"
610            ),
611            Self::FrameDrawsTooManyNodes {
612                clip,
613                found,
614                maximum,
615            } => write!(
616                formatter,
617                "the clips on the timeline where clip {clip} starts draw {found} composition nodes; a frame draws at most {maximum}"
618            ),
619            Self::InvalidCompositionSource { source } => {
620                write!(
621                    formatter,
622                    "source {source} is not a valid composition source"
623                )
624            }
625            Self::UnknownNodeResource {
626                composition,
627                node,
628                resource,
629            } => write!(
630                formatter,
631                "node {node} of composition {composition} references unknown effect resource {resource}"
632            ),
633            Self::InvalidClipMask { clip } => write!(formatter, "clip {clip} has an invalid mask"),
634            Self::InvalidMatte { clip } => write!(formatter, "clip {clip} has an invalid matte"),
635            Self::InvalidCaptions { track } => {
636                write!(formatter, "track {track} has invalid captions")
637            }
638            Self::InvalidTimeMap { clip } => {
639                write!(formatter, "clip {clip} freezes outside its source range")
640            }
641            Self::InvalidClipTransition { track } => {
642                write!(formatter, "track {track} has an invalid clip transition")
643            }
644            Self::TransitionCycle { track, at } => write!(
645                formatter,
646                "transitions on track {track} active at {} ticks lead from clip to clip in a circle",
647                at.ticks()
648            ),
649            Self::DuplicateTransition {
650                track,
651                first,
652                second,
653            } => write!(
654                formatter,
655                "clips {first} and {second} have more than one transition on track {track}"
656            ),
657            Self::TooManyCompositions { found, maximum } => write!(
658                formatter,
659                "project has {found} compositions; the maximum is {maximum}"
660            ),
661            Self::TooManySources { found, maximum } => {
662                write!(
663                    formatter,
664                    "project has {found} sources; the maximum is {maximum}"
665                )
666            }
667            Self::TooManyTracks { found, maximum } => {
668                write!(
669                    formatter,
670                    "project has {found} tracks; the maximum is {maximum}"
671                )
672            }
673            Self::TooManyClips { found, maximum } => {
674                write!(
675                    formatter,
676                    "project has {found} clips; the maximum is {maximum}"
677                )
678            }
679            Self::TooManyAudioEffects {
680                found,
681                maximum,
682                at_ticks,
683            } => write!(
684                formatter,
685                "project has {found} audio effects holding state at tick {at_ticks}; the maximum at once is {maximum}"
686            ),
687            Self::TooMuchAudioDelay {
688                found_ms,
689                maximum_ms,
690                at_ticks,
691            } => write!(
692                formatter,
693                "project audio effects hold {found_ms} ms of delay at tick {at_ticks}; the maximum at once is {maximum_ms} ms"
694            ),
695            Self::TooManyClipsOnTrack {
696                track,
697                found,
698                maximum,
699            } => write!(
700                formatter,
701                "track {track} has {found} clips; the maximum is {maximum}"
702            ),
703            Self::TooManyCaptionCues {
704                track,
705                found,
706                maximum,
707            } => write!(
708                formatter,
709                "track {track} has {found} caption cues; the maximum is {maximum}"
710            ),
711            Self::TooManyTransitions {
712                track,
713                found,
714                maximum,
715            } => write!(
716                formatter,
717                "track {track} has {found} transitions; the maximum is {maximum}"
718            ),
719            Self::ZeroCanvasDimension => formatter.write_str("canvas dimensions must be positive"),
720            Self::CanvasDimensionTooLarge {
721                width,
722                height,
723                maximum,
724            } => write!(
725                formatter,
726                "canvas {width}x{height} exceeds the maximum edge {maximum}"
727            ),
728            Self::CanvasPixelBudgetExceeded { pixels, maximum } => write!(
729                formatter,
730                "canvas has {pixels} pixels; the maximum is {maximum}"
731            ),
732            Self::InvalidBackground => {
733                formatter.write_str("background color components must be finite and within 0..=1")
734            }
735            Self::InvalidColorPipeline => {
736                formatter.write_str("the HDR peak must be within 100..=10000 nits")
737            }
738            Self::UnsupportedSampleRate {
739                found,
740                minimum,
741                maximum,
742            } => write!(
743                formatter,
744                "sample rate {found} is outside {minimum}..={maximum}"
745            ),
746        }
747    }
748}
749
750impl std::error::Error for ModelError {}
751
752/// Failure while decoding a serialized project document.
753#[derive(Debug)]
754#[non_exhaustive]
755pub enum ProjectLoadError {
756    /// The JSON input exceeded the parser byte budget.
757    TooLarge {
758        /// Supplied UTF-8 byte length.
759        bytes: usize,
760        /// Largest accepted byte length.
761        maximum: usize,
762    },
763    /// JSON syntax or shape was invalid.
764    Json(serde_json::Error),
765    /// The decoded document violated a model invariant.
766    InvalidProject(ModelError),
767}
768
769impl fmt::Display for ProjectLoadError {
770    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
771        match self {
772            Self::TooLarge { bytes, maximum } => {
773                write!(
774                    formatter,
775                    "project JSON is {bytes} bytes; the maximum is {maximum}"
776                )
777            }
778            Self::Json(error) => write!(formatter, "invalid project JSON: {error}"),
779            Self::InvalidProject(error) => write!(formatter, "invalid project: {error}"),
780        }
781    }
782}
783
784impl std::error::Error for ProjectLoadError {
785    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
786        match self {
787            Self::TooLarge { .. } => None,
788            Self::Json(error) => Some(error),
789            Self::InvalidProject(error) => Some(error),
790        }
791    }
792}