mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
301e89df89 |
@@ -45,9 +45,10 @@ 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:** Web development uses the published `opencut-wasm` package by default, so a fresh clone does not need a local WASM build.
|
||||
> **Note:** If you see an error like `Unsupported URL Type "workspace:*"` when running `npm install`, you have two options:
|
||||
>
|
||||
> 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`.
|
||||
> 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**.
|
||||
|
||||
### Desktop setup
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ 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
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
*.json
|
||||
*.svg
|
||||
*.xml
|
||||
*.lock
|
||||
**/dist/**
|
||||
**/build/**
|
||||
**/node_modules/**
|
||||
@@ -1,19 +1,13 @@
|
||||
# Agents.md
|
||||
|
||||
## Architecture
|
||||
## Apps
|
||||
|
||||
An ongoing migration is moving all business logic into `rust/`. Each app under `apps/` is a UI shell — it owns rendering, interaction, and platform-specific concerns, but never owns logic. The UI framework for any given app is a replaceable detail.
|
||||
- Web
|
||||
- Desktop
|
||||
|
||||
### `rust/`
|
||||
## Rust
|
||||
|
||||
The single source of truth for all non-UI code. Everything platform-agnostic belongs here: no components, no hooks, no framework imports.
|
||||
|
||||
### `apps/`
|
||||
|
||||
Each app is a frontend that calls into Rust. Logic is never duplicated between apps — only UI is, because each platform may use an entirely different framework and language to build it.
|
||||
|
||||
- `web/` — Next.js
|
||||
- `desktop/` — GPUI
|
||||
Shared code between apps live in `rust/`, not in `packages/`
|
||||
|
||||
## Web
|
||||
|
||||
@@ -21,17 +15,3 @@ Each app is a frontend that calls into Rust. Logic is never duplicated between a
|
||||
|
||||
- Read components before using them. They may already apply classes, which affects what you need to pass and how to override them.
|
||||
|
||||
### TypeScript
|
||||
|
||||
Function signatures should make the call site readable and let the function evolve without breaking callers. Positional parameters fail both: `formatTime(30, 24)` hides which number is which, and adding, removing, or reordering an argument silently breaks every caller whose types happen to still line up. A single destructured object fixes both at once - each argument names itself at the call site, and the shape can grow without churn. So signatures default to one object parameter:
|
||||
|
||||
```tsx
|
||||
// ❌ meaning depends on order; the shape can't evolve without touching every caller
|
||||
function formatTime(seconds: number, fps: number) { ... }
|
||||
|
||||
// ✅ each argument names itself; fields can be added, reordered, or made optional freely
|
||||
function formatTime({ seconds, fps }: { seconds: number; fps: number }) { ... }
|
||||
```
|
||||
|
||||
The one real exception is type predicates (`element is VideoElement`) — the language requires a positional subject, so the reasoning above doesn't get to apply.
|
||||
|
||||
|
||||
Generated
+31
-628
@@ -60,21 +60,6 @@ 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"
|
||||
@@ -151,7 +136,7 @@ checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"raw-window-handle",
|
||||
"raw-window-metal 0.4.0",
|
||||
"raw-window-metal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -517,16 +502,7 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"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",
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -535,12 +511,6 @@ 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"
|
||||
@@ -578,8 +548,8 @@ dependencies = [
|
||||
"ash-window",
|
||||
"bitflags 2.11.0",
|
||||
"bytemuck",
|
||||
"codespan-reporting 0.12.0",
|
||||
"glow 0.16.0",
|
||||
"codespan-reporting",
|
||||
"glow",
|
||||
"gpu-alloc",
|
||||
"gpu-alloc-ash",
|
||||
"hidden-trait",
|
||||
@@ -588,7 +558,7 @@ dependencies = [
|
||||
"libloading",
|
||||
"log",
|
||||
"mint",
|
||||
"naga 25.0.1",
|
||||
"naga",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
@@ -928,17 +898,6 @@ 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"
|
||||
@@ -955,18 +914,6 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compositor"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"effects",
|
||||
"gpu",
|
||||
"masks",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.37"
|
||||
@@ -994,16 +941,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
@@ -1395,15 +1332,6 @@ 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"
|
||||
@@ -1449,16 +1377,6 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "effects"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"gpu",
|
||||
"thiserror 2.0.18",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -1747,12 +1665,6 @@ 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"
|
||||
@@ -2042,17 +1954,6 @@ 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"
|
||||
@@ -2109,38 +2010,6 @@ 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",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-alloc"
|
||||
version = "0.6.0"
|
||||
@@ -2171,40 +2040,6 @@ 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.62.2",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -2256,7 +2091,7 @@ dependencies = [
|
||||
"log",
|
||||
"lyon",
|
||||
"metal",
|
||||
"naga 25.0.1",
|
||||
"naga",
|
||||
"num_cpus",
|
||||
"objc",
|
||||
"oo7",
|
||||
@@ -2291,7 +2126,7 @@ dependencies = [
|
||||
"wayland-protocols-plasma",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-numerics 0.2.0",
|
||||
"windows-numerics",
|
||||
"windows-registry 0.5.3",
|
||||
"x11-clipboard",
|
||||
"x11rb",
|
||||
@@ -2513,7 +2348,7 @@ version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2521,11 +2356,6 @@ 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"
|
||||
@@ -2959,34 +2789,6 @@ 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"
|
||||
@@ -2999,9 +2801,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.93"
|
||||
version = "0.3.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d"
|
||||
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -3017,15 +2819,8 @@ 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"
|
||||
@@ -3144,12 +2939,6 @@ 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"
|
||||
@@ -3251,15 +3040,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "masks"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"gpu",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
@@ -3385,10 +3165,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.8.0",
|
||||
"bit-set",
|
||||
"bitflags 2.11.0",
|
||||
"cfg_aliases",
|
||||
"codespan-reporting 0.12.0",
|
||||
"codespan-reporting",
|
||||
"half",
|
||||
"hashbrown 0.15.5",
|
||||
"hexf-parse",
|
||||
@@ -3397,38 +3177,12 @@ dependencies = [
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv 0.3.0+sdk-1.3.268.0",
|
||||
"spirv",
|
||||
"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"
|
||||
@@ -3438,15 +3192,6 @@ 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"
|
||||
@@ -3820,22 +3565,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opencut-wasm"
|
||||
version = "0.2.9"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"bridge",
|
||||
"compositor",
|
||||
"console_error_panic_hook",
|
||||
"effects",
|
||||
"gpu",
|
||||
"js-sys",
|
||||
"masks",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"time",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3850,15 +3582,6 @@ 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"
|
||||
@@ -4058,21 +3781,6 @@ 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"
|
||||
@@ -4108,12 +3816,6 @@ 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"
|
||||
@@ -4367,12 +4069,6 @@ 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"
|
||||
@@ -4447,18 +4143,6 @@ 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"
|
||||
@@ -4576,12 +4260,6 @@ 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"
|
||||
@@ -5204,15 +4882,6 @@ 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"
|
||||
@@ -5630,7 +5299,6 @@ name = "time"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bridge",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"tsify-next",
|
||||
"wasm-bindgen",
|
||||
@@ -6259,9 +5927,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.116"
|
||||
version = "0.2.115"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631"
|
||||
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -6272,9 +5940,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.66"
|
||||
version = "0.4.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05"
|
||||
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -6282,9 +5950,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.116"
|
||||
version = "0.2.115"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537"
|
||||
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -6292,9 +5960,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.116"
|
||||
version = "0.2.115"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac"
|
||||
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -6305,9 +5973,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.116"
|
||||
version = "0.2.115"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633"
|
||||
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -6458,9 +6126,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.93"
|
||||
version = "0.3.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837"
|
||||
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -6482,183 +6150,6 @@ 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-wasm",
|
||||
"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-wasm"
|
||||
version = "29.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f7b75e72f49035f000dd5262e4126242e92a090a4fd75931ecfe7e60784e6fa"
|
||||
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"
|
||||
@@ -6718,23 +6209,11 @@ version = "0.61.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections 0.2.0",
|
||||
"windows-collections",
|
||||
"windows-core 0.61.2",
|
||||
"windows-future 0.2.1",
|
||||
"windows-future",
|
||||
"windows-link 0.1.3",
|
||||
"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",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6747,7 +6226,7 @@ dependencies = [
|
||||
"rayon",
|
||||
"thiserror 2.0.18",
|
||||
"windows 0.61.3",
|
||||
"windows-future 0.2.1",
|
||||
"windows-future",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6759,15 +6238,6 @@ 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"
|
||||
@@ -6793,19 +6263,6 @@ 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"
|
||||
@@ -6814,18 +6271,7 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
"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",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6894,16 +6340,6 @@ 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"
|
||||
@@ -6944,15 +6380,6 @@ 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"
|
||||
@@ -6971,15 +6398,6 @@ 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"
|
||||
@@ -7082,15 +6500,6 @@ 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"
|
||||
@@ -7469,12 +6878,6 @@ 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
-4
@@ -4,8 +4,5 @@ members = [
|
||||
"apps/desktop",
|
||||
"rust/crates/time",
|
||||
"rust/crates/bridge",
|
||||
"rust/crates/effects",
|
||||
"rust/crates/gpu",
|
||||
"rust/crates/masks",
|
||||
"rust/wasm", "rust/crates/compositor",
|
||||
"rust/wasm",
|
||||
]
|
||||
|
||||
@@ -28,12 +28,24 @@ Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss
|
||||
- **Free features**: Most basic CapCut features are now paywalled
|
||||
- **Simple**: People want editors that are easy to use - CapCut proved that
|
||||
|
||||
## Features
|
||||
|
||||
- Timeline-based editing
|
||||
- Multi-track support
|
||||
- Real-time preview
|
||||
- No watermarks or subscriptions
|
||||
- Analytics provided by [Databuddy](https://www.databuddy.cc?utm_source=opencut), 100% Anonymized & Non-invasive.
|
||||
- Blog powered by [Marble](https://marblecms.com?utm_source=opencut), Headless CMS.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `apps/web/`: Next.js web application
|
||||
- `apps/desktop/`: Native desktop app built with GPUI (in progress)
|
||||
- `rust/`: Platform-agnostic core: GPU compositor, effects, masks, and WASM bindings. We're actively migrating business logic here from TypeScript.
|
||||
- `docs/`: Architecture and subsystem documentation
|
||||
- `apps/web/` – Main Next.js web application
|
||||
- `apps/desktop/` – Native desktop app (in progress)
|
||||
- `src/components/` – UI and editor components
|
||||
- `src/hooks/` – Custom React hooks
|
||||
- `src/lib/` – Utility and API logic
|
||||
- `src/stores/` – State management (Zustand, etc.)
|
||||
- `src/types/` – TypeScript types
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -81,56 +93,6 @@ 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.
|
||||
|
||||
**Prerequisites** — install these once before anything else:
|
||||
|
||||
```bash
|
||||
# Rust toolchain
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# build the WASM package
|
||||
cargo install wasm-pack
|
||||
|
||||
# reruns the build on file changes, used by bun dev:wasm
|
||||
cargo install cargo-watch
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use gpui::{
|
||||
div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window,
|
||||
WindowBounds, WindowOptions,
|
||||
App, Application, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div,
|
||||
prelude::*, px, rgb, size,
|
||||
};
|
||||
|
||||
struct AppWindow {
|
||||
|
||||
@@ -19,3 +19,10 @@ 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,6 +29,11 @@ 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,7 +3,7 @@ import { z } from "zod";
|
||||
|
||||
const changelog = defineCollection({
|
||||
name: "changelog",
|
||||
directory: "src/lib/changelog/entries",
|
||||
directory: "content/changelog",
|
||||
include: "*.md",
|
||||
schema: z.object({
|
||||
content: z.string(),
|
||||
@@ -12,7 +12,6 @@ const changelog = defineCollection({
|
||||
published: z.boolean().default(true),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
changes: z.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
@@ -26,8 +25,7 @@ const changelog = defineCollection({
|
||||
const sorted = [...publishedDocs].sort((a, b) =>
|
||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||
);
|
||||
const isLatest =
|
||||
doc.published !== false && sorted[0]?.version === doc.version;
|
||||
const isLatest = doc.published !== false && sorted[0]?.version === doc.version;
|
||||
return { ...doc, isLatest };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ date: "2026-02-23"
|
||||
published: true
|
||||
title: "Editor foundation"
|
||||
description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use."
|
||||
summary: "Properties panel overhaul, 1,000+ fonts, blend modes, a new color picker, and direct manipulation in the preview."
|
||||
changes:
|
||||
- type: new
|
||||
text: "Rebuilt the properties panel from scratch. Number inputs now support math expressions and click-drag scrubbing instead of just typing values in."
|
||||
@@ -4,7 +4,6 @@ date: "2026-03-01"
|
||||
published: true
|
||||
title: "Motion & effects"
|
||||
description: "This release adds the foundation for motion and effects, alongside many improvements & fixes."
|
||||
summary: "Keyframe animation, a new effects system with per-clip blur, and ripple editing mode."
|
||||
changes:
|
||||
- type: new
|
||||
text: "Keyframe animation system for animating element properties over time."
|
||||
@@ -1,114 +1,83 @@
|
||||
---
|
||||
version: "0.3.0"
|
||||
date: "2026-04-15"
|
||||
published: true
|
||||
title: "Masks, animation & more"
|
||||
description: "This release adds masks, a graph editor for keyframe curves, volume and speed controls, preview zoom, canvas backgrounds, stickers, and a lot more."
|
||||
summary: "Masks, graph editor for curves, volume & speed controls, preview zoom, canvas backgrounds, and stickers."
|
||||
changes:
|
||||
- type: new
|
||||
text: "Added a brand page with downloadable brand assets. Assets are also accessible directly from the header logo context menu."
|
||||
- type: fixed
|
||||
text: "Sponsor logos now correctly invert in dark mode."
|
||||
- type: improved
|
||||
text: "Added a changelog link to the footer."
|
||||
- type: fixed
|
||||
text: "Media asset cards in the grid view were larger than their thumbnails, leaving empty space around each item. Cards now size to their content."
|
||||
- type: improved
|
||||
text: "Playback performance is dramatically better. The editor used to slow to a crawl during playback, and audio would stutter when interacting with elements mid-play. Both are fixed."
|
||||
- type: fixed
|
||||
text: "Project thumbnails now generate correctly even when the timeline is empty. If you set a background color or gradient with no clips, the thumbnail reflects that instead of staying blank."
|
||||
- type: fixed
|
||||
text: "Clicking a selected element no longer keeps the rest of the selection."
|
||||
- type: improved
|
||||
text: "More vertical space between timeline tracks. Selection outlines on adjacent tracks no longer overlap each other."
|
||||
- type: fixed
|
||||
text: "Track labels on the left side of the timeline were misaligned with the tracks on the right."
|
||||
- type: improved
|
||||
text: "Holding Shift or Ctrl while drag-selecting in the timeline now adds to your existing selection instead of replacing it. Matches how Shift/Ctrl+click already worked."
|
||||
- type: improved
|
||||
text: "Timeline track rows now highlight when they contain a selected element, making it easier to see which tracks are active at a glance."
|
||||
- type: fixed
|
||||
text: "Clicking in the empty space below timeline tracks now seeks the playhead."
|
||||
- type: fixed
|
||||
text: "Timeline vertical scroll now works anywhere in the panel, not just over the track labels. The header no longer scrolls with the tracks."
|
||||
- type: new
|
||||
text: "Scale now has separate width and height controls. You can stretch or squash elements independently on each axis."
|
||||
- type: improved
|
||||
text: "System fonts (Arial, Helvetica, Times New Roman, and others) now appear in the font picker alongside Google Fonts."
|
||||
- type: fixed
|
||||
text: "MP4 exports with audio failed on Firefox. The export now completes successfully regardless of browser."
|
||||
- type: improved
|
||||
text: "Your projects can disappear if your disk runs low on space. OpenCut now asks the browser to protect them."
|
||||
- type: improved
|
||||
text: "The properties panel has been redesigned with a tabbed layout."
|
||||
- type: new
|
||||
text: "Volume control for audio and video elements."
|
||||
- type: new
|
||||
text: "Speed control for video and audio clips. Includes a maintain pitch option so audio doesn't chipmunk or deepen when you change the rate."
|
||||
- type: improved
|
||||
text: "Audio waveforms have gotten an upgrade. The old ones were inaccurate, prone to crashing, and not very useful. The new ones are RMS-based, properly scaled, and only render what's visible so they don't slow things down."
|
||||
- type: improved
|
||||
text: "The timeline now opens scrolled to the bottom, so your main video track is visible right away instead of being hidden above the fold."
|
||||
- type: fixed
|
||||
text: "Snap guides in the preview panel didn't show when moving a full-canvas element to the center of the frame."
|
||||
- type: fixed
|
||||
text: "The rotation handle could float above the editor UI when an element was near the top of the canvas. It now clips to the preview area."
|
||||
- type: fixed
|
||||
text: "Images with a blur effect at 0% intensity would disappear completely when a blurred background was also active. Now 0% blur correctly renders the image unchanged."
|
||||
- type: fixed
|
||||
text: "Section titles in the properties panel showed the wrong text color when the section wasn't collapsible. Non-collapsible titles now use the correct muted style."
|
||||
- type: new
|
||||
text: "Masks. Hide or reveal parts of any video or image layer. Choose from split, rectangle, ellipse, star, heart, diamond, and cinematic bars. Adjust position, size, rotation, feather, and stroke in the properties panel, or drag the handles in the preview."
|
||||
- type: improved
|
||||
text: "The settings panel has been redesigned with a cleaner layout and better organization."
|
||||
- type: new
|
||||
text: "Canvas backgrounds are back. Set a blur, solid color, or gradient as the canvas background from the new Background tab in the settings panel."
|
||||
- type: fixed
|
||||
text: "Blur looked pixelated at higher intensities instead of producing a real blur. Both the canvas background blur and the blur effect are now fixed."
|
||||
- type: new
|
||||
text: "Custom canvas size. Instead of being locked to a handful of presets, you can now enter any width and height you want."
|
||||
- type: new
|
||||
text: "Preview zoom and panning. Zoom into the canvas to work on fine details, and pan around to navigate when zoomed in."
|
||||
- type: new
|
||||
text: "Stickers panel now has content. Browse and add country flags and shapes."
|
||||
- type: improved
|
||||
text: "You can now select multiple assets in the assets panel. Useful for deleting several at once."
|
||||
- type: fixed
|
||||
text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed."
|
||||
- type: new
|
||||
text: "You can now import transcript files to generate captions instead of running auto-transcription."
|
||||
- type: new
|
||||
text: "Graph editor for keyframe curves. Shape the easing between keyframes by dragging bezier handles."
|
||||
- type: fixed
|
||||
text: "Text element handles no longer shift position as you type."
|
||||
- type: new
|
||||
text: "Multiple selected timeline clips move and resize together."
|
||||
- type: fixed
|
||||
text: "The rotation handle drifted further and further from the element as the canvas size got smaller. On a 100x100 project it was almost off-screen. The gap is now a fixed size regardless of canvas dimensions or zoom."
|
||||
- type: new
|
||||
text: "Position X and Y can now be animated independently."
|
||||
- type: new
|
||||
text: "Keyframes can be copied and pasted between elements."
|
||||
- type: new
|
||||
text: 'Timeline clips can expand to show a lane for each animated property. Right-click a clip -> "Expand keyframes" to see all properties in their own rows.'
|
||||
- type: improved
|
||||
text: "If your browser doesn't support GPU-accelerated rendering, the editor now shows a notice explaining why and suggests switching to another browser."
|
||||
- type: technical
|
||||
text: "Ripple editing now has a proper algorithmic foundation. The old inline approach was replaced with a diff-based system in `src/lib/ripple/`: track snapshots before and after an operation are compared as interval sets, vacated intervals are computed (accounting for elements moved to other tracks), and the result is a list of explicit `RippleAdjustment` values applied in a separate pass. Composable, testable, and independent of the command that triggered it."
|
||||
- type: technical
|
||||
text: "Time is now represented as `MediaTime`, an integer tick count at 120,000 ticks per second, defined in `rust/crates/time`. Previously, floating-point seconds were used throughout, which accumulated rounding errors and made frame alignment imprecise. 120,000 was chosen because it divides evenly by every standard frame rate denominator, including drop-frame rates: 23.976 maps to 5,005 ticks/frame, 29.97 to 4,004, 30 to 4,000. `FrameRate` is a rational `{numerator, denominator}` type rather than a float. All time arithmetic, frame snapping, and timecode formatting are implemented in Rust and exposed via WASM."
|
||||
- type: technical
|
||||
text: "Track ordering is now enforced at the type level. `SceneTracks` replaces a flat track array with three explicit fields: `overlay`, `main` (a singleton `VideoTrack`), and `audio`. The type makes it structurally impossible to mix track kinds or misplace a track."
|
||||
- type: technical
|
||||
text: "The WebGL renderer has been replaced with a Rust/wgpu compositor compiled to WASM. The `rust/` crate tree covers compositing (`rust/crates/compositor`), effects (`rust/crates/effects`), masks with JFA-based feathering (`rust/crates/masks`), GPU context (`rust/crates/gpu`), and time (`rust/crates/time`). WASM bindings live in `rust/wasm/` and are consumed from TypeScript through `src/lib/wasm/`. The old GLSL shaders and WebGL utilities are gone."
|
||||
- type: technical
|
||||
text: "`src/types/` and `src/constants/` have been dissolved. All types and constants now live alongside the code that owns them inside `src/lib/`. The `packages/env` and `packages/ui` internal packages were also folded into the web app, with icons at `src/components/icons/` and env at `src/lib/env/`."
|
||||
- type: technical
|
||||
text: "The web app can now deploy to Cloudflare Workers via OpenNext. `wrangler.jsonc` and `apps/web/open-next.config.ts` are included for anyone self-hosting."
|
||||
- type: technical
|
||||
text: "`apps/desktop/` is a new GPUI + Rust app. It is early: there is a `main.rs` and a working build setup, but no features yet."
|
||||
- type: technical
|
||||
text: "16 new storage migration steps were added (v9 through v25), covering the schema changes accumulated during this release cycle."
|
||||
|
||||
---
|
||||
---
|
||||
version: "0.3.0"
|
||||
date: "2026-03-04"
|
||||
published: false
|
||||
title: "Brand & polish"
|
||||
description: "This release adds a brand page with downloadable assets, alongside a few fixes and improvements."
|
||||
changes:
|
||||
- type: new
|
||||
text: "Added a brand page with downloadable brand assets. Assets are also accessible directly from the header logo context menu."
|
||||
- type: fixed
|
||||
text: "Sponsor logos now correctly invert in dark mode."
|
||||
- type: improved
|
||||
text: "Added a changelog link to the footer."
|
||||
- type: fixed
|
||||
text: "Media asset cards in the grid view were larger than their thumbnails, leaving empty space around each item. Cards now size to their content."
|
||||
- type: improved
|
||||
text: "Playback performance is dramatically better. The editor used to slow to a crawl during playback, and audio would stutter when interacting with elements mid-play. Both are fixed. Playback now runs at a consistent 60fps."
|
||||
- type: fixed
|
||||
text: "Project thumbnails now generate correctly even when the timeline is empty. If you set a background color or gradient with no clips, the thumbnail reflects that instead of staying blank."
|
||||
- type: fixed
|
||||
text: "Clicking a selected element no longer keeps the rest of the selection."
|
||||
- type: improved
|
||||
text: "More vertical space between timeline tracks. Selection outlines on adjacent tracks no longer overlap each other."
|
||||
- type: fixed
|
||||
text: "Track labels on the left side of the timeline were misaligned with the tracks on the right."
|
||||
- type: improved
|
||||
text: "Holding Shift or Ctrl while drag-selecting in the timeline now adds to your existing selection instead of replacing it. Matches how Shift/Ctrl+click already worked."
|
||||
- type: improved
|
||||
text: "Timeline track rows now highlight when they contain a selected element, making it easier to see which tracks are active at a glance."
|
||||
- type: fixed
|
||||
text: "Clicking in the empty space below timeline tracks now seeks the playhead."
|
||||
- type: fixed
|
||||
text: "Timeline vertical scroll now works anywhere in the panel, not just over the track labels. The header no longer scrolls with the tracks."
|
||||
- type: new
|
||||
text: "Scale now has separate width and height controls. You can stretch or squash elements independently on each axis."
|
||||
- type: improved
|
||||
text: "System fonts (Arial, Helvetica, Times New Roman, and others) now appear in the font picker alongside Google Fonts."
|
||||
- type: fixed
|
||||
text: "MP4 exports with audio failed on Firefox. The export now completes successfully regardless of browser."
|
||||
- type: improved
|
||||
text: "Your projects can disappear if your disk runs low on space. OpenCut now asks the browser to protect them."
|
||||
- type: fixed
|
||||
text: "Opening the mask or effects panel for one element, then selecting a different element, would keep the wrong panel open and hide the transform handles for the new selection. Each element now remembers its own panel state independently."
|
||||
- type: new
|
||||
text: "Volume control for audio and video elements, both in the properties panel and inline on timeline clips."
|
||||
- type: new
|
||||
text: "Speed control for video and audio clips. Includes a maintain pitch option so audio doesn't chipmunk or deepen when you change the rate."
|
||||
- type: improved
|
||||
text: "Audio waveforms have gotten an upgrade. The old ones were inaccurate, prone to crashing, and not very useful. The new ones are RMS-based, properly scaled, and only render what's visible so they don't slow things down."
|
||||
- type: improved
|
||||
text: "The timeline now opens scrolled to the bottom, so your main video track is visible right away instead of being hidden above the fold."
|
||||
- type: fixed
|
||||
text: "Snap guides didn't show when moving a full-canvas element to the center of the frame."
|
||||
- type: fixed
|
||||
text: "The rotation handle could float above the editor UI when an element was near the top of the canvas. It now clips to the preview area."
|
||||
- type: fixed
|
||||
text: "Images with a blur effect at 0% intensity would disappear completely when a blurred background was also active. Now 0% blur correctly renders the image unchanged."
|
||||
- type: fixed
|
||||
text: "Section titles in the properties panel showed the wrong text color when the section wasn't collapsible. Non-collapsible titles now use the correct muted style."
|
||||
- type: new
|
||||
text: "Masks. Hide or reveal parts of any video or image layer. Choose from split, rectangle, ellipse, star, heart, diamond, text, and custom masks. Adjust position, size, rotation, feather, and stroke in the properties panel, or drag the handles in the preview."
|
||||
- type: improved
|
||||
text: "The settings panel has been redesigned with a cleaner layout and better organization."
|
||||
- type: new
|
||||
text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
|
||||
- type: new
|
||||
text: "Canvas backgrounds are back. Set a blur, solid color, or gradient as the canvas background from the new Background tab in the settings panel."
|
||||
- type: fixed
|
||||
text: "Blur looked pixelated at higher intensities instead of producing a real blur. Both the canvas background blur and the blur effect are now fixed."
|
||||
- type: new
|
||||
text: "Custom canvas size. Instead of being locked to a handful of presets, you can now enter any width and height you want."
|
||||
- type: new
|
||||
text: "Preview zoom and panning. Zoom into the canvas to work on fine details, and pan around to navigate when zoomed in."
|
||||
- type: new
|
||||
text: "Stickers panel now has content. Browse and add logos, country flags, and shapes."
|
||||
- type: improved
|
||||
text: "You can now select multiple assets in the assets panel. Useful for deleting several at once."
|
||||
- type: fixed
|
||||
text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed."
|
||||
- type: new
|
||||
text: "You can now import transcript files to generate captions instead of running auto-transcription."
|
||||
|
||||
---
|
||||
@@ -1,26 +1,23 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
import * as dotenv from "dotenv";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
// Load the right env file based on environment
|
||||
if (webEnv.NODE_ENV === "production") {
|
||||
dotenv.config({ path: ".env.production" });
|
||||
} else {
|
||||
dotenv.config({ path: ".env.local" });
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
}
|
||||
|
||||
export default {
|
||||
schema: "./src/lib/db/schema.ts",
|
||||
schema: "./src/schema.ts",
|
||||
dialect: "postgresql",
|
||||
migrations: {
|
||||
table: "drizzle_migrations",
|
||||
},
|
||||
dbCredentials: {
|
||||
url: databaseUrl,
|
||||
url: webEnv.DATABASE_URL,
|
||||
},
|
||||
out: "./migrations",
|
||||
strict: process.env.NODE_ENV === "production",
|
||||
strict: webEnv.NODE_ENV === "production",
|
||||
} satisfies Config;
|
||||
|
||||
@@ -3,6 +3,14 @@ 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",
|
||||
},
|
||||
|
||||
@@ -19,9 +19,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@hugeicons/core-free-icons": "^3.3.0",
|
||||
"@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",
|
||||
@@ -52,7 +53,6 @@
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"opencut-wasm": "^0.2.9",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -94,6 +94,7 @@
|
||||
"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",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -1,19 +0,0 @@
|
||||
import type { KeybindingConfig } from "@/actions/keybinding";
|
||||
|
||||
interface V6State {
|
||||
keybindings: KeybindingConfig;
|
||||
isCustomized: boolean;
|
||||
}
|
||||
|
||||
export function v6ToV7({ state }: { state: unknown }): unknown {
|
||||
const v6 = state as V6State;
|
||||
const keybindings = { ...v6.keybindings };
|
||||
|
||||
for (const key of Object.keys(keybindings) as Array<keyof KeybindingConfig>) {
|
||||
if (keybindings[key] === ("split-element" as never)) {
|
||||
keybindings[key] = "split";
|
||||
}
|
||||
}
|
||||
|
||||
return { ...v6, keybindings };
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { invokeAction } from "@/actions";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useKeybindingsStore } from "@/actions/keybindings-store";
|
||||
import { isTypableDOMElement } from "@/utils/browser";
|
||||
|
||||
/**
|
||||
* a composable that hooks to the caller component's
|
||||
* lifecycle and hooks to the keyboard events to fire
|
||||
* the appropriate actions based on keybindings
|
||||
*/
|
||||
export function useKeybindingsListener() {
|
||||
const editor = useEditor();
|
||||
const {
|
||||
keybindings,
|
||||
getKeybindingString,
|
||||
overlayDepth,
|
||||
isLoadingProject,
|
||||
isRecording,
|
||||
} = useKeybindingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
const eventOptions: AddEventListenerOptions = { capture: true };
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
const normalizedKey = (ev.key ?? "").toLowerCase();
|
||||
|
||||
if (overlayDepth > 0 || isLoadingProject || isRecording) {
|
||||
return;
|
||||
}
|
||||
|
||||
const binding = getKeybindingString(ev);
|
||||
const activeElement = document.activeElement;
|
||||
const isTextInput =
|
||||
activeElement instanceof HTMLElement &&
|
||||
isTypableDOMElement({ element: activeElement });
|
||||
const boundAction = binding ? keybindings[binding] : undefined;
|
||||
|
||||
if (normalizedKey === "escape" && isTextInput) {
|
||||
activeElement.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!binding) return;
|
||||
if (!boundAction) return;
|
||||
|
||||
if (isTextInput) return;
|
||||
if (boundAction === "paste-copied") {
|
||||
if (!editor.clipboard.hasEntry()) return;
|
||||
ev.preventDefault();
|
||||
invokeAction("paste-copied", undefined, "keypress");
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
switch (boundAction) {
|
||||
case "seek-forward":
|
||||
invokeAction("seek-forward", { seconds: 1 }, "keypress");
|
||||
break;
|
||||
case "seek-backward":
|
||||
invokeAction("seek-backward", { seconds: 1 }, "keypress");
|
||||
break;
|
||||
case "jump-forward":
|
||||
invokeAction("jump-forward", { seconds: 5 }, "keypress");
|
||||
break;
|
||||
case "jump-backward":
|
||||
invokeAction("jump-backward", { seconds: 5 }, "keypress");
|
||||
break;
|
||||
default:
|
||||
invokeAction(boundAction, undefined, "keypress");
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown, eventOptions);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown, eventOptions);
|
||||
};
|
||||
}, [
|
||||
keybindings,
|
||||
getKeybindingString,
|
||||
overlayDepth,
|
||||
isLoadingProject,
|
||||
isRecording,
|
||||
editor,
|
||||
]);
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
coerceAnimationParamValue,
|
||||
getAnimationParamDefaultInterpolation,
|
||||
getAnimationParamNumericRange,
|
||||
getAnimationParamValueKind,
|
||||
} from "@/animation/animated-params";
|
||||
|
||||
describe("animated params", () => {
|
||||
test("snaps and clamps number params", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.25,
|
||||
},
|
||||
value: 0.62,
|
||||
}),
|
||||
).toBe(0.5);
|
||||
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.25,
|
||||
},
|
||||
value: 1.2,
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test("rejects NaN and non-number values for number params", () => {
|
||||
const param = {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number" as const,
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.25,
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: Number.NaN })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: "0.5" })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: true })).toBeNull();
|
||||
});
|
||||
|
||||
test("passthrough with step <= 0 guard", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "x",
|
||||
label: "X",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 0,
|
||||
},
|
||||
value: 0.123,
|
||||
}),
|
||||
).toBe(0.123);
|
||||
});
|
||||
|
||||
test("accepts valid select values", () => {
|
||||
const param = {
|
||||
key: "blend",
|
||||
label: "Blend",
|
||||
type: "select" as const,
|
||||
default: "normal",
|
||||
options: [
|
||||
{ value: "normal", label: "Normal" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
],
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: "normal" })).toBe("normal");
|
||||
expect(coerceAnimationParamValue({ param, value: "multiply" })).toBe("multiply");
|
||||
});
|
||||
|
||||
test("rejects select values outside the allowed options", () => {
|
||||
expect(
|
||||
coerceAnimationParamValue({
|
||||
param: {
|
||||
key: "blend",
|
||||
label: "Blend",
|
||||
type: "select",
|
||||
default: "normal",
|
||||
options: [
|
||||
{ value: "normal", label: "Normal" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
],
|
||||
},
|
||||
value: "screen",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects non-string select values", () => {
|
||||
const param = {
|
||||
key: "blend",
|
||||
label: "Blend",
|
||||
type: "select" as const,
|
||||
default: "normal",
|
||||
options: [{ value: "normal", label: "Normal" }],
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: 42 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: undefined })).toBeNull();
|
||||
});
|
||||
|
||||
test("boolean params accept booleans and reject other types", () => {
|
||||
const param = {
|
||||
key: "visible",
|
||||
label: "Visible",
|
||||
type: "boolean" as const,
|
||||
default: true,
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: true })).toBe(true);
|
||||
expect(coerceAnimationParamValue({ param, value: false })).toBe(false);
|
||||
expect(coerceAnimationParamValue({ param, value: 1 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: "true" })).toBeNull();
|
||||
});
|
||||
|
||||
test("color params accept strings and reject other types", () => {
|
||||
const param = {
|
||||
key: "fill",
|
||||
label: "Fill",
|
||||
type: "color" as const,
|
||||
default: "#ffffff",
|
||||
};
|
||||
expect(coerceAnimationParamValue({ param, value: "#ff0000" })).toBe("#ff0000");
|
||||
expect(coerceAnimationParamValue({ param, value: 0xff0000 })).toBeNull();
|
||||
expect(coerceAnimationParamValue({ param, value: null })).toBeNull();
|
||||
});
|
||||
|
||||
test("getAnimationParamValueKind maps param type to binding kind", () => {
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: {
|
||||
key: "n",
|
||||
label: "N",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 1,
|
||||
},
|
||||
}),
|
||||
).toBe("number");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBe("color");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBe("discrete");
|
||||
expect(
|
||||
getAnimationParamValueKind({
|
||||
param: {
|
||||
key: "s",
|
||||
label: "S",
|
||||
type: "select",
|
||||
default: "a",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
}),
|
||||
).toBe("discrete");
|
||||
});
|
||||
|
||||
test("getAnimationParamDefaultInterpolation is linear for continuous, hold for discrete", () => {
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: {
|
||||
key: "n",
|
||||
label: "N",
|
||||
type: "number",
|
||||
default: 0,
|
||||
min: 0,
|
||||
step: 1,
|
||||
},
|
||||
}),
|
||||
).toBe("linear");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBe("linear");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBe("hold");
|
||||
expect(
|
||||
getAnimationParamDefaultInterpolation({
|
||||
param: {
|
||||
key: "s",
|
||||
label: "S",
|
||||
type: "select",
|
||||
default: "a",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
}),
|
||||
).toBe("hold");
|
||||
});
|
||||
|
||||
test("getAnimationParamNumericRange returns spec for number params, undefined otherwise", () => {
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
param: {
|
||||
key: "intensity",
|
||||
label: "Intensity",
|
||||
type: "number",
|
||||
default: 0.5,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
},
|
||||
}),
|
||||
).toEqual({ min: 0, max: 1, step: 0.1 });
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
param: { key: "c", label: "C", type: "color", default: "#fff" },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getAnimationParamNumericRange({
|
||||
param: { key: "b", label: "B", type: "boolean", default: false },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
composeAnimationValue,
|
||||
createAnimationBinding,
|
||||
} from "@/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");
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { snapToStep } from "@/utils/math";
|
||||
import type { ParamDefinition } from "@/params";
|
||||
import type { DynamicAnimationPathValue, NumericSpec } from "./types";
|
||||
|
||||
export function getAnimationParamValueKind({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): "number" | "color" | "discrete" {
|
||||
if (param.type === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (param.type === "color") {
|
||||
return "color";
|
||||
}
|
||||
|
||||
return "discrete";
|
||||
}
|
||||
|
||||
export function getAnimationParamDefaultInterpolation({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): "linear" | "hold" {
|
||||
return param.type === "number" || param.type === "color" ? "linear" : "hold";
|
||||
}
|
||||
|
||||
export function getAnimationParamNumericRange({
|
||||
param,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
}): NumericSpec | undefined {
|
||||
if (param.type !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
step: param.step,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `coerceAnimationParamValue` accepts `unknown` rather than a narrow
|
||||
* `DynamicAnimationPathValue` because it doubles as a runtime gate for
|
||||
* untrusted inputs (persisted state, paste payloads, programmatic updates).
|
||||
* The caller does not need to pre-validate; null means "this value cannot live
|
||||
* on this param".
|
||||
*/
|
||||
export function coerceAnimationParamValue({
|
||||
param,
|
||||
value,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
value: unknown;
|
||||
}): DynamicAnimationPathValue | 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;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { ScalarAnimationKey } from "@/animation/types";
|
||||
|
||||
const BEZIER_SOLVE_ITERATIONS = 20;
|
||||
|
||||
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 < BEZIER_SOLVE_ITERATIONS; 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;
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
import { converter, formatHex, formatHex8, parse } from "culori";
|
||||
import type {
|
||||
AnimationBindingComponent,
|
||||
AnimationBindingOfKind,
|
||||
AnimationBindingInstance,
|
||||
AnimationBindingKind,
|
||||
ColorAnimationBinding,
|
||||
DiscreteAnimationBinding,
|
||||
NumberAnimationBinding,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
DiscreteValue,
|
||||
Vector2AnimationBinding,
|
||||
VectorValue,
|
||||
} from "@/animation/types";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
interface LinearRgba {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
export type AnimationComponentValue = number | DiscreteValue;
|
||||
|
||||
const toRgb = converter("rgb");
|
||||
|
||||
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 = clamp({ value, min: 0, max: 1 });
|
||||
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 type EasingMode = "independent" | "shared";
|
||||
|
||||
/**
|
||||
* Declares how easing curves apply to a binding's components.
|
||||
* "shared" means all components always use the same curve (e.g. color — you never
|
||||
* want to ease R independently from G/B/A). "independent" means each component
|
||||
* can have its own curve.
|
||||
*/
|
||||
export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode {
|
||||
return kind === "color" ? "shared" : "independent";
|
||||
}
|
||||
|
||||
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: clamp({ value: rgb.alpha ?? 1, min: 0, max: 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: clamp({ value: color.a, min: 0, max: 1 }),
|
||||
} 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,95 +0,0 @@
|
||||
import {
|
||||
getDefaultLeftHandle,
|
||||
getDefaultRightHandle,
|
||||
} from "@/animation/bezier";
|
||||
import type {
|
||||
CurveHandle,
|
||||
NormalizedCubicBezier,
|
||||
ScalarAnimationKey,
|
||||
} from "@/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,
|
||||
referenceSpanValue,
|
||||
}: {
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
/** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */
|
||||
referenceSpanValue?: number;
|
||||
}): NormalizedCubicBezier | null {
|
||||
const spanTime = rightKey.time - leftKey.time;
|
||||
const spanValue = rightKey.value - leftKey.value;
|
||||
const effectiveSpanValue =
|
||||
Math.abs(spanValue) > VALUE_EPSILON
|
||||
? spanValue
|
||||
: referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON
|
||||
? referenceSpanValue
|
||||
: null;
|
||||
|
||||
if (spanTime === 0 || effectiveSpanValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rightHandle =
|
||||
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
|
||||
const leftHandle =
|
||||
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
|
||||
|
||||
return [
|
||||
clamp01({ value: rightHandle.dt / spanTime }),
|
||||
rightHandle.dv / effectiveSpanValue,
|
||||
clamp01({ value: 1 + leftHandle.dt / spanTime }),
|
||||
1 + leftHandle.dv / effectiveSpanValue,
|
||||
];
|
||||
}
|
||||
|
||||
export function getCurveHandlesForNormalizedCubicBezier({
|
||||
leftKey,
|
||||
rightKey,
|
||||
cubicBezier,
|
||||
referenceSpanValue,
|
||||
}: {
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
cubicBezier: NormalizedCubicBezier;
|
||||
/** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */
|
||||
referenceSpanValue?: number;
|
||||
}): {
|
||||
rightHandle: CurveHandle;
|
||||
leftHandle: CurveHandle;
|
||||
} | null {
|
||||
const spanTime = rightKey.time - leftKey.time;
|
||||
const spanValue = rightKey.value - leftKey.value;
|
||||
const effectiveSpanValue =
|
||||
Math.abs(spanValue) > VALUE_EPSILON
|
||||
? spanValue
|
||||
: referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON
|
||||
? referenceSpanValue
|
||||
: null;
|
||||
|
||||
if (spanTime === 0 || effectiveSpanValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [rawX1, y1, rawX2, y2] = cubicBezier;
|
||||
const x1 = clamp01({ value: rawX1 });
|
||||
const x2 = clamp01({ value: rawX2 });
|
||||
|
||||
return {
|
||||
rightHandle: {
|
||||
dt: spanTime * x1,
|
||||
dv: effectiveSpanValue * y1,
|
||||
},
|
||||
leftHandle: {
|
||||
dt: spanTime * (x2 - 1),
|
||||
dv: effectiveSpanValue * (y2 - 1),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import type {
|
||||
ElementAnimations,
|
||||
EffectParamPath,
|
||||
} from "@/animation/types";
|
||||
import type { ParamValues } from "@/params";
|
||||
import { removeElementKeyframe } from "./keyframes";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
export const EFFECT_PARAM_PATH_PREFIX = "effects.";
|
||||
export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
|
||||
|
||||
export function buildEffectParamPath({
|
||||
effectId,
|
||||
paramKey,
|
||||
}: {
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
}): EffectParamPath {
|
||||
return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
|
||||
}
|
||||
|
||||
export function isEffectParamPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is EffectParamPath {
|
||||
return (
|
||||
propertyPath.startsWith(EFFECT_PARAM_PATH_PREFIX) &&
|
||||
propertyPath.includes(EFFECT_PARAM_PATH_SUFFIX)
|
||||
);
|
||||
}
|
||||
|
||||
export function parseEffectParamPath({
|
||||
propertyPath,
|
||||
}: {
|
||||
propertyPath: string;
|
||||
}): { effectId: string; paramKey: string } | null {
|
||||
if (!isEffectParamPath(propertyPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const withoutPrefix = propertyPath.slice(EFFECT_PARAM_PATH_PREFIX.length);
|
||||
const separatorIndex = withoutPrefix.indexOf(EFFECT_PARAM_PATH_SUFFIX);
|
||||
if (separatorIndex <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectId = withoutPrefix.slice(0, separatorIndex);
|
||||
const paramKey = withoutPrefix.slice(
|
||||
separatorIndex + EFFECT_PARAM_PATH_SUFFIX.length,
|
||||
);
|
||||
if (!effectId || !paramKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { effectId, paramKey };
|
||||
}
|
||||
|
||||
export function resolveEffectParamsAtTime({
|
||||
effectId,
|
||||
params,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
effectId: string;
|
||||
params: ParamValues;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const safeLocalTime = Math.max(0, localTime);
|
||||
const resolved: ParamValues = {};
|
||||
|
||||
for (const [paramKey, staticValue] of Object.entries(params)) {
|
||||
const path = buildEffectParamPath({ effectId, paramKey });
|
||||
resolved[paramKey] = animations?.bindings[path]
|
||||
? resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: path,
|
||||
localTime: safeLocalTime,
|
||||
fallbackValue: staticValue,
|
||||
})
|
||||
: staticValue;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function removeEffectParamKeyframe({
|
||||
animations,
|
||||
effectId,
|
||||
paramKey,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
effectId: string;
|
||||
paramKey: string;
|
||||
keyframeId: string;
|
||||
}): ElementAnimations | undefined {
|
||||
return removeElementKeyframe({
|
||||
animations,
|
||||
propertyPath: buildEffectParamPath({ effectId, paramKey }),
|
||||
keyframeId,
|
||||
});
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import type {
|
||||
AnimationBindingInstance,
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
ScalarAnimationChannel,
|
||||
ScalarGraphChannel,
|
||||
ScalarGraphKeyframeContext,
|
||||
} from "@/animation/types";
|
||||
|
||||
export interface EditableScalarChannels {
|
||||
binding: AnimationBindingInstance;
|
||||
channels: ScalarGraphChannel[];
|
||||
}
|
||||
|
||||
function isScalarAnimationChannel(
|
||||
channel: ElementAnimations["channels"][string],
|
||||
): channel is ScalarAnimationChannel {
|
||||
return channel?.kind === "scalar";
|
||||
}
|
||||
|
||||
export function getEditableScalarChannels({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
}): EditableScalarChannels | null {
|
||||
const binding = animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channels = binding.components.flatMap((component) => {
|
||||
const channel = animations?.channels[component.channelId];
|
||||
if (!isScalarAnimationChannel(channel)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
propertyPath,
|
||||
componentKey: component.key,
|
||||
channelId: component.channelId,
|
||||
channel,
|
||||
} satisfies ScalarGraphChannel,
|
||||
];
|
||||
});
|
||||
|
||||
return { binding, channels };
|
||||
}
|
||||
|
||||
export function getEditableScalarChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
componentKey,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPath;
|
||||
componentKey: string;
|
||||
}): ScalarGraphChannel | null {
|
||||
const result = getEditableScalarChannels({ animations, propertyPath });
|
||||
return result?.channels.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,65 +0,0 @@
|
||||
import type {
|
||||
ElementAnimations,
|
||||
GraphicParamPath,
|
||||
} from "@/animation/types";
|
||||
import type { ParamDefinition, ParamValues } from "@/params";
|
||||
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({
|
||||
params,
|
||||
definitions,
|
||||
animations,
|
||||
localTime,
|
||||
}: {
|
||||
params: ParamValues;
|
||||
definitions: ParamDefinition[];
|
||||
animations?: ElementAnimations;
|
||||
localTime: number;
|
||||
}): ParamValues {
|
||||
const resolved: ParamValues = { ...params };
|
||||
|
||||
for (const param of definitions) {
|
||||
const path = buildGraphicParamPath({ paramKey: param.key });
|
||||
if (!animations?.bindings[path]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved[param.key] = resolveAnimationPathValueAtTime({
|
||||
animations,
|
||||
propertyPath: path,
|
||||
localTime: Math.max(0, localTime),
|
||||
fallbackValue: params[param.key] ?? param.default,
|
||||
});
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
export {
|
||||
getChannelValueAtTime,
|
||||
getDiscreteChannelValueAtTime,
|
||||
getScalarChannelValueAtTime,
|
||||
getScalarSegmentInterpolation,
|
||||
normalizeChannel,
|
||||
} from "./interpolation";
|
||||
|
||||
export {
|
||||
clampAnimationsToDuration,
|
||||
cloneAnimations,
|
||||
getChannel,
|
||||
removeElementKeyframe,
|
||||
retimeElementKeyframe,
|
||||
setBindingComponentChannel,
|
||||
setChannel,
|
||||
splitAnimationsAtTime,
|
||||
updateScalarKeyframeCurve,
|
||||
upsertPathKeyframe,
|
||||
} from "./keyframes";
|
||||
|
||||
export {
|
||||
getElementLocalTime,
|
||||
resolveAnimationPathValueAtTime,
|
||||
} from "./resolve";
|
||||
|
||||
export {
|
||||
getElementKeyframes,
|
||||
getKeyframeById,
|
||||
getKeyframeAtTime,
|
||||
hasKeyframesForPath,
|
||||
} from "./keyframe-query";
|
||||
|
||||
export {
|
||||
type EditableScalarChannels,
|
||||
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 {
|
||||
getGroupKeyframesAtTime,
|
||||
hasGroupKeyframeAtTime,
|
||||
type GroupKeyframeRef,
|
||||
} from "./property-groups";
|
||||
|
||||
export {
|
||||
type EasingMode,
|
||||
getEasingModeForKind,
|
||||
} from "./binding-values";
|
||||
|
||||
export {
|
||||
isAnimationPath,
|
||||
} from "./path";
|
||||
@@ -1,375 +0,0 @@
|
||||
import type {
|
||||
AnimationChannel,
|
||||
AnimationInterpolation,
|
||||
AnimationValue,
|
||||
DiscreteAnimationChannel,
|
||||
DiscreteValue,
|
||||
ScalarAnimationChannel,
|
||||
ScalarAnimationKey,
|
||||
ScalarSegmentType,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
getBezierPoint,
|
||||
getDefaultLeftHandle,
|
||||
getDefaultRightHandle,
|
||||
solveBezierProgressForTime,
|
||||
} from "./bezier";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
function byTimeAscending({
|
||||
leftTime,
|
||||
rightTime,
|
||||
}: {
|
||||
leftTime: number;
|
||||
rightTime: number;
|
||||
}): number {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
function isWithinTimePair({
|
||||
time,
|
||||
leftTime,
|
||||
rightTime,
|
||||
}: {
|
||||
time: number;
|
||||
leftTime: number;
|
||||
rightTime: number;
|
||||
}): boolean {
|
||||
return time >= leftTime && time <= rightTime;
|
||||
}
|
||||
|
||||
function lerpNumber({
|
||||
leftValue,
|
||||
rightValue,
|
||||
progress,
|
||||
}: {
|
||||
leftValue: number;
|
||||
rightValue: number;
|
||||
progress: number;
|
||||
}): number {
|
||||
return leftValue + (rightValue - leftValue) * progress;
|
||||
}
|
||||
|
||||
function normalizeRightHandle({
|
||||
handle,
|
||||
leftKey,
|
||||
rightKey,
|
||||
}: {
|
||||
handle: ScalarAnimationKey["rightHandle"];
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
}) {
|
||||
if (!handle) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const span = Math.max(1, 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(1, 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>({
|
||||
channel,
|
||||
}: {
|
||||
channel: TChannel;
|
||||
}): TChannel {
|
||||
if (channel.kind === "scalar") {
|
||||
return normalizeScalarChannel({
|
||||
channel,
|
||||
}) as TChannel;
|
||||
}
|
||||
|
||||
return {
|
||||
...channel,
|
||||
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
|
||||
byTimeAscending({
|
||||
leftTime: leftKeyframe.time,
|
||||
rightTime: rightKeyframe.time,
|
||||
}),
|
||||
),
|
||||
} as TChannel;
|
||||
}
|
||||
|
||||
function extrapolateScalarEdge({
|
||||
mode,
|
||||
edgeKey,
|
||||
neighborKey,
|
||||
time,
|
||||
}: {
|
||||
mode: "hold" | "linear";
|
||||
edgeKey: ScalarAnimationKey;
|
||||
neighborKey: ScalarAnimationKey | undefined;
|
||||
time: number;
|
||||
}) {
|
||||
if (mode === "hold" || !neighborKey) {
|
||||
return edgeKey.value;
|
||||
}
|
||||
|
||||
const span = neighborKey.time - edgeKey.time;
|
||||
if (span === 0) {
|
||||
return edgeKey.value;
|
||||
}
|
||||
|
||||
return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
|
||||
}
|
||||
|
||||
export function getScalarSegmentInterpolation({
|
||||
segment,
|
||||
}: {
|
||||
segment: ScalarSegmentType;
|
||||
}): AnimationInterpolation {
|
||||
if (segment === "step") {
|
||||
return "hold";
|
||||
}
|
||||
|
||||
return segment === "bezier" ? "bezier" : "linear";
|
||||
}
|
||||
|
||||
export function getScalarChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: ScalarAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: number;
|
||||
}): number {
|
||||
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) {
|
||||
if (time < firstKey.time) {
|
||||
return extrapolateScalarEdge({
|
||||
mode: normalizedChannel.extrapolation?.before ?? "hold",
|
||||
edgeKey: firstKey,
|
||||
neighborKey: normalizedChannel.keys[1],
|
||||
time,
|
||||
});
|
||||
}
|
||||
|
||||
return firstKey.value;
|
||||
}
|
||||
|
||||
if (time >= lastKey.time) {
|
||||
if (time > lastKey.time) {
|
||||
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 (time === rightKey.time) {
|
||||
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 (span === 0) {
|
||||
return rightKey.value;
|
||||
}
|
||||
|
||||
const progress = clamp({
|
||||
value: (time - leftKey.time) / span,
|
||||
min: 0,
|
||||
max: 1,
|
||||
});
|
||||
if (leftKey.segmentToNext === "linear") {
|
||||
return lerpNumber({
|
||||
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 getDiscreteChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: DiscreteAnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: DiscreteValue;
|
||||
}): DiscreteValue {
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const normalizedChannel = normalizeChannel({
|
||||
channel,
|
||||
});
|
||||
let currentValue = fallbackValue;
|
||||
for (const key of normalizedChannel.keys) {
|
||||
if (time < key.time) {
|
||||
break;
|
||||
}
|
||||
currentValue = key.value;
|
||||
}
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
export function getChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
}: {
|
||||
channel: AnimationChannel | undefined;
|
||||
time: number;
|
||||
fallbackValue: AnimationValue;
|
||||
}): AnimationValue {
|
||||
if (!channel || channel.keys.length === 0) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
if (channel.kind === "scalar") {
|
||||
return typeof fallbackValue === "number"
|
||||
? getScalarChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
})
|
||||
: fallbackValue;
|
||||
}
|
||||
|
||||
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return getDiscreteChannelValueAtTime({
|
||||
channel,
|
||||
time,
|
||||
fallbackValue,
|
||||
});
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import type {
|
||||
AnimationBindingInstance,
|
||||
AnimationChannel,
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
ElementKeyframe,
|
||||
} from "@/animation/types";
|
||||
import {
|
||||
type AnimationComponentValue,
|
||||
composeAnimationValue,
|
||||
} from "./binding-values";
|
||||
import {
|
||||
getChannelValueAtTime,
|
||||
getScalarSegmentInterpolation,
|
||||
} from "./interpolation";
|
||||
import { isAnimationPath } from "./path";
|
||||
|
||||
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 ||
|
||||
previousMatch.keyframe.time !== match.keyframe.time
|
||||
) {
|
||||
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 }) => keyframe.time === time),
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
import type { AnimationPath, AnimationPropertyPath } from "@/animation/types";
|
||||
import { ANIMATION_PROPERTY_PATHS } from "./types";
|
||||
import { isEffectParamPath } from "./effect-param-channel";
|
||||
import { isGraphicParamPath } from "./graphic-param-channel";
|
||||
|
||||
const ANIMATION_PROPERTY_PATH_SET = new Set<string>(ANIMATION_PROPERTY_PATHS);
|
||||
|
||||
export function isAnimationPropertyPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPropertyPath {
|
||||
return ANIMATION_PROPERTY_PATH_SET.has(propertyPath);
|
||||
}
|
||||
|
||||
export function isAnimationPath(
|
||||
propertyPath: string,
|
||||
): propertyPath is AnimationPath {
|
||||
return (
|
||||
isAnimationPropertyPath(propertyPath) ||
|
||||
isGraphicParamPath(propertyPath) ||
|
||||
isEffectParamPath(propertyPath)
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import type {
|
||||
AnimationPath,
|
||||
AnimationValueForPath,
|
||||
ElementAnimations,
|
||||
} from "@/animation/types";
|
||||
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 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 +0,0 @@
|
||||
export const MIN_TRANSFORM_SCALE = 0.01;
|
||||
@@ -1,226 +0,0 @@
|
||||
export const ANIMATION_PROPERTY_PATHS = [
|
||||
"transform.positionX",
|
||||
"transform.positionY",
|
||||
"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.positionX": number;
|
||||
"transform.positionY": number;
|
||||
"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 = number | string | boolean;
|
||||
|
||||
export interface NumericSpec {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
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,61 +0,0 @@
|
||||
import type {
|
||||
AnimationColorPropertyPath,
|
||||
AnimationNumericPropertyPath,
|
||||
ElementAnimations,
|
||||
} from "./types";
|
||||
import { resolveAnimationPathValueAtTime } from "./resolve";
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auth } from "@/auth/server";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { checkRateLimit } from "@/auth/rate-limit";
|
||||
import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/feedback";
|
||||
|
||||
const submitSchema = z.object({
|
||||
message: z
|
||||
.string()
|
||||
.min(1, "Message is required")
|
||||
.max(MAX_MESSAGE_LENGTH, "Message too long"),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { limited } = await checkRateLimit({ request });
|
||||
if (limited) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const result = submitSchema.safeParse(body);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: result.error.flatten().fieldErrors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const entry = await submitFeedback(result.data);
|
||||
return NextResponse.json({ entry }, { status: 201 });
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { webEnv } from "@/env/web";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { checkRateLimit } from "@/auth/rate-limit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
q: z.string().max(500, "Query too long").optional(),
|
||||
|
||||
@@ -4,8 +4,8 @@ import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import Prose from "@/components/ui/prose";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts, getSinglePost, processHtmlContent } from "@/blog/query";
|
||||
import type { Author, Post } from "@/blog/types";
|
||||
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
|
||||
import type { Author, Post } from "@/lib/blog/types";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts } from "@/blog/query";
|
||||
import type { Post } from "@/blog/types";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import type { Post } from "@/lib/blog/types";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog - OpenCut",
|
||||
|
||||
+268
-268
@@ -1,268 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Check, Copy, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
function downloadAsset(src: string) {
|
||||
const filename = src.split("/").pop() ?? "asset.svg";
|
||||
const a = document.createElement("a");
|
||||
a.href = src;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function copyAsset(src: string) {
|
||||
const res = await fetch(src);
|
||||
const text = await res.text();
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
|
||||
|
||||
type AssetTheme = "dark" | "light" | "icon";
|
||||
|
||||
interface AssetVariant {
|
||||
src: string;
|
||||
theme: AssetTheme;
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AssetSection {
|
||||
title: string;
|
||||
description: string;
|
||||
cols: "1" | "2";
|
||||
assets: AssetVariant[];
|
||||
}
|
||||
|
||||
const ASSET_SECTIONS: AssetSection[] = [
|
||||
{
|
||||
title: "Symbol",
|
||||
description:
|
||||
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/symbol.svg",
|
||||
theme: "dark",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/symbol-light.svg",
|
||||
theme: "light",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Lockup",
|
||||
description:
|
||||
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/logo.svg",
|
||||
theme: "dark",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/logo-light.svg",
|
||||
theme: "light",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text.svg",
|
||||
theme: "dark",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text-light.svg",
|
||||
theme: "light",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrandPage() {
|
||||
return (
|
||||
<BasePage
|
||||
maxWidth="6xl"
|
||||
title="Brand"
|
||||
description={
|
||||
<>
|
||||
Download OpenCut brand assets for use in your projects.{" "}
|
||||
<Link
|
||||
href="#guidelines"
|
||||
className="underline underline-offset-4"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("guidelines")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
Read the brand guidelines.
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="mx-auto gap-2"
|
||||
onClick={() => {
|
||||
ALL_ASSETS().forEach((asset, i) => {
|
||||
setTimeout(() => downloadAsset(asset.src), i * 200);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
Download all
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-10">
|
||||
{ASSET_SECTIONS.map((section) => (
|
||||
<div key={section.title} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-semibold text-lg">{section.title}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3",
|
||||
section.cols === "2"
|
||||
? "grid-cols-1 sm:grid-cols-2"
|
||||
: "grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{section.assets.map((variant) => (
|
||||
<AssetCard key={variant.src} variant={variant} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div id="guidelines" className="flex flex-col gap-8 text-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">Usage</h2>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
OpenCut is open source — the code is free to use under its license.
|
||||
That license does not cover the name or logo. You can say you use
|
||||
OpenCut, that your project integrates with OpenCut, or that it was
|
||||
built on top of OpenCut. You cannot name your product OpenCut, imply
|
||||
we made or endorse your product, or use the marks commercially
|
||||
without asking first. For anything unclear, reach out at{" "}
|
||||
<Link
|
||||
href="mailto:brand@opencut.app"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
brand@opencut.app
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
||||
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
|
||||
{[
|
||||
"Using OpenCut in the name of your product, service, or domain.",
|
||||
"Implying that OpenCut made, sponsors, or endorses your work.",
|
||||
"Using the logo or name on merchandise or commercial marketing.",
|
||||
"Modifying the marks.",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-0.5 shrink-0">-</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
|
||||
light: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#000",
|
||||
},
|
||||
dark: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
};
|
||||
|
||||
function AssetCard({ variant }: { variant: AssetVariant }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
await copyAsset(variant.src);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="group relative overflow-hidden"
|
||||
style={
|
||||
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
|
||||
}
|
||||
>
|
||||
<div className="flex h-56 items-center justify-center px-12 py-8">
|
||||
<Image
|
||||
src={variant.src}
|
||||
alt={variant.label}
|
||||
width={variant.width}
|
||||
height={variant.height}
|
||||
className="max-h-16 w-auto select-none object-contain"
|
||||
draggable={false}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Check, Copy, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
function downloadAsset(src: string) {
|
||||
const filename = src.split("/").pop() ?? "asset.svg";
|
||||
const a = document.createElement("a");
|
||||
a.href = src;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function copyAsset(src: string) {
|
||||
const res = await fetch(src);
|
||||
const text = await res.text();
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
|
||||
|
||||
type AssetTheme = "dark" | "light" | "icon";
|
||||
|
||||
interface AssetVariant {
|
||||
src: string;
|
||||
theme: AssetTheme;
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AssetSection {
|
||||
title: string;
|
||||
description: string;
|
||||
cols: "1" | "2";
|
||||
assets: AssetVariant[];
|
||||
}
|
||||
|
||||
const ASSET_SECTIONS: AssetSection[] = [
|
||||
{
|
||||
title: "Symbol",
|
||||
description:
|
||||
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/symbol.svg",
|
||||
theme: "dark",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/symbol-light.svg",
|
||||
theme: "light",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Lockup",
|
||||
description:
|
||||
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/logo.svg",
|
||||
theme: "dark",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/logo-light.svg",
|
||||
theme: "light",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text.svg",
|
||||
theme: "dark",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text-light.svg",
|
||||
theme: "light",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrandPage() {
|
||||
return (
|
||||
<BasePage
|
||||
maxWidth="6xl"
|
||||
title="Brand"
|
||||
description={
|
||||
<>
|
||||
Download OpenCut brand assets for use in your projects.{" "}
|
||||
<Link
|
||||
href="#guidelines"
|
||||
className="underline underline-offset-4"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("guidelines")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
Read the brand guidelines.
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="mx-auto gap-2"
|
||||
onClick={() => {
|
||||
ALL_ASSETS().forEach((asset, i) => {
|
||||
setTimeout(() => downloadAsset(asset.src), i * 200);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
Download all
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-10">
|
||||
{ASSET_SECTIONS.map((section) => (
|
||||
<div key={section.title} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-semibold text-lg">{section.title}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3",
|
||||
section.cols === "2"
|
||||
? "grid-cols-1 sm:grid-cols-2"
|
||||
: "grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{section.assets.map((variant) => (
|
||||
<AssetCard key={variant.src} variant={variant} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div id="guidelines" className="flex flex-col gap-8 text-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">Usage</h2>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
OpenCut is open source — the code is free to use under its license.
|
||||
That license does not cover the name or logo. You can say you use
|
||||
OpenCut, that your project integrates with OpenCut, or that it was
|
||||
built on top of OpenCut. You cannot name your product OpenCut, imply
|
||||
we made or endorse your product, or use the marks commercially
|
||||
without asking first. For anything unclear, reach out at{" "}
|
||||
<Link
|
||||
href="mailto:brand@opencut.app"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
brand@opencut.app
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
||||
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
|
||||
{[
|
||||
"Using OpenCut in the name of your product, service, or domain.",
|
||||
"Implying that OpenCut made, sponsors, or endorses your work.",
|
||||
"Using the logo or name on merchandise or commercial marketing.",
|
||||
"Modifying the marks.",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-0.5 shrink-0">-</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
|
||||
light: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#000",
|
||||
},
|
||||
dark: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
};
|
||||
|
||||
function AssetCard({ variant }: { variant: AssetVariant }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
await copyAsset(variant.src);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="group relative overflow-hidden"
|
||||
style={
|
||||
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
|
||||
}
|
||||
>
|
||||
<div className="flex h-56 items-center justify-center px-12 py-8">
|
||||
<Image
|
||||
src={variant.src}
|
||||
alt={variant.label}
|
||||
width={variant.width}
|
||||
height={variant.height}
|
||||
className="max-h-16 w-auto select-none object-contain"
|
||||
draggable={false}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { getReleaseByVersion, getSortedReleases } from "@/changelog/utils";
|
||||
import { getReleaseByVersion, getSortedReleases } from "../utils";
|
||||
import {
|
||||
ReleaseArticle,
|
||||
ReleaseMeta,
|
||||
ReleaseTitle,
|
||||
ReleaseDescription,
|
||||
ReleaseChanges,
|
||||
} from "@/changelog/components/release";
|
||||
import { CopyMarkdownButton } from "@/changelog/components/copy-markdown-button";
|
||||
} from "../components/release";
|
||||
import { CopyMarkdownButton } from "../components/copy-markdown-button";
|
||||
|
||||
type Props = { params: Promise<{ version: string }> };
|
||||
|
||||
|
||||
+68
-98
@@ -1,98 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import {
|
||||
getSectionTitle,
|
||||
groupAndOrderChanges,
|
||||
isSectionCollapsible,
|
||||
} from "../utils";
|
||||
import type { Change } from "../utils";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function buildMarkdown({
|
||||
description,
|
||||
changes,
|
||||
}: {
|
||||
description?: string;
|
||||
changes: Change[];
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (description) {
|
||||
lines.push(description, "");
|
||||
}
|
||||
|
||||
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
|
||||
|
||||
for (const type of orderedTypes) {
|
||||
if (isSectionCollapsible({ type })) {
|
||||
lines.push(
|
||||
buildCollapsibleMarkdownSection({
|
||||
title: getSectionTitle({ type }),
|
||||
changes: grouped[type],
|
||||
}),
|
||||
"",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push(`## ${getSectionTitle({ type })}`);
|
||||
for (const change of grouped[type]) {
|
||||
lines.push(`- ${change.text}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n").trimEnd();
|
||||
}
|
||||
|
||||
function buildCollapsibleMarkdownSection({
|
||||
title,
|
||||
changes,
|
||||
}: {
|
||||
title: string;
|
||||
changes: Change[];
|
||||
}): string {
|
||||
const bulletLines = changes.map((change) => `- ${change.text}`).join("\n");
|
||||
|
||||
return `<details>\n<summary>${title}</summary>\n\n${bulletLines}\n\n</details>`;
|
||||
}
|
||||
|
||||
export function CopyMarkdownButton({
|
||||
description,
|
||||
changes,
|
||||
}: {
|
||||
description?: string;
|
||||
changes: Change[];
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const markdown = buildMarkdown({ description, changes });
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5",
|
||||
copied && "pointer-events-none",
|
||||
)}
|
||||
title="Copy as markdown"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-4" />
|
||||
)}
|
||||
{copied ? "Copied!" : "Copy markdown"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
||||
import type { Change } from "../utils";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function buildMarkdown({
|
||||
description,
|
||||
changes,
|
||||
}: {
|
||||
description?: string;
|
||||
changes: Change[];
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (description) {
|
||||
lines.push(description, "");
|
||||
}
|
||||
|
||||
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
|
||||
|
||||
for (const type of orderedTypes) {
|
||||
lines.push(`## ${getSectionTitle({ type })}`);
|
||||
for (const change of grouped[type]) {
|
||||
lines.push(`- ${change.text}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n").trimEnd();
|
||||
}
|
||||
|
||||
export function CopyMarkdownButton({
|
||||
description,
|
||||
changes,
|
||||
}: {
|
||||
description?: string;
|
||||
changes: Change[];
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const markdown = buildMarkdown({ description, changes });
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onClick={handleCopy}
|
||||
className={cn("flex items-center gap-1.5", copied && "pointer-events-none")}
|
||||
title="Copy as markdown"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-4" />
|
||||
)}
|
||||
{copied ? "Copied!" : "Copy markdown"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+106
-153
@@ -1,153 +1,106 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
|
||||
import {
|
||||
getSectionTitle,
|
||||
groupAndOrderChanges,
|
||||
isSectionCollapsible,
|
||||
type Change,
|
||||
type Release,
|
||||
} from "../utils";
|
||||
import { ArrowRightIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
export function ReleaseArticle({
|
||||
variant,
|
||||
isLatest,
|
||||
children,
|
||||
}: {
|
||||
variant: "list" | "detail";
|
||||
isLatest?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<article className="relative sm:pl-10">
|
||||
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
|
||||
<div
|
||||
className={cn(
|
||||
"size-[11px] rounded-full border-[1.5px]",
|
||||
isLatest
|
||||
? "border-foreground bg-foreground"
|
||||
: "border-muted-foreground/30 bg-background",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">{children}</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return <article className="flex flex-col gap-8">{children}</article>;
|
||||
}
|
||||
|
||||
export function ReleaseMeta({ release }: { release: Release }) {
|
||||
return (
|
||||
<span className="text-sm font-medium tracking-widest text-muted-foreground">
|
||||
{release.version} — {release.date}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const titleSizes: Record<"h1" | "h2", string> = {
|
||||
h1: "text-4xl",
|
||||
h2: "text-2xl",
|
||||
};
|
||||
|
||||
export function ReleaseTitle({
|
||||
as: As,
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
as: "h1" | "h2";
|
||||
href?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<As className={cn("font-bold tracking-tight", titleSizes[As])}>
|
||||
{href ? (
|
||||
<Link href={href} className="hover:underline underline-offset-4">
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</As>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseDescription({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="text-base text-foreground leading-relaxed max-w-xl">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseChanges({ release }: { release: Release }) {
|
||||
const { grouped, orderedTypes } = groupAndOrderChanges({
|
||||
changes: release.changes,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{orderedTypes.map((type) => (
|
||||
<ReleaseChangeSection key={type} type={type} changes={grouped[type]} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseChangeSection({
|
||||
type,
|
||||
changes,
|
||||
}: {
|
||||
type: string;
|
||||
changes: Change[];
|
||||
}) {
|
||||
const title = getSectionTitle({ type });
|
||||
|
||||
if (isSectionCollapsible({ type })) {
|
||||
return (
|
||||
<details className="group flex flex-col gap-1.5">
|
||||
<summary className="flex w-fit cursor-pointer list-none items-center gap-1.5 text-base font-semibold text-foreground [&::-webkit-details-marker]:hidden">
|
||||
<span>{title}:</span>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRightIcon}
|
||||
className="size-3 shrink-0 text-muted-foreground group-open:rotate-90"
|
||||
/>
|
||||
</summary>
|
||||
<ReleaseChangeList changes={changes} />
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">{title}:</h3>
|
||||
<ReleaseChangeList changes={changes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseChangeList({
|
||||
changes,
|
||||
className,
|
||||
}: {
|
||||
changes: Change[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<ul className={cn("list-disc space-y-1.5 pl-5", className)}>
|
||||
{changes.map((change) => (
|
||||
<li
|
||||
key={change.text}
|
||||
className="text-base leading-relaxed text-foreground"
|
||||
>
|
||||
<ReactMarkdownWrapper inline>{change.text}</ReactMarkdownWrapper>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { getSectionTitle, groupAndOrderChanges } from "../utils";
|
||||
import type { Release } from "../utils";
|
||||
|
||||
export function ReleaseArticle({
|
||||
variant,
|
||||
isLatest,
|
||||
children,
|
||||
}: {
|
||||
variant: "list" | "detail";
|
||||
isLatest?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<article className="relative sm:pl-10">
|
||||
<div aria-hidden className="absolute left-0 top-[3px] hidden sm:block">
|
||||
<div
|
||||
className={cn(
|
||||
"size-[11px] rounded-full border-[1.5px]",
|
||||
isLatest
|
||||
? "border-foreground bg-foreground"
|
||||
: "border-muted-foreground/30 bg-background",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">{children}</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return <article className="flex flex-col gap-8">{children}</article>;
|
||||
}
|
||||
|
||||
export function ReleaseMeta({ release }: { release: Release }) {
|
||||
return (
|
||||
<span className="text-sm font-medium tracking-widest text-muted-foreground">
|
||||
{release.version} — {release.date}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const titleSizes: Record<"h1" | "h2", string> = {
|
||||
h1: "text-4xl",
|
||||
h2: "text-2xl",
|
||||
};
|
||||
|
||||
export function ReleaseTitle({
|
||||
as: As,
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
as: "h1" | "h2";
|
||||
href?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<As className={cn("font-bold tracking-tight", titleSizes[As])}>
|
||||
{href ? (
|
||||
<Link href={href} className="hover:underline underline-offset-4">
|
||||
{children}
|
||||
</Link>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</As>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseDescription({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="text-base text-foreground leading-relaxed max-w-xl">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseChanges({ release }: { release: Release }) {
|
||||
const { grouped, orderedTypes } = groupAndOrderChanges({
|
||||
changes: release.changes,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{orderedTypes.map((type) => (
|
||||
<div key={type} className="flex flex-col gap-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{getSectionTitle({ type })}:
|
||||
</h3>
|
||||
<ul className="list-disc pl-5 space-y-1.5">
|
||||
{grouped[type].map((change) => (
|
||||
<li
|
||||
key={change.text}
|
||||
className="text-base text-foreground leading-relaxed"
|
||||
>
|
||||
{change.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
type Release as ReleaseType,
|
||||
getSortedReleases,
|
||||
} from "@/changelog/utils";
|
||||
import { type Release as ReleaseType, getSortedReleases } from "./utils";
|
||||
import {
|
||||
ReleaseArticle,
|
||||
ReleaseMeta,
|
||||
ReleaseTitle,
|
||||
ReleaseDescription,
|
||||
ReleaseChanges,
|
||||
} from "@/changelog/components/release";
|
||||
} from "./components/release";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Changelog - OpenCut",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { allChangelogs } from "content-collections";
|
||||
|
||||
export type Change = { type: string; text: string };
|
||||
export type Release = (typeof allChangelogs)[number];
|
||||
|
||||
const knownSectionOrder = ["new", "improved", "fixed", "breaking"];
|
||||
|
||||
const knownSectionTitles: Record<string, string> = {
|
||||
new: "Features",
|
||||
improved: "Improvements",
|
||||
fixed: "Fixes",
|
||||
breaking: "Breaking Changes",
|
||||
};
|
||||
|
||||
export function getSectionTitle({ type }: { type: string }): string {
|
||||
return (
|
||||
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
|
||||
);
|
||||
}
|
||||
|
||||
export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
|
||||
const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
|
||||
if (!acc[change.type]) acc[change.type] = [];
|
||||
acc[change.type].push(change);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const customTypes = Object.keys(grouped).filter(
|
||||
(type) => !knownSectionOrder.includes(type),
|
||||
);
|
||||
const orderedTypes = [
|
||||
...knownSectionOrder.filter((type) => grouped[type]?.length > 0),
|
||||
...customTypes,
|
||||
];
|
||||
|
||||
return { grouped, orderedTypes };
|
||||
}
|
||||
|
||||
function isPublishedRelease({ published }: Release) {
|
||||
return published !== false;
|
||||
}
|
||||
|
||||
export function getSortedReleases() {
|
||||
return allChangelogs
|
||||
.filter(isPublishedRelease)
|
||||
.sort((a, b) =>
|
||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||
);
|
||||
}
|
||||
|
||||
export function getReleaseByVersion({ version }: { version: string }) {
|
||||
return getSortedReleases().find((release) => release.version === version);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EXTERNAL_TOOLS } from "@/site/external-tools";
|
||||
import { EXTERNAL_TOOLS } from "@/constants/site-constants";
|
||||
import { BasePage } from "../base-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
||||
@@ -1,200 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { AssetsPanel } from "@/components/editor/panels/assets";
|
||||
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
||||
import { Timeline } from "@/timeline/components";
|
||||
import { PreviewPanel } from "@/preview/components";
|
||||
import { EditorHeader } from "@/components/editor/editor-header";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { Onboarding } from "@/components/editor/onboarding";
|
||||
import { MigrationDialog } from "@/project/components/migration-dialog";
|
||||
import { usePanelStore } from "@/editor/panel-store";
|
||||
import { usePasteMedia } from "@/media/use-paste-media";
|
||||
import { MobileGate } from "@/components/editor/mobile-gate";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { Cancel01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||
import {
|
||||
createPreviewOverlayControl,
|
||||
isPreviewOverlayVisible,
|
||||
mergePreviewOverlaySources,
|
||||
} from "@/preview/overlays";
|
||||
import { usePreviewStore } from "@/preview/preview-store";
|
||||
import { getGuidePreviewOverlaySource } from "@/guides";
|
||||
import {
|
||||
bookmarkNotesPreviewOverlay,
|
||||
getBookmarkPreviewOverlaySource,
|
||||
} from "@/timeline/bookmarks/index";
|
||||
|
||||
export default function Editor() {
|
||||
const params = useParams();
|
||||
const projectId = params.project_id as string;
|
||||
|
||||
return (
|
||||
<MobileGate>
|
||||
<EditorProvider projectId={projectId}>
|
||||
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
||||
<DegradedRendererBanner />
|
||||
<EditorHeader />
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<EditorLayout />
|
||||
</div>
|
||||
<Onboarding />
|
||||
<MigrationDialog />
|
||||
<ChangelogNotification />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
</MobileGate>
|
||||
);
|
||||
}
|
||||
|
||||
function DegradedRendererBanner() {
|
||||
const isDegraded = useEditor((e) => e.renderer.isDegraded);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
if (!isDegraded || dismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-accent border-b h-9 flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<span>For the best experience, open OpenCut in Chrome.</span>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="p-0 w-auto [&_svg]:size-3.5"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorLayout() {
|
||||
usePasteMedia();
|
||||
const { panels, setPanel } = usePanelStore();
|
||||
const activeScene = useEditor((editor) =>
|
||||
editor.scenes.getActiveSceneOrNull(),
|
||||
);
|
||||
const currentTime = useEditor((editor) => editor.playback.getCurrentTime());
|
||||
const activeGuide = usePreviewStore((state) => state.activeGuide);
|
||||
const overlays = usePreviewStore((state) => state.overlays);
|
||||
const setOverlayVisibility = usePreviewStore(
|
||||
(state) => state.setOverlayVisibility,
|
||||
);
|
||||
const showBookmarkNotes = isPreviewOverlayVisible({
|
||||
overlay: bookmarkNotesPreviewOverlay,
|
||||
overlays,
|
||||
});
|
||||
|
||||
const overlaySource = useMemo(
|
||||
() =>
|
||||
mergePreviewOverlaySources({
|
||||
sources: [
|
||||
getGuidePreviewOverlaySource({
|
||||
guideId: activeGuide,
|
||||
}),
|
||||
activeScene
|
||||
? getBookmarkPreviewOverlaySource({
|
||||
bookmarks: activeScene.bookmarks,
|
||||
time: currentTime,
|
||||
isVisible: showBookmarkNotes,
|
||||
})
|
||||
: {
|
||||
definitions: [bookmarkNotesPreviewOverlay],
|
||||
instances: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[activeGuide, activeScene, currentTime, showBookmarkNotes],
|
||||
);
|
||||
|
||||
const overlayControls = useMemo(
|
||||
() =>
|
||||
overlaySource.definitions.map((overlay) =>
|
||||
createPreviewOverlayControl({ overlay, overlays }),
|
||||
),
|
||||
[overlaySource.definitions, overlays],
|
||||
);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="size-full gap-[0.18rem]"
|
||||
onLayout={(sizes) => {
|
||||
setPanel("mainContent", sizes[0] ?? panels.mainContent);
|
||||
setPanel("timeline", sizes[1] ?? panels.timeline);
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={panels.mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="size-full gap-[0.19rem] px-3"
|
||||
onLayout={(sizes) => {
|
||||
setPanel("tools", sizes[0] ?? panels.tools);
|
||||
setPanel("preview", sizes[1] ?? panels.preview);
|
||||
setPanel("properties", sizes[2] ?? panels.properties);
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={panels.tools}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0"
|
||||
>
|
||||
<AssetsPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.preview}
|
||||
minSize={30}
|
||||
className="min-h-0 min-w-0 flex-1"
|
||||
>
|
||||
<PreviewPanel
|
||||
overlayControls={overlayControls}
|
||||
overlayInstances={overlaySource.instances}
|
||||
onOverlayVisibilityChange={setOverlayVisibility}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.properties}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
className="min-h-0 px-3 pb-3"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { AssetsPanel } from "@/components/editor/panels/assets";
|
||||
import { PropertiesPanel } from "@/components/editor/panels/properties";
|
||||
import { Timeline } from "@/components/editor/panels/timeline";
|
||||
import { PreviewPanel } from "@/components/editor/panels/preview";
|
||||
import { EditorHeader } from "@/components/editor/editor-header";
|
||||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { Onboarding } from "@/components/editor/onboarding";
|
||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { usePasteMedia } from "@/hooks/use-paste-media";
|
||||
import { MobileGate } from "@/components/editor/mobile-gate";
|
||||
|
||||
export default function Editor() {
|
||||
const params = useParams();
|
||||
const projectId = params.project_id as string;
|
||||
|
||||
return (
|
||||
<MobileGate>
|
||||
<EditorProvider projectId={projectId}>
|
||||
<div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
|
||||
<EditorHeader />
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<EditorLayout />
|
||||
</div>
|
||||
<Onboarding />
|
||||
<MigrationDialog />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
</MobileGate>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorLayout() {
|
||||
usePasteMedia();
|
||||
const { panels, setPanel } = usePanelStore();
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="size-full gap-[0.18rem]"
|
||||
onLayout={(sizes) => {
|
||||
setPanel("mainContent", sizes[0] ?? panels.mainContent);
|
||||
setPanel("timeline", sizes[1] ?? panels.timeline);
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={panels.mainContent}
|
||||
minSize={30}
|
||||
maxSize={85}
|
||||
className="min-h-0"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="size-full gap-[0.19rem] px-3"
|
||||
onLayout={(sizes) => {
|
||||
setPanel("tools", sizes[0] ?? panels.tools);
|
||||
setPanel("preview", sizes[1] ?? panels.preview);
|
||||
setPanel("properties", sizes[2] ?? panels.properties);
|
||||
}}
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={panels.tools}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0"
|
||||
>
|
||||
<AssetsPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.preview}
|
||||
minSize={30}
|
||||
className="min-h-0 min-w-0 flex-1"
|
||||
>
|
||||
<PreviewPanel />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.properties}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={panels.timeline}
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
className="min-h-0 px-3 pb-3"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
+284
-291
@@ -1,291 +1,284 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Custom variant for dark mode */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Plugins */
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
:root {
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(0 0% 11%);
|
||||
--card: hsl(0, 0%, 100%);
|
||||
--card-foreground: hsl(0 0% 11%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-hover: hsl(0, 0%, 96%);
|
||||
--popover-foreground: hsl(0 0% 2%);
|
||||
--primary: hsl(200, 90%, 52%);
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--secondary: hsl(204, 100%, 97%);
|
||||
--secondary-border: hsl(204, 100%, 94%);
|
||||
--secondary-foreground: hsl(200, 98%, 39%);
|
||||
--muted: hsl(0 0% 85.1%);
|
||||
--muted-foreground: hsl(0 0% 50%);
|
||||
--accent: hsl(0, 0%, 96%);
|
||||
--accent-foreground: hsl(0 0% 2%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 100%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0, 0%, 100%);
|
||||
--caution: hsl(38, 92%, 50%);
|
||||
--caution-foreground: hsl(0, 0%, 10%);
|
||||
--border: hsl(0 0% 91%);
|
||||
--input: hsl(0, 0%, 100%);
|
||||
--ring: hsl(0, 0%, 55%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 96.1%);
|
||||
--sidebar-foreground: hsl(0 0% 2%);
|
||||
--sidebar-primary: hsl(0 0% 2%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||
--sidebar-accent: hsl(0 0% 85.1%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||
--sidebar-border: hsl(0 0% 85.1%);
|
||||
--sidebar-ring: hsl(0 0% 16.9%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
}
|
||||
|
||||
.panel {
|
||||
--background: hsl(210, 20%, 98%);
|
||||
--foreground: hsl(0 0% 13%);
|
||||
--card: hsl(0, 0%, 98%);
|
||||
--card-foreground: hsl(0 0% 13%);
|
||||
--primary-foreground: hsl(0, 0%, 98%);
|
||||
--secondary: hsl(204, 100%, 95%);
|
||||
--secondary-border: hsl(204, 100%, 92%);
|
||||
--secondary-foreground: hsl(200, 98%, 37%);
|
||||
--muted: hsl(0 0% 83.1%);
|
||||
--muted-foreground: hsl(0 0% 48%);
|
||||
--accent: hsl(0, 0%, 93%);
|
||||
--accent-foreground: hsl(0 0% 5%);
|
||||
--destructive-foreground: hsl(0, 0%, 98%);
|
||||
--constructive-foreground: hsl(0, 0%, 98%);
|
||||
--border: hsl(0 0% 87%);
|
||||
--input: hsl(0 0% 93%);
|
||||
--ring: hsl(0, 0%, 53%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(0, 0%, 5%);
|
||||
--foreground: hsl(0 0% 87%);
|
||||
--card: hsl(0, 0%, 5%);
|
||||
--card-foreground: hsl(0 0% 87%);
|
||||
--popover: hsl(0, 0%, 5%);
|
||||
--popover-hover: hsl(0, 0%, 13%);
|
||||
--popover-foreground: hsl(0 0% 95%);
|
||||
--secondary: hsl(204, 100%, 12%);
|
||||
--secondary-border: hsl(204, 100%, 15%);
|
||||
--secondary-foreground: hsl(200, 98%, 61%);
|
||||
--muted: hsl(0 0% 20%);
|
||||
--accent: hsl(0, 0%, 14%);
|
||||
--accent-foreground: hsl(0 0% 95%);
|
||||
--border: hsl(0 0% 16%);
|
||||
--input: hsl(0 0% 5%);
|
||||
--ring: hsl(0, 0%, 50%);
|
||||
--caution: hsl(38, 92%, 60%);
|
||||
--caution-foreground: hsl(0, 0%, 10%);
|
||||
--sidebar-background: hsl(0 0% 8%);
|
||||
--sidebar-foreground: hsl(0 0% 95%);
|
||||
--sidebar-primary: hsl(0 0% 95%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 15%);
|
||||
--sidebar-accent: hsl(0 0% 20%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 95%);
|
||||
--sidebar-border: hsl(0 0% 20%);
|
||||
--sidebar-ring: hsl(0 0% 83.1%);
|
||||
--sidebar: hsl(0 0% 6%);
|
||||
}
|
||||
|
||||
.dark .panel {
|
||||
--background: hsl(0 0% 10%);
|
||||
--foreground: hsl(0 0% 85%);
|
||||
--card: hsl(0, 0%, 10%);
|
||||
--card-foreground: hsl(0 0% 85%);
|
||||
--secondary: hsl(204, 67%, 9%);
|
||||
--secondary-border: hsl(204, 100%, 14%);
|
||||
--secondary-foreground: hsl(200, 98%, 63%);
|
||||
--muted: hsl(0 0% 22%);
|
||||
--accent: hsl(0, 0%, 15%);
|
||||
--accent-foreground: hsl(0 0% 93%);
|
||||
--border: hsl(0 0% 18%);
|
||||
--input: hsl(0 0% 22%);
|
||||
--ring: hsl(0, 0%, 52%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
/* Other default base styles */
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-primary/35 selection:text-primary-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Responsive breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: var(--font-inter), sans-serif;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xs: 0.72rem;
|
||||
--text-sm: 0.79rem;
|
||||
--text-base: 0.92rem;
|
||||
--text-base--line-height: calc(1.5 / 0.95);
|
||||
--text-xs--line-height: calc(1 / 0.8);
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: 0.82rem;
|
||||
--radius-md: 0.65rem;
|
||||
--radius-sm: 0.35rem;
|
||||
|
||||
/* Palette mapped to root design tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-hover: var(--popover-hover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-border: var(--secondary-border);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-constructive: var(--constructive);
|
||||
--color-constructive-foreground: var(--constructive-foreground);
|
||||
|
||||
--color-caution: var(--caution);
|
||||
--color-caution-foreground: var(--caution-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Chart colors */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Animations */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-x-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-y-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:vertical {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-thin {
|
||||
&::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Custom variant for dark mode */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Plugins */
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
:root {
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(0 0% 11%);
|
||||
--card: hsl(0, 0%, 100%);
|
||||
--card-foreground: hsl(0 0% 11%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-hover: hsl(0, 0%, 96%);
|
||||
--popover-foreground: hsl(0 0% 2%);
|
||||
--primary: hsl(200, 90%, 52%);
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--secondary: hsl(204, 100%, 97%);
|
||||
--secondary-border: hsl(204, 100%, 94%);
|
||||
--secondary-foreground: hsl(200, 98%, 39%);
|
||||
--muted: hsl(0 0% 85.1%);
|
||||
--muted-foreground: hsl(0 0% 50%);
|
||||
--accent: hsl(0, 0%, 96%);
|
||||
--accent-foreground: hsl(0 0% 2%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 100%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0, 0%, 100%);
|
||||
--border: hsl(0 0% 91%);
|
||||
--input: hsl(0, 0%, 100%);
|
||||
--ring: hsl(0, 0%, 55%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 96.1%);
|
||||
--sidebar-foreground: hsl(0 0% 2%);
|
||||
--sidebar-primary: hsl(0 0% 2%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||
--sidebar-accent: hsl(0 0% 85.1%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||
--sidebar-border: hsl(0 0% 85.1%);
|
||||
--sidebar-ring: hsl(0 0% 16.9%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
}
|
||||
|
||||
.panel {
|
||||
--background: hsl(210, 20%, 98%);
|
||||
--foreground: hsl(0 0% 13%);
|
||||
--card: hsl(0, 0%, 98%);
|
||||
--card-foreground: hsl(0 0% 13%);
|
||||
--primary-foreground: hsl(0, 0%, 98%);
|
||||
--secondary: hsl(204, 100%, 95%);
|
||||
--secondary-border: hsl(204, 100%, 92%);
|
||||
--secondary-foreground: hsl(200, 98%, 37%);
|
||||
--muted: hsl(0 0% 83.1%);
|
||||
--muted-foreground: hsl(0 0% 48%);
|
||||
--accent: hsl(0, 0%, 93%);
|
||||
--accent-foreground: hsl(0 0% 5%);
|
||||
--destructive-foreground: hsl(0, 0%, 98%);
|
||||
--constructive-foreground: hsl(0, 0%, 98%);
|
||||
--border: hsl(0 0% 87%);
|
||||
--input: hsl(0 0% 93%);
|
||||
--ring: hsl(0, 0%, 53%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(0, 0%, 5%);
|
||||
--foreground: hsl(0 0% 87%);
|
||||
--card: hsl(0, 0%, 5%);
|
||||
--card-foreground: hsl(0 0% 87%);
|
||||
--popover: hsl(0, 0%, 5%);
|
||||
--popover-hover: hsl(0, 0%, 13%);
|
||||
--popover-foreground: hsl(0 0% 95%);
|
||||
--secondary: hsl(204, 100%, 12%);
|
||||
--secondary-border: hsl(204, 100%, 15%);
|
||||
--secondary-foreground: hsl(200, 98%, 61%);
|
||||
--muted: hsl(0 0% 20%);
|
||||
--accent: hsl(0, 0%, 14%);
|
||||
--accent-foreground: hsl(0 0% 95%);
|
||||
--border: hsl(0 0% 16%);
|
||||
--input: hsl(0 0% 5%);
|
||||
--ring: hsl(0, 0%, 50%);
|
||||
--sidebar-background: hsl(0 0% 8%);
|
||||
--sidebar-foreground: hsl(0 0% 95%);
|
||||
--sidebar-primary: hsl(0 0% 95%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 15%);
|
||||
--sidebar-accent: hsl(0 0% 20%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 95%);
|
||||
--sidebar-border: hsl(0 0% 20%);
|
||||
--sidebar-ring: hsl(0 0% 83.1%);
|
||||
--sidebar: hsl(0 0% 6%);
|
||||
}
|
||||
|
||||
.dark .panel {
|
||||
--background: hsl(0 0% 10%);
|
||||
--foreground: hsl(0 0% 85%);
|
||||
--card: hsl(0, 0%, 10%);
|
||||
--card-foreground: hsl(0 0% 85%);
|
||||
--secondary: hsl(204, 67%, 9%);
|
||||
--secondary-border: hsl(204, 100%, 14%);
|
||||
--secondary-foreground: hsl(200, 98%, 63%);
|
||||
--muted: hsl(0 0% 22%);
|
||||
--accent: hsl(0, 0%, 15%);
|
||||
--accent-foreground: hsl(0 0% 93%);
|
||||
--border: hsl(0 0% 18%);
|
||||
--input: hsl(0 0% 22%);
|
||||
--ring: hsl(0, 0%, 52%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
/* Other default base styles */
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-primary/35 selection:text-primary-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Responsive breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: var(--font-inter), sans-serif;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xs: 0.72rem;
|
||||
--text-sm: 0.79rem;
|
||||
--text-base: 0.92rem;
|
||||
--text-base--line-height: calc(1.5 / 0.95);
|
||||
--text-xs--line-height: calc(1 / 0.8);
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: 0.82rem;
|
||||
--radius-md: 0.65rem;
|
||||
--radius-sm: 0.35rem;
|
||||
|
||||
/* Palette mapped to root design tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-hover: var(--popover-hover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-border: var(--secondary-border);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-constructive: var(--constructive);
|
||||
--color-constructive-foreground: var(--constructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Chart colors */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Animations */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-x-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-y-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:vertical {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-thin {
|
||||
&::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ import { ThemeProvider } from "next-themes";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||
import { TooltipProvider } from "../components/ui/tooltip";
|
||||
import { baseMetaData } from "./metadata";
|
||||
import { BotIdClient } from "botid/client";
|
||||
import { webEnv } from "@/env/web";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
const siteFont = Inter({ subsets: ["latin"] });
|
||||
@@ -36,6 +35,13 @@ export default function RootLayout({
|
||||
crossOrigin="anonymous"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
|
||||
{/* code to figma */}
|
||||
{/* <script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){var s=document.createElement('script');s.src='https://mcp.figma.com/mcp/html-to-design/capture.js';document.head.appendChild(s);})();`,
|
||||
}}
|
||||
/> */}
|
||||
</>
|
||||
)}
|
||||
</head>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_INFO, SITE_URL } from "@/site/brand";
|
||||
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export const baseMetaData: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hero } from "@/components/landing/hero";
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import type { Metadata } from "next";
|
||||
import { SITE_URL } from "@/site/brand";
|
||||
import { SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
alternates: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SOCIAL_LINKS } from "@/site/social";
|
||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy - OpenCut",
|
||||
|
||||
@@ -7,21 +7,21 @@ import type { KeyboardEvent, MouseEvent } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { EditorCore } from "@/core";
|
||||
import { MigrationDialog } from "@/project/components/migration-dialog";
|
||||
import { StoragePersistenceDialog } from "@/services/storage/components/storage-persistence-dialog";
|
||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
||||
import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useProjectsStore } from "./store";
|
||||
import type {
|
||||
TProjectMetadata,
|
||||
TProjectSortKey,
|
||||
TProjectSortOption,
|
||||
} from "@/project/types";
|
||||
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
|
||||
} from "@/lib/project/types";
|
||||
import { formatTimeCode } from "opencut-wasm";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
@@ -62,11 +62,11 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DeleteProjectDialog } from "@/project/components/delete-project-dialog";
|
||||
import { ProjectInfoDialog } from "@/project/components/project-info-dialog";
|
||||
import { RenameProjectDialog } from "@/project/components/rename-project-dialog";
|
||||
import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog";
|
||||
import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog";
|
||||
import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { ChangelogNotification } from "@/changelog/components/changelog-notification";
|
||||
|
||||
const formatProjectDuration = ({
|
||||
duration,
|
||||
}: {
|
||||
@@ -76,9 +76,8 @@ const formatProjectDuration = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationSeconds = mediaTimeToSeconds({ time: duration });
|
||||
const format = durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS";
|
||||
return formatTimecode({ time: duration, format }) ?? "";
|
||||
const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
|
||||
return formatTimeCode({ timeInSeconds: duration, format }) ?? "";
|
||||
};
|
||||
|
||||
const VIEW_MODE_OPTIONS = [
|
||||
@@ -107,7 +106,6 @@ export default function ProjectsPage() {
|
||||
<div className="bg-background min-h-screen">
|
||||
<MigrationDialog />
|
||||
<StoragePersistenceDialog />
|
||||
<ChangelogNotification />
|
||||
<ProjectsHeader />
|
||||
<ProjectsToolbar projectIds={projectsToDisplay.map((p) => p.id)} />
|
||||
<main className="mx-auto px-4 pt-2 pb-6 flex flex-col gap-4">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { TProjectSortKey } from "@/project/types";
|
||||
import type { TProjectSortKey } from "@/lib/project/types";
|
||||
|
||||
export type ProjectsViewMode = "grid" | "list";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/site/brand";
|
||||
import { SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Feed } from "feed";
|
||||
import { getPosts } from "@/blog/query";
|
||||
import { SITE_INFO, SITE_URL } from "@/site/brand";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SITE_URL } from "@/site/brand";
|
||||
import { getPosts } from "@/blog/query";
|
||||
import { SITE_URL } from "@/constants/site-constants";
|
||||
import { getPosts } from "@/lib/blog/query";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { SPONSORS, type Sponsor } from "@/site/sponsors";
|
||||
import { SPONSORS, type Sponsor } from "@/constants/site-constants";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SOCIAL_LINKS } from "@/site/social";
|
||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service - OpenCut",
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{
|
||||
label: string;
|
||||
value: number;
|
||||
}> = [
|
||||
{ label: "Light", value: 100 },
|
||||
{ label: "Medium", value: 200 },
|
||||
{ label: "Heavy", value: 500 },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10;
|
||||
@@ -1 +0,0 @@
|
||||
export const DEFAULT_BACKGROUND_COLOR = "#000000";
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { TCanvasSize } from "@/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,87 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Cancel01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getSortedReleases } from "../utils";
|
||||
import type { Release } from "../utils";
|
||||
|
||||
const STORAGE_KEY = "last-seen-version";
|
||||
|
||||
export function ChangelogNotification() {
|
||||
const [release, setRelease] = useState<Release | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const releases = getSortedReleases();
|
||||
const latest = releases[0];
|
||||
if (!latest) return;
|
||||
|
||||
let storedVersion: string | null = null;
|
||||
try {
|
||||
storedVersion = localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
|
||||
const isOutdated =
|
||||
storedVersion === null ||
|
||||
storedVersion.localeCompare(latest.version, undefined, {
|
||||
numeric: true,
|
||||
}) < 0;
|
||||
|
||||
// TODO(v0.4): revert to the standard "null = first-time visitor, record silently"
|
||||
// path. The null case intentionally shows the card for this release so existing
|
||||
// users who never had the key get the 0.3.0 announcement.
|
||||
if (!isOutdated) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, latest.version);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
setRelease(latest);
|
||||
}, []);
|
||||
|
||||
if (!release) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-5 left-5 z-50 flex w-72 flex-col gap-3 rounded-xl border bg-card p-4 shadow-lg">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold leading-snug">
|
||||
{release.title}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
v{release.version}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="-mr-1 -mt-1 shrink-0"
|
||||
onClick={() => setRelease(null)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{release.summary && (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{release.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button asChild size="sm">
|
||||
<Link href="/changelog" onClick={() => setRelease(null)}>
|
||||
See full changelog
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
version: "0.3.0"
|
||||
date: "2026-03-04"
|
||||
published: false
|
||||
title: "Title"
|
||||
description: "Description"
|
||||
changes:
|
||||
- type: new
|
||||
text: "Layout guides are back and expanded. TikTok was the only option before. Now includes a grid guide plus TikTok, Instagram Reels, YouTube Shorts, and Snapchat Spotlight."
|
||||
- type: new
|
||||
text: "Two new mask types: text and custom (draw any shape with the pen tool using bezier curves). Both support feather, invert, and stroke."
|
||||
- type: fixed
|
||||
text: "Preview panning now keeps working while the Masks tab is open."
|
||||
- type: new
|
||||
text: "Audio clips have a horizontal volume line you can drag up or down to change volume. The line position reflects the current volume setting and is consistent across clips: same dB always means the same position."
|
||||
- type: improved
|
||||
text: "Audio waveforms now show the actual shape of the audio. Quiet parts are visible, loud parts are tall, and any bar that peaks above 0 dBFS gets a colored tip at the top."
|
||||
- type: fixed
|
||||
text: "Trimming or cutting an audio clip no longer causes the waveform to compress or show the wrong section of audio."
|
||||
@@ -1,82 +0,0 @@
|
||||
import { allChangelogs } from "content-collections";
|
||||
|
||||
export type Change = { type: string; text: string };
|
||||
export type Release = (typeof allChangelogs)[number];
|
||||
|
||||
type ChangeSectionConfig = {
|
||||
title: string;
|
||||
order: number;
|
||||
collapsible?: boolean;
|
||||
};
|
||||
|
||||
const knownSectionConfigs: Record<string, ChangeSectionConfig> = {
|
||||
new: { title: "Features", order: 0 },
|
||||
improved: { title: "Improvements", order: 1 },
|
||||
fixed: { title: "Fixes", order: 2 },
|
||||
breaking: { title: "Breaking Changes", order: 3 },
|
||||
technical: {
|
||||
title: "Technical details",
|
||||
order: 4,
|
||||
collapsible: true,
|
||||
},
|
||||
};
|
||||
|
||||
function getSectionConfig({ type }: { type: string }): ChangeSectionConfig {
|
||||
return (
|
||||
knownSectionConfigs[type] ?? {
|
||||
title: type.charAt(0).toUpperCase() + type.slice(1),
|
||||
order: Number.MAX_SAFE_INTEGER,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function getSectionTitle({ type }: { type: string }): string {
|
||||
return getSectionConfig({ type }).title;
|
||||
}
|
||||
|
||||
export function isSectionCollapsible({ type }: { type: string }): boolean {
|
||||
return getSectionConfig({ type }).collapsible ?? false;
|
||||
}
|
||||
|
||||
export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
|
||||
const typeEncounterOrder = new Map<string, number>();
|
||||
const grouped = changes.reduce<Record<string, Change[]>>((acc, change) => {
|
||||
if (!typeEncounterOrder.has(change.type)) {
|
||||
typeEncounterOrder.set(change.type, typeEncounterOrder.size);
|
||||
}
|
||||
if (!acc[change.type]) acc[change.type] = [];
|
||||
acc[change.type].push(change);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const orderedTypes = Object.keys(grouped).sort((left, right) => {
|
||||
const orderDifference =
|
||||
getSectionConfig({ type: left }).order -
|
||||
getSectionConfig({ type: right }).order;
|
||||
if (orderDifference !== 0) {
|
||||
return orderDifference;
|
||||
}
|
||||
return (
|
||||
(typeEncounterOrder.get(left) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(typeEncounterOrder.get(right) ?? Number.MAX_SAFE_INTEGER)
|
||||
);
|
||||
});
|
||||
|
||||
return { grouped, orderedTypes };
|
||||
}
|
||||
|
||||
function isPublishedRelease({ published }: Release) {
|
||||
return published !== false;
|
||||
}
|
||||
|
||||
export function getSortedReleases() {
|
||||
return allChangelogs
|
||||
.filter(isPublishedRelease)
|
||||
.sort((a, b) =>
|
||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||
);
|
||||
}
|
||||
|
||||
export function getReleaseByVersion({ version }: { version: string }) {
|
||||
return getSortedReleases().find((release) => release.version === version);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { PasteCommand } from "@/commands/timeline";
|
||||
import type { ClipboardHandler } from "../types";
|
||||
|
||||
export const ElementsClipboardHandler = {
|
||||
type: "elements",
|
||||
|
||||
canCopy({ selectedElements }) {
|
||||
return selectedElements.length > 0;
|
||||
},
|
||||
|
||||
copy({ editor, selectedElements }) {
|
||||
if (selectedElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
});
|
||||
const items = results.map(({ track, element }) => {
|
||||
const { id: _elementId, ...elementWithoutId } = element;
|
||||
return {
|
||||
trackId: track.id,
|
||||
trackType: track.type,
|
||||
element: elementWithoutId,
|
||||
};
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "elements",
|
||||
items,
|
||||
};
|
||||
},
|
||||
|
||||
paste(entry, context) {
|
||||
if (entry.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PasteCommand({
|
||||
time: context.time,
|
||||
clipboardItems: entry.items,
|
||||
});
|
||||
},
|
||||
} satisfies ClipboardHandler<"elements">;
|
||||
@@ -1,49 +0,0 @@
|
||||
import type {
|
||||
ClipboardEntry,
|
||||
ClipboardEntryType,
|
||||
ClipboardHandler,
|
||||
ClipboardHandlerMap,
|
||||
CopyContext,
|
||||
PasteContext,
|
||||
} from "../types";
|
||||
import { ElementsClipboardHandler } from "./elements";
|
||||
import { KeyframesClipboardHandler } from "./keyframes";
|
||||
|
||||
export const clipboardHandlers = {
|
||||
elements: ElementsClipboardHandler,
|
||||
keyframes: KeyframesClipboardHandler,
|
||||
} satisfies ClipboardHandlerMap;
|
||||
|
||||
export const clipboardCopyHandlers = [
|
||||
KeyframesClipboardHandler,
|
||||
ElementsClipboardHandler,
|
||||
] as const satisfies readonly ClipboardHandler<ClipboardEntryType>[];
|
||||
|
||||
export function copyClipboardEntry({
|
||||
context,
|
||||
}: {
|
||||
context: CopyContext;
|
||||
}): ClipboardEntry | null {
|
||||
for (const handler of clipboardCopyHandlers) {
|
||||
if (!handler.canCopy(context)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return handler.copy(context);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildPasteClipboardCommand({
|
||||
entry,
|
||||
context,
|
||||
}: {
|
||||
entry: ClipboardEntry;
|
||||
context: PasteContext;
|
||||
}) {
|
||||
const handler = clipboardHandlers[entry.type] as ClipboardHandler<
|
||||
typeof entry.type
|
||||
>;
|
||||
return handler.paste(entry as never, context);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import { getKeyframeById } from "@/animation";
|
||||
import type { SelectedKeyframeRef } from "@/animation/types";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import { PasteKeyframesCommand } from "@/commands/timeline";
|
||||
import type {
|
||||
ClipboardHandler,
|
||||
KeyframeClipboardCurvePatch,
|
||||
KeyframeClipboardItem,
|
||||
} from "../types";
|
||||
|
||||
function resolveSingleSourceElement({
|
||||
selectedKeyframes,
|
||||
}: {
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
}) {
|
||||
const firstKeyframe = selectedKeyframes[0];
|
||||
if (!firstKeyframe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceElement = {
|
||||
trackId: firstKeyframe.trackId,
|
||||
elementId: firstKeyframe.elementId,
|
||||
};
|
||||
const isSingleSource = selectedKeyframes.every(
|
||||
(keyframe) =>
|
||||
keyframe.trackId === sourceElement.trackId &&
|
||||
keyframe.elementId === sourceElement.elementId,
|
||||
);
|
||||
|
||||
return isSingleSource ? sourceElement : null;
|
||||
}
|
||||
|
||||
function getCurvePatches({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: KeyframeClipboardItem["propertyPath"];
|
||||
keyframeId: string;
|
||||
}): KeyframeClipboardCurvePatch[] {
|
||||
const binding = element.animations?.bindings[propertyPath];
|
||||
if (!binding) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return binding.components.flatMap((component) => {
|
||||
const channel = element.animations?.channels[component.channelId];
|
||||
if (channel?.kind !== "scalar") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keyframe = channel.keys.find(
|
||||
(candidate) => candidate.id === keyframeId,
|
||||
);
|
||||
if (!keyframe) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
componentKey: component.key,
|
||||
patch: {
|
||||
leftHandle: keyframe.leftHandle ?? null,
|
||||
rightHandle: keyframe.rightHandle ?? null,
|
||||
segmentToNext: keyframe.segmentToNext,
|
||||
tangentMode: keyframe.tangentMode,
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function buildClipboardItem({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: KeyframeClipboardItem["propertyPath"];
|
||||
keyframeId: string;
|
||||
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null {
|
||||
const keyframe = getKeyframeById({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
});
|
||||
if (!keyframe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
propertyPath,
|
||||
time: keyframe.time,
|
||||
value: keyframe.value,
|
||||
interpolation: keyframe.interpolation,
|
||||
curvePatches: getCurvePatches({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const KeyframesClipboardHandler = {
|
||||
type: "keyframes",
|
||||
|
||||
canCopy({ selectedKeyframes }) {
|
||||
return selectedKeyframes.length > 0;
|
||||
},
|
||||
|
||||
copy({ editor, selectedKeyframes }) {
|
||||
const sourceElement = resolveSingleSourceElement({ selectedKeyframes });
|
||||
if (!sourceElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [result] = editor.timeline.getElementsWithTracks({
|
||||
elements: [sourceElement],
|
||||
});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawItems = selectedKeyframes.flatMap((keyframeRef) => {
|
||||
const item = buildClipboardItem({
|
||||
element: result.element,
|
||||
propertyPath: keyframeRef.propertyPath,
|
||||
keyframeId: keyframeRef.keyframeId,
|
||||
});
|
||||
return item ? [item] : [];
|
||||
});
|
||||
if (rawItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minTime = Math.min(...rawItems.map((item) => item.time));
|
||||
const items = rawItems
|
||||
.map(({ time, ...item }) => ({
|
||||
...item,
|
||||
timeOffset: time - minTime,
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.timeOffset - right.timeOffset ||
|
||||
left.propertyPath.localeCompare(right.propertyPath),
|
||||
);
|
||||
|
||||
return {
|
||||
type: "keyframes",
|
||||
sourceElement,
|
||||
items,
|
||||
};
|
||||
},
|
||||
|
||||
paste(entry, { selectedElements, time }) {
|
||||
const targetElement = selectedElements[0];
|
||||
if (!targetElement || entry.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PasteKeyframesCommand({
|
||||
trackId: targetElement.trackId,
|
||||
elementId: targetElement.elementId,
|
||||
time,
|
||||
clipboardItems: entry.items,
|
||||
});
|
||||
},
|
||||
} satisfies ClipboardHandler<"keyframes">;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./types";
|
||||
export * from "./handlers";
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type {
|
||||
AnimationInterpolation,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
ScalarCurveKeyframePatch,
|
||||
SelectedKeyframeRef,
|
||||
} from "@/animation/types";
|
||||
import type { Command } from "@/commands/base-command";
|
||||
import type {
|
||||
CreateTimelineElement,
|
||||
ElementRef,
|
||||
TrackType,
|
||||
} from "@/timeline";
|
||||
|
||||
export interface ElementClipboardItem {
|
||||
trackId: string;
|
||||
trackType: TrackType;
|
||||
element: CreateTimelineElement;
|
||||
}
|
||||
|
||||
export interface KeyframeClipboardCurvePatch {
|
||||
componentKey: string;
|
||||
patch: ScalarCurveKeyframePatch;
|
||||
}
|
||||
|
||||
export interface KeyframeClipboardItem {
|
||||
propertyPath: AnimationPath;
|
||||
timeOffset: number;
|
||||
value: AnimationValue;
|
||||
interpolation: AnimationInterpolation;
|
||||
curvePatches: KeyframeClipboardCurvePatch[];
|
||||
}
|
||||
|
||||
export interface ElementsClipboardEntry {
|
||||
type: "elements";
|
||||
items: ElementClipboardItem[];
|
||||
}
|
||||
|
||||
export interface KeyframesClipboardEntry {
|
||||
type: "keyframes";
|
||||
sourceElement: ElementRef;
|
||||
items: KeyframeClipboardItem[];
|
||||
}
|
||||
|
||||
export interface ClipboardEntryByType {
|
||||
elements: ElementsClipboardEntry;
|
||||
keyframes: KeyframesClipboardEntry;
|
||||
}
|
||||
|
||||
export type ClipboardEntry = ClipboardEntryByType[keyof ClipboardEntryByType];
|
||||
export type ClipboardEntryType = keyof ClipboardEntryByType;
|
||||
|
||||
export interface CopyContext {
|
||||
editor: EditorCore;
|
||||
selectedElements: ElementRef[];
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
}
|
||||
|
||||
export interface PasteContext {
|
||||
editor: EditorCore;
|
||||
selectedElements: ElementRef[];
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface ClipboardHandler<TType extends ClipboardEntryType> {
|
||||
type: TType;
|
||||
canCopy(context: CopyContext): boolean;
|
||||
copy(context: CopyContext): ClipboardEntryByType[TType] | null;
|
||||
paste(
|
||||
entry: ClipboardEntryByType[TType],
|
||||
context: PasteContext,
|
||||
): Command | null;
|
||||
}
|
||||
|
||||
export type ClipboardHandlerMap = {
|
||||
[TType in ClipboardEntryType]: ClipboardHandler<TType>;
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { EditorSelectionPatch } from "@/selection/editor-selection";
|
||||
import type { ElementRef } from "@/timeline/types";
|
||||
|
||||
export interface CommandResult {
|
||||
selection?: EditorSelectionPatch;
|
||||
}
|
||||
|
||||
export function createElementSelectionResult(
|
||||
selectedElements: ElementRef[],
|
||||
): CommandResult {
|
||||
return {
|
||||
selection: {
|
||||
selectedElements,
|
||||
selectedKeyframes: [],
|
||||
keyframeSelectionAnchor: null,
|
||||
selectedMaskPoints: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export abstract class Command {
|
||||
abstract execute(): CommandResult | undefined;
|
||||
|
||||
undo(): void {
|
||||
throw new Error("Undo not implemented for this command");
|
||||
}
|
||||
|
||||
redo(): CommandResult | undefined {
|
||||
return this.execute();
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { PasteCommand } from "./paste";
|
||||
export { PasteKeyframesCommand } from "./paste-keyframes";
|
||||
@@ -1,135 +0,0 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
getKeyframeAtTime,
|
||||
updateScalarKeyframeCurve,
|
||||
upsertPathKeyframe,
|
||||
} from "@/animation";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { KeyframeClipboardItem } from "@/clipboard";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
|
||||
function pasteKeyframesIntoElement({
|
||||
element,
|
||||
time,
|
||||
clipboardItems,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
time: number;
|
||||
clipboardItems: KeyframeClipboardItem[];
|
||||
}): TimelineElement {
|
||||
let nextElement = element;
|
||||
|
||||
for (const item of clipboardItems) {
|
||||
const target = resolveAnimationTarget({
|
||||
element: nextElement,
|
||||
path: item.propertyPath,
|
||||
});
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyframeTime = Math.max(
|
||||
0,
|
||||
Math.min(time + item.timeOffset, nextElement.duration),
|
||||
);
|
||||
const nextAnimations = upsertPathKeyframe({
|
||||
animations: nextElement.animations,
|
||||
propertyPath: item.propertyPath,
|
||||
time: keyframeTime,
|
||||
value: item.value,
|
||||
interpolation: item.interpolation,
|
||||
keyframeId: generateUUID(),
|
||||
kind: target.kind,
|
||||
defaultInterpolation: target.defaultInterpolation,
|
||||
coerceValue: target.coerceValue,
|
||||
});
|
||||
const pastedKeyframe = getKeyframeAtTime({
|
||||
animations: nextAnimations,
|
||||
propertyPath: item.propertyPath,
|
||||
time: keyframeTime,
|
||||
});
|
||||
|
||||
let patchedAnimations = nextAnimations;
|
||||
if (pastedKeyframe) {
|
||||
for (const curvePatch of item.curvePatches) {
|
||||
const nextPatchedAnimations = updateScalarKeyframeCurve({
|
||||
animations: patchedAnimations,
|
||||
propertyPath: item.propertyPath,
|
||||
componentKey: curvePatch.componentKey,
|
||||
keyframeId: pastedKeyframe.id,
|
||||
patch: curvePatch.patch,
|
||||
});
|
||||
patchedAnimations = nextPatchedAnimations ?? patchedAnimations;
|
||||
}
|
||||
}
|
||||
|
||||
nextElement = {
|
||||
...nextElement,
|
||||
animations: patchedAnimations,
|
||||
};
|
||||
}
|
||||
|
||||
return nextElement;
|
||||
}
|
||||
|
||||
export class PasteKeyframesCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly time: number;
|
||||
private readonly clipboardItems: KeyframeClipboardItem[];
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
time,
|
||||
clipboardItems,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
time: number;
|
||||
clipboardItems: KeyframeClipboardItem[];
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.time = time;
|
||||
this.clipboardItems = clipboardItems;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
if (this.clipboardItems.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
const updatedTracks = updateElementInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
update: (element) =>
|
||||
pasteKeyframesIntoElement({
|
||||
element,
|
||||
time: this.time,
|
||||
clipboardItems: this.clipboardItems,
|
||||
}),
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TimelineTrack } from "@/timeline";
|
||||
|
||||
function removeTrackElements<TTrack extends TimelineTrack>({
|
||||
track,
|
||||
elements,
|
||||
}: {
|
||||
track: TTrack;
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}): TTrack {
|
||||
const nextElements = track.elements.filter(
|
||||
(element) =>
|
||||
!elements.some(
|
||||
(target) =>
|
||||
target.trackId === track.id && target.elementId === element.id,
|
||||
),
|
||||
);
|
||||
|
||||
return { ...track, elements: nextElements } as TTrack;
|
||||
}
|
||||
|
||||
export class DeleteElementsCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
|
||||
constructor({
|
||||
elements,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
const updatedTracks: SceneTracks = {
|
||||
overlay: this.savedState.overlay.map((track) =>
|
||||
removeTrackElements({ track, elements: this.elements }),
|
||||
),
|
||||
main: removeTrackElements({
|
||||
track: this.savedState.main,
|
||||
elements: this.elements,
|
||||
}),
|
||||
audio: this.savedState.audio.map((track) =>
|
||||
removeTrackElements({ track, elements: this.elements }),
|
||||
),
|
||||
};
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
return {
|
||||
selection: {
|
||||
selectedElements: [],
|
||||
selectedKeyframes: [],
|
||||
keyframeSelectionAnchor: null,
|
||||
selectedMaskPoints: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export { InsertElementCommand } from "./insert-element";
|
||||
export { DeleteElementsCommand } from "./delete-elements";
|
||||
export { DuplicateElementsCommand } from "./duplicate-elements";
|
||||
export { SplitElementsCommand } from "./split-elements";
|
||||
export { UpdateElementsCommand } from "./update-elements";
|
||||
export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation";
|
||||
export { MoveElementCommand } from "./move-elements";
|
||||
|
||||
export * from "./keyframes";
|
||||
export * from "./effects";
|
||||
export * from "./masks";
|
||||
@@ -1,107 +0,0 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
hasKeyframesForPath,
|
||||
removeElementKeyframe,
|
||||
} from "@/animation";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import type { AnimationPath, AnimationValue } from "@/animation/types";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
|
||||
function removeKeyframeAndPersist({
|
||||
element,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
valueAtPlayhead,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
valueAtPlayhead: AnimationValue | null;
|
||||
}): TimelineElement {
|
||||
const target = resolveAnimationTarget({ element, path: propertyPath });
|
||||
if (!target) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const nextAnimations = removeElementKeyframe({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
});
|
||||
|
||||
const isChannelNowEmpty = !hasKeyframesForPath({
|
||||
animations: nextAnimations,
|
||||
propertyPath,
|
||||
});
|
||||
|
||||
// When clearing all keyframes, preserve the value at the playhead (what the user was seeing)
|
||||
const shouldPersistToBase = isChannelNowEmpty && valueAtPlayhead !== null;
|
||||
|
||||
const baseElement = shouldPersistToBase
|
||||
? target.setBaseValue({ value: valueAtPlayhead })
|
||||
: element;
|
||||
|
||||
return { ...baseElement, animations: nextAnimations };
|
||||
}
|
||||
|
||||
export class RemoveKeyframeCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPath;
|
||||
private readonly keyframeId: string;
|
||||
private readonly valueAtPlayhead: AnimationValue | null;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
valueAtPlayhead,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
valueAtPlayhead: AnimationValue | null;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.propertyPath = propertyPath;
|
||||
this.keyframeId = keyframeId;
|
||||
this.valueAtPlayhead = valueAtPlayhead;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
const updatedTracks = updateElementInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
update: (element) =>
|
||||
removeKeyframeAndPersist({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
keyframeId: this.keyframeId,
|
||||
valueAtPlayhead: this.valueAtPlayhead,
|
||||
}),
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
updateScalarKeyframeCurve,
|
||||
} from "@/animation";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { updateElementInSceneTracks } from "@/timeline";
|
||||
import { resolveAnimationTarget } from "@/timeline/animation-targets";
|
||||
import type {
|
||||
AnimationPath,
|
||||
ScalarCurveKeyframePatch,
|
||||
} from "@/animation/types";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
|
||||
export class UpdateScalarKeyframeCurveCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPath;
|
||||
private readonly componentKey: string;
|
||||
private readonly keyframeId: string;
|
||||
private readonly patch: ScalarCurveKeyframePatch;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
componentKey,
|
||||
keyframeId,
|
||||
patch,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
componentKey: string;
|
||||
keyframeId: string;
|
||||
patch: ScalarCurveKeyframePatch;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.propertyPath = propertyPath;
|
||||
this.componentKey = componentKey;
|
||||
this.keyframeId = keyframeId;
|
||||
this.patch = patch;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
const updatedTracks = updateElementInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
update: (element) => {
|
||||
if (!resolveAnimationTarget({ element, path: this.propertyPath })) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
animations: updateScalarKeyframeCurve({
|
||||
animations: element.animations,
|
||||
propertyPath: this.propertyPath,
|
||||
componentKey: this.componentKey,
|
||||
keyframeId: this.keyframeId,
|
||||
patch: this.patch,
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import {
|
||||
getCustomMaskClosedStateAfterPointRemoval,
|
||||
removeCustomMaskPoints,
|
||||
} from "@/masks/custom-path";
|
||||
import type { CustomMask } from "@/masks/types";
|
||||
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { MaskableElement, SceneTracks } from "@/timeline";
|
||||
|
||||
function deletePointsFromCustomMask({
|
||||
mask,
|
||||
pointIds,
|
||||
}: {
|
||||
mask: CustomMask;
|
||||
pointIds: string[];
|
||||
}): CustomMask {
|
||||
const points = mask.params.path;
|
||||
const nextPoints = removeCustomMaskPoints({ points, pointIds });
|
||||
if (nextPoints.length === points.length) {
|
||||
return mask;
|
||||
}
|
||||
|
||||
return {
|
||||
...mask,
|
||||
params: {
|
||||
...mask.params,
|
||||
path: nextPoints,
|
||||
closed: getCustomMaskClosedStateAfterPointRemoval({
|
||||
wasClosed: mask.params.closed,
|
||||
remainingPointCount: nextPoints.length,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deletePointsFromElementMask({
|
||||
element,
|
||||
maskId,
|
||||
pointIds,
|
||||
}: {
|
||||
element: MaskableElement;
|
||||
maskId: string;
|
||||
pointIds: string[];
|
||||
}): { element: MaskableElement; didDeletePoints: boolean } {
|
||||
const currentMasks = element.masks ?? [];
|
||||
let didDeletePoints = false;
|
||||
const nextMasks = currentMasks.map((mask) => {
|
||||
if (mask.id !== maskId || mask.type !== "custom") {
|
||||
return mask;
|
||||
}
|
||||
|
||||
const nextMask = deletePointsFromCustomMask({
|
||||
mask,
|
||||
pointIds,
|
||||
});
|
||||
didDeletePoints ||= nextMask !== mask;
|
||||
return nextMask;
|
||||
});
|
||||
|
||||
return {
|
||||
element: didDeletePoints ? { ...element, masks: nextMasks } : element,
|
||||
didDeletePoints,
|
||||
};
|
||||
}
|
||||
|
||||
export class DeleteCustomMaskPointsCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly maskId: string;
|
||||
private readonly pointIds: string[];
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
pointIds,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
pointIds: string[];
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.maskId = maskId;
|
||||
this.pointIds = pointIds;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
let didDeletePoints = false;
|
||||
const updatedTracks = updateElementInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isMaskableElement,
|
||||
update: (element) => {
|
||||
const result = deletePointsFromElementMask({
|
||||
element: element as MaskableElement,
|
||||
maskId: this.maskId,
|
||||
pointIds: this.pointIds,
|
||||
});
|
||||
didDeletePoints ||= result.didDeletePoints;
|
||||
return result.element;
|
||||
},
|
||||
});
|
||||
|
||||
if (didDeletePoints) {
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return {
|
||||
selection: {
|
||||
selectedMaskPoints: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points";
|
||||
export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point";
|
||||
export { RemoveMaskCommand } from "./remove-mask";
|
||||
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
|
||||
@@ -1,169 +0,0 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom";
|
||||
import type { ElementBounds } from "@/preview/element-bounds";
|
||||
import type { CustomMask } from "@/masks/types";
|
||||
import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
|
||||
import type { MaskableElement, SceneTracks } from "@/timeline";
|
||||
|
||||
function insertPointIntoCustomMask({
|
||||
mask,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
}: {
|
||||
mask: CustomMask;
|
||||
segmentIndex: number;
|
||||
canvasPoint: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}): { mask: CustomMask; insertedPointId: string | null } {
|
||||
const result = insertPointOnCustomMaskSegment({
|
||||
params: mask.params,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
});
|
||||
if (!result) {
|
||||
return {
|
||||
mask,
|
||||
insertedPointId: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mask: {
|
||||
...mask,
|
||||
params: result.params,
|
||||
},
|
||||
insertedPointId: result.pointId,
|
||||
};
|
||||
}
|
||||
|
||||
function insertPointIntoElementMask({
|
||||
element,
|
||||
maskId,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
}: {
|
||||
element: MaskableElement;
|
||||
maskId: string;
|
||||
segmentIndex: number;
|
||||
canvasPoint: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}): {
|
||||
element: MaskableElement;
|
||||
didInsertPoint: boolean;
|
||||
insertedPointId: string | null;
|
||||
} {
|
||||
const currentMasks = element.masks ?? [];
|
||||
let insertedPointId: string | null = null;
|
||||
let didInsertPoint = false;
|
||||
|
||||
const nextMasks = currentMasks.map((mask) => {
|
||||
if (mask.id !== maskId || mask.type !== "custom") {
|
||||
return mask;
|
||||
}
|
||||
|
||||
const result = insertPointIntoCustomMask({
|
||||
mask,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
});
|
||||
if (result.insertedPointId) {
|
||||
insertedPointId = result.insertedPointId;
|
||||
didInsertPoint = true;
|
||||
}
|
||||
return result.mask;
|
||||
});
|
||||
|
||||
return {
|
||||
element: didInsertPoint ? { ...element, masks: nextMasks } : element,
|
||||
didInsertPoint,
|
||||
insertedPointId,
|
||||
};
|
||||
}
|
||||
|
||||
export class InsertCustomMaskPointCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly maskId: string;
|
||||
private readonly segmentIndex: number;
|
||||
private readonly canvasPoint: { x: number; y: number };
|
||||
private readonly bounds: ElementBounds;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
segmentIndex,
|
||||
canvasPoint,
|
||||
bounds,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
segmentIndex: number;
|
||||
canvasPoint: { x: number; y: number };
|
||||
bounds: ElementBounds;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.maskId = maskId;
|
||||
this.segmentIndex = segmentIndex;
|
||||
this.canvasPoint = canvasPoint;
|
||||
this.bounds = bounds;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
let didInsertPoint = false;
|
||||
let insertedPointId: string | null = null;
|
||||
const updatedTracks = updateElementInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isMaskableElement,
|
||||
update: (element) => {
|
||||
const result = insertPointIntoElementMask({
|
||||
element: element as MaskableElement,
|
||||
maskId: this.maskId,
|
||||
segmentIndex: this.segmentIndex,
|
||||
canvasPoint: this.canvasPoint,
|
||||
bounds: this.bounds,
|
||||
});
|
||||
didInsertPoint ||= result.didInsertPoint;
|
||||
insertedPointId ??= result.insertedPointId;
|
||||
return result.element;
|
||||
},
|
||||
});
|
||||
|
||||
if (!didInsertPoint || !insertedPointId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return {
|
||||
selection: {
|
||||
selectedMaskPoints: {
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
maskId: this.maskId,
|
||||
pointIds: [insertedPointId],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import {
|
||||
Command,
|
||||
createElementSelectionResult,
|
||||
type CommandResult,
|
||||
} from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
validateElementTrackCompatibility,
|
||||
} from "@/timeline/placement";
|
||||
import type {
|
||||
PlannedElementMove,
|
||||
PlannedTrackCreation,
|
||||
} from "@/timeline/group-move";
|
||||
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
||||
|
||||
export class MoveElementCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
|
||||
constructor({
|
||||
moves,
|
||||
createTracks = [],
|
||||
}: {
|
||||
moves: PlannedElementMove[];
|
||||
createTracks?: PlannedTrackCreation[];
|
||||
}) {
|
||||
super();
|
||||
this.moves = moves;
|
||||
this.createTracks = createTracks;
|
||||
}
|
||||
|
||||
private readonly moves: PlannedElementMove[];
|
||||
private readonly createTracks: PlannedTrackCreation[];
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
let tracksToUpdate = this.savedState;
|
||||
for (const createTrack of [...this.createTracks].sort(
|
||||
(firstTrack, secondTrack) => firstTrack.index - secondTrack.index,
|
||||
)) {
|
||||
tracksToUpdate = insertTrackAtDisplayIndex({
|
||||
tracks: tracksToUpdate,
|
||||
track: buildEmptyTrack({
|
||||
id: createTrack.id,
|
||||
type: createTrack.type,
|
||||
}),
|
||||
insertIndex: createTrack.index,
|
||||
});
|
||||
}
|
||||
|
||||
const movedElementsById = new Map<string, TimelineElement>();
|
||||
for (const move of this.moves) {
|
||||
const sourceTrack = findTrackInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: move.sourceTrackId,
|
||||
});
|
||||
const sourceElement = sourceTrack?.elements.find(
|
||||
(trackElement) => trackElement.id === move.elementId,
|
||||
);
|
||||
if (!sourceTrack || !sourceElement) {
|
||||
throw new Error("Source track or element not found");
|
||||
}
|
||||
|
||||
const targetTrack = findTrackInSceneTracks({
|
||||
tracks: tracksToUpdate,
|
||||
trackId: move.targetTrackId,
|
||||
});
|
||||
if (!targetTrack) {
|
||||
throw new Error("Target track not found");
|
||||
}
|
||||
|
||||
const validation = validateElementTrackCompatibility({
|
||||
element: sourceElement,
|
||||
track: targetTrack,
|
||||
});
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.errorMessage);
|
||||
}
|
||||
|
||||
movedElementsById.set(move.elementId, {
|
||||
...sourceElement,
|
||||
startTime: move.newStartTime,
|
||||
});
|
||||
}
|
||||
|
||||
const movedElementIds = new Set(this.moves.map((move) => move.elementId));
|
||||
const movedElementsByTargetTrackId = new Map<string, TimelineElement[]>();
|
||||
for (const move of this.moves) {
|
||||
const movedElement = movedElementsById.get(move.elementId);
|
||||
if (!movedElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextTargetElements =
|
||||
movedElementsByTargetTrackId.get(move.targetTrackId) ?? [];
|
||||
nextTargetElements.push(movedElement);
|
||||
movedElementsByTargetTrackId.set(move.targetTrackId, nextTargetElements);
|
||||
}
|
||||
|
||||
const updatedTracks = mapSceneTracks({
|
||||
tracks: tracksToUpdate,
|
||||
update: (track) => ({
|
||||
...track,
|
||||
elements: [
|
||||
...track.elements.filter(
|
||||
(element) => !movedElementIds.has(element.id),
|
||||
),
|
||||
...(movedElementsByTargetTrackId.get(track.id) ?? []),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return createElementSelectionResult(
|
||||
this.moves.map(({ elementId, targetTrackId }) => ({
|
||||
trackId: targetTrackId,
|
||||
elementId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapSceneTracks({
|
||||
tracks,
|
||||
update,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack;
|
||||
}): SceneTracks {
|
||||
return {
|
||||
overlay: tracks.overlay.map((track) => update(track)),
|
||||
main: update(tracks.main),
|
||||
audio: tracks.audio.map((track) => update(track)),
|
||||
};
|
||||
}
|
||||
|
||||
function insertTrackAtDisplayIndex({
|
||||
tracks,
|
||||
track,
|
||||
insertIndex,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
track: TimelineTrack;
|
||||
insertIndex: number;
|
||||
}): SceneTracks {
|
||||
if (track.type === "audio") {
|
||||
const audioInsertIndex = Math.max(
|
||||
0,
|
||||
Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length),
|
||||
);
|
||||
return {
|
||||
...tracks,
|
||||
audio: [
|
||||
...tracks.audio.slice(0, audioInsertIndex),
|
||||
track,
|
||||
...tracks.audio.slice(audioInsertIndex),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const overlayInsertIndex = Math.max(
|
||||
0,
|
||||
Math.min(insertIndex, tracks.overlay.length),
|
||||
);
|
||||
return {
|
||||
...tracks,
|
||||
overlay: [
|
||||
...tracks.overlay.slice(0, overlayInsertIndex),
|
||||
track,
|
||||
...tracks.overlay.slice(overlayInsertIndex),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import {
|
||||
Command,
|
||||
createElementSelectionResult,
|
||||
type CommandResult,
|
||||
} from "@/commands/base-command";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isRetimableElement } from "@/timeline";
|
||||
import { splitAnimationsAtTime } from "@/animation";
|
||||
import { getSourceSpanAtClipTime } from "@/retime";
|
||||
|
||||
export class SplitElementsCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private rightSideElements: { trackId: string; elementId: string }[] = [];
|
||||
private readonly elements: { trackId: string; elementId: string }[];
|
||||
private readonly splitTime: number;
|
||||
private readonly retainSide: "both" | "left" | "right";
|
||||
|
||||
constructor({
|
||||
elements,
|
||||
splitTime,
|
||||
retainSide = "both",
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
splitTime: number;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
}) {
|
||||
super();
|
||||
this.elements = elements;
|
||||
this.splitTime = splitTime;
|
||||
this.retainSide = retainSide;
|
||||
}
|
||||
|
||||
getRightSideElements(): { trackId: string; elementId: string }[] {
|
||||
return this.rightSideElements;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
this.rightSideElements = [];
|
||||
|
||||
const splitTrack = <
|
||||
TTrack extends { id: string; elements: TimelineElement[] },
|
||||
>(
|
||||
track: TTrack,
|
||||
): TTrack => {
|
||||
const elementsToSplit = this.elements.filter(
|
||||
(target) => target.trackId === track.id,
|
||||
);
|
||||
|
||||
if (elementsToSplit.length === 0) {
|
||||
return track;
|
||||
}
|
||||
|
||||
const elements = track.elements.flatMap((element) => {
|
||||
const shouldSplit = elementsToSplit.some(
|
||||
(target) => target.elementId === element.id,
|
||||
);
|
||||
|
||||
if (!shouldSplit) {
|
||||
return [element];
|
||||
}
|
||||
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd = element.startTime + element.duration;
|
||||
|
||||
if (
|
||||
this.splitTime <= effectiveStart ||
|
||||
this.splitTime >= effectiveEnd
|
||||
) {
|
||||
return [element];
|
||||
}
|
||||
|
||||
const relativeTime = this.splitTime - element.startTime;
|
||||
const leftVisibleDuration = relativeTime;
|
||||
const rightVisibleDuration = element.duration - relativeTime;
|
||||
const retimeRef = isRetimableElement(element)
|
||||
? element.retime
|
||||
: undefined;
|
||||
const leftSourceSpan = getSourceSpanAtClipTime({
|
||||
clipTime: leftVisibleDuration,
|
||||
retime: retimeRef,
|
||||
});
|
||||
const totalSourceSpan = getSourceSpanAtClipTime({
|
||||
clipTime: element.duration,
|
||||
retime: retimeRef,
|
||||
});
|
||||
const rightSourceSpan = totalSourceSpan - leftSourceSpan;
|
||||
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
|
||||
animations: element.animations,
|
||||
splitTime: relativeTime,
|
||||
shouldIncludeSplitBoundary: true,
|
||||
});
|
||||
let splitResult: TimelineElement[];
|
||||
|
||||
if (this.retainSide === "left") {
|
||||
splitResult = [
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
} else if (this.retainSide === "right") {
|
||||
const newId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
elementId: newId,
|
||||
});
|
||||
splitResult = [
|
||||
{
|
||||
...element,
|
||||
id: newId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// "both" - split into two pieces
|
||||
const secondElementId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
elementId: secondElementId,
|
||||
});
|
||||
splitResult = [
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
{
|
||||
...element,
|
||||
id: secondElementId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return splitResult;
|
||||
});
|
||||
|
||||
return { ...track, elements } as TTrack;
|
||||
};
|
||||
|
||||
const updatedTracks: SceneTracks = {
|
||||
overlay: this.savedState.overlay.map((track) => splitTrack(track)),
|
||||
main: splitTrack(this.savedState.main),
|
||||
audio: this.savedState.audio.map((track) => splitTrack(track)),
|
||||
};
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
if (this.rightSideElements.length > 0) {
|
||||
return createElementSelectionResult(this.rightSideElements);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||
import {
|
||||
findTrackInSceneTracks,
|
||||
updateElementInSceneTracks,
|
||||
} from "@/timeline";
|
||||
import { applyElementUpdate } from "@/timeline/update-pipeline";
|
||||
|
||||
export class UpdateElementsCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
private readonly updates: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
patch: Partial<TimelineElement>;
|
||||
}>;
|
||||
|
||||
constructor({
|
||||
updates,
|
||||
}: {
|
||||
updates: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
patch: Partial<TimelineElement>;
|
||||
}>;
|
||||
}) {
|
||||
super();
|
||||
this.updates = updates;
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
let updatedTracks = this.savedState;
|
||||
|
||||
for (const updateEntry of this.updates) {
|
||||
const currentTrack = findTrackInSceneTracks({
|
||||
tracks: updatedTracks,
|
||||
trackId: updateEntry.trackId,
|
||||
});
|
||||
const currentElement = currentTrack?.elements.find(
|
||||
(element) => element.id === updateEntry.elementId,
|
||||
);
|
||||
if (!currentTrack || !currentElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextElement = applyElementUpdate({
|
||||
element: currentElement,
|
||||
patch: updateEntry.patch,
|
||||
context: {
|
||||
tracks: updatedTracks,
|
||||
trackId: updateEntry.trackId,
|
||||
},
|
||||
});
|
||||
|
||||
updatedTracks = updateElementInSceneTracks({
|
||||
tracks: updatedTracks,
|
||||
trackId: updateEntry.trackId,
|
||||
elementId: updateEntry.elementId,
|
||||
update: () => nextElement,
|
||||
});
|
||||
}
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { SceneTracks, TrackType } from "@/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
getDefaultInsertIndexForTrack,
|
||||
} from "@/timeline/placement";
|
||||
|
||||
export class AddTrackCommand extends Command {
|
||||
private trackId: string;
|
||||
private savedState: SceneTracks | null = null;
|
||||
|
||||
constructor(
|
||||
private type: TrackType,
|
||||
private index?: number,
|
||||
) {
|
||||
super();
|
||||
this.trackId = generateUUID();
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
const insertIndex =
|
||||
this.index ??
|
||||
getDefaultInsertIndexForTrack({
|
||||
tracks: this.savedState,
|
||||
trackType: this.type,
|
||||
});
|
||||
|
||||
const updatedTracks =
|
||||
this.type === "audio"
|
||||
? buildAudioTrackState({
|
||||
tracks: this.savedState,
|
||||
insertIndex,
|
||||
trackId: this.trackId,
|
||||
})
|
||||
: buildOverlayTrackState({
|
||||
tracks: this.savedState,
|
||||
insertIndex,
|
||||
trackId: this.trackId,
|
||||
trackType: this.type,
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
|
||||
getTrackId(): string {
|
||||
return this.trackId;
|
||||
}
|
||||
}
|
||||
|
||||
function buildAudioTrackState({
|
||||
tracks,
|
||||
insertIndex,
|
||||
trackId,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
insertIndex: number;
|
||||
trackId: string;
|
||||
}): SceneTracks {
|
||||
const audioInsertIndex = Math.max(0, insertIndex - tracks.overlay.length - 1);
|
||||
const newTrack = buildEmptyTrack({
|
||||
id: trackId,
|
||||
type: "audio",
|
||||
});
|
||||
return {
|
||||
...tracks,
|
||||
audio: [
|
||||
...tracks.audio.slice(0, audioInsertIndex),
|
||||
newTrack,
|
||||
...tracks.audio.slice(audioInsertIndex),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildOverlayTrackState({
|
||||
tracks,
|
||||
insertIndex,
|
||||
trackId,
|
||||
trackType,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
insertIndex: number;
|
||||
trackId: string;
|
||||
trackType: Exclude<TrackType, "audio">;
|
||||
}): SceneTracks {
|
||||
const overlayInsertIndex = Math.min(insertIndex, tracks.overlay.length);
|
||||
const newTrack =
|
||||
trackType === "video"
|
||||
? buildEmptyTrack({ id: trackId, type: "video" })
|
||||
: trackType === "text"
|
||||
? buildEmptyTrack({ id: trackId, type: "text" })
|
||||
: trackType === "graphic"
|
||||
? buildEmptyTrack({ id: trackId, type: "graphic" })
|
||||
: buildEmptyTrack({ id: trackId, type: "effect" });
|
||||
return {
|
||||
...tracks,
|
||||
overlay: [
|
||||
...tracks.overlay.slice(0, overlayInsertIndex),
|
||||
newTrack,
|
||||
...tracks.overlay.slice(overlayInsertIndex),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
|
||||
export class RemoveTrackCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
|
||||
constructor(private trackId: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
const updatedTracks: SceneTracks = {
|
||||
...this.savedState,
|
||||
overlay: this.savedState.overlay.filter((track) => track.id !== this.trackId),
|
||||
audio: this.savedState.audio.filter((track) => track.id !== this.trackId),
|
||||
};
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { canTrackHaveAudio, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/timeline";
|
||||
|
||||
export class ToggleTrackMuteCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
|
||||
constructor(private trackId: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
const targetTrack = findTrackInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
});
|
||||
if (!targetTrack) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTracks = updateTrackInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
update: (track) =>
|
||||
canTrackHaveAudio(track) ? { ...track, muted: !track.muted } : track,
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Command, type CommandResult } from "@/commands/base-command";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { canTrackBeHidden, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/timeline";
|
||||
|
||||
export class ToggleTrackVisibilityCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
|
||||
constructor(private trackId: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): CommandResult | undefined {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||
|
||||
const targetTrack = findTrackInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
});
|
||||
if (!targetTrack) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTracks = updateTrackInSceneTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
update: (track) => {
|
||||
if (canTrackBeHidden(track)) {
|
||||
return { ...track, hidden: !track.hidden };
|
||||
}
|
||||
return track;
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm";
|
||||
import { formatTimeCode, parseTimeCode, type TimeCodeFormat } from "opencut-wasm";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
interface EditableTimecodeProps {
|
||||
time: number;
|
||||
duration: number;
|
||||
format?: TimeCodeFormat;
|
||||
fps: FrameRate;
|
||||
fps: number;
|
||||
onTimeChange?: ({ time }: { time: number }) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
@@ -28,7 +28,7 @@ export function EditableTimecode({
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const enterPressedRef = useRef(false);
|
||||
const formattedTime = formatTimecode({ time, format, rate: fps }) ?? "";
|
||||
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps }) ?? "";
|
||||
|
||||
const startEditing = () => {
|
||||
if (disabled) return;
|
||||
@@ -46,16 +46,17 @@ export function EditableTimecode({
|
||||
};
|
||||
|
||||
const applyEdit = () => {
|
||||
const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps });
|
||||
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
|
||||
|
||||
if (parsedTime == null) {
|
||||
setHasError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const clampedTime = duration
|
||||
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime)
|
||||
: parsedTime;
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
duration ? Math.min(duration, parsedTime) : parsedTime,
|
||||
);
|
||||
|
||||
onTimeChange?.({ time: clampedTime });
|
||||
setIsEditing(false);
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function MigrationDialog() {
|
||||
+3
-4
@@ -6,9 +6,9 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { TProjectMetadata } from "@/project/types";
|
||||
import type { TProjectMetadata } from "@/lib/project/types";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm";
|
||||
import { formatTimeCode } from "opencut-wasm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function InfoRow({
|
||||
@@ -35,10 +35,9 @@ export function ProjectInfoDialog({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: TProjectMetadata;
|
||||
}) {
|
||||
const durationSeconds = mediaTimeToSeconds({ time: project.duration });
|
||||
const durationFormatted =
|
||||
project.duration > 0
|
||||
? (formatTimecode({ time: project.duration, format: durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "")
|
||||
? (formatTimeCode({ timeInSeconds: project.duration, format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "")
|
||||
: "0:00";
|
||||
|
||||
return (
|
||||
+2
-2
@@ -5,8 +5,8 @@ import { toast } from "sonner";
|
||||
import {
|
||||
type KeyboardShortcut,
|
||||
useKeyboardShortcutsHelp,
|
||||
} from "@/actions/use-keyboard-shortcuts-help";
|
||||
import { useKeybindingsStore } from "@/actions/keybindings-store";
|
||||
} from "@/hooks/use-keyboard-shortcuts-help";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
+1
-1
@@ -9,7 +9,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useStoragePersistence } from "@/services/storage/use-storage-persistence";
|
||||
import { useStoragePersistence } from "@/hooks/use-storage-persistence";
|
||||
|
||||
export function StoragePersistenceDialog() {
|
||||
const { showDialog, onConfirm, onDismiss } = useStoragePersistence();
|
||||
@@ -10,20 +10,18 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { RenameProjectDialog } from "@/project/components/rename-project-dialog";
|
||||
import { DeleteProjectDialog } from "@/project/components/delete-project-dialog";
|
||||
import { RenameProjectDialog } from "./dialogs/rename-project-dialog";
|
||||
import { DeleteProjectDialog } from "./dialogs/delete-project-dialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FaDiscord } from "react-icons/fa6";
|
||||
import { ExportButton } from "./export-button";
|
||||
import { FeedbackPopover } from "@/feedback/components/feedback-popover";
|
||||
import { ThemeToggle } from "../theme-toggle";
|
||||
import { DEFAULT_LOGO_URL } from "@/site/brand";
|
||||
import { SOCIAL_LINKS } from "@/site/social";
|
||||
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ShortcutsDialog } from "@/actions/components/shortcuts-dialog";
|
||||
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
@@ -35,7 +33,6 @@ export function EditorHeader() {
|
||||
<EditableProjectName />
|
||||
</div>
|
||||
<nav className="flex items-center gap-2">
|
||||
<FeedbackPopover />
|
||||
<ExportButton />
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user