diff --git a/Cargo.lock b/Cargo.lock index 3103a560..95fc3df8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,7 +20,7 @@ dependencies = [ "accesskit", "accesskit_consumer 0.36.0", "atspi-common", - "phf", + "phf 0.13.1", "serde", "zvariant", ] @@ -134,6 +134,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -483,6 +492,150 @@ dependencies = [ "piper", ] +[[package]] +name = "boa_ast" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9221b0a090572b4a118b46c5874c0781fe4bc9546b85f0a615b8bfb609ca4ed7" +dependencies = [ + "bitflags 2.13.2", + "boa_interner", + "boa_macros", + "boa_string", + "indexmap", + "num-bigint", + "rustc-hash 2.1.3", + "strum", +] + +[[package]] +name = "boa_engine" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6aaf7fdc4299df68adb67e1a5440a51d34630a1f5271b9698fd74bc97b60d9" +dependencies = [ + "aligned-vec", + "arrayvec", + "async-channel", + "bitflags 2.13.2", + "boa_ast", + "boa_gc", + "boa_interner", + "boa_macros", + "boa_parser", + "boa_string", + "bytemuck", + "cfg-if", + "cow-utils", + "dashmap", + "dynify", + "fast-float2", + "futures-concurrency", + "futures-lite", + "getrandom 0.4.3", + "hashbrown 0.17.1", + "icu_normalizer", + "indexmap", + "intrusive-collections", + "itertools", + "num-bigint", + "num-integer", + "num-traits", + "num_enum", + "oneshot", + "pastey", + "portable-atomic", + "rand 0.10.3", + "regress", + "rustc-hash 2.1.3", + "ryu-js", + "serde", + "serde_json", + "small_btree", + "static_assertions", + "tag_ptr", + "tap", + "thin-vec", + "thiserror 2.0.21", + "time", + "web-time", +] + +[[package]] +name = "boa_gc" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af82119558ab1002d1978c8459b53a3ebb506177d29a3aca37c07c1777b1d966" +dependencies = [ + "arrayvec", + "boa_macros", + "boa_string", + "hashbrown 0.17.1", + "thin-vec", +] + +[[package]] +name = "boa_interner" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317c631e791f5cb7d0017e43c0a758b0eaffd40c34c4c360e2a6be7752b78cd1" +dependencies = [ + "boa_gc", + "boa_macros", + "hashbrown 0.17.1", + "indexmap", + "once_cell", + "phf 0.14.0", + "rustc-hash 2.1.3", + "static_assertions", +] + +[[package]] +name = "boa_macros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b882fd440dc0f4b7441367cf89567bb077920356750c9a76a16c002d8ec2fa5c" +dependencies = [ + "cfg-if", + "cow-utils", + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "boa_parser" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641e81b3c8cbb7a2140547fb3ea534e9edf210b4e8c920b5fa864a919e11fbcf" +dependencies = [ + "bitflags 2.13.2", + "boa_ast", + "boa_interner", + "boa_macros", + "fast-float2", + "icu_properties", + "num-bigint", + "num-traits", + "regress", + "rustc-hash 2.1.3", +] + +[[package]] +name = "boa_string" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bf86c2bcff5bf2547acbfc516c5d3108ca527cb131511d749bf6fbef762c0e" +dependencies = [ + "fast-float2", + "itoa", + "pastey", + "rustc-hash 2.1.3", + "ryu-js", + "static_assertions", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -596,6 +749,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -696,6 +860,21 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -742,6 +921,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "dispatch" version = "0.2.0" @@ -758,6 +957,17 @@ dependencies = [ "objc2 0.6.4", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "dlib" version = "0.5.3" @@ -788,6 +998,26 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dynify" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81acb15628a3e22358bf73de5e7e62360b8a777dbcb5fc9ac7dfa9ae73723747" +dependencies = [ + "dynify-macros", +] + +[[package]] +name = "dynify-macros" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec431cd708430d5029356535259c5d645d60edd3d39c54e5eea9782d46caa7d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ecolor" version = "0.36.2" @@ -849,6 +1079,21 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "effectcraft-expr" +version = "0.1.0" +dependencies = [ + "boa_engine", + "effectcraft-color", + "effectcraft-effects", + "effectcraft-geom", + "effectcraft-keyframe", + "effectcraft-path", + "effectcraft-project", + "effectcraft-render", + "effectcraft-time", +] + [[package]] name = "effectcraft-geom" version = "0.1.0" @@ -1183,6 +1428,26 @@ version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1256,6 +1521,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "fast-float2" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f" + [[package]] name = "fastrand" version = "2.5.0" @@ -1449,6 +1720,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1508,6 +1785,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1592,8 +1882,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1701,6 +1994,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.1" @@ -1718,9 +2017,17 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -1733,6 +2040,91 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "image" version = "0.25.10" @@ -1773,6 +2165,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "intrusive-collections" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4275b20e6057cd7733fd8df8a5a31701e4fe44497dad0f3fa0e1c4fb971506be" + [[package]] name = "itertools" version = "0.15.0" @@ -1984,6 +2382,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "litrs" version = "1.0.0" @@ -2139,6 +2543,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -2149,6 +2564,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2181,6 +2611,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -2496,6 +2935,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oneshot" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c" + [[package]] name = "orbclient" version = "0.3.55" @@ -2560,6 +3005,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "peniko" version = "0.6.1" @@ -2585,11 +3036,21 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -2597,7 +3058,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", ] [[package]] @@ -2606,8 +3077,21 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -2622,6 +3106,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -2734,6 +3227,21 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2783,7 +3291,7 @@ dependencies = [ "bit-vec 0.8.0", "bitflags 2.13.2", "num-traits", - "rand", + "rand 0.9.5", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -2870,7 +3378,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -2880,7 +3399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -2892,13 +3411,19 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -3004,6 +3529,15 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "regress" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eef8b209c3c1c15dbad02c1f30f9539f00dc7253e0cbcdaae442a50a09d7c1" +dependencies = [ + "memchr", +] + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -3075,6 +3609,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" @@ -3240,6 +3780,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "small_btree" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba60d2df92ba73864714808ca68c059734853e6ab722b40e1cf543ebb3a057a" +dependencies = [ + "arrayvec", +] + [[package]] name = "smallvec" version = "1.16.2" @@ -3330,6 +3879,12 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -3342,6 +3897,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "symphonia" version = "0.5.5" @@ -3522,6 +4098,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tag_ptr" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e973b34477b7823833469eb0f5a3a60370fef7a453e02d751b59180d0a5a05" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.27.0" @@ -3544,6 +4143,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thin-vec" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568d7e143ec86d2021c338bae2afa88699e84b8e0af523626654fe6f03a1748" + [[package]] name = "thiserror" version = "1.0.69" @@ -3598,6 +4203,39 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "js-sys", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.12.0" @@ -3623,6 +4261,16 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.53.1" @@ -3769,6 +4417,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.26.1" @@ -4591,6 +5251,18 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "x11-dl" version = "2.21.0" @@ -4654,6 +5326,29 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + [[package]] name = "zbus" version = "5.19.0" @@ -4780,6 +5475,60 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index 96820578..4c5def65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ effectcraft-text = { path = "crates/text" } effectcraft-effects = { path = "crates/effects" } effectcraft-render = { path = "crates/render" } effectcraft-media = { path = "crates/media" } +effectcraft-expr = { path = "crates/expr" } effectcraft-engine = { path = "crates/engine" } effectcraft-ui-egui = { path = "crates/ui-egui" } effectcraft-automation = { path = "crates/automation" } @@ -35,6 +36,9 @@ log = "0.4" rayon = "1" proptest = "1" +# scripting (expressions) +boa_engine = { version = "0.22", default-features = false } + # geometry / fonts kurbo = { version = "0.13", features = ["serde"] } tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd"] } diff --git a/crates/expr/Cargo.toml b/crates/expr/Cargo.toml new file mode 100644 index 00000000..92dc1d81 --- /dev/null +++ b/crates/expr/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "effectcraft-expr" +description = "After Effects-compatible expressions: a boa JavaScript runtime with the AE object model (thisComp, thisLayer, wiggle, loopOut, valueAtTime…) and array maths" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +effectcraft-time = { workspace = true } +effectcraft-geom = { workspace = true } +effectcraft-keyframe = { workspace = true } +effectcraft-project = { workspace = true } +effectcraft-path = { workspace = true } +effectcraft-render = { workspace = true } +boa_engine = { workspace = true } + +# On the web, boa's clock and Math.random need the browser (getrandom `wasm_js`, web-time). +[target.'cfg(target_arch = "wasm32")'.dependencies] +boa_engine = { workspace = true, features = ["js"] } + +[dev-dependencies] +effectcraft-color = { workspace = true } +effectcraft-effects = { workspace = true } diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs new file mode 100644 index 00000000..23362970 --- /dev/null +++ b/crates/expr/src/host.rs @@ -0,0 +1,352 @@ +//! Host requests: everything an expression reads from the project. +//! +//! Scripts never hold references into the project. Instead they ask for plain data +//! ([`Req`] → [`Resp`]) through one native function. A request that isn't answered yet is +//! recorded as a *miss* and the script is aborted; the evaluator then resolves the misses here +//! (with full access to the project, which may recursively evaluate other expressions), memoizes +//! the answers and runs the script again. Expressions are pure functions of these answers, so +//! re-running is exact, and the runtime stays free of borrowed state. + +use effectcraft_geom::{Mat3, Mat4}; +use effectcraft_keyframe::Value; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property}; +use effectcraft_render::{EvalCtx, source_size}; +use effectcraft_time::Tick; + +/// A name-or-index argument (`layer("A")`, `layer(2)`, `effect(1)`). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Key { + Index(i64), + Name(String), +} + +/// Seconds as hashable bits. +pub type Secs = u64; + +pub fn secs(t: f64) -> Secs { + t.to_bits() +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Req { + /// `[name, width, height, duration, frameDuration, numLayers, pixelAspect, displayStart, bgColor]`. + CompInfo { comp: u64 }, + /// Comp item id or null. + CompByName { name: String }, + /// Layer id or null. + LayerLookup { comp: u64, key: Key }, + /// `[name, index, inPoint, outPoint, startTime, width, height, parentId|null, hasVideo, is3D, source, enabled]`. + LayerInfo { comp: u64, layer: u64 }, + /// `[path, isGroup, name, matchName, numChildren]` or null. + Child { comp: u64, layer: u64, group: String, key: Key }, + /// `[name, uid, kind, dims, keyTimes, keyValues, hasExpression, propertyIndex, matchName]`. + PropInfo { comp: u64, layer: u64, path: String }, + /// Value at comp time `t`; `pre` = keyframes only (no expression). + Value { comp: u64, layer: u64, path: String, t: Secs, pre: bool }, + /// `[layerToComp (3×3), compToLayer, layerToWorld (4×4), worldToLayer]`, row-major. + Xform { comp: u64, layer: u64, t: Secs }, + /// `[top, left, width, height]` in layer space. + SourceRect { comp: u64, layer: u64, t: Secs, extents: bool }, + /// `[[time, duration, comment, chapter, url], …]` for a layer, or the comp when `layer` is None. + Markers { comp: u64, layer: Option }, +} + +/// Plain data answer, converted to a JS value by the runtime. +#[derive(Clone, Debug, PartialEq)] +pub enum Resp { + Null, + Bool(bool), + Num(f64), + Str(String), + List(Vec), +} + +impl Resp { + fn nums(v: impl IntoIterator) -> Resp { + Resp::List(v.into_iter().map(Resp::Num).collect()) + } +} + +/// Number of components an expression sees: 2D layers see two-dimensional Position, Scale and +/// Anchor Point even though they're stored in 3D. +pub fn prop_dims(layer: &Layer, prop: &Property) -> usize { + match &prop.value { + Value::Vec3(_) if prop.shown_dims == 2 && !layer.is_3d() => 2, + v => v.dims(), + } +} + +/// JS-facing kind of a property's value. +pub fn kind_name(v: &Value) -> &'static str { + match v { + Value::Scalar(_) => "number", + Value::Vec2(_) | Value::Vec3(_) => "array", + Value::Color(_) => "color", + Value::Bool(_) => "bool", + Value::Enum(_) => "enum", + Value::Path(_) => "path", + Value::Text(_) | Value::Str(_) => "text", + Value::Layer(_) => "layer", + Value::Gradient(_) => "other", + } +} + +/// A property value as expression data. +pub fn value_resp(v: &Value, dims: usize) -> Resp { + match v { + Value::Scalar(x) => Resp::Num(*x), + Value::Vec2(_) | Value::Vec3(_) | Value::Color(_) => Resp::nums(v.components().into_iter().take(dims.max(1))), + Value::Bool(b) => Resp::Num(if *b { 1.0 } else { 0.0 }), + // Popups are 1-based in expressions. + Value::Enum(i) => Resp::Num(*i as f64 + 1.0), + Value::Text(t) => Resp::Str(t.text.clone()), + Value::Str(s) => Resp::Str(s.clone()), + Value::Layer(l) => l.map_or(Resp::Null, |l| Resp::Num(l as f64)), + Value::Path(p) => { + let pts = |v: &[[f64; 2]]| Resp::List(v.iter().map(|q| Resp::nums(q.iter().copied())).collect()); + Resp::List(vec![pts(&p.vertices), pts(&p.in_tangents), pts(&p.out_tangents), Resp::Bool(p.closed)]) + } + Value::Gradient(_) => Resp::Null, + } +} + +/// The property being evaluated (it may not be reachable by path, e.g. in standalone use). +pub struct Own<'a> { + pub comp: u64, + pub layer: &'a Layer, + pub prop: &'a Property, + pub path: String, +} + +pub struct Resolver<'a> { + pub ctx: EvalCtx<'a>, + pub own: Own<'a>, +} + +fn mat3(m: &Mat3) -> Resp { + Resp::nums(m.0.iter().flatten().copied()) +} +fn mat4(m: &Mat4) -> Resp { + Resp::nums(m.0.iter().flatten().copied()) +} + +fn compact(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect() +} + +/// AE attribute names that differ from our match ids. +fn alias(key: &str) -> &str { + match key { + "anchorPoint" => "anchor", + "xRotation" => "rotationX", + "yRotation" => "rotationY", + "zRotation" => "rotation", + "pointOfInterest" => "poi", + "maskPath" => "path", + "maskOpacity" => "opacity", + "maskFeather" => "feather", + "maskExpansion" => "expansion", + "effect" => "effects", + "mask" => "masks", + "content" => "contents", + "strokeWidth" => "width", + k => k, + } +} + +fn find_child<'g>(g: &'g PropGroup, key: &Key) -> Option<&'g Node> { + match key { + Key::Index(i) => usize::try_from(*i).ok().filter(|i| *i >= 1).and_then(|i| g.children.get(i - 1)), + Key::Name(name) => { + let m = alias(name); + let c = compact(name); + g.children + .iter() + .find(|n| n.match_id() == m) + .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(name))) + .or_else(|| g.children.iter().find(|n| compact(n.name()) == c || compact(n.match_id()) == c)) + } + } +} + +impl<'a> Resolver<'a> { + fn comp(&self, c: u64) -> Option<&'a Comp> { + if c == self.ctx.comp_id.0 { Some(self.ctx.comp) } else { self.ctx.project.comp(ItemId(c)) } + } + fn layer(&self, c: u64, l: u64) -> Option<&'a Layer> { + if c == self.own.comp && l == self.own.layer.id.0 { + return Some(self.own.layer); + } + self.comp(c)?.layer(LayerId(l)) + } + fn ctx_at(&self, c: u64, t: f64) -> Option> { + let comp = self.comp(c)?; + Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr }) + } + fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> { + let layer = self.layer(c, l)?; + if c == self.own.comp && l == self.own.layer.id.0 && path == self.own.path { + return Some((layer, self.own.prop)); + } + Some((layer, layer.props.prop(path)?)) + } + + pub fn resolve(&self, req: &Req) -> Resp { + self.try_resolve(req).unwrap_or(Resp::Null) + } + + fn try_resolve(&self, req: &Req) -> Option { + let project = self.ctx.project; + Some(match req { + Req::CompInfo { comp } => { + let c = self.comp(*comp)?; + let name = project.item(ItemId(*comp)).map(|i| i.name.clone()).unwrap_or_default(); + Resp::List(vec![ + Resp::Str(name), + Resp::Num(c.width as f64), + Resp::Num(c.height as f64), + Resp::Num(c.duration.seconds()), + Resp::Num(c.frame_duration().seconds()), + Resp::Num(c.layers.len() as f64), + Resp::Num(c.pixel_aspect), + Resp::Num(c.display_start.seconds()), + Resp::nums([c.background[0] as f64, c.background[1] as f64, c.background[2] as f64, 1.0]), + ]) + } + Req::CompByName { name } => { + let item = project.items.values().find(|i| matches!(i.kind, ItemKind::Comp(_)) && &i.name == name)?; + Resp::Num(item.id.0 as f64) + } + Req::LayerLookup { comp, key } => { + let c = self.comp(*comp)?; + let l = match key { + Key::Index(i) => usize::try_from(*i).ok().filter(|i| *i >= 1).and_then(|i| c.layers.get(i - 1))?, + Key::Name(n) => c.layer_by_name(n)?, + }; + Resp::Num(l.id.0 as f64) + } + Req::LayerInfo { comp, layer } => { + let c = self.comp(*comp)?; + let l = self.layer(*comp, *layer)?; + let (mut w, mut h) = source_size(project, l); + if w == 0 || h == 0 { + (w, h) = (c.width, c.height); + } + Resp::List(vec![ + Resp::Str(l.name.clone()), + Resp::Num(c.index_of(l.id).unwrap_or(0) as f64), + Resp::Num(l.in_point.seconds()), + Resp::Num(l.out_point.seconds()), + Resp::Num(l.start_time.seconds()), + Resp::Num(w as f64), + Resp::Num(h as f64), + l.parent.map_or(Resp::Null, |p| Resp::Num(p.0 as f64)), + Resp::Bool(l.has_video()), + Resp::Bool(l.is_3d()), + Resp::Str(l.source.type_name().to_string()), + Resp::Bool(l.switches.video), + ]) + } + Req::Child { comp, layer, group, key } => { + let l = self.layer(*comp, *layer)?; + let g = if group.is_empty() { &l.props } else { l.props.group(group)? }; + let n = find_child(g, key)?; + let path = if group.is_empty() { format!("@{}", n.uid()) } else { format!("{group}/@{}", n.uid()) }; + let count = n.as_group().map_or(0, |g| g.children.len()); + Resp::List(vec![ + Resp::Str(path), + Resp::Bool(n.as_group().is_some()), + Resp::Str(n.name().into()), + Resp::Str(n.match_id().into()), + Resp::Num(count as f64), + ]) + } + Req::PropInfo { comp, layer, path } => { + let (l, p) = self.prop(*comp, *layer, path)?; + let dims = prop_dims(l, p); + let times = p.keys.iter().map(|k| Resp::Num(l.comp_time(k.time).seconds())).collect(); + let values = p.keys.iter().map(|k| value_resp(&k.value, dims)).collect(); + let index = path.rsplit('/').next().and_then(|last| { + let parent = path.rsplit_once('/').map(|(g, _)| g); + let g = match parent { + Some(g) => l.props.group(g)?, + None => &l.props, + }; + g.children.iter().position(|n| format!("@{}", n.uid()) == last).map(|i| i + 1) + }); + Resp::List(vec![ + Resp::Str(p.name.clone()), + Resp::Num(p.uid as f64), + Resp::Str(kind_name(&p.value).into()), + Resp::Num(dims as f64), + Resp::List(times), + Resp::List(values), + Resp::Bool(p.has_expression()), + Resp::Num(index.unwrap_or(1) as f64), + Resp::Str(p.match_id.clone()), + ]) + } + Req::Value { comp, layer, path, t, pre } => { + let (l, p) = self.prop(*comp, *layer, path)?; + let t = f64::from_bits(*t); + let v = if *pre { p.value_at(l.layer_time(Tick::from_seconds_f64(t))) } else { self.ctx_at(*comp, t)?.value(l, p) }; + value_resp(&v, prop_dims(l, p)) + } + Req::Xform { comp, layer, t } => { + let l = self.layer(*comp, *layer)?; + let ctx = self.ctx_at(*comp, f64::from_bits(*t))?; + let (m3, _) = ctx.layer_to_comp(l); + let m4 = ctx.world_matrix(l); + Resp::List(vec![mat3(&m3), mat3(&m3.inverse().unwrap_or_default()), mat4(&m4), mat4(&m4.inverse().unwrap_or_default())]) + } + Req::SourceRect { comp, layer, t, extents: _ } => { + let l = self.layer(*comp, *layer)?; + let ctx = self.ctx_at(*comp, f64::from_bits(*t))?; + let r = match &l.source { + LayerSource::Text => { + let paths: Vec<_> = effectcraft_render::text::glyph_paths(&ctx, l).into_iter().map(|(p, _)| p).collect(); + effectcraft_path::bounds(&paths).map(|b| [b.y0, b.x0, b.width(), b.height()]).unwrap_or([0.0; 4]) + } + LayerSource::Shape => { + let contents = l.props.sub("contents")?; + let buf = effectcraft_render::shapes::render(&ctx, l, contents, 1.0); + if buf.img.width <= 4 { + [0.0; 4] + } else { + // `render` pads its buffer by 2 px on each side. + [2.0 - buf.offset[1], 2.0 - buf.offset[0], buf.img.width as f64 - 4.0, buf.img.height as f64 - 4.0] + } + } + _ => { + let (w, h) = source_size(project, l); + [0.0, 0.0, w as f64, h as f64] + } + }; + Resp::nums(r) + } + Req::Markers { comp, layer } => { + let marks: Vec<(f64, &effectcraft_project::Marker)> = match layer { + Some(l) => { + let l = self.layer(*comp, *l)?; + l.markers.iter().map(|m| (l.comp_time(m.time).seconds(), m)).collect() + } + None => self.comp(*comp)?.markers.iter().map(|m| (m.time.seconds(), m)).collect(), + }; + Resp::List( + marks + .into_iter() + .map(|(t, m)| { + Resp::List(vec![ + Resp::Num(t), + Resp::Num(m.duration.seconds()), + Resp::Str(m.comment.clone()), + Resp::Str(m.chapter.clone()), + Resp::Str(m.url.clone()), + ]) + }) + .collect(), + ) + } + }) + } +} diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs new file mode 100644 index 00000000..48049cf3 --- /dev/null +++ b/crates/expr/src/lib.rs @@ -0,0 +1,144 @@ +//! After Effects-compatible expressions on the boa JavaScript engine. +//! +//! [`Expressions`] implements [`ExprHost`] so the renderer can evaluate property expressions: +//! +//! ```text +//! let host = effectcraft_expr::Expressions; +//! let mut r = Renderer::new(&project, &NoFootage, RenderOpts::default()); +//! r.expr = Some(&host); +//! ``` +//! +//! * **Language**: JavaScript plus AE's legacy array maths (`value + [10, 0]`, `[1, 2] * 2`), +//! implemented by rewriting `+ - * /` into helper calls ([`rewrite`]). The last evaluated +//! statement is the result (`var x = …; x`, `if/else` blocks). +//! * **Object model** (`prelude.js`): `time`, `value`, `thisComp`, `thisLayer`, `thisProperty`, +//! `comp(name)`, layer attributes (`index`, `inPoint`, `width`, `transform`, `effect(…)(…)`, +//! `mask`, `content`, `text.sourceText`, `marker`, `toComp`/`fromComp`/`toWorld`/`fromWorld`, +//! `sourceRectAtTime`), property methods (`valueAtTime`, `velocityAtTime`, `speedAtTime`, `key`, +//! `nearestKey`, `numKeys`, `wiggle`, `temporalWiggle`, `smooth`, `loopIn/Out[Duration]`) and the +//! global helpers (`linear`/`ease`/`easeIn`/`easeOut`, `random`/`gaussRandom`/`seedRandom`/ +//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`). +//! * **Determinism**: `wiggle` and `random` are seeded by layer index, property uid (and time / +//! `seedRandom`), so frames render identically on every thread and platform. +//! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read +//! the project through memoized host requests (see [`host`]), so nothing borrowed ever enters +//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels. + +pub mod host; +mod noise; +pub mod rewrite; +mod runtime; + +use effectcraft_keyframe::Value; +use effectcraft_project::{ItemId, Layer, LayerId, Project, Property}; +use effectcraft_render::{EvalCtx, ExprHost}; +use effectcraft_time::Tick; + +use host::{Own, Req, Resolver, prop_dims, value_resp}; +use runtime::{Begin, FRAMES, Frame, Run}; + +/// Maximum nesting of expressions that read other expressions (cycles stop here). +pub const MAX_DEPTH: usize = 16; +/// Maximum script re-runs while answering host requests. +const MAX_PASSES: usize = 2000; + +/// The expression engine (stateless; runtimes are per thread). +#[derive(Clone, Copy, Debug, Default)] +pub struct Expressions; + +impl ExprHost for Expressions { + fn eval(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value) -> Result { + match &prop.expr { + Some(e) => evaluate(self, ctx, layer, prop, value, &e.text), + None => Ok(value.clone()), + } + } +} + +/// Pops the evaluation frame on every exit path. +struct FrameGuard; +impl Drop for FrameGuard { + fn drop(&mut self) { + FRAMES.with(|f| f.borrow_mut().pop()); + } +} + +/// Evaluate `text` as the expression of `prop` on `layer` at `ctx.time`, given its keyframed +/// `value`. Other properties' expressions are evaluated through `host`. +pub fn evaluate(host: &dyn ExprHost, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value, text: &str) -> Result { + let depth = FRAMES.with(|f| f.borrow().len()); + if depth >= MAX_DEPTH { + return Err("Error: expressions reference each other too deeply (circular reference?)".into()); + } + let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host) }; + let comp = ctx.comp_id.0; + let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid)); + let resolver = Resolver { ctx, own: Own { comp, layer, prop, path: path.clone() } }; + let current = value_resp(value, prop_dims(layer, prop)); + let begin = Begin { + time: ctx.time.seconds(), + value: ¤t, + comp, + layer: layer.id.0, + path: &path, + uid: prop.uid, + index: ctx.comp.index_of(layer.id).unwrap_or(0) as f64, + frame_duration: ctx.comp.frame_duration().seconds(), + }; + // Answer the requests nearly every expression makes up front. + let mut frame = Frame::default(); + for req in [Req::CompInfo { comp }, Req::LayerInfo { comp, layer: layer.id.0 }, Req::PropInfo { comp, layer: layer.id.0, path: path.clone() }] { + let r = resolver.resolve(&req); + frame.memo.insert(req, r); + } + FRAMES.with(|f| f.borrow_mut().push(frame)); + let _guard = FrameGuard; + for _ in 0..MAX_PASSES { + match runtime::run(text, &begin) { + Run::Done(r) => return r.and_then(|o| runtime::to_value(&o, value)), + Run::Pending => { + let misses = FRAMES.with(|f| f.borrow_mut().get_mut(depth).map(|fr| std::mem::take(&mut fr.misses)).unwrap_or_default()); + for m in misses { + // May evaluate other expressions (nested frames above ours). + let r = resolver.resolve(&m); + FRAMES.with(|f| f.borrow_mut().get_mut(depth).map(|fr| fr.memo.insert(m, r))); + } + } + } + } + Err("Error: expression needs too many property reads".into()) +} + +/// Evaluate `text` as if it were the expression on the property at `path` (e.g. +/// `transform/rotation`) of `layer` in `comp`, at comp time `t` seconds. Other properties' +/// expressions are evaluated too. +pub fn eval_standalone(project: &Project, comp: ItemId, layer: LayerId, path: &str, text: &str, t: f64) -> Result { + let c = project.comp(comp).ok_or("no such comp")?; + let l = c.layer(layer).ok_or("no such layer")?; + let prop = l.props.prop(path).ok_or_else(|| format!("no property at {path}"))?; + let ctx = EvalCtx::new(project, comp, c, Tick::from_seconds_f64(t)); + let value = prop.value_at(l.layer_time(ctx.time)); + evaluate(&Expressions, &ctx, l, prop, &value, text) +} + +/// Evaluate the expression stored on the property at `path`, reporting errors (the renderer +/// silently falls back to the keyframed value instead). +pub fn eval_property(project: &Project, comp: ItemId, layer: LayerId, path: &str, t: f64) -> Result { + let c = project.comp(comp).ok_or("no such comp")?; + let l = c.layer(layer).ok_or("no such layer")?; + let prop = l.props.prop(path).ok_or_else(|| format!("no property at {path}"))?; + let ctx = EvalCtx::new(project, comp, c, Tick::from_seconds_f64(t)); + let value = prop.value_at(l.layer_time(ctx.time)); + match &prop.expr { + Some(e) if prop.has_expression() => evaluate(&Expressions, &ctx, l, prop, &value, &e.text), + _ => Ok(value), + } +} + +/// Syntax-check an expression (after the array-maths rewrite) without evaluating it. +pub fn check_syntax(text: &str) -> Result<(), String> { + runtime::check(text) +} + +#[cfg(test)] +mod tests; diff --git a/crates/expr/src/noise.rs b/crates/expr/src/noise.rs new file mode 100644 index 00000000..854f15ce --- /dev/null +++ b/crates/expr/src/noise.rs @@ -0,0 +1,109 @@ +//! Deterministic hashing, random numbers and smooth noise for `wiggle`, `random` and `noise`. +//! +//! Everything is a pure function of its seeds, so a frame renders identically on any thread, +//! in any order, on native and on the web. + +/// SplitMix64 finaliser: a good 64-bit mixing function. +pub fn hash64(mut x: u64) -> u64 { + x = x.wrapping_add(0x9e37_79b9_7f4a_7c15); + x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + x ^ (x >> 31) +} + +/// Combine several seeds into one. +pub fn mix(parts: &[u64]) -> u64 { + parts.iter().fold(0x2545_f491_4f6c_dd1d, |h, p| hash64(h ^ hash64(*p))) +} + +/// Uniform in `[0, 1)`. +pub fn unit(seed: u64) -> f64 { + (hash64(seed) >> 11) as f64 / (1u64 << 53) as f64 +} + +/// Lattice value in `[-1, 1]`. +fn lattice(seed: u64, i: i64) -> f64 { + unit(seed ^ hash64(i as u64)) * 2.0 - 1.0 +} + +fn fade(t: f64) -> f64 { + t * t * t * (t * (t * 6.0 - 15.0) + 10.0) +} + +/// Smooth 1D value noise in `[-1, 1]` (C2-continuous, bounded by the lattice values). +pub fn noise1(seed: u64, x: f64) -> f64 { + if !x.is_finite() { + return 0.0; + } + let i = x.floor(); + let u = fade(x - i); + let i = i as i64; + let (a, b) = (lattice(seed, i), lattice(seed, i + 1)); + a + (b - a) * u +} + +/// Smooth 3D value noise in `[-1, 1]` (AE's `noise()`: nearby inputs give nearby outputs). +pub fn noise3(x: f64, y: f64, z: f64) -> f64 { + if !(x.is_finite() && y.is_finite() && z.is_finite()) { + return 0.0; + } + let (fx, fy, fz) = (x.floor(), y.floor(), z.floor()); + let (u, v, w) = (fade(x - fx), fade(y - fy), fade(z - fz)); + let (ix, iy, iz) = (fx as i64, fy as i64, fz as i64); + let g = |dx: i64, dy: i64, dz: i64| unit(mix(&[(ix + dx) as u64, (iy + dy) as u64, (iz + dz) as u64])) * 2.0 - 1.0; + let l = |a: f64, b: f64, t: f64| a + (b - a) * t; + let x00 = l(g(0, 0, 0), g(1, 0, 0), u); + let x10 = l(g(0, 1, 0), g(1, 1, 0), u); + let x01 = l(g(0, 0, 1), g(1, 0, 1), u); + let x11 = l(g(0, 1, 1), g(1, 1, 1), u); + l(l(x00, x10, v), l(x01, x11, v), w) +} + +/// Per-dimension wiggle offsets: `Σ amp·mult^k · noise(freq·2^k·t)` over `octaves`. +/// With one octave every offset lies in `[-amp, amp]`. +pub fn wiggle(seed: u64, dims: usize, freq: f64, amp: f64, octaves: f64, amp_mult: f64, t: f64) -> Vec { + let octaves = if octaves.is_finite() { octaves.clamp(1.0, 16.0) as usize } else { 1 }; + (0..dims) + .map(|d| { + let mut a = amp; + let mut f = freq; + let mut sum = 0.0; + for k in 0..octaves { + let s = mix(&[seed, d as u64, k as u64]); + // Offset each octave's phase so octaves don't share lattice points. + sum += a * noise1(s, f * t + unit(s) * 1000.0); + a *= amp_mult; + f *= 2.0; + } + sum + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn noise_is_bounded_smooth_and_deterministic() { + let mut prev = noise1(7, 0.0); + for i in 1..10_000 { + let x = i as f64 * 0.001; + let v = noise1(7, x); + assert!((-1.0..=1.0).contains(&v)); + assert!((v - prev).abs() < 0.01, "jump at {x}"); + prev = v; + } + assert_eq!(noise1(3, 1.25), noise1(3, 1.25)); + assert_ne!(noise1(3, 1.25), noise1(4, 1.25)); + assert!((-1.0..=1.0).contains(&noise3(0.3, 4.2, -7.5))); + } + + #[test] + fn wiggle_bounds() { + for i in 0..2000 { + let w = wiggle(99, 2, 3.0, 50.0, 1.0, 0.5, i as f64 / 30.0); + assert!(w.iter().all(|v| v.abs() <= 50.0)); + } + } +} diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js new file mode 100644 index 00000000..ca9f95e4 --- /dev/null +++ b/crates/expr/src/prelude.js @@ -0,0 +1,724 @@ +// EffectCraft expression runtime: the After Effects expression object model and global +// functions, written from the public Expression Language Reference (behaviour only). +// +// Natives (Rust): __h(op, ...) host data request; __wig(...) wiggle offsets; __noise(x, y, z); +// __rnd(...) seeded random. This file is evaluated once per runtime and is NOT passed through +// the array-maths rewriter, so it uses plain JS operators. + +var time = 0; +var value = 0; +var __S = null; +var __rc = 0; +var __seed = 0; +var __timeless = false; + +// Request ops (must match runtime.rs). +var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9; + +function __begin(t, v, c, l, path, uid, idx, fd) { + time = t; + value = v; + __S = { c: c, l: l, path: path, uid: uid, idx: idx, fd: fd, comp: null, layer: null, prop: null, vars: {} }; + __rc = 0; + __seed = 0; + __timeless = false; +} + +function __finish(r) { + r = __v(r); + if (r !== null && typeof r === 'object' && r.__isLayer) return r.index; + return r; +} + +function __err(msg) { + throw new Error(msg); +} + +// --------------------------------------------------------------------------------------------- +// Array maths (targets of the operator rewrite). + +function __v(x) { + if (x !== null && typeof x === 'object' && x.__isProp === true) return x.value; + return x; +} + +function __bin(a, b, f) { + a = __v(a); + b = __v(b); + var aa = Array.isArray(a), ba = Array.isArray(b), i, r; + if (aa && ba) { + var n = Math.max(a.length, b.length); + r = new Array(n); + for (i = 0; i < n; i++) r[i] = f(i < a.length ? a[i] : 0, i < b.length ? b[i] : 0); + return r; + } + if (aa && (typeof b === 'number' || typeof b === 'boolean')) { + r = new Array(a.length); + for (i = 0; i < a.length; i++) r[i] = f(a[i], b); + return r; + } + if (ba && (typeof a === 'number' || typeof a === 'boolean')) { + r = new Array(b.length); + for (i = 0; i < b.length; i++) r[i] = f(a, b[i]); + return r; + } + return f(a, b); +} + +function __fadd(x, y) { return x + y; } +function __fsub(x, y) { return x - y; } +function __fmul(x, y) { return x * y; } +function __fdiv(x, y) { return x / y; } + +function __add(a, b) { + if (typeof a === 'number' && typeof b === 'number') return a + b; + if (typeof a === 'string' || typeof b === 'string') return __v(a) + __v(b); + var x = __v(a), y = __v(b); + if (typeof x === 'string' || typeof y === 'string') return x + y; + if ((Array.isArray(x) && typeof y === 'string') || (Array.isArray(y) && typeof x === 'string')) return x + y; + return __bin(x, y, __fadd); +} +function __sub(a, b) { + if (typeof a === 'number' && typeof b === 'number') return a - b; + return __bin(a, b, __fsub); +} +function __mul(a, b) { + if (typeof a === 'number' && typeof b === 'number') return a * b; + return __bin(a, b, __fmul); +} +function __div(a, b) { + if (typeof a === 'number' && typeof b === 'number') return a / b; + return __bin(a, b, __fdiv); +} +function __neg(a) { + if (typeof a === 'number') return -a; + a = __v(a); + if (Array.isArray(a)) return a.map(function (x) { return -x; }); + return -a; +} + +// --------------------------------------------------------------------------------------------- +// Vector maths. + +function add(a, b) { return __bin(a, b, __fadd); } +function sub(a, b) { return __bin(a, b, __fsub); } +function mul(a, b) { return __bin(a, b, __fmul); } +function div(a, b) { return __bin(a, b, __fdiv); } + +function clamp(v, a, b) { + v = __v(v); a = __v(a); b = __v(b); + var lo = function (x, y) { return Math.min(Math.max(x, Math.min(y[0], y[1])), Math.max(y[0], y[1])); }; + if (Array.isArray(v)) { + return v.map(function (x, i) { + return lo(x, [Array.isArray(a) ? a[i] : a, Array.isArray(b) ? b[i] : b]); + }); + } + return lo(v, [a, b]); +} + +function dot(a, b) { + a = __v(a); b = __v(b); + var s = 0; + for (var i = 0; i < Math.min(a.length, b.length); i++) s += a[i] * b[i]; + return s; +} + +function cross(a, b) { + a = __v(a); b = __v(b); + var a2 = a[2] || 0, b2 = b[2] || 0; + return [a[1] * b2 - a2 * b[1], a2 * b[0] - a[0] * b2, a[0] * b[1] - a[1] * b[0]]; +} + +function length(a, b) { + a = __v(a); + if (b !== undefined) a = sub(a, __v(b)); + if (!Array.isArray(a)) return Math.abs(a); + var s = 0; + for (var i = 0; i < a.length; i++) s += a[i] * a[i]; + return Math.sqrt(s); +} + +function normalize(a) { + a = __v(a); + var l = length(a); + return l === 0 ? a.map(function () { return 0; }) : div(a, l); +} + +function lookAt(from, at) { + var d = normalize(sub(__v(at), __v(from))); + var x = d[0], y = d[1], z = d[2] || 0; + var rx = -Math.asin(Math.max(-1, Math.min(1, y))) * 180 / Math.PI; + var ry = Math.atan2(x, z) * 180 / Math.PI; + return [rx, ry, 0]; +} + +function degreesToRadians(d) { return d * Math.PI / 180; } +function radiansToDegrees(r) { return r * 180 / Math.PI; } + +// --------------------------------------------------------------------------------------------- +// Interpolation. + +function __interp(shape, args) { + var t, tMin, tMax, v1, v2; + if (args.length <= 3) { + t = args[0]; tMin = 0; tMax = 1; v1 = args[1]; v2 = args[2]; + } else { + t = args[0]; tMin = args[1]; tMax = args[2]; v1 = args[3]; v2 = args[4]; + } + t = __v(t); v1 = __v(v1); v2 = __v(v2); + var u; + if (tMax === tMin) u = t >= tMax ? 1 : 0; + else u = (t - tMin) / (tMax - tMin); + u = Math.max(0, Math.min(1, u)); + u = shape(u); + if (Array.isArray(v1) || Array.isArray(v2)) return add(v1, mul(sub(v2, v1), u)); + return v1 + (v2 - v1) * u; +} + +function __lin(u) { return u; } +function __ease(u) { return u * u * (3 - 2 * u); } +function __easeIn(u) { return u * u * (2 - u); } +function __easeOut(u) { var w = 1 - u; return 1 - w * w * (2 - w); } + +function linear() { return __interp(__lin, arguments); } +function ease() { return __interp(__ease, arguments); } +function easeIn() { return __interp(__easeIn, arguments); } +function easeOut() { return __interp(__easeOut, arguments); } + +// --------------------------------------------------------------------------------------------- +// Random numbers and noise. + +function seedRandom(offset, timeless) { + __seed = offset; + __timeless = !!timeless; +} + +function __r01() { + return __rnd(__S.idx, __S.uid, __seed, __timeless, time, __rc++); +} + +function __scaleRand(r, args) { + if (args.length === 0) return r(); + var a = __v(args[0]); + if (args.length === 1) { + if (Array.isArray(a)) return a.map(function (m) { return r() * m; }); + return r() * a; + } + var b = __v(args[1]); + if (Array.isArray(a) || Array.isArray(b)) { + var n = Math.max(Array.isArray(a) ? a.length : 1, Array.isArray(b) ? b.length : 1), out = []; + for (var i = 0; i < n; i++) { + var lo = Array.isArray(a) ? (a[i] || 0) : a, hi = Array.isArray(b) ? (b[i] || 0) : b; + out.push(lo + r() * (hi - lo)); + } + return out; + } + return a + r() * (b - a); +} + +function random() { return __scaleRand(__r01, arguments); } + +function __gauss01() { + var u1 = Math.max(__r01(), 1e-12), u2 = __r01(); + var z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); + // ~90% of results fall in [0, 1]. + return 0.5 + z * 0.304; +} + +function gaussRandom() { return __scaleRand(__gauss01, arguments); } + +function noise(x) { + x = __v(x); + if (Array.isArray(x)) return __noise(x[0] || 0, x[1] || 0, x[2] || 0); + return __noise(x, 0, 0); +} + +// --------------------------------------------------------------------------------------------- +// Time conversion. + +function timeToFrames(t, fps, isDuration) { + if (t === undefined) t = time + thisComp.displayStartTime; + if (fps === undefined) fps = 1 / __S.fd; + return Math.floor(t * fps + 1e-6); +} + +function framesToTime(frames, fps) { + if (fps === undefined) fps = 1 / __S.fd; + return frames / fps; +} + +function __pad(n) { return n < 10 ? '0' + n : '' + n; } + +function timeToTimecode(t, base, isDuration) { + if (t === undefined) t = time + thisComp.displayStartTime; + if (base === undefined) base = 30; + var neg = t < 0; + var f = Math.floor(Math.abs(t) * base + 1e-6); + var ff = f % base, s = Math.floor(f / base), ss = s % 60, m = Math.floor(s / 60), mm = m % 60, h = Math.floor(m / 60); + return (neg ? '-' : '') + h + ':' + __pad(mm) + ':' + __pad(ss) + ':' + __pad(ff); +} + +function timeToCurrentFormat(t, fps) { + if (fps === undefined) fps = Math.round(1 / __S.fd); + return timeToTimecode(t, fps); +} + +function posterizeTime(fps) { + if (!(fps > 0)) return; + var t = Math.floor(time * fps + 1e-6) / fps; + if (t !== time) { + var v = thisProperty.valueAtTime(t); + time = t; + value = v; + } +} + +// --------------------------------------------------------------------------------------------- +// Colour. + +function rgbToHsl(c) { + c = __v(c); + var r = c[0], g = c[1], b = c[2], a = c.length > 3 ? c[3] : 1; + var mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2, h = 0, s = 0; + if (mx !== mn) { + var d = mx - mn; + s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); + if (mx === r) h = (g - b) / d + (g < b ? 6 : 0); + else if (mx === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h /= 6; + } + return [h, s, l, a]; +} + +function hslToRgb(c) { + c = __v(c); + var h = c[0], s = c[1], l = c[2], a = c.length > 3 ? c[3] : 1; + if (s === 0) return [l, l, l, a]; + var hue = function (p, q, t) { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + var q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q; + return [hue(p, q, h + 1 / 3), hue(p, q, h), hue(p, q, h - 1 / 3), a]; +} + +function hexToRgb(hex) { + var s = String(hex).replace(/^#|^0x/i, ''); + if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2]; + var n = parseInt(s.slice(0, 6), 16); + var a = s.length >= 8 ? parseInt(s.slice(6, 8), 16) / 255 : 1; + return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, a]; +} + +// --------------------------------------------------------------------------------------------- +// Paths. + +function __Path(pts, ins, outs, closed) { + this.__path = true; + this.__pts = pts; this.__in = ins; this.__out = outs; this.__closed = !!closed; +} +__Path.prototype.points = function () { return this.__pts; }; +__Path.prototype.inTangents = function () { return this.__in; }; +__Path.prototype.outTangents = function () { return this.__out; }; +__Path.prototype.isClosed = function () { return this.__closed; }; + +function createPath(points, inTangents, outTangents, isClosed) { + points = points || []; + var zero = points.map(function () { return [0, 0]; }); + return new __Path(points, inTangents || zero, outTangents || zero, isClosed === undefined ? true : isClosed); +} + +// --------------------------------------------------------------------------------------------- +// Object model. + +function __mod(a, d) { return ((a % d) + d) % d; } + +function __child(c, l, group, key) { + var info = __h(__CHILD, c, l, group, key); + if (info === null) return undefined; + return info[1] ? __mkGroup(c, l, info) : __mkProp(c, l, info[0]); +} + +// Comp ------------------------------------------------------------------------------------------ + +function Comp(c) { this.__c = c; this.__i = null; } +Comp.prototype = { + __isComp: true, + get __info() { return this.__i || (this.__i = __h(__COMP, this.__c)); }, + get name() { return this.__info[0]; }, + get width() { return this.__info[1]; }, + get height() { return this.__info[2]; }, + get duration() { return this.__info[3]; }, + get frameDuration() { return this.__info[4]; }, + get numLayers() { return this.__info[5]; }, + get pixelAspect() { return this.__info[6]; }, + get displayStartTime() { return this.__info[7]; }, + get bgColor() { return this.__info[8]; }, + get marker() { return new Markers(this.__c, -1); }, + layer: function (a, rel) { + if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0)); + var id = __h(__LAYER, this.__c, a); + if (id === null) __err(typeof a === 'string' ? 'Layer named "' + a + '" does not exist' : 'Layer index ' + a + ' out of range'); + return new Layer(this.__c, id); + }, + toString: function () { return '[object Comp]'; }, +}; + +function comp(name) { + var id = __h(__COMPNAME, String(name)); + if (id === null) __err('Comp named "' + name + '" does not exist'); + return new Comp(id); +} + +// Layer ----------------------------------------------------------------------------------------- + +function Layer(c, l) { this.__c = c; this.__l = l; this.__i = null; } + +function __m3(m, p) { + var x = p[0] || 0, y = p[1] || 0; + var X = m[0] * x + m[1] * y + m[2], Y = m[3] * x + m[4] * y + m[5], W = m[6] * x + m[7] * y + m[8]; + if (W !== 0 && W !== 1) { X /= W; Y /= W; } + return p.length > 2 ? [X, Y, 0] : [X, Y]; +} +function __m3v(m, p) { + var x = p[0] || 0, y = p[1] || 0; + return p.length > 2 ? [m[0] * x + m[1] * y, m[3] * x + m[4] * y, 0] : [m[0] * x + m[1] * y, m[3] * x + m[4] * y]; +} +function __m4(m, p, vec) { + var x = p[0] || 0, y = p[1] || 0, z = p[2] || 0, k = vec ? 0 : 1; + var X = m[0] * x + m[1] * y + m[2] * z + m[3] * k; + var Y = m[4] * x + m[5] * y + m[6] * z + m[7] * k; + var Z = m[8] * x + m[9] * y + m[10] * z + m[11] * k; + var W = vec ? 1 : m[12] * x + m[13] * y + m[14] * z + m[15]; + if (W !== 0 && W !== 1) { X /= W; Y /= W; Z /= W; } + return [X, Y, Z]; +} + +Layer.prototype = { + __isLayer: true, + get __info() { return this.__i || (this.__i = __h(__LINFO, this.__c, this.__l)); }, + get name() { return this.__info[0]; }, + get index() { return this.__info[1]; }, + get inPoint() { return this.__info[2]; }, + get outPoint() { return this.__info[3]; }, + get startTime() { return this.__info[4]; }, + get width() { return this.__info[5]; }, + get height() { return this.__info[6]; }, + get hasParent() { return this.__info[7] !== null; }, + get parent() { + var p = this.__info[7]; + if (p === null) __err('Layer "' + this.name + '" has no parent'); + return new Layer(this.__c, p); + }, + get hasVideo() { return this.__info[8]; }, + get enabled() { return this.__info[11]; }, + get active() { return this.__info[11] && time >= this.inPoint && time < this.outPoint; }, + get containingComp() { return new Comp(this.__c); }, + __group: function (k) { + var g = __child(this.__c, this.__l, '', k); + if (g === undefined) __err('Layer "' + this.name + '" has no property "' + k + '"'); + return g; + }, + get transform() { return this.__group('transform'); }, + get position() { return this.transform.position; }, + get scale() { return this.transform.scale; }, + get rotation() { return this.transform.rotation; }, + get opacity() { return this.transform.opacity; }, + get anchorPoint() { return this.transform.anchorPoint; }, + get text() { return this.__group('text'); }, + get marker() { return new Markers(this.__c, this.__l); }, + effect: function (k) { + var g = __child(this.__c, this.__l, '', 'effects'); + var e = g === undefined ? undefined : __child(g.__c, g.__l, g.__p, k); + if (e === undefined) __err('Effect ' + (typeof k === 'string' ? '"' + k + '"' : k) + ' not found on layer "' + this.name + '"'); + return e; + }, + mask: function (k) { return this.__group('masks').__get(k); }, + content: function (k) { return this.__group('contents').__get(k); }, + __x: function (t) { return __h(__XFORM, this.__c, this.__l, t === undefined ? time : t); }, + toComp: function (p, t) { return __m3(this.__x(t)[0], __v(p)); }, + fromComp: function (p, t) { return __m3(this.__x(t)[1], __v(p)); }, + toWorld: function (p, t) { return __m4(this.__x(t)[2], __v(p), false); }, + fromWorld: function (p, t) { return __m4(this.__x(t)[3], __v(p), false); }, + toCompVec: function (p, t) { return __m3v(this.__x(t)[0], __v(p)); }, + fromCompVec: function (p, t) { return __m3v(this.__x(t)[1], __v(p)); }, + toWorldVec: function (p, t) { return __m4(this.__x(t)[2], __v(p), true); }, + fromWorldVec: function (p, t) { return __m4(this.__x(t)[3], __v(p), true); }, + sourceRectAtTime: function (t, includeExtents) { + var r = __h(__RECT, this.__c, this.__l, t === undefined ? time : t, !!includeExtents); + return { top: r[0], left: r[1], width: r[2], height: r[3] }; + }, + sampleImage: function () { return [0, 0, 0, 0]; }, + toString: function () { return '[object Layer]'; }, +}; + +// Property groups (callable: `effect("Slider Control")("Slider")`) --------------------------------- + +var __GroupProto = { + __isGroup: true, + __get: function (k) { + var r = __child(this.__c, this.__l, this.__p, k); + if (r === undefined) __err('Property ' + (typeof k === 'string' ? '"' + k + '"' : k) + ' not found in "' + this.__info[2] + '"'); + return r; + }, + param: function (k) { return this.__get(k); }, + property: function (k) { return this.__get(k); }, + content: function (k) { + var inner = __child(this.__c, this.__l, this.__p, 'contents'); + return (inner !== undefined && inner.__isGroup ? inner : this).__get(k); + }, + get name() { return this.__info[2]; }, + get matchName() { return this.__info[3]; }, + get numProperties() { return this.__info[4]; }, + get active() { return true; }, + toString: function () { return '[object PropertyGroup]'; }, +}; + +var __groupHandler = { + get: function (t, k) { + if (typeof k !== 'string' || k in t) return t[k]; + return __child(t.__c, t.__l, t.__p, k); + }, +}; + +function __mkGroup(c, l, info) { + var f = function (k) { return f.__get(k); }; + delete f.name; + delete f.length; + Object.setPrototypeOf(f, __GroupProto); + f.__c = c; f.__l = l; f.__p = info[0]; f.__info = info; + return new Proxy(f, __groupHandler); +} + +// Properties ------------------------------------------------------------------------------------ + +function Prop(c, l, path) { + this.__c = c; this.__l = l; this.__p = path; this.__i = null; + this.__self = __S !== null && c === __S.c && l === __S.l && path === __S.path; +} + +function __avg(vals) { + var s = vals[0]; + for (var i = 1; i < vals.length; i++) s = add(s, vals[i]); + return div(s, vals.length); +} + +function __loop(p, out, type, n, dur) { + type = type === undefined ? 'cycle' : String(type).toLowerCase(); + var times = p.__info[4], N = times.length, t = time; + if (N === 0) return p.value; + var tStart, tEnd, h = 1e-3, k, ph; + if (out) { + tEnd = times[N - 1]; + if (t <= tEnd) return p.value; + if (dur !== undefined && dur > 0) tStart = tEnd - dur; + else if (n > 0 && N - 1 - n >= 0) tStart = times[N - 1 - n]; + else tStart = times[0]; + } else { + tStart = times[0]; + if (t >= tStart) return p.value; + if (dur !== undefined && dur > 0) tEnd = tStart + dur; + else if (n > 0 && n < N) tEnd = times[n]; + else tEnd = times[N - 1]; + } + var d = tEnd - tStart; + if (type === 'continue') { + if (out) { + var ve = p.valueAtTime(tEnd); + return add(ve, mul(div(sub(ve, p.valueAtTime(tEnd - h)), h), t - tEnd)); + } + var vs = p.valueAtTime(tStart); + return add(vs, mul(div(sub(p.valueAtTime(tStart + h), vs), h), t - tStart)); + } + if (!(d > 0)) return p.value; + ph = __mod(t - tStart, d); + if (type === 'pingpong') { + if (out) { + k = Math.floor((t - tStart) / d); + return p.valueAtTime(k % 2 === 1 ? tEnd - ph : tStart + ph); + } + k = Math.floor((tEnd - t) / d); + return p.valueAtTime(k % 2 === 1 ? tStart + __mod(tStart - t, d) : tEnd - __mod(tStart - t, d)); + } + if (type === 'offset') { + k = Math.floor((t - tStart) / d); + return add(p.valueAtTime(tStart + ph), mul(sub(p.valueAtTime(tEnd), p.valueAtTime(tStart)), k)); + } + return p.valueAtTime(tStart + ph); +} + +Prop.prototype = { + __isProp: true, + get __info() { return this.__i || (this.__i = __h(__PINFO, this.__c, this.__l, this.__p)); }, + get name() { return this.__info[0]; }, + get __uid() { return this.__info[1]; }, + get numKeys() { return this.__info[4].length; }, + get propertyIndex() { return this.__info[7]; }, + get matchName() { return this.__info[8]; }, + __wrap: function (v) { + var kind = this.__info[2]; + if (kind === 'layer') return v === null ? null : new Layer(this.__c, v); + if (kind === 'path' && v !== null) return new __Path(v[0], v[1], v[2], v[3]); + return v; + }, + get value() { return this.__self ? value : this.valueAtTime(time); }, + valueAtTime: function (t) { + t = __v(t); + if (this.__self && t === time) return value; + return this.__wrap(__h(__VALUE, this.__c, this.__l, this.__p, t, this.__self)); + }, + velocityAtTime: function (t) { + var h = 1e-3; + return div(sub(this.valueAtTime(t + h), this.valueAtTime(t - h)), 2 * h); + }, + get velocity() { return this.velocityAtTime(time); }, + speedAtTime: function (t) { return length(this.velocityAtTime(t)); }, + get speed() { return this.speedAtTime(time); }, + key: function (n) { + var info = this.__info, N = info[4].length; + if (!(n >= 1 && n <= N)) __err('Key index ' + n + ' out of range (property has ' + N + ' keys)'); + n = Math.floor(n); + return { time: info[4][n - 1], value: this.__wrap(info[5][n - 1]), index: n }; + }, + nearestKey: function (t) { + var times = this.__info[4]; + if (times.length === 0) __err('Property has no keyframes'); + var best = 0; + for (var i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i; + return this.key(best + 1); + }, + __seedIndex: function () { return this.__self ? __S.idx : new Layer(this.__c, this.__l).index; }, + wiggle: function (freq, amp, octaves, ampMult, t) { + if (octaves === undefined) octaves = 1; + if (ampMult === undefined) ampMult = 0.5; + if (t === undefined) t = time; + var base = this.valueAtTime(t); + var dims = Array.isArray(base) ? base.length : 1; + var off = __wig(this.__seedIndex(), this.__uid, dims, __v(freq), __v(amp), octaves, ampMult, t); + if (!Array.isArray(base)) return base + off[0]; + var r = base.slice(); + for (var i = 0; i < r.length; i++) r[i] += off[i]; + return r; + }, + temporalWiggle: function (freq, amp, octaves, ampMult, t) { + if (octaves === undefined) octaves = 1; + if (ampMult === undefined) ampMult = 0.5; + if (t === undefined) t = time; + var off = __wig(this.__seedIndex(), this.__uid, 1, __v(freq), __v(amp), octaves, ampMult, t); + return this.valueAtTime(t + off[0]); + }, + smooth: function (width, samples, t) { + if (width === undefined) width = 0.2; + if (samples === undefined) samples = 5; + if (t === undefined) t = time; + samples = Math.max(1, Math.round(samples)); + if (samples === 1) return this.valueAtTime(t); + var vals = []; + for (var i = 0; i < samples; i++) vals.push(this.valueAtTime(t - width / 2 + width * i / (samples - 1))); + return __avg(vals); + }, + loopOut: function (type, n) { return __loop(this, true, type, n || 0); }, + loopIn: function (type, n) { return __loop(this, false, type, n || 0); }, + loopOutDuration: function (type, d) { return __loop(this, true, type, 0, d === undefined ? 0 : d); }, + loopInDuration: function (type, d) { return __loop(this, false, type, 0, d === undefined ? 0 : d); }, + points: function (t) { return this.valueAtTime(t === undefined ? time : t).points(); }, + inTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).inTangents(); }, + outTangents: function (t) { return this.valueAtTime(t === undefined ? time : t).outTangents(); }, + isClosed: function () { return this.value.isClosed(); }, + valueOf: function () { return this.value; }, + toString: function () { return String(this.value); }, +}; + +var __propHandler = { + get: function (t, k) { + if (typeof k !== 'string' || k in t) return t[k]; + var v = t.value; + if (v === null || v === undefined) return undefined; + var x = v[k]; + return typeof x === 'function' ? x.bind(v) : x; + }, +}; + +function __mkProp(c, l, path) { + return new Proxy(new Prop(c, l, path), __propHandler); +} + +// Markers --------------------------------------------------------------------------------------- + +function Markers(c, l) { this.__c = c; this.__l = l; this.__m = null; } +Markers.prototype = { + get __list() { return this.__m || (this.__m = __h(__MARKERS, this.__c, this.__l)); }, + get numKeys() { return this.__list.length; }, + key: function (n) { + var list = this.__list, i; + if (typeof n === 'string') { + for (i = 0; i < list.length; i++) if (list[i][2] === n) return this.key(i + 1); + __err('Marker "' + n + '" not found'); + } + if (!(n >= 1 && n <= list.length)) __err('Marker index ' + n + ' out of range'); + var m = list[n - 1]; + return { time: m[0], duration: m[1], comment: m[2], chapter: m[3], url: m[4], index: n }; + }, + nearestKey: function (t) { + var list = this.__list; + if (list.length === 0) __err('No markers'); + var best = 0; + for (var i = 1; i < list.length; i++) if (Math.abs(list[i][0] - t) < Math.abs(list[best][0] - t)) best = i; + return this.key(best + 1); + }, +}; + +// --------------------------------------------------------------------------------------------- +// Unqualified names: thisLayer / thisProperty attributes and the `this*` objects. They're +// accessors on the global object; assigning one (`var width = 10`) shadows it for the current +// evaluation only. + +function __global(name, get) { + Object.defineProperty(globalThis, name, { + configurable: true, + get: function () { return name in __S.vars ? __S.vars[name] : get(); }, + set: function (v) { __S.vars[name] = v; }, + }); +} + +__global('thisComp', function () { return __S.comp || (__S.comp = new Comp(__S.c)); }); +__global('thisLayer', function () { return __S.layer || (__S.layer = new Layer(__S.c, __S.l)); }); +__global('thisProperty', function () { return __S.prop || (__S.prop = __mkProp(__S.c, __S.l, __S.path)); }); +['name', 'index', 'inPoint', 'outPoint', 'startTime', 'width', 'height', 'hasParent', 'parent', 'hasVideo', 'enabled', 'active', + 'transform', 'position', 'scale', 'rotation', 'opacity', 'anchorPoint', 'text', 'marker'].forEach(function (k) { + __global(k, function () { return thisLayer[k]; }); +}); +['numKeys', 'velocity', 'speed'].forEach(function (k) { + __global(k, function () { return thisProperty[k]; }); +}); + +function effect(k) { return thisLayer.effect(k); } +function mask(k) { return thisLayer.mask(k); } +function content(k) { return thisLayer.content(k); } +function toComp(p, t) { return thisLayer.toComp(p, t); } +function fromComp(p, t) { return thisLayer.fromComp(p, t); } +function toWorld(p, t) { return thisLayer.toWorld(p, t); } +function fromWorld(p, t) { return thisLayer.fromWorld(p, t); } +function toCompVec(p, t) { return thisLayer.toCompVec(p, t); } +function fromCompVec(p, t) { return thisLayer.fromCompVec(p, t); } +function toWorldVec(p, t) { return thisLayer.toWorldVec(p, t); } +function fromWorldVec(p, t) { return thisLayer.fromWorldVec(p, t); } +function sourceRectAtTime(t, e) { return thisLayer.sourceRectAtTime(t, e); } + +function wiggle(f, a, o, m, t) { return thisProperty.wiggle(f, a, o, m, t); } +function temporalWiggle(f, a, o, m, t) { return thisProperty.temporalWiggle(f, a, o, m, t); } +function smooth(w, s, t) { return thisProperty.smooth(w, s, t); } +function loopOut(type, n) { return thisProperty.loopOut(type, n); } +function loopIn(type, n) { return thisProperty.loopIn(type, n); } +function loopOutDuration(type, d) { return thisProperty.loopOutDuration(type, d); } +function loopInDuration(type, d) { return thisProperty.loopInDuration(type, d); } +function valueAtTime(t) { return thisProperty.valueAtTime(t); } +function velocityAtTime(t) { return thisProperty.velocityAtTime(t); } +function speedAtTime(t) { return thisProperty.speedAtTime(t); } +function key(n) { return thisProperty.key(n); } +function nearestKey(t) { return thisProperty.nearestKey(t); } diff --git a/crates/expr/src/rewrite.rs b/crates/expr/src/rewrite.rs new file mode 100644 index 00000000..36fdc3a6 --- /dev/null +++ b/crates/expr/src/rewrite.rs @@ -0,0 +1,602 @@ +//! Source-to-source rewrite that gives JavaScript After Effects' array maths. +//! +//! AE's legacy expression engine lets `+ - * /` operate on arrays (`value + [10, 0]`, +//! `[1, 2] * 2`). We get the same by rewriting every binary `+ - * /` into a helper call +//! (`__add(a, b)`, `__sub`, `__mul`, `__div`) and unary minus into `__neg(a)`; the helpers fall +//! back to the native operator for numbers and strings, so string concatenation is unchanged. +//! Compound assignments (`x += [1, 0]`) become `x = __add(x, (…))`. +//! +//! The rewriter is a small tokenizer plus a precedence-aware operand/chain parser over bracket +//! groups. It is deliberately lenient: anything it does not understand is copied verbatim, and +//! every newline is preserved so the engine's error line numbers match the user's text. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Ident, + Num, + Str, + Template, + Regex, + Punct, +} + +#[derive(Clone, Debug)] +struct Tok { + kind: Kind, + text: String, + /// Whitespace (comments become spaces/newlines) before the token. + ws: String, + /// A line break precedes the token. + nl: bool, +} + +impl Tok { + fn is(&self, p: &str) -> bool { + self.kind == Kind::Punct && self.text == p + } + fn is_kw(&self, k: &[&str]) -> bool { + self.kind == Kind::Ident && k.contains(&self.text.as_str()) + } +} + +enum Item { + Tok(Tok), + Group { open: Tok, inner: Vec, close: Option }, +} + +impl Item { + fn tok(&self) -> Option<&Tok> { + if let Item::Tok(t) = self { Some(t) } else { None } + } + fn is(&self, p: &str) -> bool { + self.tok().is_some_and(|t| t.is(p)) + } + fn group(&self) -> Option<&str> { + if let Item::Group { open, .. } = self { Some(open.text.as_str()) } else { None } + } + fn ws(&self) -> &str { + match self { + Item::Tok(t) => &t.ws, + Item::Group { open, .. } => &open.ws, + } + } + fn nl(&self) -> bool { + match self { + Item::Tok(t) => t.nl, + Item::Group { open, .. } => open.nl, + } + } +} + +/// Keywords that end an operand chain (statement or lower-precedence syntax). +const DELIM_KW: &[&str] = &[ + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "do", + "else", + "export", + "extends", + "finally", + "for", + "if", + "import", + "in", + "instanceof", + "let", + "return", + "switch", + "throw", + "try", + "var", + "while", + "with", + "yield", + "of", +]; +/// Prefix operators spelled as keywords. +const PREFIX_KW: &[&str] = &["typeof", "void", "delete", "await", "new"]; +/// Keywords followed by a parenthesised header that is not an operand. +const HEADER_KW: &[&str] = &["if", "while", "for", "switch", "catch", "with"]; + +const PUNCT: &[&str] = &[ + ">>>=", "...", "===", "!==", "**=", "<<=", ">>=", ">>>", "&&=", "||=", "??=", "=>", "==", "!=", "<=", ">=", "&&", "||", "??", "?.", "++", "--", "+=", "-=", + "*=", "/=", "%=", "&=", "|=", "^=", "**", "<<", ">>", +]; + +fn is_ident_start(c: char) -> bool { + c.is_alphabetic() || c == '_' || c == '$' || (!c.is_ascii() && !c.is_whitespace()) +} +fn is_ident_part(c: char) -> bool { + is_ident_start(c) || c.is_ascii_digit() +} + +/// Can a `/` after `prev` start a regular expression literal? +fn regex_allowed(prev: Option<&Tok>) -> bool { + match prev { + None => true, + Some(t) => match t.kind { + Kind::Num | Kind::Str | Kind::Template | Kind::Regex => false, + Kind::Ident => t.is_kw(DELIM_KW) || t.is_kw(PREFIX_KW), + Kind::Punct => !matches!(t.text.as_str(), ")" | "]" | "}" | "++" | "--"), + }, + } +} + +fn skip_string(c: &[char], mut i: usize) -> usize { + let q = c[i]; + i += 1; + while i < c.len() && c[i] != q && c[i] != '\n' { + if c[i] == '\\' { + i += 1; + } + i += 1; + } + (i + 1).min(c.len()) +} + +fn skip_template(c: &[char], mut i: usize) -> usize { + i += 1; + let mut depth = 0usize; + while i < c.len() { + match c[i] { + '\\' => i += 1, + '`' if depth == 0 => return i + 1, + '$' if c.get(i + 1) == Some(&'{') => { + depth += 1; + i += 1; + } + '{' if depth > 0 => depth += 1, + '}' if depth > 0 => depth -= 1, + '\'' | '"' if depth > 0 => { + i = skip_string(c, i); + continue; + } + _ => {} + } + i += 1; + } + c.len() +} + +fn skip_number(c: &[char], mut i: usize) -> usize { + if c[i] == '0' && c.get(i + 1).is_some_and(|x| matches!(x, 'x' | 'X' | 'b' | 'B' | 'o' | 'O')) { + i += 2; + while i < c.len() && (c[i].is_ascii_alphanumeric() || c[i] == '_') { + i += 1; + } + return i; + } + let mut seen_dot = false; + while i < c.len() { + let x = c[i]; + if x.is_ascii_digit() || x == '_' { + i += 1; + } else if x == '.' && !seen_dot { + seen_dot = true; + i += 1; + } else if (x == 'e' || x == 'E') && c.get(i + 1).is_some_and(|y| y.is_ascii_digit() || *y == '+' || *y == '-') { + i += 2; + while i < c.len() && c[i].is_ascii_digit() { + i += 1; + } + break; + } else { + break; + } + } + if c.get(i) == Some(&'n') { + i += 1; + } + i +} + +fn skip_regex(c: &[char], mut i: usize) -> usize { + i += 1; + let mut class = false; + while i < c.len() && c[i] != '\n' { + match c[i] { + '\\' => i += 1, + '[' => class = true, + ']' => class = false, + '/' if !class => break, + _ => {} + } + i += 1; + } + i += 1; + while i < c.len() && is_ident_part(c[i]) { + i += 1; + } + i.min(c.len()) +} + +fn punct_len(c: &[char], i: usize) -> usize { + for p in PUNCT { + let n = p.chars().count(); + if i + n <= c.len() && c[i..i + n].iter().copied().eq(p.chars()) { + // `a?.5:b` is a conditional, not optional chaining. + if *p == "?." && c.get(i + 2).is_some_and(|d| d.is_ascii_digit()) { + return 1; + } + return n; + } + } + 1 +} + +fn tokenize(src: &str) -> (Vec, String) { + let c: Vec = src.chars().collect(); + let mut out: Vec = Vec::new(); + let mut ws = String::new(); + let mut i = 0; + while i < c.len() { + let ch = c[i]; + if ch.is_whitespace() { + ws.push(ch); + i += 1; + continue; + } + if ch == '/' && c.get(i + 1) == Some(&'/') { + while i < c.len() && c[i] != '\n' { + i += 1; + } + ws.push(' '); + continue; + } + if ch == '/' && c.get(i + 1) == Some(&'*') { + i += 2; + while i < c.len() && !(c[i] == '*' && c.get(i + 1) == Some(&'/')) { + if c[i] == '\n' { + ws.push('\n'); + } + i += 1; + } + i = (i + 2).min(c.len()); + ws.push(' '); + continue; + } + let start = i; + let kind = if ch == '"' || ch == '\'' { + i = skip_string(&c, i); + Kind::Str + } else if ch == '`' { + i = skip_template(&c, i); + Kind::Template + } else if ch.is_ascii_digit() || (ch == '.' && c.get(i + 1).is_some_and(|d| d.is_ascii_digit())) { + i = skip_number(&c, i); + Kind::Num + } else if is_ident_start(ch) { + while i < c.len() && is_ident_part(c[i]) { + i += 1; + } + Kind::Ident + } else if ch == '/' && regex_allowed(out.last()) { + i = skip_regex(&c, i); + Kind::Regex + } else { + i += punct_len(&c, i); + Kind::Punct + }; + let nl = ws.contains('\n'); + out.push(Tok { kind, text: c[start..i].iter().collect(), ws: std::mem::take(&mut ws), nl }); + } + (out, ws) +} + +fn closer(open: &str) -> &'static str { + match open { + "(" => ")", + "[" => "]", + _ => "}", + } +} + +/// Bracket tree. Stray closers stay as plain tokens (the engine reports the syntax error). +fn build(toks: Vec) -> Vec { + let mut stack: Vec<(Tok, Vec)> = Vec::new(); + let mut cur: Vec = Vec::new(); + for t in toks { + if t.kind == Kind::Punct && matches!(t.text.as_str(), "(" | "[" | "{") { + stack.push((t, std::mem::take(&mut cur))); + } else if t.kind == Kind::Punct && matches!(t.text.as_str(), ")" | "]" | "}") && stack.last().is_some_and(|(o, _)| closer(&o.text) == t.text) { + let (open, parent) = stack.pop().unwrap_or_else(|| unreachable!()); + let inner = std::mem::replace(&mut cur, parent); + cur.push(Item::Group { open, inner, close: Some(t) }); + } else { + cur.push(Item::Tok(t)); + } + } + while let Some((open, parent)) = stack.pop() { + let inner = std::mem::replace(&mut cur, parent); + cur.push(Item::Group { open, inner, close: None }); + } + cur +} + +/// A rewritten operand or expression: leading whitespace kept apart so helper calls start where +/// the expression did (keeps automatic semicolon insertion unchanged). +struct Piece { + ws: String, + body: String, +} + +fn raw(item: &Item) -> Piece { + match item { + Item::Tok(t) => Piece { ws: t.ws.clone(), body: t.text.clone() }, + Item::Group { open, inner, close } => { + let mut body = open.text.clone(); + body.push_str(&rewrite_seq(inner)); + if let Some(c) = close { + body.push_str(&c.ws); + body.push_str(&c.text); + } + Piece { ws: open.ws.clone(), body } + } + } +} + +fn starts_operand(items: &[Item], i: usize) -> bool { + match &items[i] { + Item::Group { open, .. } => open.text != "{", + Item::Tok(t) => match t.kind { + Kind::Num | Kind::Str | Kind::Template | Kind::Regex => true, + Kind::Ident => !t.is_kw(DELIM_KW), + Kind::Punct => matches!(t.text.as_str(), "-" | "+" | "!" | "~" | "++" | "--"), + }, + } +} + +/// Parse one unary/postfix operand starting at `i`. +fn operand(items: &[Item], i: usize) -> Option<(Piece, usize)> { + let first = items.get(i)?; + if !starts_operand(items, i) { + return None; + } + // Prefix operators. + if let Some(t) = first.tok() + && (t.kind == Kind::Punct || t.is_kw(PREFIX_KW)) + { + let (inner, j) = operand(items, i + 1)?; + let body = if t.is("-") { + if items[i + 1].tok().is_some_and(|n| n.kind == Kind::Num) { + format!("-{}{}", inner.ws, inner.body) + } else { + format!("__neg({}{})", inner.ws, inner.body) + } + } else { + let sep = if t.kind == Kind::Ident && inner.ws.is_empty() { " " } else { "" }; + format!("{}{sep}{}{}", t.text, inner.ws, inner.body) + }; + return Some((Piece { ws: t.ws.clone(), body }, j)); + } + let mut p = raw(first); + let mut j = i + 1; + // `function name(args) { body }` is one atom. + if first.tok().is_some_and(|t| t.is_kw(&["function"])) { + if items.get(j).is_some_and(|x| x.is("*")) { + push(&mut p, &items[j]); + j += 1; + } + if items.get(j).and_then(Item::tok).is_some_and(|t| t.kind == Kind::Ident) { + push(&mut p, &items[j]); + j += 1; + } + for g in ["(", "{"] { + if items.get(j).and_then(Item::group) == Some(g) { + push(&mut p, &items[j]); + j += 1; + } + } + } + // Postfix: member access, calls, indexing, tagged templates, `x++`. + while let Some(it) = items.get(j) { + let member = it.is(".") || it.is("?."); + let call = matches!(it.group(), Some("(" | "[")) || it.tok().is_some_and(|t| t.kind == Kind::Template); + let incr = (it.is("++") || it.is("--")) && !it.nl(); + if !(member || call || incr) { + break; + } + push(&mut p, it); + j += 1; + if member + && let Some(n) = items.get(j) + && (n.tok().is_some_and(|t| t.kind == Kind::Ident) || matches!(n.group(), Some("(" | "["))) + { + push(&mut p, n); + j += 1; + } + } + // Exponentiation binds tighter than `*` and is right-associative: keep it native. + if items.get(j).is_some_and(|x| x.is("**")) + && let Some((rhs, k)) = operand(items, j + 1) + { + p.body.push_str(items[j].ws()); + p.body.push_str("**"); + p.body.push_str(&rhs.ws); + p.body.push_str(&rhs.body); + j = k; + } + Some((p, j)) +} + +fn push(p: &mut Piece, item: &Item) { + let r = raw(item); + p.body.push_str(&r.ws); + p.body.push_str(&r.body); +} + +fn helper(op: &str) -> Option<&'static str> { + Some(match op { + "+" => "__add", + "-" => "__sub", + "*" => "__mul", + "/" => "__div", + _ => return None, + }) +} + +/// Fold `a op b op c…` with `* / %` binding tighter than `+ -`. +fn fold(operands: Vec, ops: Vec<(String, String)>) -> Piece { + let mut terms: Vec = Vec::new(); + let mut add_ops: Vec<(String, String)> = Vec::new(); + let mut it = operands.into_iter(); + let mut acc = it.next().unwrap_or(Piece { ws: String::new(), body: String::new() }); + for ((op, ws), rhs) in ops.into_iter().zip(it) { + if op == "+" || op == "-" { + terms.push(acc); + add_ops.push((op, ws)); + acc = rhs; + } else { + acc = bin(acc, &op, &ws, rhs); + } + } + terms.push(acc); + let mut it = terms.into_iter(); + let mut acc = it.next().unwrap_or(Piece { ws: String::new(), body: String::new() }); + for ((op, ws), rhs) in add_ops.into_iter().zip(it) { + acc = bin(acc, &op, &ws, rhs); + } + acc +} + +fn bin(a: Piece, op: &str, op_ws: &str, b: Piece) -> Piece { + let body = match helper(op) { + Some(h) => format!("{h}({}, {op_ws}{}{})", a.body, b.ws, b.body), + None => format!("({}{op_ws}{op}{}{})", a.body, b.ws, b.body), + }; + Piece { ws: a.ws, body } +} + +fn is_arith(item: &Item) -> bool { + item.tok().is_some_and(|t| t.kind == Kind::Punct && matches!(t.text.as_str(), "+" | "-" | "*" | "/" | "%")) +} + +/// Parse `operand (op operand)*` from `i`. +fn chain(items: &[Item], i: usize) -> Option<(Piece, usize)> { + let (first, mut j) = operand(items, i)?; + let mut operands = vec![first]; + let mut ops = Vec::new(); + while let Some(op) = items.get(j).filter(|x| is_arith(x)).and_then(Item::tok) { + let Some((rhs, k)) = operand(items, j + 1) else { break }; + ops.push((op.text.clone(), op.ws.clone())); + operands.push(rhs); + j = k; + } + Some((fold(operands, ops), j)) +} + +/// End of the right-hand side of an assignment starting at `i`. +fn assignment_end(items: &[Item], i: usize) -> usize { + let mut j = i; + while j < items.len() { + let it = &items[j]; + if it.is(";") || it.is(",") { + break; + } + if j > i && it.nl() && !items.get(j - 1).is_some_and(|p| p.tok().is_some_and(|t| t.kind == Kind::Punct && !t.is(")") && !t.is("]"))) { + break; + } + j += 1; + } + j +} + +fn rewrite_seq(items: &[Item]) -> String { + let mut out = String::new(); + let mut i = 0; + while i < items.len() { + let it = &items[i]; + if it.tok().is_some_and(|t| t.is_kw(HEADER_KW)) && items.get(i + 1).and_then(Item::group) == Some("(") { + push_piece(&mut out, raw(it)); + push_piece(&mut out, raw(&items[i + 1])); + i += 2; + continue; + } + if let Some((p, j)) = chain(items, i) { + // Compound assignment: `x += v` → `x = __add(x, (v))` (the target is evaluated twice, + // which only matters for targets with side effects). + if let Some(op) = items.get(j).and_then(Item::tok).filter(|t| t.kind == Kind::Punct && matches!(t.text.as_str(), "+=" | "-=" | "*=" | "/=")) { + let end = assignment_end(items, j + 1); + let rhs = rewrite_seq(&items[j + 1..end]); + let h = helper(&op.text[..1]).unwrap_or("__add"); + out.push_str(&p.ws); + out.push_str(&format!("{} ={}{h}({}, ({rhs}))", p.body, op.ws, p.body)); + i = end; + continue; + } + push_piece(&mut out, p); + i = j; + } else { + push_piece(&mut out, raw(it)); + i += 1; + } + } + out +} + +fn push_piece(out: &mut String, p: Piece) { + out.push_str(&p.ws); + out.push_str(&p.body); +} + +/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved. +pub fn rewrite(src: &str) -> String { + let (toks, tail) = tokenize(src); + let mut out = rewrite_seq(&build(toks)); + out.push_str(&tail); + out +} + +#[cfg(test)] +mod tests { + use super::rewrite; + + /// Compare ignoring spaces (newlines are significant). + fn n(s: &str) -> String { + s.replace(' ', "") + } + + #[test] + fn precedence_and_unary() { + assert_eq!(n(&rewrite("a + b * c")), n("__add(a, __mul(b, c))")); + assert_eq!(n(&rewrite("-x * 2")), n("__mul(__neg(x), 2)")); + assert_eq!(n(&rewrite("-2*x")), n("__mul(-2, x)")); + assert_eq!(n(&rewrite("a % b + c")), n("__add((a % b), c)")); + assert_eq!(n(&rewrite("a - b - c")), n("__sub(__sub(a, b), c)")); + } + + #[test] + fn calls_members_and_groups() { + assert_eq!(n(&rewrite("f(a+b)[0] / 2")), n("__div(f(__add(a, b))[0], 2)")); + assert_eq!(n(&rewrite("thisComp.layer(\"A\").transform.position[0] + 1")), n("__add(thisComp.layer(\"A\").transform.position[0], 1)")); + assert_eq!(n(&rewrite("x = a ? b + 1 : c")), n("x = a ? __add(b, 1) : c")); + assert_eq!(n(&rewrite("if (a) -b; else c")), n("if (a) __neg(b); else c")); + assert_eq!(n(&rewrite("return -x")), n("return __neg(x)")); + } + + #[test] + fn strings_regex_comments_and_lines() { + assert_eq!(n(&rewrite("'a+b' + \"c\"")), n("__add('a+b', \"c\")")); + assert_eq!(n(&rewrite("s.replace(/a+b/g, '')")), n("s.replace(/a+b/g, '')")); + assert_eq!(n(&rewrite("a // x + y\n+ b")), n("__add(a, \n b)")); + assert_eq!(rewrite("a /* + */ * b").matches("__mul").count(), 1); + assert_eq!(rewrite("x\n\n+ 1").lines().count(), 3); + assert_eq!(n(&rewrite("`${a+b}` + 1")), n("__add(`${a+b}`, 1)")); + } + + #[test] + fn compound_assignment_and_functions() { + assert_eq!(n(&rewrite("x += [1, 0];")), n("x = __add(x, ( [1, 0]));")); + assert_eq!(n(&rewrite("function f(a) { return a * 2 }")), n("function f(a) { return __mul(a, 2) }")); + assert_eq!(n(&rewrite("var g = (a) => a - 1")), n("var g = (a) => __sub(a, 1)")); + assert_eq!(n(&rewrite("typeof a + 'x'")), n("__add(typeof a, 'x')")); + assert_eq!(n(&rewrite("i++ + 1")), n("__add(i++, 1)")); + assert_eq!(n(&rewrite("2 ** 3 * 2")), n("__mul(2 ** 3, 2)")); + } +} diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs new file mode 100644 index 00000000..b00e6324 --- /dev/null +++ b/crates/expr/src/runtime.rs @@ -0,0 +1,450 @@ +//! The per-thread boa runtime: one `Context` with the prelude loaded, a compiled-script cache, +//! the native functions, and the request/miss bookkeeping (see [`crate::host`]). + +use std::cell::RefCell; +use std::collections::HashMap; +use std::mem::ManuallyDrop; + +use boa_engine::object::builtins::JsArray; +use boa_engine::{Context, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Script, Source, js_string}; +use effectcraft_keyframe::Value; + +use crate::host::{Key, Req, Resp, secs}; +use crate::noise; + +const PRELUDE: &str = include_str!("prelude.js"); +/// Message of the error thrown when a request isn't answered yet (never shown to users). +const PENDING: &str = "\u{1}effectcraft: pending host request"; +const MAX_SCRIPTS: usize = 4096; + +/// Memoized host answers and pending misses of one expression evaluation. +#[derive(Default)] +pub struct Frame { + pub memo: HashMap, + pub misses: Vec, +} + +thread_local! { + /// Never dropped: boa's own thread-local heap may be torn down first at thread exit, and + /// dropping GC handles after that aborts the process. The OS reclaims it with the thread. + static RUNTIME: RefCell>>> = const { RefCell::new(None) }; + /// Evaluation stack (nested when one expression reads another's value). + pub static FRAMES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +struct Runtime { + ctx: Context, + scripts: HashMap>, + begin: JsObject, + finish: JsObject, +} + +/// Per-evaluation inputs for `__begin`. +pub struct Begin<'a> { + pub time: f64, + pub value: &'a Resp, + pub comp: u64, + pub layer: u64, + pub path: &'a str, + pub uid: u64, + pub index: f64, + pub frame_duration: f64, +} + +/// Outcome of one script run. +pub enum Run { + Done(Result), + /// The script needs host answers first. + Pending, +} + +/// A script result, detached from the JS heap. +#[derive(Clone, Debug, PartialEq)] +pub enum Out { + Undefined, + Null, + Bool(bool), + Num(f64), + Str(String), + Arr(Vec), + Path { points: Vec<[f64; 2]>, ins: Vec<[f64; 2]>, outs: Vec<[f64; 2]>, closed: bool }, + Object(String), +} + +fn arg(args: &[JsValue], i: usize) -> JsValue { + args.get(i).cloned().unwrap_or_default() +} +fn num(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult { + arg(args, i).to_number(ctx) +} +fn id(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult { + let v = num(args, i, ctx)?; + Ok(if v.is_finite() && v >= 0.0 { v as u64 } else { u64::MAX }) +} +fn string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult { + Ok(arg(args, i).to_string(ctx)?.to_std_string_escaped()) +} +fn key(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult { + let v = arg(args, i); + Ok(match v.as_number() { + Some(n) => Key::Index(n as i64), + None => Key::Name(v.to_string(ctx)?.to_std_string_escaped()), + }) +} + +fn to_js(r: &Resp, ctx: &mut Context) -> JsValue { + match r { + Resp::Null => JsValue::null(), + Resp::Bool(b) => JsValue::from(*b), + Resp::Num(n) => JsValue::from(*n), + Resp::Str(s) => JsValue::from(JsString::from(s.as_str())), + Resp::List(items) => { + let vals: Vec = items.iter().map(|i| to_js(i, ctx)).collect(); + JsArray::from_iter(vals, ctx).into() + } + } +} + +/// `__h(op, …)`: answer a host request from the memo, or record a miss and abort. +fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let op = num(args, 0, ctx)? as i64; + let req = match op { + 0 => Req::CompInfo { comp: id(args, 1, ctx)? }, + 1 => Req::CompByName { name: string(args, 1, ctx)? }, + 2 => Req::LayerLookup { comp: id(args, 1, ctx)?, key: key(args, 2, ctx)? }, + 3 => Req::LayerInfo { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)? }, + 4 => Req::Child { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)?, group: string(args, 3, ctx)?, key: key(args, 4, ctx)? }, + 5 => Req::PropInfo { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)?, path: string(args, 3, ctx)? }, + 6 => Req::Value { + comp: id(args, 1, ctx)?, + layer: id(args, 2, ctx)?, + path: string(args, 3, ctx)?, + t: secs(num(args, 4, ctx)?), + pre: arg(args, 5).to_boolean(), + }, + 7 => Req::Xform { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)?, t: secs(num(args, 3, ctx)?) }, + 8 => Req::SourceRect { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)?, t: secs(num(args, 3, ctx)?), extents: arg(args, 4).to_boolean() }, + 9 => { + let l = num(args, 2, ctx)?; + Req::Markers { comp: id(args, 1, ctx)?, layer: (l >= 0.0).then_some(l as u64) } + } + _ => return Err(JsNativeError::typ().with_message("unknown host request").into()), + }; + let found = FRAMES.with(|f| { + let mut f = f.borrow_mut(); + let frame = f.last_mut()?; + match frame.memo.get(&req) { + Some(r) => Some(r.clone()), + None => { + frame.misses.push(req); + None + } + } + }); + match found { + Some(r) => Ok(to_js(&r, ctx)), + None => Err(JsNativeError::error().with_message(PENDING).into()), + } +} + +/// `__wig(layerIndex, uid, dims, freq, amp, octaves, ampMult, t)`. +fn native_wiggle(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let mut a = [0.0; 8]; + for (i, x) in a.iter_mut().enumerate() { + *x = num(args, i, ctx)?; + } + let seed = noise::mix(&[a[0].to_bits(), a[1].to_bits()]); + let dims = (a[2].max(1.0) as usize).min(16); + let off = noise::wiggle(seed, dims, a[3], a[4], a[5], a[6], a[7]); + let vals: Vec = off.into_iter().map(JsValue::from).collect(); + Ok(JsArray::from_iter(vals, ctx).into()) +} + +/// `__noise(x, y, z)`. +fn native_noise(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(noise::noise3(num(args, 0, ctx)?, num(args, 1, ctx)?, num(args, 2, ctx)?))) +} + +/// `__rnd(layerIndex, uid, userSeed, timeless, time, counter)`. +fn native_random(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let timeless = arg(args, 3).to_boolean(); + let t = if timeless { 0.0 } else { num(args, 4, ctx)? }; + let parts = [num(args, 0, ctx)?.to_bits(), num(args, 1, ctx)?.to_bits(), num(args, 2, ctx)?.to_bits(), t.to_bits(), num(args, 5, ctx)?.to_bits()]; + Ok(JsValue::from(noise::unit(noise::mix(&parts)))) +} + +impl Runtime { + fn new() -> Result { + let mut ctx = Context::default(); + let limits = ctx.runtime_limits_mut(); + limits.set_loop_iteration_limit(10_000_000); + limits.set_recursion_limit(400); + let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 4] = [ + (js_string!("__h"), 6, native_host), + (js_string!("__wig"), 8, native_wiggle), + (js_string!("__noise"), 3, native_noise), + (js_string!("__rnd"), 6, native_random), + ]; + for (name, len, f) in natives { + ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?; + } + ctx.eval(Source::from_bytes(PRELUDE)).map_err(|e| format!("expression prelude: {e}"))?; + let global = ctx.global_object(); + let func = |name: JsString, ctx: &mut Context| -> Result { + global.get(name, ctx).ok().and_then(|v| v.as_object()).ok_or_else(|| "expression prelude: missing function".to_string()) + }; + let begin = func(js_string!("__begin"), &mut ctx)?; + let finish = func(js_string!("__finish"), &mut ctx)?; + Ok(Runtime { ctx, scripts: HashMap::new(), begin, finish }) + } + + fn script(&mut self, text: &str) -> Result { + if let Some(s) = self.scripts.get(text) { + return s.clone(); + } + if self.scripts.len() >= MAX_SCRIPTS { + self.scripts.clear(); + } + // A block keeps `let`/`const` local to one evaluation; `{` shares line 1 with the code so + // error line numbers match the user's text. + let src = format!("{{{}\n}}", crate::rewrite::rewrite(text)); + let s = Script::parse(Source::from_bytes(src.as_bytes()), None, &mut self.ctx).map_err(|e| format_error(&e.to_string(), line_count(text))); + self.scripts.insert(text.to_string(), s.clone()); + s + } + + fn run(&mut self, text: &str, b: &Begin) -> Run { + let script = match self.script(text) { + Ok(s) => s, + Err(e) => return Run::Done(Err(e)), + }; + let ctx = &mut self.ctx; + let args = [ + JsValue::from(b.time), + to_js(b.value, ctx), + JsValue::from(b.comp as f64), + JsValue::from(b.layer as f64), + JsValue::from(JsString::from(b.path)), + JsValue::from(b.uid as f64), + JsValue::from(b.index), + JsValue::from(b.frame_duration), + ]; + if let Err(e) = self.begin.call(&JsValue::undefined(), &args, ctx) { + return Run::Done(Err(format_error(&e.to_string(), line_count(text)))); + } + let result = script.evaluate(ctx).and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx)); + let pending = FRAMES.with(|f| f.borrow().last().is_some_and(|fr| !fr.misses.is_empty())); + if pending { + return Run::Pending; + } + Run::Done(match result { + Ok(v) => detach(&v, ctx, 0), + Err(e) => Err(format_error(&e.to_string(), line_count(text))), + }) + } +} + +/// Copy a JS value out of the heap. +fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result { + if v.is_undefined() { + return Ok(Out::Undefined); + } + if v.is_null() { + return Ok(Out::Null); + } + if let Some(b) = v.as_boolean() { + return Ok(Out::Bool(b)); + } + if let Some(n) = v.as_number() { + return Ok(Out::Num(n)); + } + if let Some(s) = v.as_string() { + return Ok(Out::Str(s.to_std_string_escaped())); + } + let err = |e: boa_engine::JsError| format_error(&e.to_string(), usize::MAX); + if let Some(o) = v.as_object() { + if o.is_array() && depth < 4 { + let arr = JsArray::from_object(o).map_err(err)?; + let n = arr.length(ctx).map_err(err)?.min(1 << 16); + let mut out = Vec::with_capacity(n as usize); + for i in 0..n { + out.push(detach(&arr.get(i, ctx).map_err(err)?, ctx, depth + 1)?); + } + return Ok(Out::Arr(out)); + } + if o.get(js_string!("__path"), ctx).map_err(err)?.to_boolean() { + let pts = |name: JsString, ctx: &mut Context| -> Result, String> { + let v = o.get(name, ctx).map_err(err)?; + Ok(match detach(&v, ctx, depth + 1)? { + Out::Arr(items) => items.iter().map(|p| out_vec(p, 2)).map(|c| [c[0], c[1]]).collect(), + _ => vec![], + }) + }; + let points = pts(js_string!("__pts"), ctx)?; + let ins = pts(js_string!("__in"), ctx)?; + let outs = pts(js_string!("__out"), ctx)?; + let closed = o.get(js_string!("__closed"), ctx).map_err(err)?.to_boolean(); + return Ok(Out::Path { points, ins, outs, closed }); + } + } + Ok(Out::Object(v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default())) +} + +fn out_vec(o: &Out, n: usize) -> Vec { + let mut v = match o { + Out::Arr(items) => items.iter().map(|i| if let Out::Num(x) = i { *x } else { f64::NAN }).collect(), + Out::Num(x) => vec![*x], + _ => vec![], + }; + v.resize(n.max(v.len()), 0.0); + v +} + +/// Make boa's messages read like AE's: `Error at line N: message`. +fn format_error(msg: &str, max_line: usize) -> String { + let msg = msg.trim(); + let first = msg.lines().next().unwrap_or("").trim(); + // The wrapper's closing brace sits one line below the user's text. + let line = find_line(msg).map(|l| l.min(max_line.max(1))); + // Drop the position boa appends to the first line (` at line 2, col 5`, ` (unknown at :2:11)`). + let mut text = first.split(" at line ").next().unwrap_or(first).trim(); + if let Some(i) = text.rfind(" (") + && text.ends_with(')') + && text[i..].contains(" at ") + { + text = text[..i].trim(); + } + // `throw new Error("x")` reads better as just the message. + let text = text.strip_prefix("Error: ").unwrap_or(text); + match line { + Some(l) => format!("Error at line {l}: {text}"), + None => format!("Error: {text}"), + } +} + +fn line_count(text: &str) -> usize { + text.lines().count() +} + +/// Line number from `at line N` (parser errors), else the user script's (`
`) backtrace +/// entry, else the first `:line:col` position. +fn find_line(msg: &str) -> Option { + if let Some(i) = msg.find("at line ") { + return msg[i + 8..].split(|c: char| !c.is_ascii_digit()).next()?.parse().ok(); + } + if let Some(l) = msg.lines().find(|l| l.trim_start().starts_with("at
")).and_then(position_line) { + return Some(l); + } + position_line(msg) +} + +/// First `:line:col` in `msg`. +fn position_line(msg: &str) -> Option { + let b = msg.as_bytes(); + for i in 0..b.len() { + if b[i] != b':' { + continue; + } + let digits = |from: usize| b[from..].iter().take_while(|c| c.is_ascii_digit()).count(); + let n = digits(i + 1); + if n > 0 && b.get(i + 1 + n) == Some(&b':') && digits(i + 2 + n) > 0 { + return msg[i + 1..i + 1 + n].parse().ok(); + } + } + None +} + +/// Run `text` once on this thread's runtime. +pub fn run(text: &str, b: &Begin) -> Run { + RUNTIME.with(|r| { + let mut r = r.borrow_mut(); + match &mut **r.get_or_insert_with(|| ManuallyDrop::new(Runtime::new())) { + Ok(rt) => rt.run(text, b), + Err(e) => Run::Done(Err(e.clone())), + } + }) +} + +/// Compile `text` (cached) without running it. +pub fn check(text: &str) -> Result<(), String> { + RUNTIME.with(|r| { + let mut r = r.borrow_mut(); + match &mut **r.get_or_insert_with(|| ManuallyDrop::new(Runtime::new())) { + Ok(rt) => rt.script(text).map(|_| ()), + Err(e) => Err(e.clone()), + } + }) +} + +/// Convert a script result to the property's value kind. +pub fn to_value(out: &Out, current: &Value) -> Result { + let finite = |x: f64| if x.is_finite() { Ok(x) } else { Err("Error: expression result is not a finite number".to_string()) }; + let numeric = |o: &Out| -> Option { + match o { + Out::Num(x) => Some(*x), + Out::Bool(b) => Some(if *b { 1.0 } else { 0.0 }), + Out::Arr(a) => a.first().and_then(|x| if let Out::Num(x) = x { Some(*x) } else { None }), + _ => None, + } + }; + let truthy = |o: &Out| match o { + Out::Undefined | Out::Null => false, + Out::Bool(b) => *b, + Out::Num(x) => *x != 0.0 && !x.is_nan(), + Out::Str(s) => !s.is_empty(), + _ => true, + }; + let text = |o: &Out| -> String { + match o { + Out::Str(s) => s.clone(), + Out::Num(x) => js_number(*x), + Out::Bool(b) => b.to_string(), + Out::Arr(a) => a.iter().map(|x| if let Out::Num(n) = x { js_number(*n) } else { String::new() }).collect::>().join(","), + Out::Undefined | Out::Null => String::new(), + Out::Path { .. } => "[object Path]".into(), + Out::Object(s) => s.clone(), + } + }; + if matches!(out, Out::Undefined) && !matches!(current, Value::Text(_) | Value::Str(_)) { + return Err("Error: expression result is undefined".into()); + } + Ok(match current { + Value::Scalar(_) => Value::Scalar(finite(numeric(out).ok_or_else(|| "Error: expression result must be a number".to_string())?)?), + Value::Vec2(_) | Value::Vec3(_) | Value::Color(_) => { + let Out::Arr(items) = out else { + return Err(format!("Error: expression result must be of dimension {}, not 1", current.dims())); + }; + let mut c = current.components(); + for (i, x) in items.iter().enumerate().take(c.len()) { + c[i] = finite(numeric(x).ok_or_else(|| "Error: expression result array must contain numbers".to_string())?)?; + } + current.with_components(&c) + } + Value::Bool(_) => Value::Bool(truthy(out)), + Value::Enum(_) => Value::Enum((finite(numeric(out).unwrap_or(1.0))? - 1.0).round().max(0.0) as u32), + Value::Text(doc) => { + let mut d = doc.clone(); + d.text = text(out); + Value::Text(d) + } + Value::Str(_) => Value::Str(text(out)), + Value::Path(_) => match out { + Out::Path { points, ins, outs, closed } => { + let n = points.len(); + let fit = |v: &[[f64; 2]]| { + let mut v = v.to_vec(); + v.resize(n, [0.0; 2]); + v + }; + Value::Path(effectcraft_keyframe::ShapePath { vertices: points.clone(), in_tangents: fit(ins), out_tangents: fit(outs), closed: *closed }) + } + _ => return Err("Error: expression result must be a path".into()), + }, + Value::Layer(_) | Value::Gradient(_) => current.clone(), + }) +} + +/// Number → string like JS (`3`, not `3.0`). +fn js_number(x: f64) -> String { + if x.is_finite() && x == x.trunc() && x.abs() < 1e21 { format!("{}", x as i64) } else { format!("{x}") } +} diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs new file mode 100644 index 00000000..779797a0 --- /dev/null +++ b/crates/expr/src/tests.rs @@ -0,0 +1,944 @@ +use std::time::Instant; + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, LayerSource, Marker, MaskMode, Project, PropGroup, Solid}; +use effectcraft_render::{EvalCtx, NoFootage, RenderOpts, Renderer}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{Expressions, check_syntax, eval_property, eval_standalone}; + +const ROT: &str = "transform/rotation"; +const POS: &str = "transform/position"; +const OPA: &str = "transform/opacity"; + +/// A 200×100, 30 fps, 10 s comp named "Main" with: +/// 1. "A": 20×20 red solid at (50, 40), slider effect = 42, rotation keyed 0→90 over 0–1 s +/// 2. "B": 40×40 blue solid at (100, 50) +/// 3. "Title": text layer "Hello" +/// 4. "Shape": shape layer with "Rectangle 1" (60×30 rect path, red fill) +/// +/// plus an "Other" comp with one layer "Inner". +struct Fx { + p: Project, + cid: ItemId, + a: LayerId, + b: LayerId, + title: LayerId, + shape: LayerId, +} + +fn solid(p: &mut Project, comp: &Comp, name: &str, color: [f32; 3], w: u32, h: u32) -> Layer { + let sid = p.add_item(name, Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); + build::layer(p, comp, name, LayerSource::Solid { item: sid }, (w, h), None) +} + +fn group_push(p: &mut Project, f: impl FnOnce(&mut Ids) -> PropGroup) -> PropGroup { + let mut next = p.next_id; + let g = f(&mut Ids(&mut next)); + p.next_id = next; + g +} + +fn fx() -> Fx { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(10.0)); + let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + + let mut a = solid(&mut p, &comp, "A", [1.0, 0.0, 0.0], 20, 20); + a.props.prop_mut(POS).unwrap().value = Value::Vec3([50.0, 40.0, 0.0]); + a.props.prop_mut(ROT).unwrap().keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(secs(1.0), Value::Scalar(90.0))]; + let spec = effectcraft_effects::find("ec.control.slider").unwrap(); + let mut slider = group_push(&mut p, |ids| effectcraft_effects::instantiate(spec, ids, "Slider Control", [20.0, 20.0])); + slider.prop_mut("slider").unwrap().value = Value::Scalar(42.0); + a.props.sub_mut("effects").unwrap().children.push(slider.into()); + let mask = group_push(&mut p, |ids| build::mask(ids, "Mask 1", ShapePath::rect([10.0, 10.0], 10.0, 10.0), MaskMode::Add, [255, 0, 0])); + a.props.sub_mut("masks").unwrap().children.push(mask.into()); + a.markers.push(Marker { time: secs(2.0), comment: "beat".into(), ..Default::default() }); + a.markers.push(Marker { time: secs(4.0), comment: "drop".into(), ..Default::default() }); + + let b = solid(&mut p, &comp, "B", [0.0, 0.0, 1.0], 40, 40); + + let mut title = build::layer(&mut p, &comp, "Title", LayerSource::Text, (200, 100), None); + title.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "Hello".into(), size: 40.0, ..Default::default() })); + title.props.prop_mut(POS).unwrap().value = Value::Vec3([20.0, 60.0, 0.0]); + + let mut shape = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 100), None); + let g = group_push(&mut p, |ids| { + let rect = build::shape_rect(ids, [60.0, 30.0], [0.0, 0.0], 0.0); + let fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]); + build::shape_group(ids, "Rectangle 1", vec![rect, fill]) + }); + shape.props.sub_mut("contents").unwrap().children.push(g.into()); + + let ids = (a.id, b.id, title.id, shape.id); + let c = p.comp_mut(cid).unwrap(); + c.layers = vec![a, b, title, shape]; + c.markers.push(Marker { time: secs(1.5), comment: "comp marker".into(), ..Default::default() }); + + let other = Comp::new(64, 32, FrameRate::FPS_24, Tick::from_seconds_f64(3.0)); + let oid = p.add_item("Other", Label::Sandstone, None, ItemKind::Comp(other.clone().into())); + let inner = solid(&mut p, &other, "Inner", [0.0, 1.0, 0.0], 64, 32); + p.comp_mut(oid).unwrap().layers.push(inner); + + Fx { p, cid, a: ids.0, b: ids.1, title: ids.2, shape: ids.3 } +} + +fn secs(s: f64) -> Tick { + Tick::from_seconds_f64(s) +} + +impl Fx { + fn ev(&self, layer: LayerId, path: &str, text: &str, t: f64) -> Result { + eval_standalone(&self.p, self.cid, layer, path, text, t) + } + /// Scalar result on layer A's rotation. + fn num(&self, text: &str, t: f64) -> f64 { + match self.ev(self.a, ROT, text, t) { + Ok(v) => v.as_f64(), + Err(e) => panic!("{text}: {e}"), + } + } + /// Position result on layer A (3 components). + fn pos(&self, text: &str, t: f64) -> [f64; 3] { + match self.ev(self.a, POS, text, t) { + Ok(v) => v.as_vec3(), + Err(e) => panic!("{text}: {e}"), + } + } + fn text(&self, text: &str, t: f64) -> String { + match self.ev(self.title, "text/sourceText", text, t) { + Ok(Value::Text(d)) => d.text, + Ok(v) => panic!("{text}: not text: {v:?}"), + Err(e) => panic!("{text}: {e}"), + } + } + fn err(&self, text: &str) -> String { + match self.ev(self.a, ROT, text, 0.0) { + Ok(v) => panic!("{text}: expected an error, got {v:?}"), + Err(e) => e, + } + } + fn set_expr(&mut self, layer: LayerId, path: &str, text: &str) { + let l = self.p.comp_mut(self.cid).unwrap().layer_mut(layer).unwrap(); + l.props.prop_mut(path).unwrap().expr = Some(Expression { text: text.into(), enabled: true }); + } + fn layer_mut(&mut self, layer: LayerId) -> &mut Layer { + self.p.comp_mut(self.cid).unwrap().layer_mut(layer).unwrap() + } +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +macro_rules! assert_close { + ($a:expr, $b:expr) => {{ + let (a, b): (f64, f64) = ($a, $b); + assert!((a - b).abs() < 1e-6, "{} = {a}, expected {b}", stringify!($a)); + }}; + ($a:expr, $b:expr, $eps:expr) => {{ + let (a, b): (f64, f64) = ($a, $b); + assert!((a - b).abs() < $eps, "{} = {a}, expected {b} ± {}", stringify!($a), $eps); + }}; +} + +// ---- basics ----------------------------------------------------------------------------------- + +#[test] +fn time_times_360() { + let f = fx(); + assert_close!(f.num("time * 360", 0.5), 180.0); + assert_close!(f.num("time*360", 2.0), 720.0); +} + +#[test] +fn value_is_the_keyframed_value() { + let f = fx(); + assert_close!(f.num("value", 0.5), 45.0); + assert_close!(f.num("value + 10", 1.0), 100.0); +} + +#[test] +fn math_object() { + let f = fx(); + assert_close!(f.num("Math.sin(Math.PI / 2) * 100", 0.0), 100.0); + assert_close!(f.num("Math.max(3, 7) - Math.floor(2.7)", 0.0), 5.0); +} + +#[test] +fn last_statement_is_the_result() { + let f = fx(); + assert_close!(f.num("var x = 5;\nvar y = x * 2;\ny", 0.0), 10.0); + assert_close!(f.num("x = 3; x + 1", 0.0), 4.0); +} + +#[test] +fn if_else_blocks_end_in_a_value() { + let f = fx(); + assert_close!(f.num("if (time > 1) { 10 } else { 20 }", 2.0), 10.0); + assert_close!(f.num("if (time > 1) { 10 } else { 20 }", 0.0), 20.0); + assert_close!(f.num("if (time > 1) 1; else if (time > 0.5) 2; else 3", 0.75), 2.0); +} + +#[test] +fn functions_and_let_are_reusable_across_evaluations() { + let f = fx(); + let e = "function sq(x) { return x * x }\nlet k = 2;\nconst j = 1;\nsq(time) * k + j"; + assert_close!(f.num(e, 3.0), 19.0); + assert_close!(f.num(e, 2.0), 9.0); +} + +#[test] +fn comments_are_ignored() { + let f = fx(); + assert_close!(f.num("// spin\n/* fast */ time * 100 // per second", 1.0), 100.0); +} + +#[test] +fn ternary_and_comparison() { + let f = fx(); + assert_close!(f.num("time > 1 ? 1 + 1 : 3 * 3", 2.0), 2.0); + assert_close!(f.num("time > 1 ? 1 + 1 : 3 * 3", 0.0), 9.0); +} + +// ---- array maths ------------------------------------------------------------------------------ + +#[test] +fn array_plus_array() { + let f = fx(); + assert_eq!(f.pos("value + [10, 0]", 0.0), [60.0, 40.0, 0.0]); + assert_eq!(f.pos("[10, 5] + value", 0.0), [60.0, 45.0, 0.0]); +} + +#[test] +fn array_times_and_divide_scalar() { + let f = fx(); + assert_eq!(f.pos("[1, 2] * 2", 0.0), [2.0, 4.0, 0.0]); + assert_eq!(f.pos("2 * [1, 2]", 0.0), [2.0, 4.0, 0.0]); + assert_eq!(f.pos("[10, 20] / 2", 0.0), [5.0, 10.0, 0.0]); + assert_eq!(f.pos("value - [50, 40]", 0.0), [0.0, 0.0, 0.0]); +} + +#[test] +fn array_precedence_and_unary_minus() { + let f = fx(); + assert_eq!(f.pos("[1, 1] + [2, 3] * 2", 0.0), [5.0, 7.0, 0.0]); + assert_eq!(f.pos("([1, 1] + [2, 3]) * 2", 0.0), [6.0, 8.0, 0.0]); + assert_eq!(f.pos("-value + [100, 100]", 0.0), [50.0, 60.0, 0.0]); +} + +#[test] +fn compound_assignment_on_arrays() { + let f = fx(); + assert_eq!(f.pos("var p = value;\np += [5, 5];\np", 0.0), [55.0, 45.0, 0.0]); + assert_eq!(f.pos("var p = [1, 2];\np *= 3;\np", 0.0), [3.0, 6.0, 0.0]); +} + +#[test] +fn string_concatenation_still_works() { + let f = fx(); + assert_eq!(f.text("'Count: ' + 5", 0.0), "Count: 5"); + assert_eq!(f.text("'a' + 1 + 2", 0.0), "a12"); + assert_eq!(f.text("1 + 2 + 'a'", 0.0), "3a"); + assert_eq!(f.text("'pos ' + [1, 2]", 0.0), "pos 1,2"); +} + +#[test] +fn vector_helpers() { + let f = fx(); + assert_eq!(f.pos("add([1, 2], [3, 4])", 0.0), [4.0, 6.0, 0.0]); + assert_eq!(f.pos("sub([5, 5], [1, 2])", 0.0), [4.0, 3.0, 0.0]); + assert_eq!(f.pos("mul([1, 2], 3)", 0.0), [3.0, 6.0, 0.0]); + assert_eq!(f.pos("div([3, 6], 3)", 0.0), [1.0, 2.0, 0.0]); + assert_close!(f.num("dot([1, 2, 3], [4, 5, 6])", 0.0), 32.0); + assert_eq!(f.pos("cross([1, 0, 0], [0, 1, 0])", 0.0), [0.0, 0.0, 1.0]); + assert_close!(f.num("length([3, 4])", 0.0), 5.0); + assert_close!(f.num("length([1, 1], [4, 5])", 0.0), 5.0); + assert_close!(f.num("normalize([0, 10])[1]", 0.0), 1.0); +} + +#[test] +fn clamp_scalar_and_array() { + let f = fx(); + assert_close!(f.num("clamp(150, 0, 100)", 0.0), 100.0); + assert_close!(f.num("clamp(-5, 0, 100)", 0.0), 0.0); + assert_eq!(f.pos("clamp([-10, 500], 0, 100)", 0.0), [0.0, 100.0, 0.0]); +} + +#[test] +fn angles_and_look_at() { + let f = fx(); + assert_close!(f.num("radiansToDegrees(Math.PI)", 0.0), 180.0); + assert_close!(f.num("degreesToRadians(90)", 0.0), std::f64::consts::FRAC_PI_2); + let o = f.ev(f.a, "transform/orientation", "lookAt([0, 0, 0], [100, 0, 100])", 0.0).unwrap().as_vec3(); + assert!(close(o[1], 45.0) && close(o[0], 0.0), "{o:?}"); +} + +// ---- interpolation ---------------------------------------------------------------------------- + +#[test] +fn linear_five_args_maps_and_clamps() { + let f = fx(); + assert_close!(f.num("linear(time, 0, 2, 0, 100)", 1.0), 50.0); + assert_close!(f.num("linear(time, 0, 2, 0, 100)", 5.0), 100.0); + assert_close!(f.num("linear(time, 1, 2, 0, 100)", 0.0), 0.0); + assert_close!(f.num("linear(time, 2, 0, 0, 100)", 0.5), 75.0); +} + +#[test] +fn linear_three_args() { + let f = fx(); + assert_close!(f.num("linear(0.25, 10, 20)", 0.0), 12.5); +} + +#[test] +fn ease_curves() { + let f = fx(); + assert_close!(f.num("ease(time, 0, 1, 0, 100)", 0.5), 50.0); + assert!(f.num("ease(time, 0, 1, 0, 100)", 0.25) < 25.0); + assert!(f.num("easeIn(time, 0, 1, 0, 100)", 0.25) < 25.0); + assert!(f.num("easeOut(time, 0, 1, 0, 100)", 0.25) > 25.0); + assert_close!(f.num("easeIn(time, 0, 1, 0, 100)", 1.0), 100.0); + assert_close!(f.num("easeOut(0.5, 0, 10)", 0.0) + f.num("easeIn(0.5, 0, 10)", 0.0), 10.0); +} + +#[test] +fn interpolation_with_arrays() { + let f = fx(); + assert_eq!(f.pos("linear(time, 0, 1, [0, 0], [100, 50])", 0.5), [50.0, 25.0, 0.0]); + assert_eq!(f.pos("ease(time, 0, 1, [0, 0], [100, 50])", 1.0), [100.0, 50.0, 0.0]); +} + +// ---- object model ----------------------------------------------------------------------------- + +#[test] +fn comp_attributes() { + let f = fx(); + assert_close!(f.num("thisComp.width + thisComp.height", 0.0), 300.0); + assert_close!(f.num("thisComp.duration", 0.0), 10.0); + assert_close!(f.num("thisComp.frameDuration * 30", 0.0), 1.0); + assert_close!(f.num("thisComp.numLayers", 0.0), 4.0); + assert_eq!(f.text("thisComp.name", 0.0), "Main"); +} + +#[test] +fn layer_attributes() { + let f = fx(); + assert_eq!(f.text("thisLayer.name + ' ' + index", 0.0), "Title 3"); + assert_close!(f.num("index", 0.0), 1.0); + assert_close!(f.num("inPoint + outPoint + startTime", 0.0), 10.0); + assert_close!(f.num("width * 1000 + height", 0.0), 20_020.0); + assert_close!(f.num("thisLayer.hasParent ? 1 : 0", 0.0), 0.0); +} + +#[test] +fn layer_by_name_and_index() { + let f = fx(); + assert_close!(f.num("thisComp.layer(\"B\").transform.position[0]", 0.0), 100.0); + assert_eq!(f.text("thisComp.layer(2).name", 0.0), "B"); + assert_eq!(f.text("thisComp.layer(thisLayer, 1).name", 0.0), "Shape"); + assert_eq!(f.text("thisComp.layer(thisLayer, -2).name", 0.0), "A"); +} + +#[test] +fn cross_layer_position_reference() { + let f = fx(); + assert_eq!(f.pos("thisComp.layer(\"B\").transform.position", 0.0), [100.0, 50.0, 0.0]); + assert_eq!(f.pos("thisComp.layer(\"B\").position + [0, 10]", 0.0), [100.0, 60.0, 0.0]); + assert_close!(f.num("thisComp.layer('B').transform.scale[1]", 0.0), 100.0); + assert_close!(f.num("thisComp.layer('B').transform.opacity", 0.0), 100.0); +} + +#[test] +fn layer_shorthand_attributes() { + let f = fx(); + assert_close!(f.num("position[0] + anchorPoint[0]", 0.0), 60.0); + assert_close!(f.num("scale[0] + opacity", 0.0), 200.0); + assert_close!(f.num("transform.anchorPoint[1]", 0.0), 10.0); +} + +#[test] +fn reading_a_property_that_has_an_expression() { + let mut f = fx(); + f.set_expr(f.b, ROT, "time * 10"); + assert_close!(f.num("thisComp.layer('B').transform.rotation + 1", 2.0), 21.0); + // Chains: B.opacity reads B.rotation (expression), A reads B.opacity. + f.set_expr(f.b, OPA, "transform.rotation * 2"); + assert_close!(f.num("thisComp.layer('B').transform.opacity", 1.0), 20.0); +} + +#[test] +fn own_property_reads_pre_expression_value() { + let f = fx(); + assert_close!(f.num("transform.rotation + 1", 0.5), 46.0); + assert_close!(f.num("thisProperty.value * 2", 1.0), 180.0); +} + +#[test] +fn circular_references_terminate() { + let mut f = fx(); + f.set_expr(f.a, OPA, "thisComp.layer('B').transform.opacity"); + f.set_expr(f.b, OPA, "thisComp.layer('A').transform.opacity"); + let start = Instant::now(); + let r = eval_property(&f.p, f.cid, f.a, OPA, 0.0); + assert!(start.elapsed().as_secs_f64() < 10.0); + // The cycle bottoms out at the depth limit and falls back to keyframed values. + assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}"); +} + +#[test] +fn effect_slider_reference() { + let f = fx(); + assert_close!(f.num("effect(\"Slider Control\")(\"Slider\")", 0.0), 42.0); + assert_close!(f.num("effect('Slider Control')('Slider') * 2", 0.0), 84.0); + assert_close!(f.num("thisLayer.effect('Slider Control').param('Slider')", 0.0), 42.0); + assert_close!(f.num("effect(1).param(1) + effect(1)(1)", 0.0), 84.0); + assert_close!(f.num("thisComp.layer('A').effect('Slider Control')('Slider').value", 0.0), 42.0); + assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').effect('Slider Control')('Slider')", 0.0).unwrap().as_f64(), 42.0); +} + +#[test] +fn effect_slider_with_expression_drives_another_layer() { + let mut f = fx(); + f.set_expr(f.a, "effects/#1/slider", "time * 5"); + assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').effect('Slider Control')('Slider')", 2.0).unwrap().as_f64(), 10.0); +} + +#[test] +fn missing_effect_is_an_error() { + let f = fx(); + let e = f.err("effect('Nope')('Slider')"); + assert!(e.contains("Nope"), "{e}"); +} + +#[test] +fn group_names_and_counts() { + let f = fx(); + assert_eq!(f.text("thisComp.layer('A').effect(1).name", 0.0), "Slider Control"); + assert_close!(f.ev(f.title, ROT, "thisComp.layer('A').transform.numProperties", 0.0).unwrap().as_f64(), 8.0); +} + +#[test] +fn mask_properties() { + let f = fx(); + assert_close!(f.num("mask('Mask 1').maskOpacity", 0.0), 100.0); + assert_close!(f.num("mask('Mask 1').maskExpansion + 1", 0.0), 1.0); +} + +#[test] +fn shape_content_paths() { + let f = fx(); + let v = f.ev(f.shape, ROT, "content('Rectangle 1').content('Rectangle Path 1').size[0]", 0.0).unwrap(); + assert_close!(v.as_f64(), 60.0); + let c = f.ev(f.shape, ROT, "content('Rectangle 1').content('Fill 1').color[0] * 10", 0.0).unwrap(); + assert_close!(c.as_f64(), 10.0); +} + +#[test] +fn shape_fill_color_expression() { + let f = fx(); + let path = "contents/#1/contents/fill/color"; + let v = f.ev(f.shape, path, "[0, 1, 0]", 0.0).unwrap(); + assert_eq!(v, Value::Color([0.0, 1.0, 0.0, 1.0])); +} + +#[test] +fn comp_by_name() { + let f = fx(); + assert_eq!(f.text("comp('Other').layer(1).name", 0.0), "Inner"); + assert_close!(f.num("comp('Other').width", 0.0), 64.0); + assert!(f.err("comp('Missing').width").contains("Missing")); +} + +#[test] +fn parent_attributes() { + let mut f = fx(); + let b = f.b; + f.layer_mut(f.a).parent = Some(b); + assert_eq!(f.text("thisComp.layer('A').parent.name", 0.0), "B"); + assert_close!(f.num("hasParent ? parent.transform.position[0] : -1", 0.0), 100.0); +} + +#[test] +fn markers() { + let f = fx(); + assert_close!(f.num("marker.numKeys", 0.0), 2.0); + assert_close!(f.num("marker.key(2).time", 0.0), 4.0); + assert_eq!(f.text("thisComp.layer('A').marker.key(1).comment", 0.0), "beat"); + assert_close!(f.num("marker.key('drop').time", 0.0), 4.0); + assert_close!(f.num("marker.nearestKey(time).index", 3.5), 2.0); + assert_close!(f.num("thisComp.marker.key(1).time", 0.0), 1.5); +} + +#[test] +fn layer_markers_follow_start_time() { + let mut f = fx(); + f.layer_mut(f.a).start_time = secs(1.0); + assert_close!(f.num("marker.key(1).time", 0.0), 3.0); +} + +// ---- keyframes, time --------------------------------------------------------------------------- + +#[test] +fn value_at_time() { + let f = fx(); + assert_close!(f.num("valueAtTime(0.5)", 0.0), 45.0); + assert_close!(f.num("thisProperty.valueAtTime(time - 0.5)", 1.0), 45.0); + assert_close!(f.ev(f.b, ROT, "thisComp.layer('A').transform.rotation.valueAtTime(0.25)", 0.0).unwrap().as_f64(), 22.5); +} + +#[test] +fn value_at_time_respects_layer_start_time() { + let mut f = fx(); + f.layer_mut(f.a).start_time = secs(1.0); + // Keys at layer time 0 and 1 → comp time 1 and 2. + assert_close!(f.num("valueAtTime(1.5)", 0.0), 45.0); + assert_close!(f.num("key(1).time", 0.0), 1.0); +} + +#[test] +fn velocity_and_speed() { + let f = fx(); + assert_close!(f.num("velocityAtTime(0.5)", 0.0), 90.0, 1e-3); + assert_close!(f.num("velocity", 0.5), 90.0, 1e-3); + assert_close!(f.num("speedAtTime(0.5)", 0.0), 90.0, 1e-3); + assert_close!(f.num("thisComp.layer('B').transform.position.speed", 0.5), 0.0, 1e-6); +} + +#[test] +fn keys_and_nearest_key() { + let f = fx(); + assert_close!(f.num("numKeys", 0.0), 2.0); + assert_close!(f.num("key(2).time * 1000 + key(2).value", 0.0), 1090.0); + assert_close!(f.num("nearestKey(0.8).index", 0.0), 2.0); + assert_close!(f.num("nearestKey(0.2).value", 0.0), 0.0); + assert!(f.err("key(3).time").contains("out of range")); +} + +#[test] +fn loop_out_cycle() { + let f = fx(); + assert_close!(f.num("loopOut('cycle')", 1.25), 22.5); + assert_close!(f.num("loopOut()", 2.5), 45.0); + assert_close!(f.num("loopOut('cycle')", 0.5), 45.0); +} + +#[test] +fn loop_out_pingpong() { + let f = fx(); + assert_close!(f.num("loopOut('pingpong')", 1.25), 67.5); + assert_close!(f.num("loopOut('pingpong')", 2.25), 22.5); +} + +#[test] +fn loop_out_offset() { + let f = fx(); + assert_close!(f.num("loopOut('offset')", 1.5), 135.0); + assert_close!(f.num("loopOut('offset')", 3.5), 315.0); +} + +#[test] +fn loop_out_continue() { + let f = fx(); + assert_close!(f.num("loopOut('continue')", 2.0), 180.0, 1e-3); +} + +#[test] +fn loop_in_cycle_and_offset() { + let mut f = fx(); + f.layer_mut(f.a).props.prop_mut(ROT).unwrap().keys = vec![Keyframe::new(secs(1.0), Value::Scalar(0.0)), Keyframe::new(secs(2.0), Value::Scalar(100.0))]; + assert_close!(f.num("loopIn('cycle')", 0.25), 25.0); + assert_close!(f.num("loopIn('offset')", 0.25), -75.0); + assert_close!(f.num("loopIn('pingpong')", 0.75), 25.0); + assert_close!(f.num("loopIn('cycle')", 1.5), 50.0); +} + +#[test] +fn loop_out_num_keyframes_and_duration() { + let mut f = fx(); + let keys = + vec![Keyframe::new(secs(0.0), Value::Scalar(0.0)), Keyframe::new(secs(1.0), Value::Scalar(100.0)), Keyframe::new(secs(2.0), Value::Scalar(200.0))]; + f.layer_mut(f.a).props.prop_mut(ROT).unwrap().keys = keys; + // Only the last segment (1 s → 2 s) loops. + assert_close!(f.num("loopOut('cycle', 1)", 2.5), 150.0); + assert_close!(f.num("loopOut('cycle', 0)", 2.5), 50.0); + assert_close!(f.num("loopOutDuration('cycle', 0.5)", 2.25), 175.0); +} + +#[test] +fn loop_on_two_dimensional_position() { + let mut f = fx(); + f.layer_mut(f.a).props.prop_mut(POS).unwrap().keys = + vec![Keyframe::new(secs(0.0), Value::Vec3([0.0, 0.0, 0.0])), Keyframe::new(secs(1.0), Value::Vec3([100.0, 50.0, 0.0]))]; + assert_eq!(f.pos("loopOut('offset')", 1.5), [150.0, 75.0, 0.0]); +} + +#[test] +fn smooth_on_linear_ramp_is_identity() { + let f = fx(); + assert_close!(f.num("smooth(0.2, 5)", 0.5), 45.0, 1e-6); +} + +#[test] +fn time_conversions() { + let f = fx(); + assert_close!(f.num("timeToFrames()", 2.0), 60.0); + assert_close!(f.num("timeToFrames(1, 24)", 0.0), 24.0); + assert_close!(f.num("framesToTime(45)", 0.0), 1.5); + assert_eq!(f.text("timeToTimecode()", 61.5), "0:01:01:15"); + assert_eq!(f.text("timeToCurrentFormat(2.5)", 0.0), "0:00:02:15"); +} + +#[test] +fn posterize_time() { + let f = fx(); + assert_close!(f.num("posterizeTime(2); time", 0.7), 0.5); + assert_close!(f.num("posterizeTime(2); value", 0.7), 45.0); +} + +// ---- wiggle / random / noise ------------------------------------------------------------------ + +#[test] +fn wiggle_is_deterministic() { + let f = fx(); + let a = f.pos("wiggle(3, 25)", 1.234); + let b = f.pos("wiggle(3, 25)", 1.234); + assert_eq!(a, b); + let g = fx(); + assert_eq!(g.pos("wiggle(3, 25)", 1.234), a); + let other_thread = std::thread::spawn(move || g.pos("wiggle(3, 25)", 1.234)).join().unwrap(); + assert_eq!(other_thread, a); +} + +#[test] +fn wiggle_amplitude_bounds_and_motion() { + let f = fx(); + let mut seen = Vec::new(); + for i in 0..120 { + let p = f.pos("wiggle(5, 30)", i as f64 / 30.0); + assert!((p[0] - 50.0).abs() <= 30.0 + 1e-9 && (p[1] - 40.0).abs() <= 30.0 + 1e-9, "{p:?}"); + assert_eq!(p[2], 0.0, "2D layers keep z"); + seen.push(p[0]); + } + let spread = seen.iter().cloned().fold(f64::MIN, f64::max) - seen.iter().cloned().fold(f64::MAX, f64::min); + assert!(spread > 10.0, "wiggle should move: spread {spread}"); +} + +#[test] +fn wiggle_octaves_bound() { + let f = fx(); + for i in 0..60 { + let r = f.num("wiggle(2, 10, 3, 0.5) - value", i as f64 / 10.0); + assert!(r.abs() <= 10.0 * 1.75 + 1e-9, "{r}"); + } +} + +#[test] +fn wiggle_differs_per_layer_and_dimension() { + let f = fx(); + let a = f.ev(f.a, OPA, "wiggle(2, 20)", 0.7).unwrap().as_f64(); + let b = f.ev(f.b, OPA, "wiggle(2, 20)", 0.7).unwrap().as_f64(); + assert_ne!(a, b); + let p = f.pos("wiggle(2, 20) - value", 0.7); + assert_ne!(p[0], p[1]); +} + +#[test] +fn wiggle_on_other_layer_property() { + let f = fx(); + let v = f.num("thisComp.layer('B').transform.rotation.wiggle(1, 5)", 0.3); + assert!(v.abs() <= 5.0 && v != 0.0, "{v}"); +} + +#[test] +fn temporal_wiggle_stays_on_the_curve() { + let f = fx(); + for i in 0..10 { + let v = f.num("temporalWiggle(2, 0.1)", 0.2 + i as f64 * 0.05); + assert!((0.0..=90.0).contains(&v), "{v}"); + } +} + +#[test] +fn random_is_seeded_and_in_range() { + let f = fx(); + let a = f.num("random()", 0.5); + assert!((0.0..1.0).contains(&a)); + assert_eq!(a, f.num("random()", 0.5)); + assert_ne!(a, f.num("random()", 0.6)); + let r = f.num("random(10, 20)", 0.0); + assert!((10.0..20.0).contains(&r)); + let p = f.pos("random([100, 50])", 0.0); + assert!((0.0..100.0).contains(&p[0]) && (0.0..50.0).contains(&p[1])); + assert_ne!(f.num("random() - random()", 0.0), 0.0, "successive calls differ"); +} + +#[test] +fn seed_random_timeless() { + let f = fx(); + let a = f.num("seedRandom(5, true); random(100)", 0.1); + let b = f.num("seedRandom(5, true); random(100)", 3.7); + assert_eq!(a, b); + assert_ne!(a, f.num("seedRandom(6, true); random(100)", 0.1)); +} + +#[test] +fn gauss_random_distribution() { + let f = fx(); + let e = + "var s = 0, n = 2000, inside = 0;\nfor (var i = 0; i < n; i++) { var g = gaussRandom(); s += g; if (g >= 0 && g <= 1) inside++; }\n[s / n, inside / n]"; + let r = f.pos(e, 0.0); + assert!((r[0] - 0.5).abs() < 0.05, "mean {}", r[0]); + assert!((0.85..0.95).contains(&r[1]), "inside {}", r[1]); +} + +#[test] +fn noise_is_smooth_and_bounded() { + let f = fx(); + let a = f.num("noise(time)", 1.0); + let b = f.num("noise(time)", 1.001); + assert!(a.abs() <= 1.0 && (a - b).abs() < 0.01); + assert!(f.num("noise([1.5, 2.5, 3.5])", 0.0).abs() <= 1.0); +} + +// ---- text ------------------------------------------------------------------------------------- + +#[test] +fn source_text_from_expression_keeps_style() { + let f = fx(); + let v = f.ev(f.title, "text/sourceText", "'Frame ' + timeToFrames()", 1.0).unwrap(); + let Value::Text(doc) = v else { panic!() }; + assert_eq!(doc.text, "Frame 30"); + assert_eq!(doc.size, 40.0, "style from the keyframed document"); +} + +#[test] +fn source_text_reading_and_string_methods() { + let f = fx(); + assert_eq!(f.text("value + '!'", 0.0), "Hello!"); + assert_eq!(f.text("thisComp.layer('Title').text.sourceText + ' world'", 0.0), "Hello world"); + assert_eq!(f.text("text.sourceText.toUpperCase()", 0.0), "HELLO"); + assert_close!(f.num("thisComp.layer('Title').text.sourceText.length", 0.0), 5.0); +} + +#[test] +fn numbers_to_source_text() { + let f = fx(); + assert_eq!(f.text("Math.round(time * 10) / 10", 1.25), "1.3"); + assert_eq!(f.text("(time * 2).toFixed(2)", 1.0), "2.00"); + assert_eq!(f.text("12", 0.0), "12"); +} + +// ---- space conversions ------------------------------------------------------------------------ + +#[test] +fn to_comp_and_from_comp() { + let f = fx(); + // A: 20×20 solid, anchor (10, 10), position (50, 40). + assert_eq!(f.pos("toComp([0, 0])", 0.0), [40.0, 30.0, 0.0]); + assert_eq!(f.pos("fromComp([50, 40])", 0.0), [10.0, 10.0, 0.0]); + // Rotation keyed to 90° at 1 s. + let p = f.pos("toComp([20, 10])", 1.0); + assert!(close(p[0], 50.0) && close(p[1], 50.0), "{p:?}"); + let q = f.pos("toWorld([10, 10, 0])", 0.0); + assert!(close(q[0], 50.0) && close(q[1], 40.0), "{q:?}"); + let v = f.pos("toCompVec([1, 0])", 1.0); + assert!(close(v[0], 0.0) && close(v[1], 1.0), "{v:?}"); +} + +#[test] +fn source_rect_at_time() { + let f = fx(); + assert_eq!(f.pos("var r = sourceRectAtTime(); [r.width, r.height]", 0.0), [20.0, 20.0, 0.0]); + let t = f.ev(f.title, POS, "var r = sourceRectAtTime(); [r.width, r.height]", 0.0).unwrap().as_vec3(); + assert!(t[0] > 40.0 && t[1] > 15.0, "{t:?}"); + let s = f.ev(f.shape, POS, "var r = sourceRectAtTime(); [r.left, r.top, r.width]", 0.0).unwrap().as_vec3(); + assert!((s[0] + 31.0).abs() < 2.0 && (s[1] + 16.0).abs() < 2.0 && (s[2] - 62.0).abs() < 3.0, "{s:?}"); +} + +// ---- colour ----------------------------------------------------------------------------------- + +#[test] +fn colour_conversions() { + let f = fx(); + let hsl = f.pos("rgbToHsl([1, 0, 0, 1])", 0.0); + assert_eq!(hsl, [0.0, 1.0, 0.5]); + let back = f.pos("hslToRgb([2 / 3, 1, 0.5, 1])", 0.0); + assert!(close(back[0], 0.0) && close(back[1], 0.0) && close(back[2], 1.0), "{back:?}"); + let hex = f.pos("hexToRgb('#FF8000')", 0.0); + assert!(close(hex[0], 1.0) && close(hex[1], 128.0 / 255.0) && close(hex[2], 0.0), "{hex:?}"); +} + +#[test] +fn colour_result_keeps_alpha() { + let f = fx(); + let path = "contents/#1/contents/fill/color"; + let v = f.ev(f.shape, path, "hslToRgb([0, 0, 0.5])", 0.0).unwrap(); + assert_eq!(v, Value::Color([0.5, 0.5, 0.5, 1.0])); +} + +// ---- result conversion and errors ------------------------------------------------------------- + +#[test] +fn checkbox_and_dropdown_results() { + let mut f = fx(); + let spec = effectcraft_effects::find("ec.control.checkbox").unwrap(); + let cb = group_push(&mut f.p, |ids| effectcraft_effects::instantiate(spec, ids, "Checkbox Control", [20.0, 20.0])); + let spec = effectcraft_effects::find("ec.control.dropdown").unwrap(); + let dd = group_push(&mut f.p, |ids| effectcraft_effects::instantiate(spec, ids, "Dropdown Menu Control", [20.0, 20.0])); + let fxg = f.layer_mut(f.b).props.sub_mut("effects").unwrap(); + fxg.children.push(cb.into()); + fxg.children.push(dd.into()); + assert_eq!(f.ev(f.b, "effects/#1/checkbox", "time > 1", 2.0).unwrap(), Value::Bool(true)); + assert_eq!(f.ev(f.b, "effects/#1/checkbox", "0", 2.0).unwrap(), Value::Bool(false)); + assert_eq!(f.ev(f.b, "effects/#2/menu", "3", 0.0).unwrap(), Value::Enum(2)); + assert_close!(f.ev(f.b, ROT, "effect('Dropdown Menu Control')('Menu') * 10", 0.0).unwrap().as_f64(), 10.0); +} + +#[test] +fn syntax_error_reports_line() { + let f = fx(); + let e = f.err("var a = 1;\nvar b = ;\na"); + assert!(e.contains("line 2"), "{e}"); + assert!(check_syntax("var a = (1;").is_err()); + assert!(check_syntax("value + [1, 2]").is_ok()); +} + +#[test] +fn runtime_error_reports_line() { + let f = fx(); + let e = f.err("var a = 1;\nfoo.bar + a"); + assert!(e.contains("foo"), "{e}"); + assert!(e.contains("line 2"), "{e}"); +} + +#[test] +fn thrown_errors_and_missing_layers() { + let f = fx(); + assert!(f.err("throw new Error('custom failure')").contains("custom failure")); + assert!(f.err("thisComp.layer('Ghost').position").contains("Ghost")); + assert!(f.err("thisComp.layer(99).name").contains("99")); +} + +#[test] +fn result_kind_errors() { + let f = fx(); + assert!(f.ev(f.a, POS, "5", 0.0).unwrap_err().contains("dimension")); + assert!(f.err("'abc'").contains("number")); + assert!(f.err("var x = 1").contains("undefined")); + assert!(f.err("0 / 0").contains("finite")); +} + +#[test] +fn shadowing_layer_attribute_names_is_local() { + let f = fx(); + assert_close!(f.num("var width = 3;\nwidth * 2", 0.0), 6.0); + assert_close!(f.num("width", 0.0), 20.0, 1e-9); +} + +#[test] +fn try_catch_does_not_swallow_host_reads() { + let f = fx(); + assert_close!(f.num("try { thisComp.layer('B').transform.position[0] } catch (e) { -1 }", 0.0), 100.0); + assert_close!(f.num("try { thisComp.layer('Ghost').name; 1 } catch (e) { -1 }", 0.0), -1.0); +} + +#[test] +fn loops_over_layers() { + let f = fx(); + let e = "var s = 0;\nfor (var i = 1; i <= thisComp.numLayers; i++) s += thisComp.layer(i).transform.position[0];\ns"; + assert_close!(f.num(e, 0.0), 50.0 + 100.0 + 20.0 + 100.0); +} + +#[test] +fn infinite_loops_are_stopped() { + let f = fx(); + let e = f.err("while (true) {}"); + assert!(!e.is_empty()); +} + +#[test] +fn renderer_uses_expressions_through_eval_ctx() { + let mut f = fx(); + f.set_expr(f.b, ROT, "time * 360"); + let host = Expressions; + let comp = f.p.comp(f.cid).unwrap(); + let b = comp.layer(f.b).unwrap(); + let prop = b.props.prop(ROT).unwrap(); + let mut ctx = EvalCtx::new(&f.p, f.cid, comp, secs(0.25)); + assert_eq!(ctx.value(b, prop), Value::Scalar(0.0), "no host: keyframed value"); + ctx.expr = Some(&host); + assert_eq!(ctx.value(b, prop), Value::Scalar(90.0)); +} + +#[test] +fn disabled_expression_is_ignored() { + let mut f = fx(); + f.set_expr(f.b, ROT, "time * 360"); + f.layer_mut(f.b).props.prop_mut(ROT).unwrap().expr.as_mut().unwrap().enabled = false; + assert_eq!(eval_property(&f.p, f.cid, f.b, ROT, 0.25).unwrap(), Value::Scalar(0.0)); +} + +// ---- render & performance --------------------------------------------------------------------- + +#[test] +fn expression_drives_rotation_in_a_rendered_frame() { + let mut p = Project::default(); + let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + // A 80×10 horizontal bar at the centre; rotating 90° makes it vertical. + let mut bar = solid(&mut p, &comp, "Bar", [1.0, 1.0, 1.0], 80, 10); + bar.props.prop_mut(ROT).unwrap().expr = Some(Expression { text: "time * 360".into(), enabled: true }); + p.comp_mut(cid).unwrap().layers.push(bar); + let host = Expressions; + let mut r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() }); + let still = r.comp_frame(cid, Tick::ZERO); + assert!(still.get(85, 50)[3] > 0.99 && still.get(50, 85)[3] < 0.01, "horizontal at t=0"); + let no_expr = r.comp_frame(cid, secs(0.25)); + assert!(no_expr.get(85, 50)[3] > 0.99, "without a host the bar stays put"); + r.expr = Some(&host); + let img = r.comp_frame(cid, secs(0.25)); + assert!(img.get(50, 85)[3] > 0.99, "vertical at t=0.25 (90°)"); + assert!(img.get(85, 50)[3] < 0.01); +} + +#[test] +fn hundred_wiggles_are_fast() { + let mut p = Project::default(); + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(10.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + for i in 0..100 { + let mut l = solid(&mut p, &comp, &format!("L{i}"), [1.0, 1.0, 1.0], 10, 10); + l.props.prop_mut(POS).unwrap().expr = Some(Expression { text: "wiggle(2, 50)".into(), enabled: true }); + p.comp_mut(cid).unwrap().layers.push(l); + } + let host = Expressions; + let c = p.comp(cid).unwrap(); + let frame = |t: f64| { + let mut ctx = EvalCtx::new(&p, cid, c, secs(t)); + ctx.expr = Some(&host); + c.layers.iter().map(|l| ctx.value(l, l.props.prop(POS).unwrap()).as_vec3()[0]).sum::() + }; + frame(0.0); // warm up: runtime + compile + let n = 20; + let start = Instant::now(); + let mut acc = 0.0; + for i in 0..n { + acc += frame(i as f64 / 30.0); + } + let per_frame = start.elapsed().as_secs_f64() * 1000.0 / n as f64; + println!("100 wiggle properties: {per_frame:.3} ms/frame (checksum {acc})"); + // Generous bound so unoptimised CI builds pass; release is far below. + assert!(per_frame < 100.0, "{per_frame} ms/frame"); +} + +#[test] +fn error_messages_point_at_the_users_line() { + let f = fx(); + assert_eq!(f.err("var a = 1;\n\nthisComp.layer('Ghost').position"), "Error at line 3: Layer named \"Ghost\" does not exist"); + assert_eq!(f.err("var a = 1;\nfoo.bar + a"), "Error at line 2: ReferenceError: foo is not defined"); + // An unfinished last line is reported on that line, not on the hidden wrapper. + assert!(f.err("5 +").starts_with("Error at line 1: SyntaxError")); +}