├── .cargo └── config.toml ├── .clippy.toml ├── .github ├── copyright.sh └── workflows │ ├── ci.yml │ └── pages-release.yml ├── .gitignore ├── .taplo.toml ├── .typos.toml ├── AUTHORS ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── examples ├── assets │ ├── downloads │ │ └── .tracked │ ├── google_fonts │ │ ├── LICENSE │ │ ├── README.md │ │ └── Tiger.json │ └── roboto │ │ ├── LICENSE.txt │ │ └── Roboto-Regular.ttf ├── run_wasm │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── scenes │ ├── Cargo.toml │ └── src │ │ ├── download.rs │ │ ├── download │ │ └── default_downloads.rs │ │ ├── lib.rs │ │ ├── lottie.rs │ │ ├── simple_text.rs │ │ └── test_scenes.rs └── with_winit │ ├── Cargo.toml │ ├── README.md │ └── src │ ├── hot_reload.rs │ ├── lib.rs │ ├── main.rs │ ├── multi_touch.rs │ └── stats.rs ├── rustfmt.toml └── src ├── error.rs ├── import ├── builders.rs ├── converters.rs ├── defaults.rs └── mod.rs ├── lib.rs ├── runtime ├── mod.rs ├── model │ ├── animated.rs │ ├── fixed.rs │ ├── mod.rs │ ├── spline.rs │ └── value.rs └── render.rs └── schema ├── animated_properties ├── animated_property.rs ├── color_value.rs ├── gradient_colors.rs ├── keyframe.rs ├── keyframe_base.rs ├── keyframe_bezier_handle.rs ├── mod.rs ├── multi_dimensional.rs ├── position.rs ├── position_keyframe.rs ├── shape_keyframe.rs ├── shape_property.rs ├── split_vector.rs └── value.rs ├── animation ├── animation.rs ├── composition.rs └── mod.rs ├── assets ├── asset.rs ├── file_asset.rs ├── image.rs ├── mod.rs └── precomposition.rs ├── constants ├── blend_mode.rs ├── composite.rs ├── fill_rule.rs ├── font_path_origin.rs ├── gradient_type.rs ├── line_cap.rs ├── line_join.rs ├── mask_mode.rs ├── matte_mode.rs ├── merge_mode.rs ├── mod.rs ├── shape_direction.rs ├── star_type.rs ├── stroke_dash_type.rs ├── text_based.rs ├── text_caps.rs ├── text_grouping.rs ├── text_justify.rs ├── text_range_units.rs ├── text_shape.rs └── trim_multiple_shapes.rs ├── helpers ├── bezier.rs ├── color.rs ├── int_boolean.rs ├── marker.rs ├── mask.rs ├── mod.rs ├── transform.rs └── visual_object.rs ├── layers ├── enumerations.rs ├── mod.rs ├── null.rs ├── precomposition.rs ├── shape.rs ├── solid_color.rs └── visual.rs ├── mod.rs ├── shapes ├── base_stroke.rs ├── ellipse.rs ├── fill.rs ├── gradient.rs ├── gradient_fill.rs ├── gradient_stroke.rs ├── group.rs ├── merge.rs ├── mod.rs ├── offset_path.rs ├── path.rs ├── polystar.rs ├── pucker_bloat.rs ├── rectangle.rs ├── repeater.rs ├── repeater_transform.rs ├── shape.rs ├── shape_element.rs ├── stroke.rs ├── stroke_dash.rs ├── transform.rs └── trim.rs └── styles ├── color_overlay_style.rs ├── gradient_overlay_style.rs ├── layer_style.rs ├── mod.rs ├── outer_glow_style.rs └── satin_style.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | run_wasm = "run --release --package run_wasm --" 3 | # Other crates use the alias run-wasm, even though crate names should use `_`s not `-`s 4 | # Allow this to be used 5 | run-wasm = "run_wasm" 6 | -------------------------------------------------------------------------------- /.clippy.toml: -------------------------------------------------------------------------------- 1 | # LINEBENDER LINT SET - .clippy.toml - v1 2 | # See https://linebender.org/wiki/canonical-lints/ 3 | 4 | # The default Clippy value is capped at 8 bytes, which was chosen to improve performance on 32-bit. 5 | # Given that we are building for the future and even low-end mobile phones have 64-bit CPUs, 6 | # it makes sense to optimize for 64-bit and accept the performance hits on 32-bit. 7 | # 16 bytes is the number of bytes that fits into two 64-bit CPU registers. 8 | trivial-copy-size-limit = 16 9 | 10 | # END LINEBENDER LINT SET 11 | -------------------------------------------------------------------------------- /.github/copyright.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # If there are new files with headers that can't match the conditions here, 4 | # then the files can be ignored by an additional glob argument via the -g flag. 5 | # For example: 6 | # -g "!src/special_file.rs" 7 | # -g "!src/special_directory" 8 | 9 | # Check all the standard Rust source files 10 | output=$(rg "^// Copyright (19|20)[\d]{2} (.+ and )?the Velato Authors( and .+)?$\n^// SPDX-License-Identifier: Apache-2\.0 OR MIT$\n\n" --files-without-match --multiline -g "*.rs" .) 11 | 12 | if [ -n "$output" ]; then 13 | echo -e "The following files lack the correct copyright header:\n" 14 | echo $output 15 | echo -e "\n\nPlease add the following header:\n" 16 | echo "// Copyright $(date +%Y) the Velato Authors" 17 | echo "// SPDX-License-Identifier: Apache-2.0 OR MIT" 18 | echo -e "\n... rest of the file ...\n" 19 | exit 1 20 | fi 21 | 22 | echo "All files have correct copyright headers." 23 | exit 0 24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/pages-release.yml: -------------------------------------------------------------------------------- 1 | name: Web Demo Update 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release-web: 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | environment: 15 | name: github-pages 16 | url: ${{ steps.deployment.outputs.page_url }} 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Install | Rust 22 | uses: dtolnay/rust-toolchain@stable 23 | with: 24 | targets: wasm32-unknown-unknown 25 | 26 | - name: Install | WASM Bindgen 27 | uses: jetli/wasm-bindgen-action@v0.2.0 28 | with: 29 | version: 'latest' 30 | 31 | - name: Build | WASM 32 | run: cargo build -p with_winit --bin with_winit_bin --release --target wasm32-unknown-unknown 33 | 34 | - name: Package | WASM 35 | run: | 36 | mkdir public 37 | wasm-bindgen --target web --out-dir public target/wasm32-unknown-unknown/release/with_winit_bin.wasm --no-typescript 38 | cat << EOF > public/index.html 39 | 40 | Velato Web Demo 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 54 | 55 | 56 | EOF 57 | 58 | - name: Setup Pages 59 | uses: actions/configure-pages@v4 60 | 61 | - name: Upload artifact 62 | uses: actions/upload-pages-artifact@v3 63 | with: 64 | path: './public' 65 | 66 | - name: Deploy to GitHub Pages 67 | id: deployment 68 | uses: actions/deploy-pages@v4 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Don't commit example downloads 2 | examples/assets/downloads/* 3 | 4 | # Generated by Cargo 5 | # will have compiled files and executables 6 | /target 7 | 8 | # Some people have Apple 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /.taplo.toml: -------------------------------------------------------------------------------- 1 | # See https://taplo.tamasfe.dev/configuration/file.html 2 | # and https://taplo.tamasfe.dev/configuration/formatter-options.html 3 | 4 | [formatting] 5 | # Aligning comments with the largest line creates 6 | # diff noise when neighboring lines are changed. 7 | align_comments = false 8 | 9 | # Matches how rustfmt formats Rust code 10 | column_width = 100 11 | indent_string = " " 12 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | # See the configuration reference at 2 | # https://github.com/crate-ci/typos/blob/master/docs/reference.md 3 | 4 | # Corrections take the form of a key/value pair. The key is the incorrect word 5 | # and the value is the correct word. If the key and value are the same, the 6 | # word is treated as always correct. If the value is an empty string, the word 7 | # is treated as always incorrect. 8 | 9 | # Match Identifier - Case Sensitive 10 | [default.extend-identifiers] 11 | t0_iy = "t0_iy" 12 | tranform_before_mask_deprecated = "tranform_before_mask_deprecated" 13 | 14 | # Match Inside a Word - Case Insensitive 15 | [default.extend-words] 16 | 17 | [files] 18 | # Include .github, .cargo, etc. 19 | ignore-hidden = false 20 | # /.git isn't in .gitignore, because git never tracks it. 21 | # Typos doesn't know that, though. 22 | extend-exclude = ["/.git"] 23 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This is the list of Velato's significant contributors. 2 | # 3 | # This does not necessarily list everyone who has contributed code, 4 | # especially since many employees of one corporation may be contributing. 5 | # To see the full list of contributors, see the revision history in 6 | # source control. 7 | Google LLC 8 | Chad Brokaw 9 | Daniel McNab 10 | Spencer C. Imbleau 11 | Bruce Mitchener 12 | Sebastian Hamel 13 | Kaur Kuut 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 10 | 11 | ## [Unreleased] 12 | 13 | This release has an [MSRV][] of 1.85. 14 | 15 | ## [0.6.0] - 2025-05-26 16 | 17 | This release has an [MSRV][] of 1.85. 18 | 19 | ### Added 20 | 21 | - Keyframes in an animated Spline may now specify an `"e"` key, dictating an end value. When specified, this value is used instead of the next keyframe's start value, allowing for discontinuous animations. ([#60][] by [@RishiChalla][]) 22 | - The last keyframe in an animated Spline may now specify only the timestamp, omitting all other fields. In this scenario, the previous keyframe's end/start values will be used. ([#60][] by [@RishiChalla][]) 23 | 24 | ### Changed 25 | 26 | - Updated to `vello` 0.5 ([#63][] by [@RobertBrewitz][]) 27 | 28 | ## [0.5.0] - 2025-02-02 29 | 30 | This release has an [MSRV][] of 1.82. 31 | 32 | ### Changed 33 | 34 | - Updated to `vello` 0.4 ([#49][] by [@simbleau][]). 35 | 36 | ## [0.4.0] - 2024-11-21 37 | 38 | This release has an [MSRV][] of 1.75. 39 | 40 | ### Changed 41 | 42 | - Updated to `vello` 0.3 43 | - Updated `thiserror` to 2.0 44 | 45 | ## [0.3.1] - 2024-11-11 46 | 47 | This release has an [MSRV][] of 1.75. 48 | 49 | ### Fixed 50 | 51 | - Non-linear easing is now correctly interpolated ([#42] by [@atoktoto]) 52 | 53 | ## [0.3.0] - 2024-07-04 54 | 55 | This release has an [MSRV][] of 1.75. 56 | 57 | ### Added 58 | 59 | - Added `velato::Renderer::render`, which now returns a new vello scene. 60 | 61 | ### Changed 62 | 63 | - Updated to vello 0.2 64 | - Renamed `VelatoError` to `velato::Error` 65 | - Renamed the existing `velato::Renderer::render` to `velato::Renderer::append` 66 | 67 | ### Removed 68 | 69 | - All code and related profiling (`wgpu_profiler`) used in examples. 70 | 71 | ## [0.2.0] - 2024-05-26 72 | 73 | This release has an [MSRV][] of 1.75. 74 | 75 | ### Changed 76 | 77 | - Disable `vello`'s default `wgpu` feature, and provide a `wgpu` passthrough feature to turn it back on. ([#17] by [@MarijnS95]) 78 | 79 | ### Fixed 80 | 81 | - Image viewBox clipping is now applied to the animation ([#16] by [@luke1188]) 82 | - Errors that may occur on parsing a lottie composition are now public as `VelatoError`. ([#19] by [@simbleau]) 83 | 84 | ## [0.1.0] - 2024-03-26 85 | 86 | This release has an [MSRV][] of 1.75. 87 | 88 | - Initial release 89 | 90 | [@luke1188]: https://github.com/luke1188 91 | [@MarijnS95]: https://github.com/MarijnS95 92 | [@simbleau]: https://github.com/simbleau 93 | [@atoktoto]: https://github.com/atoktoto 94 | [@RishiChalla]: https://github.com/RishiChalla 95 | [@RobertBrewitz]: https://github.com/RobertBrewitz 96 | 97 | [#16]: https://github.com/linebender/velato/pull/16 98 | [#17]: https://github.com/linebender/velato/pull/17 99 | [#19]: https://github.com/linebender/velato/pull/19 100 | [#42]: https://github.com/linebender/velato/pull/42 101 | [#49]: https://github.com/linebender/velato/pull/49 102 | [#60]: https://github.com/linebender/velato/pull/60 103 | [#63]: https://github.com/linebender/velato/pull/63 104 | 105 | [Unreleased]: https://github.com/linebender/velato/compare/v0.6.0...HEAD 106 | [0.6.0]: https://github.com/linebender/velato/compare/v0.5.0...v0.6.0 107 | [0.5.0]: https://github.com/linebender/velato/compare/v0.4.0...v0.5.0 108 | [0.4.0]: https://github.com/linebender/velato/compare/v0.3.1...v0.4.0 109 | [0.3.1]: https://github.com/linebender/velato/compare/v0.3.0...v0.3.1 110 | [0.3.0]: https://github.com/linebender/velato/compare/v0.2.0...v0.3.0 111 | [0.2.0]: https://github.com/linebender/velato/compare/v0.1.0...v0.2.0 112 | [0.1.0]: https://github.com/linebender/velato/releases/tag/v0.1.0 113 | 114 | [MSRV]: README.md#minimum-supported-rust-version-msrv 115 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = ["examples/with_winit", "examples/run_wasm", "examples/scenes"] 4 | 5 | [workspace.package] 6 | edition = "2024" 7 | version = "0.6.0" 8 | license = "Apache-2.0 OR MIT" 9 | repository = "https://github.com/linebender/velato" 10 | # Keep in sync with RUST_MIN_VER in .github/workflows/ci.yml, with the relevant README.md files 11 | # and with the MSRV in the `Unreleased` section of CHANGELOG.md. 12 | rust-version = "1.85" 13 | 14 | [package] 15 | name = "velato" 16 | description = "A Lottie integration for vello." 17 | categories = ["rendering", "graphics"] 18 | keywords = ["2d", "vector-graphics", "vello", "animation", "lottie"] 19 | version.workspace = true 20 | rust-version.workspace = true 21 | license.workspace = true 22 | edition.workspace = true 23 | repository.workspace = true 24 | 25 | exclude = [ 26 | ".cargo", 27 | ".clippy.toml", 28 | ".github", 29 | ".gitignore", 30 | ".taplo.toml", 31 | ".typos.toml", 32 | "examples/", 33 | "rustfmt.toml", 34 | ] 35 | 36 | [package.metadata.docs.rs] 37 | all-features = true 38 | # There are no platform specific docs. 39 | default-target = "x86_64-unknown-linux-gnu" 40 | targets = [] 41 | 42 | [workspace.lints] 43 | # LINEBENDER LINT SET - Cargo.toml - v4 44 | # See https://linebender.org/wiki/canonical-lints/ 45 | rust.keyword_idents_2024 = "forbid" 46 | rust.non_ascii_idents = "forbid" 47 | rust.non_local_definitions = "forbid" 48 | rust.unsafe_op_in_unsafe_fn = "forbid" 49 | 50 | rust.elided_lifetimes_in_paths = "warn" 51 | rust.let_underscore_drop = "warn" 52 | rust.missing_debug_implementations = "warn" 53 | rust.missing_docs = "warn" 54 | rust.single_use_lifetimes = "warn" 55 | rust.trivial_numeric_casts = "warn" 56 | rust.unexpected_cfgs = "warn" 57 | rust.unit_bindings = "warn" 58 | rust.unnameable_types = "warn" 59 | rust.unreachable_pub = "warn" 60 | rust.unused_import_braces = "warn" 61 | rust.unused_lifetimes = "warn" 62 | rust.unused_macro_rules = "warn" 63 | rust.unused_qualifications = "warn" 64 | rust.variant_size_differences = "warn" 65 | 66 | clippy.too_many_arguments = "allow" 67 | 68 | clippy.allow_attributes = "warn" 69 | clippy.allow_attributes_without_reason = "warn" 70 | clippy.cast_possible_truncation = "warn" 71 | clippy.collection_is_never_read = "warn" 72 | clippy.dbg_macro = "warn" 73 | clippy.debug_assert_with_mut_call = "warn" 74 | clippy.doc_markdown = "warn" 75 | clippy.fn_to_numeric_cast_any = "warn" 76 | clippy.infinite_loop = "warn" 77 | clippy.large_include_file = "warn" 78 | clippy.large_stack_arrays = "warn" 79 | clippy.match_same_arms = "warn" 80 | clippy.mismatching_type_param_order = "warn" 81 | clippy.missing_assert_message = "warn" 82 | clippy.missing_errors_doc = "warn" 83 | clippy.missing_fields_in_debug = "warn" 84 | clippy.missing_panics_doc = "warn" 85 | clippy.partial_pub_fields = "warn" 86 | clippy.return_self_not_must_use = "warn" 87 | clippy.same_functions_in_if_condition = "warn" 88 | clippy.semicolon_if_nothing_returned = "warn" 89 | clippy.shadow_unrelated = "warn" 90 | clippy.should_panic_without_expect = "warn" 91 | clippy.todo = "warn" 92 | clippy.unseparated_literal_suffix = "warn" 93 | clippy.use_self = "warn" 94 | clippy.wildcard_imports = "warn" 95 | 96 | clippy.cargo_common_metadata = "warn" 97 | clippy.negative_feature_names = "warn" 98 | clippy.redundant_feature_names = "warn" 99 | clippy.wildcard_dependencies = "warn" 100 | # END LINEBENDER LINT SET 101 | 102 | [workspace.dependencies] 103 | # NOTE: Make sure to keep this in sync with the version badge in README.md 104 | vello = { version = "0.5.0", default-features = false } 105 | 106 | [lints] 107 | workspace = true 108 | 109 | [dependencies] 110 | vello = { workspace = true } 111 | keyframe = "1.1.1" 112 | once_cell = "1.21.3" 113 | thiserror = "2.0.12" 114 | 115 | # For the parser 116 | serde = { version = "1.0.219", features = ["derive"] } 117 | serde_json = "1.0.140" 118 | serde_repr = "0.1.20" 119 | 120 | [target.'cfg(target_arch = "wasm32")'.dev-dependencies] 121 | wasm-bindgen-test = "0.3.50" 122 | 123 | [features] 124 | default = [] 125 | wgpu = ["vello/wgpu"] 126 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Velato 4 | 5 | **An integration to parse and render [Lottie](https://lottie.github.io/) with [Vello](https://vello.dev).** 6 | 7 | [![Linebender Zulip](https://img.shields.io/badge/Linebender-%23vello-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/stream/197075-vello) 8 | [![dependency status](https://deps.rs/repo/github/linebender/velato/status.svg)](https://deps.rs/repo/github/linebender/velato) 9 | [![MIT/Apache 2.0](https://img.shields.io/badge/license-MIT%2FApache-blue.svg)](#license) 10 | [![vello version](https://img.shields.io/badge/vello-v0.5.0-purple.svg)](https://crates.io/crates/vello)\ 11 | [![Crates.io](https://img.shields.io/crates/v/velato.svg)](https://crates.io/crates/velato) 12 | [![Docs](https://docs.rs/velato/badge.svg)](https://docs.rs/velato) 13 | [![Build status](https://github.com/linebender/velato/workflows/CI/badge.svg)](https://github.com/linebender/velato/actions) 14 | 15 |
16 | 17 | > [!WARNING] 18 | > The goal of this crate is to provide coverage of the large Lottie spec, up to what vello can render, for use in interactive graphics. We are working towards correctness, but there are missing features listed below. 19 | 20 | ## Version compatibility 21 | 22 | | velato | vello | 23 | | ------ | ----- | 24 | | 0.6 | 0.5 | 25 | | 0.5 | 0.4 | 26 | | 0.4 | 0.3 | 27 | | 0.3 | 0.2 | 28 | | 0.1, 0.2 | 0.1 | 29 | 30 | ## Missing features 31 | 32 | Several Lottie features are not yet supported, including: 33 | 34 | - Position keyframe (`ti`, `to`) easing 35 | - Time remapping (`tm`) 36 | - Text 37 | - Image embedding 38 | - Advanced shapes (stroke dash, zig-zag, etc.) 39 | - Advanced effects (motion blur, drop shadows, etc.) 40 | - Correct color stop handling 41 | - Split rotations 42 | - Split positions 43 | 44 | ## Usage 45 | 46 | Velato makes it simple to encode Lottie as a [`vello::Scene`](https://docs.rs/vello/*/vello/struct.Scene.html). 47 | 48 | ```rust 49 | // Parse your lottie file 50 | let lottie = include_str!("../lottie.json"); 51 | let composition = velato::Composition::from_str(lottie).expect("valid file"); 52 | 53 | // Render to a scene 54 | let mut new_scene = vello::Scene::new(); 55 | 56 | // Render to a scene! 57 | let mut renderer = velato::Renderer::new(); 58 | let frame = 0.0; // Arbitrary number chosen. Ensure it's a valid frame! 59 | let transform = vello::kurbo::Affine::IDENTITY; 60 | let alpha = 1.0; 61 | renderer.render(&composition, frame, transform, alpha, &mut new_scene); 62 | ``` 63 | 64 | ## Examples 65 | 66 | ### Cross platform (Winit) 67 | 68 | ```shell 69 | cargo run -p with_winit 70 | ``` 71 | 72 | You can also load an entire folder or individual files. 73 | 74 | ```shell 75 | cargo run -p with_winit -- examples/assets 76 | ``` 77 | 78 | ### Web platform 79 | 80 | Because Vello relies heavily on compute shaders, we rely on the emerging WebGPU standard to run on the web. 81 | Until browser support becomes widespread, it will probably be necessary to use development browser versions (e.g. Chrome Canary) and explicitly enable WebGPU. 82 | 83 | This uses [`cargo-run-wasm`](https://github.com/rukai/cargo-run-wasm) to build the example for web, and host a local server for it 84 | 85 | ```shell 86 | # Make sure the Rust toolchain supports the wasm32 target 87 | rustup target add wasm32-unknown-unknown 88 | 89 | # The binary name must also be explicitly provided as it differs from the package name 90 | cargo run_wasm -p with_winit --bin with_winit_bin 91 | ``` 92 | 93 | There is also a web demo [available here](https://linebender.github.io/velato) on supporting web browsers. 94 | 95 | > [!WARNING] 96 | > The web is not currently a primary target for Vello, and WebGPU implementations are incomplete, so you might run into issues running this example. 97 | 98 | ## Minimum supported Rust Version (MSRV) 99 | 100 | This version of Velato has been verified to compile with **Rust 1.85** and later. 101 | 102 | Future versions of Velato might increase the Rust version requirement. 103 | It will not be treated as a breaking change and as such can even happen with small patch releases. 104 | 105 |
106 | Click here if compiling fails. 107 | 108 | As time has passed, some of Velato's dependencies could have released versions with a higher Rust requirement. 109 | If you encounter a compilation issue due to a dependency and don't want to upgrade your Rust toolchain, then you could downgrade the dependency. 110 | 111 | ```sh 112 | # Use the problematic dependency's name and version 113 | cargo update -p package_name --precise 0.1.1 114 | ``` 115 | 116 |
117 | 118 | ## Community 119 | 120 | Discussion of Velato development happens in the [Linebender Zulip](https://xi.zulipchat.com/), specifically the [#vello channel](https://xi.zulipchat.com/#narrow/channel/197075-vello). All public content can be read without logging in. 121 | 122 | Contributions are welcome by pull request. The [Rust code of conduct](https://www.rust-lang.org/policies/code-of-conduct) applies. 123 | 124 | ## License 125 | 126 | Licensed under either of 127 | 128 | - Apache License, Version 2.0 129 | ([LICENSE-APACHE](LICENSE-APACHE) or ) 130 | - MIT license 131 | ([LICENSE-MIT](LICENSE-MIT) or ) 132 | 133 | at your option. 134 | 135 | The files in subdirectories of the [`examples/assets`](/examples/assets) directory are licensed solely under 136 | their respective licenses, available in the `LICENSE` file in their directories. 137 | 138 | ## Contribution 139 | 140 | Unless you explicitly state otherwise, any contribution intentionally submitted 141 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall be 142 | dual licensed as above, without any additional terms or conditions. 143 | -------------------------------------------------------------------------------- /examples/assets/downloads/.tracked: -------------------------------------------------------------------------------- 1 | This directory is used to store the downloaded scenes by default 2 | -------------------------------------------------------------------------------- /examples/assets/google_fonts/README.md: -------------------------------------------------------------------------------- 1 | Assets in this folder originate from Google Fonts. 2 | The assets are part of a collection of Animated Emoji for Google Noto. 3 | 4 | See: https://googlefonts.github.io/noto-emoji-animation/ 5 | -------------------------------------------------------------------------------- /examples/assets/roboto/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linebender/velato/6ede3f374c660ba1301096c0b53fa7e365c3ef97/examples/assets/roboto/Roboto-Regular.ttf -------------------------------------------------------------------------------- /examples/run_wasm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "run_wasm" 3 | edition.workspace = true 4 | license.workspace = true 5 | repository.workspace = true 6 | publish = false 7 | 8 | [lints] 9 | workspace = true 10 | 11 | [dependencies] 12 | cargo-run-wasm = "0.4.0" 13 | -------------------------------------------------------------------------------- /examples/run_wasm/src/main.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | //! Use [cargo-run-wasm](https://github.com/rukai/cargo-run-wasm) to build an example for web 5 | //! 6 | //! Usage: 7 | //! ``` 8 | //! cargo run_wasm --package [example_name] 9 | //! ``` 10 | //! Generally: 11 | //! ``` 12 | //! cargo run_wasm -p with_winit 13 | //! ``` 14 | 15 | fn main() { 16 | cargo_run_wasm::run_wasm_cli_with_css("body { margin: 0px; }"); 17 | } 18 | -------------------------------------------------------------------------------- /examples/scenes/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "scenes" 3 | description = "Velato scenes used in the other examples." 4 | edition.workspace = true 5 | license.workspace = true 6 | repository.workspace = true 7 | publish = false 8 | 9 | [lints] 10 | workspace = true 11 | 12 | [dependencies] 13 | vello = { workspace = true } 14 | velato = { path = "../.." } 15 | anyhow = "1" 16 | clap = { version = "4.5.38", features = ["derive"] } 17 | image = "0.24.9" 18 | rand = "0.8.5" 19 | skrifa = "0.26.6" 20 | instant = "0.1" 21 | bytemuck = { version = "1.23.0", features = ["derive"] } 22 | 23 | # Used for the `download` command 24 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 25 | byte-unit = "4.0.19" 26 | inquire = "0.7" 27 | ureq = "2.12.1" 28 | 29 | [target.wasm32-unknown-unknown.dependencies] 30 | # We have a transitive dependency on getrandom and it does not automatically 31 | # support wasm32-unknown-unknown. We need to enable the js feature. 32 | getrandom = { version = "0.2.16", features = ["js"] } 33 | -------------------------------------------------------------------------------- /examples/scenes/src/download.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | use std::io::Seek; 5 | use std::path::{Path, PathBuf}; 6 | 7 | use anyhow::{Context, Result, bail}; 8 | use byte_unit::Byte; 9 | use clap::Args; 10 | use inquire::Confirm; 11 | use std::io::Read; 12 | mod default_downloads; 13 | 14 | #[allow(clippy::partial_pub_fields)] 15 | #[derive(Args, Debug)] 16 | pub(crate) struct Download { 17 | #[clap(long)] 18 | /// Directory to download the files into 19 | #[clap(default_value_os_t = default_directory())] 20 | pub directory: PathBuf, 21 | /// Set of files to download. Use `name@url` format to specify a file prefix 22 | downloads: Option>, 23 | /// Whether to automatically install the default set of files 24 | #[clap(long)] 25 | auto: bool, 26 | /// The size limit for each individual file (ignored if the default files are downloaded) 27 | #[clap(long, default_value = "10 MB")] 28 | size_limit: Byte, 29 | } 30 | 31 | fn default_directory() -> PathBuf { 32 | let mut result = Path::new(env!("CARGO_MANIFEST_DIR")) 33 | .parent() 34 | .unwrap() 35 | .join("assets"); 36 | result.push("downloads"); 37 | result 38 | } 39 | 40 | impl Download { 41 | pub fn action(&self) -> Result<()> { 42 | let mut to_download = vec![]; 43 | if let Some(downloads) = &self.downloads { 44 | to_download = downloads 45 | .iter() 46 | .map(|it| Self::parse_download(it)) 47 | .collect(); 48 | } else { 49 | let mut accepted = self.auto; 50 | let downloads = default_downloads::default_downloads() 51 | .into_iter() 52 | .filter(|it| { 53 | let file = it.file_path(&self.directory); 54 | !file.exists() 55 | }) 56 | .collect::>(); 57 | if !accepted { 58 | if !downloads.is_empty() { 59 | println!( 60 | "Would you like to download a set of default lottie files? These files are:" 61 | ); 62 | let mut total_bytes = 0; 63 | for download in &downloads { 64 | let builtin = download.builtin.as_ref().unwrap(); 65 | println!( 66 | "{} ({}) under license {} from {}", 67 | download.name, 68 | Byte::from_bytes(builtin.expected_size.into()) 69 | .get_appropriate_unit(false), 70 | builtin.license, 71 | builtin.info 72 | ); 73 | total_bytes += builtin.expected_size; 74 | } 75 | 76 | // For rustfmt, split prompt into its own line 77 | accepted = download_prompt(total_bytes)?; 78 | } else { 79 | println!("Nothing to download! All default downloads already created"); 80 | } 81 | } 82 | if accepted { 83 | to_download = downloads; 84 | } 85 | } 86 | let mut completed_count = 0; 87 | let mut failed_count = 0; 88 | for (index, download) in to_download.iter().enumerate() { 89 | println!( 90 | "{index}: Downloading {} from {}", 91 | download.name, download.url 92 | ); 93 | match download.fetch(&self.directory, self.size_limit) { 94 | Ok(()) => completed_count += 1, 95 | Err(e) => { 96 | failed_count += 1; 97 | eprintln!("Download failed with error: {e}"); 98 | let cont = if self.auto { 99 | false 100 | } else { 101 | Confirm::new("Would you like to try other downloads?") 102 | .with_default(false) 103 | .prompt()? 104 | }; 105 | if !cont { 106 | println!("{} downloads complete", completed_count); 107 | if failed_count > 0 { 108 | println!("{} downloads failed", failed_count); 109 | } 110 | let remaining = to_download.len() - (completed_count + failed_count); 111 | if remaining > 0 { 112 | println!("{} downloads skipped", remaining); 113 | } 114 | return Err(e); 115 | } 116 | } 117 | } 118 | } 119 | println!("{} downloads complete", completed_count); 120 | if failed_count > 0 { 121 | println!("{} downloads failed", failed_count); 122 | } 123 | debug_assert!(completed_count + failed_count == to_download.len()); 124 | Ok(()) 125 | } 126 | 127 | fn parse_download(value: &str) -> LottieDownload { 128 | if let Some(at_index) = value.find('@') { 129 | let name = &value[0..at_index]; 130 | let url = &value[at_index + 1..]; 131 | LottieDownload { 132 | name: name.to_string(), 133 | url: url.to_string(), 134 | builtin: None, 135 | } 136 | } else { 137 | let end_index = value.rfind(".json").unwrap_or(value.len()); 138 | let url_with_name = &value[0..end_index]; 139 | let name = url_with_name 140 | .rfind('/') 141 | .map(|v| &url_with_name[v + 1..]) 142 | .unwrap_or(url_with_name); 143 | LottieDownload { 144 | name: name.to_string(), 145 | url: value.to_string(), 146 | builtin: None, 147 | } 148 | } 149 | } 150 | } 151 | 152 | fn download_prompt(total_bytes: u64) -> Result { 153 | let prompt = format!( 154 | "Would you like to download a set of default lottie files, as explained above? ({})", 155 | Byte::from_bytes(total_bytes.into()).get_appropriate_unit(false) 156 | ); 157 | let accepted = Confirm::new(&prompt).with_default(false).prompt()?; 158 | Ok(accepted) 159 | } 160 | 161 | struct LottieDownload { 162 | name: String, 163 | url: String, 164 | builtin: Option, 165 | } 166 | 167 | impl LottieDownload { 168 | fn file_path(&self, directory: &Path) -> PathBuf { 169 | directory.join(&self.name).with_extension("json") 170 | } 171 | 172 | fn fetch(&self, directory: &Path, size_limit: Byte) -> Result<()> { 173 | let mut size_limit = size_limit.get_bytes().try_into()?; 174 | let mut limit_exact = false; 175 | if let Some(builtin) = &self.builtin { 176 | size_limit = builtin.expected_size; 177 | limit_exact = true; 178 | } 179 | // If we're expecting an exact version of the file, it's worth not fetching 180 | // the file if we know it will fail 181 | if limit_exact { 182 | let head_response = ureq::head(&self.url).call()?; 183 | let content_length = head_response.header("content-length"); 184 | if let Some(Ok(content_length)) = content_length.map(|it| it.parse::()) { 185 | if content_length != size_limit { 186 | bail!( 187 | "Size is not as expected for download. Expected {}, server reported {}", 188 | Byte::from_bytes(size_limit.into()).get_appropriate_unit(true), 189 | Byte::from_bytes(content_length.into()).get_appropriate_unit(true) 190 | ) 191 | } 192 | } 193 | } 194 | let mut file = std::fs::OpenOptions::new() 195 | .create_new(true) 196 | .write(true) 197 | .open(self.file_path(directory)) 198 | .context("Creating file")?; 199 | let mut reader = ureq::get(&self.url).call()?.into_reader(); 200 | 201 | std::io::copy( 202 | // ureq::into_string() has a limit of 10MiB so we must use the reader 203 | &mut (&mut reader).take(size_limit), 204 | &mut file, 205 | )?; 206 | if reader.read_exact(&mut [0]).is_ok() { 207 | bail!("Size limit exceeded"); 208 | } 209 | if limit_exact { 210 | let bytes_downloaded = file.stream_position().context("Checking file limit")?; 211 | if bytes_downloaded != size_limit { 212 | bail!( 213 | "Builtin downloaded file was not as expected. Expected {size_limit}, received {bytes_downloaded}.", 214 | ); 215 | } 216 | } 217 | Ok(()) 218 | } 219 | } 220 | 221 | struct BuiltinLottieProps { 222 | expected_size: u64, 223 | license: &'static str, 224 | info: &'static str, 225 | } 226 | -------------------------------------------------------------------------------- /examples/scenes/src/lib.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | //! Scenes 5 | 6 | // The following lints are part of the Linebender standard set, 7 | // but resolving them has been deferred for now. 8 | // Feel free to send a PR that solves one or more of these. 9 | #![allow( 10 | missing_debug_implementations, 11 | unreachable_pub, 12 | missing_docs, 13 | unused_macro_rules, 14 | clippy::shadow_unrelated, 15 | clippy::use_self, 16 | clippy::missing_assert_message, 17 | clippy::missing_errors_doc, 18 | clippy::missing_panics_doc, 19 | clippy::allow_attributes, 20 | clippy::allow_attributes_without_reason, 21 | clippy::wildcard_imports 22 | )] 23 | 24 | #[cfg(not(target_arch = "wasm32"))] 25 | pub mod download; 26 | mod lottie; 27 | mod simple_text; 28 | mod test_scenes; 29 | use std::path::PathBuf; 30 | 31 | use anyhow::Result; 32 | use clap::{Args, Subcommand}; 33 | #[cfg(not(target_arch = "wasm32"))] 34 | use download::Download; 35 | #[cfg(not(target_arch = "wasm32"))] 36 | pub use lottie::{default_scene, scene_from_files}; 37 | pub use simple_text::RobotoText; 38 | pub use test_scenes::test_scenes; 39 | 40 | use vello::Scene; 41 | use vello::kurbo::Vec2; 42 | use vello::peniko::{Color, color}; 43 | 44 | pub struct SceneParams<'a> { 45 | pub time: f64, 46 | /// Whether blocking should be limited 47 | /// Will not change between runs 48 | // TODO: Just never block/handle this automatically? 49 | pub interactive: bool, 50 | pub text: &'a mut RobotoText, 51 | pub resolution: Option, 52 | pub base_color: Option, 53 | pub complexity: usize, 54 | } 55 | 56 | pub struct SceneConfig { 57 | // TODO: This is currently unused 58 | pub animated: bool, 59 | pub name: String, 60 | } 61 | 62 | pub struct ExampleScene { 63 | pub function: Box, 64 | pub config: SceneConfig, 65 | } 66 | 67 | pub trait TestScene { 68 | fn render(&mut self, scene: &mut Scene, params: &mut SceneParams<'_>); 69 | } 70 | 71 | impl)> TestScene for F { 72 | fn render(&mut self, scene: &mut Scene, params: &mut SceneParams<'_>) { 73 | self(scene, params); 74 | } 75 | } 76 | 77 | pub struct SceneSet { 78 | pub scenes: Vec, 79 | } 80 | 81 | #[allow(clippy::partial_pub_fields)] 82 | #[derive(Args, Debug)] 83 | /// Shared config for scene selection 84 | pub struct Arguments { 85 | #[arg(help_heading = "Scene Selection")] 86 | #[arg(long, global(false))] 87 | /// Whether to use the test scenes created by code 88 | test_scenes: bool, 89 | #[arg(help_heading = "Scene Selection", global(false))] 90 | /// The lottie files paths to render 91 | lotties: Option>, 92 | #[arg(help_heading = "Render Parameters")] 93 | #[arg(long, global(false), value_parser = parse_color)] 94 | /// The base color applied as the blend background to the rasterizer. 95 | /// Format is CSS style hexadecimal (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) or 96 | /// an SVG color name such as "aliceblue" 97 | pub base_color: Option, 98 | #[clap(subcommand)] 99 | command: Option, 100 | } 101 | 102 | #[derive(Subcommand, Debug)] 103 | enum Command { 104 | /// Download Lottie files for testing. By default, downloads a set of files from wikipedia 105 | #[cfg(not(target_arch = "wasm32"))] 106 | Download(Download), 107 | } 108 | 109 | impl Arguments { 110 | pub fn select_scene_set( 111 | &self, 112 | #[allow(unused)] command: impl FnOnce() -> clap::Command, 113 | ) -> Result> { 114 | if let Some(command) = &self.command { 115 | command.action()?; 116 | Ok(None) 117 | } else { 118 | // There is no file access on WASM, and on Android we haven't set up the assets 119 | // directory. 120 | // TODO: Upload the assets directory on Android 121 | // Therefore, only render the `test_scenes` (including one Lottie example) 122 | #[cfg(any(target_arch = "wasm32", target_os = "android"))] 123 | return Ok(Some(test_scenes())); 124 | #[cfg(not(any(target_arch = "wasm32", target_os = "android")))] 125 | if self.test_scenes { 126 | Ok(test_scenes()) 127 | } else if let Some(lotties) = &self.lotties { 128 | scene_from_files(lotties) 129 | } else { 130 | default_scene(command) 131 | } 132 | .map(Some) 133 | } 134 | } 135 | } 136 | 137 | impl Command { 138 | fn action(&self) -> Result<()> { 139 | match self { 140 | #[cfg(not(target_arch = "wasm32"))] 141 | Command::Download(download) => download.action(), 142 | #[cfg(target_arch = "wasm32")] 143 | _ => unreachable!("downloads not supported on wasm"), 144 | } 145 | } 146 | } 147 | 148 | fn parse_color(s: &str) -> Result { 149 | color::parse_color(s).map(|c| c.to_alpha_color()) 150 | } 151 | -------------------------------------------------------------------------------- /examples/scenes/src/lottie.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | use crate::SceneParams; 5 | #[cfg(not(target_arch = "wasm32"))] 6 | use crate::{ExampleScene, SceneSet}; 7 | #[cfg(not(target_arch = "wasm32"))] 8 | use anyhow::{Ok, Result}; 9 | use instant::Instant; 10 | use std::sync::Arc; 11 | #[cfg(not(target_arch = "wasm32"))] 12 | use std::{ 13 | fs::read_dir, 14 | path::{Path, PathBuf}, 15 | }; 16 | use velato::Composition; 17 | use vello::Scene; 18 | use vello::kurbo::{Affine, Vec2}; 19 | 20 | #[cfg(not(target_arch = "wasm32"))] 21 | pub fn scene_from_files(files: &[PathBuf]) -> Result { 22 | scene_from_files_inner(files, || ()) 23 | } 24 | 25 | #[cfg(not(target_arch = "wasm32"))] 26 | pub fn default_scene(command: impl FnOnce() -> clap::Command) -> Result { 27 | let assets_dir = Path::new(env!("CARGO_MANIFEST_DIR")) 28 | .join("../assets/") 29 | .canonicalize()?; 30 | let mut has_empty_directory = false; 31 | let result = scene_from_files_inner(&[assets_dir.join("google_fonts/Tiger.json")], || { 32 | has_empty_directory = true; 33 | })?; 34 | if has_empty_directory { 35 | let mut command = command(); 36 | command.build(); 37 | panic!("No test files are available."); 38 | } 39 | Ok(result) 40 | } 41 | 42 | #[cfg(not(target_arch = "wasm32"))] 43 | fn scene_from_files_inner( 44 | files: &[PathBuf], 45 | mut empty_dir: impl FnMut(), 46 | ) -> std::result::Result { 47 | let mut scenes = Vec::new(); 48 | for path in files { 49 | if path.is_dir() { 50 | let mut count = 0; 51 | let start_index = scenes.len(); 52 | for file in read_dir(path)? { 53 | let entry = file?; 54 | if let Some(extension) = Path::new(&entry.file_name()).extension() { 55 | if extension == "json" { 56 | count += 1; 57 | scenes.push(example_scene_of(entry.path())); 58 | } 59 | } 60 | } 61 | // Ensure a consistent order within directories 62 | scenes[start_index..].sort_by_key(|scene| scene.config.name.to_lowercase()); 63 | if count == 0 { 64 | empty_dir(); 65 | } 66 | } else { 67 | scenes.push(example_scene_of(path.to_owned())); 68 | } 69 | } 70 | Ok(SceneSet { scenes }) 71 | } 72 | 73 | #[cfg(not(target_arch = "wasm32"))] 74 | fn example_scene_of(file: PathBuf) -> ExampleScene { 75 | let name = file 76 | .file_stem() 77 | .map(|it| it.to_string_lossy().to_string()) 78 | .unwrap_or_else(|| "unknown".to_string()); 79 | ExampleScene { 80 | function: Box::new(lottie_function_of(name.clone(), move || { 81 | std::fs::read_to_string(&file) 82 | .unwrap_or_else(|e| panic!("failed to read lottie file {file:?}: {e}")) 83 | })), 84 | config: crate::SceneConfig { 85 | animated: true, 86 | name, 87 | }, 88 | } 89 | } 90 | 91 | pub fn lottie_function_of>( 92 | name: String, 93 | contents: impl FnOnce() -> R + Send + 'static, 94 | ) -> impl FnMut(&mut Scene, &mut SceneParams<'_>) { 95 | let start = Instant::now(); 96 | let lottie = Arc::new( 97 | Composition::from_slice(contents().as_ref()) 98 | .unwrap_or_else(|e| panic!("failed to parse lottie file {name}: {e}")), 99 | ); 100 | eprintln!("Parsed lottie {name} in {:?}", start.elapsed()); 101 | fn render_lottie_contents( 102 | renderer: &mut velato::Renderer, 103 | lottie: &Composition, 104 | start: Instant, 105 | ) -> Scene { 106 | let frame = ((start.elapsed().as_secs_f64() * lottie.frame_rate) 107 | % (lottie.frames.end - lottie.frames.start)) 108 | + lottie.frames.start; 109 | renderer.render(lottie, frame, Affine::IDENTITY, 1.0) 110 | } 111 | let started = Instant::now(); 112 | let mut renderer = velato::Renderer::new(); 113 | let lottie = lottie.clone(); 114 | let resolution = Vec2::new(lottie.width as f64, lottie.height as f64); 115 | move |scene, params| { 116 | params.resolution = Some(resolution); 117 | *scene = render_lottie_contents(&mut renderer, &lottie, started); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /examples/scenes/src/simple_text.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | use skrifa::MetadataProvider; 5 | use skrifa::raw::FontRef; 6 | use std::sync::Arc; 7 | use vello::kurbo::Affine; 8 | use vello::peniko::{Blob, Brush, BrushRef, Font, StyleRef}; 9 | use vello::{Glyph, Scene}; 10 | 11 | // This is very much a hack to get things working. 12 | // On Windows, can set this to "c:\\Windows\\Fonts\\seguiemj.ttf" to get color 13 | // emoji 14 | const ROBOTO_FONT: &[u8] = include_bytes!("../../assets/roboto/Roboto-Regular.ttf"); 15 | pub struct RobotoText { 16 | font: Font, 17 | } 18 | 19 | impl RobotoText { 20 | #[allow(clippy::new_without_default)] 21 | pub fn new() -> Self { 22 | Self { 23 | font: Font::new(Blob::new(Arc::new(ROBOTO_FONT)), 0), 24 | } 25 | } 26 | 27 | #[allow(clippy::too_many_arguments)] 28 | pub fn add_run<'a>( 29 | &mut self, 30 | scene: &mut Scene, 31 | font: Option<&Font>, 32 | size: f32, 33 | brush: impl Into>, 34 | transform: Affine, 35 | glyph_transform: Option, 36 | style: impl Into>, 37 | text: &str, 38 | ) { 39 | self.add_var_run( 40 | scene, 41 | font, 42 | size, 43 | &[], 44 | brush, 45 | transform, 46 | glyph_transform, 47 | style, 48 | text, 49 | ); 50 | } 51 | 52 | #[allow(clippy::too_many_arguments)] 53 | pub fn add_var_run<'a>( 54 | &mut self, 55 | scene: &mut Scene, 56 | font: Option<&Font>, 57 | size: f32, 58 | variations: &[(&str, f32)], 59 | brush: impl Into>, 60 | transform: Affine, 61 | glyph_transform: Option, 62 | style: impl Into>, 63 | text: &str, 64 | ) { 65 | let default_font = &self.font; 66 | let font = font.unwrap_or(default_font); 67 | let font_ref = to_font_ref(font).unwrap(); 68 | let brush = brush.into(); 69 | let style = style.into(); 70 | let axes = font_ref.axes(); 71 | let font_size = skrifa::instance::Size::new(size); 72 | let var_loc = axes.location(variations.iter().copied()); 73 | let charmap = font_ref.charmap(); 74 | let metrics = font_ref.metrics(font_size, &var_loc); 75 | let line_height = metrics.ascent - metrics.descent + metrics.leading; 76 | let glyph_metrics = font_ref.glyph_metrics(font_size, &var_loc); 77 | let mut pen_x = 0_f32; 78 | let mut pen_y = 0_f32; 79 | scene 80 | .draw_glyphs(font) 81 | .font_size(size) 82 | .transform(transform) 83 | .glyph_transform(glyph_transform) 84 | .normalized_coords(bytemuck::cast_slice(var_loc.coords())) 85 | .brush(brush) 86 | .draw( 87 | style, 88 | text.chars().filter_map(|ch| { 89 | if ch == '\n' { 90 | pen_y += line_height; 91 | pen_x = 0.0; 92 | return None; 93 | } 94 | let gid = charmap.map(ch).unwrap_or_default(); 95 | let advance = glyph_metrics.advance_width(gid).unwrap_or_default(); 96 | let x = pen_x; 97 | pen_x += advance; 98 | Some(Glyph { 99 | id: gid.to_u32(), 100 | x, 101 | y: pen_y, 102 | }) 103 | }), 104 | ); 105 | } 106 | 107 | pub fn add( 108 | &mut self, 109 | scene: &mut Scene, 110 | font: Option<&Font>, 111 | size: f32, 112 | brush: Option<&Brush>, 113 | transform: Affine, 114 | text: &str, 115 | ) { 116 | use vello::peniko::{Color, Fill}; 117 | let brush = brush.unwrap_or(&Brush::Solid(Color::WHITE)); 118 | self.add_run( 119 | scene, 120 | font, 121 | size, 122 | brush, 123 | transform, 124 | None, 125 | Fill::NonZero, 126 | text, 127 | ); 128 | } 129 | } 130 | 131 | fn to_font_ref(font: &Font) -> Option> { 132 | use skrifa::raw::FileRef; 133 | let file_ref = FileRef::new(font.data.as_ref()).ok()?; 134 | match file_ref { 135 | FileRef::Font(font) => Some(font), 136 | FileRef::Collection(collection) => collection.get(font.index).ok(), 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /examples/scenes/src/test_scenes.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | use crate::{ExampleScene, SceneConfig, SceneParams, SceneSet}; 5 | use vello::kurbo::Affine; 6 | use vello::*; 7 | 8 | macro_rules! scene { 9 | ($name: ident) => { 10 | scene!($name: false) 11 | }; 12 | ($name: ident: animated) => { 13 | scene!($name: true) 14 | }; 15 | ($name: ident: $animated: literal) => { 16 | scene!($name, stringify!($name), $animated) 17 | }; 18 | ($func:expr, $name: expr, $animated: literal) => { 19 | ExampleScene { 20 | config: SceneConfig { 21 | animated: $animated, 22 | name: $name.to_owned(), 23 | }, 24 | function: Box::new($func), 25 | } 26 | }; 27 | } 28 | 29 | pub fn test_scenes() -> SceneSet { 30 | let scenes = vec![scene!(splash_with_tiger(), "Tiger", true)]; 31 | SceneSet { scenes } 32 | } 33 | 34 | // Scenes 35 | fn splash_screen(scene: &mut Scene, params: &mut SceneParams<'_>) { 36 | let strings = [ 37 | "Velato Demo", 38 | #[cfg(not(target_arch = "wasm32"))] 39 | " Arrow keys: switch scenes", 40 | " Space: reset transform", 41 | " S: toggle stats", 42 | " V: toggle vsync", 43 | " M: cycle AA method", 44 | " Q, E: rotate", 45 | ]; 46 | // Tweak to make it fit with tiger 47 | let a = Affine::scale(1.) * Affine::translate((-90.0, -50.0)); 48 | for (i, s) in strings.iter().enumerate() { 49 | let text_size = if i == 0 { 60.0 } else { 40.0 }; 50 | params.text.add( 51 | scene, 52 | None, 53 | text_size, 54 | None, 55 | a * Affine::translate((100.0, 100.0 + 60.0 * i as f64)), 56 | s, 57 | ); 58 | } 59 | } 60 | 61 | fn splash_with_tiger() -> impl FnMut(&mut Scene, &mut SceneParams<'_>) { 62 | let contents = include_str!(concat!( 63 | env!("CARGO_MANIFEST_DIR"), 64 | "/../assets/google_fonts/Tiger.json" 65 | )); 66 | let mut lottie = crate::lottie::lottie_function_of("Tiger".to_string(), move || contents); 67 | move |scene, params| { 68 | lottie(scene, params); 69 | splash_screen(scene, params); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /examples/with_winit/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "with_winit" 3 | version = "0.0.0" 4 | description = "An example using Vello with Velato to render to a winit window" 5 | edition.workspace = true 6 | license.workspace = true 7 | repository.workspace = true 8 | publish = false 9 | 10 | [lib] 11 | name = "with_winit" 12 | crate-type = ["cdylib", "lib"] 13 | 14 | [[bin]] 15 | # Stop the PDB collision warning on windows 16 | name = "with_winit_bin" 17 | path = "src/main.rs" 18 | 19 | [lints] 20 | workspace = true 21 | 22 | [dependencies] 23 | vello = { workspace = true, features = ["wgpu"] } 24 | scenes = { path = "../scenes" } 25 | anyhow = "1" 26 | clap = { version = "4.5.38", features = ["derive"] } 27 | instant = { version = "0.1.13", features = ["wasm-bindgen"] } 28 | pollster = "0.3" 29 | winit = "0.29.15" 30 | env_logger = "0.11.8" 31 | log = "0.4.27" 32 | 33 | [target.'cfg(not(any(target_arch = "wasm32", target_os = "android")))'.dependencies] 34 | vello = { workspace = true, features = ["hot_reload", "wgpu"] } 35 | notify-debouncer-mini = "0.3" 36 | 37 | [target.'cfg(target_os = "android")'.dependencies] 38 | winit = { version = "0.29.15", features = ["android-native-activity"] } 39 | android_logger = "0.13.3" 40 | 41 | [target.'cfg(target_arch = "wasm32")'.dependencies] 42 | console_error_panic_hook = "0.1.7" 43 | console_log = "1.0.0" 44 | wasm-bindgen-futures = "0.4.50" 45 | web-sys = { version = "0.3.77", features = ["HtmlCollection", "Text"] } 46 | getrandom = { version = "0.2.16", features = ["js"] } 47 | -------------------------------------------------------------------------------- /examples/with_winit/README.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | Running the viewer without any arguments will render a built-in set of Lottie images: 4 | 5 | ```shell 6 | cargo run -p with_winit --release 7 | ``` 8 | 9 | Optionally, you can pass in paths to Lottie files that you want to render: 10 | 11 | ```shell 12 | cargo run -p with_winit --release -- [LOTTIE FILES] 13 | ``` 14 | 15 | ## Controls 16 | 17 | - Mouse drag-and-drop will translate the image. 18 | - Mouse scroll wheel will zoom. 19 | - Arrow keys switch between Lottie images in the current set. 20 | - Space resets the position and zoom of the image. 21 | - S toggles the frame statistics layer 22 | - C resets the min/max frame time tracked by statistics 23 | - D toggles displaying the required number of each kind of dynamically allocated element (default: off) 24 | - V toggles VSync on/off (default: on) 25 | - Escape exits the program. 26 | -------------------------------------------------------------------------------- /examples/with_winit/src/hot_reload.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | use std::path::Path; 5 | use std::time::Duration; 6 | 7 | use anyhow::Result; 8 | use notify_debouncer_mini::notify::*; 9 | use notify_debouncer_mini::{DebounceEventResult, new_debouncer}; 10 | 11 | pub(crate) fn hot_reload(mut f: impl FnMut() -> Option<()> + Send + 'static) -> Result { 12 | let mut debouncer = new_debouncer( 13 | Duration::from_millis(500), 14 | None, 15 | move |res: DebounceEventResult| match res { 16 | Ok(_) => f().unwrap(), 17 | Err(errors) => errors.iter().for_each(|e| println!("Error {:?}", e)), 18 | }, 19 | )?; 20 | 21 | debouncer.watcher().watch( 22 | &Path::new(env!("CARGO_MANIFEST_DIR")) 23 | .join("../../shader") 24 | .canonicalize()?, 25 | // We currently don't support hot reloading the imports, so don't 26 | // recurse into there 27 | RecursiveMode::NonRecursive, 28 | )?; 29 | Ok(debouncer) 30 | } 31 | -------------------------------------------------------------------------------- /examples/with_winit/src/main.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2022 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | //! With Winit 5 | 6 | use anyhow::Result; 7 | 8 | fn main() -> Result<()> { 9 | with_winit::main() 10 | } 11 | -------------------------------------------------------------------------------- /examples/with_winit/src/stats.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | use scenes::RobotoText; 5 | use std::collections::VecDeque; 6 | use vello::kurbo::{Affine, PathEl, Rect, Stroke}; 7 | use vello::low_level::BumpAllocators; 8 | use vello::peniko::{Brush, Color, Fill}; 9 | use vello::{AaConfig, Scene}; 10 | 11 | const SLIDING_WINDOW_SIZE: usize = 100; 12 | 13 | #[derive(Debug)] 14 | pub(crate) struct Snapshot { 15 | pub fps: f64, 16 | pub frame_time_ms: f64, 17 | pub frame_time_min_ms: f64, 18 | pub frame_time_max_ms: f64, 19 | } 20 | 21 | impl Snapshot { 22 | #[allow(clippy::too_many_arguments)] 23 | pub(crate) fn draw_layer<'a, T>( 24 | &self, 25 | scene: &mut Scene, 26 | text: &mut RobotoText, 27 | viewport_width: f64, 28 | viewport_height: f64, 29 | samples: T, 30 | bump: Option, 31 | vsync: bool, 32 | aa_config: AaConfig, 33 | ) where 34 | T: Iterator, 35 | { 36 | let width = (viewport_width * 0.4).clamp(200., 600.); 37 | let height = width * 0.7; 38 | let x_offset = viewport_width - width; 39 | let y_offset = viewport_height - height; 40 | let offset = Affine::translate((x_offset, y_offset)); 41 | 42 | // Draw the background 43 | scene.fill( 44 | Fill::NonZero, 45 | offset, 46 | &Brush::Solid(Color::from_rgba8(0, 0, 0, 200)), 47 | None, 48 | &Rect::new(0., 0., width, height), 49 | ); 50 | 51 | let mut labels = vec![ 52 | format!("Frame Time: {:.2} ms", self.frame_time_ms), 53 | format!("Frame Time (min): {:.2} ms", self.frame_time_min_ms), 54 | format!("Frame Time (max): {:.2} ms", self.frame_time_max_ms), 55 | format!("VSync: {}", if vsync { "on" } else { "off" }), 56 | format!( 57 | "AA method: {}", 58 | match aa_config { 59 | AaConfig::Area => "Analytic Area", 60 | AaConfig::Msaa16 => "16xMSAA", 61 | AaConfig::Msaa8 => "8xMSAA", 62 | } 63 | ), 64 | format!("Resolution: {viewport_width}x{viewport_height}"), 65 | ]; 66 | if let Some(bump) = &bump { 67 | if bump.failed >= 1 { 68 | labels.push("Allocation Failed!".into()); 69 | } 70 | labels.push(format!("binning: {}", bump.binning)); 71 | labels.push(format!("ptcl: {}", bump.ptcl)); 72 | labels.push(format!("tile: {}", bump.tile)); 73 | labels.push(format!("segments: {}", bump.segments)); 74 | labels.push(format!("blend: {}", bump.blend)); 75 | } 76 | 77 | // height / 2 is dedicated to the text labels and the rest is filled by the bar graph. 78 | let text_height = height * 0.5 / (1 + labels.len()) as f64; 79 | let left_margin = width * 0.01; 80 | let text_size = (text_height * 0.9) as f32; 81 | for (i, label) in labels.iter().enumerate() { 82 | text.add( 83 | scene, 84 | None, 85 | text_size, 86 | Some(&Brush::Solid(Color::WHITE)), 87 | offset * Affine::translate((left_margin, (i + 1) as f64 * text_height)), 88 | label, 89 | ); 90 | } 91 | text.add( 92 | scene, 93 | None, 94 | text_size, 95 | Some(&Brush::Solid(Color::WHITE)), 96 | offset * Affine::translate((width * 0.67, text_height)), 97 | &format!("FPS: {:.2}", self.fps), 98 | ); 99 | 100 | // Plot the samples with a bar graph 101 | use PathEl::*; 102 | let left_padding = width * 0.05; // Left padding for the frame time marker text. 103 | let graph_max_height = height * 0.5; 104 | let graph_max_width = width - 2. * left_margin - left_padding; 105 | let left_margin_padding = left_margin + left_padding; 106 | let bar_extent = graph_max_width / (SLIDING_WINDOW_SIZE as f64); 107 | let bar_width = bar_extent * 0.4; 108 | let bar = [ 109 | MoveTo((0., graph_max_height).into()), 110 | LineTo((0., 0.).into()), 111 | LineTo((bar_width, 0.).into()), 112 | LineTo((bar_width, graph_max_height).into()), 113 | ]; 114 | // We determine the scale of the graph based on the maximum sampled frame time unless it's 115 | // greater than 3x the current average. In that case we cap the max scale at 4/3 * the 116 | // current average (rounded up to the nearest multiple of 5ms). This allows the scale to 117 | // adapt to the most recent sample set as relying on the maximum alone can make the 118 | // displayed samples to look too small in the presence of spikes/fluctuation without 119 | // manually resetting the max sample. 120 | let display_max = if self.frame_time_max_ms > 3. * self.frame_time_ms { 121 | round_up((1.33334 * self.frame_time_ms) as usize, 5) as f64 122 | } else { 123 | self.frame_time_max_ms 124 | }; 125 | for (i, sample) in samples.enumerate() { 126 | let t = offset * Affine::translate((i as f64 * bar_extent, graph_max_height)); 127 | // The height of each sample is based on its ratio to the maximum observed frame time. 128 | let sample_ms = ((*sample as f64) * 0.001).min(display_max); 129 | let h = sample_ms / display_max; 130 | let s = Affine::scale_non_uniform(1., -h); 131 | #[allow(clippy::match_overlapping_arm)] 132 | let color = match *sample { 133 | ..=16_667 => Color::from_rgb8(100, 143, 255), 134 | ..=33_334 => Color::from_rgb8(255, 176, 0), 135 | _ => Color::from_rgb8(220, 38, 127), 136 | }; 137 | scene.fill( 138 | Fill::NonZero, 139 | t * Affine::translate(( 140 | left_margin_padding, 141 | (1 + labels.len()) as f64 * text_height, 142 | )) * s, 143 | color, 144 | None, 145 | &bar, 146 | ); 147 | } 148 | // Draw horizontal lines to mark 8.33ms, 16.33ms, and 33.33ms 149 | let marker = [ 150 | MoveTo((0., graph_max_height).into()), 151 | LineTo((graph_max_width, graph_max_height).into()), 152 | ]; 153 | let thresholds = [8.33, 16.66, 33.33]; 154 | let thres_text_height = graph_max_height * 0.05; 155 | let thres_text_height_2 = thres_text_height * 0.5; 156 | for t in thresholds.iter().filter(|&&t| t < display_max) { 157 | let y = t / display_max; 158 | text.add( 159 | scene, 160 | None, 161 | thres_text_height as f32, 162 | Some(&Brush::Solid(Color::WHITE)), 163 | offset 164 | * Affine::translate(( 165 | left_margin, 166 | (2. - y) * graph_max_height + thres_text_height_2, 167 | )), 168 | &format!("{}", t), 169 | ); 170 | scene.stroke( 171 | &Stroke::new(graph_max_height * 0.01), 172 | offset * Affine::translate((left_margin_padding, (1. - y) * graph_max_height)), 173 | Color::WHITE, 174 | None, 175 | &marker, 176 | ); 177 | } 178 | } 179 | } 180 | 181 | pub(crate) struct Sample { 182 | pub frame_time_us: u64, 183 | } 184 | 185 | pub(crate) struct Stats { 186 | count: usize, 187 | sum: u64, 188 | min: u64, 189 | max: u64, 190 | samples: VecDeque, 191 | } 192 | 193 | impl Stats { 194 | pub(crate) fn new() -> Stats { 195 | Stats { 196 | count: 0, 197 | sum: 0, 198 | min: u64::MAX, 199 | max: u64::MIN, 200 | samples: VecDeque::with_capacity(SLIDING_WINDOW_SIZE), 201 | } 202 | } 203 | 204 | pub(crate) fn samples(&self) -> impl Iterator { 205 | self.samples.iter() 206 | } 207 | 208 | pub(crate) fn snapshot(&self) -> Snapshot { 209 | let frame_time_ms = (self.sum as f64 / self.count as f64) * 0.001; 210 | let fps = 1000. / frame_time_ms; 211 | Snapshot { 212 | fps, 213 | frame_time_ms, 214 | frame_time_min_ms: self.min as f64 * 0.001, 215 | frame_time_max_ms: self.max as f64 * 0.001, 216 | } 217 | } 218 | 219 | pub(crate) fn clear_min_and_max(&mut self) { 220 | self.min = u64::MAX; 221 | self.max = u64::MIN; 222 | } 223 | 224 | pub(crate) fn add_sample(&mut self, sample: Sample) { 225 | let oldest = if self.count < SLIDING_WINDOW_SIZE { 226 | self.count += 1; 227 | None 228 | } else { 229 | self.samples.pop_front() 230 | }; 231 | let micros = sample.frame_time_us; 232 | self.sum += micros; 233 | self.samples.push_back(micros); 234 | if let Some(oldest) = oldest { 235 | self.sum -= oldest; 236 | } 237 | self.min = self.min.min(micros); 238 | self.max = self.max.max(micros); 239 | } 240 | } 241 | 242 | fn round_up(n: usize, f: usize) -> usize { 243 | n - 1 - (n - 1) % f + f 244 | } 245 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 100 2 | use_field_init_shorthand = true 3 | newline_style = "Unix" 4 | # TODO: imports_granularity = "Module" - Wait for this to be stable. 5 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2023 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | use thiserror::Error; 5 | 6 | /// Triggered when is an issue parsing a lottie file. 7 | #[derive(Error, Debug)] 8 | #[non_exhaustive] 9 | pub enum Error { 10 | #[error("Error parsing lottie: {0}")] 11 | Json(#[from] serde_json::Error), 12 | } 13 | -------------------------------------------------------------------------------- /src/import/builders.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | use super::converters::{conv_blend_mode, conv_scalar, conv_shape_geometry, conv_transform}; 5 | use super::defaults::FLOAT_VALUE_ONE_HUNDRED; 6 | use crate::runtime::model::Layer; 7 | use crate::schema::helpers::int_boolean::BoolInt; 8 | use crate::{runtime, schema}; 9 | use vello::peniko::{self, BlendMode, Compose, Mix}; 10 | 11 | pub fn setup_precomp_layer( 12 | source: &schema::layers::precomposition::PrecompositionLayer, 13 | target: &mut Layer, 14 | ) -> (usize, Option) { 15 | target.name = source.properties.name.clone().unwrap_or_default(); 16 | target.parent = source.properties.parent_index; 17 | let (transform, opacity) = conv_transform(&source.properties.transform); 18 | target.transform = transform; 19 | target.opacity = opacity; 20 | target.width = source.width; 21 | target.height = source.height; 22 | target.is_mask = source 23 | .properties 24 | .matte_target 25 | .as_ref() 26 | .is_some_and(|td| *td == BoolInt::True); 27 | 28 | let matte_mode = source 29 | .properties 30 | .matte_mode 31 | .as_ref() 32 | .map(|mode| match mode { 33 | schema::constants::matte_mode::MatteMode::Normal => Mix::Normal.into(), 34 | schema::constants::matte_mode::MatteMode::Alpha 35 | | schema::constants::matte_mode::MatteMode::Luma => Compose::SrcIn.into(), 36 | schema::constants::matte_mode::MatteMode::InvertedAlpha 37 | | schema::constants::matte_mode::MatteMode::InvertedLuma => Compose::SrcOut.into(), 38 | }); 39 | 40 | target.blend_mode = conv_blend_mode( 41 | source 42 | .properties 43 | .blend_mode 44 | .as_ref() 45 | .unwrap_or(&crate::schema::constants::blend_mode::BlendMode::Normal), 46 | ); 47 | if target.blend_mode == Some(peniko::Mix::Normal.into()) { 48 | target.blend_mode = None; 49 | } 50 | target.stretch = source.properties.time_stretch.unwrap_or(1.0); 51 | target.frames = source.properties.in_point..source.properties.out_point; 52 | target.start_frame = source.properties.start_time; 53 | 54 | for mask_source in source 55 | .properties 56 | .masks_properties 57 | .as_ref() 58 | .unwrap_or(&Vec::default()) 59 | { 60 | if let Some(shape) = &mask_source.shape { 61 | if let Some(geometry) = conv_shape_geometry(shape) { 62 | let mode = peniko::BlendMode::default(); 63 | let opacity = conv_scalar( 64 | mask_source 65 | .opacity 66 | .as_ref() 67 | .unwrap_or(&FLOAT_VALUE_ONE_HUNDRED), 68 | ); 69 | target.masks.push(runtime::model::Mask { 70 | mode, 71 | geometry, 72 | opacity, 73 | }); 74 | } 75 | } 76 | } 77 | 78 | (source.properties.index.unwrap_or(0), matte_mode) 79 | } 80 | 81 | pub fn setup_shape_layer( 82 | source: &schema::layers::shape::ShapeLayer, 83 | target: &mut Layer, 84 | ) -> (usize, Option) { 85 | target.name = source.properties.name.clone().unwrap_or_default(); 86 | target.parent = source.properties.parent_index; 87 | let (transform, opacity) = conv_transform(&source.properties.transform); 88 | target.transform = transform; 89 | target.opacity = opacity; 90 | target.is_mask = source 91 | .properties 92 | .matte_target 93 | .as_ref() 94 | .is_some_and(|td| *td == BoolInt::True); 95 | 96 | let matte_mode = source 97 | .properties 98 | .matte_mode 99 | .as_ref() 100 | .map(|mode| match mode { 101 | schema::constants::matte_mode::MatteMode::Normal => Mix::Normal.into(), 102 | schema::constants::matte_mode::MatteMode::Alpha 103 | | schema::constants::matte_mode::MatteMode::Luma => Compose::SrcIn.into(), 104 | schema::constants::matte_mode::MatteMode::InvertedAlpha 105 | | schema::constants::matte_mode::MatteMode::InvertedLuma => Compose::SrcOut.into(), 106 | }); 107 | 108 | target.blend_mode = conv_blend_mode( 109 | source 110 | .properties 111 | .blend_mode 112 | .as_ref() 113 | .unwrap_or(&crate::schema::constants::blend_mode::BlendMode::Normal), 114 | ); 115 | if target.blend_mode == Some(peniko::Mix::Normal.into()) { 116 | target.blend_mode = None; 117 | } 118 | target.stretch = source.properties.time_stretch.unwrap_or(1.0); 119 | target.frames = source.properties.in_point..source.properties.out_point; 120 | target.start_frame = source.properties.start_time; 121 | 122 | for mask_source in source 123 | .properties 124 | .masks_properties 125 | .as_ref() 126 | .unwrap_or(&Vec::default()) 127 | { 128 | if let Some(shape) = &mask_source.shape { 129 | if let Some(geometry) = conv_shape_geometry(shape) { 130 | let mode = peniko::BlendMode::default(); 131 | let opacity = conv_scalar( 132 | mask_source 133 | .opacity 134 | .as_ref() 135 | .unwrap_or(&FLOAT_VALUE_ONE_HUNDRED), 136 | ); 137 | target.masks.push(runtime::model::Mask { 138 | mode, 139 | geometry, 140 | opacity, 141 | }); 142 | } 143 | } 144 | } 145 | 146 | (source.properties.index.unwrap_or(0), matte_mode) 147 | } 148 | 149 | pub fn setup_layer_base( 150 | source: &schema::layers::visual::VisualLayer, 151 | target: &mut Layer, 152 | ) -> (usize, Option) { 153 | target.name = source.name.clone().unwrap_or_default(); 154 | target.parent = source.parent_index; 155 | let (transform, opacity) = conv_transform(&source.transform); 156 | target.transform = transform; 157 | target.opacity = opacity; 158 | target.is_mask = source 159 | .matte_target 160 | .as_ref() 161 | .is_some_and(|td| *td == BoolInt::True); 162 | 163 | let matte_mode = source.matte_mode.as_ref().map(|mode| match mode { 164 | schema::constants::matte_mode::MatteMode::Normal => Mix::Normal.into(), 165 | schema::constants::matte_mode::MatteMode::Alpha 166 | | schema::constants::matte_mode::MatteMode::Luma => Compose::SrcIn.into(), 167 | schema::constants::matte_mode::MatteMode::InvertedAlpha 168 | | schema::constants::matte_mode::MatteMode::InvertedLuma => Compose::SrcOut.into(), 169 | }); 170 | 171 | target.blend_mode = conv_blend_mode( 172 | source 173 | .blend_mode 174 | .as_ref() 175 | .unwrap_or(&crate::schema::constants::blend_mode::BlendMode::Normal), 176 | ); 177 | // TODO: Why do we do this next part? 178 | if target.blend_mode == Some(peniko::Mix::Normal.into()) { 179 | target.blend_mode = None; 180 | } 181 | target.stretch = source.time_stretch.unwrap_or(1.0); 182 | target.frames = source.in_point..source.out_point; 183 | target.start_frame = source.start_time; 184 | 185 | for mask_source in source.masks_properties.as_ref().unwrap_or(&Vec::default()) { 186 | if let Some(shape) = &mask_source.shape { 187 | if let Some(geometry) = conv_shape_geometry(shape) { 188 | let mode = peniko::BlendMode::default(); 189 | let opacity = conv_scalar( 190 | mask_source 191 | .opacity 192 | .as_ref() 193 | .unwrap_or(&FLOAT_VALUE_ONE_HUNDRED), 194 | ); 195 | target.masks.push(runtime::model::Mask { 196 | mode, 197 | geometry, 198 | opacity, 199 | }); 200 | } 201 | } 202 | } 203 | 204 | (source.index.unwrap_or(0), matte_mode) 205 | } 206 | -------------------------------------------------------------------------------- /src/import/defaults.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | use crate::schema::animated_properties::animated_property::{AnimatedProperty, AnimatedPropertyK}; 5 | use crate::schema::animated_properties::multi_dimensional::MultiDimensional; 6 | use crate::schema::animated_properties::position::Position; 7 | use crate::schema::animated_properties::value::FloatValue; 8 | use crate::schema::helpers::int_boolean::BoolInt; 9 | use crate::schema::{self}; 10 | use once_cell::sync::Lazy; 11 | 12 | pub static FLOAT_VALUE_ZERO: Lazy = Lazy::new(|| FloatValue { 13 | animated_property: AnimatedProperty { 14 | property_index: None, 15 | animated: Some(BoolInt::False), 16 | expression: None, 17 | slot_id: None, 18 | value: AnimatedPropertyK::Static(0.0), 19 | }, 20 | }); 21 | 22 | pub static FLOAT_VALUE_ONE_HUNDRED: Lazy = Lazy::new(|| FloatValue { 23 | animated_property: AnimatedProperty { 24 | property_index: None, 25 | animated: Some(BoolInt::False), 26 | expression: None, 27 | slot_id: None, 28 | value: AnimatedPropertyK::Static(100.0), 29 | }, 30 | }); 31 | 32 | pub static MULTIDIM_ONE: Lazy = Lazy::new(|| MultiDimensional { 33 | animated_property: AnimatedProperty { 34 | property_index: None, 35 | animated: Some(BoolInt::False), 36 | expression: None, 37 | slot_id: None, 38 | value: AnimatedPropertyK::Static(vec![1.0, 1.0, 1.0]), 39 | }, 40 | length: None, 41 | }); 42 | 43 | pub static POSITION_ZERO: Lazy = Lazy::new(|| Position { 44 | property_index: None, 45 | animated: Some(BoolInt::False), 46 | expression: None, 47 | length: None, 48 | value: schema::animated_properties::position::PositionValueK::Static(vec![0.0, 0.0]), 49 | }); 50 | -------------------------------------------------------------------------------- /src/import/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | mod builders; 5 | mod converters; 6 | mod defaults; 7 | 8 | pub use converters::conv_animation; 9 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | //! Render a Lottie animation to a Vello [`Scene`](crate::vello::Scene). 5 | //! 6 | //! This currently lacks support for a [number of important](crate#unsupported-features) SVG features. 7 | //! 8 | //! This is also intended to be the preferred integration between Vello and [usvg](https://crates.io/crates/usvg), 9 | //! so [consider contributing](https://github.com/linebender/vello_svg) if you need a feature which is missing. 10 | //! 11 | //! This crate also re-exports [`vello`], so you can easily use the specific version that is compatible with Velato. 12 | //! 13 | //! ## Usage 14 | //! 15 | //! ```no_run 16 | //! # use std::str::FromStr; 17 | //! use velato::vello; 18 | //! 19 | //! // Parse your lottie file 20 | //! let lottie = include_str!("../examples/assets/google_fonts/Tiger.json"); 21 | //! let composition = velato::Composition::from_str(lottie).expect("valid file"); 22 | //! 23 | //! // Render to a scene 24 | //! let mut new_scene = vello::Scene::new(); 25 | //! 26 | //! // Render to a scene! 27 | //! let mut renderer = velato::Renderer::new(); 28 | //! let frame = 0.0; // Arbitrary number chosen. Ensure it's a valid frame! 29 | //! let transform = vello::kurbo::Affine::IDENTITY; 30 | //! let alpha = 1.0; 31 | //! let scene = renderer.render(&composition, frame, transform, alpha); 32 | //! ``` 33 | //! 34 | //! # Unsupported features 35 | //! 36 | //! Missing features include: 37 | //! - Position keyframe (`ti`, `to`) easing 38 | //! - Time remapping (`tm`) 39 | //! - Text 40 | //! - Image embedding 41 | //! - Advanced shapes (stroke dash, zig-zag, etc.) 42 | //! - Advanced effects (motion blur, drop shadows, etc.) 43 | //! - Correct color stop handling 44 | //! - Split rotations 45 | //! - Split positions 46 | 47 | // LINEBENDER LINT SET - lib.rs - v3 48 | // See https://linebender.org/wiki/canonical-lints/ 49 | // These lints shouldn't apply to examples or tests. 50 | #![cfg_attr(not(test), warn(unused_crate_dependencies))] 51 | // These lints shouldn't apply to examples. 52 | #![warn(clippy::print_stdout, clippy::print_stderr)] 53 | // Targeting e.g. 32-bit means structs containing usize can give false positives for 64-bit. 54 | #![cfg_attr(target_pointer_width = "64", warn(clippy::trivially_copy_pass_by_ref))] 55 | // END LINEBENDER LINT SET 56 | #![cfg_attr(docsrs, feature(doc_auto_cfg))] 57 | // The following lints are part of the Linebender standard set, 58 | // but resolving them has been deferred for now. 59 | // Feel free to send a PR that solves one or more of these. 60 | #![allow( 61 | unreachable_pub, 62 | missing_docs, 63 | elided_lifetimes_in_paths, 64 | single_use_lifetimes, 65 | unused_qualifications, 66 | clippy::empty_docs, 67 | clippy::use_self, 68 | clippy::return_self_not_must_use, 69 | clippy::cast_possible_truncation, 70 | clippy::shadow_unrelated, 71 | clippy::missing_assert_message, 72 | clippy::missing_errors_doc, 73 | clippy::exhaustive_enums, 74 | clippy::todo, 75 | reason = "Deferred" 76 | )] 77 | #![cfg_attr( 78 | test, 79 | allow( 80 | unused_crate_dependencies, 81 | reason = "Some dev dependencies are only used in tests" 82 | ) 83 | )] 84 | 85 | pub(crate) mod import; 86 | pub(crate) mod runtime; 87 | pub(crate) mod schema; 88 | 89 | mod error; 90 | pub use error::Error; 91 | 92 | // Re-export vello 93 | pub use vello; 94 | 95 | pub use runtime::{Composition, Renderer, model}; 96 | -------------------------------------------------------------------------------- /src/runtime/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | mod render; 5 | 6 | use crate::Error; 7 | use crate::import; 8 | use crate::schema::Animation; 9 | use std::collections::HashMap; 10 | use std::ops::Range; 11 | 12 | pub mod model; 13 | 14 | pub use render::Renderer; 15 | 16 | /// Model of a Lottie file. 17 | #[derive(Clone, Default, Debug)] 18 | pub struct Composition { 19 | /// Frames in which the animation is active. 20 | pub frames: Range, 21 | /// Frames per second. 22 | pub frame_rate: f64, 23 | /// Width of the animation. 24 | pub width: usize, 25 | /// Height of the animation. 26 | pub height: usize, 27 | /// Precomposed layers that may be instanced. 28 | pub assets: HashMap>, 29 | /// Collection of layers. 30 | pub layers: Vec, 31 | } 32 | 33 | impl Composition { 34 | /// Creates a new runtime composition from a buffer of Lottie file contents. 35 | pub fn from_slice(source: impl AsRef<[u8]>) -> Result { 36 | let source = Animation::from_slice(source.as_ref())?; 37 | let composition = import::conv_animation(source); 38 | Ok(composition) 39 | } 40 | 41 | /// Creates a new runtime composition from a json object of Lottie file contents. 42 | pub fn from_json(v: serde_json::Value) -> Result { 43 | let source = Animation::from_json(v)?; 44 | let composition = import::conv_animation(source); 45 | Ok(composition) 46 | } 47 | } 48 | 49 | impl std::str::FromStr for Composition { 50 | type Err = Error; 51 | 52 | fn from_str(s: &str) -> Result { 53 | let source = Animation::from_str(s)?; 54 | let composition = import::conv_animation(source); 55 | Ok(composition) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/runtime/model/fixed.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | /*! 5 | Representations of fixed (non-animated) values. 6 | */ 7 | 8 | use vello::kurbo::{self, Affine, Point, Vec2}; 9 | use vello::peniko; 10 | 11 | /// Fixed affine transformation. 12 | pub type Transform = kurbo::Affine; 13 | 14 | /// Fixed RGBA color. 15 | pub type Color = peniko::Color; 16 | 17 | /// Fixed color stops. 18 | pub type ColorStops = peniko::ColorStops; 19 | 20 | /// Fixed brush. 21 | pub type Brush = peniko::Brush; 22 | 23 | /// Fixed stroke style. 24 | pub type Stroke = kurbo::Stroke; 25 | 26 | /// Fixed repeater effect. 27 | #[derive(Clone, Debug)] 28 | pub struct Repeater { 29 | /// Number of times to repeat. 30 | pub copies: usize, 31 | /// Offset of each subsequent repeated element. 32 | pub offset: f64, 33 | /// Anchor point. 34 | pub anchor_point: Point, 35 | /// Translation. 36 | pub position: Point, 37 | /// Rotation in degrees. 38 | pub rotation: f64, 39 | /// Scale. 40 | pub scale: Vec2, 41 | /// Opacity of the first element. 42 | pub start_opacity: f64, 43 | /// Opacity of the last element. 44 | pub end_opacity: f64, 45 | } 46 | 47 | impl Repeater { 48 | /// Returns the transform for the given copy index. 49 | pub fn transform(&self, index: usize) -> Affine { 50 | let t = self.offset + index as f64; 51 | Affine::translate(( 52 | t * self.position.x + self.anchor_point.x, 53 | t * self.position.y + self.anchor_point.y, 54 | )) * Affine::rotate((t * self.rotation).to_radians()) 55 | * Affine::scale_non_uniform( 56 | (self.scale.x / 100.0).powf(t), 57 | (self.scale.y / 100.0).powf(t), 58 | ) 59 | * Affine::translate((-self.anchor_point.x, -self.anchor_point.y)) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/runtime/model/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | use std::ops::Range; 5 | use vello::kurbo::{self, Affine, PathEl, Point, Shape as _, Size, Vec2}; 6 | use vello::peniko::{self, BlendMode, Color}; 7 | 8 | mod spline; 9 | mod value; 10 | 11 | pub mod animated; 12 | pub mod fixed; 13 | 14 | pub use value::{Animated, Easing, EasingHandle, Time, Tween, Value, ValueRef}; 15 | 16 | pub(crate) use spline::SplineToPath; 17 | 18 | #[derive(Clone, Debug)] 19 | #[cfg_attr( 20 | not(target_arch = "wasm32"), 21 | expect( 22 | clippy::large_enum_variant, 23 | reason = "Deferred. Furthermore, for some reason, only on wasm32, this isn't triggering clippy." 24 | ) 25 | )] 26 | pub enum Transform { 27 | Fixed(fixed::Transform), 28 | Animated(animated::Transform), 29 | } 30 | impl Transform { 31 | pub fn is_fixed(&self) -> bool { 32 | matches!(self, Self::Fixed(_)) 33 | } 34 | pub fn evaluate(&self, frame: f64) -> ValueRef { 35 | match self { 36 | Self::Fixed(value) => ValueRef::Borrowed(value), 37 | Self::Animated(value) => ValueRef::Owned(value.evaluate(frame)), 38 | } 39 | } 40 | } 41 | 42 | #[derive(Clone, Debug)] 43 | pub enum Stroke { 44 | Fixed(fixed::Stroke), 45 | Animated(animated::Stroke), 46 | } 47 | impl Stroke { 48 | pub fn is_fixed(&self) -> bool { 49 | matches!(self, Self::Fixed(_)) 50 | } 51 | pub fn evaluate(&self, frame: f64) -> ValueRef { 52 | match self { 53 | Self::Fixed(value) => ValueRef::Borrowed(value), 54 | Self::Animated(value) => ValueRef::Owned(value.evaluate(frame)), 55 | } 56 | } 57 | } 58 | 59 | #[derive(Clone, Debug)] 60 | #[cfg_attr( 61 | not(target_arch = "wasm32"), 62 | expect( 63 | clippy::large_enum_variant, 64 | reason = "Deferred. Furthermore, for some reason, only on wasm32, this isn't triggering clippy." 65 | ) 66 | )] 67 | pub enum Repeater { 68 | Fixed(fixed::Repeater), 69 | Animated(animated::Repeater), 70 | } 71 | impl Repeater { 72 | pub fn is_fixed(&self) -> bool { 73 | matches!(self, Self::Fixed(_)) 74 | } 75 | pub fn evaluate(&self, frame: f64) -> ValueRef { 76 | match self { 77 | Self::Fixed(value) => ValueRef::Borrowed(value), 78 | Self::Animated(value) => ValueRef::Owned(value.evaluate(frame)), 79 | } 80 | } 81 | } 82 | 83 | #[derive(Clone, Debug)] 84 | pub enum ColorStops { 85 | Fixed(fixed::ColorStops), 86 | Animated(animated::ColorStops), 87 | } 88 | impl ColorStops { 89 | pub fn is_fixed(&self) -> bool { 90 | matches!(self, Self::Fixed(_)) 91 | } 92 | pub fn evaluate(&self, frame: f64) -> ValueRef { 93 | match self { 94 | Self::Fixed(value) => ValueRef::Borrowed(value), 95 | Self::Animated(value) => ValueRef::Owned(value.evaluate(frame)), 96 | } 97 | } 98 | } 99 | 100 | #[derive(Clone, Debug)] 101 | pub enum Brush { 102 | Fixed(fixed::Brush), 103 | Animated(animated::Brush), 104 | } 105 | 106 | impl Brush { 107 | pub fn is_fixed(&self) -> bool { 108 | matches!(self, Self::Fixed(_)) 109 | } 110 | 111 | pub fn evaluate(&self, alpha: f64, frame: f64) -> ValueRef { 112 | match self { 113 | Self::Fixed(value) => { 114 | if alpha == 1.0 { 115 | ValueRef::Borrowed(value) 116 | } else { 117 | ValueRef::Owned(value.to_owned().multiply_alpha(alpha as _)) 118 | } 119 | } 120 | Self::Animated(value) => ValueRef::Owned(value.evaluate(alpha, frame)), 121 | } 122 | } 123 | } 124 | 125 | impl Default for Transform { 126 | fn default() -> Self { 127 | Self::Fixed(Affine::IDENTITY) 128 | } 129 | } 130 | 131 | #[derive(Clone, Debug)] 132 | pub enum Geometry { 133 | Fixed(Vec), 134 | Rect(animated::Rect), 135 | Ellipse(animated::Ellipse), 136 | Spline(animated::Spline), 137 | } 138 | 139 | impl Geometry { 140 | pub fn evaluate(&self, frame: f64, path: &mut Vec) { 141 | match self { 142 | Self::Fixed(value) => { 143 | path.extend_from_slice(value); 144 | } 145 | Self::Rect(value) => { 146 | path.extend(value.evaluate(frame).path_elements(0.1)); 147 | } 148 | Self::Ellipse(value) => { 149 | path.extend(value.evaluate(frame).path_elements(0.1)); 150 | } 151 | Self::Spline(value) => { 152 | value.evaluate(frame, path); 153 | } 154 | } 155 | } 156 | } 157 | 158 | #[derive(Clone, Debug)] 159 | pub struct Draw { 160 | /// Parameters for a stroked draw operation. 161 | pub stroke: Option, 162 | /// Brush for the draw operation. 163 | pub brush: Brush, 164 | /// Opacity of the draw operation. 165 | pub opacity: Value, 166 | } 167 | 168 | /// Elements of a shape layer. 169 | #[derive(Clone, Debug)] 170 | pub enum Shape { 171 | /// Group of shapes with an optional transform. 172 | Group(Vec, Option), 173 | /// Geometry element. 174 | Geometry(Geometry), 175 | /// Fill or stroke element. 176 | Draw(Draw), 177 | /// Repeater element. 178 | Repeater(Repeater), 179 | } 180 | 181 | /// Transform and opacity for a shape group. 182 | #[derive(Clone, Debug)] 183 | pub struct GroupTransform { 184 | pub transform: Transform, 185 | pub opacity: Value, 186 | } 187 | 188 | /// Layer in an animation. 189 | #[derive(Clone, Debug, Default)] 190 | pub struct Layer { 191 | /// Name of the layer. 192 | pub name: String, 193 | /// Index of the transform parent layer. 194 | pub parent: Option, 195 | /// Transform for the entire layer. 196 | pub transform: Transform, 197 | /// Opacity for the entire layer. 198 | pub opacity: Value, 199 | /// Width of the layer. 200 | pub width: f64, 201 | /// Height of the layer. 202 | pub height: f64, 203 | /// Blend mode for the layer. 204 | pub blend_mode: Option, 205 | /// Range of frames in which the layer is active. 206 | pub frames: Range, 207 | /// Frame time stretch factor. 208 | pub stretch: f64, 209 | /// Starting frame for the layer (only applied to instances). 210 | pub start_frame: f64, 211 | /// List of masks applied to the content. 212 | pub masks: Vec, 213 | /// True if the layer is used as a mask. 214 | pub is_mask: bool, 215 | /// Mask blend mode and layer. 216 | pub mask_layer: Option<(BlendMode, usize)>, 217 | /// Content of the layer. 218 | pub content: Content, 219 | } 220 | 221 | /// Matte layer mode. 222 | #[derive(Copy, Clone, PartialEq, Eq, Default, Debug)] 223 | pub enum Matte { 224 | #[default] 225 | Normal, 226 | // TODO: Use these 227 | // Alpha, 228 | // InvertAlpha, 229 | // Luma, 230 | // InvertLuma, 231 | } 232 | 233 | /// Mask for a layer. 234 | #[derive(Clone, Debug)] 235 | pub struct Mask { 236 | /// Blend mode for the mask. 237 | pub mode: peniko::BlendMode, 238 | /// Geometry that defines the shape of the mask. 239 | pub geometry: Geometry, 240 | /// Opacity of the mask. 241 | pub opacity: Value, 242 | } 243 | 244 | /// Content of a layer. 245 | #[derive(Clone, Default, Debug)] 246 | pub enum Content { 247 | /// Empty layer. 248 | #[default] 249 | None, 250 | /// Asset instance with the specified name and time remapping. 251 | Instance { 252 | name: String, 253 | time_remap: Option>, 254 | }, 255 | /// Collection of shapes. 256 | Shape(Vec), 257 | } 258 | -------------------------------------------------------------------------------- /src/runtime/model/spline.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | use vello::kurbo::{PathEl, Point}; 5 | 6 | /// Helper trait for converting cubic splines to paths. 7 | pub trait SplineToPath { 8 | fn get(&self, index: usize) -> Point; 9 | fn len(&self) -> usize; 10 | 11 | fn to_path(&self, is_closed: bool, path: &mut Vec) -> Option<()> { 12 | use PathEl::*; 13 | if self.len() == 0 { 14 | return None; 15 | } 16 | path.push(MoveTo(self.get(0))); 17 | let n_vertices = self.len() / 3; 18 | let mut add_element = |from_vertex, to_vertex| { 19 | let from_index = 3 * from_vertex; 20 | let to_index = 3 * to_vertex; 21 | let p0: Point = self.get(from_index); 22 | let p1: Point = self.get(to_index); 23 | let mut c0: Point = self.get(from_index + 2); 24 | c0.x += p0.x; 25 | c0.y += p0.y; 26 | let mut c1: Point = self.get(to_index + 1); 27 | c1.x += p1.x; 28 | c1.y += p1.y; 29 | if c0 == p0 && c1 == p1 { 30 | path.push(LineTo(p1)); 31 | } else { 32 | path.push(CurveTo(c0, c1, p1)); 33 | } 34 | }; 35 | for i in 1..n_vertices { 36 | add_element(i - 1, i); 37 | } 38 | if is_closed && n_vertices != 0 { 39 | add_element(n_vertices - 1, 0); 40 | path.push(ClosePath); 41 | } 42 | Some(()) 43 | } 44 | } 45 | 46 | /// Converts a static spline to a path. 47 | impl SplineToPath for &'_ [Point] { 48 | fn len(&self) -> usize { 49 | self.as_ref().len() 50 | } 51 | 52 | fn get(&self, index: usize) -> Point { 53 | self[index] 54 | } 55 | } 56 | 57 | /// Produces a path by lerping between two sets of points. 58 | impl SplineToPath for (&'_ [Point], &'_ [Point], f64) { 59 | fn len(&self) -> usize { 60 | self.0.len().min(self.1.len()) 61 | } 62 | 63 | fn get(&self, index: usize) -> Point { 64 | // TODO: This definitely shouldn't be a lerp 65 | self.0[index].lerp(self.1[index], self.2) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/runtime/model/value.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 the Velato Authors 2 | // SPDX-License-Identifier: Apache-2.0 OR MIT 3 | 4 | use vello::kurbo::{self}; 5 | use vello::peniko; 6 | 7 | /// Fixed or animated value. 8 | #[derive(Clone, Debug)] 9 | pub enum Value { 10 | /// Fixed value. 11 | Fixed(T), 12 | /// Animated value. 13 | Animated(Animated), 14 | } 15 | 16 | impl Value { 17 | /// Returns the value at a specified frame. 18 | pub fn evaluate(&self, frame: f64) -> T { 19 | match self { 20 | Self::Fixed(fixed) => fixed.clone(), 21 | Self::Animated(animated) => animated.evaluate_or(frame, T::default()), 22 | } 23 | } 24 | } 25 | 26 | impl Value { 27 | /// Returns true if the value is fixed. 28 | pub fn is_fixed(&self) -> bool { 29 | matches!(self, Self::Fixed(_)) 30 | } 31 | 32 | /// Returns the value at a specified frame, with a fallback default. 33 | pub fn evaluate_or(&self, frame: f64, default: T) -> T { 34 | match self { 35 | Self::Fixed(fixed) => fixed.clone(), 36 | Self::Animated(animated) => animated.evaluate_or(frame, default), 37 | } 38 | } 39 | } 40 | 41 | impl Default for Value { 42 | fn default() -> Self { 43 | Self::Fixed(T::default()) 44 | } 45 | } 46 | 47 | /// Borrowed or owned value. 48 | #[derive(Clone, Debug)] 49 | pub enum ValueRef<'a, T> { 50 | Borrowed(&'a T), 51 | Owned(T), 52 | } 53 | 54 | impl AsRef for ValueRef<'_, T> { 55 | fn as_ref(&self) -> &T { 56 | match self { 57 | Self::Borrowed(value) => value, 58 | Self::Owned(value) => value, 59 | } 60 | } 61 | } 62 | 63 | impl ValueRef<'_, T> { 64 | pub fn into_owned(self) -> T { 65 | match self { 66 | Self::Borrowed(value) => value.clone(), 67 | Self::Owned(value) => value, 68 | } 69 | } 70 | } 71 | 72 | #[derive(Copy, Clone, Debug)] 73 | pub struct Easing { 74 | pub o: EasingHandle, 75 | pub i: EasingHandle, 76 | } 77 | 78 | impl Easing { 79 | pub const LERP: Easing = Easing { 80 | o: EasingHandle { x: 0.0, y: 0.0 }, 81 | i: EasingHandle { x: 1.0, y: 1.0 }, 82 | }; 83 | } 84 | 85 | #[derive(Copy, Clone, Debug)] 86 | pub struct EasingHandle { 87 | pub x: f64, 88 | pub y: f64, 89 | } 90 | 91 | /// Time for a particular keyframe, represented as a frame number. 92 | #[derive(Copy, Clone, Debug)] 93 | pub struct Time { 94 | /// Frame number. 95 | pub frame: f64, 96 | /// Easing tangent going into the next keyframe 97 | pub in_tangent: Option, 98 | /// Easing tangent leaving the current keyframe 99 | pub out_tangent: Option, 100 | /// Whether it's a hold frame. 101 | pub hold: bool, 102 | } 103 | 104 | impl Time { 105 | /// Returns the frame indices and interpolation weight for the given frame, 106 | /// and whether to hold the frame 107 | pub(crate) fn frames_and_weight( 108 | times: &[Time], 109 | frame: f64, 110 | ) -> Option<([usize; 2], f64, Easing, bool)> { 111 | if times.is_empty() { 112 | return None; 113 | } 114 | use core::cmp::Ordering::*; 115 | let ix = match times.binary_search_by(|x| { 116 | if x.frame < frame { 117 | Less 118 | } else if x.frame > frame { 119 | Greater 120 | } else { 121 | Equal 122 | } 123 | }) { 124 | Ok(ix) => ix, 125 | Err(ix) => ix.saturating_sub(1), 126 | }; 127 | let ix0 = ix.min(times.len() - 1); 128 | let ix1 = (ix0 + 1).min(times.len() - 1); 129 | 130 | let t0 = times[ix0]; 131 | let t1 = times[ix1]; 132 | let (t0_ox, t0_oy) = t0.out_tangent.map(|o| (o.x, o.y)).unwrap_or((0.0, 0.0)); 133 | let (t0_ix, t0_iy) = t0.in_tangent.map(|i| (i.x, i.y)).unwrap_or((1.0, 1.0)); 134 | let easing = Easing { 135 | o: EasingHandle { x: t0_ox, y: t0_oy }, 136 | i: EasingHandle { x: t0_ix, y: t0_iy }, 137 | }; 138 | let hold = t0.hold; 139 | let t = (frame - t0.frame) / (t1.frame - t0.frame); 140 | Some(([ix0, ix1], t.clamp(0.0, 1.0), easing, hold)) 141 | } 142 | } 143 | 144 | #[derive(Clone, Debug)] 145 | pub struct Animated { 146 | pub times: Vec