From cd43ea488eda8da4abc176d10bdfb520796e396a Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Fri, 16 Oct 2026 19:42:57 -0300 Subject: [PATCH] feat: notescraft-lottie player crate, egui lottie_icon painter, icon_sheet review example, first icon animation set Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 1 + assets/icons-anim/arrow-down.json | 1 + assets/icons-anim/arrow-up.json | 1 + assets/icons-anim/bold.json | 1 + assets/icons-anim/bookmark.json | 1 + assets/icons-anim/bug.json | 1 + assets/icons-anim/calendar.json | 1 + assets/icons-anim/check.json | 1 + assets/icons-anim/chevron-down.json | 1 + assets/icons-anim/chevron-left.json | 1 + assets/icons-anim/chevron-right.json | 1 + assets/icons-anim/chevron-up.json | 1 + assets/icons-anim/circle-check.json | 1 + assets/icons-anim/circle-question-mark.json | 1 + assets/icons-anim/circle.json | 1 + assets/icons-anim/clipboard.json | 1 + assets/icons-anim/copy.json | 1 + assets/icons-anim/diamond.json | 1 + assets/icons-anim/download.json | 1 + assets/icons-anim/eraser.json | 1 + assets/icons-anim/file-text.json | 1 + assets/icons-anim/flame.json | 1 + assets/icons-anim/folder.json | 1 + assets/icons-anim/git-branch.json | 1 + assets/icons-anim/git-commit-horizontal.json | 1 + assets/icons-anim/git-fork.json | 1 + assets/icons-anim/grid-3x3.json | 1 + assets/icons-anim/hand.json | 1 + assets/icons-anim/highlighter.json | 1 + assets/icons-anim/image.json | 1 + assets/icons-anim/index.json | 1 + assets/icons-anim/info.json | 1 + assets/icons-anim/italic.json | 1 + assets/icons-anim/layers.json | 1 + assets/icons-anim/layout-dashboard.json | 1 + assets/icons-anim/link.json | 1 + assets/icons-anim/list.json | 1 + assets/icons-anim/lock.json | 1 + assets/icons-anim/maximize.json | 1 + assets/icons-anim/message-square-quote.json | 1 + assets/icons-anim/mouse-pointer-2.json | 1 + assets/icons-anim/move-up-right.json | 1 + assets/icons-anim/notebook-pen.json | 1 + assets/icons-anim/panel-left.json | 1 + assets/icons-anim/panel-right.json | 1 + assets/icons-anim/pen.json | 1 + assets/icons-anim/pencil.json | 1 + assets/icons-anim/plus.json | 1 + assets/icons-anim/quote.json | 1 + assets/icons-anim/refresh-cw.json | 1 + assets/icons-anim/scissors.json | 1 + assets/icons-anim/search.json | 1 + assets/icons-anim/settings.json | 1 + assets/icons-anim/slash.json | 1 + assets/icons-anim/sparkles.json | 1 + assets/icons-anim/square.json | 1 + assets/icons-anim/star.json | 1 + assets/icons-anim/strikethrough.json | 1 + assets/icons-anim/tag.json | 1 + assets/icons-anim/trash-2.json | 1 + assets/icons-anim/triangle-alert.json | 1 + assets/icons-anim/type.json | 1 + assets/icons-anim/undo-2.json | 1 + assets/icons-anim/upload.json | 1 + assets/icons-anim/x.json | 1 + assets/icons-anim/zap.json | 1 + crates/lottie/src/draw.rs | 77 ++++++ crates/lottie/src/error.rs | 39 +++ crates/lottie/src/eval.rs | 175 ++++++++++++ crates/lottie/src/eval_tests.rs | 137 ++++++++++ crates/lottie/src/flatten.rs | 215 +++++++++++++++ crates/lottie/src/keyframes.rs | 123 +++++++++ crates/lottie/src/lib.rs | 112 +++++++- crates/lottie/src/model.rs | 94 +++++++ crates/lottie/src/parse.rs | 115 ++++++++ crates/lottie/src/properties.rs | 218 +++++++++++++++ crates/lottie/src/shapes.rs | 196 ++++++++++++++ crates/lottie/src/trim.rs | 125 +++++++++ crates/lottie/src/validate.rs | 175 ++++++++++++ crates/lottie/tests/assets.rs | 34 +++ crates/lottie/tests/compatibility.rs | 87 ++++++ .../tests/fixtures/animated-shackle.json | 24 ++ crates/lottie/tests/fixtures/hierarchy.json | 18 ++ crates/lottie/tests/fixtures/keyframes.json | 22 ++ crates/lottie/tests/fixtures/markers.json | 5 + crates/lottie/tests/fixtures/shackle.json | 18 ++ crates/lottie/tests/fixtures/stroke.json | 12 + crates/lottie/tests/fixtures/trim.json | 14 + crates/lottie/tests/hostile.rs | 250 ++++++++++++++++++ crates/lottie/tests/metadata.rs | 34 +++ crates/lottie/tests/primitives.rs | 86 ++++++ crates/lottie/tests/sampling.rs | 217 +++++++++++++++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/examples/icon_sheet.rs | 46 ++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/lottie_icon.rs | 171 ++++++++++++ crates/ui-egui/src/lottie_icon/files.rs | 69 +++++ 97 files changed, 2970 insertions(+), 6 deletions(-) create mode 100644 assets/icons-anim/arrow-down.json create mode 100644 assets/icons-anim/arrow-up.json create mode 100644 assets/icons-anim/bold.json create mode 100644 assets/icons-anim/bookmark.json create mode 100644 assets/icons-anim/bug.json create mode 100644 assets/icons-anim/calendar.json create mode 100644 assets/icons-anim/check.json create mode 100644 assets/icons-anim/chevron-down.json create mode 100644 assets/icons-anim/chevron-left.json create mode 100644 assets/icons-anim/chevron-right.json create mode 100644 assets/icons-anim/chevron-up.json create mode 100644 assets/icons-anim/circle-check.json create mode 100644 assets/icons-anim/circle-question-mark.json create mode 100644 assets/icons-anim/circle.json create mode 100644 assets/icons-anim/clipboard.json create mode 100644 assets/icons-anim/copy.json create mode 100644 assets/icons-anim/diamond.json create mode 100644 assets/icons-anim/download.json create mode 100644 assets/icons-anim/eraser.json create mode 100644 assets/icons-anim/file-text.json create mode 100644 assets/icons-anim/flame.json create mode 100644 assets/icons-anim/folder.json create mode 100644 assets/icons-anim/git-branch.json create mode 100644 assets/icons-anim/git-commit-horizontal.json create mode 100644 assets/icons-anim/git-fork.json create mode 100644 assets/icons-anim/grid-3x3.json create mode 100644 assets/icons-anim/hand.json create mode 100644 assets/icons-anim/highlighter.json create mode 100644 assets/icons-anim/image.json create mode 100644 assets/icons-anim/index.json create mode 100644 assets/icons-anim/info.json create mode 100644 assets/icons-anim/italic.json create mode 100644 assets/icons-anim/layers.json create mode 100644 assets/icons-anim/layout-dashboard.json create mode 100644 assets/icons-anim/link.json create mode 100644 assets/icons-anim/list.json create mode 100644 assets/icons-anim/lock.json create mode 100644 assets/icons-anim/maximize.json create mode 100644 assets/icons-anim/message-square-quote.json create mode 100644 assets/icons-anim/mouse-pointer-2.json create mode 100644 assets/icons-anim/move-up-right.json create mode 100644 assets/icons-anim/notebook-pen.json create mode 100644 assets/icons-anim/panel-left.json create mode 100644 assets/icons-anim/panel-right.json create mode 100644 assets/icons-anim/pen.json create mode 100644 assets/icons-anim/pencil.json create mode 100644 assets/icons-anim/plus.json create mode 100644 assets/icons-anim/quote.json create mode 100644 assets/icons-anim/refresh-cw.json create mode 100644 assets/icons-anim/scissors.json create mode 100644 assets/icons-anim/search.json create mode 100644 assets/icons-anim/settings.json create mode 100644 assets/icons-anim/slash.json create mode 100644 assets/icons-anim/sparkles.json create mode 100644 assets/icons-anim/square.json create mode 100644 assets/icons-anim/star.json create mode 100644 assets/icons-anim/strikethrough.json create mode 100644 assets/icons-anim/tag.json create mode 100644 assets/icons-anim/trash-2.json create mode 100644 assets/icons-anim/triangle-alert.json create mode 100644 assets/icons-anim/type.json create mode 100644 assets/icons-anim/undo-2.json create mode 100644 assets/icons-anim/upload.json create mode 100644 assets/icons-anim/x.json create mode 100644 assets/icons-anim/zap.json create mode 100644 crates/lottie/src/draw.rs create mode 100644 crates/lottie/src/error.rs create mode 100644 crates/lottie/src/eval.rs create mode 100644 crates/lottie/src/eval_tests.rs create mode 100644 crates/lottie/src/flatten.rs create mode 100644 crates/lottie/src/keyframes.rs create mode 100644 crates/lottie/src/model.rs create mode 100644 crates/lottie/src/parse.rs create mode 100644 crates/lottie/src/properties.rs create mode 100644 crates/lottie/src/shapes.rs create mode 100644 crates/lottie/src/trim.rs create mode 100644 crates/lottie/src/validate.rs create mode 100644 crates/lottie/tests/assets.rs create mode 100644 crates/lottie/tests/compatibility.rs create mode 100644 crates/lottie/tests/fixtures/animated-shackle.json create mode 100644 crates/lottie/tests/fixtures/hierarchy.json create mode 100644 crates/lottie/tests/fixtures/keyframes.json create mode 100644 crates/lottie/tests/fixtures/markers.json create mode 100644 crates/lottie/tests/fixtures/shackle.json create mode 100644 crates/lottie/tests/fixtures/stroke.json create mode 100644 crates/lottie/tests/fixtures/trim.json create mode 100644 crates/lottie/tests/hostile.rs create mode 100644 crates/lottie/tests/metadata.rs create mode 100644 crates/lottie/tests/primitives.rs create mode 100644 crates/lottie/tests/sampling.rs create mode 100644 crates/ui-egui/examples/icon_sheet.rs create mode 100644 crates/ui-egui/src/lottie_icon.rs create mode 100644 crates/ui-egui/src/lottie_icon/files.rs diff --git a/Cargo.lock b/Cargo.lock index 61a6a3e..a750bda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2254,6 +2254,7 @@ dependencies = [ "notescraft-graph", "notescraft-i18n", "notescraft-index", + "notescraft-lottie", "notescraft-markdown", "notescraft-math", "notescraft-templates", diff --git a/assets/icons-anim/arrow-down.json b/assets/icons-anim/arrow-down.json new file mode 100644 index 0000000..6752212 --- /dev/null +++ b/assets/icons-anim/arrow-down.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"arrow-down","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"arrow-down","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[4.0,4.0],[20.0,4.0],[20.0,20.0],[4.0,20.0]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/arrow-up.json b/assets/icons-anim/arrow-up.json new file mode 100644 index 0000000..e3d0015 --- /dev/null +++ b/assets/icons-anim/arrow-up.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"arrow-up","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"arrow-up","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[5.0,12.0],[12.0,5.0],[19.0,12.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[12.0,19.0],[12.0,5.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/bold.json b/assets/icons-anim/bold.json new file mode 100644 index 0000000..02b18d9 --- /dev/null +++ b/assets/icons-anim/bold.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"bold","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"bold","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[6.0,4.0],[13.0,4.0],[13.0,12.0],[6.0,12.0]],"c":true}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[6.0,12.0],[14.0,12.0],[14.0,20.0],[6.0,20.0]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/bookmark.json b/assets/icons-anim/bookmark.json new file mode 100644 index 0000000..43ac235 --- /dev/null +++ b/assets/icons-anim/bookmark.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":12,"w":24,"h":24,"nm":"bookmark","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"bookmark","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[17.0,3.0],[19.0,5.0],[19.0,20.0],[17.504,20.868],[12.992,18.29],[11.008,18.29],[6.496,20.868],[5.0,20.0],[5.0,5.0],[7.0,3.0]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":12,"st":0,"bm":0}],"markers":[{"cm":"off","tm":0,"dr":0},{"cm":"on","tm":12,"dr":0}]} \ No newline at end of file diff --git a/assets/icons-anim/bug.json b/assets/icons-anim/bug.json new file mode 100644 index 0000000..93a7e98 --- /dev/null +++ b/assets/icons-anim/bug.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"bug","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"bug","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[8],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[8],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[12.0,20.0],[12.0,11.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[14.0,7.0],[18.0,11.0],[18.0,14.0],[6.0,14.0],[6.0,11.0],[10.0,7.0]],"c":true}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[14.12,3.88],[16.0,2.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[21.0,21.0],[17.19,17.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[21.0,5.0],[17.45,8.97]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[22.0,13.0],[18.0,13.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[3.0,21.0],[6.81,17.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[3.0,5.0],[6.55,8.97]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[6.0,13.0],[2.0,13.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[8.0,2.0],[9.88,3.88]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[9.0,7.13],[9.0,6.0],[15.0,6.0],[15.0,7.13]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/calendar.json b/assets/icons-anim/calendar.json new file mode 100644 index 0000000..a2a848c --- /dev/null +++ b/assets/icons-anim/calendar.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"calendar","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"calendar","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[8.0,2.0],[8.0,5.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[16.0,2.0],[16.0,5.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[3.0,9.0],[21.0,9.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/check.json b/assets/icons-anim/check.json new file mode 100644 index 0000000..32a91e6 --- /dev/null +++ b/assets/icons-anim/check.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"check","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"check","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[20.0,6.0],[9.0,17.0],[4.0,12.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/chevron-down.json b/assets/icons-anim/chevron-down.json new file mode 100644 index 0000000..4fc4cb5 --- /dev/null +++ b/assets/icons-anim/chevron-down.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"chevron-down","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"chevron-down","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[6.0,9.0],[12.0,15.0],[18.0,9.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/chevron-left.json b/assets/icons-anim/chevron-left.json new file mode 100644 index 0000000..c8c95d8 --- /dev/null +++ b/assets/icons-anim/chevron-left.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"chevron-left","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"chevron-left","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[15.0,18.0],[9.0,12.0],[15.0,6.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/chevron-right.json b/assets/icons-anim/chevron-right.json new file mode 100644 index 0000000..3e09147 --- /dev/null +++ b/assets/icons-anim/chevron-right.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":12,"w":24,"h":24,"nm":"chevron-right","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"chevron-right","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[9.0,18.0],[15.0,12.0],[9.0,6.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":12,"st":0,"bm":0}],"markers":[{"cm":"off","tm":0,"dr":0},{"cm":"on","tm":12,"dr":0}]} \ No newline at end of file diff --git a/assets/icons-anim/chevron-up.json b/assets/icons-anim/chevron-up.json new file mode 100644 index 0000000..99c0b9c --- /dev/null +++ b/assets/icons-anim/chevron-up.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"chevron-up","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"chevron-up","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[18.0,15.0],[12.0,9.0],[6.0,15.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/circle-check.json b/assets/icons-anim/circle-check.json new file mode 100644 index 0000000..5cf4e8d --- /dev/null +++ b/assets/icons-anim/circle-check.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"circle-check","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"circle-check","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[16.0,9.0],[10.5,14.5],[8.0,12.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/circle-question-mark.json b/assets/icons-anim/circle-question-mark.json new file mode 100644 index 0000000..9fc0f67 --- /dev/null +++ b/assets/icons-anim/circle-question-mark.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"circle-question-mark","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"circle-question-mark","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[9.09,9.0],[14.92,10.0],[11.92,13.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0]],"o":[[0,0]],"v":[[12.0,17.0]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/circle.json b/assets/icons-anim/circle.json new file mode 100644 index 0000000..77021f4 --- /dev/null +++ b/assets/icons-anim/circle.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"circle","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"circle","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[4.0,4.0],[20.0,4.0],[20.0,20.0],[4.0,20.0]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/clipboard.json b/assets/icons-anim/clipboard.json new file mode 100644 index 0000000..25dc144 --- /dev/null +++ b/assets/icons-anim/clipboard.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"clipboard","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"clipboard","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[16.0,4.0],[18.0,4.0],[20.0,6.0],[20.0,20.0],[18.0,22.0],[6.0,22.0],[4.0,20.0],[4.0,6.0],[6.0,4.0],[8.0,4.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/copy.json b/assets/icons-anim/copy.json new file mode 100644 index 0000000..d1e37a1 --- /dev/null +++ b/assets/icons-anim/copy.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"copy","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"copy","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[8.0,8.0],[20.0,8.0],[20.0,21.0],[8.0,21.0]],"c":true}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[16.0,8.0],[16.0,3.0],[4.0,3.0],[4.0,16.0],[8.0,16.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/diamond.json b/assets/icons-anim/diamond.json new file mode 100644 index 0000000..4c04d91 --- /dev/null +++ b/assets/icons-anim/diamond.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"diamond","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"diamond","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[2.7,10.3],[2.7,13.71],[10.29,21.3],[13.7,21.3],[21.29,13.71],[21.29,10.3],[13.7,2.71],[10.29,2.71]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/download.json b/assets/icons-anim/download.json new file mode 100644 index 0000000..8a6ab70 --- /dev/null +++ b/assets/icons-anim/download.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"download","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"download","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[12.0,15.0],[12.0,3.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[21.0,15.0],[21.0,19.0],[19.0,21.0],[5.0,21.0],[3.0,19.0],[3.0,15.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[7.0,10.0],[12.0,15.0],[17.0,10.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/eraser.json b/assets/icons-anim/eraser.json new file mode 100644 index 0000000..1dfe852 --- /dev/null +++ b/assets/icons-anim/eraser.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"eraser","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"eraser","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[21.0,21.0],[8.0,21.0],[6.58,20.413],[2.586,16.414],[2.586,13.586],[12.586,3.586],[15.415,3.586],[21.414,9.586],[21.414,12.414],[12.834,21.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[5.082,11.09],[13.91,19.918]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/file-text.json b/assets/icons-anim/file-text.json new file mode 100644 index 0000000..cf208d9 --- /dev/null +++ b/assets/icons-anim/file-text.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"file-text","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"file-text","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[6.0,22.0],[4.0,20.0],[4.0,4.0],[6.0,2.0],[14.0,2.0],[15.704,2.706],[19.292,6.294],[20.0,8.0],[20.0,20.0],[18.0,22.0]],"c":true}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[14.0,2.0],[14.0,7.0],[15.0,8.0],[20.0,8.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[10.0,9.0],[8.0,9.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[16.0,13.0],[8.0,13.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[16.0,17.0],[8.0,17.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/flame.json b/assets/icons-anim/flame.json new file mode 100644 index 0000000..012280e --- /dev/null +++ b/assets/icons-anim/flame.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"flame","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"flame","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[12.0,3.0],[16.0,9.5],[19.0,15.0],[5.0,15.0],[6.0,12.0],[11.0,12.0],[9.5,7.0]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/folder.json b/assets/icons-anim/folder.json new file mode 100644 index 0000000..488a195 --- /dev/null +++ b/assets/icons-anim/folder.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":12,"w":24,"h":24,"nm":"folder","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"folder","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[20.0,20.0],[22.0,18.0],[22.0,8.0],[20.0,6.0],[12.1,6.0],[10.41,5.1],[9.6,3.9],[7.93,3.0],[4.0,3.0],[2.0,5.0],[2.0,18.0],[4.0,20.0]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":12,"st":0,"bm":0}],"markers":[{"cm":"off","tm":0,"dr":0},{"cm":"on","tm":12,"dr":0}]} \ No newline at end of file diff --git a/assets/icons-anim/git-branch.json b/assets/icons-anim/git-branch.json new file mode 100644 index 0000000..462883a --- /dev/null +++ b/assets/icons-anim/git-branch.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"git-branch","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"git-branch","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[8],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[8],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[15.0,6.0],[6.0,15.0],[6.0,3.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/git-commit-horizontal.json b/assets/icons-anim/git-commit-horizontal.json new file mode 100644 index 0000000..c6f04eb --- /dev/null +++ b/assets/icons-anim/git-commit-horizontal.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"git-commit-horizontal","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"git-commit-horizontal","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[4.0,4.0],[20.0,4.0],[20.0,20.0],[4.0,20.0]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/git-fork.json b/assets/icons-anim/git-fork.json new file mode 100644 index 0000000..e0fe045 --- /dev/null +++ b/assets/icons-anim/git-fork.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"git-fork","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"git-fork","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[6.0,3.0],[6.0,15.0],[11.0,20.0],[12.0,20.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[18.0,6.0],[18.0,6.1]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[6.0,3.0],[6.0,3.1]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[18.0,9.0],[18.0,11.0],[13.0,16.0],[12.0,16.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/grid-3x3.json b/assets/icons-anim/grid-3x3.json new file mode 100644 index 0000000..cbcb773 --- /dev/null +++ b/assets/icons-anim/grid-3x3.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"grid-3x3","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"grid-3x3","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[3.0,9.0],[21.0,9.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[3.0,15.0],[21.0,15.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[9.0,3.0],[9.0,21.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[15.0,3.0],[15.0,21.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/hand.json b/assets/icons-anim/hand.json new file mode 100644 index 0000000..f2ed46c --- /dev/null +++ b/assets/icons-anim/hand.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"hand","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"hand","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[18.0,11.0],[18.0,6.0],[16.0,4.0],[14.0,6.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[14.0,10.0],[14.0,4.0],[12.0,2.0],[10.0,4.0],[10.0,6.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[10.0,10.5],[10.0,6.0],[8.0,4.0],[6.0,6.0],[6.0,14.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[18.0,8.0],[22.0,8.0],[22.0,14.0],[14.0,22.0],[12.0,22.0],[6.01,19.66],[2.41,16.06],[5.24,13.24],[7.0,15.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/highlighter.json b/assets/icons-anim/highlighter.json new file mode 100644 index 0000000..7072768 --- /dev/null +++ b/assets/icons-anim/highlighter.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"highlighter","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"highlighter","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[9.0,11.0],[13.0,15.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[5.0,20.0],[14.0,11.0],[19.0,16.0],[10.0,25.0],[5.0,25.0]],"c":true}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[14.0,7.0],[17.0,4.0],[23.0,10.0],[20.0,13.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/image.json b/assets/icons-anim/image.json new file mode 100644 index 0000000..5f96f71 --- /dev/null +++ b/assets/icons-anim/image.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"image","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"image","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.0,15.0],[17.914,11.914],[15.086,11.914],[6.0,21.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/index.json b/assets/icons-anim/index.json new file mode 100644 index 0000000..e852751 --- /dev/null +++ b/assets/icons-anim/index.json @@ -0,0 +1 @@ +{"toggle":["folder","chevron-right","panel-left","panel-right","star","bookmark","lock","sparkles","maximize"],"oneshot":["search","settings","plus","x","check","copy","trash-2","file-text","tag","layers","git-branch","link","list","download","upload","pencil","pen","eraser","hand","mouse-pointer-2","type","square","circle","diamond","move-up-right","slash","image","calendar","clipboard","quote","info","bug","triangle-alert","flame","zap","git-fork","git-commit-horizontal","undo-2","refresh-cw","notebook-pen","layout-dashboard","circle-check","circle-question-mark","bold","italic","strikethrough","highlighter","scissors","message-square-quote","arrow-up","arrow-down","chevron-up","chevron-down","chevron-left","grid-3x3"]} \ No newline at end of file diff --git a/assets/icons-anim/info.json b/assets/icons-anim/info.json new file mode 100644 index 0000000..4e8d04f --- /dev/null +++ b/assets/icons-anim/info.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"info","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"info","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[12.0,16.0],[12.0,12.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0]],"o":[[0,0]],"v":[[12.0,8.0]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/italic.json b/assets/icons-anim/italic.json new file mode 100644 index 0000000..ba9a52c --- /dev/null +++ b/assets/icons-anim/italic.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"italic","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"italic","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[10.0,4.0],[20.0,4.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[4.0,20.0],[14.0,20.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[14.0,4.0],[10.0,20.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/layers.json b/assets/icons-anim/layers.json new file mode 100644 index 0000000..8588b61 --- /dev/null +++ b/assets/icons-anim/layers.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"layers","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"layers","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[12.83,2.18],[11.17,2.18],[2.6,6.08],[2.6,7.91],[11.18,11.82],[12.84,11.82],[21.42,7.92],[21.42,6.09]],"c":true}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[2.0,12.0],[2.58,12.91],[11.18,16.82],[12.83,16.82],[21.41,12.92],[22.0,12.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[2.0,17.0],[2.58,17.91],[11.18,21.82],[12.83,21.82],[21.41,17.92],[22.0,17.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/layout-dashboard.json b/assets/icons-anim/layout-dashboard.json new file mode 100644 index 0000000..b1017f2 --- /dev/null +++ b/assets/icons-anim/layout-dashboard.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"layout-dashboard","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"layout-dashboard","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[4.0,4.0],[20.0,4.0],[20.0,20.0],[4.0,20.0]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/link.json b/assets/icons-anim/link.json new file mode 100644 index 0000000..2fb2798 --- /dev/null +++ b/assets/icons-anim/link.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"link","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"link","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[10.0,13.0],[17.54,13.54],[20.54,10.54],[13.47,3.47],[11.75,5.18]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[14.0,11.0],[6.46,10.46],[3.46,13.46],[10.53,20.53],[12.24,18.82]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/list.json b/assets/icons-anim/list.json new file mode 100644 index 0000000..0a10086 --- /dev/null +++ b/assets/icons-anim/list.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"list","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"list","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0]],"o":[[0,0]],"v":[[3.0,5.0]],"c":true}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0]],"o":[[0,0]],"v":[[3.0,12.0]],"c":true}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0]],"o":[[0,0]],"v":[[3.0,19.0]],"c":true}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[8.0,5.0],[21.0,5.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[8.0,12.0],[21.0,12.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[8.0,19.0],[21.0,19.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/lock.json b/assets/icons-anim/lock.json new file mode 100644 index 0000000..d7039fe --- /dev/null +++ b/assets/icons-anim/lock.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":12,"w":24,"h":24,"nm":"lock","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"lock","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[7.0,11.0],[7.0,7.0],[17.0,7.0],[17.0,11.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":12,"st":0,"bm":0}],"markers":[{"cm":"off","tm":0,"dr":0},{"cm":"on","tm":12,"dr":0}]} \ No newline at end of file diff --git a/assets/icons-anim/maximize.json b/assets/icons-anim/maximize.json new file mode 100644 index 0000000..7d192a1 --- /dev/null +++ b/assets/icons-anim/maximize.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":12,"w":24,"h":24,"nm":"maximize","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"maximize","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[8.0,3.0],[5.0,3.0],[3.0,5.0],[3.0,8.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.0,8.0],[21.0,5.0],[19.0,3.0],[16.0,3.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[3.0,16.0],[3.0,19.0],[5.0,21.0],[8.0,21.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[16.0,21.0],[19.0,21.0],[21.0,19.0],[21.0,16.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":12,"st":0,"bm":0}],"markers":[{"cm":"off","tm":0,"dr":0},{"cm":"on","tm":12,"dr":0}]} \ No newline at end of file diff --git a/assets/icons-anim/message-square-quote.json b/assets/icons-anim/message-square-quote.json new file mode 100644 index 0000000..09cfc1c --- /dev/null +++ b/assets/icons-anim/message-square-quote.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"message-square-quote","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"message-square-quote","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[4.0,4.0],[20.0,4.0],[20.0,17.0],[9.0,17.0],[4.0,21.0]],"c":true}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[8.0,9.0],[11.0,9.0],[11.0,12.0],[9.0,12.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[14.0,9.0],[17.0,9.0],[17.0,12.0],[15.0,12.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/mouse-pointer-2.json b/assets/icons-anim/mouse-pointer-2.json new file mode 100644 index 0000000..e52d22f --- /dev/null +++ b/assets/icons-anim/mouse-pointer-2.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"mouse-pointer-2","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"mouse-pointer-2","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[4.037,4.688],[4.688,4.037],[20.688,10.537],[20.625,11.484],[14.501,13.064],[13.063,14.499],[11.484,20.625],[10.537,20.688]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/move-up-right.json b/assets/icons-anim/move-up-right.json new file mode 100644 index 0000000..0b5538a --- /dev/null +++ b/assets/icons-anim/move-up-right.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"move-up-right","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"move-up-right","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[13.0,5.0],[19.0,5.0],[19.0,11.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[19.0,5.0],[5.0,19.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/notebook-pen.json b/assets/icons-anim/notebook-pen.json new file mode 100644 index 0000000..d5a6438 --- /dev/null +++ b/assets/icons-anim/notebook-pen.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"notebook-pen","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"notebook-pen","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[13.4,2.0],[6.0,2.0],[4.0,4.0],[4.0,20.0],[6.0,22.0],[18.0,22.0],[20.0,20.0],[20.0,12.6]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[2.0,6.0],[6.0,6.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[2.0,10.0],[6.0,10.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[2.0,14.0],[6.0,14.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[2.0,18.0],[6.0,18.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[21.378,5.626],[18.374,2.622],[13.364,7.634],[12.858,8.488],[12.021,11.358],[12.641,11.978],[15.511,11.141],[16.365,10.635]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/panel-left.json b/assets/icons-anim/panel-left.json new file mode 100644 index 0000000..f6185e3 --- /dev/null +++ b/assets/icons-anim/panel-left.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":12,"w":24,"h":24,"nm":"panel-left","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"panel-left","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[9.0,3.0],[9.0,21.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":12,"st":0,"bm":0}],"markers":[{"cm":"off","tm":0,"dr":0},{"cm":"on","tm":12,"dr":0}]} \ No newline at end of file diff --git a/assets/icons-anim/panel-right.json b/assets/icons-anim/panel-right.json new file mode 100644 index 0000000..4ddbd8a --- /dev/null +++ b/assets/icons-anim/panel-right.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":12,"w":24,"h":24,"nm":"panel-right","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"panel-right","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[15.0,3.0],[15.0,21.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":12,"st":0,"bm":0}],"markers":[{"cm":"off","tm":0,"dr":0},{"cm":"on","tm":12,"dr":0}]} \ No newline at end of file diff --git a/assets/icons-anim/pen.json b/assets/icons-anim/pen.json new file mode 100644 index 0000000..f502c71 --- /dev/null +++ b/assets/icons-anim/pen.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"pen","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"pen","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[8],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[8],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[21.174,6.812],[17.188,2.825],[3.842,16.174],[3.342,17.004],[2.021,21.356],[2.644,21.978],[6.997,20.658],[7.827,20.161]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/pencil.json b/assets/icons-anim/pencil.json new file mode 100644 index 0000000..81989cf --- /dev/null +++ b/assets/icons-anim/pencil.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"pencil","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"pencil","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[8],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[8],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[21.174,6.812],[17.188,2.825],[3.842,16.174],[3.342,17.004],[2.021,21.356],[2.644,21.978],[6.997,20.658],[7.827,20.161]],"c":true}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[15.0,5.0],[19.0,9.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/plus.json b/assets/icons-anim/plus.json new file mode 100644 index 0000000..857b33d --- /dev/null +++ b/assets/icons-anim/plus.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"plus","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"plus","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[8],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[8],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[5.0,12.0],[19.0,12.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[12.0,5.0],[12.0,19.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/quote.json b/assets/icons-anim/quote.json new file mode 100644 index 0000000..3b982f8 --- /dev/null +++ b/assets/icons-anim/quote.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"quote","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"quote","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[16.0,3.0],[14.0,5.0],[14.0,11.0],[16.0,13.0],[17.0,14.0],[17.0,15.0],[15.0,17.0],[14.0,18.0],[14.0,20.0],[15.0,21.0],[21.0,15.0],[21.0,5.0],[19.0,3.0]],"c":true}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[5.0,3.0],[3.0,5.0],[3.0,11.0],[5.0,13.0],[6.0,14.0],[6.0,15.0],[4.0,17.0],[3.0,18.0],[3.0,20.0],[4.0,21.0],[10.0,15.0],[10.0,5.0],[8.0,3.0]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/refresh-cw.json b/assets/icons-anim/refresh-cw.json new file mode 100644 index 0000000..1cb74c3 --- /dev/null +++ b/assets/icons-anim/refresh-cw.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"refresh-cw","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"refresh-cw","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[360],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[360]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[3.0,12.0],[12.0,3.0],[18.74,5.74],[21.0,8.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[21.0,3.0],[21.0,8.0],[16.0,8.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[21.0,12.0],[12.0,21.0],[5.26,18.26],[3.0,16.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[8.0,16.0],[3.0,16.0],[3.0,21.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/scissors.json b/assets/icons-anim/scissors.json new file mode 100644 index 0000000..2e55fa2 --- /dev/null +++ b/assets/icons-anim/scissors.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"scissors","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"scissors","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[6.0,6.0],[6.0,6.1]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[6.0,18.0],[6.0,18.1]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[8.0,7.0],[20.0,19.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[8.0,17.0],[13.0,12.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[14.0,10.0],[20.0,4.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/search.json b/assets/icons-anim/search.json new file mode 100644 index 0000000..1009c12 --- /dev/null +++ b/assets/icons-anim/search.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"search","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"search","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[21.0,21.0],[16.66,16.66]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/settings.json b/assets/icons-anim/settings.json new file mode 100644 index 0000000..62656f1 --- /dev/null +++ b/assets/icons-anim/settings.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"settings","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"settings","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[8],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[8],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[9.671,4.136],[14.33,4.136],[17.649,6.051],[19.979,10.084],[19.979,13.915],[17.649,17.948],[14.33,19.863],[9.671,19.863],[6.351,17.948],[4.021,13.915],[4.021,10.084],[6.35,6.051]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/slash.json b/assets/icons-anim/slash.json new file mode 100644 index 0000000..fd2711e --- /dev/null +++ b/assets/icons-anim/slash.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"slash","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"slash","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[22.0,2.0],[2.0,22.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/sparkles.json b/assets/icons-anim/sparkles.json new file mode 100644 index 0000000..405a2ea --- /dev/null +++ b/assets/icons-anim/sparkles.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":12,"w":24,"h":24,"nm":"sparkles","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"sparkles","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[11.017,2.814],[12.983,2.814],[14.034,8.372],[15.628,9.966],[21.186,11.017],[21.186,12.983],[15.628,14.034],[14.034,15.628],[12.983,21.186],[11.017,21.186],[9.966,15.628],[8.372,14.034],[2.814,12.983],[2.814,11.017],[8.372,9.966],[9.966,8.372]],"c":true}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[20.0,2.0],[20.0,6.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[22.0,4.0],[18.0,4.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":12,"st":0,"bm":0}],"markers":[{"cm":"off","tm":0,"dr":0},{"cm":"on","tm":12,"dr":0}]} \ No newline at end of file diff --git a/assets/icons-anim/square.json b/assets/icons-anim/square.json new file mode 100644 index 0000000..4a2c53c --- /dev/null +++ b/assets/icons-anim/square.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"square","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"square","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[4.0,4.0],[20.0,4.0],[20.0,20.0],[4.0,20.0]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/star.json b/assets/icons-anim/star.json new file mode 100644 index 0000000..855e959 --- /dev/null +++ b/assets/icons-anim/star.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":12,"w":24,"h":24,"nm":"star","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"star","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":6.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":12,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[11.525,2.295],[12.475,2.295],[14.785,6.974],[16.38,8.134],[21.546,8.89],[21.84,9.794],[18.104,13.432],[17.493,15.31],[18.375,20.45],[17.604,21.01],[12.986,18.582],[11.013,18.582],[6.396,21.01],[5.626,20.45],[6.507,15.311],[5.896,13.432],[2.16,9.795],[2.454,8.889],[7.619,8.134],[9.216,6.974]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":12,"st":0,"bm":0}],"markers":[{"cm":"off","tm":0,"dr":0},{"cm":"on","tm":12,"dr":0}]} \ No newline at end of file diff --git a/assets/icons-anim/strikethrough.json b/assets/icons-anim/strikethrough.json new file mode 100644 index 0000000..09deb49 --- /dev/null +++ b/assets/icons-anim/strikethrough.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"strikethrough","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"strikethrough","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[4.0,12.0],[20.0,12.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[17.0,7.0],[13.0,4.0],[9.0,4.0],[8.0,11.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[7.0,17.0],[11.0,20.0],[15.0,20.0],[18.0,18.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/tag.json b/assets/icons-anim/tag.json new file mode 100644 index 0000000..b8de264 --- /dev/null +++ b/assets/icons-anim/tag.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"tag","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"tag","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[8],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[8],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[12.586,2.586],[11.172,2.0],[4.0,2.0],[2.0,4.0],[2.0,11.172],[2.586,12.586],[11.29,21.29],[14.71,21.29],[21.29,14.71],[21.29,11.29]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/trash-2.json b/assets/icons-anim/trash-2.json new file mode 100644 index 0000000..851278f --- /dev/null +++ b/assets/icons-anim/trash-2.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"trash-2","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"trash-2","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[3.0,6.0],[21.0,6.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[19.0,6.0],[19.0,20.0],[17.0,22.0],[7.0,22.0],[5.0,20.0],[5.0,6.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[8.0,6.0],[8.0,4.0],[10.0,2.0],[14.0,2.0],[16.0,4.0],[16.0,6.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/triangle-alert.json b/assets/icons-anim/triangle-alert.json new file mode 100644 index 0000000..cc4ca53 --- /dev/null +++ b/assets/icons-anim/triangle-alert.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"triangle-alert","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"triangle-alert","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[8],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[8],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[21.73,18.0],[13.73,4.0],[10.25,4.0],[2.25,18.0],[4.0,21.0],[20.0,21.0]],"c":true}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[12.0,9.0],[12.0,13.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0]],"o":[[0,0]],"v":[[12.0,17.0]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/type.json b/assets/icons-anim/type.json new file mode 100644 index 0000000..09b1540 --- /dev/null +++ b/assets/icons-anim/type.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"type","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"type","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[12.0,4.0],[12.0,20.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[4.0,7.0],[4.0,5.0],[5.0,4.0],[19.0,4.0],[20.0,5.0],[20.0,7.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[9.0,20.0],[15.0,20.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/undo-2.json b/assets/icons-anim/undo-2.json new file mode 100644 index 0000000..23656f5 --- /dev/null +++ b/assets/icons-anim/undo-2.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"undo-2","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"undo-2","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[9.0,14.0],[4.0,9.0],[9.0,4.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[4.0,9.0],[14.5,9.0],[20.0,14.5],[14.5,20.0],[11.0,20.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/upload.json b/assets/icons-anim/upload.json new file mode 100644 index 0000000..b5deaca --- /dev/null +++ b/assets/icons-anim/upload.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"upload","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"upload","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[12.0,3.0],[12.0,15.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[17.0,8.0],[12.0,3.0],[7.0,8.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[21.0,15.0],[21.0,19.0],[19.0,21.0],[5.0,21.0],[3.0,19.0],[3.0,15.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/x.json b/assets/icons-anim/x.json new file mode 100644 index 0000000..f5b3379 --- /dev/null +++ b/assets/icons-anim/x.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"x","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"x","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[8],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[8],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[18.0,6.0],[6.0,18.0]],"c":false}},"nm":"Path"},{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[6.0,6.0],[18.0,18.0]],"c":false}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/assets/icons-anim/zap.json b/assets/icons-anim/zap.json new file mode 100644 index 0000000..b2120a7 --- /dev/null +++ b/assets/icons-anim/zap.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":60,"ip":0,"op":18,"w":24,"h":24,"nm":"zap","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"zap","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[0],"e":[0],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[0]}]},"p":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":1,"k":[{"t":0,"s":[100,100,100],"e":[102,102,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":9.0,"s":[102,102,100],"e":[100,100,100],"i":{"x":[0.42],"y":[0]},"o":{"x":[0.58],"y":[1]}},{"t":18,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[15.914,4.0],[13.44,2.439],[4.44,11.439],[5.5,14.0],[9.502,14.0],[9.973,14.666],[8.086,20.0],[10.561,21.56],[19.561,12.56],[18.5,10.0],[14.503,10.0],[14.031,9.333]],"c":true}},"nm":"Path"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.75},"lc":2,"lj":2,"nm":"Stroke"},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}],"nm":"Icon"}],"ip":0,"op":18,"st":0,"bm":0}],"markers":[{"cm":"play","tm":0,"dr":18}]} \ No newline at end of file diff --git a/crates/lottie/src/draw.rs b/crates/lottie/src/draw.rs new file mode 100644 index 0000000..577f08f --- /dev/null +++ b/crates/lottie/src/draw.rs @@ -0,0 +1,77 @@ +//! Renderer-independent draw list, in animation pixel coordinates. + +/// Stroke endpoint decoration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Cap { + /// Flat endpoint. + Butt, + /// Semicircular endpoint. + Round, + /// Square projecting endpoint. + Square, +} + +/// Stroke corner decoration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Join { + /// Extended corner. + Miter, + /// Circular corner. + Round, + /// Clipped corner. + Bevel, +} + +/// Flattened path in animation pixels. +#[derive(Debug, Clone, PartialEq)] +pub struct Polyline { + /// Vertices in traversal order; a closed path has an implicit closing edge. + pub points: Vec<[f32; 2]>, + /// Whether the final vertex connects to the first. + pub closed: bool, +} + +/// Host-tinted stroke geometry. +#[derive(Debug, Clone, PartialEq)] +pub struct StrokePath { + /// Paths sharing this stroke. + pub polylines: Vec, + /// Stroke width in animation pixels, scaled by the composed transform's + /// geometric mean scale (including reflections and nonuniform scales). + pub width: f32, + /// Endpoint decoration. + pub cap: Cap, + /// Corner decoration. + pub join: Join, + /// Combined layer, group and stroke opacity in `0..=1`. + pub opacity: f32, +} + +/// Host-tinted fill geometry. +#[derive(Debug, Clone, PartialEq)] +pub struct FillPath { + /// Paths sharing this fill. + pub polylines: Vec, + /// Combined layer, group and fill opacity in `0..=1`. + pub opacity: f32, +} + +/// One painter operation. +#[derive(Debug, Clone, PartialEq)] +pub enum Item { + /// Stroke the paths. + Stroke(StrokePath), + /// Fill the paths using the nonzero winding rule. + Fill(FillPath), +} + +/// Evaluated draw list, in painter order (bottom first). +#[derive(Debug, Clone, PartialEq)] +pub struct Frame { + /// Animation width in pixels. + pub width: f32, + /// Animation height in pixels. + pub height: f32, + /// Painter operations. + pub items: Vec, +} diff --git a/crates/lottie/src/error.rs b/crates/lottie/src/error.rs new file mode 100644 index 0000000..4261e80 --- /dev/null +++ b/crates/lottie/src/error.rs @@ -0,0 +1,39 @@ +//! Structured failures at the untrusted JSON boundary. + +/// A malformed, unsupported, or over-budget animation. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The source exceeds the 512 KiB limit. + #[error("lottie: source exceeds 512 KiB")] + TooLarge, + /// The document is not valid JSON. + #[error("lottie: invalid JSON: {message}")] + Json { + /// JSON decoder diagnostic. + message: String, + }, + /// A required value has the wrong type or range. + #[error("lottie: invalid {what} at {at}")] + Invalid { + /// Property or violated invariant. + what: String, + /// Layer name/index and property path. + at: String, + }, + /// A feature or resource budget lies outside the supported subset. + #[error("lottie: unsupported {what} at {at}")] + Unsupported { + /// Unsupported feature or exceeded resource budget. + what: String, + /// Layer name/index and property path. + at: String, + }, +} + +pub(crate) fn invalid(what: impl Into, at: &str) -> Error { + Error::Invalid { what: what.into(), at: at.to_owned() } +} + +pub(crate) fn unsupported(what: impl Into, at: &str) -> Error { + Error::Unsupported { what: what.into(), at: at.to_owned() } +} diff --git a/crates/lottie/src/eval.rs b/crates/lottie/src/eval.rs new file mode 100644 index 0000000..95a6871 --- /dev/null +++ b/crates/lottie/src/eval.rs @@ -0,0 +1,175 @@ +//! Evaluates validated shape layers into renderer-independent paths. + +use std::ops::Range; + +use crate::model::{Layer, Matrix, Position, Shape, Transform}; +use crate::{Animation, FillPath, Frame, Item, Polyline, StrokePath, flatten, trim}; + +const PERCENT: f32 = 100.0; +const MAX_PARENT_DEPTH: usize = 64; + +struct Geometry { + paths: Vec, + opacity: f32, + scale: f32, +} + +struct Paint<'a> { + shape: &'a Shape, + geometry: Range, +} + +#[derive(Default)] +struct Scene<'a> { + geometry: Vec, + paints: Vec>, +} + +pub(crate) fn frame(animation: &Animation, requested: f32) -> Frame { + let time = if requested.is_finite() { requested.clamp(animation.first, animation.last) } else { animation.first }; + let mut output = Frame { width: animation.width, height: animation.height, items: Vec::new() }; + for layer in animation.layers.iter().rev() { + // `op` is exclusive, but the animation's own last frame must still show the layers that run to the end. + let ends_here = time >= animation.last && layer.last >= animation.last; + if layer.hidden || time < layer.first || (time >= layer.last && !ends_here) { + continue; + } + let local_time = time - layer.start; + let (local, opacity) = transform(&layer.transform, local_time); + let matrix = parents(animation, layer, time).then(local); + let mut scene = Scene::default(); + scene.collect(&layer.shapes, local_time, matrix, opacity); + scene.paint(local_time, &mut output.items); + } + output +} + +fn parents(animation: &Animation, layer: &Layer, time: f32) -> Matrix { + let mut chain = Vec::new(); + let mut parent_id = layer.parent; + for _ in 0..MAX_PARENT_DEPTH { + let Some(parent) = parent_id.and_then(|id| animation.layers.iter().find(|candidate| candidate.id == id)) else { + break; + }; + chain.push(parent); + parent_id = parent.parent; + } + chain.into_iter().rev().fold(Matrix::IDENTITY, |matrix, parent| matrix.then(transform(&parent.transform, time - parent.start).0)) +} + +fn transform(input: &Transform, time: f32) -> (Matrix, f32) { + let [ax, ay] = pair(&input.anchor.at(time)); + let [px, py] = match &input.position { + Position::Combined(position) => pair(&position.at(time)), + Position::Split(x, y) => [x.scalar(time), y.scalar(time)], + }; + let [sx, sy] = pair(&input.scale.at(time)).map(|value| value / PERCENT); + let (sin, cos) = input.rotation.scalar(time).to_radians().sin_cos(); + let [a, b, c, d] = [cos * sx, sin * sx, -sin * sy, cos * sy]; + (Matrix([a, b, c, d, px - a * ax - c * ay, py - b * ax - d * ay]), input.opacity.scalar(time) / PERCENT) +} + +fn pair(values: &[f32]) -> [f32; 2] { + let mut values = values.iter().copied(); + [values.next().unwrap_or_default(), values.next().unwrap_or_default()] +} + +impl<'a> Scene<'a> { + fn collect(&mut self, shapes: &'a [Shape], time: f32, matrix: Matrix, opacity: f32) { + let first = self.geometry.len(); + for shape in shapes { + let bezier = match shape { + Shape::Path(path) => path.at(time), + Shape::Rectangle { position, size, radius, reversed } => { + let mut path = flatten::rectangle(pair(&position.at(time)), pair(&size.at(time)), radius.scalar(time)); + if *reversed { + reverse(&mut path); + } + path + } + Shape::Ellipse { position, size, reversed } => { + let mut path = flatten::ellipse(pair(&position.at(time)), pair(&size.at(time))); + if *reversed { + reverse(&mut path); + } + path + } + Shape::Group(children, input) => { + let (local, alpha) = transform(input, time); + self.collect(children, time, matrix.then(local), opacity * alpha); + continue; + } + Shape::Stroke { .. } | Shape::Fill { .. } => { + self.paints.push(Paint { shape, geometry: first..self.geometry.len() }); + continue; + } + Shape::Trim { start, end, offset } => { + self.trim(first, start.scalar(time), end.scalar(time), offset.scalar(time)); + continue; + } + }; + let [a, b, c, d, _, _] = matrix.0; + self.geometry.push(Geometry { paths: vec![flatten::path(&bezier, matrix)], opacity, scale: (a * d - b * c).abs().sqrt() }); + } + } + + fn trim(&mut self, first: usize, start: f32, end: f32, offset: f32) { + for geometry in self.geometry.iter_mut().skip(first) { + let paths = std::mem::take(&mut geometry.paths).into_iter().enumerate().collect(); + geometry.paths = trim::apply_tagged(paths, start, end, offset).into_iter().map(|(_, path)| path).collect(); + } + } + + fn paint(&self, time: f32, items: &mut Vec) { + for paint in self.paints.iter().rev() { + let is_fill = matches!(paint.shape, Shape::Fill { .. }); + for batch in self + .geometry + .get(paint.geometry.clone()) + .unwrap_or_default() + .chunk_by(|a, b| a.opacity.to_bits() == b.opacity.to_bits() && (is_fill || a.scale.to_bits() == b.scale.to_bits())) + { + let Some(geometry) = batch.first() else { + continue; + }; + if geometry.opacity <= 0.0 { + continue; + } + let polylines: Vec<_> = batch.iter().flat_map(|geometry| geometry.paths.iter().cloned()).collect(); + if polylines.is_empty() { + continue; + } + let item = match paint.shape { + Shape::Stroke { width, opacity, cap, join } => { + let width = width.scalar(time) * geometry.scale; + let opacity = opacity.scalar(time) / PERCENT * geometry.opacity; + if width <= 0.0 || opacity <= 0.0 { + continue; + } + Item::Stroke(StrokePath { polylines, width, opacity, cap: *cap, join: *join }) + } + Shape::Fill { opacity } => { + let opacity = opacity.scalar(time) / PERCENT * geometry.opacity; + if opacity <= 0.0 { + continue; + } + Item::Fill(FillPath { polylines, opacity }) + } + _ => continue, + }; + items.push(item); + } + } + } +} + +fn reverse(path: &mut crate::model::Bezier) { + path.vertices.reverse(); + path.incoming.reverse(); + path.outgoing.reverse(); + std::mem::swap(&mut path.incoming, &mut path.outgoing); +} + +#[cfg(test)] +#[path = "eval_tests.rs"] +mod tests; diff --git a/crates/lottie/src/eval_tests.rs b/crates/lottie/src/eval_tests.rs new file mode 100644 index 0000000..0c2c57a --- /dev/null +++ b/crates/lottie/src/eval_tests.rs @@ -0,0 +1,137 @@ +//! Evaluation regression tests. + +use super::*; +use crate::model::{Key, Position, Track}; + +fn track(values: &[f32]) -> Track { + Track { keys: vec![Key { time: 0.0, value: values.to_vec(), incoming: Vec::new(), outgoing: Vec::new(), hold: false }] } +} + +fn identity() -> Transform { + Transform { + anchor: track(&[0.0, 0.0]), + position: Position::Combined(track(&[0.0, 0.0])), + scale: track(&[100.0, 100.0]), + rotation: track(&[0.0]), + opacity: track(&[100.0]), + } +} + +fn rectangle() -> Shape { + Shape::Rectangle { position: track(&[0.0, 0.0]), size: track(&[20.0, 10.0]), radius: track(&[0.0]), reversed: false } +} + +fn layer(id: i64, shapes: Vec) -> Layer { + Layer { id, parent: None, first: 0.0, last: 20.0, start: 0.0, hidden: false, transform: identity(), shapes } +} + +fn animation(layers: Vec) -> Animation { + Animation { width: 100.0, height: 100.0, rate: 30.0, first: 0.0, last: 20.0, layers, markers: Vec::new() } +} + +#[test] +fn transform_applies_anchor_scale_rotation_then_position() { + let mut input = identity(); + input.anchor = track(&[1.0, 2.0]); + input.scale = track(&[200.0, 300.0]); + input.rotation = track(&[90.0]); + input.position = Position::Combined(track(&[10.0, 20.0])); + input.opacity = track(&[50.0]); + let (matrix, opacity) = transform(&input, 0.0); + let [x, y] = matrix.apply([2.0, 3.0]); + assert!((x - 7.0).abs() < 0.0001); + assert!((y - 22.0).abs() < 0.0001); + assert!((opacity - 0.5).abs() < f32::EPSILON); +} + +#[test] +fn group_and_style_opacities_multiply() { + let mut group = identity(); + group.opacity = track(&[50.0]); + let mut input = layer(1, vec![Shape::Group(vec![rectangle()], group), Shape::Fill { opacity: track(&[50.0]) }]); + input.transform.opacity = track(&[50.0]); + let output = frame(&animation(vec![input]), 0.0); + let Some(Item::Fill(fill)) = output.items.first() else { + panic!("expected fill"); + }; + assert!((fill.opacity - 0.125).abs() < f32::EPSILON); +} + +#[test] +fn later_trim_also_affects_earlier_style() { + let input = + layer(1, vec![rectangle(), Shape::Fill { opacity: track(&[100.0]) }, Shape::Trim { start: track(&[0.0]), end: track(&[0.0]), offset: track(&[0.0]) }]); + assert_eq!(frame(&animation(vec![input]), 0.0).items, Vec::::new()); +} + +#[test] +fn layer_interval_excludes_out_point_and_hidden_layers() { + let mut input = layer(1, vec![rectangle(), Shape::Fill { opacity: track(&[100.0]) }]); + input.last = 10.0; + let mut hidden = input.clone(); + hidden.hidden = true; + assert_eq!(frame(&animation(vec![input, hidden]), 10.0).items, Vec::::new()); +} + +#[test] +fn parent_transform_composes_without_parent_opacity() { + let mut parent = layer(1, Vec::new()); + parent.transform.position = Position::Combined(track(&[30.0, 40.0])); + parent.transform.opacity = track(&[0.0]); + let mut child = layer(2, vec![rectangle(), Shape::Fill { opacity: track(&[100.0]) }]); + child.parent = Some(1); + let output = frame(&animation(vec![parent, child]), 0.0); + let Some(Item::Fill(fill)) = output.items.first() else { + panic!("expected child fill"); + }; + assert!(fill.polylines.iter().flat_map(|line| &line.points).all(|point| point.first().is_some_and(|x| *x >= 20.0))); + assert!((fill.opacity - 1.0).abs() < f32::EPSILON); +} + +#[test] +fn parent_chain_composes_in_validated_order_without_intermediate_overflow() { + let mut outer = layer(0, Vec::new()); + outer.transform.scale = track(&[1e-42, 1e-42]); + let mut layers = vec![outer]; + for id in 1..=11 { + let shapes = if id == 11 { vec![rectangle(), Shape::Fill { opacity: track(&[100.0]) }] } else { Vec::new() }; + let mut child = layer(id, shapes); + child.parent = Some(id - 1); + child.transform.scale = track(&[1_000_000.0, 1_000_000.0]); + layers.push(child); + } + let animation = animation(layers); + assert!(crate::validate::animation(&animation).is_ok()); + let output = frame(&animation, 0.0); + assert!(output.items.iter().all(|item| match item { + Item::Fill(fill) => fill.polylines.iter().flat_map(|line| line.points.iter().flatten()).all(|value| value.is_finite()), + Item::Stroke(stroke) => stroke.polylines.iter().flat_map(|line| line.points.iter().flatten()).all(|value| value.is_finite()), + })); +} + +#[test] +fn later_styles_paint_below_earlier_styles() { + let input = layer( + 1, + vec![ + rectangle(), + Shape::Stroke { width: track(&[2.0]), opacity: track(&[100.0]), cap: crate::Cap::Butt, join: crate::Join::Miter }, + Shape::Fill { opacity: track(&[100.0]) }, + ], + ); + let output = frame(&animation(vec![input]), 0.0); + assert!(matches!(output.items.as_slice(), [Item::Fill(_), Item::Stroke(_)])); +} + +#[test] +fn simultaneous_trim_applies_percentage_to_each_original_shape() { + let wider = Shape::Rectangle { position: track(&[0.0, 0.0]), size: track(&[40.0, 10.0]), radius: track(&[0.0]), reversed: false }; + let input = layer( + 1, + vec![rectangle(), wider, Shape::Trim { start: track(&[0.0]), end: track(&[50.0]), offset: track(&[0.0]) }, Shape::Fill { opacity: track(&[100.0]) }], + ); + let output = frame(&animation(vec![input]), 0.0); + let Some(Item::Fill(fill)) = output.items.first() else { panic!("expected fill") }; + assert_eq!(fill.polylines.len(), 2); + assert!(fill.polylines.iter().all(|line| line.points.last().is_some_and(|[_, y]| (*y - 5.0).abs() < 0.001))); +} diff --git a/crates/lottie/src/flatten.rs b/crates/lottie/src/flatten.rs new file mode 100644 index 0000000..e0704c0 --- /dev/null +++ b/crates/lottie/src/flatten.rs @@ -0,0 +1,215 @@ +//! Cubic path flattening and primitive shape construction. + +use crate::Polyline; +use crate::model::{Bezier, Matrix}; + +const TOLERANCE: f64 = 0.05; +const ELLIPSE_TANGENT: f32 = 0.552_284_8; +const MAX_SUBDIVISION_DEPTH: u32 = 256; +type Cubic = [[f32; 2]; 4]; + +/// Flattens a validated path to at most 0.05 transformed pixels of cubic error. +pub(crate) fn path(shape: &Bezier, matrix: Matrix) -> Polyline { + let mut points = Vec::new(); + let scale = matrix.scale(); + if let Some(first) = shape.vertices.first() { + points.push(matrix.apply(*first)); + } + for curve in curves(shape) { + let remaining = depth(curve, scale); + let mut pending = vec![(curve.map(|point| matrix.apply(point)), remaining)]; + while let Some((curve, remaining)) = pending.pop() { + let [a, b, c, d] = curve; + if remaining == 0 || distance(b, a, d).max(distance(c, a, d)) <= TOLERANCE { + points.push(d); + continue; + } + let ab = midpoint(a, b); + let bc = midpoint(b, c); + let cd = midpoint(c, d); + let abc = midpoint(ab, bc); + let bcd = midpoint(bc, cd); + let center = midpoint(abc, bcd); + pending.push(([center, bcd, cd, d], remaining - 1)); + pending.push(([a, ab, abc, center], remaining - 1)); + } + } + Polyline { points, closed: shape.closed } +} + +/// Bounds output points for a transform whose operator norm is at most `scale`. +pub(crate) fn estimate(shape: &Bezier, scale: f32) -> usize { + curves(shape) + .fold(usize::from(!shape.vertices.is_empty()), |total, curve| total.saturating_add(1_usize.checked_shl(depth(curve, scale)).unwrap_or(usize::MAX))) +} + +fn curves(shape: &Bezier) -> impl Iterator + '_ { + let nodes = shape.vertices.iter().zip(&shape.incoming).zip(&shape.outgoing).map(|((vertex, incoming), outgoing)| (*vertex, *incoming, *outgoing)); + let final_node = nodes.clone().next().filter(|_| shape.closed); + nodes.clone().zip(nodes.skip(1).chain(final_node)).map(|((a, _, outgoing), (d, incoming, _))| [a, add(a, outgoing), add(d, incoming), d]) +} + +fn depth([a, b, c, d]: Cubic, scale: f32) -> u32 { + let second = |[ax, ay]: [f32; 2], [bx, by]: [f32; 2], [cx, cy]: [f32; 2]| { + (f64::from(ax) - 2.0 * f64::from(bx) + f64::from(cx)).hypot(f64::from(ay) - 2.0 * f64::from(by) + f64::from(cy)) + }; + // Linear interpolation error is bounded by max |B''| / 8. + let mut error = 0.75 * second(a, b, c).max(second(b, c, d)) * f64::from(scale.abs()); + let mut depth = 0; + while error > TOLERANCE && depth < MAX_SUBDIVISION_DEPTH { + error *= 0.25; + depth += 1; + } + depth +} + +fn add([ax, ay]: [f32; 2], [bx, by]: [f32; 2]) -> [f32; 2] { + [ax + bx, ay + by] +} + +fn midpoint([ax, ay]: [f32; 2], [bx, by]: [f32; 2]) -> [f32; 2] { + [ax * 0.5 + bx * 0.5, ay * 0.5 + by * 0.5] +} + +fn distance([px, py]: [f32; 2], [ax, ay]: [f32; 2], [bx, by]: [f32; 2]) -> f64 { + let dx = f64::from(bx) - f64::from(ax); + let dy = f64::from(by) - f64::from(ay); + let x = f64::from(px) - f64::from(ax); + let y = f64::from(py) - f64::from(ay); + let length = dx * dx + dy * dy; + let t = if length == 0.0 { 0.0 } else { ((x * dx + y * dy) / length).clamp(0.0, 1.0) }; + (x - dx * t).hypot(y - dy * t) +} + +/// Creates a clockwise rectangle, limiting corner radius to its half dimensions. +pub(crate) fn rectangle([x, y]: [f32; 2], [width, height]: [f32; 2], radius: f32) -> Bezier { + let left = x - width * 0.5; + let right = x + width * 0.5; + let top = y - height * 0.5; + let bottom = y + height * 0.5; + let r = radius.min(width.abs() * 0.5).min(height.abs() * 0.5); + if r == 0.0 { + return Bezier { + closed: true, + vertices: vec![[left, top], [right, top], [right, bottom], [left, bottom]], + incoming: vec![[0.0; 2]; 4], + outgoing: vec![[0.0; 2]; 4], + }; + } + let tangent = r * ELLIPSE_TANGENT; + Bezier { + closed: true, + vertices: vec![ + [left + r, top], + [right - r, top], + [right, top + r], + [right, bottom - r], + [right - r, bottom], + [left + r, bottom], + [left, bottom - r], + [left, top + r], + ], + incoming: vec![[-tangent, 0.0], [0.0; 2], [0.0, -tangent], [0.0; 2], [tangent, 0.0], [0.0; 2], [0.0, tangent], [0.0; 2]], + outgoing: vec![[0.0; 2], [tangent, 0.0], [0.0; 2], [0.0, tangent], [0.0; 2], [-tangent, 0.0], [0.0; 2], [0.0, -tangent]], + } +} + +/// Creates the standard four-cubic clockwise Lottie ellipse. +pub(crate) fn ellipse([x, y]: [f32; 2], [width, height]: [f32; 2]) -> Bezier { + let rx = width * 0.5; + let ry = height * 0.5; + let tx = rx * ELLIPSE_TANGENT; + let ty = ry * ELLIPSE_TANGENT; + Bezier { + closed: true, + vertices: vec![[x, y - ry], [x + rx, y], [x, y + ry], [x - rx, y]], + incoming: vec![[-tx, 0.0], [0.0, -ty], [tx, 0.0], [0.0, ty]], + outgoing: vec![[tx, 0.0], [0.0, ty], [-tx, 0.0], [0.0, -ty]], + } +} + +#[cfg(test)] +mod tests { + use super::{ellipse, estimate, path, rectangle}; + use crate::model::{Bezier, Matrix}; + + fn identity() -> Matrix { + Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]) + } + + #[test] + fn straight_path_keeps_endpoints_and_transform() { + let shape = Bezier { closed: false, vertices: vec![[0.0, 0.0], [10.0, 0.0]], incoming: vec![[0.0; 2]; 2], outgoing: vec![[0.0; 2]; 2] }; + let result = path(&shape, Matrix([2.0, 0.0, 0.0, 2.0, 3.0, 4.0])); + assert_eq!(result.points, vec![[3.0, 4.0], [23.0, 4.0]]); + assert!(!result.closed); + } + + #[test] + fn curved_path_deviates_less_than_tolerance() { + let shape = Bezier { + closed: false, + vertices: vec![[0.0, 0.0], [100.0, 0.0]], + incoming: vec![[0.0, 0.0], [0.0, 100.0]], + outgoing: vec![[0.0, 100.0], [0.0, 0.0]], + }; + let result = path(&shape, identity()); + assert!(result.points.len() > 10); + assert!(estimate(&shape, 1.0) >= result.points.len()); + for step in 0..=1000 { + let parameter = f64::from(step) / 1000.0; + let x = 100.0 * parameter * parameter * (3.0 - 2.0 * parameter); + let y = 300.0 * parameter * (1.0 - parameter); + let distance = result + .points + .windows(2) + .map(|pair| { + let [a, b] = pair else { + return f64::INFINITY; + }; + let dx = f64::from(b[0] - a[0]); + let dy = f64::from(b[1] - a[1]); + let fraction = (((x - f64::from(a[0])) * dx + (y - f64::from(a[1])) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0); + (x - f64::from(a[0]) - dx * fraction).hypot(y - f64::from(a[1]) - dy * fraction) + }) + .fold(f64::INFINITY, f64::min); + assert!(distance <= 0.05, "deviation {distance}"); + } + } + + #[test] + fn collinear_cubic_preserves_reversal() { + let shape = + Bezier { closed: false, vertices: vec![[0.0; 2], [1.0, 0.0]], incoming: vec![[0.0; 2], [-101.0, 0.0]], outgoing: vec![[100.0, 0.0], [0.0; 2]] }; + let result = path(&shape, identity()); + assert!(result.points.iter().any(|[x, _]| *x > 20.0)); + assert!(result.points.iter().any(|[x, _]| *x < -20.0)); + } + + #[test] + fn cancelling_translation_retains_subpixel_curve_precision() { + let mut shape = Bezier { + closed: false, + vertices: vec![[0.0, 0.0], [100.0, 0.0]], + incoming: vec![[0.0, 0.0], [0.0, 100.0]], + outgoing: vec![[0.0, 100.0], [0.0, 0.0]], + }; + let expected = path(&shape, identity()); + for vertex in &mut shape.vertices { + *vertex = vertex.map(|coordinate| coordinate + 10_000_000.0); + } + let translated = path(&shape, Matrix([1.0, 0.0, 0.0, 1.0, -10_000_000.0, -10_000_000.0])); + assert_eq!(translated.points, expected.points); + } + + #[test] + fn shapes_have_expected_extents_and_closed_edges() { + for shape in [rectangle([10.0, 20.0], [40.0, 20.0], 4.0), ellipse([10.0, 20.0], [40.0, 20.0])] { + let result = path(&shape, identity()); + assert!(result.closed); + assert!(result.points.iter().all(|[x, y]| (-10.0..=30.0).contains(x) && (10.0..=30.0).contains(y))); + assert_eq!(result.points.first(), result.points.last()); + assert!(estimate(&shape, 1.0) >= result.points.len()); + } + } +} diff --git a/crates/lottie/src/keyframes.rs b/crates/lottie/src/keyframes.rs new file mode 100644 index 0000000..c08fbbb --- /dev/null +++ b/crates/lottie/src/keyframes.rs @@ -0,0 +1,123 @@ +//! Bounded temporal easing and property interpolation. + +use crate::model::{Bezier, PathTrack, Track}; + +impl Track { + pub(crate) fn constant(values: &[f32]) -> Self { + Self { keys: vec![crate::model::Key { time: 0.0, value: values.to_vec(), incoming: vec![[1.0, 1.0]], outgoing: vec![[0.0, 0.0]], hold: false }] } + } + + pub(crate) fn at(&self, frame: f32) -> Vec { + let Some(first) = self.keys.first() else { return Vec::new() }; + let mut previous = first; + for next in self.keys.iter().skip(1) { + if frame < next.time { + if previous.hold || frame <= previous.time { + return previous.value.clone(); + } + let progress = (frame - previous.time) / (next.time - previous.time); + return previous + .value + .iter() + .zip(&next.value) + .enumerate() + .map(|(dimension, (&start, &end))| { + let outgoing = previous.outgoing.get(dimension).or_else(|| previous.outgoing.first()).copied().unwrap_or([0.0, 0.0]); + let incoming = next.incoming.get(dimension).or_else(|| next.incoming.first()).copied().unwrap_or([1.0, 1.0]); + start + (end - start) * ease(progress, outgoing, incoming) + }) + .collect(); + } + previous = next; + } + previous.value.clone() + } + + pub(crate) fn scalar(&self, frame: f32) -> f32 { + self.at(frame).first().copied().unwrap_or(0.0) + } + + pub(crate) fn bounds(&self) -> Vec<[f32; 2]> { + let Some(first) = self.keys.first() else { return Vec::new() }; + let mut bounds: Vec<_> = first.value.iter().map(|&value| [value, value]).collect(); + for key in &self.keys { + for (bound, &value) in bounds.iter_mut().zip(&key.value) { + bound[0] = bound[0].min(value); + bound[1] = bound[1].max(value); + } + } + for pair in self.keys.windows(2) { + let Some((previous, tail)) = pair.split_first() else { continue }; + let Some(next) = tail.first() else { continue }; + if previous.hold { + continue; + } + for (dimension, ((bound, &start), &end)) in bounds.iter_mut().zip(&previous.value).zip(&next.value).enumerate() { + let outgoing = previous.outgoing.get(dimension).or_else(|| previous.outgoing.first()).copied().unwrap_or([0.0, 0.0]); + let incoming = next.incoming.get(dimension).or_else(|| next.incoming.first()).copied().unwrap_or([1.0, 1.0]); + for weight in [0.0_f32.min(outgoing[1]).min(incoming[1]), 1.0_f32.max(outgoing[1]).max(incoming[1])] { + let value = start + (end - start) * weight; + bound[0] = bound[0].min(value); + bound[1] = bound[1].max(value); + } + } + } + bounds + } + + pub(crate) fn magnitude(&self) -> f32 { + self.bounds().iter().flatten().fold(0.0_f32, |maximum, value| maximum.max(value.abs())) + } +} + +impl PathTrack { + pub(crate) fn at(&self, frame: f32) -> Bezier { + let values = self.track.at(frame); + let mut path = Bezier { closed: self.closed, vertices: Vec::new(), incoming: Vec::new(), outgoing: Vec::new() }; + for vertex in values.as_chunks::<6>().0 { + let [x, y, ix, iy, ox, oy] = *vertex; + path.vertices.push([x, y]); + path.incoming.push([ix, iy]); + path.outgoing.push([ox, oy]); + } + path + } +} + +fn cubic(parameter: f32, first: f32, second: f32) -> f32 { + let inverse = 1.0 - parameter; + 3.0 * inverse * inverse * parameter * first + 3.0 * inverse * parameter * parameter * second + parameter * parameter * parameter +} + +fn ease(progress: f32, outgoing: [f32; 2], incoming: [f32; 2]) -> f32 { + let mut lower = 0.0; + let mut upper = 1.0; + // Bisection remains stable for flat derivatives and all monotone timing curves. + for _ in 0..24 { + let middle = f32::midpoint(lower, upper); + if cubic(middle, outgoing[0], incoming[0]) < progress { + lower = middle; + } else { + upper = middle; + } + } + cubic(f32::midpoint(lower, upper), outgoing[1], incoming[1]) +} + +#[cfg(test)] +mod tests { + use crate::model::{Key, Track}; + + #[test] + fn easing_and_hold_use_adjacent_keys() { + let first = Key { time: 0.0, value: vec![0.0], incoming: vec![[0.0, 0.0]], outgoing: vec![[0.5, 0.0]], hold: false }; + let last = Key { time: 10.0, value: vec![10.0], incoming: vec![[0.5, 1.0]], outgoing: vec![[1.0, 1.0]], hold: false }; + let mut track = Track { keys: vec![first, last] }; + assert!((track.scalar(5.0) - 5.0).abs() < 0.001); + if let Some(first) = track.keys.first_mut() { + first.hold = true; + } + assert_eq!(track.scalar(9.0), 0.0); + assert_eq!(track.scalar(10.0), 10.0); + } +} diff --git a/crates/lottie/src/lib.rs b/crates/lottie/src/lib.rs index 40728fa..ac1ef92 100644 --- a/crates/lottie/src/lib.rs +++ b/crates/lottie/src/lib.rs @@ -1,11 +1,111 @@ -//! Lottie subset player (TODO: filled in by the lottie-crate task). +//! Bounded, pure-data Lottie shape animation player for host-tinted UI icons. +//! +//! Spatial position tangents (`ti`/`to`) are ignored: positions interpolate +//! linearly in space, with the specified temporal Bezier easing. Colours are +//! validated but discarded so the host can tint every stroke and fill. +//! Parent layers contribute transforms, while opacity comes from the drawn +//! layer, its shape groups, and each paint item. Stroke widths use the geometric +//! mean of the composed horizontal and vertical scales. +//! +//! Input numbers have magnitude at most one million; composed geometry and +//! transforms have a further numeric budget. Point estimates are conservative +//! over all keyframes and may reject animations whose sampled frames are smaller. +//! The 0.05 pixel cubic tessellation tolerance is subject to `f32` rounding. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -#[cfg(test)] -mod tests { - #[test] - fn package_name_is_correct() { - assert_eq!(env!("CARGO_PKG_NAME"), "notescraft-lottie"); +mod draw; +mod error; +mod eval; +mod flatten; +mod keyframes; +mod model; +mod parse; +mod properties; +mod shapes; +mod trim; +mod validate; + +pub use draw::{Cap, FillPath, Frame, Item, Join, Polyline, StrokePath}; +pub use error::Error; + +/// Crate result type. +pub type Result = std::result::Result; + +/// Named timeline interval, measured in absolute composition frames. +#[derive(Debug, Clone, PartialEq)] +pub struct Marker { + /// Marker name. + pub name: String, + /// Absolute starting frame. + pub frame: f32, + /// Duration in frames. + pub duration: f32, +} + +/// Parsed and validated animation, with a conservative per-frame geometry budget. +#[derive(Debug, Clone)] +pub struct Animation { + pub(crate) width: f32, + pub(crate) height: f32, + pub(crate) rate: f32, + pub(crate) first: f32, + pub(crate) last: f32, + pub(crate) layers: Vec, + pub(crate) markers: Vec, +} + +impl Animation { + /// Parse Bodymovin v5 JSON, rejecting unsupported features and invalid input. + /// + /// # Errors + /// Returns [`Error::TooLarge`] above 512 KiB, [`Error::Json`] for malformed + /// JSON, [`Error::Invalid`] for invalid values, and [`Error::Unsupported`] + /// for features outside this subset or resource budgets. + pub fn parse(json: &str) -> Result { + parse::animation(json) + } + + /// Canvas width and height in pixels. + #[must_use] + pub fn size(&self) -> (f32, f32) { + (self.width, self.height) + } + + /// Timeline frames per second. + #[must_use] + pub fn frame_rate(&self) -> f32 { + self.rate + } + + /// First absolute timeline frame. + #[must_use] + pub fn first_frame(&self) -> f32 { + self.first + } + + /// Last absolute timeline frame (the Lottie out point). + #[must_use] + pub fn last_frame(&self) -> f32 { + self.last + } + + /// Timeline duration in seconds. + #[must_use] + pub fn duration_secs(&self) -> f32 { + (self.last - self.first) / self.rate + } + + /// Look up a named marker. + #[must_use] + pub fn marker(&self, name: &str) -> Option { + self.markers.iter().find(|marker| marker.name == name).cloned() + } + + /// Evaluate an absolute frame, clamped to the timeline bounds. + /// Non-finite frame arguments evaluate the first frame. + #[must_use] + pub fn frame_at(&self, frame: f32) -> Frame { + eval::frame(self, frame) } } diff --git a/crates/lottie/src/model.rs b/crates/lottie/src/model.rs new file mode 100644 index 0000000..91477ff --- /dev/null +++ b/crates/lottie/src/model.rs @@ -0,0 +1,94 @@ +//! Validated animation data and affine transforms. + +use crate::{Cap, Join}; + +#[derive(Debug, Clone)] +pub(crate) struct Track { + pub keys: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct Key { + pub time: f32, + pub value: Vec, + pub incoming: Vec<[f32; 2]>, + pub outgoing: Vec<[f32; 2]>, + pub hold: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct Bezier { + pub closed: bool, + pub vertices: Vec<[f32; 2]>, + pub incoming: Vec<[f32; 2]>, + pub outgoing: Vec<[f32; 2]>, +} + +#[derive(Debug, Clone)] +pub(crate) struct PathTrack { + pub track: Track, + pub closed: bool, +} + +#[derive(Debug, Clone)] +pub(crate) enum Position { + Combined(Track), + Split(Track, Track), +} + +#[derive(Debug, Clone)] +pub(crate) struct Transform { + pub anchor: Track, + pub position: Position, + pub scale: Track, + pub rotation: Track, + pub opacity: Track, +} + +#[derive(Debug, Clone)] +pub(crate) enum Shape { + Group(Vec, Transform), + Path(PathTrack), + Rectangle { position: Track, size: Track, radius: Track, reversed: bool }, + Ellipse { position: Track, size: Track, reversed: bool }, + Stroke { width: Track, opacity: Track, cap: Cap, join: Join }, + Fill { opacity: Track }, + Trim { start: Track, end: Track, offset: Track }, +} + +#[derive(Debug, Clone)] +pub(crate) struct Layer { + pub id: i64, + pub parent: Option, + pub first: f32, + pub last: f32, + pub start: f32, + pub hidden: bool, + pub transform: Transform, + pub shapes: Vec, +} + +/// Affine matrix `[a, b, c, d, tx, ty]`. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Matrix(pub [f32; 6]); + +impl Matrix { + pub const IDENTITY: Self = Self([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + + pub fn apply(self, [x, y]: [f32; 2]) -> [f32; 2] { + let [xx, yx, xy, yy, tx, ty] = self.0; + [xx * x + xy * y + tx, yx * x + yy * y + ty] + } + + pub fn then(self, child: Self) -> Self { + let [xx, yx, xy, yy, tx, ty] = self.0; + let [cxx, cyx, cxy, cyy, ux, uy] = child.0; + Self([xx * cxx + xy * cyx, yx * cxx + yy * cyx, xx * cxy + xy * cyy, yx * cxy + yy * cyy, xx * ux + xy * uy + tx, yx * ux + yy * uy + ty]) + } + + pub fn scale(self) -> f32 { + let [a, b, c, d, _, _] = self.0; + // Frobenius norm bounds the largest singular value. + (a * a + b * b + c * c + d * d).sqrt() + } +} diff --git a/crates/lottie/src/parse.rs b/crates/lottie/src/parse.rs new file mode 100644 index 0000000..5c47cbc --- /dev/null +++ b/crates/lottie/src/parse.rs @@ -0,0 +1,115 @@ +//! Document and layer parsing with precise feature diagnostics. + +use crate::error::{invalid, unsupported}; +use crate::model::Layer; +use crate::properties::{array, fields, flag, integer, num, number, required, transform}; +use crate::{Animation, Error, Marker, Result, shapes, validate}; +use serde_json::Value; + +const MAX_BYTES: usize = 512 * 1024; +const MAX_LAYERS: usize = 64; + +pub(crate) fn animation(json: &str) -> Result { + if json.len() > MAX_BYTES { + return Err(Error::TooLarge); + } + let document: Value = serde_json::from_str(json).map_err(|error| Error::Json { message: error.to_string() })?; + fields(&document, &["v", "fr", "ip", "op", "w", "h", "layers", "markers", "nm", "ddd", "assets"], "document")?; + if num(&document, "ddd", 0.0, "document")? != 0.0 { + return Err(unsupported("3D (ddd)", "document")); + } + if let Some(assets) = document.get("assets") + && !array(assets, "document.assets")?.is_empty() + { + return Err(unsupported("assets", "document")); + } + let version = required(&document, "v", "document")?.as_str().ok_or_else(|| invalid("version string", "document.v"))?; + if !version.starts_with("5.") { + return Err(unsupported("version other than Bodymovin v5", "document.v")); + } + let width = number(required(&document, "w", "document")?, "document.w")?; + let height = number(required(&document, "h", "document")?, "document.h")?; + let rate = number(required(&document, "fr", "document")?, "document.fr")?; + let first = number(required(&document, "ip", "document")?, "document.ip")?; + let last = number(required(&document, "op", "document")?, "document.op")?; + if width <= 0.0 || height <= 0.0 || rate <= 0.0 || last <= first || !((last - first) / rate).is_finite() { + return Err(invalid("positive dimensions/rate and ordered timeline", "document")); + } + let source_layers = array(required(&document, "layers", "document")?, "document.layers")?; + if source_layers.len() > MAX_LAYERS { + return Err(unsupported("more than 64 layers", "document.layers")); + } + let mut vertices = 0; + let mut layers = Vec::with_capacity(source_layers.len()); + for (index, value) in source_layers.iter().enumerate() { + let name = value.get("nm").and_then(Value::as_str).unwrap_or("unnamed"); + let at = format!("layer[{index}] ({name})"); + layers.push(layer(value, index, first, last, &mut vertices, &at)?); + } + let mut markers = Vec::new(); + if let Some(source) = document.get("markers") { + for (index, value) in array(source, "document.markers")?.iter().enumerate() { + let at = format!("marker[{index}]"); + fields(value, &["cm", "tm", "dr"], &at)?; + let name = required(value, "cm", &at)?.as_str().ok_or_else(|| invalid("marker name", &at))?.to_owned(); + if markers.iter().any(|marker: &Marker| marker.name == name) { + return Err(invalid("duplicate marker name", &at)); + } + let frame = number(required(value, "tm", &at)?, &at)?; + let duration = number(required(value, "dr", &at)?, &at)?; + if duration < 0.0 { + return Err(invalid("negative marker duration", &at)); + } + markers.push(Marker { name, frame, duration }); + } + } + let animation = Animation { width, height, rate, first, last, layers, markers }; + validate::animation(&animation)?; + Ok(animation) +} + +fn layer(value: &Value, index: usize, first: f32, last: f32, vertices: &mut usize, at: &str) -> Result { + for key in ["tt", "td", "ef", "masksProperties"] { + if value.get(key).is_some() { + return Err(unsupported(key, at)); + } + } + if let Some(has_mask) = value.get("hasMask") + && flag(has_mask, at)? + { + return Err(unsupported("hasMask masks", at)); + } + fields(value, &["ty", "ks", "ip", "op", "st", "ind", "parent", "hd", "nm", "mn", "ddd", "sr", "shapes", "ao", "bm", "hasMask"], at)?; + if num(value, "ddd", 0.0, at)? != 0.0 { + return Err(unsupported("3D (ddd)", at)); + } + if !matches!(num(value, "sr", 1.0, at)?, 1.0) { + return Err(unsupported("time stretch other than 1", at)); + } + if num(value, "ao", 0.0, at)? != 0.0 { + return Err(unsupported("auto orientation", at)); + } + if num(value, "bm", 0.0, at)? != 0.0 { + return Err(unsupported("blend mode", at)); + } + let kind = integer(required(value, "ty", at)?, at)?; + if ![3, 4].contains(&kind) { + return Err(unsupported(format!("layer type {kind}"), at)); + } + let id = value.get("ind").map_or_else(|| i64::try_from(index).map_err(|_| invalid("layer index", at)), |value| integer(value, at))?; + let parent = value.get("parent").map(|value| integer(value, at)).transpose()?; + let layer_first = num(value, "ip", first, at)?; + let layer_last = num(value, "op", last, at)?; + if layer_last <= layer_first { + return Err(invalid("layer timing", at)); + } + let transform = transform(required(value, "ks", at)?, &format!("{at}.ks"), false)?; + let hidden = value.get("hd").map_or(Ok(false), |value| flag(value, at))?; + let shapes = match (kind, value.get("shapes")) { + (4, Some(source)) => shapes::parse(source, 0, vertices, at)?, + (4, None) => return Err(invalid("missing shapes", at)), + (_, Some(source)) if !array(source, at)?.is_empty() => return Err(unsupported("shapes on null layer", at)), + _ => Vec::new(), + }; + Ok(Layer { id, parent, first: layer_first, last: layer_last, start: num(value, "st", 0.0, at)?, hidden, transform, shapes }) +} diff --git a/crates/lottie/src/properties.rs b/crates/lottie/src/properties.rs new file mode 100644 index 0000000..2bed5e4 --- /dev/null +++ b/crates/lottie/src/properties.rs @@ -0,0 +1,218 @@ +//! Strict property decoding and numeric boundary validation. + +use crate::Result; +use crate::error::{invalid, unsupported}; +use crate::model::{Key, Position, Track, Transform}; +use serde_json::{Map, Value}; + +const NUMBER_LIMIT: f32 = 1_000_000.0; + +pub(crate) fn object<'a>(value: &'a Value, at: &str) -> Result<&'a Map> { + value.as_object().ok_or_else(|| invalid("object", at)) +} + +pub(crate) fn array<'a>(value: &'a Value, at: &str) -> Result<&'a Vec> { + value.as_array().ok_or_else(|| invalid("array", at)) +} + +pub(crate) fn required<'a>(value: &'a Value, key: &str, at: &str) -> Result<&'a Value> { + value.get(key).ok_or_else(|| invalid(format!("missing {key}"), at)) +} + +pub(crate) fn number(value: &Value, at: &str) -> Result { + if !value.is_number() { + return Err(invalid("number", at)); + } + let number = value.to_string().parse::().map_err(|_| invalid("finite number", at))?; + if !number.is_finite() || number.abs() > NUMBER_LIMIT { + return Err(invalid("number out of range", at)); + } + Ok(number) +} + +pub(crate) fn num(value: &Value, key: &str, default: f32, at: &str) -> Result { + value.get(key).map_or(Ok(default), |value| number(value, &format!("{at}.{key}"))) +} + +pub(crate) fn integer(value: &Value, at: &str) -> Result { + value.as_i64().ok_or_else(|| invalid("integer", at)) +} + +pub(crate) fn flag(value: &Value, at: &str) -> Result { + value.as_bool().ok_or_else(|| invalid("boolean", at)) +} + +pub(crate) fn fields(value: &Value, allowed: &[&str], at: &str) -> Result<()> { + for key in object(value, at)?.keys() { + if !allowed.contains(&key.as_str()) { + return Err(unsupported(key, at)); + } + } + Ok(()) +} + +pub(crate) fn values(value: &Value, dimensions: usize, at: &str) -> Result> { + if dimensions == 1 && value.is_number() { + return Ok(vec![number(value, at)?]); + } + let components = array(value, at)?; + if components.len() != dimensions { + return Err(invalid("component count", at)); + } + components.iter().map(|component| number(component, at)).collect() +} + +fn plane(value: &Value, neutral: f32, at: &str) -> Result> { + let components = array(value, at)?; + if ![2, 3].contains(&components.len()) { + return Err(invalid("2D vector component count", at)); + } + if components.len() == 3 { + let z = number(components.last().ok_or_else(|| invalid("z component", at))?, at)?; + if (z - neutral).abs() > 0.0 { + return Err(unsupported("3D vector component", at)); + } + } + components.iter().take(2).map(|component| number(component, at)).collect() +} + +pub(crate) fn track(value: &Value, dimensions: usize, at: &str, spatial: bool) -> Result { + read_track(value, at, spatial, |value, at| values(value, dimensions, at)) +} + +pub(crate) fn read_track(value: &Value, at: &str, spatial: bool, mut read: impl FnMut(&Value, &str) -> Result>) -> Result { + fields(value, &["a", "k", "ix", "l"], at)?; + let animated = value.get("a").map_or(Ok(0), |value| integer(value, at))?; + let data = required(value, "k", at)?; + if animated == 0 { + return Ok(Track::constant(&read(data, at)?)); + } + if animated != 1 { + return Err(invalid("animation flag", at)); + } + let frames = array(data, at)?; + if frames.is_empty() { + return Err(invalid("empty keyframes", at)); + } + let mut keys: Vec = Vec::with_capacity(frames.len()); + let mut endpoint: Option> = None; + for (index, frame) in frames.iter().enumerate() { + let location = format!("{at}.key[{index}]"); + fields(frame, &["t", "s", "e", "i", "o", "h", "ti", "to"], &location)?; + let time = number(required(frame, "t", &location)?, &location)?; + let components = match frame.get("s") { + Some(value) => read(value, &location)?, + None if index.saturating_add(1) == frames.len() => endpoint.clone().ok_or_else(|| invalid("missing terminal value", &location))?, + None => return Err(invalid("missing s", &location)), + }; + if let Some(expected) = endpoint.take() + && expected.iter().map(|value| value.to_bits()).ne(components.iter().map(|value| value.to_bits())) + { + return Err(unsupported("discontinuous keyframe endpoint", &location)); + } + endpoint = frame.get("e").map(|value| read(value, &location)).transpose()?; + if let Some(previous) = keys.last() + && (time <= previous.time || components.len() != previous.value.len()) + { + return Err(invalid("keyframe order or component count", &location)); + } + for tangent in ["ti", "to"] { + if let Some(value) = frame.get(tangent) { + if !spatial { + return Err(unsupported(format!("spatial tangent {tangent}"), &location)); + } + if components.len() == 2 { + plane(value, 0.0, &location)?; + } else { + values(value, components.len(), &location)?; + } + } + } + let hold = match num(frame, "h", 0.0, &location)? { + 0.0 => false, + 1.0 => true, + _ => return Err(invalid("hold flag", &location)), + }; + let incoming = control(frame.get("i"), [1.0, 1.0], components.len(), &location)?; + let outgoing = control(frame.get("o"), [0.0, 0.0], components.len(), &location)?; + keys.push(Key { time, value: components, incoming, outgoing, hold }); + } + if endpoint.is_some() { + return Err(unsupported("terminal endpoint without a following keyframe", at)); + } + Ok(Track { keys }) +} + +fn control(value: Option<&Value>, default: [f32; 2], dimensions: usize, at: &str) -> Result> { + let Some(value) = value else { + return Ok(vec![default]); + }; + fields(value, &["x", "y"], at)?; + let axis = |key: &str| -> Result> { + let value = required(value, key, at)?; + if value.is_number() { + return Ok(vec![number(value, at)?]); + } + let values = array(value, at)?; + if values.len() != 1 && values.len() != dimensions { + return Err(invalid("easing component count", at)); + } + values.iter().map(|value| number(value, at)).collect() + }; + let xs = axis("x")?; + let ys = axis("y")?; + if xs.is_empty() || ys.is_empty() { + return Err(invalid("empty easing", at)); + } + let mut points = Vec::new(); + for dimension in 0..xs.len().max(ys.len()) { + let x = xs.get(dimension).or_else(|| xs.first()).copied().ok_or_else(|| invalid("easing x", at))?; + let y = ys.get(dimension).or_else(|| ys.first()).copied().ok_or_else(|| invalid("easing y", at))?; + if !(0.0..=1.0).contains(&x) { + return Err(invalid("easing x outside 0..1", at)); + } + points.push([x, y]); + } + Ok(points) +} + +pub(crate) fn property(value: &Value, key: &str, default: &[f32], at: &str, spatial: bool) -> Result { + value.get(key).map_or_else(|| Ok(Track::constant(default)), |value| track(value, default.len(), &format!("{at}.{key}"), spatial)) +} + +fn vector(value: &Value, key: &str, default: &[f32], neutral: f32, at: &str, spatial: bool) -> Result { + value + .get(key) + .map_or_else(|| Ok(Track::constant(default)), |value| read_track(value, &format!("{at}.{key}"), spatial, |value, at| plane(value, neutral, at))) +} + +pub(crate) fn range(track: &Track, lower: f32, upper: f32, at: &str) -> Result<()> { + if track.bounds().iter().any(|&[minimum, maximum]| minimum < lower || maximum > upper || !minimum.is_finite() || !maximum.is_finite()) { + return Err(invalid(format!("property outside {lower}..{upper}"), at)); + } + Ok(()) +} + +pub(crate) fn transform(value: &Value, at: &str, group: bool) -> Result { + let mut allowed = vec!["a", "p", "s", "r", "o", "ix"]; + if group { + allowed.extend(["ty", "nm", "mn", "hd", "np", "cix"]); + } + fields(value, &allowed, at)?; + let position = match value.get("p") { + Some(position) if position.get("s").and_then(Value::as_bool) == Some(true) => { + fields(position, &["s", "x", "y", "ix"], at)?; + Position::Split(track(required(position, "x", at)?, 1, at, true)?, track(required(position, "y", at)?, 1, at, true)?) + } + _ => Position::Combined(vector(value, "p", &[0.0, 0.0], 0.0, at, true)?), + }; + let opacity = property(value, "o", &[100.0], at, false)?; + range(&opacity, 0.0, 100.0, at)?; + Ok(Transform { + anchor: vector(value, "a", &[0.0, 0.0], 0.0, at, false)?, + position, + scale: vector(value, "s", &[100.0, 100.0], 100.0, at, false)?, + rotation: property(value, "r", &[0.0], at, false)?, + opacity, + }) +} diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs new file mode 100644 index 0000000..0a9b962 --- /dev/null +++ b/crates/lottie/src/shapes.rs @@ -0,0 +1,196 @@ +//! Shape decoding, topology checks, and nesting limits. + +use crate::error::{invalid, unsupported}; +use crate::model::{PathTrack, Shape}; +use crate::properties::{array, fields, flag, num, number, property, range, read_track, required, transform, values}; +use crate::{Cap, Join, Result}; +use serde_json::Value; + +const MAX_DEPTH: usize = 8; +const MAX_VERTICES: usize = 4096; + +pub(crate) fn parse(value: &Value, depth: usize, vertices: &mut usize, at: &str) -> Result> { + if depth > MAX_DEPTH { + return Err(unsupported("shape nesting deeper than 8", at)); + } + let source = array(value, at)?; + if let Some((last, children)) = source.split_last() + && last.get("ty").and_then(Value::as_str) == Some("tr") + { + let transform = transform(last, at, true)?; + let children = parse(&Value::Array(children.to_vec()), depth.saturating_add(1), vertices, at)?; + return Ok(vec![Shape::Group(children, transform)]); + } + let mut shapes = Vec::new(); + for (index, value) in source.iter().enumerate() { + let location = format!("{at}.shape[{index}]"); + let kind = required(value, "ty", &location)?.as_str().ok_or_else(|| invalid("shape type", &location))?; + let shape = match kind { + "gr" => group(value, depth, vertices, &location)?, + "sh" => { + check_fields(value, &["ks", "ind"], &location)?; + let path = path(required(value, "ks", &location)?, &location)?; + let count = path.track.keys.first().map_or(0, |key| key.value.len() / 6); + add_vertices(vertices, count, &location)?; + Shape::Path(path) + } + "rc" => { + check_fields(value, &["p", "s", "r", "d"], &location)?; + add_vertices(vertices, 8, &location)?; + let size = property(value, "s", &[0.0, 0.0], &location, false)?; + let radius = property(value, "r", &[0.0], &location, false)?; + range(&size, 0.0, 1_000_000.0, &location)?; + range(&radius, 0.0, 1_000_000.0, &location)?; + Shape::Rectangle { position: property(value, "p", &[0.0, 0.0], &location, true)?, size, radius, reversed: direction(value, &location)? } + } + "el" => { + check_fields(value, &["p", "s", "d"], &location)?; + add_vertices(vertices, 4, &location)?; + let size = property(value, "s", &[0.0, 0.0], &location, false)?; + range(&size, 0.0, 1_000_000.0, &location)?; + Shape::Ellipse { position: property(value, "p", &[0.0, 0.0], &location, true)?, size, reversed: direction(value, &location)? } + } + "st" => { + check_fields(value, &["w", "lc", "lj", "o", "c"], &location)?; + colour(required(value, "c", &location)?, &location)?; + let cap = match num(value, "lc", 1.0, &location)? { + 1.0 => Cap::Butt, + 2.0 => Cap::Round, + 3.0 => Cap::Square, + _ => return Err(invalid("stroke cap", &location)), + }; + let join = match num(value, "lj", 1.0, &location)? { + 1.0 => Join::Miter, + 2.0 => Join::Round, + 3.0 => Join::Bevel, + _ => return Err(invalid("stroke join", &location)), + }; + let width = property(value, "w", &[1.0], &location, false)?; + range(&width, 0.0, 1_000_000.0, &location)?; + Shape::Stroke { width, opacity: opacity(value, &location)?, cap, join } + } + "fl" => { + check_fields(value, &["o", "c", "r"], &location)?; + if !matches!(num(value, "r", 1.0, &location)?, 1.0) { + return Err(unsupported("even-odd fill rule", &location)); + } + colour(required(value, "c", &location)?, &location)?; + Shape::Fill { opacity: opacity(value, &location)? } + } + "tm" => { + check_fields(value, &["s", "e", "o", "m"], &location)?; + if !matches!(num(value, "m", 1.0, &location)?, 1.0) { + return Err(unsupported("trim mode other than simultaneous (m=1)", &location)); + } + let start = property(value, "s", &[0.0], &location, false)?; + let end = property(value, "e", &[100.0], &location, false)?; + range(&start, 0.0, 100.0, &location)?; + range(&end, 0.0, 100.0, &location)?; + Shape::Trim { start, end, offset: property(value, "o", &[0.0], &location, false)? } + } + "tr" => return Err(unsupported("transform outside final group position", &location)), + other => return Err(unsupported(format!("shape {other}"), &location)), + }; + if !value.get("hd").map_or(Ok(false), |value| flag(value, &location))? { + shapes.push(shape); + } + } + Ok(shapes) +} + +fn group(value: &Value, depth: usize, vertices: &mut usize, at: &str) -> Result { + check_fields(value, &["it", "np", "cix", "bm"], at)?; + if num(value, "bm", 0.0, at)? != 0.0 { + return Err(unsupported("blend mode", at)); + } + let items = array(required(value, "it", at)?, at)?; + let Some((last, children)) = items.split_last() else { + return Err(invalid("group requires final transform", at)); + }; + if last.get("ty").and_then(Value::as_str) != Some("tr") { + return Err(invalid("group requires final transform", at)); + } + let transform = transform(last, at, true)?; + let children = parse(&Value::Array(children.to_vec()), depth.saturating_add(1), vertices, at)?; + Ok(Shape::Group(children, transform)) +} + +fn check_fields(value: &Value, extra: &[&str], at: &str) -> Result<()> { + let mut allowed = vec!["ty", "nm", "mn", "ix", "hd"]; + allowed.extend(extra); + fields(value, &allowed, at) +} + +fn direction(value: &Value, at: &str) -> Result { + match num(value, "d", 1.0, at)? { + 1.0 | 2.0 => Ok(false), + 3.0 => Ok(true), + _ => Err(invalid("path direction", at)), + } +} + +fn add_vertices(vertices: &mut usize, count: usize, at: &str) -> Result<()> { + *vertices = vertices.saturating_add(count); + if *vertices > MAX_VERTICES { + return Err(unsupported("more than 4096 vertices", at)); + } + Ok(()) +} + +fn opacity(value: &Value, at: &str) -> Result { + let opacity = property(value, "o", &[100.0], at, false)?; + range(&opacity, 0.0, 100.0, at)?; + Ok(opacity) +} + +fn colour(value: &Value, at: &str) -> Result<()> { + // Lottie exports either RGB or RGBA. Both are validated, then host-tinted. + let colour = read_track(value, at, false, |value, at| { + let channels = array(value, at)?; + if ![3, 4].contains(&channels.len()) { + return Err(invalid("RGB/RGBA colour", at)); + } + channels.iter().map(|value| number(value, at)).collect() + })?; + range(&colour, 0.0, 1.0, at) +} + +fn path(value: &Value, at: &str) -> Result { + let mut closed = None; + let mut count = None; + let track = read_track(value, at, false, |value, at| { + let data = if value.is_array() { + let values = array(value, at)?; + if values.len() != 1 { + return Err(invalid("path keyframe", at)); + } + values.first().ok_or_else(|| invalid("path keyframe", at))? + } else { + value + }; + fields(data, &["c", "v", "i", "o"], at)?; + let is_closed = flag(required(data, "c", at)?, at)?; + let vertices = array(required(data, "v", at)?, at)?; + let incoming = array(required(data, "i", at)?, at)?; + let outgoing = array(required(data, "o", at)?, at)?; + if vertices.len() > MAX_VERTICES { + return Err(unsupported("more than 4096 vertices", at)); + } + if vertices.is_empty() || vertices.len() != incoming.len() || vertices.len() != outgoing.len() { + return Err(invalid("path vertex/tangent count", at)); + } + if closed.is_some_and(|previous| previous != is_closed) || count.is_some_and(|previous| previous != vertices.len()) { + return Err(invalid("path keyframe topology", at)); + } + closed = Some(is_closed); + count = Some(vertices.len()); + let mut coordinates = Vec::with_capacity(vertices.len().saturating_mul(6)); + for ((vertex, incoming), outgoing) in vertices.iter().zip(incoming).zip(outgoing) { + coordinates.extend(values(vertex, 2, at)?); + coordinates.extend(values(incoming, 2, at)?); + coordinates.extend(values(outgoing, 2, at)?); + } + Ok(coordinates) + })?; + Ok(PathTrack { track, closed: closed.unwrap_or(false) }) +} diff --git a/crates/lottie/src/trim.rs b/crates/lottie/src/trim.rs new file mode 100644 index 0000000..9b51a0b --- /dev/null +++ b/crates/lottie/src/trim.rs @@ -0,0 +1,125 @@ +//! Arc-length trim paths applied simultaneously across shape contours. + +use crate::Polyline; + +/// Trims normalized percentages over the combined length of every contour. +#[cfg(test)] +pub(crate) fn apply(paths: Vec, start: f32, end: f32, offset: f32) -> Vec { + apply_tagged(paths.into_iter().enumerate().collect(), start, end, offset).into_iter().map(|(_, path)| path).collect() +} + +/// Preserves caller tags when trimming splits contours into visible fragments. +pub(crate) fn apply_tagged(paths: Vec<(usize, Polyline)>, start: f32, end: f32, offset: f32) -> Vec<(usize, Polyline)> { + let span = (end - start).abs() / 100.0; + if span >= 1.0 { + return paths; + } + if span == 0.0 { + return Vec::new(); + } + let total = paths.iter().map(|(_, path)| length(path)).sum::(); + if total == 0.0 { + return Vec::new(); + } + let start = (start.min(end) / 100.0 + offset / 360.0).rem_euclid(1.0) * total; + let end = start + span * total; + let intervals = [(start, end.min(total)), (0.0, (end - total).max(0.0))]; + let mut result = Vec::new(); + let mut position = 0.0; + for (tag, path) in paths { + let path_length = length(&path); + let mut pieces = Vec::new(); + for (from, to) in intervals { + let from = from.max(position); + let to = to.min(position + path_length); + if from < to { + pieces.push(piece(&path, from - position, to - position)); + } + } + if path.closed && pieces.len() == 2 { + let head = pieces.pop(); + if let (Some(tail), Some(head)) = (pieces.last_mut(), head) { + tail.points.extend(head.points.into_iter().skip(1)); + } + } + result.extend(pieces.into_iter().map(|piece| (tag, piece))); + position += path_length; + } + result +} + +fn edges(path: &Polyline) -> impl Iterator + '_ { + let closure = path.points.first().filter(|first| path.closed && Some(*first) != path.points.last()); + path.points.iter().zip(path.points.iter().skip(1).chain(closure)).map(|(a, b)| (*a, *b)) +} + +fn distance([ax, ay]: [f32; 2], [bx, by]: [f32; 2]) -> f32 { + (bx - ax).hypot(by - ay) +} + +fn length(path: &Polyline) -> f32 { + edges(path).map(|(a, b)| distance(a, b)).sum() +} + +fn piece(path: &Polyline, start: f32, end: f32) -> Polyline { + let mut points = Vec::new(); + let mut position = 0.0; + for (a, b) in edges(path) { + let length = distance(a, b); + let from = start.max(position); + let to = end.min(position + length); + if from < to { + let first = interpolate(a, b, (from - position) / length); + if points.last() != Some(&first) { + points.push(first); + } + points.push(interpolate(a, b, (to - position) / length)); + } + position += length; + if position >= end { + break; + } + } + Polyline { points, closed: false } +} + +fn interpolate([ax, ay]: [f32; 2], [bx, by]: [f32; 2], t: f32) -> [f32; 2] { + [ax + (bx - ax) * t, ay + (by - ay) * t] +} + +#[cfg(test)] +mod tests { + use super::apply; + use crate::Polyline; + + fn line(start: f32, end: f32) -> Polyline { + Polyline { points: vec![[start, 0.0], [end, 0.0]], closed: false } + } + + #[test] + fn trims_exactly_by_length_across_paths() { + let result = apply(vec![line(0.0, 10.0), line(20.0, 50.0)], 12.5, 75.0, 0.0); + assert_eq!(result.len(), 2); + assert_eq!(result.first().map(|p| &p.points), Some(&vec![[5.0, 0.0], [10.0, 0.0]])); + assert_eq!(result.last().map(|p| &p.points), Some(&vec![[20.0, 0.0], [40.0, 0.0]])); + } + + #[test] + fn offset_wraps_around_closed_contour() { + let square = Polyline { points: vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]], closed: true }; + let result = apply(vec![square], 0.0, 50.0, 270.0); + assert_eq!(result.len(), 1); + assert_eq!(result.first().map(|p| &p.points), Some(&vec![[0.0, 10.0], [0.0, 0.0], [10.0, 0.0]])); + assert!(result.iter().all(|p| !p.closed)); + } + + #[test] + fn full_trim_retains_closed_path_and_zero_trim_removes_it() { + let mut full = line(0.0, 10.0); + full.closed = true; + let result = apply(vec![full], 0.0, 100.0, 23.0); + assert!(result.first().is_some_and(|p| p.closed)); + assert_eq!(apply(vec![line(0.0, 10.0)], 50.0, 50.0, 0.0), Vec::::new()); + assert_eq!(apply(Vec::new(), 0.0, 50.0, 0.0), Vec::::new()); + } +} diff --git a/crates/lottie/src/validate.rs b/crates/lottie/src/validate.rs new file mode 100644 index 0000000..e3a5933 --- /dev/null +++ b/crates/lottie/src/validate.rs @@ -0,0 +1,175 @@ +//! Conservative animation-wide bounds, parent validation, and output budgets. + +use crate::error::{invalid, unsupported}; +use crate::model::{PathTrack, Position, Shape, Transform}; +use crate::{Animation, Result}; + +const MAX_POINTS: usize = 20_000; +const MAX_MAGNITUDE: f32 = 1_000_000_000.0; +const FLATNESS: f64 = 0.05; + +#[derive(Clone, Copy)] +struct Bounds { + scale: f32, + extent: f32, +} + +impl Bounds { + fn transform(self, transform: &Transform, at: &str) -> Result { + let local_scale = transform.scale.magnitude() / 100.0; + let position = match &transform.position { + Position::Combined(position) => position.magnitude(), + Position::Split(x, y) => x.magnitude().max(y.magnitude()), + }; + let scale = self.scale * local_scale; + let extent = self.extent + self.scale * (position + local_scale * transform.anchor.magnitude()) * 2.0; + if !scale.is_finite() || !extent.is_finite() || scale > MAX_MAGNITUDE || extent > MAX_MAGNITUDE { + return Err(unsupported("transform numeric budget", at)); + } + Ok(Self { scale, extent }) + } +} + +pub(crate) fn animation(animation: &Animation) -> Result<()> { + let mut total = 0_usize; + for (index, layer) in animation.layers.iter().enumerate() { + let at = format!("layer[{index}] (ind={})", layer.id); + if animation.layers.iter().filter(|candidate| candidate.id == layer.id).count() != 1 { + return Err(invalid("duplicate layer ind", &at)); + } + let mut visited = Vec::new(); + let mut chain = Vec::new(); + let mut current = Some(layer); + while let Some(layer) = current { + if visited.contains(&layer.id) { + return Err(invalid("parent cycle", &at)); + } + visited.push(layer.id); + chain.push(&layer.transform); + current = match layer.parent { + Some(id) => Some(animation.layers.iter().find(|candidate| candidate.id == id).ok_or_else(|| invalid("unknown parent ind", &at))?), + None => None, + }; + } + let mut bounds = Bounds { scale: 1.0, extent: 0.0 }; + for transform in chain.into_iter().rev() { + bounds = bounds.transform(transform, &at)?; + } + let (points, paths, styles, trims) = shapes(&layer.shapes, bounds, &at)?; + let point_count = points.saturating_add(paths.saturating_mul(trims).saturating_mul(4)); + total = total.saturating_add(point_count.saturating_mul(styles.max(1))); + if total > MAX_POINTS { + return Err(unsupported("more than 20000 points per frame", &at)); + } + } + Ok(()) +} + +fn shapes(input: &[Shape], bounds: Bounds, at: &str) -> Result<(usize, usize, usize, usize)> { + let (mut points, mut paths, mut styles, mut trims) = (0_usize, 0_usize, 0_usize, 0_usize); + for shape in input { + let count = match shape { + Shape::Path(path) => { + check_extent(path.track.magnitude(), bounds, at)?; + path_points(path, bounds.scale) + } + Shape::Rectangle { position, size, radius, .. } => { + let extent = position.magnitude() + size.magnitude() + radius.magnitude(); + check_extent(extent, bounds, at)?; + // Eight edges, each with a control polygon bounded by the size. + primitive_points(8, size.magnitude(), bounds.scale) + } + Shape::Ellipse { position, size, .. } => { + check_extent(position.magnitude() + size.magnitude(), bounds, at)?; + primitive_points(4, size.magnitude(), bounds.scale) + } + Shape::Group(children, transform) => { + let (child_points, child_paths, child_styles, child_trims) = shapes(children, bounds.transform(transform, at)?, at)?; + points = points.saturating_add(child_points); + paths = paths.saturating_add(child_paths); + styles = styles.saturating_add(child_styles); + trims = trims.saturating_add(child_trims); + continue; + } + Shape::Stroke { width, .. } => { + check_extent(width.magnitude(), bounds, at)?; + styles = styles.saturating_add(1); + continue; + } + Shape::Fill { .. } => { + styles = styles.saturating_add(1); + continue; + } + Shape::Trim { .. } => { + trims = trims.saturating_add(1); + continue; + } + }; + points = points.saturating_add(count); + paths = paths.saturating_add(1); + } + Ok((points, paths, styles, trims)) +} + +fn check_extent(extent: f32, bounds: Bounds, at: &str) -> Result<()> { + let maximum = bounds.extent + extent * bounds.scale * 4.0; + if !maximum.is_finite() || maximum > MAX_MAGNITUDE { + return Err(unsupported("geometry numeric budget", at)); + } + Ok(()) +} + +fn primitive_points(edges: usize, size: f32, scale: f32) -> usize { + // Every primitive control coordinate lies within a size-wide box. This + // includes rounded rectangles whose radius saturates at half the size. + let second = f64::from(size) * 8.0 * std::f64::consts::SQRT_2; + edges.saturating_mul(segments(second, scale)).saturating_add(1) +} + +fn path_points(path: &PathTrack, scale: f32) -> usize { + if path.track.keys.len() == 1 { + return crate::flatten::estimate(&path.at(0.0), scale * std::f32::consts::SQRT_2); + } + let values = path.track.bounds(); + let vertices: Vec<_> = values.as_chunks::<6>().0.iter().collect(); + let first = vertices.first().copied().filter(|_| path.closed); + let mut count = 1_usize; + for (start, end) in vertices.iter().copied().zip(vertices.iter().copied().skip(1).chain(first)) { + let ([vx, vy, _, _, ox, oy], [ex, ey, ix, iy, _, _]) = (start, end); + let x = second_bound(*vx, *ex, *ox, *ix); + let y = second_bound(*vy, *ey, *oy, *iy); + count = count.saturating_add(segments(x.hypot(y), scale)); + } + count +} + +fn second_bound(start: [f32; 2], end: [f32; 2], outgoing: [f32; 2], incoming: [f32; 2]) -> f64 { + let [start_min, start_max] = start.map(f64::from); + let [end_min, end_max] = end.map(f64::from); + let [out_min, out_max] = outgoing.map(f64::from); + let [in_min, in_max] = incoming.map(f64::from); + [ + end_min - start_max + in_min - 2.0 * out_max, + end_max - start_min + in_max - 2.0 * out_min, + start_min - end_max + out_min - 2.0 * in_max, + start_max - end_min + out_max - 2.0 * in_min, + ] + .into_iter() + .map(f64::abs) + .fold(0.0, f64::max) +} + +fn segments(second: f64, scale: f32) -> usize { + // Rotation can align either axis: sqrt(2) bounds Euclidean scale from the + // maximum component scale, including nonuniform parent compositions. + let mut error = second * 0.75 * f64::from(scale) * std::f64::consts::SQRT_2; + let mut points = 1_usize; + for _ in 0..64 { + if error <= FLATNESS { + return points; + } + error *= 0.25; + points = points.saturating_mul(2); + } + usize::MAX +} diff --git a/crates/lottie/tests/assets.rs b/crates/lottie/tests/assets.rs new file mode 100644 index 0000000..0348240 --- /dev/null +++ b/crates/lottie/tests/assets.rs @@ -0,0 +1,34 @@ +//! Every shipped icon animation parses and evaluates at every frame. + +use notescraft_lottie::Animation; + +#[test] +fn every_shipped_icon_parses_and_plays() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/icons-anim"); + let mut seen = 0; + let mut failures = Vec::new(); + for entry in std::fs::read_dir(&dir).map(|entries| entries.flatten().collect::>()).unwrap_or_default() { + let path = entry.path(); + if path.extension().is_none_or(|ext| ext != "json") || path.file_name().is_some_and(|name| name == "index.json") { + continue; + } + let text = std::fs::read_to_string(&path).unwrap_or_default(); + let animation = match Animation::parse(&text) { + Ok(animation) => animation, + Err(error) => { + failures.push(format!("{}: {error}", path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default())); + continue; + } + }; + let (first, last) = (animation.first_frame(), animation.last_frame()); + assert!(last > first, "{}: no duration", path.display()); + let mut frame = first; + while frame <= last { + assert!(!animation.frame_at(frame).items.is_empty(), "{}: empty at frame {frame}", path.display()); + frame += 1.0; + } + seen += 1; + } + assert!(failures.is_empty(), "icons that do not parse:\n{}", failures.join("\n")); + assert!(seen >= 60, "icons found: {seen}"); +} diff --git a/crates/lottie/tests/compatibility.rs b/crates/lottie/tests/compatibility.rs new file mode 100644 index 0000000..ebc7738 --- /dev/null +++ b/crates/lottie/tests/compatibility.rs @@ -0,0 +1,87 @@ +//! Canonical Bodymovin vector forms and explicit endpoint validation. + +use notescraft_lottie::{Animation, Error, Item}; +use serde_json::{Value, json}; + +fn fixture() -> Value { + serde_json::from_str(include_str!("fixtures/stroke.json")).expect("valid fixture") +} + +#[test] +fn two_dimensional_layers_accept_neutral_z_components() { + let mut input = fixture(); + input["layers"][0]["ks"]["a"]["k"] = json!([0, 0, 0]); + input["layers"][0]["ks"]["p"]["k"] = json!([10, 20, 0]); + input["layers"][0]["ks"]["s"]["k"] = json!([200, 200, 100]); + let animation = Animation::parse(&input.to_string()).expect("2D transform with neutral z"); + let frame = animation.frame_at(0.0); + let Some(Item::Stroke(stroke)) = frame.items.first() else { panic!("stroke") }; + assert_eq!(stroke.polylines.first().and_then(|line| line.points.first()), Some(&[14.0, 28.0])); +} + +#[test] +fn nonneutral_z_is_rejected_as_three_dimensional() { + let mut input = fixture(); + input["layers"][0]["ks"]["p"]["k"] = json!([0, 0, 1]); + assert!(matches!(Animation::parse(&input.to_string()), Err(Error::Unsupported { .. }))); +} + +#[test] +fn final_shape_transform_applies_to_root_geometry() { + let mut input = fixture(); + input["layers"][0]["shapes"].as_array_mut().expect("shapes").push(json!({"ty":"tr", "p":{"a":0,"k":[3,4]}})); + let animation = Animation::parse(&input.to_string()).expect("root transform"); + let frame = animation.frame_at(0.0); + let Some(Item::Stroke(stroke)) = frame.items.first() else { panic!("stroke") }; + assert_eq!(stroke.polylines.first().and_then(|line| line.points.first()), Some(&[5.0, 8.0])); +} + +#[test] +fn explicit_keyframe_endpoints_are_validated_and_terminal_start_can_be_omitted() { + let mut input = fixture(); + input["layers"][0]["ks"]["p"] = json!({"a":1,"k":[{"t":0,"s":[0,0],"e":[10,0]},{"t":10}]}); + assert!(Animation::parse(&input.to_string()).is_ok()); + input["layers"][0]["ks"]["p"]["k"][1]["s"] = json!([20, 0]); + assert!(Animation::parse(&input.to_string()).is_err()); +} + +#[test] +fn lock_shackle_vertices_and_tangents_animate_with_fixed_topology() { + let animation = Animation::parse(include_str!("fixtures/animated-shackle.json")).expect("animated shackle"); + for (time, endpoint) in [(0.0, [16.0, 12.0]), (5.0, [18.0, 10.0]), (10.0, [20.0, 8.0])] { + let frame = animation.frame_at(time); + let Some(Item::Stroke(stroke)) = frame.items.first() else { panic!("stroke") }; + let line = stroke.polylines.first().expect("shackle contour"); + assert_eq!(line.points.first(), Some(&[8.0, 12.0])); + for (&actual, expected) in line.points.last().expect("endpoint").iter().zip(endpoint) { + assert!((actual - expected).abs() < 0.001); + } + assert!(line.points.len() > 4); + } +} + +#[test] +fn animated_path_vertex_count_changes_return_error() { + let mut value: Value = serde_json::from_str(include_str!("fixtures/animated-shackle.json")).expect("fixture"); + for key in ["v", "i", "o"] { + value["layers"][0]["shapes"][0]["ks"]["k"][1]["s"][0][key].as_array_mut().expect("vertices").pop(); + } + assert!(Animation::parse(&value.to_string()).is_err()); +} + +#[test] +fn every_fixture_byte_can_be_mutated_without_panicking() { + for source in [include_str!("fixtures/stroke.json"), include_str!("fixtures/animated-shackle.json")] { + for index in 0..source.len() { + let mut bytes = source.as_bytes().to_vec(); + bytes[index] ^= 0x5a; + let input = String::from_utf8_lossy(&bytes); + let result = std::panic::catch_unwind(|| { + if let Ok(animation) = Animation::parse(&input) { + let _ = animation.frame_at(5.0); + } + }); + assert!(result.is_ok(), "byte {index}"); + } + } +} diff --git a/crates/lottie/tests/fixtures/animated-shackle.json b/crates/lottie/tests/fixtures/animated-shackle.json new file mode 100644 index 0000000..4a57811 --- /dev/null +++ b/crates/lottie/tests/fixtures/animated-shackle.json @@ -0,0 +1,24 @@ +{ + "v":"5.7.4", "fr":10, "ip":0, "op":11, "w":24, "h":24, + "layers":[{ + "ty":4,"ind":1,"nm":"deforming lock shackle","ip":0,"op":11, + "ks":{}, + "shapes":[ + {"ty":"sh","ks":{"a":1,"k":[ + {"t":0,"s":[{ + "c":false, + "v":[[8,12],[8,8],[16,8],[16,12]], + "i":[[0,0],[0,0],[0,-5],[0,0]], + "o":[[0,0],[0,-5],[0,0],[0,0]] + }],"o":{"x":0,"y":0}}, + {"t":10,"s":[{ + "c":false, + "v":[[8,12],[10,6],[18,4],[20,8]], + "i":[[0,0],[0,0],[-2,-5],[0,0]], + "o":[[0,0],[0,-5],[0,0],[0,0]] + }],"i":{"x":1,"y":1}} + ]}}, + {"ty":"st","c":{"a":0,"k":[0,0,0,1]},"w":{"a":0,"k":2},"lc":2,"lj":2} + ] + }] +} diff --git a/crates/lottie/tests/fixtures/hierarchy.json b/crates/lottie/tests/fixtures/hierarchy.json new file mode 100644 index 0000000..28019df --- /dev/null +++ b/crates/lottie/tests/fixtures/hierarchy.json @@ -0,0 +1,18 @@ +{ + "v": "5.7.4", "fr": 10, "ip": 0, "op": 11, "w": 32, "h": 32, + "layers": [ + {"ty": 3, "ind": 1, "ip": 0, "op": 11, "ks": { + "p": {"a": 0, "k": [10, 0]}, "s": {"a": 0, "k": [200, 200]}, + "r": {"a": 0, "k": 90}, "o": {"a": 0, "k": 50} + }}, + {"ty": 4, "ind": 2, "parent": 1, "ip": 0, "op": 11, + "ks": {"p": {"a": 0, "k": [1, 0]}, "o": {"a": 0, "k": 80}}, + "shapes": [{"ty": "gr", "it": [ + {"ty": "sh", "ks": {"a": 0, "k": {"v": [[1, 0], [2, 0]], "i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "c": false}}}, + {"ty": "st", "c": {"a": 0, "k": [0, 0, 0, 1]}, "w": {"a": 0, "k": 1}, "o": {"a": 0, "k": 50}, "lc": 1, "lj": 1}, + {"ty": "tr", "p": {"a": 0, "k": [2, 0]}, "a": {"a": 0, "k": [1, 0]}, + "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 50}} + ]}] + } + ] +} diff --git a/crates/lottie/tests/fixtures/keyframes.json b/crates/lottie/tests/fixtures/keyframes.json new file mode 100644 index 0000000..e6bc758 --- /dev/null +++ b/crates/lottie/tests/fixtures/keyframes.json @@ -0,0 +1,22 @@ +{ + "v": "5.7.4", "fr": 10, "ip": 0, "op": 11, "w": 32, "h": 32, + "layers": [{"ty": 4, "ind": 1, "ip": 0, "op": 11, "st": 0, + "ks": { + "a": {"a": 0, "k": [0, 0]}, + "p": {"a": 1, "k": [ + {"t": 0, "s": [0, 0], "e": [10, 0], "o": {"x": [0.42], "y": [0]}}, + {"t": 10, "s": [10, 0], "i": {"x": [1], "y": [1]}} + ]}, + "s": {"a": 1, "k": [ + {"t": 0, "s": [100, 100], "e": [200, 200], "o": {"x": [0], "y": [0]}}, + {"t": 10, "s": [200, 200], "i": {"x": [1], "y": [1]}} + ]}, + "r": {"a": 1, "k": [{"t": 0, "s": [0], "e": [90], "h": 1}, {"t": 10, "s": [90]}]}, + "o": {"a": 0, "k": 100} + }, + "shapes": [ + {"ty": "sh", "ks": {"a": 0, "k": {"v": [[2, 0], [4, 0]], "i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "c": false}}}, + {"ty": "st", "c": {"a": 0, "k": [0, 0, 0, 1]}, "w": {"a": 0, "k": 1}, "o": {"a": 0, "k": 100}, "lc": 3, "lj": 2} + ] + }] +} diff --git a/crates/lottie/tests/fixtures/markers.json b/crates/lottie/tests/fixtures/markers.json new file mode 100644 index 0000000..350cf06 --- /dev/null +++ b/crates/lottie/tests/fixtures/markers.json @@ -0,0 +1,5 @@ +{ + "v": "5.7.4", "fr": 10, "ip": 10, "op": 30, "w": 24, "h": 24, + "layers": [], + "markers": [{"cm": "unlock", "tm": 12, "dr": 5}, {"cm": "locked", "tm": 25, "dr": 0}] +} diff --git a/crates/lottie/tests/fixtures/shackle.json b/crates/lottie/tests/fixtures/shackle.json new file mode 100644 index 0000000..70681bd --- /dev/null +++ b/crates/lottie/tests/fixtures/shackle.json @@ -0,0 +1,18 @@ +{ + "v": "5.7.4", "fr": 10, "ip": 0, "op": 11, "w": 24, "h": 24, + "layers": [{"ty": 4, "ind": 1, "ip": 0, "op": 11, + "ks": {"a": {"a": 0, "k": [8, 12]}, "p": {"a": 0, "k": [8, 12]}, + "r": {"a": 1, "k": [ + {"t": 0, "s": [0], "e": [-90], "o": {"x": [0], "y": [0]}}, + {"t": 10, "s": [-90], "i": {"x": [1], "y": [1]}} + ]}, "o": {"a": 0, "k": 100}}, + "shapes": [ + {"ty": "sh", "ks": {"a": 0, "k": { + "v": [[8, 12], [8, 8], [16, 8], [16, 12]], + "i": [[0, 0], [0, 0], [0, -5.333333], [0, 0]], + "o": [[0, 0], [0, -5.333333], [0, 0], [0, 0]], "c": false + }}}, + {"ty": "st", "c": {"a": 0, "k": [0, 0, 0, 1]}, "w": {"a": 0, "k": 2}, "o": {"a": 0, "k": 100}, "lc": 2, "lj": 2} + ] + }] +} diff --git a/crates/lottie/tests/fixtures/stroke.json b/crates/lottie/tests/fixtures/stroke.json new file mode 100644 index 0000000..bef03af --- /dev/null +++ b/crates/lottie/tests/fixtures/stroke.json @@ -0,0 +1,12 @@ +{ + "v": "5.7.4", "fr": 10, "ip": 0, "op": 10, "w": 24, "h": 24, + "layers": [{ + "ty": 4, "ind": 1, "ip": 0, "op": 10, "st": 0, "sr": 1, + "ks": {"a": {"a": 0, "k": [0, 0]}, "p": {"a": 0, "k": [0, 0]}, + "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 80}}, + "shapes": [ + {"ty": "sh", "ks": {"a": 0, "k": {"v": [[2, 4], [22, 4]], "i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "c": false}}}, + {"ty": "st", "c": {"a": 0, "k": [0.9, 0.2, 0.4, 1]}, "w": {"a": 0, "k": 2}, "o": {"a": 0, "k": 50}, "lc": 2, "lj": 3} + ] + }] +} diff --git a/crates/lottie/tests/fixtures/trim.json b/crates/lottie/tests/fixtures/trim.json new file mode 100644 index 0000000..02a3cfa --- /dev/null +++ b/crates/lottie/tests/fixtures/trim.json @@ -0,0 +1,14 @@ +{ + "v": "5.7.4", "fr": 10, "ip": 0, "op": 11, "w": 24, "h": 24, + "layers": [{"ty": 4, "ind": 1, "ip": 0, "op": 11, + "ks": {"p": {"a": 0, "k": [0, 0]}, "o": {"a": 0, "k": 100}}, + "shapes": [ + {"ty": "sh", "ks": {"a": 0, "k": {"v": [[0, 0], [10, 0]], "i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "c": false}}}, + {"ty": "st", "c": {"a": 0, "k": [1, 1, 1, 1]}, "w": {"a": 0, "k": 2}, "o": {"a": 0, "k": 100}, "lc": 1, "lj": 1}, + {"ty": "tm", "s": {"a": 0, "k": 0}, "e": {"a": 1, "k": [ + {"t": 0, "s": [0], "e": [100], "o": {"x": [0], "y": [0]}}, + {"t": 10, "s": [100], "i": {"x": [1], "y": [1]}} + ]}, "o": {"a": 0, "k": 0}, "m": 1} + ] + }] +} diff --git a/crates/lottie/tests/hostile.rs b/crates/lottie/tests/hostile.rs new file mode 100644 index 0000000..d52ae38 --- /dev/null +++ b/crates/lottie/tests/hostile.rs @@ -0,0 +1,250 @@ +//! Unsupported features, bounded input, and deterministic parser mutations. + +use std::panic::catch_unwind; + +use notescraft_lottie::{Animation, Error, Frame, Item}; +use serde_json::{Value, json}; + +const STROKE: &str = include_str!("fixtures/stroke.json"); + +fn fixture() -> Value { + serde_json::from_str(STROKE).expect("fixture") +} + +fn group(mut shapes: Value) -> Value { + shapes.as_array_mut().expect("shape array").push(json!({"ty": "tr"})); + json!([{"ty": "gr", "it": shapes}]) +} + +fn rejected(value: &Value, reason: &str) { + assert!(Animation::parse(&value.to_string()).is_err(), "accepted {reason}"); +} + +fn unsupported(value: &Value, reason: &str) { + assert!( + matches!(Animation::parse(&value.to_string()), + Err(Error::Unsupported { what, at }) if !what.is_empty() && !at.is_empty()), + "expected contextual Unsupported for {reason}" + ); +} + +fn finite_bounded(frame: &Frame) { + assert!(frame.width.is_finite()); + assert!(frame.height.is_finite()); + let mut count = 0; + for item in &frame.items { + let (lines, opacity) = match item { + Item::Stroke(path) => { + assert!(path.width.is_finite()); + (&path.polylines, path.opacity) + } + Item::Fill(path) => (&path.polylines, path.opacity), + }; + assert!(opacity.is_finite()); + for line in lines { + count += line.points.len(); + for point in &line.points { + assert!(point.iter().all(|value| value.is_finite())); + } + } + } + assert!(count <= 20_000, "sample budget exceeded: {count}"); +} + +#[test] +fn rejects_precomp_solid_image_and_text_layers() { + for layer_type in [0, 1, 2, 5] { + let mut value = fixture(); + value["layers"][0]["ty"] = json!(layer_type); + unsupported(&value, "unsupported layer type"); + } +} + +#[test] +fn rejects_masks_mattes_effects_three_d_and_time_stretch() { + for (key, content) in + [("masksProperties", json!([{"mode": "a"}])), ("tt", json!(1)), ("td", json!(1)), ("ef", json!([{"ty": 0}])), ("ddd", json!(1)), ("sr", json!(2))] + { + let mut value = fixture(); + value["layers"][0][key] = content; + unsupported(&value, key); + } + let mut value = fixture(); + value["ddd"] = json!(1); + unsupported(&value, "3D composition"); +} + +#[test] +fn rejects_expressions_at_property_boundaries() { + for expression in [json!("time * 10"), json!(""), json!(true)] { + let mut value = fixture(); + value["layers"][0]["ks"]["p"]["x"] = expression; + unsupported(&value, "expression property"); + } +} + +#[test] +fn rejects_unsupported_shape_operators_including_nested_groups() { + for shape_type in ["gf", "gs", "rp", "mm", "rd", "pb", "zz"] { + let mut value = fixture(); + value["layers"][0]["shapes"].as_array_mut().expect("shapes").push(json!({"ty": shape_type})); + unsupported(&value, shape_type); + let shapes = value["layers"][0]["shapes"].take(); + value["layers"][0]["shapes"] = group(shapes); + unsupported(&value, "nested unsupported shape"); + } +} + +#[test] +fn rejects_invalid_composition_numbers_without_silent_repair() { + for (key, number) in + [("fr", json!(0)), ("fr", json!(-1)), ("w", json!(0)), ("w", json!(-1)), ("h", json!(0)), ("op", json!(-1)), ("fr", json!(1e100)), ("w", json!(1e100))] + { + let mut value = fixture(); + value[key] = number; + rejected(&value, key); + } + for invalid in ["NaN", "Infinity", "-Infinity", "1e999"] { + let source = STROKE.replacen("\"fr\": 10", &format!("\"fr\": {invalid}"), 1); + assert!(Animation::parse(&source).is_err()); + } +} + +#[test] +fn rejects_nonfinite_or_overflowing_path_and_transform_values() { + for pointer in ["/layers/0/ks/p/k/0", "/layers/0/ks/s/k/0", "/layers/0/ks/r/k", "/layers/0/shapes/1/w/k", "/layers/0/shapes/0/ks/k/v/0/0"] { + let mut value = fixture(); + *value.pointer_mut(pointer).expect("fixture field") = json!(1e100); + rejected(&value, pointer); + } +} + +#[test] +fn rejects_mismatched_vertex_and_tangent_counts() { + for field in ["v", "i", "o"] { + let mut value = fixture(); + value["layers"][0]["shapes"][0]["ks"]["k"][field] = json!([[0, 0]]); + rejected(&value, "mismatched path arrays"); + } + for point in [json!([]), json!([0]), json!([0, 0, 0]), json!(["x", 0])] { + let mut value = fixture(); + value["layers"][0]["shapes"][0]["ks"]["k"]["v"][0] = point; + rejected(&value, "invalid vertex dimensions"); + } +} + +#[test] +fn rejects_parent_cycles_and_unknown_parent_ids() { + let mut value = fixture(); + value["layers"][0]["parent"] = json!(1); + rejected(&value, "self parent"); + value["layers"][0]["parent"] = json!(999); + rejected(&value, "unknown parent"); + let mut parent = value["layers"][0].clone(); + value["layers"][0]["parent"] = json!(2); + parent["ind"] = json!(2); + parent["parent"] = json!(1); + value["layers"].as_array_mut().expect("layers").push(parent); + rejected(&value, "parent cycle"); +} + +#[test] +fn rejects_layer_budget_and_ten_thousand_layer_attack() { + for count in [65, 10_000] { + let mut value = fixture(); + value["layers"] = json!( + (0..count) + .map(|index| { + let mut layer = value["layers"][0].clone(); + layer["ind"] = json!(index + 1); + layer + }) + .collect::>() + ); + if count == 65 { + unsupported(&value, "layer cap"); + } else { + rejected(&value, "ten thousand layer attack"); + } + } +} + +#[test] +fn rejects_group_nesting_beyond_eight() { + let mut value = fixture(); + let mut shapes = value["layers"][0]["shapes"].take(); + for _ in 0..9 { + shapes = group(shapes); + } + value["layers"][0]["shapes"] = shapes; + unsupported(&value, "group depth cap"); +} + +#[test] +fn rejects_more_than_four_thousand_ninety_six_vertices() { + let mut value = fixture(); + let points = vec![[0, 0]; 4097]; + value["layers"][0]["shapes"][0]["ks"]["k"] = json!({ + "v": points, "i": points, "o": points, "c": false + }); + unsupported(&value, "vertex cap"); +} + +#[test] +fn rejects_cubic_subdivision_requiring_more_than_twenty_thousand_points() { + let mut value = fixture(); + value["layers"][0]["shapes"][0]["ks"]["k"] = json!({ + "v": [[0, 0], [10, 0]], "i": [[0, 0], [0, 1_000_000]], + "o": [[0, 1_000_000], [0, 0]], "c": false + }); + value["layers"][0]["ks"]["s"]["k"] = json!([10_000, 10_000]); + unsupported(&value, "flattened point budget"); +} + +#[test] +fn oversized_source_is_rejected_before_json_parsing() { + let source = " ".repeat(512 * 1024 + 1); + assert!(matches!(Animation::parse(&source), Err(Error::TooLarge))); +} + +#[test] +fn truncation_and_wrong_json_types_return_errors() { + for end in [0, 1, 10, STROKE.len() / 2, STROKE.len() - 3] { + assert!(Animation::parse(&STROKE[..end]).is_err(), "prefix {end}"); + } + for source in ["null", "[]", "{}", "true", "42", "\"lottie\""] { + assert!(Animation::parse(source).is_err(), "accepted {source}"); + } +} + +#[test] +fn nonfinite_and_extreme_sample_frames_never_emit_nonfinite_geometry() { + let animation = Animation::parse(STROKE).expect("fixture"); + for frame in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, -f32::MAX] { + finite_bounded(&animation.frame_at(frame)); + } +} + +#[test] +fn deterministic_byte_mutations_never_panic_or_emit_invalid_geometry() { + let sources = [STROKE, include_str!("fixtures/shackle.json"), include_str!("fixtures/keyframes.json")]; + let mut state = 0x9e37_79b9_u32; + for iteration in 0..2000 { + let source = sources[iteration % sources.len()]; + let mut bytes = source.as_bytes().to_vec(); + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + let index = usize::try_from(state).expect("u32 fits usize") % bytes.len(); + bytes[index] ^= state.to_le_bytes()[1]; + let input = String::from_utf8_lossy(&bytes); + let outcome = catch_unwind(|| { + if let Ok(animation) = Animation::parse(&input) { + for frame in [0.0, 2.5, 5.0, 10.0] { + finite_bounded(&animation.frame_at(frame)); + } + } + }); + assert!(outcome.is_ok(), "mutation {iteration} at byte {index} panicked"); + } +} diff --git a/crates/lottie/tests/metadata.rs b/crates/lottie/tests/metadata.rs new file mode 100644 index 0000000..d747ed6 --- /dev/null +++ b/crates/lottie/tests/metadata.rs @@ -0,0 +1,34 @@ +//! Public metadata and input contract. + +use notescraft_lottie::Animation; + +const STROKE: &str = include_str!("fixtures/stroke.json"); + +fn near(actual: f32, expected: f32) { + assert!((actual - expected).abs() < 0.001, "{actual} != {expected}"); +} + +#[test] +fn composition_metadata_is_available_without_sampling() { + let animation = Animation::parse(STROKE).expect("valid handwritten fixture"); + near(animation.size().0, 24.0); + near(animation.size().1, 24.0); + near(animation.frame_rate(), 10.0); + near(animation.first_frame(), 0.0); + near(animation.last_frame(), 10.0); + near(animation.duration_secs(), 1.0); +} + +#[test] +fn markers_preserve_names_absolute_frames_and_durations() { + let animation = Animation::parse(include_str!("fixtures/markers.json")).expect("fixture"); + near(animation.first_frame(), 10.0); + near(animation.last_frame(), 30.0); + near(animation.duration_secs(), 2.0); + let marker = animation.marker("unlock").expect("named marker"); + assert_eq!(marker.name, "unlock"); + near(marker.frame, 12.0); + near(marker.duration, 5.0); + near(animation.marker("locked").expect("zero duration marker").duration, 0.0); + assert!(animation.marker("missing").is_none()); +} diff --git a/crates/lottie/tests/primitives.rs b/crates/lottie/tests/primitives.rs new file mode 100644 index 0000000..b86c7bc --- /dev/null +++ b/crates/lottie/tests/primitives.rs @@ -0,0 +1,86 @@ +//! Primitive evaluation and combined resource boundaries. + +use notescraft_lottie::{Animation, Error, Item}; +use serde_json::{Value, json}; + +fn fixture() -> Value { + serde_json::from_str(include_str!("fixtures/stroke.json")).expect("valid fixture") +} + +#[test] +fn rounded_rectangle_and_ellipse_emit_closed_pixel_contours() { + for primitive in [ + json!({"ty":"rc","p":{"a":0,"k":[12,12]},"s":{"a":0,"k":[20,10]},"r":{"a":0,"k":2}}), + json!({"ty":"el","p":{"a":0,"k":[12,12]},"s":{"a":0,"k":[20,10]}}), + ] { + let mut input = fixture(); + input["layers"][0]["shapes"][0] = primitive; + let animation = Animation::parse(&input.to_string()).expect("primitive"); + let frame = animation.frame_at(0.0); + let Some(Item::Stroke(stroke)) = frame.items.first() else { panic!("stroke") }; + let line = stroke.polylines.first().expect("contour"); + assert!(line.closed); + assert!(line.points.len() > 8); + assert!(line.points.iter().all(|[x, y]| (2.0..=22.0).contains(x) && (7.0..=17.0).contains(y))); + } +} + +#[test] +fn animated_ellipse_size_and_position_interpolate() { + let mut input = fixture(); + input["layers"][0]["shapes"][0] = json!({ + "ty":"el", + "p":{"a":1,"k":[{"t":0,"s":[10,10]},{"t":10,"s":[20,20]}]}, + "s":{"a":1,"k":[{"t":0,"s":[10,10]},{"t":10,"s":[20,20]}]} + }); + let animation = Animation::parse(&input.to_string()).expect("ellipse"); + let frame = animation.frame_at(5.0); + let Some(Item::Stroke(stroke)) = frame.items.first() else { panic!("stroke") }; + let [x, y] = stroke.polylines.first().and_then(|line| line.points.first()).expect("top vertex"); + assert!((*x - 15.0).abs() < 0.001); + assert!((*y - 7.5).abs() < 0.001); +} + +#[test] +fn vertex_budget_counts_all_paths_not_only_each_path() { + let mut input = fixture(); + let mut path = input["layers"][0]["shapes"][0].clone(); + path["ks"]["k"] = json!({"v":vec![[0,0];2050],"i":vec![[0,0];2050],"o":vec![[0,0];2050],"c":false}); + input["layers"][0]["shapes"] = json!([path, path]); + assert!(matches!(Animation::parse(&input.to_string()), Err(Error::Unsupported {what,..}) if what.contains("4096"))); +} + +#[test] +fn exactly_eight_group_levels_are_accepted() { + let mut input = fixture(); + let mut shapes = input["layers"][0]["shapes"].take(); + for _ in 0..8 { + shapes.as_array_mut().expect("shapes").push(json!({"ty":"tr"})); + shapes = json!([{"ty":"gr","it":shapes}]); + } + input["layers"][0]["shapes"] = shapes; + assert!(Animation::parse(&input.to_string()).is_ok()); +} + +#[test] +fn masks_and_unimplemented_trim_modes_are_explicitly_unsupported() { + let mut input = fixture(); + input["layers"][0]["hasMask"] = json!(true); + assert!(matches!(Animation::parse(&input.to_string()),Err(Error::Unsupported{what,..}) if what.contains("Mask"))); + input["layers"][0]["hasMask"] = json!(false); + input["layers"][0]["shapes"].as_array_mut().expect("shapes").push(json!({"ty":"tm","m":2})); + assert!(matches!(Animation::parse(&input.to_string()),Err(Error::Unsupported{what,..}) if what.contains("trim"))); +} + +#[test] +fn point_budget_includes_every_stroke_and_fill_copy() { + let mut input = fixture(); + let path = json!({"ty":"sh","ks":{"a":0,"k":{ + "v":[[0,0],[100,0]],"i":[[0,0],[0,100]],"o":[[0,100],[0,0]],"c":false + }}}); + let stroke = input["layers"][0]["shapes"][1].clone(); + let mut shapes = vec![path]; + shapes.extend(std::iter::repeat_n(stroke, 1000)); + input["layers"][0]["shapes"] = json!(shapes); + assert!(matches!(Animation::parse(&input.to_string()),Err(Error::Unsupported{what,..}) if what.contains("20000"))); +} diff --git a/crates/lottie/tests/sampling.rs b/crates/lottie/tests/sampling.rs new file mode 100644 index 0000000..142fb71 --- /dev/null +++ b/crates/lottie/tests/sampling.rs @@ -0,0 +1,217 @@ +//! Hand-calculated geometry checks for the renderer-independent draw list. + +use notescraft_lottie::{Animation, Cap, Frame, Item, Join, StrokePath}; +use serde_json::{Value, json}; + +const STROKE: &str = include_str!("fixtures/stroke.json"); +const TRIM: &str = include_str!("fixtures/trim.json"); +const KEYFRAMES: &str = include_str!("fixtures/keyframes.json"); +const HIERARCHY: &str = include_str!("fixtures/hierarchy.json"); +const SHACKLE: &str = include_str!("fixtures/shackle.json"); + +fn near(actual: f32, expected: f32) { + assert!((actual - expected).abs() < 0.015, "{actual} != {expected}"); +} + +fn stroke(frame: &Frame) -> &StrokePath { + match frame.items.first().expect("one paint item") { + Item::Stroke(path) => path, + Item::Fill(_) => panic!("expected stroke"), + } +} + +fn first(frame: &Frame) -> [f32; 2] { + *stroke(frame).polylines[0].points.first().expect("point") +} + +fn last(frame: &Frame) -> [f32; 2] { + *stroke(frame).polylines[0].points.last().expect("point") +} + +fn parse_value(value: &Value) -> Animation { + Animation::parse(&value.to_string()).expect("valid fixture variation") +} + +#[test] +fn stroke_preserves_geometry_style_and_multiplies_opacity() { + let animation = Animation::parse(STROKE).expect("fixture"); + let frame = animation.frame_at(5.0); + near(frame.width, 24.0); + near(frame.height, 24.0); + assert_eq!(frame.items.len(), 1); + let path = stroke(&frame); + near(path.width, 2.0); + near(path.opacity, 0.4); + assert!(matches!(path.cap, Cap::Round)); + assert!(matches!(path.join, Join::Bevel)); + assert!(!path.polylines[0].closed); + near(first(&frame)[0], 2.0); + near(first(&frame)[1], 4.0); + near(last(&frame)[0], 22.0); +} + +#[test] +fn cap_and_join_numeric_codes_follow_lottie() { + let mut value: Value = serde_json::from_str(STROKE).expect("fixture"); + for code in 1..=3 { + value["layers"][0]["shapes"][1]["lc"] = json!(code); + value["layers"][0]["shapes"][1]["lj"] = json!(code); + let frame = parse_value(&value).frame_at(0.0); + let path = stroke(&frame); + assert!(matches!((code, &path.cap, &path.join), (1, Cap::Butt, Join::Miter) | (2, Cap::Round, Join::Round) | (3, Cap::Square, Join::Bevel))); + } +} + +#[test] +fn closed_fill_produces_fill_item_and_layer_opacity() { + let mut value: Value = serde_json::from_str(STROKE).expect("fixture"); + value["layers"][0]["shapes"][0]["ks"]["k"] = json!({ + "v": [[2, 2], [12, 2], [12, 12]], + "i": [[0, 0], [0, 0], [0, 0]], + "o": [[0, 0], [0, 0], [0, 0]], "c": true + }); + value["layers"][0]["shapes"][1] = json!({ + "ty": "fl", "c": {"a": 0, "k": [1, 0, 0, 1]}, + "o": {"a": 0, "k": 50}, "r": 1 + }); + let frame = parse_value(&value).frame_at(0.0); + let Item::Fill(path) = &frame.items[0] else { panic!("fill item") }; + near(path.opacity, 0.4); + assert!(path.polylines[0].closed); + assert!(path.polylines[0].points.len() >= 3); +} + +#[test] +fn trim_draw_on_uses_arc_length_at_start_middle_and_end() { + let animation = Animation::parse(TRIM).expect("fixture"); + let start = animation.frame_at(0.0); + let visible_points: usize = start + .items + .iter() + .map(|item| match item { + Item::Stroke(path) => path.polylines.iter().map(|line| line.points.len()).sum::(), + Item::Fill(path) => path.polylines.iter().map(|line| line.points.len()).sum::(), + }) + .sum(); + assert!(visible_points <= 1, "zero trim must not draw a segment"); + near(last(&animation.frame_at(5.0))[0], 5.0); + near(last(&animation.frame_at(10.0))[0], 10.0); +} + +#[test] +fn temporal_bezier_easing_changes_position_from_linear_midpoint() { + let animation = Animation::parse(KEYFRAMES).expect("fixture"); + let point = first(&animation.frame_at(5.0)); + // Cubic-bezier(.42, 0, 1, 1) at x=.5 is about .31536. + // The local x=2 also scales to 3; rotation holds at zero. + near(point[0], 6.1536); + near(point[1], 0.0); +} + +#[test] +fn hold_rotation_jumps_at_keyframe_and_linear_scale_reaches_endpoint() { + let animation = Animation::parse(KEYFRAMES).expect("fixture"); + let before = first(&animation.frame_at(9.0)); + near(before[1], 0.0); + let endpoint = first(&animation.frame_at(10.0)); + near(endpoint[0], 10.0); + near(endpoint[1], 4.0); +} + +#[test] +fn layer_start_time_offsets_animation_keyframes() { + let mut value: Value = serde_json::from_str(KEYFRAMES).expect("fixture"); + value["layers"][0]["st"] = json!(2); + let point = first(&parse_value(&value).frame_at(7.0)); + near(point[0], 6.1536); +} + +#[test] +fn parent_and_group_transforms_compose_with_anchor_and_opacity() { + let frame = Animation::parse(HIERARCHY).expect("fixture").frame_at(0.0); + assert_eq!(frame.items.len(), 1, "null parent never paints"); + let point = first(&frame); + near(point[0], 10.0); + near(point[1], 6.0); + near(last(&frame)[1], 8.0); + near(stroke(&frame).opacity, 0.2); +} + +#[test] +fn curved_shackle_flattens_cubics_and_rotates_about_hinge() { + let animation = Animation::parse(SHACKLE).expect("fixture"); + let closed = animation.frame_at(0.0); + let open = animation.frame_at(10.0); + let line = &stroke(&closed).polylines[0]; + assert!(line.points.len() > 4, "cubic must contain interior samples"); + assert!(line.points.iter().any(|point| point[1] < 5.0)); + near(first(&open)[0], 8.0); + near(first(&open)[1], 12.0); + near(last(&open)[0], 8.0); + near(last(&open)[1], 4.0); +} + +#[test] +fn layer_visibility_uses_inclusive_in_and_exclusive_out_frame() { + let mut value: Value = serde_json::from_str(STROKE).expect("fixture"); + value["layers"][0]["ip"] = json!(2); + value["layers"][0]["op"] = json!(8); + let animation = parse_value(&value); + assert_eq!(animation.frame_at(1.0).items, Vec::new()); + assert_eq!(animation.frame_at(2.0).items.len(), 1); + assert_eq!(animation.frame_at(8.0).items, Vec::new()); +} + +#[test] +fn separated_position_components_animate_independently() { + let mut value: Value = serde_json::from_str(STROKE).expect("fixture"); + value["layers"][0]["ks"]["p"] = json!({ + "s": true, + "x": {"a": 1, "k": [ + {"t": 0, "s": [0], "e": [10], "o": {"x": [0], "y": [0]}}, + {"t": 10, "s": [10], "i": {"x": [1], "y": [1]}} + ]}, + "y": {"a": 0, "k": 6} + }); + let frame = parse_value(&value).frame_at(5.0); + near(first(&frame)[0], 7.0); + near(first(&frame)[1], 10.0); +} + +#[test] +fn topmost_lottie_layer_is_painted_last() { + let mut value: Value = serde_json::from_str(STROKE).expect("fixture"); + let mut back = value["layers"][0].clone(); + back["ind"] = json!(2); + back["ks"]["p"]["k"] = json!([10, 0]); + value["layers"].as_array_mut().expect("layers").push(back); + let frame = parse_value(&value).frame_at(0.0); + let starts: Vec<_> = frame + .items + .iter() + .flat_map(|item| match item { + Item::Stroke(path) => &path.polylines, + Item::Fill(path) => &path.polylines, + }) + .filter_map(|line| line.points.first()) + .collect(); + assert_eq!(starts.len(), 2); + near(starts[0][0], 12.0); + near(starts[1][0], 2.0); +} + +#[test] +fn frame_requests_clamp_to_composition_and_nonfinite_uses_first_frame() { + let mut value: Value = serde_json::from_str(KEYFRAMES).expect("fixture"); + value["op"] = json!(10); + value["layers"][0]["op"] = json!(20); + let animation = parse_value(&value); + for requested in [-10.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let point = first(&animation.frame_at(requested)); + near(point[0], 2.0); + near(point[1], 0.0); + } + let point = first(&animation.frame_at(100.0)); + near(point[0], 10.0); + near(point[1], 4.0); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 1aba8dd..157b1f2 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -24,6 +24,7 @@ thiserror = { workspace = true } notescraft-engine = { workspace = true } notescraft-acp = { workspace = true } notescraft-git = { workspace = true } +notescraft-lottie = { workspace = true } notescraft-i18n = { workspace = true } notescraft-math = { workspace = true } notescraft-themes = { workspace = true } diff --git a/crates/ui-egui/examples/icon_sheet.rs b/crates/ui-egui/examples/icon_sheet.rs new file mode 100644 index 0000000..c4283d5 --- /dev/null +++ b/crates/ui-egui/examples/icon_sheet.rs @@ -0,0 +1,46 @@ +//! Renders animated icons as contact sheets for visual review: +//! `icon_sheet out.png [--frames 9] [--cell 72] name...` draws one row of frames per icon (toggles run +//! `off` -> `on`, one-shots run their whole length). + +use egui::{Color32, Rect, Vec2}; + +fn main() { + let mut args = std::env::args().skip(1); + let output = args.next().unwrap_or_else(|| "/tmp/icon-sheet.png".to_owned()); + let (mut frames, mut cell) = (9_u16, 72.0_f32); + let mut names = Vec::new(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--frames" => frames = args.next().and_then(|v| v.parse().ok()).unwrap_or(frames), + "--cell" => cell = args.next().and_then(|v| v.parse().ok()).unwrap_or(cell), + name => names.push(name.to_owned()), + } + } + let rows = u16::try_from(names.len()).unwrap_or(u16::MAX); + let size = Vec2::new(cell * f32::from(frames) + 100.0, cell * f32::from(rows) + 8.0); + let mut harness = egui_kittest::Harness::builder().with_size(size).with_pixels_per_point(2.0).wgpu().build_ui_state( + |ui, state: &mut (Vec, u16)| { + egui_extras::install_image_loaders(ui.ctx()); + ui.painter().rect_filled(ui.max_rect(), 0.0, Color32::from_rgb(24, 24, 37)); + for (row, name) in state.0.iter().enumerate() { + let top = 4.0 + cell * f32::from(u16::try_from(row).unwrap_or(0)); + ui.painter().text(egui::pos2(6.0, top + cell / 2.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(11.0), Color32::GRAY); + let Some((first, last)) = notescraft_ui_egui::lottie_icon::range(name) else { continue }; + for column in 0..state.1 { + let t = if state.1 > 1 { f32::from(column) / f32::from(state.1 - 1) } else { 0.0 }; + let rect = Rect::from_min_size(egui::pos2(100.0 + cell * f32::from(column), top), Vec2::splat(cell)).shrink(cell * 0.22); + notescraft_ui_egui::lottie_icon::paint_frame(ui, name, rect, Color32::from_rgb(205, 214, 244), first + (last - first) * t); + } + } + }, + (names, frames), + ); + harness.run(); + match harness.render() { + Ok(image) => match image.save(&output) { + Ok(()) => println!("wrote {output}"), + Err(error) => eprintln!("could not save {output}: {error}"), + }, + Err(error) => eprintln!("render failed: {error}"), + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79835dd..7768d56 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -30,6 +30,7 @@ pub mod graph_view; pub mod icon_motion; pub mod icons; pub mod layout; +pub mod lottie_icon; pub mod math_face; pub mod math_view; pub mod motion; diff --git a/crates/ui-egui/src/lottie_icon.rs b/crates/ui-egui/src/lottie_icon.rs new file mode 100644 index 0000000..fca2fb5 --- /dev/null +++ b/crates/ui-egui/src/lottie_icon.rs @@ -0,0 +1,171 @@ +//! Animated icons: Lottie shape animations (`assets/icons-anim`) played with the egui painter, tinted with the +//! caller's colour. An icon with no animation falls back to its static Lucide drawing. +//! +//! Two kinds exist. A *toggle* has an `off` and an `on` marker and runs forward to open and backward to close +//! ([`toggle`]); a *one-shot* has a `play` marker and runs once when triggered ([`play`]). + +use std::collections::HashMap; +use std::sync::OnceLock; + +use egui::{Color32, Id, Pos2, Rect, Shape, Stroke, Ui}; +use notescraft_lottie::{Animation, Cap, Item, Polyline}; + +mod files; + +/// Marker name of a toggle's resting frame. +const OFF: &str = "off"; +/// Marker name of a toggle's final frame. +const ON: &str = "on"; + +fn library() -> &'static HashMap<&'static str, Animation> { + static LIBRARY: OnceLock> = OnceLock::new(); + LIBRARY.get_or_init(|| files::FILES.iter().filter_map(|(name, text)| Animation::parse(text).ok().map(|animation| (*name, animation))).collect()) +} + +/// Whether `name` has an animation. +#[must_use] +pub fn has(name: &str) -> bool { + library().contains_key(name) +} + +/// The first and last frame to review an icon over: a toggle's `off`..`on`, a one-shot's whole length. +#[must_use] +pub fn range(name: &str) -> Option<(f32, f32)> { + let animation = library().get(name)?; + Some(if animation.marker(OFF).is_some() { toggle_range(animation) } else { (animation.first_frame(), animation.last_frame()) }) +} + +/// Paints icon `name` at the animation's `frame` into `rect` (square), tinted `tint`. Falls back to the static +/// icon when there is no animation. +pub fn paint_frame(ui: &Ui, name: &str, rect: Rect, tint: Color32, frame: f32) { + let Some(animation) = library().get(name) else { + crate::icons::paint(ui, name, rect, tint); + return; + }; + let side = rect.width().min(rect.height()); + let drawn = Rect::from_center_size(rect.center(), egui::Vec2::splat(side)); + let (width, height) = animation.size(); + let scale = side / width.max(f32::EPSILON).max(height.max(f32::EPSILON)).max(1.0); + let to_screen = |point: [f32; 2]| Pos2::new(drawn.left() + point[0] * scale, drawn.top() + point[1] * scale); + for item in animation.frame_at(frame).items { + match item { + Item::Stroke(stroke) => { + let colour = tint.gamma_multiply(stroke.opacity.clamp(0.0, 1.0)); + let line = Stroke::new((stroke.width * scale).max(0.5), colour); + for polyline in &stroke.polylines { + stroke_polyline(ui, polyline, line, stroke.cap == Cap::Round, &to_screen); + } + } + Item::Fill(fill) => { + let colour = tint.gamma_multiply(fill.opacity.clamp(0.0, 1.0)); + for polyline in &fill.polylines { + let points: Vec = polyline.points.iter().map(|point| to_screen(*point)).collect(); + if points.len() >= 3 { + ui.painter().add(Shape::convex_polygon(points, colour, Stroke::NONE)); + } + } + } + } + } +} + +fn stroke_polyline(ui: &Ui, polyline: &Polyline, stroke: Stroke, round: bool, to_screen: &dyn Fn([f32; 2]) -> Pos2) { + let points: Vec = polyline.points.iter().map(|point| to_screen(*point)).collect(); + if points.len() < 2 { + return; + } + let painter = ui.painter(); + if polyline.closed { + painter.add(Shape::closed_line(points, stroke)); + return; + } + if round { + // egui lines have butt ends: a dot at each end makes the round cap. + for end in [points.first(), points.last()].into_iter().flatten() { + painter.circle_filled(*end, stroke.width / 2.0, stroke.color); + } + } + painter.add(Shape::line(points, stroke)); +} + +/// The frames a toggle runs between. +fn toggle_range(animation: &Animation) -> (f32, f32) { + let off = animation.marker(OFF).map_or_else(|| animation.first_frame(), |marker| marker.frame); + let on = animation.marker(ON).map_or_else(|| animation.last_frame(), |marker| marker.frame); + (off, on) +} + +/// Paints a toggle icon that plays forward when `on` turns true and backward when it turns false, so opening and +/// closing both animate. Without an animation it draws the static icon. +pub fn toggle(ui: &Ui, id: impl std::hash::Hash + std::fmt::Debug, name: &str, rect: Rect, tint: Color32, on: bool) { + let Some(animation) = library().get(name) else { + crate::icons::paint(ui, name, rect, tint); + return; + }; + let (off, end) = toggle_range(animation); + let seconds = ((end - off).abs() / animation.frame_rate().max(1.0)).max(0.05); + let progress = crate::motion::progress(ui.ctx(), (id, "lottie-toggle"), on, seconds, linear); + paint_frame(ui, name, rect, tint, off + (end - off) * progress); +} + +fn linear(t: f32) -> f32 { + t +} + +/// Paints a one-shot icon: it runs once each time `trigger` turns true (for example on hover) and rests on its +/// first frame otherwise. +pub fn play(ui: &Ui, id: impl std::hash::Hash + std::fmt::Debug, name: &str, rect: Rect, tint: Color32, trigger: bool) { + let Some(animation) = library().get(name) else { + crate::icons::paint(ui, name, rect, tint); + return; + }; + let ctx = ui.ctx(); + let key = Id::new((id, "lottie-play")); + let now = ctx.input(|input| input.time); + let was = ctx.data(|data| data.get_temp::(key.with("was"))).unwrap_or(false); + ctx.data_mut(|data| data.insert_temp(key.with("was"), trigger)); + if trigger && !was { + ctx.data_mut(|data| data.insert_temp(key, now)); + } + let started = ctx.data(|data| data.get_temp::(key)); + let length = animation.last_frame() - animation.first_frame(); + let elapsed = started.map_or(f64::INFINITY, |start| now - start); + let frame = if crate::motion::enabled(ctx) && elapsed < f64::from(animation.duration_secs()) { + ctx.request_repaint(); + #[allow(clippy::cast_possible_truncation)] // a frame offset inside one animation + let offset = (elapsed as f32 * animation.frame_rate()).clamp(0.0, length); + animation.first_frame() + offset + } else { + animation.first_frame() + }; + paint_frame(ui, name, rect, tint, frame); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every file in `assets/icons-anim` is embedded (so a new animation cannot be forgotten) and parses. + #[test] + fn every_animation_file_is_embedded_and_valid() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/icons-anim"); + let mut on_disk: Vec = std::fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| entry.file_name().to_str().and_then(|name| name.strip_suffix(".json")).map(str::to_owned)) + .filter(|name| name != "index") + .collect(); + on_disk.sort(); + let embedded: Vec<&str> = files::FILES.iter().map(|(name, _)| *name).collect(); + assert_eq!(on_disk.iter().map(String::as_str).collect::>(), embedded); + assert_eq!(library().len(), embedded.len(), "every embedded file parses"); + } + + #[test] + fn toggles_run_between_their_markers() { + let Some(animation) = library().get("folder") else { return }; + let (off, on) = toggle_range(animation); + assert!(on > off); + } +} diff --git a/crates/ui-egui/src/lottie_icon/files.rs b/crates/ui-egui/src/lottie_icon/files.rs new file mode 100644 index 0000000..e526c0b --- /dev/null +++ b/crates/ui-egui/src/lottie_icon/files.rs @@ -0,0 +1,69 @@ +//! The embedded animated icon files (`assets/icons-anim`). A test checks the list covers the folder. + +/// `(name, Lottie JSON)` of every shipped animation. +pub(super) static FILES: &[(&str, &str)] = &[ + ("arrow-down", include_str!("../../../../assets/icons-anim/arrow-down.json")), + ("arrow-up", include_str!("../../../../assets/icons-anim/arrow-up.json")), + ("bold", include_str!("../../../../assets/icons-anim/bold.json")), + ("bookmark", include_str!("../../../../assets/icons-anim/bookmark.json")), + ("bug", include_str!("../../../../assets/icons-anim/bug.json")), + ("calendar", include_str!("../../../../assets/icons-anim/calendar.json")), + ("check", include_str!("../../../../assets/icons-anim/check.json")), + ("chevron-down", include_str!("../../../../assets/icons-anim/chevron-down.json")), + ("chevron-left", include_str!("../../../../assets/icons-anim/chevron-left.json")), + ("chevron-right", include_str!("../../../../assets/icons-anim/chevron-right.json")), + ("chevron-up", include_str!("../../../../assets/icons-anim/chevron-up.json")), + ("circle", include_str!("../../../../assets/icons-anim/circle.json")), + ("circle-check", include_str!("../../../../assets/icons-anim/circle-check.json")), + ("circle-question-mark", include_str!("../../../../assets/icons-anim/circle-question-mark.json")), + ("clipboard", include_str!("../../../../assets/icons-anim/clipboard.json")), + ("copy", include_str!("../../../../assets/icons-anim/copy.json")), + ("diamond", include_str!("../../../../assets/icons-anim/diamond.json")), + ("download", include_str!("../../../../assets/icons-anim/download.json")), + ("eraser", include_str!("../../../../assets/icons-anim/eraser.json")), + ("file-text", include_str!("../../../../assets/icons-anim/file-text.json")), + ("flame", include_str!("../../../../assets/icons-anim/flame.json")), + ("folder", include_str!("../../../../assets/icons-anim/folder.json")), + ("git-branch", include_str!("../../../../assets/icons-anim/git-branch.json")), + ("git-commit-horizontal", include_str!("../../../../assets/icons-anim/git-commit-horizontal.json")), + ("git-fork", include_str!("../../../../assets/icons-anim/git-fork.json")), + ("grid-3x3", include_str!("../../../../assets/icons-anim/grid-3x3.json")), + ("hand", include_str!("../../../../assets/icons-anim/hand.json")), + ("highlighter", include_str!("../../../../assets/icons-anim/highlighter.json")), + ("image", include_str!("../../../../assets/icons-anim/image.json")), + ("info", include_str!("../../../../assets/icons-anim/info.json")), + ("italic", include_str!("../../../../assets/icons-anim/italic.json")), + ("layers", include_str!("../../../../assets/icons-anim/layers.json")), + ("layout-dashboard", include_str!("../../../../assets/icons-anim/layout-dashboard.json")), + ("link", include_str!("../../../../assets/icons-anim/link.json")), + ("list", include_str!("../../../../assets/icons-anim/list.json")), + ("lock", include_str!("../../../../assets/icons-anim/lock.json")), + ("maximize", include_str!("../../../../assets/icons-anim/maximize.json")), + ("message-square-quote", include_str!("../../../../assets/icons-anim/message-square-quote.json")), + ("mouse-pointer-2", include_str!("../../../../assets/icons-anim/mouse-pointer-2.json")), + ("move-up-right", include_str!("../../../../assets/icons-anim/move-up-right.json")), + ("notebook-pen", include_str!("../../../../assets/icons-anim/notebook-pen.json")), + ("panel-left", include_str!("../../../../assets/icons-anim/panel-left.json")), + ("panel-right", include_str!("../../../../assets/icons-anim/panel-right.json")), + ("pen", include_str!("../../../../assets/icons-anim/pen.json")), + ("pencil", include_str!("../../../../assets/icons-anim/pencil.json")), + ("plus", include_str!("../../../../assets/icons-anim/plus.json")), + ("quote", include_str!("../../../../assets/icons-anim/quote.json")), + ("refresh-cw", include_str!("../../../../assets/icons-anim/refresh-cw.json")), + ("scissors", include_str!("../../../../assets/icons-anim/scissors.json")), + ("search", include_str!("../../../../assets/icons-anim/search.json")), + ("settings", include_str!("../../../../assets/icons-anim/settings.json")), + ("slash", include_str!("../../../../assets/icons-anim/slash.json")), + ("sparkles", include_str!("../../../../assets/icons-anim/sparkles.json")), + ("square", include_str!("../../../../assets/icons-anim/square.json")), + ("star", include_str!("../../../../assets/icons-anim/star.json")), + ("strikethrough", include_str!("../../../../assets/icons-anim/strikethrough.json")), + ("tag", include_str!("../../../../assets/icons-anim/tag.json")), + ("trash-2", include_str!("../../../../assets/icons-anim/trash-2.json")), + ("triangle-alert", include_str!("../../../../assets/icons-anim/triangle-alert.json")), + ("type", include_str!("../../../../assets/icons-anim/type.json")), + ("undo-2", include_str!("../../../../assets/icons-anim/undo-2.json")), + ("upload", include_str!("../../../../assets/icons-anim/upload.json")), + ("x", include_str!("../../../../assets/icons-anim/x.json")), + ("zap", include_str!("../../../../assets/icons-anim/zap.json")), +];