mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dedb13546a | ||
|
|
0997466331 | ||
|
|
1bd9fac203 | ||
|
|
69da5bee0c | ||
|
|
cb99925d75 | ||
|
|
78c6c497f1 | ||
|
|
22725f71fa | ||
|
|
1a8a1e889a | ||
|
|
3516bd69f4 | ||
|
|
57fc0fd323 | ||
|
|
bd9ec023ee | ||
|
|
f4b9f40ab1 | ||
|
|
93cc5d50f3 | ||
|
|
8a1d37cf3e | ||
|
|
e579ae1202 | ||
|
|
b048b739bd | ||
|
|
608dc1a62f | ||
|
|
f1d5932c54 | ||
|
|
cc150c7cfb | ||
|
|
0488a6be45 |
@@ -45,10 +45,9 @@ Thank you for your interest in contributing to OpenCut! This document provides g
|
||||
5. Install dependencies: `bun install`
|
||||
6. Start the development server: `bun run dev`
|
||||
|
||||
> **Note:** If you see an error like `Unsupported URL Type "workspace:*"` when running `npm install`, you have two options:
|
||||
> **Note:** Web development uses the published `opencut-wasm` package by default, so a fresh clone does not need a local WASM build.
|
||||
>
|
||||
> 1. Upgrade to a recent npm version (v9 or later), which has full workspace protocol support.
|
||||
> 2. Use an alternative package manager such as **bun** or **pnpm**.
|
||||
> If you are editing `rust/wasm`, run `bun run build:wasm`, then `cd rust/wasm/pkg && bun link`, then `cd ../../../apps/web && bun link opencut-wasm`.
|
||||
|
||||
### Desktop setup
|
||||
|
||||
|
||||
@@ -31,11 +31,6 @@ jobs:
|
||||
MARBLE_WORKSPACE_KEY: "placeholder"
|
||||
FREESOUND_CLIENT_ID: "placeholder"
|
||||
FREESOUND_API_KEY: "placeholder"
|
||||
CLOUDFLARE_ACCOUNT_ID: "placeholder"
|
||||
R2_ACCESS_KEY_ID: "placeholder"
|
||||
R2_SECRET_ACCESS_KEY: "placeholder"
|
||||
R2_BUCKET_NAME: "placeholder"
|
||||
MODAL_TRANSCRIPTION_URL: "https://placeholder.example.com"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
Generated
+568
-31
@@ -60,6 +60,21 @@ dependencies = [
|
||||
"equator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
@@ -136,7 +151,7 @@ checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"raw-window-handle",
|
||||
"raw-window-metal",
|
||||
"raw-window-metal 0.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -502,7 +517,16 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -511,6 +535,12 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.3"
|
||||
@@ -548,8 +578,8 @@ dependencies = [
|
||||
"ash-window",
|
||||
"bitflags 2.11.0",
|
||||
"bytemuck",
|
||||
"codespan-reporting",
|
||||
"glow",
|
||||
"codespan-reporting 0.12.0",
|
||||
"glow 0.16.0",
|
||||
"gpu-alloc",
|
||||
"gpu-alloc-ash",
|
||||
"hidden-trait",
|
||||
@@ -558,7 +588,7 @@ dependencies = [
|
||||
"libloading",
|
||||
"log",
|
||||
"mint",
|
||||
"naga",
|
||||
"naga 25.0.1",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
@@ -898,6 +928,17 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"termcolor",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
@@ -1332,6 +1373,15 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
@@ -1665,6 +1715,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "font-types"
|
||||
version = "0.11.1"
|
||||
@@ -1954,6 +2010,17 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
|
||||
dependencies = [
|
||||
"khronos_api",
|
||||
"log",
|
||||
"xml-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
@@ -2010,6 +2077,36 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"slotmap",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glutin_wgl_sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
|
||||
dependencies = [
|
||||
"gl_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"thiserror 2.0.18",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-alloc"
|
||||
version = "0.6.0"
|
||||
@@ -2040,6 +2137,40 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-allocator"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"hashbrown 0.16.1",
|
||||
"log",
|
||||
"presser",
|
||||
"thiserror 2.0.18",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-descriptor"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"gpu-descriptor-types",
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-descriptor-types"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpui"
|
||||
version = "0.2.2"
|
||||
@@ -2091,7 +2222,7 @@ dependencies = [
|
||||
"log",
|
||||
"lyon",
|
||||
"metal",
|
||||
"naga",
|
||||
"naga 25.0.1",
|
||||
"num_cpus",
|
||||
"objc",
|
||||
"oo7",
|
||||
@@ -2126,7 +2257,7 @@ dependencies = [
|
||||
"wayland-protocols-plasma",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-numerics",
|
||||
"windows-numerics 0.2.0",
|
||||
"windows-registry 0.5.3",
|
||||
"x11-clipboard",
|
||||
"x11rb",
|
||||
@@ -2348,7 +2479,7 @@ version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2356,6 +2487,11 @@ name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -2789,6 +2925,34 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||
dependencies = [
|
||||
"jni-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||
dependencies = [
|
||||
"jni-sys-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys-macros"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
@@ -2801,9 +2965,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.92"
|
||||
version = "0.3.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
|
||||
checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -2819,8 +2983,15 @@ checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libloading",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos_api"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.11.3"
|
||||
@@ -2939,6 +3110,12 @@ version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -3165,10 +3342,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set",
|
||||
"bit-set 0.8.0",
|
||||
"bitflags 2.11.0",
|
||||
"cfg_aliases",
|
||||
"codespan-reporting",
|
||||
"codespan-reporting 0.12.0",
|
||||
"half",
|
||||
"hashbrown 0.15.5",
|
||||
"hexf-parse",
|
||||
@@ -3177,12 +3354,38 @@ dependencies = [
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv",
|
||||
"spirv 0.3.0+sdk-1.3.268.0",
|
||||
"strum 0.26.3",
|
||||
"thiserror 2.0.18",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "29.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa2630921705b9b01dcdd0b6864b9562ca3c1951eecd0f0c4f5f04f61e412647"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"codespan-reporting 0.13.1",
|
||||
"half",
|
||||
"hashbrown 0.16.1",
|
||||
"hexf-parse",
|
||||
"indexmap",
|
||||
"libm",
|
||||
"log",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv 0.4.0+sdk-1.4.341.0",
|
||||
"thiserror 2.0.18",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nanorand"
|
||||
version = "0.7.0"
|
||||
@@ -3192,6 +3395,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.6.0+11769913"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||
dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
@@ -3565,9 +3777,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opencut-wasm"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"gpu",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"time",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3582,6 +3801,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -3781,6 +4009,21 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postage"
|
||||
version = "0.5.0"
|
||||
@@ -3816,6 +4059,12 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@@ -4069,6 +4318,12 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
|
||||
|
||||
[[package]]
|
||||
name = "rangemap"
|
||||
version = "1.7.1"
|
||||
@@ -4143,6 +4398,18 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-metal"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"objc2-quartz-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
@@ -4260,6 +4527,12 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "renderdoc-sys"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||
|
||||
[[package]]
|
||||
name = "resvg"
|
||||
version = "0.45.1"
|
||||
@@ -4882,6 +5155,15 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spirv"
|
||||
version = "0.4.0+sdk-1.4.341.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -5927,9 +6209,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.115"
|
||||
version = "0.2.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
|
||||
checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -5940,9 +6222,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.65"
|
||||
version = "0.4.66"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
|
||||
checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -5950,9 +6232,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.115"
|
||||
version = "0.2.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
|
||||
checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -5960,9 +6242,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.115"
|
||||
version = "0.2.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
|
||||
checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -5973,9 +6255,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.115"
|
||||
version = "0.2.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
|
||||
checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -6126,9 +6408,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.92"
|
||||
version = "0.3.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
|
||||
checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -6150,6 +6432,173 @@ version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "wgpu"
|
||||
version = "29.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72c239a9a747bbd379590985bac952c2e53cb19873f7072b3370c6a6a8e06837"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags 2.11.0",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"document-features",
|
||||
"hashbrown 0.16.1",
|
||||
"js-sys",
|
||||
"log",
|
||||
"naga 29.0.1",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"smallvec",
|
||||
"static_assertions",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"wgpu-core",
|
||||
"wgpu-hal",
|
||||
"wgpu-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core"
|
||||
version = "29.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e80ac6cf1895df6342f87d975162108f9d98772a0d74bc404ab7304ac29469e"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.9.1",
|
||||
"bit-vec 0.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
"document-features",
|
||||
"hashbrown 0.16.1",
|
||||
"indexmap",
|
||||
"log",
|
||||
"naga 29.0.1",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"rustc-hash 1.1.0",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"wgpu-core-deps-apple",
|
||||
"wgpu-core-deps-emscripten",
|
||||
"wgpu-core-deps-windows-linux-android",
|
||||
"wgpu-hal",
|
||||
"wgpu-naga-bridge",
|
||||
"wgpu-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-apple"
|
||||
version = "29.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43acd053312501689cd92a01a9638d37f3e41a5fd9534875efa8917ee2d11ac0"
|
||||
dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-emscripten"
|
||||
version = "29.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef043bf135cc68b6f667c55ff4e345ce2b5924d75bad36a47921b0287ca4b24a"
|
||||
dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-core-deps-windows-linux-android"
|
||||
version = "29.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "725d5c006a8c02967b6d93ef04f6537ec4593313e330cfe86d9d3f946eb90f28"
|
||||
dependencies = [
|
||||
"wgpu-hal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-hal"
|
||||
version = "29.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a47aef47636562f3937285af4c44b4b5b404b46577471411cc5313a921da7e"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"ash",
|
||||
"bit-set 0.9.1",
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"glow 0.17.0",
|
||||
"glutin_wgl_sys",
|
||||
"gpu-allocator",
|
||||
"gpu-descriptor",
|
||||
"hashbrown 0.16.1",
|
||||
"js-sys",
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading",
|
||||
"log",
|
||||
"naga 29.0.1",
|
||||
"ndk-sys",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"objc2-metal",
|
||||
"objc2-quartz-core",
|
||||
"once_cell",
|
||||
"ordered-float",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"profiling",
|
||||
"range-alloc",
|
||||
"raw-window-handle",
|
||||
"raw-window-metal 1.1.0",
|
||||
"renderdoc-sys",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"wasm-bindgen",
|
||||
"wayland-sys",
|
||||
"web-sys",
|
||||
"wgpu-naga-bridge",
|
||||
"wgpu-types",
|
||||
"windows 0.62.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-naga-bridge"
|
||||
version = "29.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b4684f4410da0cf95a4cb63bb5edaac022461dedb6adf0b64d0d9b5f6890d51"
|
||||
dependencies = [
|
||||
"naga 29.0.1",
|
||||
"wgpu-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wgpu-types"
|
||||
version = "29.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec2675540fb1a5cfa5ef122d3d5f390e2c75711a0b946410f2d6ac3a0f77d1f6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bytemuck",
|
||||
"js-sys",
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "6.0.3"
|
||||
@@ -6209,11 +6658,23 @@ version = "0.61.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-collections 0.2.0",
|
||||
"windows-core 0.61.2",
|
||||
"windows-future",
|
||||
"windows-future 0.2.1",
|
||||
"windows-link 0.1.3",
|
||||
"windows-numerics",
|
||||
"windows-numerics 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections 0.3.2",
|
||||
"windows-core 0.62.2",
|
||||
"windows-future 0.3.2",
|
||||
"windows-numerics 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6226,7 +6687,7 @@ dependencies = [
|
||||
"rayon",
|
||||
"thiserror 2.0.18",
|
||||
"windows 0.61.3",
|
||||
"windows-future",
|
||||
"windows-future 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6238,6 +6699,15 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
@@ -6263,6 +6733,19 @@ dependencies = [
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.2.1"
|
||||
@@ -6271,7 +6754,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
"windows-threading",
|
||||
"windows-threading 0.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
"windows-link 0.2.1",
|
||||
"windows-threading 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6340,6 +6834,16 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.4.0"
|
||||
@@ -6380,6 +6884,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.3.1"
|
||||
@@ -6398,6 +6911,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
@@ -6500,6 +7022,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@@ -6878,6 +7409,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||
|
||||
[[package]]
|
||||
name = "xmlwriter"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-1
@@ -4,5 +4,5 @@ members = [
|
||||
"apps/desktop",
|
||||
"rust/crates/time",
|
||||
"rust/crates/bridge",
|
||||
"rust/wasm",
|
||||
"rust/wasm", "rust/crates/gpu",
|
||||
]
|
||||
|
||||
@@ -93,6 +93,43 @@ Desktop is opt-in. If you're only working on the web app, skip this entirely.
|
||||
|
||||
If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps/desktop/README.md). It's a two-step setup: Rust toolchain first, then desktop native dependencies.
|
||||
|
||||
### Local WASM development
|
||||
|
||||
Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
|
||||
|
||||
1. Build the package once from the repo root:
|
||||
|
||||
```bash
|
||||
bun run build:wasm
|
||||
```
|
||||
|
||||
2. Register the generated package for linking:
|
||||
|
||||
```bash
|
||||
cd rust/wasm/pkg
|
||||
bun link
|
||||
```
|
||||
|
||||
3. Link `apps/web` to the local package:
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
bun link opencut-wasm
|
||||
```
|
||||
|
||||
4. Rebuild on changes while you work:
|
||||
|
||||
```bash
|
||||
bun dev:wasm
|
||||
```
|
||||
|
||||
To switch `apps/web` back to the published package, run:
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
bun add opencut-wasm
|
||||
```
|
||||
|
||||
### Self-Hosting with Docker
|
||||
|
||||
To run everything (including a production build of the app) in Docker:
|
||||
|
||||
@@ -19,10 +19,3 @@ MARBLE_WORKSPACE_KEY=your_workspace_key_here
|
||||
|
||||
FREESOUND_CLIENT_ID=your_client_id_here
|
||||
FREESOUND_API_KEY=your_api_key_here
|
||||
|
||||
CLOUDFLARE_ACCOUNT_ID=your_account_id_here
|
||||
R2_ACCESS_KEY_ID=your_access_key_here
|
||||
R2_SECRET_ACCESS_KEY=your_secret_key_here
|
||||
R2_BUCKET_NAME=opencut-transcription # whatever you named your r2 bucket
|
||||
|
||||
MODAL_TRANSCRIPTION_URL=your_modal_url_here
|
||||
@@ -29,11 +29,6 @@ ENV UPSTASH_REDIS_REST_TOKEN="example_token"
|
||||
ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||
ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL
|
||||
ENV MARBLE_WORKSPACE_KEY=$MARBLE_WORKSPACE_KEY
|
||||
ENV CLOUDFLARE_ACCOUNT_ID="build-placeholder"
|
||||
ENV R2_ACCESS_KEY_ID="build-placeholder"
|
||||
ENV R2_SECRET_ACCESS_KEY="build-placeholder"
|
||||
ENV R2_BUCKET_NAME="build-placeholder"
|
||||
ENV MODAL_TRANSCRIPTION_URL="http://localhost:0"
|
||||
|
||||
ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID
|
||||
ENV FREESOUND_API_KEY=$FREESOUND_API_KEY
|
||||
|
||||
@@ -3,14 +3,6 @@ import { withBotId } from "botid/next/config";
|
||||
import { withContentCollections } from "@content-collections/next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
rules: {
|
||||
"*.glsl": {
|
||||
loaders: [require.resolve("raw-loader")],
|
||||
as: "*.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.6",
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"opencut-wasm": "workspace:*",
|
||||
"@opennextjs/cloudflare": "^1.18.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -53,6 +52,7 @@
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"opencut-wasm": "^0.1.3",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -94,7 +94,6 @@
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"postcss": "^8",
|
||||
"raw-loader": "^4.0.2",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.8.3",
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
@@ -257,7 +257,7 @@ function MediaAssetDraggable({
|
||||
startTime: number;
|
||||
}) => {
|
||||
const duration =
|
||||
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: asset.id,
|
||||
mediaType: asset.type,
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import {
|
||||
BLUR_INTENSITY_PRESETS,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_COLOR,
|
||||
} from "@/constants/project-constants";
|
||||
BACKGROUND_BLUR_INTENSITY_PRESETS,
|
||||
DEFAULT_BACKGROUND_BLUR_INTENSITY,
|
||||
DEFAULT_BACKGROUND_COLOR,
|
||||
} from "@/lib/background/constants";
|
||||
import { patternCraftGradients } from "@/data/colors/pattern-craft";
|
||||
import { colors } from "@/data/colors/solid";
|
||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
@@ -164,16 +164,16 @@ export function BackgroundContent() {
|
||||
const currentBlurIntensity = isBlurBackground
|
||||
? (activeProject.settings.background as { blurIntensity: number })
|
||||
.blurIntensity
|
||||
: DEFAULT_BLUR_INTENSITY;
|
||||
: DEFAULT_BACKGROUND_BLUR_INTENSITY;
|
||||
|
||||
const currentBackgroundColor = isColorBackground
|
||||
? (activeProject.settings.background as { color: string }).color
|
||||
: DEFAULT_COLOR;
|
||||
: DEFAULT_BACKGROUND_COLOR;
|
||||
const canvasSize = activeProject.settings.canvasSize;
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
BLUR_INTENSITY_PRESETS.map((blur) => (
|
||||
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
|
||||
<BlurPreview
|
||||
key={blur.value}
|
||||
blur={blur}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { FPS_PRESETS } from "@/constants/project-constants";
|
||||
import { FPS_PRESETS } from "@/lib/fps/constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
Section,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { resolveStickerIntrinsicSize } from "@/lib/stickers";
|
||||
import {
|
||||
@@ -70,41 +71,25 @@ export function StickersView() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b border-border px-2">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Sticker categories"
|
||||
className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0"
|
||||
>
|
||||
{Object.entries(STICKER_CATEGORIES).map(([key, label]) => {
|
||||
const isActive = key === selectedCategory;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() =>
|
||||
setSelectedCategory({
|
||||
category: key as StickerCategory,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px",
|
||||
isActive && "text-primary border-primary",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
value={selectedCategory}
|
||||
onValueChange={(value) => {
|
||||
setSelectedCategory({ category: value as StickerCategory });
|
||||
}}
|
||||
variant="underline"
|
||||
className="mt-2 flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<TabsList aria-label="Sticker categories">
|
||||
{Object.entries(STICKER_CATEGORIES).map(([key, label]) => (
|
||||
<TabsTrigger key={key} value={key}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
|
||||
<StickersContentView />
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
screenToCanvas,
|
||||
} from "@/lib/preview/preview-coords";
|
||||
import { clamp, isNearlyEqual } from "@/utils/math";
|
||||
import { PREVIEW_ZOOM } from "@/constants/editor-constants";
|
||||
import { PREVIEW_ZOOM } from "@/lib/preview/constants";
|
||||
|
||||
const MIDDLE_MOUSE_BUTTON = 1;
|
||||
const IS_AT_FIT_EPSILON = 0.001;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
} from "@/components/ui/select";
|
||||
import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
|
||||
import { PREVIEW_ZOOM_PRESETS } from "@/lib/preview/constants";
|
||||
import { usePreviewViewport } from "./preview-viewport";
|
||||
import { GridPopover } from "./guide-popover";
|
||||
import { usePreviewStore } from "@/stores/preview-store";
|
||||
|
||||
@@ -9,8 +9,7 @@ export function useElementPlayhead({
|
||||
startTime: number;
|
||||
duration: number;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: playheadTime,
|
||||
elementStartTime: startTime,
|
||||
|
||||
+150
-148
@@ -1,148 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
buildGraphicParamPath,
|
||||
getKeyframeAtTime,
|
||||
getParamDefaultInterpolation,
|
||||
getParamValueKind,
|
||||
hasKeyframesForPath,
|
||||
upsertPathKeyframe,
|
||||
} from "@/lib/animation";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
|
||||
export interface KeyframedParamPropertyResult {
|
||||
hasAnimatedKeyframes: boolean;
|
||||
isKeyframedAtTime: boolean;
|
||||
keyframeIdAtTime: string | null;
|
||||
onPreview: (value: number | string | boolean) => void;
|
||||
onCommit: () => void;
|
||||
toggleKeyframe: () => void;
|
||||
}
|
||||
|
||||
export function useKeyframedParamProperty({
|
||||
param,
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue,
|
||||
buildBaseUpdates,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedValue: number | string | boolean;
|
||||
buildBaseUpdates: ({
|
||||
value,
|
||||
}: {
|
||||
value: number | string | boolean;
|
||||
}) => Partial<TimelineElement>;
|
||||
}): KeyframedParamPropertyResult {
|
||||
const editor = useEditor();
|
||||
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
});
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
})
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
|
||||
if (shouldUseAnimatedChannel) {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
animations: upsertPathKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value,
|
||||
valueKind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({
|
||||
param,
|
||||
}),
|
||||
numericRange:
|
||||
param.type === "number"
|
||||
? { min: param.min, max: param.max, step: param.step }
|
||||
: undefined,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: buildBaseUpdates({ value }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId: keyframeIdAtTime,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: resolvedValue,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
hasAnimatedKeyframes,
|
||||
isKeyframedAtTime,
|
||||
keyframeIdAtTime,
|
||||
onPreview: previewValue,
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
toggleKeyframe,
|
||||
};
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
buildGraphicParamPath,
|
||||
coerceAnimationValueForParam,
|
||||
getKeyframeAtTime,
|
||||
getParamDefaultInterpolation,
|
||||
getParamValueKind,
|
||||
hasKeyframesForPath,
|
||||
upsertPathKeyframe,
|
||||
} from "@/lib/animation";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
|
||||
export interface KeyframedParamPropertyResult {
|
||||
hasAnimatedKeyframes: boolean;
|
||||
isKeyframedAtTime: boolean;
|
||||
keyframeIdAtTime: string | null;
|
||||
onPreview: (value: number | string | boolean) => void;
|
||||
onCommit: () => void;
|
||||
toggleKeyframe: () => void;
|
||||
}
|
||||
|
||||
export function useKeyframedParamProperty({
|
||||
param,
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue,
|
||||
buildBaseUpdates,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedValue: number | string | boolean;
|
||||
buildBaseUpdates: ({
|
||||
value,
|
||||
}: {
|
||||
value: number | string | boolean;
|
||||
}) => Partial<TimelineElement>;
|
||||
}): KeyframedParamPropertyResult {
|
||||
const editor = useEditor();
|
||||
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
});
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
})
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
|
||||
if (shouldUseAnimatedChannel) {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
animations: upsertPathKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value,
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({
|
||||
param,
|
||||
}),
|
||||
coerceValue: (nextValue) =>
|
||||
coerceAnimationValueForParam({
|
||||
param,
|
||||
value: nextValue,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: buildBaseUpdates({ value }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId: keyframeIdAtTime,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: resolvedValue,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
hasAnimatedKeyframes,
|
||||
isKeyframedAtTime,
|
||||
keyframeIdAtTime,
|
||||
onPreview: previewValue,
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
toggleKeyframe,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
||||
import { isSourceAudioSeparated } from "@/lib/timeline/audio-separation";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import {
|
||||
clamp,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
} from "@/utils/math";
|
||||
import type { AudioElement, VideoElement } from "@/lib/timeline";
|
||||
import { resolveNumberAtTime } from "@/lib/animation";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
import { KeyframeToggle } from "../components/keyframe-toggle";
|
||||
@@ -34,6 +37,7 @@ export function AudioTab({
|
||||
element: AudioElement | VideoElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
@@ -80,46 +84,68 @@ export function AudioTab({
|
||||
rightValue: DEFAULTS.element.volume,
|
||||
})
|
||||
: (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
|
||||
const isSeparated =
|
||||
element.type === "video" && isSourceAudioSeparated({ element });
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.id}:audio`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Audio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField
|
||||
label="Volume"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={volume.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle volume keyframe"
|
||||
onToggle={volume.toggleKeyframe}
|
||||
/>
|
||||
<>
|
||||
{isSeparated && (
|
||||
<div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
|
||||
<p className="text-sm">Audio has been separated.</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
editor.timeline.toggleSourceAudioSeparation({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
|
||||
value={volume.displayValue}
|
||||
onFocus={volume.onFocus}
|
||||
onChange={volume.onChange}
|
||||
onBlur={volume.onBlur}
|
||||
dragSensitivity="slow"
|
||||
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
|
||||
onScrub={volume.scrubTo}
|
||||
onScrubEnd={volume.commitScrub}
|
||||
onReset={() =>
|
||||
volume.commitValue({
|
||||
value: DEFAULTS.element.volume,
|
||||
})
|
||||
Recover audio
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Section collapsible sectionKey={`${element.id}:audio`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Audio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField
|
||||
label="Volume"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={volume.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle volume keyframe"
|
||||
onToggle={volume.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
isDefault={isDefault}
|
||||
suffix="dB"
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
>
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
|
||||
value={volume.displayValue}
|
||||
onFocus={volume.onFocus}
|
||||
onChange={volume.onChange}
|
||||
onBlur={volume.onBlur}
|
||||
dragSensitivity="slow"
|
||||
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
|
||||
onScrub={volume.scrubTo}
|
||||
onScrubEnd={volume.commitScrub}
|
||||
onReset={() =>
|
||||
volume.commitValue({
|
||||
value: DEFAULTS.element.volume,
|
||||
})
|
||||
}
|
||||
isDefault={isDefault}
|
||||
suffix="dB"
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
MAX_RETIME_RATE,
|
||||
clampRetimeRate,
|
||||
canMaintainPitch,
|
||||
} from "@/constants/retime-constants";
|
||||
} from "@/lib/retime/rate";
|
||||
import type { AudioElement, VideoElement } from "@/lib/timeline";
|
||||
import {
|
||||
Section,
|
||||
|
||||
@@ -18,13 +18,13 @@ import { Button } from "@/components/ui/button";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import { clamp, formatNumberForDisplay } from "@/utils/math";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_COLOR } from "@/constants/project-constants";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
DEFAULT_TEXT_COLOR,
|
||||
CORNER_RADIUS_MIN,
|
||||
MAX_FONT_SIZE,
|
||||
MIN_FONT_SIZE,
|
||||
} from "@/constants/text-constants";
|
||||
} from "@/lib/text/constants";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
@@ -343,7 +343,7 @@ function BackgroundSection({
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const lastSelectedColor = useRef(DEFAULT_COLOR);
|
||||
const lastSelectedColor = useRef(DEFAULT_TEXT_COLOR);
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
|
||||
@@ -6,10 +6,10 @@ import { useEditor } from "@/hooks/use-editor";
|
||||
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
|
||||
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
|
||||
import {
|
||||
DEFAULT_BOOKMARK_COLOR,
|
||||
TIMELINE_BOOKMARK_ROW_HEIGHT,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||
DEFAULT_TIMELINE_BOOKMARK_COLOR,
|
||||
} from "./theme";
|
||||
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
|
||||
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
||||
import { getSnappedSeekTime } from "opencut-wasm";
|
||||
import {
|
||||
ArrowTurnBackwardIcon,
|
||||
@@ -83,12 +83,12 @@ export function TimelineBookmarksRow({
|
||||
return (
|
||||
<div
|
||||
className="relative flex-1 overflow-hidden"
|
||||
style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT }}
|
||||
style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX }}
|
||||
>
|
||||
<button
|
||||
className="relative w-full cursor-default select-none border-0 bg-transparent p-0"
|
||||
style={{
|
||||
height: TIMELINE_BOOKMARK_ROW_HEIGHT,
|
||||
height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX,
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
aria-label="Timeline ruler"
|
||||
@@ -153,7 +153,7 @@ function TimelineBookmark({
|
||||
const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel });
|
||||
const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
|
||||
const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
|
||||
const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR;
|
||||
const iconColor = bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR;
|
||||
|
||||
const handleSeek = () => seekToBookmarkTime({ editor, time });
|
||||
|
||||
@@ -298,12 +298,16 @@ function BookmarkPopoverContent({
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const [draftColorHex, setDraftColorHex] = useState(
|
||||
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
|
||||
(bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
|
||||
.replace("#", "")
|
||||
.toUpperCase(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftColorHex(
|
||||
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
|
||||
(bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
|
||||
.replace("#", "")
|
||||
.toUpperCase(),
|
||||
);
|
||||
}, [bookmark.color]);
|
||||
|
||||
@@ -357,7 +361,7 @@ function BookmarkPopoverContent({
|
||||
/>
|
||||
{bookmark.color &&
|
||||
bookmark.color.replace(/^#/, "").toUpperCase() !==
|
||||
DEFAULT_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
|
||||
DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getDropLineY } from "@/lib/timeline/drop-utils";
|
||||
import { getDropLineY } from "./drop-target";
|
||||
import type { TimelineTrack, DropTarget } from "@/lib/timeline";
|
||||
import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
|
||||
import { TIMELINE_LAYERS } from "./layers";
|
||||
|
||||
interface DragLineProps {
|
||||
dropTarget: DropTarget | null;
|
||||
|
||||
+251
-257
@@ -1,257 +1,251 @@
|
||||
import type {
|
||||
TimelineTrack,
|
||||
TimelineElement,
|
||||
} from "@/lib/timeline";
|
||||
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
|
||||
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
|
||||
import { resolveTrackPlacement } from "@/lib/timeline/placement";
|
||||
|
||||
function findElementAtPosition({
|
||||
mouseX,
|
||||
tracks,
|
||||
trackIndex,
|
||||
targetElementTypes,
|
||||
pixelsPerSecond,
|
||||
zoomLevel,
|
||||
}: {
|
||||
mouseX: number;
|
||||
tracks: TimelineTrack[];
|
||||
trackIndex: number;
|
||||
targetElementTypes: string[];
|
||||
pixelsPerSecond: number;
|
||||
zoomLevel: number;
|
||||
}): { elementId: string; trackId: string } | null {
|
||||
const time = mouseX / (pixelsPerSecond * zoomLevel);
|
||||
const track = tracks[trackIndex];
|
||||
if (!track || !("elements" in track)) return null;
|
||||
|
||||
const hit = track.elements.find(
|
||||
(element: TimelineElement) =>
|
||||
targetElementTypes.includes(element.type) &&
|
||||
element.startTime <= time &&
|
||||
time < element.startTime + element.duration,
|
||||
);
|
||||
if (!hit) return null;
|
||||
return { elementId: hit.id, trackId: track.id };
|
||||
}
|
||||
|
||||
function getTrackAtY({
|
||||
mouseY,
|
||||
tracks,
|
||||
verticalDragDirection,
|
||||
}: {
|
||||
mouseY: number;
|
||||
tracks: TimelineTrack[];
|
||||
verticalDragDirection?: "up" | "down" | null;
|
||||
}): { trackIndex: number; relativeY: number } | null {
|
||||
let cumulativeHeight = 0;
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const trackHeight = TRACK_CONFIG[tracks[i].type].height;
|
||||
const trackTop = cumulativeHeight;
|
||||
const trackBottom = trackTop + trackHeight;
|
||||
|
||||
if (mouseY >= trackTop && mouseY < trackBottom) {
|
||||
return {
|
||||
trackIndex: i,
|
||||
relativeY: mouseY - trackTop,
|
||||
};
|
||||
}
|
||||
|
||||
if (i < tracks.length - 1 && verticalDragDirection) {
|
||||
const gapTop = trackBottom;
|
||||
const gapBottom = gapTop + TRACK_GAP;
|
||||
if (mouseY >= gapTop && mouseY < gapBottom) {
|
||||
const isDraggingUp = verticalDragDirection === "up";
|
||||
return {
|
||||
trackIndex: isDraggingUp ? i : i + 1,
|
||||
relativeY: isDraggingUp ? trackHeight - 1 : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
cumulativeHeight += trackHeight + TRACK_GAP;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const EMPTY_TARGET_ELEMENT = null;
|
||||
|
||||
function fallbackNewTrackDropTarget({
|
||||
xPosition,
|
||||
}: {
|
||||
xPosition: number;
|
||||
}): DropTarget {
|
||||
return {
|
||||
trackIndex: 0,
|
||||
isNewTrack: true,
|
||||
insertPosition: null,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeDropTarget({
|
||||
elementType,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks,
|
||||
playheadTime,
|
||||
isExternalDrop,
|
||||
elementDuration,
|
||||
pixelsPerSecond,
|
||||
zoomLevel,
|
||||
verticalDragDirection,
|
||||
startTimeOverride,
|
||||
excludeElementId,
|
||||
targetElementTypes,
|
||||
}: ComputeDropTargetParams): DropTarget {
|
||||
const xPosition =
|
||||
typeof startTimeOverride === "number"
|
||||
? startTimeOverride
|
||||
: isExternalDrop
|
||||
? playheadTime
|
||||
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
||||
|
||||
if (tracks.length === 0) {
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: 0,
|
||||
hoverDirection: "below",
|
||||
createNewTrackOnly: true,
|
||||
},
|
||||
});
|
||||
const emptyTimelineResult =
|
||||
placementResult?.kind === "newTrack" ? placementResult : null;
|
||||
if (!emptyTimelineResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: emptyTimelineResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: emptyTimelineResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
|
||||
|
||||
if (!trackAtMouse) {
|
||||
const isAboveAllTracks = mouseY < 0;
|
||||
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
|
||||
hoverDirection: isAboveAllTracks ? "above" : "below",
|
||||
createNewTrackOnly: true,
|
||||
},
|
||||
});
|
||||
const outOfBoundsResult =
|
||||
placementResult?.kind === "newTrack" ? placementResult : null;
|
||||
if (!outOfBoundsResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: outOfBoundsResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: outOfBoundsResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
const { trackIndex, relativeY } = trackAtMouse;
|
||||
const track = tracks[trackIndex];
|
||||
|
||||
if (
|
||||
targetElementTypes &&
|
||||
targetElementTypes.length > 0
|
||||
) {
|
||||
const targetElement = findElementAtPosition({
|
||||
mouseX,
|
||||
tracks,
|
||||
trackIndex,
|
||||
targetElementTypes,
|
||||
pixelsPerSecond,
|
||||
zoomLevel,
|
||||
});
|
||||
if (targetElement) {
|
||||
return {
|
||||
trackIndex,
|
||||
isNewTrack: false,
|
||||
insertPosition: null,
|
||||
xPosition,
|
||||
targetElement,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const trackHeight = TRACK_CONFIG[track.type].height;
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex,
|
||||
hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
|
||||
verticalDragDirection,
|
||||
},
|
||||
});
|
||||
if (!placementResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
if (placementResult.kind === "existingTrack") {
|
||||
const adjustedXPosition =
|
||||
placementResult.adjustedStartTime ?? xPosition;
|
||||
|
||||
return {
|
||||
trackIndex: placementResult.trackIndex,
|
||||
isNewTrack: false,
|
||||
insertPosition: null,
|
||||
xPosition: adjustedXPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: placementResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: placementResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDropLineY({
|
||||
dropTarget,
|
||||
tracks,
|
||||
}: {
|
||||
dropTarget: DropTarget;
|
||||
tracks: TimelineTrack[];
|
||||
}): number {
|
||||
const safeTrackIndex = Math.min(
|
||||
Math.max(dropTarget.trackIndex, 0),
|
||||
tracks.length,
|
||||
);
|
||||
let y = 0;
|
||||
|
||||
for (let i = 0; i < safeTrackIndex; i++) {
|
||||
y += TRACK_CONFIG[tracks[i].type].height + TRACK_GAP;
|
||||
}
|
||||
|
||||
return y;
|
||||
}
|
||||
import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
|
||||
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
|
||||
import { resolveTrackPlacement } from "@/lib/timeline/placement";
|
||||
import { TIMELINE_TRACK_GAP_PX } from "./layout";
|
||||
import { getTrackHeight } from "./track-layout";
|
||||
|
||||
function findElementAtPosition({
|
||||
mouseX,
|
||||
tracks,
|
||||
trackIndex,
|
||||
targetElementTypes,
|
||||
pixelsPerSecond,
|
||||
zoomLevel,
|
||||
}: {
|
||||
mouseX: number;
|
||||
tracks: TimelineTrack[];
|
||||
trackIndex: number;
|
||||
targetElementTypes: string[];
|
||||
pixelsPerSecond: number;
|
||||
zoomLevel: number;
|
||||
}): { elementId: string; trackId: string } | null {
|
||||
const time = mouseX / (pixelsPerSecond * zoomLevel);
|
||||
const track = tracks[trackIndex];
|
||||
if (!track || !("elements" in track)) return null;
|
||||
|
||||
const hit = track.elements.find(
|
||||
(element: TimelineElement) =>
|
||||
targetElementTypes.includes(element.type) &&
|
||||
element.startTime <= time &&
|
||||
time < element.startTime + element.duration,
|
||||
);
|
||||
if (!hit) return null;
|
||||
return { elementId: hit.id, trackId: track.id };
|
||||
}
|
||||
|
||||
function getTrackAtY({
|
||||
mouseY,
|
||||
tracks,
|
||||
verticalDragDirection,
|
||||
}: {
|
||||
mouseY: number;
|
||||
tracks: TimelineTrack[];
|
||||
verticalDragDirection?: "up" | "down" | null;
|
||||
}): { trackIndex: number; relativeY: number } | null {
|
||||
let cumulativeHeight = 0;
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const trackHeight = getTrackHeight({ type: tracks[i].type });
|
||||
const trackTop = cumulativeHeight;
|
||||
const trackBottom = trackTop + trackHeight;
|
||||
|
||||
if (mouseY >= trackTop && mouseY < trackBottom) {
|
||||
return {
|
||||
trackIndex: i,
|
||||
relativeY: mouseY - trackTop,
|
||||
};
|
||||
}
|
||||
|
||||
if (i < tracks.length - 1 && verticalDragDirection) {
|
||||
const gapTop = trackBottom;
|
||||
const gapBottom = gapTop + TIMELINE_TRACK_GAP_PX;
|
||||
if (mouseY >= gapTop && mouseY < gapBottom) {
|
||||
const isDraggingUp = verticalDragDirection === "up";
|
||||
return {
|
||||
trackIndex: isDraggingUp ? i : i + 1,
|
||||
relativeY: isDraggingUp ? trackHeight - 1 : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
cumulativeHeight += trackHeight + TIMELINE_TRACK_GAP_PX;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const EMPTY_TARGET_ELEMENT = null;
|
||||
|
||||
function fallbackNewTrackDropTarget({
|
||||
xPosition,
|
||||
}: {
|
||||
xPosition: number;
|
||||
}): DropTarget {
|
||||
return {
|
||||
trackIndex: 0,
|
||||
isNewTrack: true,
|
||||
insertPosition: null,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeDropTarget({
|
||||
elementType,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks,
|
||||
playheadTime,
|
||||
isExternalDrop,
|
||||
elementDuration,
|
||||
pixelsPerSecond,
|
||||
zoomLevel,
|
||||
verticalDragDirection,
|
||||
startTimeOverride,
|
||||
excludeElementId,
|
||||
targetElementTypes,
|
||||
}: ComputeDropTargetParams): DropTarget {
|
||||
const xPosition =
|
||||
typeof startTimeOverride === "number"
|
||||
? startTimeOverride
|
||||
: isExternalDrop
|
||||
? playheadTime
|
||||
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
||||
|
||||
if (tracks.length === 0) {
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: 0,
|
||||
hoverDirection: "below",
|
||||
createNewTrackOnly: true,
|
||||
},
|
||||
});
|
||||
const emptyTimelineResult =
|
||||
placementResult?.kind === "newTrack" ? placementResult : null;
|
||||
if (!emptyTimelineResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: emptyTimelineResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: emptyTimelineResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
|
||||
|
||||
if (!trackAtMouse) {
|
||||
const isAboveAllTracks = mouseY < 0;
|
||||
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
|
||||
hoverDirection: isAboveAllTracks ? "above" : "below",
|
||||
createNewTrackOnly: true,
|
||||
},
|
||||
});
|
||||
const outOfBoundsResult =
|
||||
placementResult?.kind === "newTrack" ? placementResult : null;
|
||||
if (!outOfBoundsResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: outOfBoundsResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: outOfBoundsResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
const { trackIndex, relativeY } = trackAtMouse;
|
||||
const track = tracks[trackIndex];
|
||||
|
||||
if (targetElementTypes && targetElementTypes.length > 0) {
|
||||
const targetElement = findElementAtPosition({
|
||||
mouseX,
|
||||
tracks,
|
||||
trackIndex,
|
||||
targetElementTypes,
|
||||
pixelsPerSecond,
|
||||
zoomLevel,
|
||||
});
|
||||
if (targetElement) {
|
||||
return {
|
||||
trackIndex,
|
||||
isNewTrack: false,
|
||||
insertPosition: null,
|
||||
xPosition,
|
||||
targetElement,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex,
|
||||
hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
|
||||
verticalDragDirection,
|
||||
},
|
||||
});
|
||||
if (!placementResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
if (placementResult.kind === "existingTrack") {
|
||||
const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition;
|
||||
|
||||
return {
|
||||
trackIndex: placementResult.trackIndex,
|
||||
isNewTrack: false,
|
||||
insertPosition: null,
|
||||
xPosition: adjustedXPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: placementResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: placementResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDropLineY({
|
||||
dropTarget,
|
||||
tracks,
|
||||
}: {
|
||||
dropTarget: DropTarget;
|
||||
tracks: TimelineTrack[];
|
||||
}): number {
|
||||
const safeTrackIndex = Math.min(
|
||||
Math.max(dropTarget.trackIndex, 0),
|
||||
tracks.length,
|
||||
);
|
||||
let y = 0;
|
||||
|
||||
for (let i = 0; i < safeTrackIndex; i++) {
|
||||
y += getTrackHeight({ type: tracks[i].type }) + TIMELINE_TRACK_GAP_PX;
|
||||
}
|
||||
|
||||
return y;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type PointerEvent } from "react";
|
||||
import { getBezierPoint } from "@/lib/animation/bezier";
|
||||
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const GRAPH_WIDTH = 140;
|
||||
const GRAPH_HEIGHT = 94;
|
||||
const GRAPH_PADDING = 12;
|
||||
const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2;
|
||||
const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2;
|
||||
const HANDLE_RADIUS = 3.5;
|
||||
const ENDPOINT_RADIUS = 2;
|
||||
const SNAP_THRESHOLD = 0.06;
|
||||
const SNAP_TARGETS = [0, 1];
|
||||
const CURVE_SEGMENTS = 64;
|
||||
const Y_CLAMP_MIN = -0.5;
|
||||
const Y_CLAMP_MAX = 1.5;
|
||||
|
||||
type BezierHandle = "c1" | "c2";
|
||||
|
||||
export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT;
|
||||
|
||||
function snap({
|
||||
value,
|
||||
targets,
|
||||
isEnabled,
|
||||
}: {
|
||||
value: number;
|
||||
targets: number[];
|
||||
isEnabled: boolean;
|
||||
}) {
|
||||
if (!isEnabled) return value;
|
||||
for (const target of targets) {
|
||||
if (Math.abs(value - target) < SNAP_THRESHOLD) return target;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function toSvgX({ value }: { value: number }) {
|
||||
return GRAPH_PADDING + value * GRAPH_WIDTH;
|
||||
}
|
||||
|
||||
function toSvgY({ value }: { value: number }) {
|
||||
return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT;
|
||||
}
|
||||
|
||||
function fromSvgX({ svgX }: { svgX: number }) {
|
||||
return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH));
|
||||
}
|
||||
|
||||
function fromSvgY({ svgY }: { svgY: number }) {
|
||||
return Math.max(
|
||||
Y_CLAMP_MIN,
|
||||
Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT),
|
||||
);
|
||||
}
|
||||
|
||||
function curvePath({ curve }: { curve: NormalizedCubicBezier }) {
|
||||
const points: string[] = [];
|
||||
for (let i = 0; i <= CURVE_SEGMENTS; i++) {
|
||||
const progress = i / CURVE_SEGMENTS;
|
||||
points.push(
|
||||
`${toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) })},${toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) })}`,
|
||||
);
|
||||
}
|
||||
return `M${points.join("L")}`;
|
||||
}
|
||||
|
||||
function clampHandleY({ svgY }: { svgY: number }) {
|
||||
return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY));
|
||||
}
|
||||
|
||||
export function BezierGraph({
|
||||
value,
|
||||
onChange,
|
||||
onChangeEnd,
|
||||
onCancel,
|
||||
}: {
|
||||
value: NormalizedCubicBezier;
|
||||
onChange?: (value: NormalizedCubicBezier) => void;
|
||||
onChangeEnd?: (value: NormalizedCubicBezier) => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null);
|
||||
const isShiftPressedRef = useRef(false);
|
||||
const latestValueRef = useRef(value);
|
||||
|
||||
latestValueRef.current = value;
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Shift") isShiftPressedRef.current = true;
|
||||
};
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === "Shift") isShiftPressedRef.current = false;
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function getPointerPosition({
|
||||
event,
|
||||
}: {
|
||||
event: PointerEvent;
|
||||
}): { x: number; y: number } {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return { x: 0, y: 0 };
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const scale = SVG_WIDTH / rect.width;
|
||||
return {
|
||||
x: (event.clientX - rect.left) * scale,
|
||||
y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height),
|
||||
};
|
||||
}
|
||||
|
||||
function onHandlePointerDown({ handle }: { handle: BezierHandle }) {
|
||||
return (event: PointerEvent<SVGCircleElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setActiveHandle(handle);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
}
|
||||
|
||||
function onPointerMove({ event }: { event: PointerEvent<SVGSVGElement> }) {
|
||||
if (!activeHandle) return;
|
||||
const pointerPos = getPointerPosition({ event });
|
||||
const x = fromSvgX({ svgX: pointerPos.x });
|
||||
const y = snap({
|
||||
value: fromSvgY({ svgY: pointerPos.y }),
|
||||
targets: SNAP_TARGETS,
|
||||
isEnabled: !isShiftPressedRef.current,
|
||||
});
|
||||
const next: NormalizedCubicBezier = [...value];
|
||||
if (activeHandle === "c1") {
|
||||
next[0] = x;
|
||||
next[1] = y;
|
||||
} else {
|
||||
next[2] = x;
|
||||
next[3] = y;
|
||||
}
|
||||
latestValueRef.current = next;
|
||||
onChange?.(next);
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (!activeHandle) return;
|
||||
setActiveHandle(null);
|
||||
onChangeEnd?.(latestValueRef.current);
|
||||
}
|
||||
|
||||
function onPointerCancel() {
|
||||
if (!activeHandle) return;
|
||||
setActiveHandle(null);
|
||||
onCancel?.();
|
||||
}
|
||||
|
||||
const path = curvePath({ curve: value });
|
||||
const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) };
|
||||
const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) };
|
||||
const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) };
|
||||
const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) };
|
||||
const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) };
|
||||
const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) };
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
|
||||
className="bg-foreground/3 w-full cursor-crosshair select-none"
|
||||
onPointerMove={(event) => onPointerMove({ event })}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
>
|
||||
<title>Bezier curve editor</title>
|
||||
<line
|
||||
x1={p0.x}
|
||||
y1={p0.y}
|
||||
x2={p1.x}
|
||||
y2={p1.y}
|
||||
className="stroke-foreground/8"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<line
|
||||
x1={p0.x}
|
||||
y1={p0.y}
|
||||
x2={c1Clamped.x}
|
||||
y2={c1Clamped.y}
|
||||
className="stroke-primary/30"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<line
|
||||
x1={p1.x}
|
||||
y1={p1.y}
|
||||
x2={c2Clamped.x}
|
||||
y2={c2Clamped.y}
|
||||
className="stroke-primary/30"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
className="stroke-primary"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle
|
||||
cx={p0.x}
|
||||
cy={p0.y}
|
||||
r={ENDPOINT_RADIUS}
|
||||
className="fill-foreground/20"
|
||||
/>
|
||||
<circle
|
||||
cx={p1.x}
|
||||
cy={p1.y}
|
||||
r={ENDPOINT_RADIUS}
|
||||
className="fill-foreground/20"
|
||||
/>
|
||||
<circle
|
||||
cx={c1Clamped.x}
|
||||
cy={c1Clamped.y}
|
||||
r={HANDLE_RADIUS}
|
||||
className={cn(
|
||||
"fill-primary cursor-grab",
|
||||
activeHandle === "c1" && "cursor-grabbing",
|
||||
)}
|
||||
onPointerDown={onHandlePointerDown({ handle: "c1" })}
|
||||
/>
|
||||
<circle
|
||||
cx={c2Clamped.x}
|
||||
cy={c2Clamped.y}
|
||||
r={HANDLE_RADIUS}
|
||||
className={cn(
|
||||
"fill-primary cursor-grab",
|
||||
activeHandle === "c2" && "cursor-grabbing",
|
||||
)}
|
||||
onPointerDown={onHandlePointerDown({ handle: "c2" })}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Popover, PopoverContent } from "@/components/ui/popover";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
Delete02Icon,
|
||||
PlusSignIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { getBezierPoint } from "@/lib/animation/bezier";
|
||||
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||
import type { GraphEditorComponentOption } from "./session";
|
||||
import {
|
||||
BUILTIN_PRESETS,
|
||||
PRESET_MATCH_TOLERANCE,
|
||||
removePreset,
|
||||
savePreset,
|
||||
type EasingPreset,
|
||||
useCustomPresets,
|
||||
} from "./presets";
|
||||
import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph";
|
||||
|
||||
const COLLAPSED_MAX = 6;
|
||||
const THUMB_SEGMENTS = 24;
|
||||
const THUMB_WIDTH = 40;
|
||||
const THUMB_HEIGHT = 22;
|
||||
const THUMB_PADDING_X = 4;
|
||||
const THUMB_PADDING_Y = 3;
|
||||
const COLLAPSED_GRID_MAX_HEIGHT = 120;
|
||||
const EXPANDED_GRID_MAX_HEIGHT = 240;
|
||||
|
||||
export function GraphEditorPopover({
|
||||
children,
|
||||
side,
|
||||
open,
|
||||
onOpenChange,
|
||||
value,
|
||||
message,
|
||||
componentOptions,
|
||||
activeComponentKey,
|
||||
onActiveComponentKeyChange,
|
||||
onPreviewValue,
|
||||
onCommitValue,
|
||||
onCancelPreview,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
value: NormalizedCubicBezier | null;
|
||||
message: string;
|
||||
componentOptions: GraphEditorComponentOption[];
|
||||
activeComponentKey: string | null;
|
||||
onActiveComponentKeyChange?: (componentKey: string) => void;
|
||||
onPreviewValue?: (value: NormalizedCubicBezier) => void;
|
||||
onCommitValue?: (value: NormalizedCubicBezier) => void;
|
||||
onCancelPreview?: () => void;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const custom = useCustomPresets();
|
||||
const allPresets = [...BUILTIN_PRESETS, ...custom];
|
||||
const canEdit = value !== null;
|
||||
const activePresetId =
|
||||
value == null
|
||||
? null
|
||||
: (allPresets.find((preset) =>
|
||||
preset.value.every(
|
||||
(presetValue, index) =>
|
||||
Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE,
|
||||
),
|
||||
)?.id ?? null);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
onCancelPreview?.();
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<PopoverContent
|
||||
side={side}
|
||||
sideOffset={8}
|
||||
className="w-60 overflow-hidden px-0"
|
||||
>
|
||||
{componentOptions.length > 1 && (
|
||||
<div className="border-b px-3 py-2">
|
||||
<div className="bg-muted/40 inline-flex rounded-md p-0.5">
|
||||
{componentOptions.map((component) => (
|
||||
<button
|
||||
key={component.key}
|
||||
type="button"
|
||||
onClick={() => onActiveComponentKeyChange?.(component.key)}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-sm px-2 py-1 text-xs font-medium",
|
||||
activeComponentKey === component.key
|
||||
? "bg-background text-foreground shadow-xs"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{component.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-3">
|
||||
{value ? (
|
||||
<BezierGraph
|
||||
value={value}
|
||||
onChange={onPreviewValue}
|
||||
onChangeEnd={onCommitValue}
|
||||
onCancel={onCancelPreview}
|
||||
/>
|
||||
) : (
|
||||
<GraphEditorEmptyState message={message} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs variant="underline" defaultValue="presets" className="flex flex-col gap-2">
|
||||
<TabsList className="px-3">
|
||||
<TabsTrigger value="presets" className="text-xs">
|
||||
Presets
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="saved" className="text-xs">
|
||||
Saved
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="presets" className="px-3 pb-0">
|
||||
<ExpandableGrid
|
||||
isExpanded={isExpanded}
|
||||
shouldExpand={BUILTIN_PRESETS.length > COLLAPSED_MAX}
|
||||
onExpand={() => setIsExpanded(true)}
|
||||
>
|
||||
{BUILTIN_PRESETS.map((preset) => (
|
||||
<PresetItem
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isActive={activePresetId === preset.id}
|
||||
disabled={!canEdit}
|
||||
onSelect={() => onCommitValue?.(preset.value)}
|
||||
/>
|
||||
))}
|
||||
</ExpandableGrid>
|
||||
</TabsContent>
|
||||
<TabsContent value="saved" className="px-3">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{custom.map((preset) => (
|
||||
<PresetItem
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isActive={activePresetId === preset.id}
|
||||
disabled={!canEdit}
|
||||
onSelect={() => onCommitValue?.(preset.value)}
|
||||
onDelete={() => removePreset(preset.id)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => value && savePreset(value)}
|
||||
disabled={!canEdit}
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1",
|
||||
canEdit
|
||||
? "hover:bg-foreground/5 cursor-pointer"
|
||||
: "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<div className="border-foreground/10 flex aspect-video w-full items-center justify-center rounded-sm border border-dashed">
|
||||
<HugeiconsIcon
|
||||
icon={PlusSignIcon}
|
||||
className="size-3.5 opacity-40"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function GraphEditorEmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{ minHeight: BEZIER_GRAPH_MIN_HEIGHT }}
|
||||
className="bg-muted/20 text-muted-foreground flex items-center justify-center rounded-sm border border-dashed px-3 text-center text-xs leading-relaxed"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandableGrid({
|
||||
children,
|
||||
isExpanded,
|
||||
shouldExpand,
|
||||
onExpand,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isExpanded: boolean;
|
||||
shouldExpand: boolean;
|
||||
onExpand: () => void;
|
||||
}) {
|
||||
const gridStyle = shouldExpand
|
||||
? isExpanded
|
||||
? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const }
|
||||
: { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="grid grid-cols-3 gap-1" style={gridStyle}>
|
||||
{children}
|
||||
</div>
|
||||
{!isExpanded && shouldExpand && (
|
||||
<div className="from-popover/0 to-popover absolute inset-x-0 bottom-0 flex h-8 items-center justify-center bg-linear-to-b">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5"
|
||||
onClick={onExpand}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDown01Icon}
|
||||
className="text-muted-foreground size-3"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetItem({
|
||||
preset,
|
||||
isActive,
|
||||
onSelect,
|
||||
onDelete,
|
||||
disabled,
|
||||
}: {
|
||||
preset: EasingPreset;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete?: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-center gap-1 rounded-sm px-1 py-1",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "hover:bg-foreground/5 cursor-pointer",
|
||||
isActive && "bg-primary/5! text-primary",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex aspect-video w-full items-center justify-center rounded-sm bg-foreground/5",
|
||||
isActive && "bg-primary/5!",
|
||||
)}
|
||||
>
|
||||
<CurveThumb value={preset.value} />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] leading-tight",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</span>
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute -right-0.5 -top-0.5 hidden size-4.5 rounded-full [&_svg]:size-3 group-hover:flex"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} />
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CurveThumb({ value }: { value: NormalizedCubicBezier }) {
|
||||
const points: string[] = [];
|
||||
for (let i = 0; i <= THUMB_SEGMENTS; i++) {
|
||||
const progress = i / THUMB_SEGMENTS;
|
||||
points.push(
|
||||
`${THUMB_PADDING_X + getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) * (THUMB_WIDTH - THUMB_PADDING_X * 2)},${THUMB_PADDING_Y + (1 - getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 })) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2)}`,
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
width={THUMB_WIDTH}
|
||||
height={THUMB_HEIGHT}
|
||||
viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`}
|
||||
>
|
||||
<title>Curve preset preview</title>
|
||||
<path
|
||||
d={`M${points.join("L")}`}
|
||||
fill="none"
|
||||
className="stroke-current"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||
|
||||
const STORAGE_KEY = "opencut:graph-editor-presets";
|
||||
export const PRESET_MATCH_TOLERANCE = 0.02;
|
||||
|
||||
export interface EasingPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
value: NormalizedCubicBezier;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
export const BUILTIN_PRESETS: EasingPreset[] = [
|
||||
{ id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] },
|
||||
{ id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] },
|
||||
{ id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] },
|
||||
{ id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] },
|
||||
{ id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] },
|
||||
{ id: "linear", label: "Linear", value: [0, 0, 1, 1] },
|
||||
];
|
||||
|
||||
let cachedPresets: EasingPreset[] | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function readFromStorage(): EasingPreset[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
// JSON.parse can throw if the stored value is corrupted
|
||||
return raw ? (JSON.parse(raw) as EasingPreset[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeToStorage(presets: EasingPreset[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
|
||||
}
|
||||
|
||||
function getSnapshot(): EasingPreset[] {
|
||||
cachedPresets ??= readFromStorage();
|
||||
return cachedPresets;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): EasingPreset[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
cachedPresets = null;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === STORAGE_KEY) notify();
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomPresets(): EasingPreset[] {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
export function savePreset(value: NormalizedCubicBezier): void {
|
||||
const current = getSnapshot();
|
||||
writeToStorage([
|
||||
...current,
|
||||
{
|
||||
id: `custom-${Date.now()}`,
|
||||
label: `Custom ${current.length + 1}`,
|
||||
value,
|
||||
isCustom: true,
|
||||
},
|
||||
]);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function removePreset(id: string): void {
|
||||
writeToStorage(getSnapshot().filter((preset) => preset.id !== id));
|
||||
notify();
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getCurveHandlesForNormalizedCubicBezier,
|
||||
getEditableScalarChannels,
|
||||
getNormalizedCubicBezierForScalarSegment,
|
||||
getScalarKeyframeContext,
|
||||
updateScalarKeyframeCurve,
|
||||
} from "@/lib/animation";
|
||||
import type {
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
NormalizedCubicBezier,
|
||||
ScalarCurveKeyframePatch,
|
||||
ScalarGraphKeyframeContext,
|
||||
SelectedKeyframeRef,
|
||||
} from "@/lib/animation/types";
|
||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||
|
||||
const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
|
||||
const FLAT_VALUE_EPSILON = 1e-6;
|
||||
const LINEAR_CURVE_EPSILON = 1e-6;
|
||||
|
||||
export type GraphEditorUnavailableReason =
|
||||
| "no-keyframe-selected"
|
||||
| "multiple-keyframes-selected"
|
||||
| "selected-element-missing"
|
||||
| "selected-element-has-no-animations"
|
||||
| "selected-keyframe-has-no-scalar-channel"
|
||||
| "selected-keyframe-missing-on-channel"
|
||||
| "selected-keyframe-has-no-next-segment"
|
||||
| "selected-segment-is-hold"
|
||||
| "selected-segment-is-flat";
|
||||
|
||||
export interface GraphEditorComponentOption {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface GraphEditorBaseSelectionState {
|
||||
componentOptions: GraphEditorComponentOption[];
|
||||
activeComponentKey: string | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GraphEditorUnavailableState
|
||||
extends GraphEditorBaseSelectionState {
|
||||
status: "unavailable";
|
||||
reason: GraphEditorUnavailableReason;
|
||||
}
|
||||
|
||||
export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
|
||||
status: "ready";
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: SelectedKeyframeRef["propertyPath"];
|
||||
keyframeId: string;
|
||||
element: TimelineElement;
|
||||
context: ScalarGraphKeyframeContext;
|
||||
cubicBezier: NormalizedCubicBezier;
|
||||
}
|
||||
|
||||
export type GraphEditorSelectionState =
|
||||
| GraphEditorUnavailableState
|
||||
| GraphEditorReadyState;
|
||||
|
||||
export interface GraphEditorCurvePatch {
|
||||
keyframeId: string;
|
||||
patch: ScalarCurveKeyframePatch;
|
||||
}
|
||||
|
||||
function createUnavailableState({
|
||||
reason,
|
||||
message,
|
||||
componentOptions = [],
|
||||
activeComponentKey = null,
|
||||
}: {
|
||||
reason: GraphEditorUnavailableReason;
|
||||
message: string;
|
||||
componentOptions?: GraphEditorComponentOption[];
|
||||
activeComponentKey?: string | null;
|
||||
}): GraphEditorUnavailableState {
|
||||
return {
|
||||
status: "unavailable",
|
||||
reason,
|
||||
message,
|
||||
componentOptions,
|
||||
activeComponentKey,
|
||||
};
|
||||
}
|
||||
|
||||
function findElementByKeyframe({
|
||||
tracks,
|
||||
keyframe,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
keyframe: SelectedKeyframeRef;
|
||||
}): { element: TimelineElement; trackId: string; elementId: string } | null {
|
||||
for (const track of tracks) {
|
||||
if (track.id !== keyframe.trackId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const element = track.elements.find(
|
||||
(trackElement) => trackElement.id === keyframe.elementId,
|
||||
);
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
element,
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findKeyframeTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
}): number | null {
|
||||
const binding = animations.bindings[propertyPath];
|
||||
if (!binding) return null;
|
||||
|
||||
for (const component of binding.components) {
|
||||
const channel = animations.channels[component.channelId];
|
||||
if (channel?.kind !== "scalar") continue;
|
||||
const key = channel.keys.find((k) => k.id === keyframeId);
|
||||
if (key !== undefined) return key.time;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getComponentLabel({ componentKey }: { componentKey: string }): string {
|
||||
switch (componentKey) {
|
||||
case "value":
|
||||
return "Value";
|
||||
default:
|
||||
return componentKey.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
function isFlatSegment({
|
||||
context,
|
||||
}: {
|
||||
context: ScalarGraphKeyframeContext;
|
||||
}): boolean {
|
||||
if (!context.nextKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
Math.abs(context.nextKey.value - context.keyframe.value) <=
|
||||
FLAT_VALUE_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
function isLinearCurve({
|
||||
cubicBezier,
|
||||
}: {
|
||||
cubicBezier: NormalizedCubicBezier;
|
||||
}): boolean {
|
||||
return (
|
||||
Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON &&
|
||||
Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON &&
|
||||
Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON &&
|
||||
Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveGraphEditorSelectionState({
|
||||
tracks,
|
||||
selectedKeyframes,
|
||||
preferredComponentKey,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
preferredComponentKey?: string | null;
|
||||
}): GraphEditorSelectionState {
|
||||
if (selectedKeyframes.length === 0) {
|
||||
return createUnavailableState({
|
||||
reason: "no-keyframe-selected",
|
||||
message: "Select a keyframe to edit its curve.",
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedKeyframes.length > 2) {
|
||||
return createUnavailableState({
|
||||
reason: "multiple-keyframes-selected",
|
||||
message: "Select one or two adjacent keyframes to edit a curve.",
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedKeyframes.length === 2) {
|
||||
const [kf1, kf2] = selectedKeyframes;
|
||||
if (
|
||||
kf1.trackId !== kf2.trackId ||
|
||||
kf1.elementId !== kf2.elementId ||
|
||||
kf1.propertyPath !== kf2.propertyPath
|
||||
) {
|
||||
return createUnavailableState({
|
||||
reason: "multiple-keyframes-selected",
|
||||
message: "Selected keyframes must be on the same element and property.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const primaryKeyframe = selectedKeyframes[0];
|
||||
const secondaryKeyframeId =
|
||||
selectedKeyframes.length === 2 ? selectedKeyframes[1].keyframeId : null;
|
||||
|
||||
const selectedElement = findElementByKeyframe({
|
||||
tracks,
|
||||
keyframe: primaryKeyframe,
|
||||
});
|
||||
if (!selectedElement) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-element-missing",
|
||||
message: "The selected keyframe could not be resolved.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!selectedElement.element.animations) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-element-has-no-animations",
|
||||
message: "The selected keyframe has no editable graph.",
|
||||
});
|
||||
}
|
||||
|
||||
const scalarChannels = getEditableScalarChannels({
|
||||
animations: selectedElement.element.animations,
|
||||
propertyPath: primaryKeyframe.propertyPath,
|
||||
});
|
||||
if (scalarChannels.length === 0) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-keyframe-has-no-scalar-channel",
|
||||
message: "The selected keyframe has no editable graph channel.",
|
||||
});
|
||||
}
|
||||
|
||||
// When 2 keyframes are selected, resolve the earlier one as the outgoing-segment
|
||||
// anchor so the graph editor edits the curve between the two selected keyframes.
|
||||
let resolvedKeyframeId = primaryKeyframe.keyframeId;
|
||||
if (secondaryKeyframeId !== null) {
|
||||
const time1 = findKeyframeTime({
|
||||
animations: selectedElement.element.animations,
|
||||
propertyPath: primaryKeyframe.propertyPath,
|
||||
keyframeId: primaryKeyframe.keyframeId,
|
||||
});
|
||||
const time2 = findKeyframeTime({
|
||||
animations: selectedElement.element.animations,
|
||||
propertyPath: primaryKeyframe.propertyPath,
|
||||
keyframeId: secondaryKeyframeId,
|
||||
});
|
||||
if (time2 !== null && (time1 === null || time2 < time1)) {
|
||||
resolvedKeyframeId = secondaryKeyframeId;
|
||||
}
|
||||
}
|
||||
|
||||
const contexts = scalarChannels.flatMap((channel) => {
|
||||
const context = getScalarKeyframeContext({
|
||||
animations: selectedElement.element.animations,
|
||||
propertyPath: primaryKeyframe.propertyPath,
|
||||
componentKey: channel.componentKey,
|
||||
keyframeId: resolvedKeyframeId,
|
||||
});
|
||||
if (!context) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
context,
|
||||
option: {
|
||||
key: channel.componentKey,
|
||||
label: getComponentLabel({ componentKey: channel.componentKey }),
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (contexts.length === 0) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-keyframe-missing-on-channel",
|
||||
message: "The selected keyframe is not editable as a graph segment.",
|
||||
});
|
||||
}
|
||||
|
||||
const nextSegmentContexts = contexts.filter(
|
||||
({ context }) => context.nextKey !== null,
|
||||
);
|
||||
const preferredContext =
|
||||
contexts.find(({ option }) => option.key === preferredComponentKey) ?? null;
|
||||
const activeContext =
|
||||
preferredContext ?? nextSegmentContexts[0] ?? contexts[0];
|
||||
const componentOptions = contexts.map(({ option }) => option);
|
||||
|
||||
if (!activeContext.context.nextKey) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-keyframe-has-no-next-segment",
|
||||
message: "Select a keyframe that has an outgoing segment.",
|
||||
componentOptions,
|
||||
activeComponentKey: activeContext.option.key,
|
||||
});
|
||||
}
|
||||
|
||||
if (isFlatSegment({ context: activeContext.context })) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-segment-is-flat",
|
||||
message: "Flat segments are not graph-editable in this popover yet.",
|
||||
componentOptions,
|
||||
activeComponentKey: activeContext.option.key,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeContext.context.keyframe.segmentToNext === "step") {
|
||||
return createUnavailableState({
|
||||
reason: "selected-segment-is-hold",
|
||||
message: "Hold segments are not graph-editable in this popover yet.",
|
||||
componentOptions,
|
||||
activeComponentKey: activeContext.option.key,
|
||||
});
|
||||
}
|
||||
|
||||
const cubicBezier =
|
||||
activeContext.context.keyframe.segmentToNext === "linear"
|
||||
? GRAPH_LINEAR_CURVE
|
||||
: getNormalizedCubicBezierForScalarSegment({
|
||||
leftKey: activeContext.context.keyframe,
|
||||
rightKey: activeContext.context.nextKey,
|
||||
});
|
||||
if (!cubicBezier) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-segment-is-flat",
|
||||
message: "The selected segment cannot be represented in this graph view.",
|
||||
componentOptions,
|
||||
activeComponentKey: activeContext.option.key,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ready",
|
||||
message: "Edit graph",
|
||||
componentOptions,
|
||||
activeComponentKey: activeContext.option.key,
|
||||
trackId: selectedElement.trackId,
|
||||
elementId: selectedElement.elementId,
|
||||
propertyPath: primaryKeyframe.propertyPath,
|
||||
keyframeId: resolvedKeyframeId,
|
||||
element: selectedElement.element,
|
||||
context: activeContext.context,
|
||||
cubicBezier,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGraphEditorCurvePatches({
|
||||
context,
|
||||
cubicBezier,
|
||||
}: {
|
||||
context: ScalarGraphKeyframeContext;
|
||||
cubicBezier: NormalizedCubicBezier;
|
||||
}): GraphEditorCurvePatch[] | null {
|
||||
if (!context.nextKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLinearCurve({ cubicBezier })) {
|
||||
return [
|
||||
{
|
||||
keyframeId: context.keyframe.id,
|
||||
patch: {
|
||||
segmentToNext: "linear",
|
||||
rightHandle: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
keyframeId: context.nextKey.id,
|
||||
patch: {
|
||||
leftHandle: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const handles = getCurveHandlesForNormalizedCubicBezier({
|
||||
leftKey: context.keyframe,
|
||||
rightKey: context.nextKey,
|
||||
cubicBezier,
|
||||
});
|
||||
if (!handles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
keyframeId: context.keyframe.id,
|
||||
patch: {
|
||||
segmentToNext: "bezier",
|
||||
rightHandle: handles.rightHandle,
|
||||
},
|
||||
},
|
||||
{
|
||||
keyframeId: context.nextKey.id,
|
||||
patch: {
|
||||
leftHandle: handles.leftHandle,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function applyGraphEditorCurvePreview({
|
||||
animations,
|
||||
context,
|
||||
cubicBezier,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
context: ScalarGraphKeyframeContext;
|
||||
cubicBezier: NormalizedCubicBezier;
|
||||
}): ElementAnimations | undefined {
|
||||
const patches = buildGraphEditorCurvePatches({
|
||||
context,
|
||||
cubicBezier,
|
||||
});
|
||||
if (!patches) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
return patches.reduce<ElementAnimations | undefined>(
|
||||
(nextAnimations, { keyframeId, patch }) =>
|
||||
updateScalarKeyframeCurve({
|
||||
animations: nextAnimations,
|
||||
propertyPath: context.propertyPath,
|
||||
componentKey: context.componentKey,
|
||||
keyframeId,
|
||||
patch,
|
||||
}),
|
||||
animations,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { registerCanceller } from "@/lib/cancel-interaction";
|
||||
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
|
||||
import {
|
||||
applyGraphEditorCurvePreview,
|
||||
buildGraphEditorCurvePatches,
|
||||
resolveGraphEditorSelectionState,
|
||||
type GraphEditorSelectionState,
|
||||
} from "./session";
|
||||
|
||||
export function useGraphEditorController() {
|
||||
const editor = useEditor();
|
||||
const renderTracks = useEditor((currentEditor) =>
|
||||
currentEditor.timeline.getRenderTracks(),
|
||||
);
|
||||
const { selectedKeyframes } = useKeyframeSelection();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeComponentKey, setActiveComponentKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const hasPreviewRef = useRef(false);
|
||||
|
||||
const state = useMemo<GraphEditorSelectionState>(
|
||||
() =>
|
||||
resolveGraphEditorSelectionState({
|
||||
tracks: renderTracks,
|
||||
selectedKeyframes,
|
||||
preferredComponentKey: activeComponentKey,
|
||||
}),
|
||||
[activeComponentKey, renderTracks, selectedKeyframes],
|
||||
);
|
||||
|
||||
const stateKey =
|
||||
state.status === "ready"
|
||||
? `${state.trackId}:${state.elementId}:${state.propertyPath}:${state.keyframeId}:${state.activeComponentKey}`
|
||||
: `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`;
|
||||
const previousStateKeyRef = useRef(stateKey);
|
||||
|
||||
const discardPreview = useCallback(() => {
|
||||
if (!hasPreviewRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.discardPreview();
|
||||
hasPreviewRef.current = false;
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) {
|
||||
discardPreview();
|
||||
}
|
||||
|
||||
previousStateKeyRef.current = stateKey;
|
||||
}, [discardPreview, stateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
return registerCanceller({
|
||||
fn: () => {
|
||||
discardPreview();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
}, [discardPreview, open]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
discardPreview();
|
||||
}
|
||||
|
||||
setOpen(nextOpen);
|
||||
},
|
||||
[discardPreview],
|
||||
);
|
||||
|
||||
const handleActiveComponentKeyChange = useCallback(
|
||||
(nextComponentKey: string) => {
|
||||
discardPreview();
|
||||
setActiveComponentKey(nextComponentKey);
|
||||
},
|
||||
[discardPreview],
|
||||
);
|
||||
|
||||
function handlePreviewValue(nextValue: NormalizedCubicBezier) {
|
||||
if (state.status !== "ready") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextAnimations = applyGraphEditorCurvePreview({
|
||||
animations: state.element.animations,
|
||||
context: state.context,
|
||||
cubicBezier: nextValue,
|
||||
});
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId: state.trackId,
|
||||
elementId: state.elementId,
|
||||
updates: {
|
||||
animations: nextAnimations,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
hasPreviewRef.current = true;
|
||||
}
|
||||
|
||||
function handleCommitValue(nextValue: NormalizedCubicBezier) {
|
||||
if (state.status !== "ready") {
|
||||
return;
|
||||
}
|
||||
|
||||
const patches = buildGraphEditorCurvePatches({
|
||||
context: state.context,
|
||||
cubicBezier: nextValue,
|
||||
});
|
||||
if (!patches) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.updateKeyframeCurves({
|
||||
keyframes: patches.map(({ keyframeId, patch }) => ({
|
||||
trackId: state.trackId,
|
||||
elementId: state.elementId,
|
||||
propertyPath: state.propertyPath,
|
||||
componentKey: state.context.componentKey,
|
||||
keyframeId,
|
||||
patch,
|
||||
})),
|
||||
});
|
||||
hasPreviewRef.current = false;
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
onOpenChange: handleOpenChange,
|
||||
canOpen: state.status === "ready",
|
||||
tooltip: state.status === "ready" ? "Open graph editor" : state.message,
|
||||
state,
|
||||
onActiveComponentKeyChange: handleActiveComponentKeyChange,
|
||||
onPreviewValue: handlePreviewValue,
|
||||
onCommitValue: handleCommitValue,
|
||||
onCancelPreview: discardPreview,
|
||||
};
|
||||
}
|
||||
@@ -38,20 +38,26 @@ import { SnapIndicator } from "./snap-indicator";
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import {
|
||||
TIMELINE_CONSTANTS,
|
||||
TRACK_GAP,
|
||||
TIMELINE_SCROLLBAR_SIZE_PX,
|
||||
} from "@/constants/timeline-constants";
|
||||
TIMELINE_CONTENT_TOP_PADDING_PX,
|
||||
TIMELINE_TRACK_GAP_PX,
|
||||
TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX,
|
||||
} from "./layout";
|
||||
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
|
||||
import {
|
||||
getTrackHeight,
|
||||
getCumulativeHeightBefore,
|
||||
getTotalTracksHeight,
|
||||
canTracktHaveAudio,
|
||||
canTrackHaveAudio,
|
||||
canTrackBeHidden,
|
||||
getTimelineZoomMin,
|
||||
getTimelinePaddingPx,
|
||||
} from "@/lib/timeline";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import {
|
||||
getTrackHeight,
|
||||
getCumulativeHeightBefore,
|
||||
getTotalTracksHeight,
|
||||
} from "./track-layout";
|
||||
import { SELECTED_TRACK_ROW_CLASS } from "./theme";
|
||||
import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
|
||||
import { isMainTrack } from "@/lib/timeline/placement";
|
||||
import { TimelineToolbar } from "./timeline-toolbar";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
@@ -67,7 +73,7 @@ import { useEditor } from "@/hooks/use-editor";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
import { DragLine } from "./drag-line";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { resolveTimelineElementIntersections } from "@/lib/timeline/selection-hit-testing";
|
||||
import { resolveTimelineElementIntersections } from "./selection-hit-testing";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const TRACKS_CONTAINER_MAX_HEIGHT = 800;
|
||||
@@ -230,7 +236,7 @@ export function Timeline() {
|
||||
Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
||||
const clamped =
|
||||
Math.sign(raw) *
|
||||
Math.min(Math.abs(raw), TIMELINE_CONSTANTS.HORIZONTAL_WHEEL_STEP_PX);
|
||||
Math.min(Math.abs(raw), TIMELINE_HORIZONTAL_WHEEL_STEP_PX);
|
||||
tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped);
|
||||
} else {
|
||||
tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY);
|
||||
@@ -335,7 +341,7 @@ export function Timeline() {
|
||||
const containerWidth =
|
||||
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
|
||||
const contentWidth =
|
||||
timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
timelineDuration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
const paddingPx = getTimelinePaddingPx({
|
||||
containerWidth,
|
||||
zoomLevel,
|
||||
@@ -383,7 +389,7 @@ export function Timeline() {
|
||||
|
||||
const timelineHeaderHeight =
|
||||
(timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) +
|
||||
TIMELINE_CONSTANTS.PADDING_TOP_PX || 0;
|
||||
TIMELINE_CONTENT_TOP_PADDING_PX || 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -483,7 +489,7 @@ export function Timeline() {
|
||||
TRACKS_CONTAINER_HEIGHT.max,
|
||||
getTotalTracksHeight({ tracks }),
|
||||
),
|
||||
) + TIMELINE_CONSTANTS.PADDING_TOP_PX
|
||||
) + TIMELINE_CONTENT_TOP_PADDING_PX
|
||||
}px`,
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
@@ -577,7 +583,10 @@ function TrackLabelsPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-28 shrink-0 flex-col border-r">
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-r"
|
||||
style={{ width: `${TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX}px` }}
|
||||
>
|
||||
<div
|
||||
className="shrink-0"
|
||||
style={{ height: timelineHeaderHeight || 48 }}
|
||||
@@ -585,21 +594,24 @@ function TrackLabelsPanel({
|
||||
<div ref={trackLabelsRef} className="flex-1 overflow-hidden">
|
||||
<div ref={trackLabelsScrollRef} className="size-full overflow-hidden">
|
||||
{tracks.length > 0 && (
|
||||
<div className="flex flex-col" style={{ gap: `${TRACK_GAP}px` }}>
|
||||
<div
|
||||
className="flex flex-col"
|
||||
style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }}
|
||||
>
|
||||
{tracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className={cn(
|
||||
"group flex items-center px-3",
|
||||
tracksWithSelection.has(track.id) &&
|
||||
TIMELINE_CONSTANTS.TRACK_SELECTED_BG,
|
||||
SELECTED_TRACK_ROW_CLASS,
|
||||
)}
|
||||
style={{
|
||||
height: `${getTrackHeight({ type: track.type })}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
|
||||
{canTracktHaveAudio(track) && (
|
||||
{canTrackHaveAudio(track) && (
|
||||
<TrackToggleIcon
|
||||
isOff={track.muted}
|
||||
icons={{ on: VolumeHighIcon, off: VolumeOffIcon }}
|
||||
@@ -707,10 +719,10 @@ function TimelineTrackRows({
|
||||
className={cn(
|
||||
"absolute right-0 left-0 transition-colors",
|
||||
tracksWithSelection.has(track.id) &&
|
||||
TIMELINE_CONSTANTS.TRACK_SELECTED_BG,
|
||||
SELECTED_TRACK_ROW_CLASS,
|
||||
)}
|
||||
style={{
|
||||
top: `${TIMELINE_CONSTANTS.PADDING_TOP_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`,
|
||||
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`,
|
||||
height: `${getTrackHeight({ type: track.type })}px`,
|
||||
}}
|
||||
>
|
||||
@@ -753,7 +765,7 @@ function TimelineTrackRows({
|
||||
timeline.toggleTrackMute({ trackId: track.id });
|
||||
}}
|
||||
>
|
||||
{canTracktHaveAudio(track) && track.muted
|
||||
{canTrackHaveAudio(track) && track.muted
|
||||
? "Unmute track"
|
||||
: "Mute track"}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const TIMELINE_DRAG_THRESHOLD_PX = 5;
|
||||
export const TIMELINE_HORIZONTAL_WHEEL_STEP_PX = 40;
|
||||
export const TIMELINE_ZOOM_BUTTON_FACTOR = 1.7;
|
||||
export const TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD = 0.15;
|
||||
@@ -0,0 +1,6 @@
|
||||
export const TIMELINE_LAYERS = {
|
||||
trackContent: 10,
|
||||
dragLine: 20,
|
||||
playhead: 30,
|
||||
snapIndicator: 40,
|
||||
} as const;
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TrackType } from "@/lib/timeline";
|
||||
|
||||
export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = {
|
||||
video: 65,
|
||||
text: 25,
|
||||
audio: 50,
|
||||
graphic: 25,
|
||||
effect: 25,
|
||||
} as const;
|
||||
|
||||
export const TIMELINE_TRACK_GAP_PX = 6;
|
||||
export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112;
|
||||
export const TIMELINE_RULER_HEIGHT_PX = 22;
|
||||
export const TIMELINE_BOOKMARK_ROW_HEIGHT_PX = 16;
|
||||
export const TIMELINE_SCROLLBAR_SIZE_PX = 12;
|
||||
export const TIMELINE_CONTENT_TOP_PADDING_PX = 2;
|
||||
+136
-133
@@ -1,133 +1,136 @@
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { getCumulativeHeightBefore, getTrackHeight } from "@/lib/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
|
||||
type TimelineElementRef = { trackId: string; elementId: string };
|
||||
|
||||
interface SelectionRectangle {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
function getNormalizedRectangle({
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
}): SelectionRectangle {
|
||||
return {
|
||||
left: Math.min(startPos.x, endPos.x),
|
||||
top: Math.min(startPos.y, endPos.y),
|
||||
right: Math.max(startPos.x, endPos.x),
|
||||
bottom: Math.max(startPos.y, endPos.y),
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectionRectangleInContent({
|
||||
container,
|
||||
scrollContainer,
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
container: HTMLElement;
|
||||
scrollContainer: HTMLDivElement | null;
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
}): SelectionRectangle {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect;
|
||||
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
|
||||
const adjustedStart = {
|
||||
x: startPos.x - containerRect.left + scrollLeft,
|
||||
y: startPos.y - scrollRect.top + scrollTop,
|
||||
};
|
||||
const adjustedEnd = {
|
||||
x: endPos.x - containerRect.left + scrollLeft,
|
||||
y: endPos.y - scrollRect.top + scrollTop,
|
||||
};
|
||||
|
||||
return getNormalizedRectangle({
|
||||
startPos: adjustedStart,
|
||||
endPos: adjustedEnd,
|
||||
});
|
||||
}
|
||||
|
||||
function isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
}: {
|
||||
elementRectangle: SelectionRectangle;
|
||||
selectionRectangle: SelectionRectangle;
|
||||
}): boolean {
|
||||
return !(
|
||||
elementRectangle.right < selectionRectangle.left ||
|
||||
elementRectangle.left > selectionRectangle.right ||
|
||||
elementRectangle.bottom < selectionRectangle.top ||
|
||||
elementRectangle.top > selectionRectangle.bottom
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveTimelineElementIntersections({
|
||||
container,
|
||||
scrollContainer,
|
||||
tracks,
|
||||
zoomLevel,
|
||||
startPos,
|
||||
currentPos,
|
||||
}: {
|
||||
container: HTMLElement;
|
||||
scrollContainer: HTMLDivElement | null;
|
||||
tracks: TimelineTrack[];
|
||||
zoomLevel: number;
|
||||
startPos: { x: number; y: number };
|
||||
currentPos: { x: number; y: number };
|
||||
}): TimelineElementRef[] {
|
||||
const selectionRectangle = getSelectionRectangleInContent({
|
||||
container,
|
||||
scrollContainer,
|
||||
startPos,
|
||||
endPos: currentPos,
|
||||
});
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const selectedElements: TimelineElementRef[] = [];
|
||||
|
||||
for (const [trackIndex, track] of tracks.entries()) {
|
||||
const trackTop = getCumulativeHeightBefore({
|
||||
tracks,
|
||||
trackIndex,
|
||||
});
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const elementTop = TIMELINE_CONSTANTS.PADDING_TOP_PX + trackTop;
|
||||
const elementBottom = elementTop + trackHeight;
|
||||
|
||||
for (const element of track.elements) {
|
||||
const elementLeft = element.startTime * pixelsPerSecond;
|
||||
const elementRight = elementLeft + element.duration * pixelsPerSecond;
|
||||
const elementRectangle = {
|
||||
left: elementLeft,
|
||||
top: elementTop,
|
||||
right: elementRight,
|
||||
bottom: elementBottom,
|
||||
};
|
||||
|
||||
if (
|
||||
isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
})
|
||||
) {
|
||||
selectedElements.push({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selectedElements;
|
||||
}
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { getTimelinePixelsPerSecond } from "@/lib/timeline/pixel-utils";
|
||||
import {
|
||||
TIMELINE_CONTENT_TOP_PADDING_PX,
|
||||
} from "./layout";
|
||||
import { getCumulativeHeightBefore, getTrackHeight } from "./track-layout";
|
||||
|
||||
type TimelineElementRef = { trackId: string; elementId: string };
|
||||
|
||||
interface SelectionRectangle {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
function getNormalizedRectangle({
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
}): SelectionRectangle {
|
||||
return {
|
||||
left: Math.min(startPos.x, endPos.x),
|
||||
top: Math.min(startPos.y, endPos.y),
|
||||
right: Math.max(startPos.x, endPos.x),
|
||||
bottom: Math.max(startPos.y, endPos.y),
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectionRectangleInContent({
|
||||
container,
|
||||
scrollContainer,
|
||||
startPos,
|
||||
endPos,
|
||||
}: {
|
||||
container: HTMLElement;
|
||||
scrollContainer: HTMLDivElement | null;
|
||||
startPos: { x: number; y: number };
|
||||
endPos: { x: number; y: number };
|
||||
}): SelectionRectangle {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect;
|
||||
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
|
||||
const adjustedStart = {
|
||||
x: startPos.x - containerRect.left + scrollLeft,
|
||||
y: startPos.y - scrollRect.top + scrollTop,
|
||||
};
|
||||
const adjustedEnd = {
|
||||
x: endPos.x - containerRect.left + scrollLeft,
|
||||
y: endPos.y - scrollRect.top + scrollTop,
|
||||
};
|
||||
|
||||
return getNormalizedRectangle({
|
||||
startPos: adjustedStart,
|
||||
endPos: adjustedEnd,
|
||||
});
|
||||
}
|
||||
|
||||
function isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
}: {
|
||||
elementRectangle: SelectionRectangle;
|
||||
selectionRectangle: SelectionRectangle;
|
||||
}): boolean {
|
||||
return !(
|
||||
elementRectangle.right < selectionRectangle.left ||
|
||||
elementRectangle.left > selectionRectangle.right ||
|
||||
elementRectangle.bottom < selectionRectangle.top ||
|
||||
elementRectangle.top > selectionRectangle.bottom
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveTimelineElementIntersections({
|
||||
container,
|
||||
scrollContainer,
|
||||
tracks,
|
||||
zoomLevel,
|
||||
startPos,
|
||||
currentPos,
|
||||
}: {
|
||||
container: HTMLElement;
|
||||
scrollContainer: HTMLDivElement | null;
|
||||
tracks: TimelineTrack[];
|
||||
zoomLevel: number;
|
||||
startPos: { x: number; y: number };
|
||||
currentPos: { x: number; y: number };
|
||||
}): TimelineElementRef[] {
|
||||
const selectionRectangle = getSelectionRectangleInContent({
|
||||
container,
|
||||
scrollContainer,
|
||||
startPos,
|
||||
endPos: currentPos,
|
||||
});
|
||||
const pixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel });
|
||||
const selectedElements: TimelineElementRef[] = [];
|
||||
|
||||
for (const [trackIndex, track] of tracks.entries()) {
|
||||
const trackTop = getCumulativeHeightBefore({
|
||||
tracks,
|
||||
trackIndex,
|
||||
});
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const elementTop = TIMELINE_CONTENT_TOP_PADDING_PX + trackTop;
|
||||
const elementBottom = elementTop + trackHeight;
|
||||
|
||||
for (const element of track.elements) {
|
||||
const elementLeft = element.startTime * pixelsPerSecond;
|
||||
const elementRight = elementLeft + element.duration * pixelsPerSecond;
|
||||
const elementRectangle = {
|
||||
left: elementLeft,
|
||||
top: elementTop,
|
||||
right: elementRight,
|
||||
bottom: elementBottom,
|
||||
};
|
||||
|
||||
if (
|
||||
isRectangleIntersecting({
|
||||
elementRectangle,
|
||||
selectionRectangle,
|
||||
})
|
||||
) {
|
||||
selectedElements.push({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selectedElements;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getCenteredLineLeft,
|
||||
TIMELINE_INDICATOR_LINE_WIDTH_PX,
|
||||
} from "@/lib/timeline";
|
||||
import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
|
||||
import { TIMELINE_LAYERS } from "./layers";
|
||||
interface SnapIndicatorProps {
|
||||
snapPoint: SnapPoint | null;
|
||||
zoomLevel: number;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { TrackType } from "@/lib/timeline";
|
||||
|
||||
export const TIMELINE_TRACK_THEME: Record<
|
||||
TrackType,
|
||||
{
|
||||
elementClassName: string;
|
||||
waveformColor?: string;
|
||||
}
|
||||
> = {
|
||||
video: { elementClassName: "transparent" },
|
||||
text: { elementClassName: "bg-[#5DBAA0]" },
|
||||
audio: {
|
||||
elementClassName: "bg-[#8F5DBA]",
|
||||
waveformColor: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
graphic: { elementClassName: "bg-[#BA5D7A]" },
|
||||
effect: { elementClassName: "bg-[#5d93ba]" },
|
||||
} as const;
|
||||
|
||||
export const SELECTED_TRACK_ROW_CLASS = "bg-accent/50";
|
||||
export const DEFAULT_TIMELINE_BOOKMARK_COLOR = "#009dff";
|
||||
|
||||
export function getTimelineElementClassName({
|
||||
type,
|
||||
}: {
|
||||
type: TrackType;
|
||||
}): string {
|
||||
return TIMELINE_TRACK_THEME[type].elementClassName.trim();
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-sele
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import { getElementKeyframes } from "@/lib/animation";
|
||||
import {
|
||||
getElementClasses,
|
||||
getTrackHeight,
|
||||
canElementHaveAudio,
|
||||
canElementBeHidden,
|
||||
hasElementEffects,
|
||||
@@ -21,7 +19,11 @@ import {
|
||||
timelineTimeToPixels,
|
||||
timelineTimeToSnappedPixels,
|
||||
} from "@/lib/timeline";
|
||||
import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants";
|
||||
import { getTrackHeight } from "./track-layout";
|
||||
import {
|
||||
getTimelineElementClassName,
|
||||
TIMELINE_TRACK_THEME,
|
||||
} from "./theme";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -39,6 +41,11 @@ import type {
|
||||
} from "@/lib/timeline";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
||||
import {
|
||||
canToggleSourceAudio,
|
||||
getSourceAudioActionLabel,
|
||||
isSourceAudioSeparated,
|
||||
} from "@/lib/timeline/audio-separation";
|
||||
import {
|
||||
getActionDefinition,
|
||||
type TAction,
|
||||
@@ -61,7 +68,9 @@ import {
|
||||
Search01Icon,
|
||||
Exchange01Icon,
|
||||
KeyframeIcon,
|
||||
Link02Icon,
|
||||
MagicWand05Icon,
|
||||
Unlink02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { uppercase } from "@/utils/string";
|
||||
@@ -280,6 +289,16 @@ export function TimelineElement({
|
||||
};
|
||||
|
||||
const isMuted = canElementHaveAudio(element) && element.muted === true;
|
||||
const canToggleCurrentSourceAudio =
|
||||
selectedElements.length === 1 &&
|
||||
isCurrentElementSelected &&
|
||||
canToggleSourceAudio(element, mediaAsset);
|
||||
const sourceAudioLabel =
|
||||
element.type === "video"
|
||||
? getSourceAudioActionLabel({ element })
|
||||
: "Extract audio";
|
||||
const isElementSourceAudioSeparated =
|
||||
element.type === "video" && isSourceAudioSeparated({ element });
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
@@ -334,6 +353,21 @@ export function TimelineElement({
|
||||
isMuted={isMuted}
|
||||
/>
|
||||
)}
|
||||
{canToggleCurrentSourceAudio && (
|
||||
<ContextMenuItem
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={isElementSourceAudioSeparated ? Unlink02Icon : Link02Icon}
|
||||
/>
|
||||
}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
invokeAction("toggle-source-audio");
|
||||
}}
|
||||
>
|
||||
{sourceAudioLabel}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{canElementBeHidden(element) && (
|
||||
<VisibilityMenuItem
|
||||
element={element}
|
||||
@@ -429,7 +463,7 @@ function ElementInner({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-hidden rounded-sm",
|
||||
getElementClasses({ type: track.type }),
|
||||
getTimelineElementClassName({ type: track.type }),
|
||||
isReducedOpacity && "opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -675,7 +709,7 @@ function AudioElementContent({ element }: { element: AudioElement }) {
|
||||
<AudioWaveform
|
||||
audioBuffer={audioBuffer}
|
||||
audioUrl={audioUrl}
|
||||
color={ELEMENT_TYPE_CONFIG.audio.waveformColor}
|
||||
color={TIMELINE_TRACK_THEME.audio.waveformColor}
|
||||
/>
|
||||
<MediaElementHeader name={mediaLabel} hasFade={false} />
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
TIMELINE_LAYERS,
|
||||
TIMELINE_SCROLLBAR_SIZE_PX,
|
||||
} from "@/constants/timeline-constants";
|
||||
} from "./layout";
|
||||
import { TIMELINE_LAYERS } from "./layers";
|
||||
|
||||
interface TimelinePlayheadProps {
|
||||
zoomLevel: number;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type JSX, useLayoutEffect, useRef } from "react";
|
||||
import {
|
||||
TIMELINE_CONSTANTS,
|
||||
TIMELINE_RULER_HEIGHT,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||
BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
} from "@/lib/timeline/scale";
|
||||
import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
|
||||
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
|
||||
import { useScrollPosition } from "@/hooks/timeline/use-scroll-position";
|
||||
@@ -31,7 +31,7 @@ export function TimelineRuler({
|
||||
handleRulerMouseDown,
|
||||
}: TimelineRulerProps) {
|
||||
const duration = useEditor((e) => e.timeline.getTotalDuration());
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
|
||||
const effectiveDuration = Math.max(duration, visibleDuration);
|
||||
const fps =
|
||||
@@ -107,7 +107,7 @@ export function TimelineRuler({
|
||||
aria-valuemax={effectiveDuration}
|
||||
aria-valuenow={0}
|
||||
className="relative flex-1 overflow-x-visible"
|
||||
style={{ height: TIMELINE_RULER_HEIGHT }}
|
||||
style={{ height: TIMELINE_RULER_HEIGHT_PX }}
|
||||
onWheel={handleWheel}
|
||||
onClick={(event) => {
|
||||
// Ruler seek already happens on mousedown via playhead scrubbing.
|
||||
@@ -124,7 +124,7 @@ export function TimelineRuler({
|
||||
ref={rulerRef}
|
||||
className="relative cursor-default select-none"
|
||||
style={{
|
||||
height: TIMELINE_RULER_HEIGHT,
|
||||
height: TIMELINE_RULER_HEIGHT_PX,
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
onMouseDown={handleRulerMouseDown}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SplitSquareHorizontal } from "lucide-react";
|
||||
import {
|
||||
SplitButton,
|
||||
SplitButtonLeft,
|
||||
@@ -14,10 +14,17 @@ import {
|
||||
SplitButtonSeparator,
|
||||
} from "@/components/ui/split-button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction";
|
||||
import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale";
|
||||
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
|
||||
import { ScenesView } from "@/components/editor/scenes-view";
|
||||
import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions";
|
||||
import {
|
||||
canToggleSourceAudio,
|
||||
getSourceAudioActionLabel,
|
||||
isSourceAudioSeparated,
|
||||
} from "@/lib/timeline/audio-separation";
|
||||
import { hasMediaId } from "@/lib/timeline";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -27,17 +34,21 @@ import {
|
||||
SnowIcon,
|
||||
ScissorIcon,
|
||||
MagnetIcon,
|
||||
Link04Icon,
|
||||
SearchAddIcon,
|
||||
SearchMinusIcon,
|
||||
Copy01Icon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
Link02Icon,
|
||||
Layers01Icon,
|
||||
Chart03Icon,
|
||||
Unlink02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { OcRippleIcon } from "@/components/icons";
|
||||
import { GraphEditorPopover } from "./graph-editor/popover";
|
||||
import { PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useGraphEditorController } from "./graph-editor/use-controller";
|
||||
|
||||
export function TimelineToolbar({
|
||||
zoomLevel,
|
||||
@@ -51,11 +62,8 @@ export function TimelineToolbar({
|
||||
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
|
||||
const newZoomLevel =
|
||||
direction === "in"
|
||||
? Math.min(
|
||||
TIMELINE_CONSTANTS.ZOOM_MAX,
|
||||
zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR,
|
||||
)
|
||||
: Math.max(minZoom, zoomLevel / TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR);
|
||||
? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR)
|
||||
: Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR);
|
||||
setZoomLevel({ zoom: newZoomLevel });
|
||||
};
|
||||
|
||||
@@ -78,9 +86,47 @@ export function TimelineToolbar({
|
||||
}
|
||||
|
||||
function ToolbarLeftSection() {
|
||||
const editor = useEditor();
|
||||
const mediaAssets = useEditor((currentEditor) =>
|
||||
currentEditor.media.getAssets(),
|
||||
);
|
||||
const { selectedElements } = useElementSelection();
|
||||
const graphEditor = useGraphEditorController();
|
||||
const isCurrentlyBookmarked = useEditor((e) =>
|
||||
e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
|
||||
);
|
||||
const selectedElement =
|
||||
selectedElements.length === 1
|
||||
? (editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
})[0] ?? null)
|
||||
: null;
|
||||
const selectedMediaAsset = (() => {
|
||||
if (!selectedElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { element } = selectedElement;
|
||||
if (!hasMediaId(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
|
||||
})();
|
||||
const canToggleSelectedSourceAudio =
|
||||
!!selectedElement &&
|
||||
canToggleSourceAudio(selectedElement.element, selectedMediaAsset);
|
||||
const sourceAudioLabel =
|
||||
selectedElement?.element.type === "video"
|
||||
? getSourceAudioActionLabel({
|
||||
element: selectedElement.element,
|
||||
})
|
||||
: "Extract audio";
|
||||
const isSelectedSourceAudioSeparated =
|
||||
selectedElement?.element.type === "video" &&
|
||||
isSourceAudioSeparated({
|
||||
element: selectedElement.element,
|
||||
});
|
||||
|
||||
const handleAction = ({
|
||||
action,
|
||||
@@ -117,10 +163,16 @@ function ToolbarLeftSection() {
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={<SplitSquareHorizontal />}
|
||||
tooltip="Separate audio (coming soon)"
|
||||
disabled={true}
|
||||
onClick={({ event: _event }) => {}}
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
|
||||
/>
|
||||
}
|
||||
tooltip={sourceAudioLabel}
|
||||
disabled={!canToggleSelectedSourceAudio}
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "toggle-source-audio", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
@@ -159,13 +211,35 @@ function ToolbarLeftSection() {
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<GraphEditorPopover
|
||||
open={graphEditor.open}
|
||||
onOpenChange={graphEditor.onOpenChange}
|
||||
value={
|
||||
graphEditor.state.status === "ready"
|
||||
? graphEditor.state.cubicBezier
|
||||
: null
|
||||
}
|
||||
message={graphEditor.state.message}
|
||||
componentOptions={graphEditor.state.componentOptions}
|
||||
activeComponentKey={graphEditor.state.activeComponentKey}
|
||||
onActiveComponentKeyChange={graphEditor.onActiveComponentKeyChange}
|
||||
onPreviewValue={graphEditor.onPreviewValue}
|
||||
onCommitValue={graphEditor.onCommitValue}
|
||||
onCancelPreview={graphEditor.onCancelPreview}
|
||||
>
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={Chart03Icon} />}
|
||||
tooltip="Graph"
|
||||
onClick={() => {}}
|
||||
tooltip={graphEditor.tooltip}
|
||||
disabled={!graphEditor.canOpen}
|
||||
buttonWrapper={(button) =>
|
||||
graphEditor.canOpen ? (
|
||||
<PopoverTrigger asChild>{button}</PopoverTrigger>
|
||||
) : (
|
||||
button
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</GraphEditorPopover>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
@@ -262,28 +336,40 @@ function ToolbarButton({
|
||||
onClick,
|
||||
disabled,
|
||||
isActive,
|
||||
buttonWrapper,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
tooltip: string;
|
||||
onClick: ({ event }: { event: React.MouseEvent }) => void;
|
||||
onClick?: ({ event }: { event: React.MouseEvent }) => void;
|
||||
disabled?: boolean;
|
||||
isActive?: boolean;
|
||||
buttonWrapper?: (button: React.ReactElement) => React.ReactElement;
|
||||
}) {
|
||||
const button = (
|
||||
<Button
|
||||
variant={isActive ? "secondary" : "text"}
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
onClick={onClick ? (event) => onClick({ event }) : undefined}
|
||||
className={cn(
|
||||
"rounded-sm",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
const trigger = disabled ? (
|
||||
<span className="inline-flex">{button}</span>
|
||||
) : buttonWrapper ? (
|
||||
buttonWrapper(button)
|
||||
) : (
|
||||
button
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isActive ? "secondary" : "text"}
|
||||
size="icon"
|
||||
onClick={(event) => onClick({ event })}
|
||||
className={cn(
|
||||
"rounded-sm",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,8 @@ import { TimelineElement } from "./timeline-element";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants";
|
||||
import { TIMELINE_LAYERS } from "./layers";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||
import type { ElementDragState } from "@/lib/timeline";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
@@ -59,7 +60,7 @@ export function TimelineTrackContent({
|
||||
getMouseClientX: () => lastMouseXRef.current ?? 0,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { TrackType } from "@/lib/timeline";
|
||||
import {
|
||||
TIMELINE_TRACK_GAP_PX,
|
||||
TIMELINE_TRACK_HEIGHTS_PX,
|
||||
} from "./layout";
|
||||
|
||||
export function getTrackHeight({ type }: { type: TrackType }): number {
|
||||
return TIMELINE_TRACK_HEIGHTS_PX[type];
|
||||
}
|
||||
|
||||
export function getCumulativeHeightBefore({
|
||||
tracks,
|
||||
trackIndex,
|
||||
}: {
|
||||
tracks: Array<{ type: TrackType }>;
|
||||
trackIndex: number;
|
||||
}): number {
|
||||
return tracks
|
||||
.slice(0, trackIndex)
|
||||
.reduce(
|
||||
(sum, track) => sum + getTrackHeight({ type: track.type }) + TIMELINE_TRACK_GAP_PX,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export function getTotalTracksHeight({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: Array<{ type: TrackType }>;
|
||||
}): number {
|
||||
const tracksHeight = tracks.reduce(
|
||||
(sum, track) => sum + getTrackHeight({ type: track.type }),
|
||||
0,
|
||||
);
|
||||
const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX;
|
||||
return tracksHeight + gapsHeight;
|
||||
}
|
||||
@@ -1,133 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { EditorCore } from "@/core";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useKeybindingsListener } from "@/hooks/use-keybindings";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
|
||||
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
|
||||
|
||||
interface EditorProviderProps {
|
||||
projectId: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
||||
const activeProject = useEditor((e) => e.project.getActiveOrNull());
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { setLoadingProject } = useKeybindingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingProject(isLoading);
|
||||
}, [isLoading, setLoadingProject]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const editor = EditorCore.getInstance();
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editor.project.loadProject({ id: projectId });
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setIsLoading(false);
|
||||
loadFontAtlas();
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
const isNotFound =
|
||||
err instanceof Error &&
|
||||
(err.message.includes("not found") ||
|
||||
err.message.includes("does not exist"));
|
||||
|
||||
if (isNotFound) {
|
||||
try {
|
||||
const newProjectId = await editor.project.createNewProject({
|
||||
name: "Untitled Project",
|
||||
});
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
} catch (_createErr) {
|
||||
setError("Failed to create project");
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load project",
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProject();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, router]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground size-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Loading project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeProject) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground size-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Exiting project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorRuntimeBindings />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorRuntimeBindings() {
|
||||
const editor = useEditor();
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!editor.save.getIsDirty()) return;
|
||||
event.preventDefault();
|
||||
(event as unknown as { returnValue: string }).returnValue = "";
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [editor]);
|
||||
|
||||
useEditorActions();
|
||||
useKeybindingsListener();
|
||||
return null;
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { EditorCore } from "@/core";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useKeybindingsListener } from "@/hooks/use-keybindings";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
|
||||
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
|
||||
import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer";
|
||||
|
||||
interface EditorProviderProps {
|
||||
projectId: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
||||
const activeProject = useEditor((e) => e.project.getActiveOrNull());
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { setLoadingProject } = useKeybindingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingProject(isLoading);
|
||||
}, [isLoading, setLoadingProject]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const editor = EditorCore.getInstance();
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await initializeGpuRenderer();
|
||||
await editor.project.loadProject({ id: projectId });
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setIsLoading(false);
|
||||
loadFontAtlas();
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
const isNotFound =
|
||||
err instanceof Error &&
|
||||
(err.message.includes("not found") ||
|
||||
err.message.includes("does not exist"));
|
||||
|
||||
if (isNotFound) {
|
||||
try {
|
||||
const newProjectId = await editor.project.createNewProject({
|
||||
name: "Untitled Project",
|
||||
});
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
} catch (_createErr) {
|
||||
setError("Failed to create project");
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load project",
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProject();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, router]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground size-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Loading project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeProject) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground size-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Exiting project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorRuntimeBindings />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorRuntimeBindings() {
|
||||
const editor = useEditor();
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!editor.save.getIsDirty()) return;
|
||||
event.preventDefault();
|
||||
(event as unknown as { returnValue: string }).returnValue = "";
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [editor]);
|
||||
|
||||
useEditorActions();
|
||||
useKeybindingsListener();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -50,11 +50,11 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent",
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
|
||||
variant === "default" &&
|
||||
"data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5",
|
||||
"border border-transparent data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5",
|
||||
variant === "underline" &&
|
||||
"text-muted-foreground data-[state=active]:text-primary border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px",
|
||||
"text-muted-foreground data-[state=active]:text-primary border-x-0 border-t-0 border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -72,7 +72,7 @@ const TabsContent = React.forwardRef<
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
|
||||
"ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
|
||||
variant === "underline" && "px-4",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -16,12 +16,12 @@ export const FPS_PRESETS = [
|
||||
] as const;
|
||||
|
||||
export const BLUR_INTENSITY_PRESETS: { label: string; value: number }[] = [
|
||||
{ label: "Light", value: 10 },
|
||||
{ label: "Medium", value: 50 },
|
||||
{ label: "Heavy", value: 100 },
|
||||
{ label: "Light", value: 50 },
|
||||
{ label: "Medium", value: 250 },
|
||||
{ label: "Heavy", value: 500 },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };
|
||||
export const DEFAULT_FPS = 30;
|
||||
export const DEFAULT_BLUR_INTENSITY = 10;
|
||||
export const DEFAULT_BLUR_INTENSITY = 50;
|
||||
export const DEFAULT_COLOR = "#000000";
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { EditorCore } from "@/core";
|
||||
import {
|
||||
clampRetimeRate,
|
||||
shouldMaintainPitch,
|
||||
} from "@/constants/retime-constants";
|
||||
} from "@/lib/retime/rate";
|
||||
import type { AudioClipSource } from "@/lib/media/audio";
|
||||
import { createAudioContext, collectAudioClips } from "@/lib/media/audio";
|
||||
import {
|
||||
|
||||
@@ -12,11 +12,9 @@ import { storageService } from "@/services/storage/service";
|
||||
import { toast } from "sonner";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
|
||||
import {
|
||||
DEFAULT_FPS,
|
||||
DEFAULT_CANVAS_SIZE,
|
||||
DEFAULT_COLOR,
|
||||
} from "@/constants/project-constants";
|
||||
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/constants";
|
||||
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
|
||||
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
||||
import { buildDefaultScene, getProjectDurationFromScenes } from "@/lib/scenes";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||
@@ -29,7 +27,7 @@ import {
|
||||
import { loadFonts } from "@/lib/fonts/google-fonts";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { getElementFontFamilies } from "@/lib/timeline/element-utils";
|
||||
import { getRaisedProjectFpsForImportedMedia } from "@/lib/project/fps";
|
||||
import { getRaisedProjectFpsForImportedMedia } from "@/lib/fps/utils";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
|
||||
export interface MigrationState {
|
||||
@@ -101,7 +99,7 @@ export class ProjectManager {
|
||||
originalCanvasSize: null,
|
||||
background: {
|
||||
type: "color",
|
||||
color: DEFAULT_COLOR,
|
||||
color: DEFAULT_BACKGROUND_COLOR,
|
||||
},
|
||||
},
|
||||
version: CURRENT_PROJECT_VERSION,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
AnimationPath,
|
||||
AnimationInterpolation,
|
||||
AnimationValue,
|
||||
ScalarCurveKeyframePatch,
|
||||
} from "@/lib/animation/types";
|
||||
import { calculateTotalDuration } from "@/lib/timeline";
|
||||
import { getLastFrameTime } from "opencut-wasm";
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
UpsertKeyframeCommand,
|
||||
RemoveKeyframeCommand,
|
||||
RetimeKeyframeCommand,
|
||||
UpdateScalarKeyframeCurveCommand,
|
||||
AddClipEffectCommand,
|
||||
RemoveClipEffectCommand,
|
||||
UpdateClipEffectParamsCommand,
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
ToggleMaskInvertedCommand,
|
||||
UpsertEffectParamKeyframeCommand,
|
||||
RemoveEffectParamKeyframeCommand,
|
||||
ToggleSourceAudioSeparationCommand,
|
||||
} from "@/lib/commands/timeline";
|
||||
import { BatchCommand } from "@/lib/commands";
|
||||
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
||||
@@ -281,6 +284,20 @@ export class TimelineManager {
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
toggleSourceAudioSeparation({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
}): void {
|
||||
const command = new ToggleSourceAudioSeparationCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
updateElements({
|
||||
updates,
|
||||
pushHistory = true,
|
||||
@@ -534,6 +551,45 @@ export class TimelineManager {
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
updateKeyframeCurves({
|
||||
keyframes,
|
||||
}: {
|
||||
keyframes: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
componentKey: string;
|
||||
keyframeId: string;
|
||||
patch: ScalarCurveKeyframePatch;
|
||||
}>;
|
||||
}): void {
|
||||
if (keyframes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commands = keyframes.map(
|
||||
({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
componentKey,
|
||||
keyframeId,
|
||||
patch,
|
||||
}) =>
|
||||
new UpdateScalarKeyframeCurveCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
componentKey,
|
||||
keyframeId,
|
||||
patch,
|
||||
}),
|
||||
);
|
||||
const command =
|
||||
commands.length === 1 ? commands[0] : new BatchCommand(commands);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
upsertEffectParamKeyframe({
|
||||
trackId,
|
||||
elementId,
|
||||
|
||||
@@ -6,9 +6,13 @@ import { useActionHandler } from "@/hooks/actions/use-action-handler";
|
||||
import { useEditor } from "../use-editor";
|
||||
import { useElementSelection } from "../timeline/element/use-element-selection";
|
||||
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
|
||||
import { getElementsAtTime } from "@/lib/timeline";
|
||||
import {
|
||||
getElementsAtTime,
|
||||
hasMediaId,
|
||||
} from "@/lib/timeline";
|
||||
import { cancelInteraction } from "@/lib/cancel-interaction";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { canToggleSourceAudio } from "@/lib/timeline/audio-separation";
|
||||
import {
|
||||
activateScope,
|
||||
clearActiveScope,
|
||||
@@ -265,6 +269,43 @@ export function useEditorActions() {
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"toggle-source-audio",
|
||||
() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedElement = editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
})[0];
|
||||
if (!selectedElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaAsset = (() => {
|
||||
const { element } = selectedElement;
|
||||
if (!hasMediaId(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
|
||||
null
|
||||
);
|
||||
})();
|
||||
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.toggleSourceAudioSeparation({
|
||||
trackId: selectedElement.track.id,
|
||||
elementId: selectedElement.element.id,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"select-all",
|
||||
() => {
|
||||
|
||||
@@ -10,12 +10,10 @@ import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
|
||||
import { snapTimeToFrame } from "opencut-wasm";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
|
||||
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
@@ -70,7 +68,7 @@ function getClickOffsetTime({
|
||||
zoomLevel: number;
|
||||
}): number {
|
||||
const clickOffsetX = clientX - elementRect.left;
|
||||
return clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
return clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
|
||||
}
|
||||
|
||||
function getVerticalDragDirection({
|
||||
@@ -136,7 +134,7 @@ function getDragDropTarget({
|
||||
playheadTime: snappedTime,
|
||||
isExternalDrop: false,
|
||||
elementDuration,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
startTimeOverride: snappedTime,
|
||||
excludeElementId: movingElement.id,
|
||||
@@ -288,7 +286,10 @@ export function useElementInteraction({
|
||||
if (isPendingDrag && pendingDragRef.current) {
|
||||
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
|
||||
const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY);
|
||||
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
|
||||
if (
|
||||
deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
|
||||
deltaY > TIMELINE_DRAG_THRESHOLD_PX
|
||||
) {
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
@@ -416,7 +417,10 @@ export function useElementInteraction({
|
||||
if (mouseDownLocationRef.current) {
|
||||
const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x);
|
||||
const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y);
|
||||
if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
|
||||
if (
|
||||
deltaX <= TIMELINE_DRAG_THRESHOLD_PX &&
|
||||
deltaY <= TIMELINE_DRAG_THRESHOLD_PX
|
||||
) {
|
||||
mouseDownLocationRef.current = null;
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
@@ -596,7 +600,10 @@ export function useElementInteraction({
|
||||
if (mouseDownLocationRef.current) {
|
||||
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
|
||||
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
|
||||
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
|
||||
if (
|
||||
deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
|
||||
deltaY > TIMELINE_DRAG_THRESHOLD_PX
|
||||
) {
|
||||
mouseDownLocationRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import { snapTimeToFrame } from "opencut-wasm";
|
||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
@@ -190,7 +190,7 @@ export function useTimelineElementResize({
|
||||
|
||||
const deltaX = clientX - resizing.startX;
|
||||
let deltaTime =
|
||||
deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
|
||||
let resizeSnapPoint: SnapPoint | null = null;
|
||||
|
||||
const projectFps = editor.project.getActive().settings.fps;
|
||||
|
||||
@@ -6,13 +6,12 @@ import {
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { getKeyframeById } from "@/lib/animation";
|
||||
import { useKeyframeSelection } from "./use-keyframe-selection";
|
||||
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
|
||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||
import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
|
||||
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
|
||||
import { BatchCommand } from "@/lib/commands";
|
||||
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
||||
@@ -64,7 +63,7 @@ export function useKeyframeDrag({
|
||||
const activeProject = editor.project.getActive();
|
||||
const fps = activeProject.settings.fps;
|
||||
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setDragState(initialDragState);
|
||||
@@ -86,10 +85,11 @@ export function useKeyframeDrag({
|
||||
deltaTime: number;
|
||||
}) => {
|
||||
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
|
||||
const channel = element.animations?.channels[keyframeRef.propertyPath];
|
||||
const keyframe = channel?.keyframes.find(
|
||||
(keyframe) => keyframe.id === keyframeRef.keyframeId,
|
||||
);
|
||||
const keyframe = getKeyframeById({
|
||||
animations: element.animations,
|
||||
propertyPath: keyframeRef.propertyPath,
|
||||
keyframeId: keyframeRef.keyframeId,
|
||||
});
|
||||
if (!keyframe) return [];
|
||||
const nextTime = Math.max(
|
||||
0,
|
||||
@@ -127,7 +127,7 @@ export function useKeyframeDrag({
|
||||
const handleMouseMove = ({ clientX }: MouseEvent) => {
|
||||
if (isPendingDrag && pendingDragRef.current) {
|
||||
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
|
||||
if (deltaX <= DRAG_THRESHOLD_PX) return;
|
||||
if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return;
|
||||
|
||||
const pending = pendingDragRef.current;
|
||||
pendingDragRef.current = null;
|
||||
@@ -248,7 +248,8 @@ export function useKeyframeDrag({
|
||||
|
||||
const wasDrag =
|
||||
mouseDownXRef.current !== null &&
|
||||
Math.abs(event.clientX - mouseDownXRef.current) > DRAG_THRESHOLD_PX;
|
||||
Math.abs(event.clientX - mouseDownXRef.current) >
|
||||
TIMELINE_DRAG_THRESHOLD_PX;
|
||||
mouseDownXRef.current = null;
|
||||
|
||||
if (wasDrag) return;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
|
||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
|
||||
import { snapTimeToFrame } from "opencut-wasm";
|
||||
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||
import {
|
||||
@@ -131,7 +131,10 @@ export function useBookmarkDrag({
|
||||
const deltaX = Math.abs(event.clientX - startMouseX);
|
||||
const deltaY = Math.abs(event.clientY - startMouseY);
|
||||
|
||||
if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
|
||||
if (
|
||||
deltaX <= TIMELINE_DRAG_THRESHOLD_PX &&
|
||||
deltaY <= TIMELINE_DRAG_THRESHOLD_PX
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||
import { TRACK_LABELS_WIDTH_PX } from "@/constants/timeline-constants";
|
||||
import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/components/editor/panels/timeline/layout";
|
||||
interface UseSnapIndicatorPositionParams {
|
||||
snapPoint: { time: number } | null;
|
||||
zoomLevel: number;
|
||||
@@ -44,7 +44,8 @@ export function useSnapIndicatorPosition({
|
||||
time: snapPoint?.time ?? 0,
|
||||
zoomLevel,
|
||||
});
|
||||
const leftPosition = TRACK_LABELS_WIDTH_PX + timelinePosition - scrollLeft;
|
||||
const leftPosition =
|
||||
TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX + timelinePosition - scrollLeft;
|
||||
|
||||
return {
|
||||
leftPosition,
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useEditor } from "@/hooks/use-editor";
|
||||
import { processMediaAssets } from "@/lib/media/processing";
|
||||
import { toast } from "sonner";
|
||||
import { showMediaUploadToast } from "@/lib/media/upload-toast";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import { snapTimeToFrame } from "opencut-wasm";
|
||||
import {
|
||||
buildTextElement,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
|
||||
import { BatchCommand } from "@/lib/commands";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
|
||||
import { getDragData, hasDragData } from "@/lib/drag-data";
|
||||
import { isMainTrack } from "@/lib/timeline/placement";
|
||||
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
|
||||
@@ -82,14 +83,14 @@ export function useTimelineDragDrop({
|
||||
elementType === "sticker" ||
|
||||
elementType === "effect"
|
||||
) {
|
||||
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
||||
}
|
||||
if (mediaId) {
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const media = mediaAssets.find((m) => m.id === mediaId);
|
||||
return media?.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
return media?.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
||||
}
|
||||
return TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
return DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
@@ -158,7 +159,7 @@ export function useTimelineDragDrop({
|
||||
playheadTime: currentTime,
|
||||
isExternalDrop: isExternal,
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
targetElementTypes,
|
||||
});
|
||||
@@ -330,7 +331,7 @@ export function useTimelineDragDrop({
|
||||
dragData.mediaType === "audio" ? "audio" : "video";
|
||||
|
||||
const duration =
|
||||
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: mediaAsset.id,
|
||||
mediaType: mediaAsset.type,
|
||||
@@ -445,7 +446,7 @@ export function useTimelineDragDrop({
|
||||
|
||||
const duration =
|
||||
createdAsset.duration ??
|
||||
TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
||||
const currentTracks = editor.timeline.getTracks();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const onlyTrack = currentTracks[0];
|
||||
@@ -467,7 +468,7 @@ export function useTimelineDragDrop({
|
||||
playheadTime: currentTime,
|
||||
isExternalDrop: true,
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getCenteredLineLeft,
|
||||
timelineTimeToSnappedPixels,
|
||||
} from "@/lib/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
zoomLevel: number;
|
||||
@@ -67,7 +67,7 @@ export function useTimelinePlayhead({
|
||||
const relativeMouseX = event.clientX - rulerRect.left;
|
||||
|
||||
const timelineContentWidth =
|
||||
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
const clampedMouseX = Math.max(
|
||||
0,
|
||||
@@ -78,7 +78,7 @@ export function useTimelinePlayhead({
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -161,7 +161,7 @@ export function useTimelinePlayhead({
|
||||
getMouseClientX: () => lastMouseXRef.current,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -248,7 +248,7 @@ export function useTimelinePlayhead({
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
|
||||
const playheadPixels =
|
||||
time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevelRef.current;
|
||||
time * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevelRef.current;
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const scrollMinimum = 0;
|
||||
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import { getSnappedSeekTime } from "opencut-wasm";
|
||||
import { useEditor } from "../use-editor";
|
||||
|
||||
@@ -132,7 +132,7 @@ export function useTimelineSeek({
|
||||
Math.min(
|
||||
duration,
|
||||
(mouseX + scrollLeft) /
|
||||
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
(BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -7,7 +7,14 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD,
|
||||
} from "@/components/editor/panels/timeline/interaction";
|
||||
import {
|
||||
TIMELINE_ZOOM_MAX,
|
||||
TIMELINE_ZOOM_MIN,
|
||||
BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
} from "@/lib/timeline/scale";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
|
||||
|
||||
@@ -30,7 +37,7 @@ interface UseTimelineZoomReturn {
|
||||
|
||||
export function useTimelineZoom({
|
||||
containerRef,
|
||||
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
|
||||
minZoom = TIMELINE_ZOOM_MIN,
|
||||
initialZoom,
|
||||
initialScrollLeft,
|
||||
initialPlayheadTime,
|
||||
@@ -49,7 +56,7 @@ export function useTimelineZoom({
|
||||
hasInitializedRef.current = true;
|
||||
return Math.max(
|
||||
minZoom,
|
||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom),
|
||||
Math.min(TIMELINE_ZOOM_MAX, initialZoom),
|
||||
);
|
||||
}
|
||||
return minZoom;
|
||||
@@ -91,7 +98,7 @@ export function useTimelineZoom({
|
||||
setZoomLevel((prev) => {
|
||||
const nextZoom = Math.max(
|
||||
minZoom,
|
||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomFactor),
|
||||
Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor),
|
||||
);
|
||||
return nextZoom;
|
||||
});
|
||||
@@ -105,7 +112,7 @@ export function useTimelineZoom({
|
||||
if (initialZoom !== undefined && !hasInitializedRef.current) {
|
||||
hasInitializedRef.current = true;
|
||||
setZoomLevel(
|
||||
Math.max(minZoom, Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom)),
|
||||
Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -126,7 +133,7 @@ export function useTimelineZoom({
|
||||
: zoomLevelOrUpdater;
|
||||
const clampedZoom = Math.max(
|
||||
minZoom,
|
||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, nextZoom),
|
||||
Math.min(TIMELINE_ZOOM_MAX, nextZoom),
|
||||
);
|
||||
return clampedZoom;
|
||||
});
|
||||
@@ -153,12 +160,12 @@ export function useTimelineZoom({
|
||||
});
|
||||
const isCrossingThresholdUp =
|
||||
previousSliderPercent <
|
||||
TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
const isCrossingThresholdDown =
|
||||
previousSliderPercent >=
|
||||
TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent < TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
|
||||
const syncScroll = (scrollLeft: number) => {
|
||||
scrollElement.scrollLeft = scrollLeft;
|
||||
@@ -178,11 +185,11 @@ export function useTimelineZoom({
|
||||
isInPlayheadAnchorModeRef.current = true;
|
||||
}
|
||||
|
||||
if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
|
||||
if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
|
||||
const playheadPixelsBefore =
|
||||
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * previousZoom;
|
||||
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * previousZoom;
|
||||
const playheadPixelsAfter =
|
||||
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
|
||||
const newScrollLeft = playheadPixelsAfter - viewportOffset;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { buildElementFromMedia } from "@/lib/timeline/element-utils";
|
||||
import { AddMediaAssetCommand } from "@/lib/commands/media";
|
||||
import { InsertElementCommand } from "@/lib/commands/timeline";
|
||||
import { BatchCommand } from "@/lib/commands";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
|
||||
import { isTypableDOMElement } from "@/utils/browser";
|
||||
import type { MediaType } from "@/lib/media/types";
|
||||
|
||||
@@ -74,7 +74,7 @@ export function usePasteMedia() {
|
||||
);
|
||||
const assetId = addMediaCmd.getAssetId();
|
||||
const duration =
|
||||
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
||||
const trackType = asset.type === "audio" ? "audio" : "video";
|
||||
|
||||
const element = buildElementFromMedia({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -101,6 +101,10 @@ export const ACTIONS = {
|
||||
description: "Toggle ripple editing",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-source-audio": {
|
||||
description: "Extract or recover source audio",
|
||||
category: "editing",
|
||||
},
|
||||
"select-all": {
|
||||
description: "Select all elements",
|
||||
category: "selection",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
composeAnimationValue,
|
||||
createAnimationBinding,
|
||||
} from "@/lib/animation/binding-values";
|
||||
|
||||
describe("binding values", () => {
|
||||
test("formats composed animated colors as hex", () => {
|
||||
const binding = createAnimationBinding({
|
||||
path: "color",
|
||||
kind: "color",
|
||||
});
|
||||
|
||||
expect(
|
||||
composeAnimationValue({
|
||||
binding,
|
||||
componentValues: {
|
||||
r: 1,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 1,
|
||||
},
|
||||
}),
|
||||
).toBe("#ff0000");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
getElementKeyframes,
|
||||
getKeyframeById,
|
||||
getKeyframeAtTime,
|
||||
} from "@/lib/animation/keyframe-query";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
ScalarAnimationKey,
|
||||
} from "@/lib/animation/types";
|
||||
|
||||
function createScalarKey({
|
||||
id,
|
||||
time,
|
||||
value,
|
||||
}: {
|
||||
id: string;
|
||||
time: number;
|
||||
value: number;
|
||||
}): ScalarAnimationKey {
|
||||
return {
|
||||
id,
|
||||
time,
|
||||
value,
|
||||
segmentToNext: "linear",
|
||||
tangentMode: "flat",
|
||||
};
|
||||
}
|
||||
|
||||
function buildPositionAnimations({
|
||||
xKeys,
|
||||
yKeys,
|
||||
}: {
|
||||
xKeys: ScalarAnimationKey[];
|
||||
yKeys: ScalarAnimationKey[];
|
||||
}): ElementAnimations {
|
||||
return {
|
||||
bindings: {
|
||||
"transform.position": {
|
||||
path: "transform.position",
|
||||
kind: "vector2",
|
||||
components: [
|
||||
{ key: "x", channelId: "transform.position:x" },
|
||||
{ key: "y", channelId: "transform.position:y" },
|
||||
],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
"transform.position:x": {
|
||||
kind: "scalar",
|
||||
keys: xKeys,
|
||||
},
|
||||
"transform.position:y": {
|
||||
kind: "scalar",
|
||||
keys: yKeys,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("keyframe query", () => {
|
||||
test("returns keyframes from any component channel", () => {
|
||||
const animations = buildPositionAnimations({
|
||||
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
|
||||
yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
|
||||
});
|
||||
|
||||
expect(
|
||||
getElementKeyframes({ animations }).map(({ id, time }) => ({
|
||||
id,
|
||||
time,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "x-1", time: 1 },
|
||||
{ id: "y-2", time: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds a keyframe at time on a non-primary component", () => {
|
||||
const animations = buildPositionAnimations({
|
||||
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
|
||||
yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
|
||||
});
|
||||
|
||||
expect(
|
||||
getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.position",
|
||||
time: 2,
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: "y-2",
|
||||
time: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("finds a keyframe by id on a non-primary component", () => {
|
||||
const animations = buildPositionAnimations({
|
||||
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
|
||||
yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
|
||||
});
|
||||
|
||||
expect(
|
||||
getKeyframeById({
|
||||
animations,
|
||||
propertyPath: "transform.position",
|
||||
keyframeId: "y-2",
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: "y-2",
|
||||
time: 2,
|
||||
value: { x: 10, y: 20 },
|
||||
});
|
||||
});
|
||||
|
||||
test("prefers the primary component when multiple components share a time", () => {
|
||||
const animations = buildPositionAnimations({
|
||||
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
|
||||
yKeys: [createScalarKey({ id: "y-1", time: 1, value: 20 })],
|
||||
});
|
||||
|
||||
expect(
|
||||
getElementKeyframes({ animations }).map(({ id, time }) => ({
|
||||
id,
|
||||
time,
|
||||
})),
|
||||
).toEqual([{ id: "x-1", time: 1 }]);
|
||||
expect(
|
||||
getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.position",
|
||||
time: 1,
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: "x-1",
|
||||
time: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ScalarAnimationKey } from "@/lib/animation/types";
|
||||
|
||||
export function getBezierPoint({
|
||||
progress,
|
||||
p0,
|
||||
p1,
|
||||
p2,
|
||||
p3,
|
||||
}: {
|
||||
progress: number;
|
||||
p0: number;
|
||||
p1: number;
|
||||
p2: number;
|
||||
p3: number;
|
||||
}) {
|
||||
const mt = 1 - progress;
|
||||
return (
|
||||
mt * mt * mt * p0 +
|
||||
3 * mt * mt * progress * p1 +
|
||||
3 * mt * progress * progress * p2 +
|
||||
progress * progress * progress * p3
|
||||
);
|
||||
}
|
||||
|
||||
export function getDefaultRightHandle({
|
||||
leftKey,
|
||||
rightKey,
|
||||
}: {
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
}) {
|
||||
const span = rightKey.time - leftKey.time;
|
||||
const valueDelta = rightKey.value - leftKey.value;
|
||||
return {
|
||||
dt: span / 3,
|
||||
dv: valueDelta / 3,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultLeftHandle({
|
||||
leftKey,
|
||||
rightKey,
|
||||
}: {
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
}) {
|
||||
const span = rightKey.time - leftKey.time;
|
||||
const valueDelta = rightKey.value - leftKey.value;
|
||||
return {
|
||||
dt: -span / 3,
|
||||
dv: -valueDelta / 3,
|
||||
};
|
||||
}
|
||||
|
||||
export function solveBezierProgressForTime({
|
||||
time,
|
||||
leftKey,
|
||||
rightKey,
|
||||
}: {
|
||||
time: number;
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
}) {
|
||||
let lower = 0;
|
||||
let upper = 1;
|
||||
const rightHandle =
|
||||
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
|
||||
const leftHandle =
|
||||
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
|
||||
|
||||
for (let iteration = 0; iteration < 20; iteration++) {
|
||||
const mid = (lower + upper) / 2;
|
||||
const estimate = getBezierPoint({
|
||||
progress: mid,
|
||||
p0: leftKey.time,
|
||||
p1: leftKey.time + rightHandle.dt,
|
||||
p2: rightKey.time + leftHandle.dt,
|
||||
p3: rightKey.time,
|
||||
});
|
||||
if (estimate < time) {
|
||||
lower = mid;
|
||||
} else {
|
||||
upper = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return (lower + upper) / 2;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { converter, formatHex, formatHex8, parse } from "culori";
|
||||
import type {
|
||||
AnimationBindingComponent,
|
||||
AnimationBindingOfKind,
|
||||
AnimationBindingInstance,
|
||||
AnimationBindingKind,
|
||||
ColorAnimationBinding,
|
||||
DiscreteAnimationBinding,
|
||||
NumberAnimationBinding,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
DiscreteValue,
|
||||
Vector2AnimationBinding,
|
||||
VectorValue,
|
||||
} from "@/lib/animation/types";
|
||||
|
||||
interface LinearRgba {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
export type AnimationComponentValue = number | DiscreteValue;
|
||||
|
||||
const toRgb = converter("rgb");
|
||||
|
||||
function clamp01({ value }: { value: number }): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function srgbToLinear({ value }: { value: number }): number {
|
||||
return value <= 0.04045
|
||||
? value / 12.92
|
||||
: Math.pow((value + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
function linearToSrgb({ value }: { value: number }): number {
|
||||
const clamped = clamp01({ value });
|
||||
return clamped <= 0.0031308
|
||||
? clamped * 12.92
|
||||
: 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function isVectorValue(value: unknown): value is VectorValue {
|
||||
return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
|
||||
}
|
||||
|
||||
export function getBindingComponentKeys({
|
||||
kind,
|
||||
}: {
|
||||
kind: AnimationBindingKind;
|
||||
}): string[] {
|
||||
if (kind === "vector2") {
|
||||
return ["x", "y"];
|
||||
}
|
||||
|
||||
if (kind === "color") {
|
||||
return ["r", "g", "b", "a"];
|
||||
}
|
||||
|
||||
return ["value"];
|
||||
}
|
||||
|
||||
export function buildBindingChannelId({
|
||||
path,
|
||||
componentKey,
|
||||
}: {
|
||||
path: AnimationPath;
|
||||
componentKey: string;
|
||||
}): string {
|
||||
return `${path}:${componentKey}`;
|
||||
}
|
||||
|
||||
function createBindingComponent<TKey extends string>({
|
||||
path,
|
||||
key,
|
||||
}: {
|
||||
path: AnimationPath;
|
||||
key: TKey;
|
||||
}): AnimationBindingComponent<TKey> {
|
||||
return {
|
||||
key,
|
||||
channelId: buildBindingChannelId({ path, componentKey: key }),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneBindingComponents<TKey extends string>({
|
||||
components,
|
||||
}: {
|
||||
components: AnimationBindingComponent<TKey>[];
|
||||
}): AnimationBindingComponent<TKey>[] {
|
||||
return components.map((component) => ({ ...component }));
|
||||
}
|
||||
|
||||
const animationBindingFactories = {
|
||||
color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({
|
||||
path,
|
||||
kind: "color",
|
||||
colorSpace: "srgb-linear",
|
||||
components: [
|
||||
createBindingComponent({ path, key: "r" }),
|
||||
createBindingComponent({ path, key: "g" }),
|
||||
createBindingComponent({ path, key: "b" }),
|
||||
createBindingComponent({ path, key: "a" }),
|
||||
],
|
||||
}),
|
||||
vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({
|
||||
path,
|
||||
kind: "vector2",
|
||||
components: [
|
||||
createBindingComponent({ path, key: "x" }),
|
||||
createBindingComponent({ path, key: "y" }),
|
||||
],
|
||||
}),
|
||||
number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({
|
||||
path,
|
||||
kind: "number",
|
||||
components: [createBindingComponent({ path, key: "value" })],
|
||||
}),
|
||||
discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({
|
||||
path,
|
||||
kind: "discrete",
|
||||
components: [createBindingComponent({ path, key: "value" })],
|
||||
}),
|
||||
} satisfies {
|
||||
[K in AnimationBindingKind]: ({
|
||||
path,
|
||||
}: {
|
||||
path: AnimationPath;
|
||||
}) => AnimationBindingOfKind<K>;
|
||||
};
|
||||
|
||||
export function createAnimationBinding<TKind extends AnimationBindingKind>({
|
||||
path,
|
||||
kind,
|
||||
}: {
|
||||
path: AnimationPath;
|
||||
kind: TKind;
|
||||
}): AnimationBindingOfKind<TKind>;
|
||||
export function createAnimationBinding({
|
||||
path,
|
||||
kind,
|
||||
}: {
|
||||
path: AnimationPath;
|
||||
kind: AnimationBindingKind;
|
||||
}): AnimationBindingInstance {
|
||||
return animationBindingFactories[kind]({ path });
|
||||
}
|
||||
|
||||
const animationBindingCloners = {
|
||||
color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({
|
||||
...binding,
|
||||
components: cloneBindingComponents({
|
||||
components: binding.components,
|
||||
}),
|
||||
}),
|
||||
vector2: ({
|
||||
binding,
|
||||
}: {
|
||||
binding: Vector2AnimationBinding;
|
||||
}): Vector2AnimationBinding => ({
|
||||
...binding,
|
||||
components: cloneBindingComponents({
|
||||
components: binding.components,
|
||||
}),
|
||||
}),
|
||||
number: ({
|
||||
binding,
|
||||
}: {
|
||||
binding: NumberAnimationBinding;
|
||||
}): NumberAnimationBinding => ({
|
||||
...binding,
|
||||
components: cloneBindingComponents({
|
||||
components: binding.components,
|
||||
}),
|
||||
}),
|
||||
discrete: ({
|
||||
binding,
|
||||
}: {
|
||||
binding: DiscreteAnimationBinding;
|
||||
}): DiscreteAnimationBinding => ({
|
||||
...binding,
|
||||
components: cloneBindingComponents({
|
||||
components: binding.components,
|
||||
}),
|
||||
}),
|
||||
} satisfies {
|
||||
[K in AnimationBindingKind]: ({
|
||||
binding,
|
||||
}: {
|
||||
binding: AnimationBindingOfKind<K>;
|
||||
}) => AnimationBindingOfKind<K>;
|
||||
};
|
||||
|
||||
export function cloneAnimationBinding<TKind extends AnimationBindingKind>({
|
||||
binding,
|
||||
}: {
|
||||
binding: AnimationBindingOfKind<TKind>;
|
||||
}): AnimationBindingOfKind<TKind>;
|
||||
export function cloneAnimationBinding({
|
||||
binding,
|
||||
}: {
|
||||
binding: AnimationBindingInstance;
|
||||
}): AnimationBindingInstance {
|
||||
switch (binding.kind) {
|
||||
case "color":
|
||||
return animationBindingCloners.color({ binding });
|
||||
case "vector2":
|
||||
return animationBindingCloners.vector2({ binding });
|
||||
case "number":
|
||||
return animationBindingCloners.number({ binding });
|
||||
case "discrete":
|
||||
return animationBindingCloners.discrete({ binding });
|
||||
}
|
||||
}
|
||||
|
||||
export function parseColorToLinearRgba({
|
||||
color,
|
||||
}: {
|
||||
color: string;
|
||||
}): LinearRgba | null {
|
||||
const parsed = parse(color);
|
||||
const rgb = parsed ? toRgb(parsed) : null;
|
||||
if (!rgb) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
r: srgbToLinear({ value: rgb.r ?? 0 }),
|
||||
g: srgbToLinear({ value: rgb.g ?? 0 }),
|
||||
b: srgbToLinear({ value: rgb.b ?? 0 }),
|
||||
a: clamp01({ value: rgb.alpha ?? 1 }),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatLinearRgba({
|
||||
color,
|
||||
}: {
|
||||
color: LinearRgba;
|
||||
}): string {
|
||||
const rgb = {
|
||||
mode: "rgb",
|
||||
r: linearToSrgb({ value: color.r }),
|
||||
g: linearToSrgb({ value: color.g }),
|
||||
b: linearToSrgb({ value: color.b }),
|
||||
alpha: clamp01({ value: color.a }),
|
||||
} as const;
|
||||
return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
|
||||
}
|
||||
|
||||
export function decomposeAnimationValue({
|
||||
kind,
|
||||
value,
|
||||
}: {
|
||||
kind: AnimationBindingKind;
|
||||
value: AnimationValue;
|
||||
}): Record<string, AnimationComponentValue> | null {
|
||||
if (kind === "number") {
|
||||
return typeof value === "number" ? { value } : null;
|
||||
}
|
||||
|
||||
if (kind === "vector2") {
|
||||
return isVectorValue(value) ? { x: value.x, y: value.y } : null;
|
||||
}
|
||||
|
||||
if (kind === "color") {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseColorToLinearRgba({ color: value });
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
r: parsed.r,
|
||||
g: parsed.g,
|
||||
b: parsed.b,
|
||||
a: parsed.a,
|
||||
};
|
||||
}
|
||||
|
||||
return typeof value === "string" || typeof value === "boolean"
|
||||
? { value }
|
||||
: null;
|
||||
}
|
||||
|
||||
export function composeAnimationValue({
|
||||
binding,
|
||||
componentValues,
|
||||
}: {
|
||||
binding: AnimationBindingInstance;
|
||||
componentValues: Record<string, AnimationComponentValue | undefined>;
|
||||
}): AnimationValue | null {
|
||||
if (binding.kind === "number") {
|
||||
const value = componentValues.value;
|
||||
return typeof value === "number" ? value : null;
|
||||
}
|
||||
|
||||
if (binding.kind === "vector2") {
|
||||
const x = componentValues.x;
|
||||
const y = componentValues.y;
|
||||
return typeof x === "number" && typeof y === "number" ? { x, y } : null;
|
||||
}
|
||||
|
||||
if (binding.kind === "color") {
|
||||
const r = componentValues.r;
|
||||
const g = componentValues.g;
|
||||
const b = componentValues.b;
|
||||
const a = componentValues.a;
|
||||
if (
|
||||
typeof r !== "number" ||
|
||||
typeof g !== "number" ||
|
||||
typeof b !== "number" ||
|
||||
typeof a !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return formatLinearRgba({
|
||||
color: {
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
a,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const value = componentValues.value;
|
||||
return typeof value === "string" || typeof value === "boolean" ? value : null;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type {
|
||||
AnimationPropertyPath,
|
||||
ColorAnimationChannel,
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
|
||||
export function getColorChannelForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): ColorAnimationChannel | undefined {
|
||||
const channel = animations?.channels[propertyPath];
|
||||
if (!channel || channel.valueKind !== "color") {
|
||||
return undefined;
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import {
|
||||
getDefaultLeftHandle,
|
||||
getDefaultRightHandle,
|
||||
} from "@/lib/animation/bezier";
|
||||
import type {
|
||||
CurveHandle,
|
||||
NormalizedCubicBezier,
|
||||
ScalarAnimationKey,
|
||||
} from "@/lib/animation/types";
|
||||
|
||||
const VALUE_EPSILON = 1e-6;
|
||||
|
||||
function clamp01({ value }: { value: number }): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
export function getNormalizedCubicBezierForScalarSegment({
|
||||
leftKey,
|
||||
rightKey,
|
||||
}: {
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
}): NormalizedCubicBezier | null {
|
||||
const spanTime = rightKey.time - leftKey.time;
|
||||
const spanValue = rightKey.value - leftKey.value;
|
||||
if (
|
||||
Math.abs(spanTime) <= TIME_EPSILON_SECONDS ||
|
||||
Math.abs(spanValue) <= VALUE_EPSILON
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rightHandle =
|
||||
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
|
||||
const leftHandle =
|
||||
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
|
||||
|
||||
return [
|
||||
clamp01({ value: rightHandle.dt / spanTime }),
|
||||
rightHandle.dv / spanValue,
|
||||
clamp01({ value: 1 + leftHandle.dt / spanTime }),
|
||||
1 + leftHandle.dv / spanValue,
|
||||
];
|
||||
}
|
||||
|
||||
export function getCurveHandlesForNormalizedCubicBezier({
|
||||
leftKey,
|
||||
rightKey,
|
||||
cubicBezier,
|
||||
}: {
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
cubicBezier: NormalizedCubicBezier;
|
||||
}): {
|
||||
rightHandle: CurveHandle;
|
||||
leftHandle: CurveHandle;
|
||||
} | null {
|
||||
const spanTime = rightKey.time - leftKey.time;
|
||||
const spanValue = rightKey.value - leftKey.value;
|
||||
if (
|
||||
Math.abs(spanTime) <= TIME_EPSILON_SECONDS ||
|
||||
Math.abs(spanValue) <= VALUE_EPSILON
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [rawX1, y1, rawX2, y2] = cubicBezier;
|
||||
const x1 = clamp01({ value: rawX1 });
|
||||
const x2 = clamp01({ value: rawX2 });
|
||||
|
||||
return {
|
||||
rightHandle: {
|
||||
dt: spanTime * x1,
|
||||
dv: spanValue * y1,
|
||||
},
|
||||
leftHandle: {
|
||||
dt: spanTime * (x2 - 1),
|
||||
dv: spanValue * (y2 - 1),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,15 +3,9 @@ import type { Effect } from "@/lib/effects/types";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
EffectParamPath,
|
||||
NumberAnimationChannel,
|
||||
} from "@/lib/animation/types";
|
||||
import {
|
||||
getChannel,
|
||||
removeKeyframe,
|
||||
setChannel,
|
||||
upsertKeyframe,
|
||||
} from "./keyframes";
|
||||
import { getChannelValueAtTime } from "./interpolation";
|
||||
import { removeElementKeyframe } from "./keyframes";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
export const EFFECT_PARAM_PATH_PREFIX = "effects.";
|
||||
export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
|
||||
@@ -74,64 +68,19 @@ export function resolveEffectParamsAtTime({
|
||||
|
||||
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
|
||||
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
|
||||
const channel = getChannel({ animations, propertyPath: path });
|
||||
if (channel && channel.keyframes.length > 0) {
|
||||
resolved[paramKey] = getChannelValueAtTime({
|
||||
channel,
|
||||
time: localTime,
|
||||
fallbackValue: staticValue,
|
||||
}) as number | string | boolean;
|
||||
} else {
|
||||
resolved[paramKey] = staticValue;
|
||||
}
|
||||
resolved[paramKey] = animations?.bindings[path]
|
||||
? resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: path,
|
||||
localTime,
|
||||
fallbackValue: staticValue,
|
||||
})
|
||||
: staticValue;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = {
|
||||
valueKind: "number",
|
||||
keyframes: [],
|
||||
};
|
||||
|
||||
export function upsertEffectParamKeyframe({
|
||||
animations,
|
||||
effectId,
|
||||
paramKey,
|
||||
time,
|
||||
value,
|
||||
interpolation,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
time: number;
|
||||
value: number;
|
||||
interpolation?: "linear" | "hold";
|
||||
keyframeId?: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const path = buildEffectParamPath({ effectId, paramKey });
|
||||
const channel = getChannel({ animations, propertyPath: path });
|
||||
const targetChannel =
|
||||
channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL;
|
||||
const updatedChannel = upsertKeyframe({
|
||||
channel: targetChannel,
|
||||
time,
|
||||
value,
|
||||
interpolation: interpolation ?? "linear",
|
||||
keyframeId,
|
||||
});
|
||||
|
||||
return (
|
||||
setChannel({
|
||||
animations,
|
||||
propertyPath: path,
|
||||
channel: updatedChannel,
|
||||
}) ?? { channels: {} }
|
||||
);
|
||||
}
|
||||
|
||||
export function removeEffectParamKeyframe({
|
||||
animations,
|
||||
effectId,
|
||||
@@ -143,12 +92,9 @@ export function removeEffectParamKeyframe({
|
||||
paramKey: string;
|
||||
keyframeId: string;
|
||||
}): ElementAnimations | undefined {
|
||||
const path = buildEffectParamPath({ effectId, paramKey });
|
||||
const channel = getChannel({ animations, propertyPath: path });
|
||||
const updatedChannel = removeKeyframe({ channel, keyframeId });
|
||||
return setChannel({
|
||||
return removeElementKeyframe({
|
||||
animations,
|
||||
propertyPath: path,
|
||||
channel: updatedChannel,
|
||||
propertyPath: buildEffectParamPath({ effectId, paramKey }),
|
||||
keyframeId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
ScalarAnimationChannel,
|
||||
ScalarGraphChannel,
|
||||
ScalarGraphKeyframeContext,
|
||||
} from "@/lib/animation/types";
|
||||
|
||||
function isScalarAnimationChannel(
|
||||
channel: ElementAnimations["channels"][string],
|
||||
): channel is ScalarAnimationChannel {
|
||||
return channel?.kind === "scalar";
|
||||
}
|
||||
|
||||
export function getEditableScalarChannels({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
}): ScalarGraphChannel[] {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return binding.components.flatMap((component) => {
|
||||
const channel = animations?.channels[component.channelId];
|
||||
if (!isScalarAnimationChannel(channel)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
propertyPath,
|
||||
componentKey: component.key,
|
||||
channelId: component.channelId,
|
||||
channel,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function getEditableScalarChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
componentKey,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
componentKey: string;
|
||||
}): ScalarGraphChannel | null {
|
||||
return (
|
||||
getEditableScalarChannels({
|
||||
animations,
|
||||
propertyPath,
|
||||
}).find((channel) => channel.componentKey === componentKey) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function getScalarKeyframeContext({
|
||||
animations,
|
||||
propertyPath,
|
||||
componentKey,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
componentKey: string;
|
||||
keyframeId: string;
|
||||
}): ScalarGraphKeyframeContext | null {
|
||||
const scalarChannel = getEditableScalarChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
componentKey,
|
||||
});
|
||||
if (!scalarChannel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyframeIndex = scalarChannel.channel.keys.findIndex(
|
||||
(keyframe) => keyframe.id === keyframeId,
|
||||
);
|
||||
if (keyframeIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...scalarChannel,
|
||||
keyframe: scalarChannel.channel.keys[keyframeIndex],
|
||||
keyframeIndex,
|
||||
previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null,
|
||||
nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null,
|
||||
};
|
||||
}
|
||||
@@ -1,77 +1,73 @@
|
||||
import type {
|
||||
ElementAnimations,
|
||||
GraphicParamPath,
|
||||
} from "@/lib/animation/types";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import {
|
||||
getGraphicDefinition,
|
||||
resolveGraphicParams,
|
||||
} from "@/lib/graphics";
|
||||
import { getChannel } from "./keyframes";
|
||||
import { getChannelValueAtTime } from "./interpolation";
|
||||
|
||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||
|
||||
export function buildGraphicParamPath({
|
||||
paramKey,
|
||||
}: {
|
||||
paramKey: string;
|
||||
}): GraphicParamPath {
|
||||
return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
|
||||
}
|
||||
|
||||
export function isGraphicParamPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is GraphicParamPath {
|
||||
return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
|
||||
}
|
||||
|
||||
export function parseGraphicParamPath({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: string;
|
||||
}): { paramKey: string } | null {
|
||||
if (!isGraphicParamPath(propertyPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
|
||||
return paramKey.length > 0 ? { paramKey } : null;
|
||||
}
|
||||
|
||||
export function resolveGraphicParamsAtTime({
|
||||
element,
|
||||
localTime,
|
||||
}: {
|
||||
element: {
|
||||
definitionId: string;
|
||||
params: ParamValues;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
const baseParams = resolveGraphicParams(definition, element.params);
|
||||
const resolved: ParamValues = { ...baseParams };
|
||||
|
||||
for (const param of definition.params) {
|
||||
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||
const channel = getChannel({
|
||||
animations: element.animations,
|
||||
propertyPath: path,
|
||||
});
|
||||
if (!channel || channel.keyframes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved[param.key] = getChannelValueAtTime({
|
||||
channel,
|
||||
time: Math.max(0, localTime),
|
||||
fallbackValue: baseParams[param.key] ?? param.default,
|
||||
}) as number | string | boolean;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
import type {
|
||||
ElementAnimations,
|
||||
GraphicParamPath,
|
||||
} from "@/lib/animation/types";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import {
|
||||
getGraphicDefinition,
|
||||
resolveGraphicParams,
|
||||
} from "@/lib/graphics";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
|
||||
|
||||
export function buildGraphicParamPath({
|
||||
paramKey,
|
||||
}: {
|
||||
paramKey: string;
|
||||
}): GraphicParamPath {
|
||||
return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
|
||||
}
|
||||
|
||||
export function isGraphicParamPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is GraphicParamPath {
|
||||
return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
|
||||
}
|
||||
|
||||
export function parseGraphicParamPath({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: string;
|
||||
}): { paramKey: string } | null {
|
||||
if (!isGraphicParamPath(propertyPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
|
||||
return paramKey.length > 0 ? { paramKey } : null;
|
||||
}
|
||||
|
||||
export function resolveGraphicParamsAtTime({
|
||||
element,
|
||||
localTime,
|
||||
}: {
|
||||
element: {
|
||||
definitionId: string;
|
||||
params: ParamValues;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
const baseParams = resolveGraphicParams(definition, element.params);
|
||||
const resolved: ParamValues = { ...baseParams };
|
||||
|
||||
for (const param of definition.params) {
|
||||
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||
if (!element.animations?.bindings[path]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved[param.key] = resolveAnimationPathValueAtTime({
|
||||
animations: element.animations,
|
||||
propertyPath: path,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseParams[param.key] ?? param.default,
|
||||
});
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -1,71 +1,94 @@
|
||||
export {
|
||||
getChannelValueAtTime,
|
||||
getNumberChannelValueAtTime,
|
||||
getVectorChannelValueAtTime,
|
||||
normalizeChannel,
|
||||
} from "./interpolation";
|
||||
|
||||
export {
|
||||
clampAnimationsToDuration,
|
||||
cloneAnimations,
|
||||
getChannel,
|
||||
removeElementKeyframe,
|
||||
retimeElementKeyframe,
|
||||
setChannel,
|
||||
splitAnimationsAtTime,
|
||||
upsertElementKeyframe,
|
||||
upsertPathKeyframe,
|
||||
} from "./keyframes";
|
||||
|
||||
export {
|
||||
getElementLocalTime,
|
||||
resolveColorAtTime,
|
||||
resolveNumberAtTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "./resolve";
|
||||
|
||||
export {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getDefaultInterpolationForProperty,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
supportsAnimationProperty,
|
||||
type AnimationPropertyDefinition,
|
||||
type NumericSpec,
|
||||
type NumericRange,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
|
||||
export {
|
||||
getElementKeyframes,
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
} from "./keyframe-query";
|
||||
|
||||
export {
|
||||
buildGraphicParamPath,
|
||||
isGraphicParamPath,
|
||||
parseGraphicParamPath,
|
||||
resolveGraphicParamsAtTime,
|
||||
} from "./graphic-param-channel";
|
||||
|
||||
export {
|
||||
isAnimationPath,
|
||||
resolveAnimationTarget,
|
||||
getParamValueKind,
|
||||
getParamDefaultInterpolation,
|
||||
type AnimationPathDescriptor,
|
||||
} from "./target-resolver";
|
||||
|
||||
export {
|
||||
getGroupKeyframesAtTime,
|
||||
hasGroupKeyframeAtTime,
|
||||
type GroupKeyframeRef,
|
||||
} from "./property-groups";
|
||||
|
||||
export {
|
||||
getVectorChannelForPath,
|
||||
isVectorValue,
|
||||
} from "./vector-channel";
|
||||
export {
|
||||
getChannelValueAtTime,
|
||||
getDiscreteChannelValueAtTime,
|
||||
getScalarChannelValueAtTime,
|
||||
getScalarSegmentInterpolation,
|
||||
normalizeChannel,
|
||||
} from "./interpolation";
|
||||
|
||||
export {
|
||||
clampAnimationsToDuration,
|
||||
cloneAnimations,
|
||||
getChannel,
|
||||
removeElementKeyframe,
|
||||
retimeElementKeyframe,
|
||||
setBindingComponentChannel,
|
||||
setChannel,
|
||||
splitAnimationsAtTime,
|
||||
updateScalarKeyframeCurve,
|
||||
upsertElementKeyframe,
|
||||
upsertPathKeyframe,
|
||||
} from "./keyframes";
|
||||
|
||||
export {
|
||||
getElementLocalTime,
|
||||
resolveAnimationPathValueAtTime,
|
||||
resolveColorAtTime,
|
||||
resolveNumberAtTime,
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "./resolve";
|
||||
|
||||
export {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getDefaultInterpolationForProperty,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
supportsAnimationProperty,
|
||||
type AnimationPropertyDefinition,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
|
||||
export {
|
||||
getElementKeyframes,
|
||||
getKeyframeById,
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
} from "./keyframe-query";
|
||||
|
||||
export {
|
||||
getEditableScalarChannel,
|
||||
getEditableScalarChannels,
|
||||
getScalarKeyframeContext,
|
||||
} from "./graph-channels";
|
||||
|
||||
export {
|
||||
getCurveHandlesForNormalizedCubicBezier,
|
||||
getNormalizedCubicBezierForScalarSegment,
|
||||
} from "./curve-bridge";
|
||||
|
||||
export {
|
||||
buildGraphicParamPath,
|
||||
isGraphicParamPath,
|
||||
parseGraphicParamPath,
|
||||
resolveGraphicParamsAtTime,
|
||||
} from "./graphic-param-channel";
|
||||
|
||||
export {
|
||||
buildEffectParamPath,
|
||||
isEffectParamPath,
|
||||
parseEffectParamPath,
|
||||
removeEffectParamKeyframe,
|
||||
resolveEffectParamsAtTime,
|
||||
} from "./effect-param-channel";
|
||||
|
||||
export {
|
||||
isAnimationPath,
|
||||
coerceAnimationValueForParam,
|
||||
resolveAnimationTarget,
|
||||
getParamValueKind,
|
||||
getParamDefaultInterpolation,
|
||||
type AnimationPathDescriptor,
|
||||
} from "./target-resolver";
|
||||
|
||||
export {
|
||||
getGroupKeyframesAtTime,
|
||||
hasGroupKeyframeAtTime,
|
||||
type GroupKeyframeRef,
|
||||
} from "./property-groups";
|
||||
|
||||
export {
|
||||
isVectorValue,
|
||||
} from "./binding-values";
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import type {
|
||||
AnimationChannel,
|
||||
AnimationInterpolation,
|
||||
AnimationValue,
|
||||
ColorAnimationChannel,
|
||||
DiscreteValue,
|
||||
DiscreteAnimationChannel,
|
||||
NumberAnimationChannel,
|
||||
VectorAnimationChannel,
|
||||
VectorValue,
|
||||
DiscreteValue,
|
||||
ScalarAnimationChannel,
|
||||
ScalarAnimationKey,
|
||||
ScalarSegmentType,
|
||||
} from "@/lib/animation/types";
|
||||
import { isVectorValue } from "./vector-channel";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import {
|
||||
getBezierPoint,
|
||||
getDefaultLeftHandle,
|
||||
getDefaultRightHandle,
|
||||
solveBezierProgressForTime,
|
||||
} from "./bezier";
|
||||
|
||||
function byTimeAscending({
|
||||
leftTime,
|
||||
@@ -40,69 +45,6 @@ function clamp01({ value }: { value: number }): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function parseHexChannel({ hex }: { hex: string }): number | null {
|
||||
const value = Number.parseInt(hex, 16);
|
||||
return Number.isNaN(value) ? null : value;
|
||||
}
|
||||
|
||||
function parseHexColor({
|
||||
color,
|
||||
}: {
|
||||
color: string;
|
||||
}): { red: number; green: number; blue: number; alpha: number } | null {
|
||||
const trimmed = color.trim();
|
||||
if (!trimmed.startsWith("#")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawHex = trimmed.slice(1);
|
||||
if (rawHex.length === 3 || rawHex.length === 4) {
|
||||
const [redHex, greenHex, blueHex, alphaHex = "f"] = rawHex.split("");
|
||||
const red = parseHexChannel({ hex: `${redHex}${redHex}` });
|
||||
const green = parseHexChannel({ hex: `${greenHex}${greenHex}` });
|
||||
const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` });
|
||||
const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` });
|
||||
if (red === null || green === null || blue === null || alpha === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { red, green, blue, alpha: alpha / 255 };
|
||||
}
|
||||
|
||||
if (rawHex.length === 6 || rawHex.length === 8) {
|
||||
const red = parseHexChannel({ hex: rawHex.slice(0, 2) });
|
||||
const green = parseHexChannel({ hex: rawHex.slice(2, 4) });
|
||||
const blue = parseHexChannel({ hex: rawHex.slice(4, 6) });
|
||||
const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff";
|
||||
const alpha = parseHexChannel({ hex: alphaHex });
|
||||
if (red === null || green === null || blue === null || alpha === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { red, green, blue, alpha: alpha / 255 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatRgbaColor({
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
alpha,
|
||||
}: {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
alpha: number;
|
||||
}): string {
|
||||
const roundedRed = Math.round(red);
|
||||
const roundedGreen = Math.round(green);
|
||||
const roundedBlue = Math.round(blue);
|
||||
const roundedAlpha = Math.round(clamp01({ value: alpha }) * 1000) / 1000;
|
||||
return `rgba(${roundedRed}, ${roundedGreen}, ${roundedBlue}, ${roundedAlpha})`;
|
||||
}
|
||||
|
||||
function lerpNumber({
|
||||
leftValue,
|
||||
rightValue,
|
||||
@@ -115,43 +57,99 @@ function lerpNumber({
|
||||
return leftValue + (rightValue - leftValue) * progress;
|
||||
}
|
||||
|
||||
function interpolateColor({
|
||||
leftColor,
|
||||
rightColor,
|
||||
progress,
|
||||
function normalizeRightHandle({
|
||||
handle,
|
||||
leftKey,
|
||||
rightKey,
|
||||
}: {
|
||||
leftColor: string;
|
||||
rightColor: string;
|
||||
progress: number;
|
||||
}): string {
|
||||
const leftParsed = parseHexColor({ color: leftColor });
|
||||
const rightParsed = parseHexColor({ color: rightColor });
|
||||
if (!leftParsed || !rightParsed) {
|
||||
return progress >= 1 ? rightColor : leftColor;
|
||||
handle: ScalarAnimationKey["rightHandle"];
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
}) {
|
||||
if (!handle) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return formatRgbaColor({
|
||||
red: lerpNumber({
|
||||
leftValue: leftParsed.red,
|
||||
rightValue: rightParsed.red,
|
||||
progress,
|
||||
}),
|
||||
green: lerpNumber({
|
||||
leftValue: leftParsed.green,
|
||||
rightValue: rightParsed.green,
|
||||
progress,
|
||||
}),
|
||||
blue: lerpNumber({
|
||||
leftValue: leftParsed.blue,
|
||||
rightValue: rightParsed.blue,
|
||||
progress,
|
||||
}),
|
||||
alpha: lerpNumber({
|
||||
leftValue: leftParsed.alpha,
|
||||
rightValue: rightParsed.alpha,
|
||||
progress,
|
||||
}),
|
||||
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
|
||||
return {
|
||||
dt: Math.min(span, Math.max(0, handle.dt)),
|
||||
dv: handle.dv,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLeftHandle({
|
||||
handle,
|
||||
leftKey,
|
||||
rightKey,
|
||||
}: {
|
||||
handle: ScalarAnimationKey["leftHandle"];
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
}) {
|
||||
if (!handle) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
|
||||
return {
|
||||
dt: Math.max(-span, Math.min(0, handle.dt)),
|
||||
dv: handle.dv,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeScalarKey({
|
||||
key,
|
||||
}: {
|
||||
key: ScalarAnimationKey;
|
||||
}): ScalarAnimationKey {
|
||||
return {
|
||||
...key,
|
||||
tangentMode: key.tangentMode ?? "flat",
|
||||
segmentToNext: key.segmentToNext ?? "linear",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeScalarChannel({
|
||||
channel,
|
||||
}: {
|
||||
channel: ScalarAnimationChannel;
|
||||
}): ScalarAnimationChannel {
|
||||
const sortedKeys = [...channel.keys]
|
||||
.map((key) => normalizeScalarKey({ key }))
|
||||
.sort((leftKey, rightKey) =>
|
||||
byTimeAscending({
|
||||
leftTime: leftKey.time,
|
||||
rightTime: rightKey.time,
|
||||
}),
|
||||
);
|
||||
const nextKeys = sortedKeys.map((key, index) => {
|
||||
const previousKey = sortedKeys[index - 1];
|
||||
const nextKey = sortedKeys[index + 1];
|
||||
return {
|
||||
...key,
|
||||
leftHandle:
|
||||
previousKey != null
|
||||
? normalizeLeftHandle({
|
||||
handle: key.leftHandle,
|
||||
leftKey: previousKey,
|
||||
rightKey: key,
|
||||
})
|
||||
: undefined,
|
||||
rightHandle:
|
||||
nextKey != null
|
||||
? normalizeRightHandle({
|
||||
handle: key.rightHandle,
|
||||
leftKey: key,
|
||||
rightKey: nextKey,
|
||||
})
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...channel,
|
||||
keys: nextKeys,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeChannel<TChannel extends AnimationChannel>({
|
||||
@@ -159,9 +157,15 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
|
||||
}: {
|
||||
channel: TChannel;
|
||||
}): TChannel {
|
||||
if (channel.kind === "scalar") {
|
||||
return normalizeScalarChannel({
|
||||
channel,
|
||||
}) as TChannel;
|
||||
}
|
||||
|
||||
return {
|
||||
...channel,
|
||||
keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) =>
|
||||
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
|
||||
byTimeAscending({
|
||||
leftTime: leftKeyframe.time,
|
||||
rightTime: rightKeyframe.time,
|
||||
@@ -170,171 +174,152 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
|
||||
} as TChannel;
|
||||
}
|
||||
|
||||
function evaluateChannelValueAtTime<
|
||||
TKeyframe extends { time: number; value: TValue },
|
||||
TValue,
|
||||
>({
|
||||
keyframes,
|
||||
function extrapolateScalarEdge({
|
||||
mode,
|
||||
edgeKey,
|
||||
neighborKey,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue,
|
||||
}: {
|
||||
keyframes: TKeyframe[] | undefined;
|
||||
mode: "hold" | "linear";
|
||||
edgeKey: ScalarAnimationKey;
|
||||
neighborKey: ScalarAnimationKey | undefined;
|
||||
time: number;
|
||||
fallbackValue: TValue;
|
||||
getInterpolatedValue: ({
|
||||
leftKeyframe,
|
||||
rightKeyframe,
|
||||
progress,
|
||||
}: {
|
||||
leftKeyframe: TKeyframe;
|
||||
rightKeyframe: TKeyframe;
|
||||
progress: number;
|
||||
}) => TValue;
|
||||
}): TValue {
|
||||
if (!keyframes || keyframes.length === 0) {
|
||||
return fallbackValue;
|
||||
}) {
|
||||
if (mode === "hold" || !neighborKey) {
|
||||
return edgeKey.value;
|
||||
}
|
||||
|
||||
const firstKeyframe = keyframes[0];
|
||||
const lastKeyframe = keyframes[keyframes.length - 1];
|
||||
if (!firstKeyframe || !lastKeyframe) {
|
||||
return fallbackValue;
|
||||
const span = neighborKey.time - edgeKey.time;
|
||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
||||
return edgeKey.value;
|
||||
}
|
||||
|
||||
if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) {
|
||||
return firstKeyframe.value;
|
||||
}
|
||||
|
||||
if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) {
|
||||
return lastKeyframe.value;
|
||||
}
|
||||
|
||||
for (
|
||||
let keyframeIndex = 0;
|
||||
keyframeIndex < keyframes.length - 1;
|
||||
keyframeIndex++
|
||||
) {
|
||||
const leftKeyframe = keyframes[keyframeIndex];
|
||||
const rightKeyframe = keyframes[keyframeIndex + 1];
|
||||
|
||||
if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) {
|
||||
return rightKeyframe.value;
|
||||
}
|
||||
|
||||
const isBetweenPair = isWithinTimePair({
|
||||
time,
|
||||
leftTime: leftKeyframe.time,
|
||||
rightTime: rightKeyframe.time,
|
||||
});
|
||||
if (!isBetweenPair) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const span = rightKeyframe.time - leftKeyframe.time;
|
||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
||||
return rightKeyframe.value;
|
||||
}
|
||||
|
||||
const progress = clamp01({
|
||||
value: (time - leftKeyframe.time) / span,
|
||||
});
|
||||
|
||||
return getInterpolatedValue({
|
||||
leftKeyframe,
|
||||
rightKeyframe,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
|
||||
return lastKeyframe.value;
|
||||
return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
|
||||
}
|
||||
|
||||
export function getNumberChannelValueAtTime({
|
||||
export function getScalarSegmentInterpolation({
|
||||
segment,
|
||||
}: {
|
||||
segment: ScalarSegmentType;
|
||||
}): AnimationInterpolation {
|
||||
if (segment === "step") {
|
||||
return "hold";
|
||||
}
|
||||
|
||||
return segment === "bezier" ? "bezier" : "linear";
|
||||
}
|
||||
|
||||
export function getScalarChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: NumberAnimationChannel | undefined;
|
||||
channel: ScalarAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: number;
|
||||
}): number {
|
||||
return evaluateChannelValueAtTime({
|
||||
keyframes: channel?.keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
||||
if (leftKeyframe.interpolation === "hold") {
|
||||
return leftKeyframe.value;
|
||||
}
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const normalizedChannel = normalizeChannel({
|
||||
channel,
|
||||
});
|
||||
const firstKey = normalizedChannel.keys[0];
|
||||
const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
|
||||
if (!firstKey || !lastKey) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
if (time <= firstKey.time + TIME_EPSILON_SECONDS) {
|
||||
if (time < firstKey.time - TIME_EPSILON_SECONDS) {
|
||||
return extrapolateScalarEdge({
|
||||
mode: normalizedChannel.extrapolation?.before ?? "hold",
|
||||
edgeKey: firstKey,
|
||||
neighborKey: normalizedChannel.keys[1],
|
||||
time,
|
||||
});
|
||||
}
|
||||
|
||||
return firstKey.value;
|
||||
}
|
||||
|
||||
if (time >= lastKey.time - TIME_EPSILON_SECONDS) {
|
||||
if (time > lastKey.time + TIME_EPSILON_SECONDS) {
|
||||
return extrapolateScalarEdge({
|
||||
mode: normalizedChannel.extrapolation?.after ?? "hold",
|
||||
edgeKey: lastKey,
|
||||
neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2],
|
||||
time,
|
||||
});
|
||||
}
|
||||
|
||||
return lastKey.value;
|
||||
}
|
||||
|
||||
for (
|
||||
let keyIndex = 0;
|
||||
keyIndex < normalizedChannel.keys.length - 1;
|
||||
keyIndex++
|
||||
) {
|
||||
const leftKey = normalizedChannel.keys[keyIndex];
|
||||
const rightKey = normalizedChannel.keys[keyIndex + 1];
|
||||
if (Math.abs(time - rightKey.time) <= TIME_EPSILON_SECONDS) {
|
||||
return rightKey.value;
|
||||
}
|
||||
|
||||
if (
|
||||
!isWithinTimePair({
|
||||
time,
|
||||
leftTime: leftKey.time,
|
||||
rightTime: rightKey.time,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (leftKey.segmentToNext === "step") {
|
||||
return leftKey.value;
|
||||
}
|
||||
|
||||
const span = rightKey.time - leftKey.time;
|
||||
if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
|
||||
return rightKey.value;
|
||||
}
|
||||
|
||||
const progress = clamp01({
|
||||
value: (time - leftKey.time) / span,
|
||||
});
|
||||
if (leftKey.segmentToNext === "linear") {
|
||||
return lerpNumber({
|
||||
leftValue: leftKeyframe.value,
|
||||
rightValue: rightKeyframe.value,
|
||||
leftValue: leftKey.value,
|
||||
rightValue: rightKey.value,
|
||||
progress,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const curveProgress = solveBezierProgressForTime({
|
||||
time,
|
||||
leftKey,
|
||||
rightKey,
|
||||
});
|
||||
const rightHandle =
|
||||
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
|
||||
const leftHandle =
|
||||
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
|
||||
return getBezierPoint({
|
||||
progress: curveProgress,
|
||||
p0: leftKey.value,
|
||||
p1: leftKey.value + rightHandle.dv,
|
||||
p2: rightKey.value + leftHandle.dv,
|
||||
p3: rightKey.value,
|
||||
});
|
||||
}
|
||||
|
||||
return lastKey.value;
|
||||
}
|
||||
|
||||
export function getColorValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: ColorAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: string;
|
||||
}): string {
|
||||
return evaluateChannelValueAtTime({
|
||||
keyframes: channel?.keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
||||
if (leftKeyframe.interpolation === "hold") {
|
||||
return leftKeyframe.value;
|
||||
}
|
||||
|
||||
return interpolateColor({
|
||||
leftColor: leftKeyframe.value,
|
||||
rightColor: rightKeyframe.value,
|
||||
progress,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getVectorChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: VectorAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: VectorValue;
|
||||
}): VectorValue {
|
||||
return evaluateChannelValueAtTime({
|
||||
keyframes: channel?.keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
|
||||
if (leftKeyframe.interpolation === "hold") {
|
||||
return leftKeyframe.value;
|
||||
}
|
||||
|
||||
return {
|
||||
x:
|
||||
leftKeyframe.value.x +
|
||||
(rightKeyframe.value.x - leftKeyframe.value.x) * progress,
|
||||
y:
|
||||
leftKeyframe.value.y +
|
||||
(rightKeyframe.value.y - leftKeyframe.value.y) * progress,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getDiscreteValueAtTime({
|
||||
export function getDiscreteChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
@@ -343,12 +328,21 @@ function getDiscreteValueAtTime({
|
||||
time: number;
|
||||
fallbackValue: DiscreteValue;
|
||||
}): DiscreteValue {
|
||||
return evaluateChannelValueAtTime({
|
||||
keyframes: channel?.keyframes,
|
||||
time,
|
||||
fallbackValue,
|
||||
getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value,
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const normalizedChannel = normalizeChannel({
|
||||
channel,
|
||||
});
|
||||
let currentValue = fallbackValue;
|
||||
for (const key of normalizedChannel.keys) {
|
||||
if (time + TIME_EPSILON_SECONDS < key.time) {
|
||||
break;
|
||||
}
|
||||
currentValue = key.value;
|
||||
}
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
export function getChannelValueAtTime({
|
||||
@@ -360,50 +354,25 @@ export function getChannelValueAtTime({
|
||||
time: number;
|
||||
fallbackValue: AnimationValue;
|
||||
}): AnimationValue {
|
||||
if (!channel || channel.keyframes.length === 0) {
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
if (channel.valueKind === "number") {
|
||||
if (typeof fallbackValue !== "number") {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return getNumberChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
|
||||
if (channel.valueKind === "color") {
|
||||
if (typeof fallbackValue !== "string") {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return getColorValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
|
||||
if (channel.valueKind === "vector") {
|
||||
if (!isVectorValue(fallbackValue)) {
|
||||
return fallbackValue;
|
||||
}
|
||||
return getVectorChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue: fallbackValue as VectorValue,
|
||||
});
|
||||
if (channel.kind === "scalar") {
|
||||
return typeof fallbackValue === "number"
|
||||
? getScalarChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
})
|
||||
: fallbackValue;
|
||||
}
|
||||
|
||||
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return getDiscreteValueAtTime({
|
||||
return getDiscreteChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
|
||||
@@ -1,72 +1,298 @@
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import type {
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
ElementKeyframe,
|
||||
} from "@/lib/animation/types";
|
||||
import { isAnimationPath } from "./target-resolver";
|
||||
|
||||
export function getElementKeyframes({
|
||||
animations,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
}): ElementKeyframe[] {
|
||||
if (!animations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(animations.channels).flatMap(
|
||||
([propertyPath, channel]) => {
|
||||
if (
|
||||
!channel ||
|
||||
channel.keyframes.length === 0 ||
|
||||
!isAnimationPath(propertyPath)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return channel.keyframes.map((keyframe) => ({
|
||||
propertyPath,
|
||||
id: keyframe.id,
|
||||
time: keyframe.time,
|
||||
value: keyframe.value,
|
||||
interpolation: keyframe.interpolation,
|
||||
}));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
}): boolean {
|
||||
const channel = animations?.channels[propertyPath];
|
||||
return Boolean(channel && channel.keyframes.length > 0);
|
||||
}
|
||||
|
||||
export function getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
time: number;
|
||||
}): ElementKeyframe | null {
|
||||
const channel = animations?.channels[propertyPath];
|
||||
if (!channel || channel.keyframes.length === 0) return null;
|
||||
const keyframe = channel.keyframes.find(
|
||||
(keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
|
||||
);
|
||||
if (!keyframe) return null;
|
||||
return {
|
||||
propertyPath,
|
||||
id: keyframe.id,
|
||||
time: keyframe.time,
|
||||
value: keyframe.value,
|
||||
interpolation: keyframe.interpolation,
|
||||
};
|
||||
}
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import type {
|
||||
AnimationBindingInstance,
|
||||
AnimationChannel,
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
ElementKeyframe,
|
||||
} from "@/lib/animation/types";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
} from "./binding-values";
|
||||
import {
|
||||
getChannelValueAtTime,
|
||||
getScalarSegmentInterpolation,
|
||||
} from "./interpolation";
|
||||
import { isAnimationPath } from "./target-resolver";
|
||||
|
||||
function getBindingFallbackValue({
|
||||
channel,
|
||||
}: {
|
||||
channel: ElementAnimations["channels"][string];
|
||||
}) {
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return channel?.kind === "discrete" ? false : 0;
|
||||
}
|
||||
|
||||
return channel.keys[0].value;
|
||||
}
|
||||
|
||||
interface BindingKeyframeMatch {
|
||||
componentIndex: number;
|
||||
channel: AnimationChannel;
|
||||
keyframe: AnimationChannel["keys"][number];
|
||||
}
|
||||
|
||||
function getBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}: {
|
||||
animations: ElementAnimations;
|
||||
binding: AnimationBindingInstance;
|
||||
}): BindingKeyframeMatch[] {
|
||||
return binding.components.flatMap((component, componentIndex) => {
|
||||
const channel = animations.channels[component.channelId];
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return channel.keys.map((keyframe) => ({
|
||||
componentIndex,
|
||||
channel,
|
||||
keyframe,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function getUniqueBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}: {
|
||||
animations: ElementAnimations;
|
||||
binding: AnimationBindingInstance;
|
||||
}): BindingKeyframeMatch[] {
|
||||
const sortedMatches = getBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}).sort(
|
||||
(leftMatch, rightMatch) =>
|
||||
leftMatch.keyframe.time - rightMatch.keyframe.time ||
|
||||
leftMatch.componentIndex - rightMatch.componentIndex,
|
||||
);
|
||||
const uniqueMatches: BindingKeyframeMatch[] = [];
|
||||
|
||||
for (const match of sortedMatches) {
|
||||
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
|
||||
if (
|
||||
!previousMatch ||
|
||||
Math.abs(previousMatch.keyframe.time - match.keyframe.time) >
|
||||
TIME_EPSILON_SECONDS
|
||||
) {
|
||||
uniqueMatches.push(match);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
previousMatch.componentIndex !== 0 &&
|
||||
match.componentIndex === 0
|
||||
) {
|
||||
uniqueMatches[uniqueMatches.length - 1] = match;
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueMatches;
|
||||
}
|
||||
|
||||
function getPreferredBindingKeyframeMatch({
|
||||
matches,
|
||||
}: {
|
||||
matches: BindingKeyframeMatch[];
|
||||
}): BindingKeyframeMatch | null {
|
||||
return (
|
||||
matches.find((match) => match.componentIndex === 0) ??
|
||||
matches[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function getComposedBindingValueAtTime({
|
||||
animations,
|
||||
binding,
|
||||
time,
|
||||
}: {
|
||||
animations: ElementAnimations;
|
||||
binding: AnimationBindingInstance;
|
||||
time: number;
|
||||
}) {
|
||||
const componentValues = Object.fromEntries(
|
||||
binding.components.map((component) => {
|
||||
const channel = animations.channels[component.channelId];
|
||||
return [
|
||||
component.key,
|
||||
getChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue: getBindingFallbackValue({ channel }),
|
||||
}),
|
||||
];
|
||||
}),
|
||||
) as Record<string, AnimationComponentValue | undefined>;
|
||||
|
||||
return composeAnimationValue({
|
||||
binding,
|
||||
componentValues,
|
||||
});
|
||||
}
|
||||
|
||||
function getKeyframeInterpolation({
|
||||
channel,
|
||||
keyframe,
|
||||
}: {
|
||||
channel: AnimationChannel;
|
||||
keyframe: AnimationChannel["keys"][number];
|
||||
}) {
|
||||
return channel.kind === "scalar" && "segmentToNext" in keyframe
|
||||
? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
|
||||
: "hold";
|
||||
}
|
||||
|
||||
function toElementKeyframe({
|
||||
animations,
|
||||
binding,
|
||||
propertyPath,
|
||||
keyframeMatch,
|
||||
}: {
|
||||
animations: ElementAnimations;
|
||||
binding: AnimationBindingInstance;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeMatch: BindingKeyframeMatch;
|
||||
}): ElementKeyframe | null {
|
||||
const value = getComposedBindingValueAtTime({
|
||||
animations,
|
||||
binding,
|
||||
time: keyframeMatch.keyframe.time,
|
||||
});
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
propertyPath,
|
||||
id: keyframeMatch.keyframe.id,
|
||||
time: keyframeMatch.keyframe.time,
|
||||
value,
|
||||
interpolation: getKeyframeInterpolation({
|
||||
channel: keyframeMatch.channel,
|
||||
keyframe: keyframeMatch.keyframe,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getElementKeyframes({
|
||||
animations,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
}): ElementKeyframe[] {
|
||||
if (!animations) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(animations.bindings).flatMap(
|
||||
([propertyPath, binding]) => {
|
||||
if (!binding || !isAnimationPath(propertyPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getUniqueBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}).flatMap((keyframeMatch) => {
|
||||
const keyframe = toElementKeyframe({
|
||||
animations,
|
||||
binding,
|
||||
propertyPath,
|
||||
keyframeMatch,
|
||||
});
|
||||
if (!keyframe) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [keyframe];
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
}): boolean {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return binding.components.some((component) =>
|
||||
Boolean(animations?.channels[component.channelId]?.keys.length),
|
||||
);
|
||||
}
|
||||
|
||||
export function getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
time,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
time: number;
|
||||
}): ElementKeyframe | null {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyframeMatch = getPreferredBindingKeyframeMatch({
|
||||
matches: getBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}).filter(({ keyframe }) =>
|
||||
Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
|
||||
),
|
||||
});
|
||||
if (!keyframeMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toElementKeyframe({
|
||||
animations,
|
||||
binding,
|
||||
propertyPath,
|
||||
keyframeMatch,
|
||||
});
|
||||
}
|
||||
|
||||
export function getKeyframeById({
|
||||
animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
}): ElementKeyframe | null {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyframeMatch = getPreferredBindingKeyframeMatch({
|
||||
matches: getBindingKeyframeMatches({
|
||||
animations,
|
||||
binding,
|
||||
}).filter(({ keyframe }) => keyframe.id === keyframeId),
|
||||
});
|
||||
if (!keyframeMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toElementKeyframe({
|
||||
animations,
|
||||
binding,
|
||||
propertyPath,
|
||||
keyframeMatch,
|
||||
});
|
||||
}
|
||||
|
||||
+1038
-439
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
||||
import type {
|
||||
AnimationPropertyPath,
|
||||
ElementAnimations,
|
||||
NumberAnimationChannel,
|
||||
} from "@/lib/animation/types";
|
||||
|
||||
export function getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): NumberAnimationChannel | undefined {
|
||||
const channel = animations?.channels[propertyPath];
|
||||
if (!channel || channel.valueKind !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
@@ -1,369 +1,377 @@
|
||||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
AnimationValueKind,
|
||||
DiscreteValue,
|
||||
VectorValue,
|
||||
} from "@/lib/animation/types";
|
||||
import { isVectorValue } from "./vector-channel";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
} from "@/constants/text-constants";
|
||||
import {
|
||||
canElementHaveAudio,
|
||||
isVisualElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
|
||||
export interface NumericSpec {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export type NumericRange = NumericSpec;
|
||||
|
||||
export interface AnimationPropertyDefinition {
|
||||
valueKind: AnimationValueKind;
|
||||
defaultInterpolation: AnimationInterpolation;
|
||||
numericRange?: NumericSpec;
|
||||
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
||||
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
||||
setValue: ({
|
||||
element,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
value: AnimationValue;
|
||||
}) => TimelineElement;
|
||||
}
|
||||
|
||||
const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
AnimationPropertyPath,
|
||||
AnimationPropertyDefinition
|
||||
> = {
|
||||
"transform.position": {
|
||||
valueKind: "vector",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.position : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: value as VectorValue,
|
||||
},
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"transform.scaleX": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.scaleX : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, scaleX: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"transform.scaleY": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.scaleY : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, scaleY: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"transform.rotate": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: -360, max: 360, step: 1 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.rotate : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, rotate: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
opacity: {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: 0, max: 1, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.opacity : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? { ...element, opacity: value as number }
|
||||
: element,
|
||||
},
|
||||
volume: {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
|
||||
supportsElement: ({ element }) => canElementHaveAudio(element),
|
||||
getValue: ({ element }) =>
|
||||
canElementHaveAudio(element) ? element.volume ?? 0 : null,
|
||||
setValue: ({ element, value }) =>
|
||||
canElementHaveAudio(element)
|
||||
? { ...element, volume: value as number }
|
||||
: element,
|
||||
},
|
||||
color: {
|
||||
valueKind: "color",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? { ...element, color: value as string }
|
||||
: element,
|
||||
},
|
||||
"background.color": {
|
||||
valueKind: "color",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text" ? element.background.color : null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, color: value as string },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.paddingX": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: 0, step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, paddingX: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.paddingY": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: 0, step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, paddingY: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.offsetX": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, offsetX: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.offsetY": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, offsetY: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.cornerRadius": {
|
||||
valueKind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, cornerRadius: value as number },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
};
|
||||
|
||||
export function isAnimationPropertyPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPropertyPath {
|
||||
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
|
||||
}
|
||||
|
||||
export function getAnimationPropertyDefinition({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationPropertyDefinition {
|
||||
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
|
||||
}
|
||||
|
||||
export function supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): boolean {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.supportsElement({ element });
|
||||
}
|
||||
|
||||
export function getElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationValue | null {
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (!definition.supportsElement({ element })) {
|
||||
return null;
|
||||
}
|
||||
return definition.getValue({ element });
|
||||
}
|
||||
|
||||
export function withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): TimelineElement {
|
||||
const coercedValue = coerceAnimationValueForProperty({ propertyPath, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (!definition.supportsElement({ element })) {
|
||||
return element;
|
||||
}
|
||||
return definition.setValue({ element, value: coercedValue });
|
||||
}
|
||||
|
||||
export function getDefaultInterpolationForProperty({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationInterpolation {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.defaultInterpolation;
|
||||
}
|
||||
|
||||
function applyNumericSpec({
|
||||
value,
|
||||
numericRange,
|
||||
}: {
|
||||
value: number;
|
||||
numericRange: NumericSpec | undefined;
|
||||
}): number {
|
||||
if (!numericRange) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const steppedValue =
|
||||
numericRange.step != null
|
||||
? snapToStep({ value, step: numericRange.step })
|
||||
: value;
|
||||
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
|
||||
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
}
|
||||
|
||||
export function coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): AnimationValue | null {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
|
||||
if (propertyDefinition.valueKind === "number") {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return applyNumericSpec({
|
||||
value,
|
||||
numericRange: propertyDefinition.numericRange,
|
||||
});
|
||||
}
|
||||
|
||||
if (propertyDefinition.valueKind === "color") {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
if (propertyDefinition.valueKind === "vector") {
|
||||
return isVectorValue(value) ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value === "string" || typeof value === "boolean") {
|
||||
return value as DiscreteValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
import type {
|
||||
AnimationBindingKind,
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
VectorValue,
|
||||
} from "@/lib/animation/types";
|
||||
import { isVectorValue, parseColorToLinearRgba } from "./binding-values";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
|
||||
import {
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
} from "@/constants/text-constants";
|
||||
import {
|
||||
canElementHaveAudio,
|
||||
isVisualElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
|
||||
export interface NumericSpec {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface AnimationPropertyDefinition {
|
||||
kind: AnimationBindingKind;
|
||||
defaultInterpolation: AnimationInterpolation;
|
||||
numericRanges?: Partial<Record<string, NumericSpec>>;
|
||||
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
|
||||
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
|
||||
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
||||
setValue: ({
|
||||
element,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
value: AnimationValue;
|
||||
}) => TimelineElement;
|
||||
}
|
||||
|
||||
function applyNumericSpec({
|
||||
value,
|
||||
numericRange,
|
||||
}: {
|
||||
value: number;
|
||||
numericRange: NumericSpec | undefined;
|
||||
}): number {
|
||||
if (!numericRange) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const steppedValue =
|
||||
numericRange.step != null
|
||||
? snapToStep({ value, step: numericRange.step })
|
||||
: value;
|
||||
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
|
||||
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
}
|
||||
|
||||
function coerceNumberValue({
|
||||
value,
|
||||
numericRange,
|
||||
}: {
|
||||
value: AnimationValue;
|
||||
numericRange?: NumericSpec;
|
||||
}): number | null {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return applyNumericSpec({ value, numericRange });
|
||||
}
|
||||
|
||||
function coerceColorValue({
|
||||
value,
|
||||
}: {
|
||||
value: AnimationValue;
|
||||
}): string | null {
|
||||
return typeof value === "string" && parseColorToLinearRgba({ color: value })
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
function createNumberPropertyDefinition({
|
||||
numericRange,
|
||||
supportsElement,
|
||||
getValue,
|
||||
setValue,
|
||||
}: {
|
||||
numericRange?: NumericSpec;
|
||||
supportsElement: AnimationPropertyDefinition["supportsElement"];
|
||||
getValue: AnimationPropertyDefinition["getValue"];
|
||||
setValue: AnimationPropertyDefinition["setValue"];
|
||||
}): AnimationPropertyDefinition {
|
||||
return {
|
||||
kind: "number",
|
||||
defaultInterpolation: "linear",
|
||||
numericRanges: numericRange ? { value: numericRange } : undefined,
|
||||
supportsElement,
|
||||
getValue,
|
||||
coerceValue: ({ value }) =>
|
||||
coerceNumberValue({
|
||||
value,
|
||||
numericRange,
|
||||
}),
|
||||
setValue,
|
||||
};
|
||||
}
|
||||
|
||||
const ANIMATION_PROPERTY_REGISTRY: Record<
|
||||
AnimationPropertyPath,
|
||||
AnimationPropertyDefinition
|
||||
> = {
|
||||
"transform.position": {
|
||||
kind: "vector2",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.position : null,
|
||||
coerceValue: ({ value }) => (isVectorValue(value) ? value : null),
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: value as VectorValue,
|
||||
},
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"transform.scaleX": createNumberPropertyDefinition({
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.scaleX : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, scaleX: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"transform.scaleY": createNumberPropertyDefinition({
|
||||
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.scaleY : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, scaleY: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"transform.rotate": createNumberPropertyDefinition({
|
||||
numericRange: { min: -360, max: 360, step: 1 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.transform.rotate : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? {
|
||||
...element,
|
||||
transform: { ...element.transform, rotate: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
opacity: createNumberPropertyDefinition({
|
||||
numericRange: { min: 0, max: 1, step: 0.01 },
|
||||
supportsElement: ({ element }) => isVisualElement(element),
|
||||
getValue: ({ element }) =>
|
||||
isVisualElement(element) ? element.opacity : null,
|
||||
setValue: ({ element, value }) =>
|
||||
isVisualElement(element)
|
||||
? { ...element, opacity: value as number }
|
||||
: element,
|
||||
}),
|
||||
volume: createNumberPropertyDefinition({
|
||||
numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
|
||||
supportsElement: ({ element }) => canElementHaveAudio(element),
|
||||
getValue: ({ element }) =>
|
||||
canElementHaveAudio(element) ? element.volume ?? 0 : null,
|
||||
setValue: ({ element, value }) =>
|
||||
canElementHaveAudio(element)
|
||||
? { ...element, volume: value as number }
|
||||
: element,
|
||||
}),
|
||||
color: {
|
||||
kind: "color",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) => (element.type === "text" ? element.color : null),
|
||||
coerceValue: ({ value }) => coerceColorValue({ value }),
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? { ...element, color: value as string }
|
||||
: element,
|
||||
},
|
||||
"background.color": {
|
||||
kind: "color",
|
||||
defaultInterpolation: "linear",
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text" ? element.background.color : null,
|
||||
coerceValue: ({ value }) => coerceColorValue({ value }),
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, color: value as string },
|
||||
}
|
||||
: element,
|
||||
},
|
||||
"background.paddingX": createNumberPropertyDefinition({
|
||||
numericRange: { min: 0, step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, paddingX: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.paddingY": createNumberPropertyDefinition({
|
||||
numericRange: { min: 0, step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, paddingY: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.offsetX": createNumberPropertyDefinition({
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, offsetX: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.offsetY": createNumberPropertyDefinition({
|
||||
numericRange: { step: 1 },
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, offsetY: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
"background.cornerRadius": createNumberPropertyDefinition({
|
||||
numericRange: {
|
||||
min: CORNER_RADIUS_MIN,
|
||||
max: CORNER_RADIUS_MAX,
|
||||
step: 1,
|
||||
},
|
||||
supportsElement: ({ element }) => element.type === "text",
|
||||
getValue: ({ element }) =>
|
||||
element.type === "text"
|
||||
? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
|
||||
: null,
|
||||
setValue: ({ element, value }) =>
|
||||
element.type === "text"
|
||||
? {
|
||||
...element,
|
||||
background: { ...element.background, cornerRadius: value as number },
|
||||
}
|
||||
: element,
|
||||
}),
|
||||
};
|
||||
|
||||
export function isAnimationPropertyPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPropertyPath {
|
||||
return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
|
||||
}
|
||||
|
||||
export function getAnimationPropertyDefinition({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationPropertyDefinition {
|
||||
return ANIMATION_PROPERTY_REGISTRY[propertyPath];
|
||||
}
|
||||
|
||||
export function supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): boolean {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.supportsElement({ element });
|
||||
}
|
||||
|
||||
export function getElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationValue | null {
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
if (!definition.supportsElement({ element })) {
|
||||
return null;
|
||||
}
|
||||
return definition.getValue({ element });
|
||||
}
|
||||
|
||||
export function withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
value,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): TimelineElement {
|
||||
const definition = getAnimationPropertyDefinition({ propertyPath });
|
||||
const coercedValue = definition.coerceValue({ value });
|
||||
if (coercedValue === null || !definition.supportsElement({ element })) {
|
||||
return element;
|
||||
}
|
||||
return definition.setValue({ element, value: coercedValue });
|
||||
}
|
||||
|
||||
export function getDefaultInterpolationForProperty({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): AnimationInterpolation {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.defaultInterpolation;
|
||||
}
|
||||
|
||||
export function coerceAnimationValueForProperty({
|
||||
propertyPath,
|
||||
value,
|
||||
}: {
|
||||
propertyPath: AnimationPropertyPath;
|
||||
value: AnimationValue;
|
||||
}): AnimationValue | null {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
|
||||
return propertyDefinition.coerceValue({ value });
|
||||
}
|
||||
|
||||
@@ -1,135 +1,176 @@
|
||||
import type {
|
||||
AnimationPropertyPath,
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
import type { Transform } from "@/lib/rendering";
|
||||
import {
|
||||
getColorValueAtTime,
|
||||
getNumberChannelValueAtTime,
|
||||
getVectorChannelValueAtTime,
|
||||
} from "./interpolation";
|
||||
import { getColorChannelForPath } from "./color-channel";
|
||||
import { getNumberChannelForPath } from "./number-channel";
|
||||
import { getVectorChannelForPath } from "./vector-channel";
|
||||
|
||||
export function getElementLocalTime({
|
||||
timelineTime,
|
||||
elementStartTime,
|
||||
elementDuration,
|
||||
}: {
|
||||
timelineTime: number;
|
||||
elementStartTime: number;
|
||||
elementDuration: number;
|
||||
}): number {
|
||||
const localTime = timelineTime - elementStartTime;
|
||||
if (localTime <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (localTime >= elementDuration) {
|
||||
return elementDuration;
|
||||
}
|
||||
|
||||
return localTime;
|
||||
}
|
||||
|
||||
export function resolveTransformAtTime({
|
||||
baseTransform,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseTransform: Transform;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): Transform {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
return {
|
||||
position: getVectorChannelValueAtTime({
|
||||
channel: getVectorChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.position",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.position,
|
||||
}),
|
||||
scaleX: getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleX,
|
||||
}),
|
||||
scaleY: getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleY,
|
||||
}),
|
||||
rotate: getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
}),
|
||||
time: safeLocalTime,
|
||||
fallbackValue: baseTransform.rotate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOpacityAtTime({
|
||||
baseOpacity,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseOpacity: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({
|
||||
animations,
|
||||
propertyPath: "opacity",
|
||||
}),
|
||||
time: Math.max(0, localTime),
|
||||
fallbackValue: baseOpacity,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNumberAtTime({
|
||||
baseValue,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseValue: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return getNumberChannelValueAtTime({
|
||||
channel: getNumberChannelForPath({ animations, propertyPath }),
|
||||
time: Math.max(0, localTime),
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveColorAtTime({
|
||||
baseColor,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseColor: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
localTime: number;
|
||||
}): string {
|
||||
return getColorValueAtTime({
|
||||
channel: getColorChannelForPath({ animations, propertyPath }),
|
||||
time: Math.max(0, localTime),
|
||||
fallbackValue: baseColor,
|
||||
});
|
||||
}
|
||||
import type {
|
||||
AnimationColorPropertyPath,
|
||||
AnimationNumericPropertyPath,
|
||||
AnimationPath,
|
||||
AnimationPropertyPath,
|
||||
AnimationValueForPath,
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
import type { Transform } from "@/lib/rendering";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
decomposeAnimationValue,
|
||||
} from "./binding-values";
|
||||
import {
|
||||
getChannelValueAtTime,
|
||||
} from "./interpolation";
|
||||
|
||||
export function getElementLocalTime({
|
||||
timelineTime,
|
||||
elementStartTime,
|
||||
elementDuration,
|
||||
}: {
|
||||
timelineTime: number;
|
||||
elementStartTime: number;
|
||||
elementDuration: number;
|
||||
}): number {
|
||||
const localTime = timelineTime - elementStartTime;
|
||||
if (localTime <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (localTime >= elementDuration) {
|
||||
return elementDuration;
|
||||
}
|
||||
|
||||
return localTime;
|
||||
}
|
||||
|
||||
export function resolveTransformAtTime({
|
||||
baseTransform,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseTransform: Transform;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): Transform {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
return {
|
||||
position: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.position",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.position,
|
||||
}),
|
||||
scaleX: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleX,
|
||||
}),
|
||||
scaleY: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.scaleY,
|
||||
}),
|
||||
rotate: resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.rotate",
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: baseTransform.rotate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOpacityAtTime({
|
||||
baseOpacity,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
baseOpacity: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: "opacity",
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseOpacity,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveNumberAtTime({
|
||||
baseValue,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseValue: number;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationNumericPropertyPath;
|
||||
localTime: number;
|
||||
}): number {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveColorAtTime({
|
||||
baseColor,
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
}: {
|
||||
baseColor: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationColorPropertyPath;
|
||||
localTime: number;
|
||||
}): string {
|
||||
return resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: baseColor,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
|
||||
animations,
|
||||
propertyPath,
|
||||
localTime,
|
||||
fallbackValue,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: TPath;
|
||||
localTime: number;
|
||||
fallbackValue: AnimationValueForPath<TPath>;
|
||||
}): AnimationValueForPath<TPath> {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const fallbackComponents = decomposeAnimationValue({
|
||||
kind: binding.kind,
|
||||
value: fallbackValue,
|
||||
});
|
||||
if (!fallbackComponents) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const componentValues = Object.fromEntries(
|
||||
binding.components.map((component) => {
|
||||
const channel = animations?.channels[component.channelId];
|
||||
return [
|
||||
component.key,
|
||||
getChannelValueAtTime({
|
||||
channel,
|
||||
time: localTime,
|
||||
fallbackValue:
|
||||
fallbackComponents[component.key] ??
|
||||
(channel?.kind === "discrete" ? false : 0),
|
||||
}),
|
||||
];
|
||||
}),
|
||||
) as Record<string, AnimationComponentValue | undefined>;
|
||||
return (composeAnimationValue({
|
||||
binding,
|
||||
componentValues,
|
||||
}) ?? fallbackValue) as AnimationValueForPath<TPath>;
|
||||
}
|
||||
|
||||
@@ -1,277 +1,285 @@
|
||||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
AnimationValueKind,
|
||||
} from "@/lib/animation/types";
|
||||
import {
|
||||
parseEffectParamPath,
|
||||
isEffectParamPath,
|
||||
} from "@/lib/animation/effect-param-channel";
|
||||
import {
|
||||
isGraphicParamPath,
|
||||
parseGraphicParamPath,
|
||||
} from "@/lib/animation/graphic-param-channel";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
|
||||
import { getGraphicDefinition } from "@/lib/graphics";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
|
||||
export interface AnimationPathDescriptor {
|
||||
valueKind: AnimationValueKind;
|
||||
defaultInterpolation: AnimationInterpolation;
|
||||
numericRange?: NumericSpec;
|
||||
getBaseValue(): AnimationValue | null;
|
||||
setBaseValue(value: AnimationValue): TimelineElement;
|
||||
}
|
||||
|
||||
export function getParamValueKind({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): AnimationValueKind {
|
||||
if (param.type === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return "color";
|
||||
}
|
||||
|
||||
return "discrete";
|
||||
}
|
||||
|
||||
export function getParamDefaultInterpolation({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): AnimationInterpolation {
|
||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
||||
}
|
||||
|
||||
function getParamNumericRange({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): NumericSpec | undefined {
|
||||
if (param.type !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
step: param.step,
|
||||
};
|
||||
}
|
||||
|
||||
function coerceParamValue({
|
||||
param,
|
||||
value,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: AnimationValue;
|
||||
}): number | string | boolean | null {
|
||||
if (param.type === "number") {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steppedValue = snapToStep({ value, step: param.step });
|
||||
const minValue = param.min;
|
||||
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
if (param.type === "boolean") {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return param.options.some((option) => option.value === value) ? value : null;
|
||||
}
|
||||
|
||||
function buildGraphicParamDescriptor({
|
||||
element,
|
||||
paramKey,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
paramKey: string;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (element.type !== "graphic") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
||||
if (!param) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
valueKind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRange: getParamNumericRange({ param }),
|
||||
getBaseValue: () => element.params[param.key] ?? param.default,
|
||||
setBaseValue: (value) => {
|
||||
const coercedValue = coerceParamValue({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
params: {
|
||||
...element.params,
|
||||
[param.key]: coercedValue,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildEffectParamDescriptor({
|
||||
element,
|
||||
effectId,
|
||||
paramKey,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (!isVisualElement(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effect = element.effects?.find((candidate) => candidate.id === effectId);
|
||||
if (!effect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
registerDefaultEffects();
|
||||
const definition = effectsRegistry.get(effect.type);
|
||||
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
||||
if (!param) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
valueKind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRange: getParamNumericRange({ param }),
|
||||
getBaseValue: () => effect.params[param.key] ?? param.default,
|
||||
setBaseValue: (value) => {
|
||||
const coercedValue = coerceParamValue({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
effects:
|
||||
element.effects?.map((candidate) =>
|
||||
candidate.id !== effectId
|
||||
? candidate
|
||||
: {
|
||||
...candidate,
|
||||
params: {
|
||||
...candidate.params,
|
||||
[param.key]: coercedValue,
|
||||
},
|
||||
},
|
||||
) ?? element.effects,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isAnimationPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPath {
|
||||
return (
|
||||
isAnimationPropertyPath(propertyPath) ||
|
||||
isGraphicParamPath(propertyPath) ||
|
||||
isEffectParamPath(propertyPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAnimationTarget({
|
||||
element,
|
||||
path,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
path: AnimationPath;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (isAnimationPropertyPath(path)) {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({
|
||||
propertyPath: path,
|
||||
});
|
||||
if (!propertyDefinition.supportsElement({ element })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
valueKind: propertyDefinition.valueKind,
|
||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||
numericRange: propertyDefinition.numericRange,
|
||||
getBaseValue: () =>
|
||||
getElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath: path,
|
||||
}),
|
||||
setBaseValue: (value) => {
|
||||
const coercedValue = coerceAnimationValueForProperty({
|
||||
propertyPath: path,
|
||||
value,
|
||||
});
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath: path,
|
||||
value: coercedValue,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
|
||||
if (graphicParamTarget) {
|
||||
return buildGraphicParamDescriptor({
|
||||
element,
|
||||
paramKey: graphicParamTarget.paramKey,
|
||||
});
|
||||
}
|
||||
|
||||
const effectParamTarget = parseEffectParamPath({ propertyPath: path });
|
||||
if (effectParamTarget) {
|
||||
return buildEffectParamDescriptor({
|
||||
element,
|
||||
effectId: effectParamTarget.effectId,
|
||||
paramKey: effectParamTarget.paramKey,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
import type {
|
||||
AnimationBindingKind,
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
} from "@/lib/animation/types";
|
||||
import {
|
||||
parseEffectParamPath,
|
||||
isEffectParamPath,
|
||||
} from "@/lib/animation/effect-param-channel";
|
||||
import {
|
||||
isGraphicParamPath,
|
||||
parseGraphicParamPath,
|
||||
} from "@/lib/animation/graphic-param-channel";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
|
||||
import { getGraphicDefinition } from "@/lib/graphics";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import {
|
||||
coerceAnimationValueForProperty,
|
||||
getAnimationPropertyDefinition,
|
||||
getElementBaseValueForProperty,
|
||||
isAnimationPropertyPath,
|
||||
type NumericSpec,
|
||||
withElementBaseValueForProperty,
|
||||
} from "./property-registry";
|
||||
import { parseColorToLinearRgba } from "./binding-values";
|
||||
|
||||
export interface AnimationPathDescriptor {
|
||||
kind: AnimationBindingKind;
|
||||
defaultInterpolation: AnimationInterpolation;
|
||||
numericRanges?: Partial<Record<string, NumericSpec>>;
|
||||
coerceValue(value: AnimationValue): AnimationValue | null;
|
||||
getBaseValue(): AnimationValue | null;
|
||||
setBaseValue(value: AnimationValue): TimelineElement;
|
||||
}
|
||||
|
||||
export function getParamValueKind({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): AnimationBindingKind {
|
||||
if (param.type === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return "color";
|
||||
}
|
||||
|
||||
return "discrete";
|
||||
}
|
||||
|
||||
export function getParamDefaultInterpolation({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): AnimationInterpolation {
|
||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
||||
}
|
||||
|
||||
function getParamNumericRange({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): Partial<Record<string, NumericSpec>> | undefined {
|
||||
if (param.type !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
value: {
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
step: param.step,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function coerceAnimationValueForParam({
|
||||
param,
|
||||
value,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: AnimationValue;
|
||||
}): number | string | boolean | null {
|
||||
if (param.type === "number") {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steppedValue = snapToStep({ value, step: param.step });
|
||||
const minValue = param.min;
|
||||
const maxValue = param.max ?? Number.POSITIVE_INFINITY;
|
||||
return Math.min(maxValue, Math.max(minValue, steppedValue));
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
if (param.type === "boolean") {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return param.options.some((option) => option.value === value) ? value : null;
|
||||
}
|
||||
|
||||
function buildGraphicParamDescriptor({
|
||||
element,
|
||||
paramKey,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
paramKey: string;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (element.type !== "graphic") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = getGraphicDefinition({
|
||||
definitionId: element.definitionId,
|
||||
});
|
||||
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
||||
if (!param) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRanges: getParamNumericRange({ param }),
|
||||
coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
|
||||
getBaseValue: () => element.params[param.key] ?? param.default,
|
||||
setBaseValue: (value) => {
|
||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
params: {
|
||||
...element.params,
|
||||
[param.key]: coercedValue,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildEffectParamDescriptor({
|
||||
element,
|
||||
effectId,
|
||||
paramKey,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (!isVisualElement(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effect = element.effects?.find((candidate) => candidate.id === effectId);
|
||||
if (!effect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
registerDefaultEffects();
|
||||
const definition = effectsRegistry.get(effect.type);
|
||||
const param = definition.params.find((candidate) => candidate.key === paramKey);
|
||||
if (!param) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({ param }),
|
||||
numericRanges: getParamNumericRange({ param }),
|
||||
coerceValue: (value) => coerceAnimationValueForParam({ param, value }),
|
||||
getBaseValue: () => effect.params[param.key] ?? param.default,
|
||||
setBaseValue: (value) => {
|
||||
const coercedValue = coerceAnimationValueForParam({ param, value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
effects:
|
||||
element.effects?.map((candidate) =>
|
||||
candidate.id !== effectId
|
||||
? candidate
|
||||
: {
|
||||
...candidate,
|
||||
params: {
|
||||
...candidate.params,
|
||||
[param.key]: coercedValue,
|
||||
},
|
||||
},
|
||||
) ?? element.effects,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isAnimationPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPath {
|
||||
return (
|
||||
isAnimationPropertyPath(propertyPath) ||
|
||||
isGraphicParamPath(propertyPath) ||
|
||||
isEffectParamPath(propertyPath)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAnimationTarget({
|
||||
element,
|
||||
path,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
path: AnimationPath;
|
||||
}): AnimationPathDescriptor | null {
|
||||
if (isAnimationPropertyPath(path)) {
|
||||
const propertyDefinition = getAnimationPropertyDefinition({
|
||||
propertyPath: path,
|
||||
});
|
||||
if (!propertyDefinition.supportsElement({ element })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: propertyDefinition.kind,
|
||||
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
||||
numericRanges: propertyDefinition.numericRanges,
|
||||
coerceValue: (value) =>
|
||||
coerceAnimationValueForProperty({
|
||||
propertyPath: path,
|
||||
value,
|
||||
}),
|
||||
getBaseValue: () =>
|
||||
getElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath: path,
|
||||
}),
|
||||
setBaseValue: (value) => {
|
||||
const coercedValue = propertyDefinition.coerceValue({ value });
|
||||
if (coercedValue === null) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath: path,
|
||||
value: coercedValue,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
|
||||
if (graphicParamTarget) {
|
||||
return buildGraphicParamDescriptor({
|
||||
element,
|
||||
paramKey: graphicParamTarget.paramKey,
|
||||
});
|
||||
}
|
||||
|
||||
const effectParamTarget = parseEffectParamPath({ propertyPath: path });
|
||||
if (effectParamTarget) {
|
||||
return buildEffectParamDescriptor({
|
||||
element,
|
||||
effectId: effectParamTarget.effectId,
|
||||
paramKey: effectParamTarget.paramKey,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
+220
-119
@@ -1,119 +1,220 @@
|
||||
export const ANIMATION_PROPERTY_PATHS = [
|
||||
"transform.position",
|
||||
"transform.scaleX",
|
||||
"transform.scaleY",
|
||||
"transform.rotate",
|
||||
"opacity",
|
||||
"volume",
|
||||
"color",
|
||||
"background.color",
|
||||
"background.paddingX",
|
||||
"background.paddingY",
|
||||
"background.offsetX",
|
||||
"background.offsetY",
|
||||
"background.cornerRadius",
|
||||
] as const;
|
||||
|
||||
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
|
||||
export type GraphicParamPath = `params.${string}`;
|
||||
export type EffectParamPath = `effects.${string}.params.${string}`;
|
||||
export type AnimationPath =
|
||||
| AnimationPropertyPath
|
||||
| GraphicParamPath
|
||||
| EffectParamPath;
|
||||
|
||||
export const ANIMATION_PROPERTY_GROUPS = {
|
||||
"transform.scale": ["transform.scaleX", "transform.scaleY"],
|
||||
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
|
||||
|
||||
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
|
||||
|
||||
export type VectorValue = { x: number; y: number };
|
||||
|
||||
export type AnimationValueKind = "number" | "color" | "discrete" | "vector";
|
||||
export type DiscreteValue = boolean | string;
|
||||
export type AnimationValue = number | string | boolean | VectorValue;
|
||||
|
||||
export type ContinuousKeyframeInterpolation = "linear" | "hold";
|
||||
export type DiscreteKeyframeInterpolation = "hold";
|
||||
export type AnimationInterpolation =
|
||||
| ContinuousKeyframeInterpolation
|
||||
| DiscreteKeyframeInterpolation;
|
||||
|
||||
interface BaseAnimationKeyframe<
|
||||
TValue extends AnimationValue,
|
||||
TInterpolation extends AnimationInterpolation,
|
||||
> {
|
||||
id: string;
|
||||
time: number; // relative to element start time
|
||||
value: TValue;
|
||||
interpolation: TInterpolation;
|
||||
}
|
||||
|
||||
export interface NumberKeyframe
|
||||
extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {}
|
||||
|
||||
export interface ColorKeyframe
|
||||
extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {}
|
||||
|
||||
export interface DiscreteKeyframe
|
||||
extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
|
||||
|
||||
export interface VectorKeyframe
|
||||
extends BaseAnimationKeyframe<VectorValue, ContinuousKeyframeInterpolation> {}
|
||||
|
||||
export type AnimationKeyframe =
|
||||
| NumberKeyframe
|
||||
| ColorKeyframe
|
||||
| DiscreteKeyframe
|
||||
| VectorKeyframe;
|
||||
|
||||
interface BaseAnimationChannel<
|
||||
TValueKind extends AnimationValueKind,
|
||||
TKeyframe extends AnimationKeyframe,
|
||||
> {
|
||||
valueKind: TValueKind;
|
||||
keyframes: TKeyframe[];
|
||||
}
|
||||
|
||||
export interface NumberAnimationChannel
|
||||
extends BaseAnimationChannel<"number", NumberKeyframe> {}
|
||||
|
||||
export interface ColorAnimationChannel
|
||||
extends BaseAnimationChannel<"color", ColorKeyframe> {}
|
||||
|
||||
export interface DiscreteAnimationChannel
|
||||
extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
|
||||
|
||||
export interface VectorAnimationChannel
|
||||
extends BaseAnimationChannel<"vector", VectorKeyframe> {}
|
||||
|
||||
export type AnimationChannel =
|
||||
| NumberAnimationChannel
|
||||
| ColorAnimationChannel
|
||||
| DiscreteAnimationChannel
|
||||
| VectorAnimationChannel;
|
||||
|
||||
export type ElementAnimationChannelMap = Record<
|
||||
string,
|
||||
AnimationChannel | undefined
|
||||
>;
|
||||
|
||||
export interface ElementAnimations {
|
||||
channels: ElementAnimationChannelMap;
|
||||
}
|
||||
|
||||
export interface ElementKeyframe {
|
||||
propertyPath: AnimationPath;
|
||||
id: string;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
}
|
||||
|
||||
export interface SelectedKeyframeRef {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
}
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
|
||||
export const ANIMATION_PROPERTY_PATHS = [
|
||||
"transform.position",
|
||||
"transform.scaleX",
|
||||
"transform.scaleY",
|
||||
"transform.rotate",
|
||||
"opacity",
|
||||
"volume",
|
||||
"color",
|
||||
"background.color",
|
||||
"background.paddingX",
|
||||
"background.paddingY",
|
||||
"background.offsetX",
|
||||
"background.offsetY",
|
||||
"background.cornerRadius",
|
||||
] as const;
|
||||
|
||||
export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
|
||||
export type GraphicParamPath = `params.${string}`;
|
||||
export type EffectParamPath = `effects.${string}.params.${string}`;
|
||||
export type AnimationPath =
|
||||
| AnimationPropertyPath
|
||||
| GraphicParamPath
|
||||
| EffectParamPath;
|
||||
|
||||
export const ANIMATION_PROPERTY_GROUPS = {
|
||||
"transform.scale": ["transform.scaleX", "transform.scaleY"],
|
||||
} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
|
||||
|
||||
export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
|
||||
|
||||
export type VectorValue = { x: number; y: number };
|
||||
export type DiscreteValue = boolean | string;
|
||||
export type AnimationValue = number | string | boolean | VectorValue;
|
||||
export interface AnimationPropertyValueMap {
|
||||
"transform.position": VectorValue;
|
||||
"transform.scaleX": number;
|
||||
"transform.scaleY": number;
|
||||
"transform.rotate": number;
|
||||
opacity: number;
|
||||
volume: number;
|
||||
color: string;
|
||||
"background.color": string;
|
||||
"background.paddingX": number;
|
||||
"background.paddingY": number;
|
||||
"background.offsetX": number;
|
||||
"background.offsetY": number;
|
||||
"background.cornerRadius": number;
|
||||
}
|
||||
export type DynamicAnimationPathValue = ParamValues[string];
|
||||
export type AnimationValueForPath<TPath extends AnimationPath> =
|
||||
TPath extends AnimationPropertyPath
|
||||
? AnimationPropertyValueMap[TPath]
|
||||
: TPath extends GraphicParamPath | EffectParamPath
|
||||
? DynamicAnimationPathValue
|
||||
: never;
|
||||
export type AnimationNumericPropertyPath = {
|
||||
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
|
||||
}[AnimationPropertyPath];
|
||||
export type AnimationColorPropertyPath = {
|
||||
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
|
||||
}[AnimationPropertyPath];
|
||||
|
||||
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
|
||||
export type DiscreteKeyframeInterpolation = "hold";
|
||||
export type AnimationInterpolation =
|
||||
| ContinuousKeyframeInterpolation
|
||||
| DiscreteKeyframeInterpolation;
|
||||
|
||||
export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
|
||||
export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
|
||||
export type ScalarSegmentType = "step" | "linear" | "bezier";
|
||||
export type TangentMode = "auto" | "aligned" | "broken" | "flat";
|
||||
export type ChannelExtrapolationMode = "hold" | "linear";
|
||||
|
||||
export interface CurveHandle {
|
||||
dt: number;
|
||||
dv: number;
|
||||
}
|
||||
|
||||
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
|
||||
id: string;
|
||||
time: number; // relative to element start time
|
||||
value: TValue;
|
||||
}
|
||||
|
||||
export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
|
||||
leftHandle?: CurveHandle;
|
||||
rightHandle?: CurveHandle;
|
||||
segmentToNext: ScalarSegmentType;
|
||||
tangentMode: TangentMode;
|
||||
}
|
||||
|
||||
export interface DiscreteAnimationKey
|
||||
extends BaseAnimationKeyframe<DiscreteValue> {}
|
||||
|
||||
export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
|
||||
|
||||
export interface ScalarAnimationChannel {
|
||||
kind: "scalar";
|
||||
keys: ScalarAnimationKey[];
|
||||
extrapolation?: {
|
||||
before: ChannelExtrapolationMode;
|
||||
after: ChannelExtrapolationMode;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiscreteAnimationChannel {
|
||||
kind: "discrete";
|
||||
keys: DiscreteAnimationKey[];
|
||||
}
|
||||
|
||||
export type AnimationChannel =
|
||||
| ScalarAnimationChannel
|
||||
| DiscreteAnimationChannel;
|
||||
|
||||
export type ElementAnimationChannelMap = Record<
|
||||
string,
|
||||
AnimationChannel | undefined
|
||||
>;
|
||||
|
||||
export interface AnimationBindingComponent<TKey extends string = string> {
|
||||
key: TKey;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
interface BaseAnimationBinding<
|
||||
TKind extends AnimationBindingKind,
|
||||
TComponentKey extends string,
|
||||
> {
|
||||
path: AnimationPath;
|
||||
kind: TKind;
|
||||
components: AnimationBindingComponent<TComponentKey>[];
|
||||
}
|
||||
|
||||
export interface NumberAnimationBinding
|
||||
extends BaseAnimationBinding<"number", "value"> {}
|
||||
|
||||
export interface Vector2AnimationBinding
|
||||
extends BaseAnimationBinding<"vector2", "x" | "y"> {}
|
||||
|
||||
export interface ColorAnimationBinding
|
||||
extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> {
|
||||
colorSpace: "srgb-linear";
|
||||
}
|
||||
|
||||
export interface DiscreteAnimationBinding
|
||||
extends BaseAnimationBinding<"discrete", "value"> {}
|
||||
|
||||
export type AnimationBindingInstance =
|
||||
| NumberAnimationBinding
|
||||
| Vector2AnimationBinding
|
||||
| ColorAnimationBinding
|
||||
| DiscreteAnimationBinding;
|
||||
|
||||
export interface AnimationBindingByKind {
|
||||
number: NumberAnimationBinding;
|
||||
vector2: Vector2AnimationBinding;
|
||||
color: ColorAnimationBinding;
|
||||
discrete: DiscreteAnimationBinding;
|
||||
}
|
||||
|
||||
export type AnimationBindingOfKind<TKind extends AnimationBindingKind> =
|
||||
AnimationBindingByKind[TKind];
|
||||
|
||||
export type ElementAnimationBindingMap = Record<
|
||||
string,
|
||||
AnimationBindingInstance | undefined
|
||||
>;
|
||||
|
||||
export interface ElementAnimations {
|
||||
bindings: ElementAnimationBindingMap;
|
||||
channels: ElementAnimationChannelMap;
|
||||
}
|
||||
|
||||
export type NormalizedCubicBezier = [number, number, number, number];
|
||||
|
||||
export interface ScalarGraphChannelTarget {
|
||||
propertyPath: AnimationPath;
|
||||
componentKey: string;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
|
||||
channel: ScalarAnimationChannel;
|
||||
}
|
||||
|
||||
export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget {
|
||||
keyframeId: string;
|
||||
}
|
||||
|
||||
export interface ScalarGraphKeyframeContext extends ScalarGraphChannel {
|
||||
keyframe: ScalarAnimationKey;
|
||||
keyframeIndex: number;
|
||||
previousKey: ScalarAnimationKey | null;
|
||||
nextKey: ScalarAnimationKey | null;
|
||||
}
|
||||
|
||||
export interface ScalarCurveKeyframePatch {
|
||||
leftHandle?: CurveHandle | null;
|
||||
rightHandle?: CurveHandle | null;
|
||||
segmentToNext?: ScalarSegmentType;
|
||||
tangentMode?: TangentMode;
|
||||
}
|
||||
|
||||
export interface ElementKeyframe {
|
||||
propertyPath: AnimationPath;
|
||||
id: string;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
}
|
||||
|
||||
export interface SelectedKeyframeRef {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import type {
|
||||
AnimationPropertyPath,
|
||||
ElementAnimations,
|
||||
VectorAnimationChannel,
|
||||
VectorValue,
|
||||
} from "@/lib/animation/types";
|
||||
|
||||
export function isVectorValue(value: unknown): value is VectorValue {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"x" in value &&
|
||||
"y" in value &&
|
||||
typeof (value as VectorValue).x === "number" &&
|
||||
typeof (value as VectorValue).y === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function getVectorChannelForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): VectorAnimationChannel | undefined {
|
||||
const channel = animations?.channels[propertyPath];
|
||||
if (!channel || channel.valueKind !== "vector") {
|
||||
return undefined;
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
export function getVectorChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: VectorAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: VectorValue;
|
||||
}): VectorValue {
|
||||
if (!channel || channel.keyframes.length === 0) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const keyframes = [...channel.keyframes].sort((a, b) => a.time - b.time);
|
||||
const first = keyframes[0];
|
||||
const last = keyframes[keyframes.length - 1];
|
||||
|
||||
if (!first || !last) return fallbackValue;
|
||||
if (time <= first.time) return first.value;
|
||||
if (time >= last.time) return last.value;
|
||||
|
||||
for (let i = 0; i < keyframes.length - 1; i++) {
|
||||
const left = keyframes[i];
|
||||
const right = keyframes[i + 1];
|
||||
if (time < left.time || time > right.time) continue;
|
||||
|
||||
if (left.interpolation === "hold") return left.value;
|
||||
|
||||
const span = right.time - left.time;
|
||||
if (span === 0) return right.value;
|
||||
|
||||
const t = (time - left.time) / span;
|
||||
return {
|
||||
x: left.value.x + (right.value.x - left.value.x) * t,
|
||||
y: left.value.y + (right.value.y - left.value.y) * t,
|
||||
};
|
||||
}
|
||||
|
||||
return last.value;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{
|
||||
label: string;
|
||||
value: number;
|
||||
}> = [
|
||||
{ label: "Light", value: 10 },
|
||||
{ label: "Medium", value: 50 },
|
||||
{ label: "Heavy", value: 100 },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
|
||||
export const DEFAULT_BACKGROUND_COLOR = "#000000";
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { TCanvasSize } from "@/lib/project/types";
|
||||
|
||||
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
|
||||
{ width: 1920, height: 1080 },
|
||||
{ width: 1080, height: 1920 },
|
||||
{ width: 1080, height: 1080 },
|
||||
{ width: 1440, height: 1080 },
|
||||
];
|
||||
|
||||
export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { toast } from "sonner";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
||||
import { getHighestImportedVideoFps } from "@/lib/project/fps";
|
||||
import { getHighestImportedVideoFps } from "@/lib/fps/utils";
|
||||
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
|
||||
|
||||
export class AddMediaAssetCommand extends Command {
|
||||
@@ -23,7 +23,7 @@ export class AddMediaAssetCommand extends Command {
|
||||
this.assetId = generateUUID();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedAssets = [...editor.media.getAssets()];
|
||||
|
||||
@@ -80,6 +80,8 @@ export class AddMediaAssetCommand extends Command {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
@@ -18,7 +18,7 @@ export class RemoveMediaAssetCommand extends Command {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const assets = editor.media.getAssets();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TProject, TProjectSettings } from "@/lib/project/types";
|
||||
|
||||
@@ -10,7 +10,7 @@ export class UpdateProjectSettingsCommand extends Command {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { buildDefaultScene } from "@/lib/scenes";
|
||||
|
||||
export class CreateSceneCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
private createdScene: TScene | null = null;
|
||||
|
||||
constructor(
|
||||
private name: string,
|
||||
private isMain: boolean = false,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedScenes = [...editor.scenes.getScenes()];
|
||||
|
||||
this.createdScene = buildDefaultScene({
|
||||
name: this.name,
|
||||
isMain: this.isMain,
|
||||
});
|
||||
|
||||
const updatedScenes = [...this.savedScenes, this.createdScene];
|
||||
editor.scenes.setScenes({ scenes: updatedScenes });
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedScenes) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.scenes.setScenes({ scenes: this.savedScenes });
|
||||
}
|
||||
}
|
||||
|
||||
getSceneId(): string {
|
||||
return this.createdScene?.id ?? "";
|
||||
}
|
||||
}
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { buildDefaultScene } from "@/lib/scenes";
|
||||
|
||||
export class CreateSceneCommand extends Command {
|
||||
private savedScenes: TScene[] | null = null;
|
||||
private createdScene: TScene | null = null;
|
||||
|
||||
constructor(
|
||||
private name: string,
|
||||
private isMain: boolean = false,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedScenes = [...editor.scenes.getScenes()];
|
||||
|
||||
this.createdScene = buildDefaultScene({
|
||||
name: this.name,
|
||||
isMain: this.isMain,
|
||||
});
|
||||
|
||||
const updatedScenes = [...this.savedScenes, this.createdScene];
|
||||
editor.scenes.setScenes({ scenes: updatedScenes });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedScenes) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.scenes.setScenes({ scenes: this.savedScenes });
|
||||
}
|
||||
}
|
||||
|
||||
getSceneId(): string {
|
||||
return this.createdScene?.id ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
|
||||
@@ -12,7 +12,7 @@ export class DeleteSceneCommand extends Command {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const scenes = editor.scenes.getScenes();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { updateSceneInArray } from "@/lib/scenes";
|
||||
@@ -14,7 +14,7 @@ export class MoveBookmarkCommand extends Command {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { updateSceneInArray } from "@/lib/scenes";
|
||||
@@ -15,7 +15,7 @@ export class RemoveBookmarkCommand extends Command {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { updateSceneInArray } from "@/lib/scenes";
|
||||
@@ -14,7 +14,7 @@ export class RenameSceneCommand extends Command {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const scenes = editor.scenes.getScenes();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { updateSceneInArray } from "@/lib/scenes";
|
||||
@@ -12,7 +12,7 @@ export class ToggleBookmarkCommand extends Command {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { Bookmark, TScene } from "@/lib/timeline";
|
||||
import { updateSceneInArray } from "@/lib/scenes";
|
||||
@@ -14,7 +14,7 @@ export class UpdateBookmarkCommand extends Command {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
@@ -33,7 +33,7 @@ export class PasteCommand extends Command {
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
if (this.clipboardItems.length === 0) return;
|
||||
if (this.clipboardItems.length === 0) return undefined;
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
@@ -123,6 +123,7 @@ export class PasteCommand extends Command {
|
||||
if (this.pastedElements.length > 0) {
|
||||
return { select: this.pastedElements };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
|
||||
@@ -20,7 +20,7 @@ export class DeleteElementsCommand extends Command {
|
||||
this.rippleEnabled = rippleEnabled;
|
||||
}
|
||||
|
||||
execute(): CommandResult {
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ export class DuplicateElementsCommand extends Command {
|
||||
select: this.duplicatedElements,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
|
||||
@@ -1,74 +1,75 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
||||
import { buildDefaultEffectInstance } from "@/lib/effects";
|
||||
|
||||
function addEffectToElement({
|
||||
element,
|
||||
effectType,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
effectType: string;
|
||||
}): VisualElement {
|
||||
const instance = buildDefaultEffectInstance({ effectType });
|
||||
const currentEffects = element.effects ?? [];
|
||||
return { ...element, effects: [...currentEffects, instance] };
|
||||
}
|
||||
|
||||
export class AddClipEffectCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private effectId: string | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly effectType: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
effectType,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectType: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.effectType = effectType;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
const updated = addEffectToElement({
|
||||
element: element as VisualElement,
|
||||
effectType: this.effectType,
|
||||
});
|
||||
const effects = updated.effects ?? [];
|
||||
this.effectId = effects[effects.length - 1]?.id ?? null;
|
||||
return updated;
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
|
||||
getEffectId(): string | null {
|
||||
return this.effectId;
|
||||
}
|
||||
}
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
||||
import { buildDefaultEffectInstance } from "@/lib/effects";
|
||||
|
||||
function addEffectToElement({
|
||||
element,
|
||||
effectType,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
effectType: string;
|
||||
}): VisualElement {
|
||||
const instance = buildDefaultEffectInstance({ effectType });
|
||||
const currentEffects = element.effects ?? [];
|
||||
return { ...element, effects: [...currentEffects, instance] };
|
||||
}
|
||||
|
||||
export class AddClipEffectCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private effectId: string | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly effectType: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
effectType,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectType: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.effectType = effectType;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
const updated = addEffectToElement({
|
||||
element: element as VisualElement,
|
||||
effectType: this.effectType,
|
||||
});
|
||||
const effects = updated.effects ?? [];
|
||||
this.effectId = effects[effects.length - 1]?.id ?? null;
|
||||
return updated;
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
|
||||
getEffectId(): string | null {
|
||||
return this.effectId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,66 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
||||
|
||||
function removeEffectFromElement({
|
||||
element,
|
||||
effectId,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
effectId: string;
|
||||
}): VisualElement {
|
||||
const currentEffects = element.effects ?? [];
|
||||
const filtered = currentEffects.filter((effect) => effect.id !== effectId);
|
||||
return { ...element, effects: filtered };
|
||||
}
|
||||
|
||||
export class RemoveClipEffectCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly effectId: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.effectId = effectId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
return removeEffectFromElement({
|
||||
element: element as VisualElement,
|
||||
effectId: this.effectId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
||||
|
||||
function removeEffectFromElement({
|
||||
element,
|
||||
effectId,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
effectId: string;
|
||||
}): VisualElement {
|
||||
const currentEffects = element.effects ?? [];
|
||||
const filtered = currentEffects.filter((effect) => effect.id !== effectId);
|
||||
return { ...element, effects: filtered };
|
||||
}
|
||||
|
||||
export class RemoveClipEffectCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly effectId: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.effectId = effectId;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
return removeEffectFromElement({
|
||||
element: element as VisualElement,
|
||||
effectId: this.effectId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +1,74 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
||||
|
||||
function reorderEffectsOnElement({
|
||||
element,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
fromIndex: number;
|
||||
toIndex: number;
|
||||
}): VisualElement {
|
||||
const effects = [...(element.effects ?? [])];
|
||||
const [moved] = effects.splice(fromIndex, 1);
|
||||
effects.splice(toIndex, 0, moved);
|
||||
return { ...element, effects };
|
||||
}
|
||||
|
||||
export class ReorderClipEffectsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly fromIndex: number;
|
||||
private readonly toIndex: number;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
fromIndex: number;
|
||||
toIndex: number;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.fromIndex = fromIndex;
|
||||
this.toIndex = toIndex;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
return reorderEffectsOnElement({
|
||||
element: element as VisualElement,
|
||||
fromIndex: this.fromIndex,
|
||||
toIndex: this.toIndex,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
||||
|
||||
function reorderEffectsOnElement({
|
||||
element,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
fromIndex: number;
|
||||
toIndex: number;
|
||||
}): VisualElement {
|
||||
const effects = [...(element.effects ?? [])];
|
||||
const [moved] = effects.splice(fromIndex, 1);
|
||||
effects.splice(toIndex, 0, moved);
|
||||
return { ...element, effects };
|
||||
}
|
||||
|
||||
export class ReorderClipEffectsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly fromIndex: number;
|
||||
private readonly toIndex: number;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
fromIndex,
|
||||
toIndex,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
fromIndex: number;
|
||||
toIndex: number;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.fromIndex = fromIndex;
|
||||
this.toIndex = toIndex;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
return reorderEffectsOnElement({
|
||||
element: element as VisualElement,
|
||||
fromIndex: this.fromIndex,
|
||||
toIndex: this.toIndex,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,68 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
||||
|
||||
export function toggleEffectOnElement({
|
||||
element,
|
||||
effectId,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
effectId: string;
|
||||
}): VisualElement {
|
||||
const currentEffects = element.effects ?? [];
|
||||
const updated = currentEffects.map((effect) =>
|
||||
effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect,
|
||||
);
|
||||
return { ...element, effects: updated };
|
||||
}
|
||||
|
||||
export class ToggleClipEffectCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly effectId: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.effectId = effectId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
return toggleEffectOnElement({
|
||||
element: element as VisualElement,
|
||||
effectId: this.effectId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
||||
|
||||
export function toggleEffectOnElement({
|
||||
element,
|
||||
effectId,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
effectId: string;
|
||||
}): VisualElement {
|
||||
const currentEffects = element.effects ?? [];
|
||||
const updated = currentEffects.map((effect) =>
|
||||
effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect,
|
||||
);
|
||||
return { ...element, effects: updated };
|
||||
}
|
||||
|
||||
export class ToggleClipEffectCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly effectId: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
effectId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.effectId = effectId;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
return toggleEffectOnElement({
|
||||
element: element as VisualElement,
|
||||
effectId: this.effectId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { Command, type CommandResult } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
@@ -56,7 +56,7 @@ export class UpdateClipEffectParamsCommand extends Command {
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
@@ -75,6 +75,7 @@ export class UpdateClipEffectParamsCommand extends Command {
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
|
||||
@@ -8,7 +8,9 @@ export { SplitElementsCommand } from "./split-elements";
|
||||
export { UpdateElementCommand } from "./update-element";
|
||||
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
|
||||
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
|
||||
export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation";
|
||||
export { MoveElementCommand } from "./move-elements";
|
||||
|
||||
export * from "./keyframes";
|
||||
export * from "./effects";
|
||||
export * from "./masks";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user