Skip to main content

alpha2_extensions/lottie/
mod.rs

1//! Lottie JSON and dotLottie: velato's animation model, rasterized with
2//! tiny-skia, the rasterizer SVG uses, at the size it is shown.
3//!
4//! An animation is evaluated at its own whole frames: a source time shows
5//! the frame it falls in, as an image sequence's does. velato reads only
6//! the document [`normalize`] makes of the file: what the backend does not
7//! draw is reported with where it appears and left out, and what would stop
8//! it (a panic, unbounded recursion or expansion) is refused before it is
9//! read.
10
11mod device;
12mod normalize;
13
14use std::cell::{Cell, RefCell};
15use std::collections::HashMap;
16use std::io::{Cursor, Read, Seek, SeekFrom};
17use std::rc::Rc;
18use std::sync::Arc;
19
20use alpha2_time::{FrameRate, MediaTime};
21use base64::Engine as _;
22use kurbo::{Affine, PathEl, Shape};
23use peniko::{BlendMode as PenikoBlend, Brush, Compose, Extend, GradientKind, Mix};
24use resvg::tiny_skia::{
25    self, BlendMode, Color, FillRule, GradientStop, IntRect, IntSize, LineCap, LineJoin,
26    LinearGradient, Mask, Paint, PathBuilder, PathStroker, Pixmap, PixmapPaint, Point,
27    RadialGradient, Shader, SpreadMode, Stroke, StrokeDash, SweepGradient, Transform,
28};
29use serde_json::Value;
30use velato::model::ImageAsset;
31use velato::{Composition, RenderSink, Renderer};
32
33use alpha2_model::{MAX_LOTTIE_DOCUMENT_BYTES, ProviderSource};
34
35use crate::api::{
36    Diagnostic, ExtensionError, Format, FramePixels, ProbeReport, ResourceResolver, Timing,
37    VisualFrame, VisualProvider, VisualSource,
38};
39use crate::budget::{check_canvas, check_document_size, check_image_pixels};
40
41/// Most frames an animation may span.
42const MAX_LOTTIE_FRAMES: f64 = 100_000.0;
43/// Most bytes a dotLottie package unpacks to, all its files together. The
44/// package file itself is read up to
45/// [`alpha2_model::MAX_PROVIDER_DOCUMENT_BYTES`], and its manifest and the
46/// animation shown, like a Lottie JSON file, up to
47/// [`MAX_LOTTIE_DOCUMENT_BYTES`] each.
48const MAX_LOTTIE_BYTES: u64 = 256 * 1024 * 1024;
49/// Most files a dotLottie package holds.
50const MAX_DOTLOTTIE_FILES: usize = 1_024;
51/// Most bytes a dotLottie package's central directory, which lists its
52/// files, takes: a kibibyte for each file it may hold.
53const MAX_DOTLOTTIE_DIRECTORY_BYTES: u64 = 1_024 * 1_024;
54
55/// Reads Lottie JSON and dotLottie packages.
56pub struct LottieProvider;
57
58impl VisualProvider for LottieProvider {
59    fn name(&self) -> &'static str {
60        "lottie"
61    }
62
63    fn probe(&self, bytes: &[u8]) -> Option<Result<ProbeReport, ExtensionError>> {
64        let size = check_document_size(
65            &ProviderSource::Lottie { animation: None },
66            bytes,
67            bytes.len() as u64,
68        );
69        if is_zip(bytes) {
70            return Some(
71                size.and_then(|()| Package::read(bytes))
72                    .and_then(|package| package.probe()),
73            );
74        }
75        if is_json_object(bytes)
76            && let Err(error) = size
77        {
78            return Some(Err(error));
79        }
80        let json = parse_lottie(bytes)?;
81        Some(inspect(json, Format::Lottie).map(|(report, _)| report))
82    }
83
84    fn open(
85        &self,
86        bytes: Arc<[u8]>,
87        source: &ProviderSource,
88        resolver: &dyn ResourceResolver,
89    ) -> Result<Box<dyn VisualSource>, ExtensionError> {
90        Ok(Box::new(LottieSource::open(&bytes, source, resolver)?))
91    }
92}
93
94impl LottieSource {
95    fn open(
96        bytes: &[u8],
97        source: &ProviderSource,
98        resolver: &dyn ResourceResolver,
99    ) -> Result<Self, ExtensionError> {
100        let item = match source {
101            ProviderSource::Lottie { animation } => animation.as_deref(),
102            _ => return Err(ExtensionError::Mismatch("not a Lottie source".into())),
103        };
104        check_document_size(source, bytes, bytes.len() as u64)?;
105        let (json, package, format) = if is_zip(bytes) {
106            let package = Package::read(bytes)?;
107            (package.animation(item)?, Some(package), Format::DotLottie)
108        } else {
109            if item.is_some() {
110                return Err(ExtensionError::Mismatch(
111                    "a Lottie JSON file holds one animation".into(),
112                ));
113            }
114            let json = parse_lottie(bytes)
115                .ok_or_else(|| ExtensionError::Mismatch("not a Lottie animation".into()))?;
116            (json, None, Format::Lottie)
117        };
118        let (mut report, sanitized) = inspect(json, format)?;
119        if let Some(package) = &package {
120            report.items.clone_from(&package.ids);
121            report.item = Some(item.unwrap_or(&package.ids[0]).to_owned());
122        }
123        // The parsed document takes many times the file's size, and velato's parse as much again,
124        // so it is dropped before velato reads its text.
125        let text = serde_json::to_vec(&sanitized)
126            .map_err(|error| ExtensionError::Malformed(error.to_string()))?;
127        drop(sanitized);
128        let composition = Composition::from_slice(&text)
129            .map_err(|error| ExtensionError::Malformed(format!("{error:?}")))?;
130        drop(text);
131        // The assets' decoded size is checked from their headers before any is decoded; one larger
132        // than a canvas is reported missing below and does not count.
133        let assets = composition
134            .images
135            .iter()
136            .map(|(id, asset)| Ok((id, asset, image_bytes(asset, package.as_ref(), resolver)?)))
137            .collect::<Result<Vec<_>, ExtensionError>>()?;
138        check_image_pixels(
139            assets
140                .iter()
141                .filter_map(|(_, _, bytes)| crate::raster::png_size(bytes.as_deref()?))
142                .filter(|(width, height)| check_canvas(*width, *height).is_ok())
143                .map(|(width, height)| u64::from(width) * u64::from(height))
144                .fold(0, u64::saturating_add),
145        )?;
146        let mut images = HashMap::new();
147        for (id, asset, bytes) in assets {
148            match bytes.and_then(|bytes| decode_image(&bytes)) {
149                Some(pixmap) => {
150                    images.insert(id.clone(), pixmap);
151                }
152                None => report.diagnostics.push(Diagnostic::MissingImage {
153                    href: format!(
154                        "{}{}",
155                        asset.directory.clone().unwrap_or_default(),
156                        short(&asset.file_name)
157                    ),
158                }),
159            }
160        }
161        Ok(Self {
162            composition,
163            renderer: Renderer::new(),
164            images,
165            report,
166            last: None,
167        })
168    }
169}
170
171fn is_zip(bytes: &[u8]) -> bool {
172    bytes.starts_with(b"PK\x03\x04")
173}
174
175/// Whether the bytes start as a JSON object does, as a Lottie document must.
176fn is_json_object(bytes: &[u8]) -> bool {
177    bytes.iter().find(|byte| !byte.is_ascii_whitespace()) == Some(&b'{')
178}
179
180/// A Lottie document: JSON whose root has a canvas, a rate, a frame span,
181/// and layers.
182fn parse_lottie(bytes: &[u8]) -> Option<Value> {
183    if !is_json_object(bytes) {
184        return None;
185    }
186    let json: Value = serde_json::from_slice(bytes).ok()?;
187    let root = json.as_object()?;
188    ["w", "h", "fr", "ip", "op"]
189        .iter()
190        .all(|key| root.get(*key).is_some_and(Value::is_number))
191        .then_some(())
192        .filter(|()| root.get("layers").is_some_and(Value::is_array))
193        .map(|()| json)
194}
195
196/// Refuses a package whose central directory lists more files than a
197/// package may hold, or takes more bytes than such a directory needs, from
198/// what its end record says, before the directory is read: the ZIP reader
199/// indexes every file a directory lists, which for an archive of empty
200/// files is several times its size.
201///
202/// The end record read is the one the ZIP reader starts from: the last in
203/// the file that fits in it, with its comment, within the last 22 and
204/// 65,535 bytes the record and its comment take. A ZIP64 record, which the
205/// end record points to when its own fields overflow, is found as the ZIP
206/// reader finds it, and a package that points to one it does not have is
207/// malformed. When the reader cannot read the directory a record
208/// describes, it may look for an earlier record; a package that holds too
209/// many files through one is still refused once it is read.
210fn check_directory(bytes: &[u8]) -> Result<(), ExtensionError> {
211    const END: &[u8] = b"PK\x05\x06";
212    const END64_LOCATOR: &[u8] = b"PK\x06\x07";
213    const END64: &[u8] = b"PK\x06\x06";
214    let malformed = |what: &str| ExtensionError::Malformed(format!("dotLottie: {what}"));
215    let field = |at: usize, size: usize| {
216        bytes.get(at..at + size).map(|field| {
217            field
218                .iter()
219                .rev()
220                .fold(0_u64, |value, byte| value << 8 | u64::from(*byte))
221        })
222    };
223    let from = bytes.len().saturating_sub(22 + usize::from(u16::MAX));
224    let end = (from..bytes.len().saturating_sub(21))
225        .rev()
226        .find(|&at| {
227            bytes[at..].starts_with(END)
228                && field(at + 20, 2)
229                    .is_some_and(|comment| at as u64 + 22 + comment <= bytes.len() as u64)
230        })
231        .ok_or_else(|| malformed("no end of central directory record"))?;
232    let (mut files, mut size) = (field(end + 10, 2), field(end + 12, 4));
233    let zip64 =
234        files == Some(u64::from(u16::MAX)) || field(end + 16, 4) == Some(u64::from(u32::MAX));
235    if zip64
236        && let Some(locator) = end.checked_sub(20)
237        && bytes[locator..].starts_with(END64_LOCATOR)
238    {
239        let at = field(locator + 8, 8)
240            .and_then(|at| usize::try_from(at).ok())
241            .filter(|&at| at < locator)
242            .and_then(|start| {
243                bytes[start..locator]
244                    .windows(END64.len())
245                    .position(|window| window == END64)
246                    .map(|found| start + found)
247            })
248            .ok_or_else(|| malformed("no ZIP64 end of central directory record"))?;
249        (files, size) = (field(at + 32, 8), field(at + 40, 8));
250    }
251    let (Some(files), Some(size)) = (files, size) else {
252        return Err(malformed("the end of central directory record is cut off"));
253    };
254    if files > MAX_DOTLOTTIE_FILES as u64 {
255        return Err(ExtensionError::Budget(format!(
256            "the dotLottie package holds more than {MAX_DOTLOTTIE_FILES} files"
257        )));
258    }
259    if size > MAX_DOTLOTTIE_DIRECTORY_BYTES {
260        return Err(ExtensionError::Budget(format!(
261            "the dotLottie package's central directory takes more than \
262             {MAX_DOTLOTTIE_DIRECTORY_BYTES} bytes"
263        )));
264    }
265    Ok(())
266}
267
268/// Bytes the ZIP reader may read while it looks for a package's directory,
269/// besides twice the package: the last 64 KiB its end record is in and the
270/// directory itself, many times over.
271const MAX_DIRECTORY_SEARCH: u64 = 4 * 1_024 * 1_024;
272
273/// A package's bytes as the ZIP reader reads them, within what is `left`.
274///
275/// The ZIP reader looks for the end record from the end, and when the
276/// directory a record describes does not read, tries the record before it.
277/// zip 2.4 searched forward for the directory each time, so a package of
278/// nothing but such records cost the square of its size (a 0.7 MB one,
279/// 264 ms; 256 MiB, hours); zip 9 remembers what it found, but the bound
280/// here does not depend on how the reader searches. Reading the right record
281/// and its directory takes one pass from the end; once the bytes left run
282/// out, reading fails, which ends the search.
283struct Metered<'a> {
284    bytes: Cursor<&'a [u8]>,
285    left: Rc<Cell<u64>>,
286}
287
288impl Read for Metered<'_> {
289    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
290        let left = self.left.get();
291        if left == 0 {
292            return Err(std::io::Error::other("the search for a directory is over"));
293        }
294        let take = usize::try_from(left).map_or(buf.len(), |left| left.min(buf.len()));
295        let read = self.bytes.read(&mut buf[..take])?;
296        // Every call costs something, a read of nothing too.
297        self.left.set(left.saturating_sub((read as u64).max(1)));
298        Ok(read)
299    }
300}
301
302impl Seek for Metered<'_> {
303    fn seek(&mut self, to: SeekFrom) -> std::io::Result<u64> {
304        self.bytes.seek(to)
305    }
306}
307
308/// A dotLottie package: its animations by ID, and its files, each unpacked
309/// only when it is read: the manifest, the animation shown, and that
310/// animation's images, never the package's other files.
311struct Package<'a> {
312    ids: Vec<String>,
313    archive: RefCell<zip::ZipArchive<Metered<'a>>>,
314    /// Each file's index in the archive, by its name without a leading `/`.
315    names: HashMap<String, usize>,
316    /// The bytes unpacked so far, all files together.
317    unpacked: Cell<u64>,
318}
319
320impl<'a> Package<'a> {
321    fn read(bytes: &'a [u8]) -> Result<Self, ExtensionError> {
322        check_directory(bytes)?;
323        let left = Rc::new(Cell::new(
324            MAX_DIRECTORY_SEARCH.saturating_add((bytes.len() as u64).saturating_mul(2)),
325        ));
326        let metered = Metered {
327            bytes: Cursor::new(bytes),
328            left: Rc::clone(&left),
329        };
330        let archive = zip::ZipArchive::new(metered).map_err(|error| {
331            if left.get() == 0 {
332                ExtensionError::Malformed(
333                    "dotLottie: no end of central directory record describes a directory".into(),
334                )
335            } else {
336                ExtensionError::Malformed(format!("dotLottie: {error}"))
337            }
338        })?;
339        // Its files are each held to what they unpack (see `Self::file`).
340        left.set(u64::MAX);
341        if archive.len() > MAX_DOTLOTTIE_FILES {
342            return Err(ExtensionError::Budget(format!(
343                "the dotLottie package holds more than {MAX_DOTLOTTIE_FILES} files"
344            )));
345        }
346        let mut names = HashMap::new();
347        for (index, name) in archive.file_names().enumerate() {
348            let name =
349                name.map_err(|error| ExtensionError::Malformed(format!("dotLottie: {error}")))?;
350            if !name.ends_with('/') {
351                names.insert(name.trim_start_matches('/').to_owned(), index);
352            }
353        }
354        let package = Self {
355            ids: Vec::new(),
356            archive: RefCell::new(archive),
357            names,
358            unpacked: Cell::new(0),
359        };
360        let manifest: Value = serde_json::from_slice(
361            &package
362                .file("manifest.json", Some(MAX_LOTTIE_DOCUMENT_BYTES))?
363                .ok_or_else(|| ExtensionError::Malformed("dotLottie: no manifest.json".into()))?,
364        )
365        .map_err(|error| ExtensionError::Malformed(format!("dotLottie: {error}")))?;
366        let ids = manifest
367            .get("animations")
368            .and_then(Value::as_array)
369            .map(|animations| {
370                animations
371                    .iter()
372                    .filter_map(|animation| animation.get("id")?.as_str().map(str::to_owned))
373                    .collect::<Vec<_>>()
374            })
375            .unwrap_or_default();
376        if ids.is_empty() {
377            return Err(ExtensionError::Malformed(
378                "dotLottie: the manifest lists no animation".into(),
379            ));
380        }
381        Ok(Self { ids, ..package })
382    }
383
384    /// A file's bytes, unpacked now; `None` when the package has no such
385    /// file. Every file read counts toward one budget, and a JSON document,
386    /// parsed whole, is also within `document` bytes on its own.
387    ///
388    /// The size a package declares is the writer's word: a large one is
389    /// refused before unpacking, but the budgets count the bytes unpacked.
390    /// A file is read to its end, where its checksum is checked, or past
391    /// what is left of a budget, and refused. One that unpacks past the size
392    /// it declares is malformed: the ZIP reader stops there and fails.
393    fn file(&self, name: &str, document: Option<usize>) -> Result<Option<Vec<u8>>, ExtensionError> {
394        let Some(&index) = self.names.get(name) else {
395            return Ok(None);
396        };
397        let malformed = |error: &dyn std::fmt::Display| {
398            ExtensionError::Malformed(format!("dotLottie: {error}"))
399        };
400        let total = self.unpacked.get();
401        let left = MAX_LOTTIE_BYTES - total;
402        let (within, too_large) = match document {
403            Some(most) if (most as u64) < left => (
404                most as u64,
405                format!(
406                    "the dotLottie package's {name} unpacks to more than the {most} bytes a \
407                     provider reads"
408                ),
409            ),
410            _ => (left, "the dotLottie package unpacks past 256 MB".to_owned()),
411        };
412        let too_large = || ExtensionError::Budget(too_large.clone());
413        let mut archive = self.archive.borrow_mut();
414        let mut file = archive.by_index(index).map_err(|error| malformed(&error))?;
415        if file.size() > within {
416            return Err(too_large());
417        }
418        let mut contents = Vec::new();
419        let read = file
420            .by_ref()
421            .take(within + 1)
422            .read_to_end(&mut contents)
423            .map_err(|error| malformed(&error))? as u64;
424        if read > within {
425            return Err(too_large());
426        }
427        self.unpacked.set(total + read);
428        Ok(Some(contents))
429    }
430
431    /// An animation's JSON: version 2 packages keep them under `a/`,
432    /// version 1 under `animations/`.
433    fn animation(&self, id: Option<&str>) -> Result<Value, ExtensionError> {
434        let id = match id {
435            Some(id) if self.ids.iter().any(|known| known == id) => id,
436            Some(id) => {
437                return Err(ExtensionError::Mismatch(format!(
438                    "the dotLottie package has no animation {id}"
439                )));
440            }
441            None => &self.ids[0],
442        };
443        let bytes = self
444            .first_file(
445                &[format!("a/{id}.json"), format!("animations/{id}.json")],
446                Some(MAX_LOTTIE_DOCUMENT_BYTES),
447            )?
448            .ok_or_else(|| {
449                ExtensionError::Malformed(format!("dotLottie: animation {id} has no file"))
450            })?;
451        parse_lottie(&bytes).ok_or_else(|| {
452            ExtensionError::Malformed(format!("dotLottie: animation {id} is not Lottie"))
453        })
454    }
455
456    /// The package as its first animation that opens describes it, with
457    /// those before it that do not reported: a source can choose any that
458    /// opens. Refused, for its first, when none does.
459    fn probe(&self) -> Result<ProbeReport, ExtensionError> {
460        let mut unreadable = Vec::new();
461        let mut first = None;
462        for id in &self.ids {
463            match self
464                .animation(Some(id))
465                .and_then(|json| inspect(json, Format::DotLottie))
466            {
467                Ok((report, _)) => {
468                    unreadable.extend(report.diagnostics);
469                    return Ok(ProbeReport {
470                        items: self.ids.clone(),
471                        item: Some(id.clone()),
472                        diagnostics: unreadable,
473                        ..report
474                    });
475                }
476                Err(error) => {
477                    unreadable.push(Diagnostic::UnreadableItem {
478                        item: id.clone(),
479                        reason: error.to_string(),
480                    });
481                    first.get_or_insert(error);
482                }
483            }
484        }
485        Err(first.unwrap_or_else(|| {
486            ExtensionError::Malformed("dotLottie: the manifest lists no animation".into())
487        }))
488    }
489
490    /// An image the package holds: by its path, or under `i/` (version 2)
491    /// or `images/` (version 1).
492    fn image(&self, name: &str) -> Result<Option<Vec<u8>>, ExtensionError> {
493        let name = name.trim_start_matches('/');
494        self.first_file(
495            &[
496                name.to_owned(),
497                format!("i/{name}"),
498                format!("images/{name}"),
499            ],
500            None,
501        )
502    }
503
504    /// The first of `paths` the package holds, unpacked, as [`Self::file`]
505    /// unpacks it.
506    fn first_file(
507        &self,
508        paths: &[String],
509        document: Option<usize>,
510    ) -> Result<Option<Vec<u8>>, ExtensionError> {
511        for path in paths {
512            if let Some(bytes) = self.file(path, document)? {
513                return Ok(Some(bytes));
514            }
515        }
516        Ok(None)
517    }
518}
519
520/// What an animation is, and its JSON with what the backend cannot read
521/// either refused or reported.
522fn inspect(json: Value, format: Format) -> Result<(ProbeReport, Value), ExtensionError> {
523    let root = json
524        .as_object()
525        .ok_or_else(|| ExtensionError::Malformed("the animation is not an object".into()))?;
526    let number = |key: &str| root.get(key).and_then(Value::as_f64).unwrap_or(0.0);
527    let (width, height) = (number("w"), number("h"));
528    let (rate, first, last) = (number("fr"), number("ip"), number("op"));
529    if !(width >= 1.0 && height >= 1.0 && rate > 0.0 && last > first)
530        || last - first > MAX_LOTTIE_FRAMES
531    {
532        return Err(ExtensionError::Malformed(
533            "the animation has no canvas, rate, or frames".into(),
534        ));
535    }
536    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
537    let (width, height, frames) = (
538        width.round() as u32,
539        height.round() as u32,
540        (last - first).ceil() as usize,
541    );
542    check_canvas(width, height)?;
543    let (diagnostics, sanitized) = normalize::normalize(json)?;
544    Ok((
545        ProbeReport {
546            format,
547            width,
548            height,
549            timing: Timing::Rate {
550                rate: rational_rate(rate).ok_or_else(|| {
551                    ExtensionError::Malformed(format!("the frame rate {rate} is not usable"))
552                })?,
553                frames,
554            },
555            loop_count: None,
556            items: Vec::new(),
557            item: None,
558            color: None,
559            diagnostics,
560        },
561        sanitized,
562    ))
563}
564
565/// A Lottie rate as the rational a stream declares: NTSC and whole rates
566/// exactly, others to a thousandth.
567fn rational_rate(rate: f64) -> Option<FrameRate> {
568    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
569    let (num, den) = [24_u32, 30, 48, 60, 120]
570        .into_iter()
571        .find(|base| (rate - f64::from(*base) * 1_000.0 / 1_001.0).abs() < 0.005)
572        .map(|base| (base * 1_000, 1_001))
573        .or_else(|| ((rate - rate.round()).abs() < 0.005).then(|| (rate.round() as u32, 1)))
574        .unwrap_or_else(|| ((rate * 1_000.0).round() as u32, 1_000));
575    FrameRate::new(num, den).ok()
576}
577
578/// An image asset's bytes: a data URL, a file in the package, or a resource;
579/// `None` when it is none of them. A package file that cannot be unpacked
580/// fails the package, as it would have had it been read whole.
581fn image_bytes(
582    asset: &ImageAsset,
583    package: Option<&Package>,
584    resolver: &dyn ResourceResolver,
585) -> Result<Option<Vec<u8>>, ExtensionError> {
586    if let Some(data) = asset.file_name.strip_prefix("data:") {
587        return Ok(data.split_once(";base64,").and_then(|(_, encoded)| {
588            base64::engine::general_purpose::STANDARD
589                .decode(encoded)
590                .ok()
591        }));
592    }
593    let name = format!(
594        "{}{}",
595        asset.directory.clone().unwrap_or_default(),
596        asset.file_name
597    );
598    if let Some(package) = package {
599        for candidate in [&name, &asset.file_name] {
600            if let Some(bytes) = package.image(candidate)? {
601                return Ok(Some(bytes));
602            }
603        }
604    }
605    Ok(resolver.bytes(&name).ok().map(|bytes| bytes.to_vec()))
606}
607
608/// A PNG image asset as a premultiplied pixmap. Other formats are reported
609/// missing.
610fn decode_image(bytes: &[u8]) -> Option<Pixmap> {
611    let (width, height, mut pixels) = crate::raster::decode_png(bytes).ok()?;
612    crate::api::premultiply(&mut pixels);
613    Pixmap::from_vec(pixels, IntSize::from_wh(width, height)?)
614}
615
616fn short(name: &str) -> String {
617    if name.starts_with("data:") {
618        "an embedded image".into()
619    } else {
620        name.to_owned()
621    }
622}
623
624/// An open Lottie animation, with its PNG image assets decoded once at open.
625struct LottieSource {
626    composition: Composition,
627    renderer: Renderer,
628    images: HashMap<String, Pixmap>,
629    report: ProbeReport,
630    last: Option<VisualFrame>,
631}
632
633impl VisualSource for LottieSource {
634    fn report(&self) -> &ProbeReport {
635        &self.report
636    }
637
638    fn render(&mut self, time: MediaTime, fit: (u32, u32)) -> Result<VisualFrame, ExtensionError> {
639        let (index, start, end) = self.report.timing.frame_at(time);
640        let (width, height) = fitted(self.report.width, self.report.height, fit);
641        if let Some(last) = self
642            .last
643            .as_ref()
644            .filter(|last| last.index == index && (last.width, last.height) == (width, height))
645        {
646            return Ok(last.clone());
647        }
648        let mut sink = SkiaSink::new(width, height, &self.images)?;
649        #[allow(clippy::cast_precision_loss)]
650        let frame = self.composition.frames.start + index as f64;
651        let transform = Affine::scale_non_uniform(
652            f64::from(width) / f64::from(self.report.width),
653            f64::from(height) / f64::from(self.report.height),
654        );
655        self.renderer
656            .append(&self.composition, frame, transform, 1.0, &mut sink);
657        let frame = VisualFrame {
658            width,
659            height,
660            pixels: FramePixels::Rgba8(sink.finish().into()),
661            index,
662            start,
663            end,
664            diagnostics: Vec::new(),
665        };
666        self.last = Some(frame.clone());
667        Ok(frame)
668    }
669
670    fn resident_bytes(&self) -> usize {
671        self.images
672            .values()
673            .map(|image| image.data().len())
674            .sum::<usize>()
675            + self.last.as_ref().map_or(0, |last| last.pixels.byte_len())
676    }
677}
678
679/// The largest whole-pixel size of the canvas that fits in `fit`, keeping
680/// its shape; the canvas itself when `fit` is empty.
681fn fitted(width: u32, height: u32, fit: (u32, u32)) -> (u32, u32) {
682    if fit.0 == 0 || fit.1 == 0 {
683        return (width, height);
684    }
685    let scale = (f64::from(fit.0) / f64::from(width)).min(f64::from(fit.1) / f64::from(height));
686    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
687    (
688        (f64::from(width) * scale)
689            .round()
690            .clamp(1.0, f64::from(fit.0)) as u32,
691        (f64::from(height) * scale)
692            .round()
693            .clamp(1.0, f64::from(fit.1)) as u32,
694    )
695}
696
697/// velato's scene, drawn with tiny-skia: layers are pixmaps composited on
698/// pop with their blend mode, opacity, and clip.
699///
700/// A layer is composited only where its clip lets anything through and,
701/// when it blends normally, only where anything was drawn on it, so a mask,
702/// matte, image clip, or fading repeater copy costs what it covers, not the
703/// frame: elsewhere the composite would leave every pixel as it was. A
704/// layer that clips nothing, blends normally, and is opaque, pushed on a
705/// layer nothing has been drawn on yet (velato's clip to the animation's
706/// own canvas, every frame), is drawn straight onto that layer: compositing
707/// it there would give the same pixels.
708struct SkiaSink<'a> {
709    width: u32,
710    height: u32,
711    layers: Vec<Layer>,
712    images: &'a HashMap<String, Pixmap>,
713    /// The mask of a clip that covers the canvas, all of it 255: what
714    /// rasterizing such a clip makes, made once a frame.
715    covering: Option<Rc<Mask>>,
716    /// Layers popped, cleared, for the next pushed to draw on.
717    pool: Vec<Pixmap>,
718    /// Whether every layer is composited whole, in tests (see [`Self::whole`]).
719    #[cfg(test)]
720    whole: bool,
721    /// Pixels composited, in tests.
722    #[cfg(test)]
723    composited: u64,
724    /// Layer pixmaps allocated, in tests.
725    #[cfg(test)]
726    allocated: usize,
727}
728
729/// One pushed layer of a [`SkiaSink`].
730struct Layer {
731    /// `None` for a layer drawn straight onto the one below.
732    pixmap: Option<Pixmap>,
733    blend: BlendMode,
734    alpha: f32,
735    clip: Option<Rc<Mask>>,
736    /// The part of the canvas the clip lets through; `None` when it lets
737    /// nothing through.
738    bounds: Option<IntRect>,
739    /// The part of it anything has been drawn on; `None` while nothing has.
740    dirty: Option<IntRect>,
741}
742
743impl<'a> SkiaSink<'a> {
744    fn new(
745        width: u32,
746        height: u32,
747        images: &'a HashMap<String, Pixmap>,
748    ) -> Result<Self, ExtensionError> {
749        let pixmap = Pixmap::new(width, height).ok_or_else(|| {
750            ExtensionError::Budget(format!("cannot rasterize at {width}x{height}"))
751        })?;
752        Ok(Self {
753            width,
754            height,
755            layers: vec![Layer {
756                pixmap: Some(pixmap),
757                blend: BlendMode::SourceOver,
758                alpha: 1.0,
759                clip: None,
760                bounds: IntRect::from_xywh(0, 0, width, height),
761                dirty: None,
762            }],
763            images,
764            covering: None,
765            pool: Vec::new(),
766            #[cfg(test)]
767            whole: false,
768            #[cfg(test)]
769            composited: 0,
770            #[cfg(test)]
771            allocated: 0,
772        })
773    }
774
775    fn finish(mut self) -> Vec<u8> {
776        while self.layers.len() > 1 {
777            self.pop_layer();
778        }
779        self.layers
780            .pop()
781            .and_then(|layer| layer.pixmap)
782            .map_or_else(Vec::new, Pixmap::take)
783    }
784
785    /// Whether every layer is composited whole, as `draw_pixmap` would:
786    /// the reference the bounded compositing must match, in tests.
787    #[allow(clippy::unused_self)]
788    fn whole(&self) -> bool {
789        #[cfg(test)]
790        return self.whole;
791        #[cfg(not(test))]
792        false
793    }
794
795    /// The layer drawing goes onto: the last with a pixmap of its own.
796    fn target(&mut self) -> Option<&mut Layer> {
797        self.layers
798            .iter_mut()
799            .rev()
800            .find(|layer| layer.pixmap.is_some())
801    }
802
803    /// The pixmap drawing goes onto, now drawn on within `bounds` (device
804    /// pixels, `None` for all of it).
805    fn draw_on(&mut self, bounds: Option<IntRect>) -> &mut Pixmap {
806        let canvas = IntRect::from_xywh(0, 0, self.width, self.height);
807        let layer = self.target().expect("the canvas is a layer");
808        layer.dirty = match (layer.dirty, bounds.or(canvas)) {
809            (Some(dirty), Some(bounds)) => union(dirty, bounds),
810            (dirty, bounds) => dirty.or(bounds),
811        };
812        layer.pixmap.as_mut().expect("a layer with a pixmap")
813    }
814
815    /// The device pixels a path in `bounds` can touch once drawn with
816    /// `transform`, and two more around (antialiasing touches one).
817    fn reach(&self, bounds: tiny_skia::Rect, transform: Transform) -> Option<IntRect> {
818        let device = PathBuilder::from_rect(bounds)
819            .transform(transform)?
820            .bounds();
821        // Sides are at most 16384: exact in f32.
822        #[allow(clippy::cast_precision_loss)]
823        let (width, height) = (self.width as f32, self.height as f32);
824        #[allow(clippy::cast_possible_truncation)]
825        IntRect::from_ltrb(
826            device.left().floor().max(-2.0) as i32 - 2,
827            device.top().floor().max(-2.0) as i32 - 2,
828            device.right().ceil().min(width) as i32 + 2,
829            device.bottom().ceil().min(height) as i32 + 2,
830        )?
831        .intersect(&IntRect::from_xywh(0, 0, self.width, self.height)?)
832    }
833
834    fn push(&mut self, blend: BlendMode, alpha: f32, affine: Affine, shape: &impl Shape) {
835        let transform = skia_transform(affine);
836        let path = path_of(shape);
837        let canvas = IntRect::from_xywh(0, 0, self.width, self.height);
838        // Where the clip is, as the mask rasterizes it.
839        let device = path
840            .clone()
841            .map(|path| path.transform(transform).map(|path| path.bounds()));
842        // Sides are at most 16384: exact in f32.
843        #[allow(clippy::cast_precision_loss)]
844        let (width, height) = (self.width as f32, self.height as f32);
845        let clips_nothing = shape.as_rect().is_some()
846            && transform.kx == 0.0
847            && transform.ky == 0.0
848            && matches!(device, Some(Some(bounds)) if bounds.left() <= 0.0
849                && bounds.top() <= 0.0
850                && bounds.right() >= width
851                && bounds.bottom() >= height);
852        #[allow(clippy::float_cmp)]
853        let opaque = alpha == 1.0;
854        if clips_nothing
855            && blend == BlendMode::SourceOver
856            && opaque
857            && !self.whole()
858            && self.target().is_some_and(|layer| layer.dirty.is_none())
859        {
860            self.layers.push(Layer {
861                pixmap: None,
862                blend,
863                alpha,
864                clip: None,
865                bounds: None,
866                dirty: None,
867            });
868            return;
869        }
870        let bounds = match &path {
871            // No clip: the whole layer.
872            None => canvas,
873            _ if self.whole() => canvas,
874            // Every pixel the mask can cover.
875            Some(path) => self.reach(path.bounds(), transform),
876        };
877        let clip = if clips_nothing && !self.whole() {
878            if self.covering.is_none() {
879                self.covering = IntSize::from_wh(self.width, self.height)
880                    .and_then(|size| {
881                        let coverage = vec![255; size.width() as usize * size.height() as usize];
882                        Mask::from_vec(coverage, size)
883                    })
884                    .map(Rc::new);
885            }
886            self.covering.clone()
887        } else {
888            path.and_then(|path| {
889                let mut mask = Mask::new(self.width, self.height)?;
890                if device::within(shape, affine, 0.0, (self.width, self.height)) {
891                    mask.fill_path(&path, FillRule::Winding, true, transform);
892                } else if let Some(far) =
893                    device::fill_path(shape, affine, (self.width, self.height))
894                {
895                    mask.fill_path(&far, FillRule::Winding, true, Transform::identity());
896                }
897                Some(Rc::new(mask))
898            })
899        };
900        let pooled = if self.whole() { None } else { self.pool.pop() };
901        #[cfg(test)]
902        if pooled.is_none() {
903            self.allocated += 1;
904        }
905        let Some(pixmap) = pooled.or_else(|| Pixmap::new(self.width, self.height)) else {
906            return;
907        };
908        self.layers.push(Layer {
909            pixmap: Some(pixmap),
910            blend,
911            alpha,
912            clip,
913            bounds,
914            dirty: None,
915        });
916    }
917}
918
919impl SkiaSink<'_> {
920    /// Fills `shape`, which reaches farther past the canvas than tiny-skia
921    /// draws safely, through `transform`: brought within the canvas first
922    /// ([`device`]), with the paint's shader moved as tiny-skia would move it.
923    fn fill_far(&mut self, shape: &impl Shape, transform: Affine, mut paint: Paint<'_>) {
924        let Some(path) = device::fill_path(shape, transform, (self.width, self.height)) else {
925            return;
926        };
927        paint.shader.transform(skia_transform(transform));
928        let bounds = self.reach(path.bounds(), Transform::identity());
929        self.draw_on(bounds).fill_path(
930            &path,
931            &paint,
932            FillRule::Winding,
933            Transform::identity(),
934            None,
935        );
936    }
937
938    /// Composites a popped layer onto the one below within `bounds`, as
939    /// `draw_pixmap` composites it whole.
940    fn composite(&mut self, pixmap: &Pixmap, layer: &Layer, bounds: IntRect) {
941        #[cfg(test)]
942        {
943            self.composited += u64::from(bounds.width()) * u64::from(bounds.height());
944        }
945        let paint = Paint {
946            shader: tiny_skia::Pattern::new(
947                pixmap.as_ref(),
948                SpreadMode::Pad,
949                tiny_skia::FilterQuality::Nearest,
950                layer.alpha,
951                Transform::identity(),
952            ),
953            blend_mode: layer.blend,
954            anti_alias: false,
955            ..Paint::default()
956        };
957        self.draw_on(Some(bounds)).fill_rect(
958            bounds.to_rect(),
959            &paint,
960            Transform::identity(),
961            layer.clip.as_deref(),
962        );
963    }
964}
965
966/// The smallest rectangle holding both.
967fn union(a: IntRect, b: IntRect) -> Option<IntRect> {
968    IntRect::from_ltrb(
969        a.left().min(b.left()),
970        a.top().min(b.top()),
971        a.right().max(b.right()),
972        a.bottom().max(b.bottom()),
973    )
974}
975
976impl RenderSink for SkiaSink<'_> {
977    fn push_layer(
978        &mut self,
979        blend: impl Into<PenikoBlend>,
980        alpha: f32,
981        transform: Affine,
982        shape: &impl Shape,
983    ) {
984        self.push(blend_mode(blend.into()), alpha, transform, shape);
985    }
986
987    fn push_clip_layer(&mut self, transform: Affine, shape: &impl Shape) {
988        self.push(BlendMode::SourceOver, 1.0, transform, shape);
989    }
990
991    fn pop_layer(&mut self) {
992        if self.layers.len() < 2 {
993            return;
994        }
995        let Some(layer) = self.layers.pop() else {
996            return;
997        };
998        let mut layer = layer;
999        let Some(mut pixmap) = layer.pixmap.take() else {
1000            return;
1001        };
1002        // Within its clip, and over the layer below where anything was drawn on it: elsewhere a
1003        // pixel is left as it was.
1004        let bounds = layer.bounds.and_then(|bounds| match layer.blend {
1005            BlendMode::SourceOver if !self.whole() => layer.dirty?.intersect(&bounds),
1006            _ => Some(bounds),
1007        });
1008        if let Some(bounds) = bounds {
1009            self.composite(&pixmap, &layer, bounds);
1010        }
1011        // Cleared where it was drawn on, it is a new layer again.
1012        if let Some(dirty) = layer.dirty {
1013            let at = |value: i32| usize::try_from(value).unwrap_or(0);
1014            let stride = pixmap.width() as usize * 4;
1015            let (left, right) = (at(dirty.left()) * 4, at(dirty.right()) * 4);
1016            for row in pixmap
1017                .data_mut()
1018                .chunks_exact_mut(stride)
1019                .skip(at(dirty.top()))
1020                .take(dirty.height() as usize)
1021            {
1022                row[left..right].fill(0);
1023            }
1024        }
1025        self.pool.push(pixmap);
1026    }
1027    fn draw(
1028        &mut self,
1029        stroke: Option<&kurbo::Stroke>,
1030        transform: Affine,
1031        brush: &Brush,
1032        shape: &impl Shape,
1033    ) {
1034        let Some(path) = path_of(shape) else {
1035            return;
1036        };
1037        let Some(shader) = shader_of(brush) else {
1038            return;
1039        };
1040        let paint = Paint {
1041            shader,
1042            anti_alias: true,
1043            ..Paint::default()
1044        };
1045        let affine = transform;
1046        let transform = skia_transform(affine);
1047        match stroke {
1048            // tiny-skia draws a stroke of no width as a hairline; Lottie,
1049            // like SVG, draws nothing.
1050            Some(stroke) if stroke.width.is_nan() || stroke.width <= 0.0 => {}
1051            Some(stroke) => {
1052                let style = Stroke {
1053                    #[allow(clippy::cast_possible_truncation)]
1054                    width: stroke.width as f32,
1055                    #[allow(clippy::cast_possible_truncation)]
1056                    miter_limit: stroke.miter_limit as f32,
1057                    line_cap: match stroke.start_cap {
1058                        kurbo::Cap::Butt => LineCap::Butt,
1059                        kurbo::Cap::Round => LineCap::Round,
1060                        kurbo::Cap::Square => LineCap::Square,
1061                    },
1062                    line_join: match stroke.join {
1063                        kurbo::Join::Bevel => LineJoin::Bevel,
1064                        kurbo::Join::Miter => LineJoin::Miter,
1065                        kurbo::Join::Round => LineJoin::Round,
1066                    },
1067                    #[allow(clippy::cast_possible_truncation)]
1068                    dash: (!stroke.dash_pattern.is_empty())
1069                        .then(|| {
1070                            StrokeDash::new(
1071                                stroke
1072                                    .dash_pattern
1073                                    .iter()
1074                                    .map(|value| *value as f32)
1075                                    .collect(),
1076                                stroke.dash_offset as f32,
1077                            )
1078                        })
1079                        .flatten(),
1080                };
1081                // A stroke reaches half its width out, a miter or square
1082                // cap as far as the miter limit or sqrt(2) times that; a whole
1083                // width more covers how its outline is flattened.
1084                let reach = style.width / 2.0 * style.miter_limit.max(std::f32::consts::SQRT_2)
1085                    + style.width;
1086                if !device::within(shape, affine, f64::from(reach), (self.width, self.height)) {
1087                    // Its outline, as tiny-skia strokes one wider than a pixel, filled
1088                    // within the canvas.
1089                    let scale = PathStroker::compute_resolution_scale(&transform);
1090                    let dashed = match &style.dash {
1091                        Some(dash) => path.dash(dash, scale),
1092                        None => Some(path),
1093                    };
1094                    if let Some(outline) = dashed.and_then(|path| path.stroke(&style, scale)) {
1095                        self.fill_far(&device::kurbo_path(&outline), affine, paint);
1096                    }
1097                    return;
1098                }
1099                let bounds = tiny_skia::Rect::from_ltrb(
1100                    path.bounds().left() - reach,
1101                    path.bounds().top() - reach,
1102                    path.bounds().right() + reach,
1103                    path.bounds().bottom() + reach,
1104                )
1105                .and_then(|bounds| self.reach(bounds, transform));
1106                self.draw_on(bounds)
1107                    .stroke_path(&path, &paint, &style, transform, None);
1108            }
1109            None if !device::within(shape, affine, 0.0, (self.width, self.height)) => {
1110                self.fill_far(shape, affine, paint);
1111            }
1112            None => {
1113                let bounds = self.reach(path.bounds(), transform);
1114                self.draw_on(bounds)
1115                    .fill_path(&path, &paint, FillRule::Winding, transform, None);
1116            }
1117        }
1118    }
1119
1120    fn draw_image(&mut self, image: &ImageAsset, transform: Affine, alpha: f64) {
1121        let Some(pixmap) = self.images.get(&image.id) else {
1122            return;
1123        };
1124        // A picture is shown at its asset's authored size, the whole of it
1125        // fitted and centered, as velato's own sink and lottie-web draw it;
1126        // velato clips the layer to that size.
1127        let picture = (f64::from(pixmap.width()), f64::from(pixmap.height()));
1128        let transform = match image.width.zip(image.height) {
1129            Some((width, height)) if width > 0.0 && height > 0.0 && (width, height) != picture => {
1130                let scale = (width / picture.0).min(height / picture.1);
1131                transform
1132                    * Affine::translate((
1133                        (width - picture.0 * scale) / 2.0,
1134                        (height - picture.1 * scale) / 2.0,
1135                    ))
1136                    * Affine::scale(scale)
1137            }
1138            _ => transform,
1139        };
1140        let paint = PixmapPaint {
1141            #[allow(clippy::cast_possible_truncation)]
1142            opacity: alpha as f32,
1143            blend_mode: BlendMode::SourceOver,
1144            quality: tiny_skia::FilterQuality::Bilinear,
1145        };
1146        let picture = kurbo::Rect::new(0.0, 0.0, picture.0, picture.1);
1147        if !device::within(&picture, transform, 0.0, (self.width, self.height)) {
1148            // As tiny-skia draws a picture: a rectangle filled with it.
1149            let paint = Paint {
1150                shader: tiny_skia::Pattern::new(
1151                    pixmap.as_ref(),
1152                    SpreadMode::Pad,
1153                    paint.quality,
1154                    paint.opacity,
1155                    Transform::identity(),
1156                ),
1157                blend_mode: paint.blend_mode,
1158                anti_alias: false,
1159                ..Paint::default()
1160            };
1161            self.fill_far(&picture, transform, paint);
1162            return;
1163        }
1164        let transform = skia_transform(transform);
1165        let bounds = IntRect::from_xywh(0, 0, pixmap.width(), pixmap.height())
1166            .and_then(|picture| self.reach(picture.to_rect(), transform));
1167        self.draw_on(bounds)
1168            .draw_pixmap(0, 0, pixmap.as_ref(), &paint, transform, None);
1169    }
1170}
1171
1172fn skia_transform(affine: Affine) -> Transform {
1173    #[allow(clippy::cast_possible_truncation)]
1174    let [sx, ky, kx, sy, tx, ty] = affine.as_coeffs().map(|value| value as f32);
1175    Transform::from_row(sx, ky, kx, sy, tx, ty)
1176}
1177
1178/// A kurbo shape as a tiny-skia path, flattened to a tenth of a unit.
1179fn path_of(shape: &impl Shape) -> Option<tiny_skia::Path> {
1180    let mut builder = PathBuilder::new();
1181    #[allow(clippy::cast_possible_truncation)]
1182    let point = |p: kurbo::Point| (p.x as f32, p.y as f32);
1183    for element in shape.path_elements(0.1) {
1184        match element {
1185            PathEl::MoveTo(p) => {
1186                let (x, y) = point(p);
1187                builder.move_to(x, y);
1188            }
1189            PathEl::LineTo(p) => {
1190                let (x, y) = point(p);
1191                builder.line_to(x, y);
1192            }
1193            PathEl::QuadTo(p1, p2) => {
1194                let ((x1, y1), (x, y)) = (point(p1), point(p2));
1195                builder.quad_to(x1, y1, x, y);
1196            }
1197            PathEl::CurveTo(p1, p2, p3) => {
1198                let ((x1, y1), (x2, y2), (x, y)) = (point(p1), point(p2), point(p3));
1199                builder.cubic_to(x1, y1, x2, y2, x, y);
1200            }
1201            PathEl::ClosePath => builder.close(),
1202        }
1203    }
1204    builder.finish()
1205}
1206
1207fn shader_of(brush: &Brush) -> Option<Shader<'static>> {
1208    let color = |components: [f32; 4]| {
1209        Color::from_rgba(
1210            components[0].clamp(0.0, 1.0),
1211            components[1].clamp(0.0, 1.0),
1212            components[2].clamp(0.0, 1.0),
1213            components[3].clamp(0.0, 1.0),
1214        )
1215    };
1216    match brush {
1217        Brush::Solid(solid) => color(solid.components).map(Shader::SolidColor),
1218        Brush::Gradient(gradient) => {
1219            let stops = gradient
1220                .stops
1221                .iter()
1222                .filter_map(|stop| {
1223                    let srgb = stop.color.to_alpha_color::<peniko::color::Srgb>();
1224                    color(srgb.components).map(|c| GradientStop::new(stop.offset, c))
1225                })
1226                .collect::<Vec<_>>();
1227            let mode = match gradient.extend {
1228                Extend::Pad => SpreadMode::Pad,
1229                Extend::Repeat => SpreadMode::Repeat,
1230                Extend::Reflect => SpreadMode::Reflect,
1231            };
1232            #[allow(clippy::cast_possible_truncation)]
1233            let point = |p: kurbo::Point| Point::from_xy(p.x as f32, p.y as f32);
1234            match &gradient.kind {
1235                GradientKind::Linear(linear) => LinearGradient::new(
1236                    point(linear.start),
1237                    point(linear.end),
1238                    stops,
1239                    mode,
1240                    Transform::identity(),
1241                ),
1242                GradientKind::Radial(radial) => RadialGradient::new(
1243                    point(radial.start_center),
1244                    radial.start_radius,
1245                    point(radial.end_center),
1246                    radial.end_radius,
1247                    stops,
1248                    mode,
1249                    Transform::identity(),
1250                ),
1251                GradientKind::Sweep(sweep) => SweepGradient::new(
1252                    point(sweep.center),
1253                    sweep.start_angle.to_degrees(),
1254                    sweep.end_angle.to_degrees(),
1255                    stops,
1256                    mode,
1257                    Transform::identity(),
1258                ),
1259            }
1260        }
1261        Brush::Image(_) => None,
1262    }
1263}
1264
1265/// A peniko blend as tiny-skia draws it: a separable or non-separable mix
1266/// over the backdrop, otherwise the Porter-Duff composition.
1267fn blend_mode(blend: PenikoBlend) -> BlendMode {
1268    match blend.mix {
1269        Mix::Multiply => BlendMode::Multiply,
1270        Mix::Screen => BlendMode::Screen,
1271        Mix::Overlay => BlendMode::Overlay,
1272        Mix::Darken => BlendMode::Darken,
1273        Mix::Lighten => BlendMode::Lighten,
1274        Mix::ColorDodge => BlendMode::ColorDodge,
1275        Mix::ColorBurn => BlendMode::ColorBurn,
1276        Mix::HardLight => BlendMode::HardLight,
1277        Mix::SoftLight => BlendMode::SoftLight,
1278        Mix::Difference => BlendMode::Difference,
1279        Mix::Exclusion => BlendMode::Exclusion,
1280        Mix::Hue => BlendMode::Hue,
1281        Mix::Saturation => BlendMode::Saturation,
1282        Mix::Color => BlendMode::Color,
1283        Mix::Luminosity => BlendMode::Luminosity,
1284        Mix::Normal => match blend.compose {
1285            Compose::Clear => BlendMode::Clear,
1286            Compose::Copy => BlendMode::Source,
1287            Compose::Dest => BlendMode::Destination,
1288            Compose::DestOver => BlendMode::DestinationOver,
1289            Compose::SrcIn => BlendMode::SourceIn,
1290            Compose::DestIn => BlendMode::DestinationIn,
1291            Compose::SrcOut => BlendMode::SourceOut,
1292            Compose::DestOut => BlendMode::DestinationOut,
1293            Compose::SrcAtop => BlendMode::SourceAtop,
1294            Compose::DestAtop => BlendMode::DestinationAtop,
1295            Compose::Xor => BlendMode::Xor,
1296            Compose::Plus | Compose::PlusLighter => BlendMode::Plus,
1297            Compose::SrcOver => BlendMode::SourceOver,
1298        },
1299    }
1300}
1301
1302#[cfg(test)]
1303mod tests;