mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1d5932c54 | ||
|
|
d23be079cc | ||
|
|
70a6d58b84 | ||
|
|
dcf54c1250 | ||
|
|
5cc6abc3a4 | ||
|
|
77a14b1023 | ||
|
|
1c3b22edec | ||
|
|
41562baeb5 | ||
|
|
df207e0f83 | ||
|
|
dc5956900e | ||
|
|
b095862cc0 | ||
|
|
0a6ede73ae | ||
|
|
7ce0018c9b | ||
|
|
9528d0053b | ||
|
|
689853312d | ||
|
|
5a1d9ef3f4 | ||
|
|
eb66e03f0e | ||
|
|
a2418751e6 | ||
|
|
5777a555f6 | ||
|
|
220ec757d4 | ||
|
|
585e28d49b | ||
|
|
1261e663e1 | ||
|
|
c34e01c63e | ||
|
|
3e75852ffe | ||
|
|
e95992b406 | ||
|
|
6df9d70b9b | ||
|
|
f8a0390827 | ||
|
|
d862e56e34 | ||
|
|
d08fbd73e1 | ||
|
|
4d127a038b | ||
|
|
82817cb50c | ||
|
|
a54b3ef076 | ||
|
|
6dd8191f4b | ||
|
|
722dcdd88f | ||
|
|
8db3bead13 | ||
|
|
39ea298a9c | ||
|
|
2c0fd4f86b | ||
|
|
5e2b829f8a | ||
|
|
b09635064a | ||
|
|
6018efc5ac | ||
|
|
7e2a913e13 | ||
|
|
63496d05a2 |
@@ -22,3 +22,5 @@ bun.lockb
|
||||
.open-next
|
||||
|
||||
target/
|
||||
|
||||
.cargo-tools/
|
||||
Generated
+91
-18
@@ -642,6 +642,14 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
@@ -1977,6 +1985,19 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gloo-utils"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.16.0"
|
||||
@@ -2780,10 +2801,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.91"
|
||||
version = "0.3.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
|
||||
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
@@ -3540,6 +3563,13 @@ dependencies = [
|
||||
"gpui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opencut-wasm"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
@@ -4588,6 +4618,17 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-wasm-bindgen"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
@@ -5253,6 +5294,16 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bridge",
|
||||
"serde",
|
||||
"tsify-next",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
@@ -5527,6 +5578,32 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tsify-next"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d0f2208feeb5f7a6edb15a2389c14cd42480ef6417318316bb866da5806a61d"
|
||||
dependencies = [
|
||||
"gloo-utils",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"tsify-next-macros",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tsify-next-macros"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f81253930d0d388a3ab8fa4ae56da9973ab171ef833d1be2e9080fc3ce502bd6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.20.0"
|
||||
@@ -5850,9 +5927,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.114"
|
||||
version = "0.2.115"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
|
||||
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -5863,23 +5940,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.64"
|
||||
version = "0.4.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
|
||||
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.114"
|
||||
version = "0.2.115"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
|
||||
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -5887,9 +5960,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.114"
|
||||
version = "0.2.115"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
|
||||
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -5900,9 +5973,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.114"
|
||||
version = "0.2.115"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
|
||||
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -6053,9 +6126,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.91"
|
||||
version = "0.3.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
|
||||
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
|
||||
@@ -2,4 +2,7 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"apps/desktop",
|
||||
"rust/crates/time",
|
||||
"rust/crates/bridge",
|
||||
"rust/wasm",
|
||||
]
|
||||
|
||||
@@ -1,129 +1,129 @@
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="left" width="120">
|
||||
<img src="apps/web/public/logos/opencut/1k/logo-white-black.png" alt="OpenCut Logo" width="100" />
|
||||
</td>
|
||||
<td align="right">
|
||||
<h1>OpenCut</span></h1>
|
||||
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
|
||||
|
||||
<a href="https://vercel.com/oss">
|
||||
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
|
||||
</a>
|
||||
|
||||
<a href="https://fal.ai">
|
||||
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
|
||||
</a>
|
||||
|
||||
## Why?
|
||||
|
||||
- **Privacy**: Your videos stay on your device
|
||||
- **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/` – 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
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/docs/installation)
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Fork and clone the repository
|
||||
|
||||
2. Copy the environment file:
|
||||
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
cp apps/web/.env.example apps/web/.env.local
|
||||
|
||||
# Windows PowerShell
|
||||
Copy-Item apps/web/.env.example apps/web/.env.local
|
||||
```
|
||||
|
||||
3. Start the database and Redis:
|
||||
|
||||
```bash
|
||||
docker compose up -d db redis serverless-redis-http
|
||||
```
|
||||
|
||||
4. Install dependencies and start the dev server:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun dev:web
|
||||
```
|
||||
|
||||
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
|
||||
|
||||
### Desktop setup
|
||||
|
||||
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.
|
||||
|
||||
### Self-Hosting with Docker
|
||||
|
||||
To run everything (including a production build of the app) in Docker:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The app will be available at [http://localhost:3100](http://localhost:3100).
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
|
||||
|
||||
**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
|
||||
|
||||
**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
|
||||
|
||||
See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
|
||||
|
||||
**Quick start for contributors:**
|
||||
|
||||
- Fork the repo and clone locally
|
||||
- Follow the setup instructions in CONTRIBUTING.md
|
||||
- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup
|
||||
- Create a feature branch and submit a PR
|
||||
|
||||
## License
|
||||
|
||||
[MIT LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||

|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="left" width="120">
|
||||
<img src="apps/web/public/logos/opencut/icon.svg" alt="OpenCut Logo" width="100" />
|
||||
</td>
|
||||
<td align="right">
|
||||
<h1>OpenCut</h1>
|
||||
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
|
||||
|
||||
<a href="https://vercel.com/oss">
|
||||
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
|
||||
</a>
|
||||
|
||||
<a href="https://fal.ai">
|
||||
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
|
||||
</a>
|
||||
|
||||
## Why?
|
||||
|
||||
- **Privacy**: Your videos stay on your device
|
||||
- **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/` – 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
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/docs/installation)
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Fork and clone the repository
|
||||
|
||||
2. Copy the environment file:
|
||||
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
cp apps/web/.env.example apps/web/.env.local
|
||||
|
||||
# Windows PowerShell
|
||||
Copy-Item apps/web/.env.example apps/web/.env.local
|
||||
```
|
||||
|
||||
3. Start the database and Redis:
|
||||
|
||||
```bash
|
||||
docker compose up -d db redis serverless-redis-http
|
||||
```
|
||||
|
||||
4. Install dependencies and start the dev server:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun dev:web
|
||||
```
|
||||
|
||||
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
|
||||
|
||||
### Desktop setup
|
||||
|
||||
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.
|
||||
|
||||
### Self-Hosting with Docker
|
||||
|
||||
To run everything (including a production build of the app) in Docker:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The app will be available at [http://localhost:3100](http://localhost:3100).
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
|
||||
|
||||
**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
|
||||
|
||||
**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
|
||||
|
||||
See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
|
||||
|
||||
**Quick start for contributors:**
|
||||
|
||||
- Fork the repo and clone locally
|
||||
- Follow the setup instructions in CONTRIBUTING.md
|
||||
- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup
|
||||
- Create a feature branch and submit a PR
|
||||
|
||||
## License
|
||||
|
||||
[MIT LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
+5
-1
@@ -7,4 +7,8 @@
|
||||
|
||||
.next/
|
||||
|
||||
.font-cache/
|
||||
.font-cache/
|
||||
|
||||
next-env.d.ts
|
||||
|
||||
*.tsbuildinfo
|
||||
@@ -14,14 +14,9 @@ COPY bun.lock bun.lock
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY packages/env/package.json packages/env/package.json
|
||||
COPY packages/ui/package.json packages/ui/package.json
|
||||
|
||||
RUN bun install
|
||||
|
||||
COPY apps/web/ apps/web/
|
||||
COPY packages/env/ packages/env/
|
||||
COPY packages/ui/ packages/ui/
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
@@ -9,6 +9,7 @@ const changelog = defineCollection({
|
||||
content: z.string(),
|
||||
version: z.string(),
|
||||
date: z.string(),
|
||||
published: z.boolean().default(true),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
changes: z.array(
|
||||
@@ -20,10 +21,11 @@ const changelog = defineCollection({
|
||||
}),
|
||||
transform: async (doc, { collection }) => {
|
||||
const allDocs = await collection.documents();
|
||||
const sorted = [...allDocs].sort((a, b) =>
|
||||
const publishedDocs = allDocs.filter((entry) => entry.published !== false);
|
||||
const sorted = [...publishedDocs].sort((a, b) =>
|
||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||
);
|
||||
const isLatest = sorted[0]?.version === doc.version;
|
||||
const isLatest = doc.published !== false && sorted[0]?.version === doc.version;
|
||||
return { ...doc, isLatest };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
version: "0.1.0"
|
||||
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."
|
||||
changes:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
version: "0.2.0"
|
||||
date: "2026-03-01"
|
||||
published: true
|
||||
title: "Motion & effects"
|
||||
description: "This release adds the foundation for motion and effects, alongside many improvements & fixes."
|
||||
changes:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
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:
|
||||
|
||||
+23
-23
@@ -1,23 +1,23 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
import * as dotenv from "dotenv";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
|
||||
// Load the right env file based on environment
|
||||
if (webEnv.NODE_ENV === "production") {
|
||||
dotenv.config({ path: ".env.production" });
|
||||
} else {
|
||||
dotenv.config({ path: ".env.local" });
|
||||
}
|
||||
|
||||
export default {
|
||||
schema: "./src/schema.ts",
|
||||
dialect: "postgresql",
|
||||
migrations: {
|
||||
table: "drizzle_migrations",
|
||||
},
|
||||
dbCredentials: {
|
||||
url: webEnv.DATABASE_URL,
|
||||
},
|
||||
out: "./migrations",
|
||||
strict: webEnv.NODE_ENV === "production",
|
||||
} satisfies Config;
|
||||
import type { Config } from "drizzle-kit";
|
||||
import * as dotenv from "dotenv";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
|
||||
// Load the right env file based on environment
|
||||
if (webEnv.NODE_ENV === "production") {
|
||||
dotenv.config({ path: ".env.production" });
|
||||
} else {
|
||||
dotenv.config({ path: ".env.local" });
|
||||
}
|
||||
|
||||
export default {
|
||||
schema: "./src/schema.ts",
|
||||
dialect: "postgresql",
|
||||
migrations: {
|
||||
table: "drizzle_migrations",
|
||||
},
|
||||
dbCredentials: {
|
||||
url: webEnv.DATABASE_URL,
|
||||
},
|
||||
out: "./migrations",
|
||||
strict: webEnv.NODE_ENV === "production",
|
||||
} satisfies Config;
|
||||
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { withBotId } from "botid/next/config";
|
||||
import { withContentCollections } from "@content-collections/next";
|
||||
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
|
||||
|
||||
initOpenNextCloudflareForDev();
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
|
||||
@@ -22,8 +22,7 @@
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.6",
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"opencut-wasm": "workspace:*",
|
||||
"@opennextjs/cloudflare": "^1.18.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -77,7 +76,7 @@
|
||||
"unified": "^11.0.5",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"wavesurfer.js": "^7.9.8",
|
||||
"zod": "^3.25.67",
|
||||
"zod": "4.3.6",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Binary file not shown.
@@ -1,280 +1,280 @@
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
q: z.string().max(500, "Query too long").optional(),
|
||||
type: z.enum(["songs", "effects"]).optional(),
|
||||
page: z.coerce.number().int().min(1).max(1000).default(1),
|
||||
page_size: z.coerce.number().int().min(1).max(150).default(20),
|
||||
sort: z
|
||||
.enum(["downloads", "rating", "created", "score"])
|
||||
.default("downloads"),
|
||||
min_rating: z.coerce.number().min(0).max(5).default(3),
|
||||
commercial_only: z.coerce.boolean().default(true),
|
||||
});
|
||||
|
||||
const freesoundResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string().url(),
|
||||
previews: z
|
||||
.object({
|
||||
"preview-hq-mp3": z.string().url(),
|
||||
"preview-lq-mp3": z.string().url(),
|
||||
"preview-hq-ogg": z.string().url(),
|
||||
"preview-lq-ogg": z.string().url(),
|
||||
})
|
||||
.optional(),
|
||||
download: z.string().url().optional(),
|
||||
duration: z.number(),
|
||||
filesize: z.number(),
|
||||
type: z.string(),
|
||||
channels: z.number(),
|
||||
bitrate: z.number(),
|
||||
bitdepth: z.number(),
|
||||
samplerate: z.number(),
|
||||
username: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
license: z.string(),
|
||||
created: z.string(),
|
||||
num_downloads: z.number().optional(),
|
||||
avg_rating: z.number().optional(),
|
||||
num_ratings: z.number().optional(),
|
||||
});
|
||||
|
||||
const freesoundResponseSchema = z.object({
|
||||
count: z.number(),
|
||||
next: z.string().url().nullable(),
|
||||
previous: z.string().url().nullable(),
|
||||
results: z.array(freesoundResultSchema),
|
||||
});
|
||||
|
||||
const transformedResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string(),
|
||||
previewUrl: z.string().optional(),
|
||||
downloadUrl: z.string().optional(),
|
||||
duration: z.number(),
|
||||
filesize: z.number(),
|
||||
type: z.string(),
|
||||
channels: z.number(),
|
||||
bitrate: z.number(),
|
||||
bitdepth: z.number(),
|
||||
samplerate: z.number(),
|
||||
username: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
license: z.string(),
|
||||
created: z.string(),
|
||||
downloads: z.number().optional(),
|
||||
rating: z.number().optional(),
|
||||
ratingCount: z.number().optional(),
|
||||
});
|
||||
|
||||
const apiResponseSchema = z.object({
|
||||
count: z.number(),
|
||||
next: z.string().nullable(),
|
||||
previous: z.string().nullable(),
|
||||
results: z.array(transformedResultSchema),
|
||||
query: z.string().optional(),
|
||||
type: z.string(),
|
||||
page: z.number(),
|
||||
pageSize: z.number(),
|
||||
sort: z.string(),
|
||||
minRating: z.number().optional(),
|
||||
});
|
||||
|
||||
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
|
||||
if (!query) return `${sort}_desc`;
|
||||
return sort === "score" ? "score" : `${sort}_desc`;
|
||||
}
|
||||
|
||||
function applyEffectsFilters({
|
||||
params,
|
||||
min_rating,
|
||||
commercial_only,
|
||||
}: {
|
||||
params: URLSearchParams;
|
||||
min_rating: number;
|
||||
commercial_only: boolean;
|
||||
}) {
|
||||
params.append("filter", "duration:[* TO 30.0]");
|
||||
params.append("filter", `avg_rating:[${min_rating} TO *]`);
|
||||
|
||||
if (commercial_only) {
|
||||
params.append(
|
||||
"filter",
|
||||
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
|
||||
);
|
||||
}
|
||||
|
||||
params.append(
|
||||
"filter",
|
||||
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
|
||||
);
|
||||
}
|
||||
|
||||
function transformFreesoundResult(
|
||||
result: z.infer<typeof freesoundResultSchema>,
|
||||
) {
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
url: result.url,
|
||||
previewUrl:
|
||||
result.previews?.["preview-hq-mp3"] ||
|
||||
result.previews?.["preview-lq-mp3"],
|
||||
downloadUrl: result.download,
|
||||
duration: result.duration,
|
||||
filesize: result.filesize,
|
||||
type: result.type,
|
||||
channels: result.channels,
|
||||
bitrate: result.bitrate,
|
||||
bitdepth: result.bitdepth,
|
||||
samplerate: result.samplerate,
|
||||
username: result.username,
|
||||
tags: result.tags,
|
||||
license: result.license,
|
||||
created: result.created,
|
||||
downloads: result.num_downloads || 0,
|
||||
rating: result.avg_rating || 0,
|
||||
ratingCount: result.num_ratings || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { limited } = await checkRateLimit({ request });
|
||||
if (limited) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const validationResult = searchParamsSchema.safeParse({
|
||||
q: searchParams.get("q") || undefined,
|
||||
type: searchParams.get("type") || undefined,
|
||||
page: searchParams.get("page") || undefined,
|
||||
page_size: searchParams.get("page_size") || undefined,
|
||||
sort: searchParams.get("sort") || undefined,
|
||||
min_rating: searchParams.get("min_rating") || undefined,
|
||||
});
|
||||
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid parameters",
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
q: query,
|
||||
type,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
sort,
|
||||
min_rating,
|
||||
commercial_only,
|
||||
} = validationResult.data;
|
||||
|
||||
if (type === "songs") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Songs are not available yet",
|
||||
message:
|
||||
"Song search functionality is coming soon. Try searching for sound effects instead.",
|
||||
},
|
||||
{ status: 501 },
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = "https://freesound.org/apiv2/search/text/";
|
||||
|
||||
const sortParam = buildSortParameter({ query, sort });
|
||||
|
||||
const params = new URLSearchParams({
|
||||
query: query || "",
|
||||
token: webEnv.FREESOUND_API_KEY,
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
sort: sortParam,
|
||||
fields:
|
||||
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
|
||||
});
|
||||
|
||||
const isEffectsSearch = type === "effects" || !type;
|
||||
if (isEffectsSearch) {
|
||||
applyEffectsFilters({ params, min_rating, commercial_only });
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("Freesound API error:", response.status, errorText);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to search sounds" },
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
|
||||
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
|
||||
if (!freesoundValidation.success) {
|
||||
console.error(
|
||||
"Invalid Freesound API response:",
|
||||
freesoundValidation.error,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response from Freesound API" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const data = freesoundValidation.data;
|
||||
|
||||
const transformedResults = data.results.map(transformFreesoundResult);
|
||||
|
||||
const responseData = {
|
||||
count: data.count,
|
||||
next: data.next,
|
||||
previous: data.previous,
|
||||
results: transformedResults,
|
||||
query: query || "",
|
||||
type: type || "effects",
|
||||
page,
|
||||
pageSize,
|
||||
sort,
|
||||
minRating: min_rating,
|
||||
};
|
||||
|
||||
const responseValidation = apiResponseSchema.safeParse(responseData);
|
||||
if (!responseValidation.success) {
|
||||
console.error(
|
||||
"Invalid API response structure:",
|
||||
responseValidation.error,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal response formatting error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(responseValidation.data);
|
||||
} catch (error) {
|
||||
console.error("Error searching sounds:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
q: z.string().max(500, "Query too long").optional(),
|
||||
type: z.enum(["songs", "effects"]).optional(),
|
||||
page: z.coerce.number().int().min(1).max(1000).default(1),
|
||||
page_size: z.coerce.number().int().min(1).max(150).default(20),
|
||||
sort: z
|
||||
.enum(["downloads", "rating", "created", "score"])
|
||||
.default("downloads"),
|
||||
min_rating: z.coerce.number().min(0).max(5).default(3),
|
||||
commercial_only: z.coerce.boolean().default(true),
|
||||
});
|
||||
|
||||
const freesoundResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string().url(),
|
||||
previews: z
|
||||
.object({
|
||||
"preview-hq-mp3": z.string().url(),
|
||||
"preview-lq-mp3": z.string().url(),
|
||||
"preview-hq-ogg": z.string().url(),
|
||||
"preview-lq-ogg": z.string().url(),
|
||||
})
|
||||
.optional(),
|
||||
download: z.string().url().optional(),
|
||||
duration: z.number(),
|
||||
filesize: z.number(),
|
||||
type: z.string(),
|
||||
channels: z.number(),
|
||||
bitrate: z.number(),
|
||||
bitdepth: z.number(),
|
||||
samplerate: z.number(),
|
||||
username: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
license: z.string(),
|
||||
created: z.string(),
|
||||
num_downloads: z.number().optional(),
|
||||
avg_rating: z.number().optional(),
|
||||
num_ratings: z.number().optional(),
|
||||
});
|
||||
|
||||
const freesoundResponseSchema = z.object({
|
||||
count: z.number(),
|
||||
next: z.string().url().nullable(),
|
||||
previous: z.string().url().nullable(),
|
||||
results: z.array(freesoundResultSchema),
|
||||
});
|
||||
|
||||
const transformedResultSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string(),
|
||||
previewUrl: z.string().optional(),
|
||||
downloadUrl: z.string().optional(),
|
||||
duration: z.number(),
|
||||
filesize: z.number(),
|
||||
type: z.string(),
|
||||
channels: z.number(),
|
||||
bitrate: z.number(),
|
||||
bitdepth: z.number(),
|
||||
samplerate: z.number(),
|
||||
username: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
license: z.string(),
|
||||
created: z.string(),
|
||||
downloads: z.number().optional(),
|
||||
rating: z.number().optional(),
|
||||
ratingCount: z.number().optional(),
|
||||
});
|
||||
|
||||
const apiResponseSchema = z.object({
|
||||
count: z.number(),
|
||||
next: z.string().nullable(),
|
||||
previous: z.string().nullable(),
|
||||
results: z.array(transformedResultSchema),
|
||||
query: z.string().optional(),
|
||||
type: z.string(),
|
||||
page: z.number(),
|
||||
pageSize: z.number(),
|
||||
sort: z.string(),
|
||||
minRating: z.number().optional(),
|
||||
});
|
||||
|
||||
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
|
||||
if (!query) return `${sort}_desc`;
|
||||
return sort === "score" ? "score" : `${sort}_desc`;
|
||||
}
|
||||
|
||||
function applyEffectsFilters({
|
||||
params,
|
||||
min_rating,
|
||||
commercial_only,
|
||||
}: {
|
||||
params: URLSearchParams;
|
||||
min_rating: number;
|
||||
commercial_only: boolean;
|
||||
}) {
|
||||
params.append("filter", "duration:[* TO 30.0]");
|
||||
params.append("filter", `avg_rating:[${min_rating} TO *]`);
|
||||
|
||||
if (commercial_only) {
|
||||
params.append(
|
||||
"filter",
|
||||
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
|
||||
);
|
||||
}
|
||||
|
||||
params.append(
|
||||
"filter",
|
||||
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
|
||||
);
|
||||
}
|
||||
|
||||
function transformFreesoundResult(
|
||||
result: z.infer<typeof freesoundResultSchema>,
|
||||
) {
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
description: result.description,
|
||||
url: result.url,
|
||||
previewUrl:
|
||||
result.previews?.["preview-hq-mp3"] ||
|
||||
result.previews?.["preview-lq-mp3"],
|
||||
downloadUrl: result.download,
|
||||
duration: result.duration,
|
||||
filesize: result.filesize,
|
||||
type: result.type,
|
||||
channels: result.channels,
|
||||
bitrate: result.bitrate,
|
||||
bitdepth: result.bitdepth,
|
||||
samplerate: result.samplerate,
|
||||
username: result.username,
|
||||
tags: result.tags,
|
||||
license: result.license,
|
||||
created: result.created,
|
||||
downloads: result.num_downloads || 0,
|
||||
rating: result.avg_rating || 0,
|
||||
ratingCount: result.num_ratings || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { limited } = await checkRateLimit({ request });
|
||||
if (limited) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const validationResult = searchParamsSchema.safeParse({
|
||||
q: searchParams.get("q") || undefined,
|
||||
type: searchParams.get("type") || undefined,
|
||||
page: searchParams.get("page") || undefined,
|
||||
page_size: searchParams.get("page_size") || undefined,
|
||||
sort: searchParams.get("sort") || undefined,
|
||||
min_rating: searchParams.get("min_rating") || undefined,
|
||||
});
|
||||
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid parameters",
|
||||
details: validationResult.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
q: query,
|
||||
type,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
sort,
|
||||
min_rating,
|
||||
commercial_only,
|
||||
} = validationResult.data;
|
||||
|
||||
if (type === "songs") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Songs are not available yet",
|
||||
message:
|
||||
"Song search functionality is coming soon. Try searching for sound effects instead.",
|
||||
},
|
||||
{ status: 501 },
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = "https://freesound.org/apiv2/search/text/";
|
||||
|
||||
const sortParam = buildSortParameter({ query, sort });
|
||||
|
||||
const params = new URLSearchParams({
|
||||
query: query || "",
|
||||
token: webEnv.FREESOUND_API_KEY,
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
sort: sortParam,
|
||||
fields:
|
||||
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
|
||||
});
|
||||
|
||||
const isEffectsSearch = type === "effects" || !type;
|
||||
if (isEffectsSearch) {
|
||||
applyEffectsFilters({ params, min_rating, commercial_only });
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("Freesound API error:", response.status, errorText);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to search sounds" },
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
|
||||
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
|
||||
if (!freesoundValidation.success) {
|
||||
console.error(
|
||||
"Invalid Freesound API response:",
|
||||
freesoundValidation.error,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response from Freesound API" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const data = freesoundValidation.data;
|
||||
|
||||
const transformedResults = data.results.map(transformFreesoundResult);
|
||||
|
||||
const responseData = {
|
||||
count: data.count,
|
||||
next: data.next,
|
||||
previous: data.previous,
|
||||
results: transformedResults,
|
||||
query: query || "",
|
||||
type: type || "effects",
|
||||
page,
|
||||
pageSize,
|
||||
sort,
|
||||
minRating: min_rating,
|
||||
};
|
||||
|
||||
const responseValidation = apiResponseSchema.safeParse(responseData);
|
||||
if (!responseValidation.success) {
|
||||
console.error(
|
||||
"Invalid API response structure:",
|
||||
responseValidation.error,
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal response formatting error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(responseValidation.data);
|
||||
} catch (error) {
|
||||
console.error("Error searching sounds:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { allChangelogs } from "content-collections";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { getSortedReleases } from "../utils";
|
||||
import { getReleaseByVersion, getSortedReleases } from "../utils";
|
||||
import {
|
||||
ReleaseArticle,
|
||||
ReleaseMeta,
|
||||
@@ -17,12 +16,12 @@ import { CopyMarkdownButton } from "../components/copy-markdown-button";
|
||||
type Props = { params: Promise<{ version: string }> };
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return allChangelogs.map((r) => ({ version: r.version }));
|
||||
return getSortedReleases().map((release) => ({ version: release.version }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { version } = await params;
|
||||
const release = allChangelogs.find((r) => r.version === version);
|
||||
const release = getReleaseByVersion({ version });
|
||||
if (!release) return {};
|
||||
return {
|
||||
title: `${release.title} (${release.version}) - OpenCut Changelog`,
|
||||
@@ -33,10 +32,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
export default async function ReleaseDetailPage({ params }: Props) {
|
||||
const { version } = await params;
|
||||
const releases = getSortedReleases();
|
||||
const index = releases.findIndex((r) => r.version === version);
|
||||
|
||||
const index = releases.findIndex((entry) => entry.version === version);
|
||||
if (index === -1) notFound();
|
||||
|
||||
const release = releases[index];
|
||||
const newer = index > 0 ? releases[index - 1] : null;
|
||||
const older = index < releases.length - 1 ? releases[index + 1] : null;
|
||||
@@ -52,20 +49,20 @@ export default async function ReleaseDetailPage({ params }: Props) {
|
||||
All releases
|
||||
</Link>
|
||||
|
||||
<ReleaseArticle variant="detail">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<ReleaseMeta release={release} />
|
||||
<CopyMarkdownButton
|
||||
description={release.description}
|
||||
changes={release.changes}
|
||||
/>
|
||||
<ReleaseArticle variant="detail">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<ReleaseMeta release={release} />
|
||||
<CopyMarkdownButton
|
||||
description={release.description}
|
||||
changes={release.changes}
|
||||
/>
|
||||
</div>
|
||||
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
|
||||
{release.description && (
|
||||
<ReleaseDescription>{release.description}</ReleaseDescription>
|
||||
)}
|
||||
</div>
|
||||
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
|
||||
{release.description && (
|
||||
<ReleaseDescription>{release.description}</ReleaseDescription>
|
||||
)}
|
||||
</div>
|
||||
<ReleaseChanges release={release} />
|
||||
</ReleaseArticle>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function buildMarkdown({
|
||||
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
|
||||
|
||||
for (const type of orderedTypes) {
|
||||
lines.push(`## ${getSectionTitle(type)}`);
|
||||
lines.push(`## ${getSectionTitle({ type })}`);
|
||||
for (const change of grouped[type]) {
|
||||
lines.push(`- ${change.text}`);
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function ReleaseChanges({ release }: { release: Release }) {
|
||||
{orderedTypes.map((type) => (
|
||||
<div key={type} className="flex flex-col gap-1.5">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{getSectionTitle(type)}:
|
||||
{getSectionTitle({ type })}:
|
||||
</h3>
|
||||
<ul className="list-disc pl-5 space-y-1.5">
|
||||
{grouped[type].map((change) => (
|
||||
|
||||
@@ -12,7 +12,7 @@ const knownSectionTitles: Record<string, string> = {
|
||||
breaking: "Breaking Changes",
|
||||
};
|
||||
|
||||
export function getSectionTitle(type: string): string {
|
||||
export function getSectionTitle({ type }: { type: string }): string {
|
||||
return (
|
||||
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
|
||||
);
|
||||
@@ -36,8 +36,18 @@ export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
|
||||
return { grouped, orderedTypes };
|
||||
}
|
||||
|
||||
function isPublishedRelease({ published }: Release) {
|
||||
return published !== false;
|
||||
}
|
||||
|
||||
export function getSortedReleases() {
|
||||
return [...allChangelogs].sort((a, b) =>
|
||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Toaster } from "../components/ui/sonner";
|
||||
import { TooltipProvider } from "../components/ui/tooltip";
|
||||
import { baseMetaData } from "./metadata";
|
||||
import { BotIdClient } from "botid/client";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
const siteFont = Inter({ subsets: ["latin"] });
|
||||
|
||||
+1015
-1015
File diff suppressed because it is too large
Load Diff
@@ -1,150 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { formatTimeCode, parseTimeCode } from "@/lib/time";
|
||||
import type { TTimeCode } from "@/lib/time";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
interface EditableTimecodeProps {
|
||||
time: number;
|
||||
duration: number;
|
||||
format?: TTimeCode;
|
||||
fps: number;
|
||||
onTimeChange?: ({ time }: { time: number }) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function EditableTimecode({
|
||||
time,
|
||||
duration,
|
||||
format = "HH:MM:SS:FF",
|
||||
fps,
|
||||
onTimeChange,
|
||||
className,
|
||||
disabled = false,
|
||||
}: EditableTimecodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const enterPressedRef = useRef(false);
|
||||
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps });
|
||||
|
||||
const startEditing = () => {
|
||||
if (disabled) return;
|
||||
setIsEditing(true);
|
||||
setInputValue(formattedTime);
|
||||
setHasError(false);
|
||||
enterPressedRef.current = false;
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setIsEditing(false);
|
||||
setInputValue("");
|
||||
setHasError(false);
|
||||
enterPressedRef.current = false;
|
||||
};
|
||||
|
||||
const applyEdit = () => {
|
||||
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
|
||||
|
||||
if (parsedTime === null) {
|
||||
setHasError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
duration ? Math.min(duration, parsedTime) : parsedTime,
|
||||
);
|
||||
|
||||
onTimeChange?.({ time: clampedTime });
|
||||
setIsEditing(false);
|
||||
setInputValue("");
|
||||
setHasError(false);
|
||||
enterPressedRef.current = false;
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
enterPressedRef.current = true;
|
||||
applyEdit();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelEditing();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = ({
|
||||
target,
|
||||
}: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(target.value);
|
||||
setHasError(false);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!enterPressedRef.current && isEditing) {
|
||||
applyEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisplayKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
startEditing();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
"-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none",
|
||||
"focus:bg-background focus:border-primary focus:rounded",
|
||||
"text-primary tabular-nums",
|
||||
hasError && "text-destructive focus:border-destructive",
|
||||
className,
|
||||
)}
|
||||
style={{ width: `${formattedTime.length + 1}ch` }}
|
||||
placeholder={formattedTime}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
onKeyDown={handleDisplayKeyDown}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"text-primary cursor-pointer font-mono text-xs tabular-nums",
|
||||
"hover:bg-muted/50 -mx-1 px-1 hover:rounded",
|
||||
disabled && "cursor-default hover:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
title={disabled ? undefined : "Click to edit time"}
|
||||
>
|
||||
{formattedTime}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { formatTimeCode, parseTimeCode, type TimeCodeFormat } from "opencut-wasm";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
interface EditableTimecodeProps {
|
||||
time: number;
|
||||
duration: number;
|
||||
format?: TimeCodeFormat;
|
||||
fps: number;
|
||||
onTimeChange?: ({ time }: { time: number }) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function EditableTimecode({
|
||||
time,
|
||||
duration,
|
||||
format = "HH:MM:SS:FF",
|
||||
fps,
|
||||
onTimeChange,
|
||||
className,
|
||||
disabled = false,
|
||||
}: EditableTimecodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const enterPressedRef = useRef(false);
|
||||
const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps }) ?? "";
|
||||
|
||||
const startEditing = () => {
|
||||
if (disabled) return;
|
||||
setIsEditing(true);
|
||||
setInputValue(formattedTime);
|
||||
setHasError(false);
|
||||
enterPressedRef.current = false;
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setIsEditing(false);
|
||||
setInputValue("");
|
||||
setHasError(false);
|
||||
enterPressedRef.current = false;
|
||||
};
|
||||
|
||||
const applyEdit = () => {
|
||||
const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
|
||||
|
||||
if (parsedTime == null) {
|
||||
setHasError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
duration ? Math.min(duration, parsedTime) : parsedTime,
|
||||
);
|
||||
|
||||
onTimeChange?.({ time: clampedTime });
|
||||
setIsEditing(false);
|
||||
setInputValue("");
|
||||
setHasError(false);
|
||||
enterPressedRef.current = false;
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
enterPressedRef.current = true;
|
||||
applyEdit();
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelEditing();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = ({
|
||||
target,
|
||||
}: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(target.value);
|
||||
setHasError(false);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!enterPressedRef.current && isEditing) {
|
||||
applyEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisplayKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
startEditing();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
"-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none",
|
||||
"focus:bg-background focus:border-primary focus:rounded",
|
||||
"text-primary tabular-nums",
|
||||
hasError && "text-destructive focus:border-destructive",
|
||||
className,
|
||||
)}
|
||||
style={{ width: `${formattedTime.length + 1}ch` }}
|
||||
placeholder={formattedTime}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
onKeyDown={handleDisplayKeyDown}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"text-primary cursor-pointer font-mono text-xs tabular-nums",
|
||||
"hover:bg-muted/50 -mx-1 px-1 hover:rounded",
|
||||
disabled && "cursor-default hover:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
title={disabled ? undefined : "Click to edit time"}
|
||||
>
|
||||
{formattedTime}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,83 +1,80 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { TProjectMetadata } from "@/lib/project/types";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between items-center py-0 last:pb-0">
|
||||
<span className="text-muted-foreground text-sm">{label}</span>
|
||||
<span className="text-sm font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectInfoDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
project,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: TProjectMetadata;
|
||||
}) {
|
||||
const durationFormatted =
|
||||
project.duration > 0
|
||||
? formatTimeCode({
|
||||
timeInSeconds: project.duration,
|
||||
format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
|
||||
})
|
||||
: "0:00";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate max-w-[350px]">
|
||||
{project.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="flex flex-col">
|
||||
<InfoRow label="Duration" value={durationFormatted} />
|
||||
<InfoRow
|
||||
label="Created"
|
||||
value={formatDate({ date: project.createdAt })}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Modified"
|
||||
value={formatDate({ date: project.updatedAt })}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Project ID"
|
||||
value={
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{project.id.slice(0, 8)}
|
||||
</code>
|
||||
}
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { TProjectMetadata } from "@/lib/project/types";
|
||||
import { formatDate } from "@/utils/date";
|
||||
import { formatTimeCode } from "opencut-wasm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between items-center py-0 last:pb-0">
|
||||
<span className="text-muted-foreground text-sm">{label}</span>
|
||||
<span className="text-sm font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectInfoDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
project,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: TProjectMetadata;
|
||||
}) {
|
||||
const durationFormatted =
|
||||
project.duration > 0
|
||||
? (formatTimeCode({ timeInSeconds: project.duration, format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "")
|
||||
: "0:00";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate max-w-[350px]">
|
||||
{project.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="flex flex-col">
|
||||
<InfoRow label="Duration" value={durationFormatted} />
|
||||
<InfoRow
|
||||
label="Created"
|
||||
value={formatDate({ date: project.createdAt })}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Modified"
|
||||
value={formatDate({ date: project.updatedAt })}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Project ID"
|
||||
value={
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{project.id.slice(0, 8)}
|
||||
</code>
|
||||
}
|
||||
/>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,9 @@ import { useState, useRef } from "react";
|
||||
import { extractTimelineAudio } from "@/lib/media/mediabunny";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
BatchCommand,
|
||||
AddTrackCommand,
|
||||
InsertElementCommand,
|
||||
} from "@/lib/commands";
|
||||
import { TRANSCRIPTION_LANGUAGES } from "@/constants/transcription-constants";
|
||||
DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
|
||||
TRANSCRIPTION_LANGUAGES,
|
||||
} from "@/constants/transcription-constants";
|
||||
import type {
|
||||
TranscriptionLanguage,
|
||||
TranscriptionProgress,
|
||||
@@ -31,6 +29,11 @@ import {
|
||||
SectionFields,
|
||||
} from "@/components/section";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import {
|
||||
AddTrackCommand,
|
||||
BatchCommand,
|
||||
InsertElementCommand,
|
||||
} from "@/lib/commands";
|
||||
|
||||
export function Captions() {
|
||||
const [selectedLanguage, setSelectedLanguage] =
|
||||
@@ -62,7 +65,10 @@ export function Captions() {
|
||||
});
|
||||
|
||||
setProcessingStep("Preparing audio...");
|
||||
const { samples } = await decodeAudioToFloat32({ audioBlob });
|
||||
const { samples } = await decodeAudioToFloat32({
|
||||
audioBlob,
|
||||
sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
|
||||
});
|
||||
|
||||
const result = await transcriptionService.transcribe({
|
||||
audioData: samples,
|
||||
|
||||
@@ -1,369 +1,369 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { FPS_PRESETS } from "@/constants/project-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { BackgroundContent } from "./background";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { dimensionToAspectRatio } from "@/utils/geometry";
|
||||
import { formatNumberForDisplay } from "@/utils/math";
|
||||
import { OcSquarePlusIcon } from "@opencut/ui/icons";
|
||||
import type { TCanvasSize } from "@/lib/project/types";
|
||||
|
||||
type SettingsView = "project-info" | "background";
|
||||
|
||||
const PRESET_LABELS: Record<string, string> = {
|
||||
"1:1": "1:1",
|
||||
"16:9": "16:9",
|
||||
"9:16": "9:16",
|
||||
"4:3": "4:3",
|
||||
};
|
||||
|
||||
function areCanvasSizesEqual({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: TCanvasSize;
|
||||
right: TCanvasSize;
|
||||
}) {
|
||||
return left.width === right.width && left.height === right.height;
|
||||
}
|
||||
|
||||
function formatCanvasDimension({ value }: { value: number }) {
|
||||
return formatNumberForDisplay({ value, maxFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function parseCanvasDimension({ input }: { input: string }): number | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
|
||||
const rounded = Math.round(parsed);
|
||||
return rounded > 0 ? rounded : null;
|
||||
}
|
||||
|
||||
function useCanvasDimensionDraft({
|
||||
value,
|
||||
onCommit,
|
||||
}: {
|
||||
value: number;
|
||||
onCommit: (value: number) => void;
|
||||
}) {
|
||||
const pendingValueRef = useRef(value);
|
||||
const syncedValueRef = useRef(value);
|
||||
|
||||
if (syncedValueRef.current !== value) {
|
||||
syncedValueRef.current = value;
|
||||
pendingValueRef.current = value;
|
||||
}
|
||||
|
||||
return usePropertyDraft({
|
||||
displayValue: formatCanvasDimension({ value }),
|
||||
parse: (input) => parseCanvasDimension({ input }),
|
||||
onPreview: (nextValue) => {
|
||||
pendingValueRef.current = nextValue;
|
||||
},
|
||||
onCommit: () => {
|
||||
if (pendingValueRef.current !== value) {
|
||||
onCommit(pendingValueRef.current);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function SettingsView() {
|
||||
const [view, setView] = useState<SettingsView>("project-info");
|
||||
const editor = useEditor();
|
||||
const activeProject = useEditor((e) => e.project.getActive());
|
||||
const { canvasPresets } = useEditorStore();
|
||||
const currentCanvasSize = activeProject.settings.canvasSize;
|
||||
const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
|
||||
const lastCustomCanvasSize =
|
||||
activeProject.settings.lastCustomCanvasSize ?? null;
|
||||
|
||||
const presetItems = canvasPresets.map((preset, index) => {
|
||||
const ratio = dimensionToAspectRatio(preset);
|
||||
return {
|
||||
id: index.toString(),
|
||||
label: PRESET_LABELS[ratio] ?? ratio,
|
||||
ratio,
|
||||
canvasSize: preset,
|
||||
};
|
||||
});
|
||||
|
||||
const selectedPresetId = canvasSizeMode === "preset"
|
||||
? (presetItems.find((preset) =>
|
||||
areCanvasSizesEqual({
|
||||
left: preset.canvasSize,
|
||||
right: currentCanvasSize,
|
||||
}),
|
||||
)?.id ?? null)
|
||||
: null;
|
||||
|
||||
const updateCustomCanvasSize = ({
|
||||
canvasSize,
|
||||
}: {
|
||||
canvasSize: TCanvasSize;
|
||||
}) => {
|
||||
const shouldUpdateCanvasSize = !areCanvasSizesEqual({
|
||||
left: canvasSize,
|
||||
right: currentCanvasSize,
|
||||
});
|
||||
const shouldUpdateLastCustomCanvasSize =
|
||||
lastCustomCanvasSize === null ||
|
||||
!areCanvasSizesEqual({
|
||||
left: canvasSize,
|
||||
right: lastCustomCanvasSize,
|
||||
});
|
||||
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
|
||||
|
||||
if (
|
||||
!shouldUpdateCanvasSize &&
|
||||
!shouldUpdateLastCustomCanvasSize &&
|
||||
!shouldUpdateCanvasSizeMode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.project.updateSettings({
|
||||
settings: {
|
||||
...(shouldUpdateCanvasSize ? { canvasSize } : {}),
|
||||
...(shouldUpdateCanvasSizeMode
|
||||
? { canvasSizeMode: "custom" as const }
|
||||
: {}),
|
||||
lastCustomCanvasSize: canvasSize,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectPresetCanvasSize = ({
|
||||
canvasSize,
|
||||
}: {
|
||||
canvasSize: TCanvasSize;
|
||||
}) => {
|
||||
const shouldUpdateCanvasSize = !areCanvasSizesEqual({
|
||||
left: canvasSize,
|
||||
right: currentCanvasSize,
|
||||
});
|
||||
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
|
||||
|
||||
if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
|
||||
|
||||
editor.project.updateSettings({
|
||||
settings: {
|
||||
...(shouldUpdateCanvasSize ? { canvasSize } : {}),
|
||||
...(shouldUpdateCanvasSizeMode
|
||||
? { canvasSizeMode: "preset" as const }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectCustomCanvasSize = () => {
|
||||
updateCustomCanvasSize({
|
||||
canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
|
||||
});
|
||||
};
|
||||
|
||||
const widthDraft = useCanvasDimensionDraft({
|
||||
value: currentCanvasSize.width,
|
||||
onCommit: (width) =>
|
||||
updateCustomCanvasSize({
|
||||
canvasSize: { width, height: currentCanvasSize.height },
|
||||
}),
|
||||
});
|
||||
|
||||
const heightDraft = useCanvasDimensionDraft({
|
||||
value: currentCanvasSize.height,
|
||||
onCommit: (height) =>
|
||||
updateCustomCanvasSize({
|
||||
canvasSize: { width: currentCanvasSize.width, height },
|
||||
}),
|
||||
});
|
||||
|
||||
const isCustomSelected = canvasSizeMode === "custom";
|
||||
|
||||
return (
|
||||
<PanelView
|
||||
contentClassName="px-0"
|
||||
scrollClassName="pt-0"
|
||||
actions={
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="project-info">Project info</TabsTrigger>
|
||||
<TabsTrigger value="background">Background</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
}
|
||||
>
|
||||
{view === "project-info" && (
|
||||
<div className="flex flex-col">
|
||||
<Section showTopBorder={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle className="flex-1">Name</SectionTitle>
|
||||
<span className="text-sm truncate">
|
||||
{activeProject.metadata.name}
|
||||
</span>
|
||||
</SectionHeader>
|
||||
</Section>
|
||||
<Section showTopBorder={false}>
|
||||
<SectionHeader className="justify-between">
|
||||
<SectionTitle className="flex-1">Frame rate</SectionTitle>
|
||||
<Select
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={(value) => {
|
||||
const fps = parseFloat(value);
|
||||
editor.project.updateSettings({ settings: { fps } });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-transparent border-none p-1 h-auto">
|
||||
<SelectValue placeholder="Select a frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FPS_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SectionHeader>
|
||||
</Section>
|
||||
<Section
|
||||
showTopBorder={false}
|
||||
collapsible
|
||||
sectionKey="settings:aspect-ratio"
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTitle className="flex-1">Aspect ratio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent className="px-2 flex flex-col gap-1 pb-2">
|
||||
{presetItems.map((preset) => (
|
||||
<AspectRatioItem
|
||||
key={preset.id}
|
||||
label={preset.label}
|
||||
previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
|
||||
isSelected={selectedPresetId === preset.id}
|
||||
onClick={() => {
|
||||
selectPresetCanvasSize({
|
||||
canvasSize: preset.canvasSize,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="pb-2">
|
||||
<AspectRatioItem
|
||||
key="custom"
|
||||
label="Custom"
|
||||
previewIcon={<OcSquarePlusIcon />}
|
||||
isSelected={isCustomSelected}
|
||||
onClick={selectCustomCanvasSize}
|
||||
uiOptions={
|
||||
<div className=" flex items-center gap-2 text-foreground">
|
||||
<NumberField
|
||||
value={widthDraft.displayValue}
|
||||
className="w-full"
|
||||
aria-label="Canvas width"
|
||||
onFocus={widthDraft.onFocus}
|
||||
onChange={widthDraft.onChange}
|
||||
onBlur={widthDraft.onBlur}
|
||||
/>
|
||||
<NumberField
|
||||
value={heightDraft.displayValue}
|
||||
className="w-full"
|
||||
aria-label="Canvas height"
|
||||
onFocus={heightDraft.onFocus}
|
||||
onChange={heightDraft.onChange}
|
||||
onBlur={heightDraft.onBlur}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
{view === "background" && <BackgroundContent />}
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
||||
function AspectRatioItem({
|
||||
label,
|
||||
previewIcon,
|
||||
isSelected,
|
||||
onClick,
|
||||
uiOptions,
|
||||
}: {
|
||||
label: string;
|
||||
previewIcon: React.ReactNode;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
uiOptions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant={isSelected ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"px-2 py-0 flex flex-col h-fit w-full",
|
||||
!isSelected && "border border-transparent opacity-75!",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="w-full flex justify-between items-center h-8">
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="flex items-center justify-center size-5">
|
||||
{previewIcon}
|
||||
</div>
|
||||
<span className="text-sm truncate">{label}</span>
|
||||
</div>
|
||||
<div>
|
||||
{isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
|
||||
</div>
|
||||
</div>
|
||||
{uiOptions && isSelected && (
|
||||
<div className="w-full pb-2">{uiOptions}</div>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function AspectRatioPreview({ ratio }: { ratio?: string }) {
|
||||
if (!ratio) return null;
|
||||
|
||||
const [w, h] = ratio.split(":").map(Number);
|
||||
const maxSize = 16;
|
||||
const width = w >= h ? maxSize : (w / h) * maxSize;
|
||||
const height = h >= w ? maxSize : (h / w) * maxSize;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width, height, borderWidth: 1.5 }}
|
||||
className="rounded-xs border-current opacity-60"
|
||||
/>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { FPS_PRESETS } from "@/constants/project-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { BackgroundContent } from "./background";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { dimensionToAspectRatio } from "@/utils/geometry";
|
||||
import { formatNumberForDisplay } from "@/utils/math";
|
||||
import { OcSquarePlusIcon } from "@/components/icons";
|
||||
import type { TCanvasSize } from "@/lib/project/types";
|
||||
|
||||
type SettingsView = "project-info" | "background";
|
||||
|
||||
const PRESET_LABELS: Record<string, string> = {
|
||||
"1:1": "1:1",
|
||||
"16:9": "16:9",
|
||||
"9:16": "9:16",
|
||||
"4:3": "4:3",
|
||||
};
|
||||
|
||||
function areCanvasSizesEqual({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: TCanvasSize;
|
||||
right: TCanvasSize;
|
||||
}) {
|
||||
return left.width === right.width && left.height === right.height;
|
||||
}
|
||||
|
||||
function formatCanvasDimension({ value }: { value: number }) {
|
||||
return formatNumberForDisplay({ value, maxFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function parseCanvasDimension({ input }: { input: string }): number | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
|
||||
const rounded = Math.round(parsed);
|
||||
return rounded > 0 ? rounded : null;
|
||||
}
|
||||
|
||||
function useCanvasDimensionDraft({
|
||||
value,
|
||||
onCommit,
|
||||
}: {
|
||||
value: number;
|
||||
onCommit: (value: number) => void;
|
||||
}) {
|
||||
const pendingValueRef = useRef(value);
|
||||
const syncedValueRef = useRef(value);
|
||||
|
||||
if (syncedValueRef.current !== value) {
|
||||
syncedValueRef.current = value;
|
||||
pendingValueRef.current = value;
|
||||
}
|
||||
|
||||
return usePropertyDraft({
|
||||
displayValue: formatCanvasDimension({ value }),
|
||||
parse: (input) => parseCanvasDimension({ input }),
|
||||
onPreview: (nextValue) => {
|
||||
pendingValueRef.current = nextValue;
|
||||
},
|
||||
onCommit: () => {
|
||||
if (pendingValueRef.current !== value) {
|
||||
onCommit(pendingValueRef.current);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function SettingsView() {
|
||||
const [view, setView] = useState<SettingsView>("project-info");
|
||||
const editor = useEditor();
|
||||
const activeProject = useEditor((e) => e.project.getActive());
|
||||
const { canvasPresets } = useEditorStore();
|
||||
const currentCanvasSize = activeProject.settings.canvasSize;
|
||||
const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
|
||||
const lastCustomCanvasSize =
|
||||
activeProject.settings.lastCustomCanvasSize ?? null;
|
||||
|
||||
const presetItems = canvasPresets.map((preset, index) => {
|
||||
const ratio = dimensionToAspectRatio(preset);
|
||||
return {
|
||||
id: index.toString(),
|
||||
label: PRESET_LABELS[ratio] ?? ratio,
|
||||
ratio,
|
||||
canvasSize: preset,
|
||||
};
|
||||
});
|
||||
|
||||
const selectedPresetId = canvasSizeMode === "preset"
|
||||
? (presetItems.find((preset) =>
|
||||
areCanvasSizesEqual({
|
||||
left: preset.canvasSize,
|
||||
right: currentCanvasSize,
|
||||
}),
|
||||
)?.id ?? null)
|
||||
: null;
|
||||
|
||||
const updateCustomCanvasSize = ({
|
||||
canvasSize,
|
||||
}: {
|
||||
canvasSize: TCanvasSize;
|
||||
}) => {
|
||||
const shouldUpdateCanvasSize = !areCanvasSizesEqual({
|
||||
left: canvasSize,
|
||||
right: currentCanvasSize,
|
||||
});
|
||||
const shouldUpdateLastCustomCanvasSize =
|
||||
lastCustomCanvasSize === null ||
|
||||
!areCanvasSizesEqual({
|
||||
left: canvasSize,
|
||||
right: lastCustomCanvasSize,
|
||||
});
|
||||
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
|
||||
|
||||
if (
|
||||
!shouldUpdateCanvasSize &&
|
||||
!shouldUpdateLastCustomCanvasSize &&
|
||||
!shouldUpdateCanvasSizeMode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.project.updateSettings({
|
||||
settings: {
|
||||
...(shouldUpdateCanvasSize ? { canvasSize } : {}),
|
||||
...(shouldUpdateCanvasSizeMode
|
||||
? { canvasSizeMode: "custom" as const }
|
||||
: {}),
|
||||
lastCustomCanvasSize: canvasSize,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectPresetCanvasSize = ({
|
||||
canvasSize,
|
||||
}: {
|
||||
canvasSize: TCanvasSize;
|
||||
}) => {
|
||||
const shouldUpdateCanvasSize = !areCanvasSizesEqual({
|
||||
left: canvasSize,
|
||||
right: currentCanvasSize,
|
||||
});
|
||||
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
|
||||
|
||||
if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
|
||||
|
||||
editor.project.updateSettings({
|
||||
settings: {
|
||||
...(shouldUpdateCanvasSize ? { canvasSize } : {}),
|
||||
...(shouldUpdateCanvasSizeMode
|
||||
? { canvasSizeMode: "preset" as const }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectCustomCanvasSize = () => {
|
||||
updateCustomCanvasSize({
|
||||
canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
|
||||
});
|
||||
};
|
||||
|
||||
const widthDraft = useCanvasDimensionDraft({
|
||||
value: currentCanvasSize.width,
|
||||
onCommit: (width) =>
|
||||
updateCustomCanvasSize({
|
||||
canvasSize: { width, height: currentCanvasSize.height },
|
||||
}),
|
||||
});
|
||||
|
||||
const heightDraft = useCanvasDimensionDraft({
|
||||
value: currentCanvasSize.height,
|
||||
onCommit: (height) =>
|
||||
updateCustomCanvasSize({
|
||||
canvasSize: { width: currentCanvasSize.width, height },
|
||||
}),
|
||||
});
|
||||
|
||||
const isCustomSelected = canvasSizeMode === "custom";
|
||||
|
||||
return (
|
||||
<PanelView
|
||||
contentClassName="px-0"
|
||||
scrollClassName="pt-0"
|
||||
actions={
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="project-info">Project info</TabsTrigger>
|
||||
<TabsTrigger value="background">Background</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
}
|
||||
>
|
||||
{view === "project-info" && (
|
||||
<div className="flex flex-col">
|
||||
<Section showTopBorder={false}>
|
||||
<SectionHeader>
|
||||
<SectionTitle className="flex-1">Name</SectionTitle>
|
||||
<span className="text-sm truncate">
|
||||
{activeProject.metadata.name}
|
||||
</span>
|
||||
</SectionHeader>
|
||||
</Section>
|
||||
<Section showTopBorder={false}>
|
||||
<SectionHeader className="justify-between">
|
||||
<SectionTitle className="flex-1">Frame rate</SectionTitle>
|
||||
<Select
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={(value) => {
|
||||
const fps = parseFloat(value);
|
||||
editor.project.updateSettings({ settings: { fps } });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-transparent border-none p-1 h-auto">
|
||||
<SelectValue placeholder="Select a frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FPS_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SectionHeader>
|
||||
</Section>
|
||||
<Section
|
||||
showTopBorder={false}
|
||||
collapsible
|
||||
sectionKey="settings:aspect-ratio"
|
||||
>
|
||||
<SectionHeader>
|
||||
<SectionTitle className="flex-1">Aspect ratio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent className="px-2 flex flex-col gap-1 pb-2">
|
||||
{presetItems.map((preset) => (
|
||||
<AspectRatioItem
|
||||
key={preset.id}
|
||||
label={preset.label}
|
||||
previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
|
||||
isSelected={selectedPresetId === preset.id}
|
||||
onClick={() => {
|
||||
selectPresetCanvasSize({
|
||||
canvasSize: preset.canvasSize,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="pb-2">
|
||||
<AspectRatioItem
|
||||
key="custom"
|
||||
label="Custom"
|
||||
previewIcon={<OcSquarePlusIcon />}
|
||||
isSelected={isCustomSelected}
|
||||
onClick={selectCustomCanvasSize}
|
||||
uiOptions={
|
||||
<div className=" flex items-center gap-2 text-foreground">
|
||||
<NumberField
|
||||
value={widthDraft.displayValue}
|
||||
className="w-full"
|
||||
aria-label="Canvas width"
|
||||
onFocus={widthDraft.onFocus}
|
||||
onChange={widthDraft.onChange}
|
||||
onBlur={widthDraft.onBlur}
|
||||
/>
|
||||
<NumberField
|
||||
value={heightDraft.displayValue}
|
||||
className="w-full"
|
||||
aria-label="Canvas height"
|
||||
onFocus={heightDraft.onFocus}
|
||||
onChange={heightDraft.onChange}
|
||||
onBlur={heightDraft.onBlur}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
{view === "background" && <BackgroundContent />}
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
||||
function AspectRatioItem({
|
||||
label,
|
||||
previewIcon,
|
||||
isSelected,
|
||||
onClick,
|
||||
uiOptions,
|
||||
}: {
|
||||
label: string;
|
||||
previewIcon: React.ReactNode;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
uiOptions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant={isSelected ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"px-2 py-0 flex flex-col h-fit w-full",
|
||||
!isSelected && "border border-transparent opacity-75!",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="w-full flex justify-between items-center h-8">
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<div className="flex items-center justify-center size-5">
|
||||
{previewIcon}
|
||||
</div>
|
||||
<span className="text-sm truncate">{label}</span>
|
||||
</div>
|
||||
<div>
|
||||
{isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
|
||||
</div>
|
||||
</div>
|
||||
{uiOptions && isSelected && (
|
||||
<div className="w-full pb-2">{uiOptions}</div>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function AspectRatioPreview({ ratio }: { ratio?: string }) {
|
||||
if (!ratio) return null;
|
||||
|
||||
const [w, h] = ratio.split(":").map(Number);
|
||||
const maxSize = 16;
|
||||
const width = w >= h ? maxSize : (w / h) * maxSize;
|
||||
const height = h >= w ? maxSize : (h / w) * maxSize;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width, height, borderWidth: 1.5 }}
|
||||
className="rounded-xs border-current opacity-60"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,151 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { EditableTimecode } from "@/components/editable-timecode";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FullScreenIcon,
|
||||
GridTableIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { getGuideById } from "@/lib/guides";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
} from "@/components/ui/select";
|
||||
import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
|
||||
import { usePreviewViewport } from "./preview-viewport";
|
||||
import { GridPopover } from "./guide-popover";
|
||||
import { usePreviewStore } from "@/stores/preview-store";
|
||||
|
||||
export function PreviewToolbar({
|
||||
onToggleFullscreen,
|
||||
}: {
|
||||
onToggleFullscreen: () => void;
|
||||
}) {
|
||||
const activeGuide = usePreviewStore((state) => state.activeGuide);
|
||||
const activeGuideDefinition = getGuideById(activeGuide);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
|
||||
<TimecodeDisplay />
|
||||
<PlayPauseButton />
|
||||
<div className="justify-self-end flex items-center gap-2.5">
|
||||
<ZoomSelect />
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<GridPopover>
|
||||
<Button
|
||||
variant={activeGuideDefinition ? "secondary" : "text"}
|
||||
size="icon"
|
||||
>
|
||||
{activeGuideDefinition ? (
|
||||
activeGuideDefinition.renderTriggerIcon()
|
||||
) : (
|
||||
<HugeiconsIcon icon={GridTableIcon} />
|
||||
)}
|
||||
</Button>
|
||||
</GridPopover>
|
||||
<Button variant="text" onClick={onToggleFullscreen}>
|
||||
<HugeiconsIcon icon={FullScreenIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimecodeDisplay() {
|
||||
const editor = useEditor();
|
||||
const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
|
||||
const fps = useEditor((e) => e.project.getActive().settings.fps);
|
||||
const [currentTime, setCurrentTime] = useState(() =>
|
||||
editor.playback.getCurrentTime(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) =>
|
||||
setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
|
||||
window.addEventListener("playback-update", handler);
|
||||
window.addEventListener("playback-seek", handler);
|
||||
return () => {
|
||||
window.removeEventListener("playback-update", handler);
|
||||
window.removeEventListener("playback-seek", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<EditableTimecode
|
||||
time={currentTime}
|
||||
duration={totalDuration}
|
||||
format="HH:MM:SS:FF"
|
||||
fps={fps}
|
||||
onTimeChange={({ time }) => editor.playback.seek({ time })}
|
||||
className="text-center"
|
||||
/>
|
||||
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{formatTimeCode({
|
||||
timeInSeconds: totalDuration,
|
||||
format: "HH:MM:SS:FF",
|
||||
fps,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomSelect() {
|
||||
const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
|
||||
usePreviewViewport();
|
||||
|
||||
const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
|
||||
|
||||
const onValueChange = (value: string) => {
|
||||
if (value === "fit") {
|
||||
fitToScreen();
|
||||
} else {
|
||||
setViewportPercent({ percent: Number(value) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={isAtFit ? "fit" : String(zoomPercent)}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
<SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fit">Fit</SelectItem>
|
||||
<SelectSeparator />
|
||||
{PREVIEW_ZOOM_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset} value={String(preset)}>
|
||||
{preset}%
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayPauseButton() {
|
||||
const isPlaying = useEditor((e) => e.playback.getIsPlaying());
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={() => invokeAction("toggle-play")}
|
||||
>
|
||||
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { formatTimeCode } from "opencut-wasm";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { EditableTimecode } from "@/components/editable-timecode";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FullScreenIcon,
|
||||
GridTableIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { getGuideById } from "@/lib/guides";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
} from "@/components/ui/select";
|
||||
import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
|
||||
import { usePreviewViewport } from "./preview-viewport";
|
||||
import { GridPopover } from "./guide-popover";
|
||||
import { usePreviewStore } from "@/stores/preview-store";
|
||||
|
||||
export function PreviewToolbar({
|
||||
onToggleFullscreen,
|
||||
}: {
|
||||
onToggleFullscreen: () => void;
|
||||
}) {
|
||||
const activeGuide = usePreviewStore((state) => state.activeGuide);
|
||||
const activeGuideDefinition = getGuideById(activeGuide);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
|
||||
<TimecodeDisplay />
|
||||
<PlayPauseButton />
|
||||
<div className="justify-self-end flex items-center gap-2.5">
|
||||
<ZoomSelect />
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<GridPopover>
|
||||
<Button
|
||||
variant={activeGuideDefinition ? "secondary" : "text"}
|
||||
size="icon"
|
||||
>
|
||||
{activeGuideDefinition ? (
|
||||
activeGuideDefinition.renderTriggerIcon()
|
||||
) : (
|
||||
<HugeiconsIcon icon={GridTableIcon} />
|
||||
)}
|
||||
</Button>
|
||||
</GridPopover>
|
||||
<Button variant="text" onClick={onToggleFullscreen}>
|
||||
<HugeiconsIcon icon={FullScreenIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimecodeDisplay() {
|
||||
const editor = useEditor();
|
||||
const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
|
||||
const fps = useEditor((e) => e.project.getActive().settings.fps);
|
||||
const [currentTime, setCurrentTime] = useState(() =>
|
||||
editor.playback.getCurrentTime(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) =>
|
||||
setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
|
||||
window.addEventListener("playback-update", handler);
|
||||
window.addEventListener("playback-seek", handler);
|
||||
return () => {
|
||||
window.removeEventListener("playback-update", handler);
|
||||
window.removeEventListener("playback-seek", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<EditableTimecode
|
||||
time={currentTime}
|
||||
duration={totalDuration}
|
||||
format="HH:MM:SS:FF"
|
||||
fps={fps}
|
||||
onTimeChange={({ time }) => editor.playback.seek({ time })}
|
||||
className="text-center"
|
||||
/>
|
||||
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{formatTimeCode({ timeInSeconds: totalDuration, format: "HH:MM:SS:FF", fps })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomSelect() {
|
||||
const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
|
||||
usePreviewViewport();
|
||||
|
||||
const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
|
||||
|
||||
const onValueChange = (value: string) => {
|
||||
if (value === "fit") {
|
||||
fitToScreen();
|
||||
} else {
|
||||
setViewportPercent({ percent: Number(value) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={isAtFit ? "fit" : String(zoomPercent)}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
<SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fit">Fit</SelectItem>
|
||||
<SelectSeparator />
|
||||
{PREVIEW_ZOOM_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset} value={String(preset)}>
|
||||
{preset}%
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayPauseButton() {
|
||||
const isPlaying = useEditor((e) => e.playback.getIsPlaying());
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={() => invokeAction("toggle-play")}
|
||||
>
|
||||
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,303 +1,303 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
EffectElement,
|
||||
GraphicElement,
|
||||
ImageElement,
|
||||
MaskableElement,
|
||||
RetimableElement,
|
||||
StickerElement,
|
||||
TextElement,
|
||||
VisualElement,
|
||||
VideoElement,
|
||||
AudioElement,
|
||||
TimelineElement,
|
||||
} from "@/lib/timeline";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
TextFontIcon,
|
||||
ArrowExpandIcon,
|
||||
RainDropIcon,
|
||||
MusicNote03Icon,
|
||||
MagicWand05Icon,
|
||||
DashboardSpeed02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { TransformTab } from "./tabs/transform-tab";
|
||||
import { BlendingTab } from "./tabs/blending-tab";
|
||||
import { AudioTab } from "./tabs/audio-tab";
|
||||
import { TextTab } from "./tabs/text-tab";
|
||||
import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
|
||||
import { MasksTab } from "./tabs/masks-tab";
|
||||
import { SpeedTab } from "./tabs/speed-tab";
|
||||
import { GraphicTab } from "./tabs/graphic-tab";
|
||||
import { OcShapesIcon } from "@opencut/ui/icons";
|
||||
|
||||
export type TabContentProps = {
|
||||
trackId: string;
|
||||
};
|
||||
|
||||
export type PropertiesTabDef = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
content: (props: TabContentProps) => ReactNode;
|
||||
};
|
||||
|
||||
export type ElementPropertiesConfig = {
|
||||
defaultTab: string;
|
||||
tabs: PropertiesTabDef[];
|
||||
};
|
||||
|
||||
function buildTransformTab({
|
||||
element,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "transform",
|
||||
label: "Transform",
|
||||
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<TransformTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBlendingTab({
|
||||
element,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "blending",
|
||||
label: "Blending",
|
||||
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<BlendingTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAudioTab({
|
||||
element,
|
||||
}: {
|
||||
element: AudioElement | VideoElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "audio",
|
||||
label: "Audio",
|
||||
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
|
||||
content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSpeedTab({
|
||||
element,
|
||||
}: {
|
||||
element: RetimableElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "speed",
|
||||
label: "Speed",
|
||||
icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
|
||||
content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMasksTab({
|
||||
element,
|
||||
}: {
|
||||
element: MaskableElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "masks",
|
||||
label: "Masks",
|
||||
icon: <OcShapesIcon size={16} />,
|
||||
content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClipEffectsTab({
|
||||
element,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "effects",
|
||||
label: "Effects",
|
||||
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<ClipEffectsTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
|
||||
return {
|
||||
id: "text",
|
||||
label: "Text",
|
||||
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
|
||||
content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildGraphicTab({
|
||||
element,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "graphic",
|
||||
label: "Graphic",
|
||||
icon: <OcShapesIcon size={16} />,
|
||||
content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStandaloneEffectTab({
|
||||
element,
|
||||
}: {
|
||||
element: EffectElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "effects",
|
||||
label: "Effects",
|
||||
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<StandaloneEffectTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getTextConfig({
|
||||
element,
|
||||
}: {
|
||||
element: TextElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "text",
|
||||
tabs: [
|
||||
buildTextTab({ element }),
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getVideoConfig({
|
||||
element,
|
||||
mediaAsset,
|
||||
}: {
|
||||
element: VideoElement;
|
||||
mediaAsset: MediaAsset | undefined;
|
||||
}): ElementPropertiesConfig {
|
||||
const showAudioTab = mediaAsset?.hasAudio !== false;
|
||||
return {
|
||||
defaultTab: "transform",
|
||||
tabs: [
|
||||
buildTransformTab({ element }),
|
||||
...(showAudioTab ? [buildAudioTab({ element })] : []),
|
||||
buildSpeedTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildMasksTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getImageConfig({
|
||||
element,
|
||||
}: {
|
||||
element: ImageElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "transform",
|
||||
tabs: [
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildMasksTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getStickerConfig({
|
||||
element,
|
||||
}: {
|
||||
element: StickerElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "transform",
|
||||
tabs: [
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getGraphicConfig({
|
||||
element,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "graphic",
|
||||
tabs: [
|
||||
buildGraphicTab({ element }),
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildMasksTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getAudioConfig({
|
||||
element,
|
||||
}: {
|
||||
element: AudioElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "audio",
|
||||
tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
|
||||
};
|
||||
}
|
||||
|
||||
function getEffectConfig({
|
||||
element,
|
||||
}: {
|
||||
element: EffectElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "effects",
|
||||
tabs: [buildStandaloneEffectTab({ element })],
|
||||
};
|
||||
}
|
||||
|
||||
export function getPropertiesConfig({
|
||||
element,
|
||||
mediaAssets,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
mediaAssets: MediaAsset[];
|
||||
}): ElementPropertiesConfig {
|
||||
switch (element.type) {
|
||||
case "text":
|
||||
return getTextConfig({ element });
|
||||
case "video": {
|
||||
const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
|
||||
return getVideoConfig({ element, mediaAsset });
|
||||
}
|
||||
case "image":
|
||||
return getImageConfig({ element });
|
||||
case "sticker":
|
||||
return getStickerConfig({ element });
|
||||
case "graphic":
|
||||
return getGraphicConfig({ element });
|
||||
case "audio":
|
||||
return getAudioConfig({ element });
|
||||
case "effect":
|
||||
return getEffectConfig({ element });
|
||||
}
|
||||
}
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
EffectElement,
|
||||
GraphicElement,
|
||||
ImageElement,
|
||||
MaskableElement,
|
||||
RetimableElement,
|
||||
StickerElement,
|
||||
TextElement,
|
||||
VisualElement,
|
||||
VideoElement,
|
||||
AudioElement,
|
||||
TimelineElement,
|
||||
} from "@/lib/timeline";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
TextFontIcon,
|
||||
ArrowExpandIcon,
|
||||
RainDropIcon,
|
||||
MusicNote03Icon,
|
||||
MagicWand05Icon,
|
||||
DashboardSpeed02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { TransformTab } from "./tabs/transform-tab";
|
||||
import { BlendingTab } from "./tabs/blending-tab";
|
||||
import { AudioTab } from "./tabs/audio-tab";
|
||||
import { TextTab } from "./tabs/text-tab";
|
||||
import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
|
||||
import { MasksTab } from "./tabs/masks-tab";
|
||||
import { SpeedTab } from "./tabs/speed-tab";
|
||||
import { GraphicTab } from "./tabs/graphic-tab";
|
||||
import { OcShapesIcon } from "@/components/icons";
|
||||
|
||||
export type TabContentProps = {
|
||||
trackId: string;
|
||||
};
|
||||
|
||||
export type PropertiesTabDef = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
content: (props: TabContentProps) => ReactNode;
|
||||
};
|
||||
|
||||
export type ElementPropertiesConfig = {
|
||||
defaultTab: string;
|
||||
tabs: PropertiesTabDef[];
|
||||
};
|
||||
|
||||
function buildTransformTab({
|
||||
element,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "transform",
|
||||
label: "Transform",
|
||||
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<TransformTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBlendingTab({
|
||||
element,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "blending",
|
||||
label: "Blending",
|
||||
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<BlendingTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAudioTab({
|
||||
element,
|
||||
}: {
|
||||
element: AudioElement | VideoElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "audio",
|
||||
label: "Audio",
|
||||
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
|
||||
content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSpeedTab({
|
||||
element,
|
||||
}: {
|
||||
element: RetimableElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "speed",
|
||||
label: "Speed",
|
||||
icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
|
||||
content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMasksTab({
|
||||
element,
|
||||
}: {
|
||||
element: MaskableElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "masks",
|
||||
label: "Masks",
|
||||
icon: <OcShapesIcon size={16} />,
|
||||
content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClipEffectsTab({
|
||||
element,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "effects",
|
||||
label: "Effects",
|
||||
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<ClipEffectsTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
|
||||
return {
|
||||
id: "text",
|
||||
label: "Text",
|
||||
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
|
||||
content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildGraphicTab({
|
||||
element,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "graphic",
|
||||
label: "Graphic",
|
||||
icon: <OcShapesIcon size={16} />,
|
||||
content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStandaloneEffectTab({
|
||||
element,
|
||||
}: {
|
||||
element: EffectElement;
|
||||
}): PropertiesTabDef {
|
||||
return {
|
||||
id: "effects",
|
||||
label: "Effects",
|
||||
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
|
||||
content: ({ trackId }) => (
|
||||
<StandaloneEffectTab element={element} trackId={trackId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getTextConfig({
|
||||
element,
|
||||
}: {
|
||||
element: TextElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "text",
|
||||
tabs: [
|
||||
buildTextTab({ element }),
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getVideoConfig({
|
||||
element,
|
||||
mediaAsset,
|
||||
}: {
|
||||
element: VideoElement;
|
||||
mediaAsset: MediaAsset | undefined;
|
||||
}): ElementPropertiesConfig {
|
||||
const showAudioTab = mediaAsset?.hasAudio !== false;
|
||||
return {
|
||||
defaultTab: "transform",
|
||||
tabs: [
|
||||
buildTransformTab({ element }),
|
||||
...(showAudioTab ? [buildAudioTab({ element })] : []),
|
||||
buildSpeedTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildMasksTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getImageConfig({
|
||||
element,
|
||||
}: {
|
||||
element: ImageElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "transform",
|
||||
tabs: [
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildMasksTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getStickerConfig({
|
||||
element,
|
||||
}: {
|
||||
element: StickerElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "transform",
|
||||
tabs: [
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getGraphicConfig({
|
||||
element,
|
||||
}: {
|
||||
element: GraphicElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "graphic",
|
||||
tabs: [
|
||||
buildGraphicTab({ element }),
|
||||
buildTransformTab({ element }),
|
||||
buildBlendingTab({ element }),
|
||||
buildMasksTab({ element }),
|
||||
buildClipEffectsTab({ element }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getAudioConfig({
|
||||
element,
|
||||
}: {
|
||||
element: AudioElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "audio",
|
||||
tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
|
||||
};
|
||||
}
|
||||
|
||||
function getEffectConfig({
|
||||
element,
|
||||
}: {
|
||||
element: EffectElement;
|
||||
}): ElementPropertiesConfig {
|
||||
return {
|
||||
defaultTab: "effects",
|
||||
tabs: [buildStandaloneEffectTab({ element })],
|
||||
};
|
||||
}
|
||||
|
||||
export function getPropertiesConfig({
|
||||
element,
|
||||
mediaAssets,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
mediaAssets: MediaAsset[];
|
||||
}): ElementPropertiesConfig {
|
||||
switch (element.type) {
|
||||
case "text":
|
||||
return getTextConfig({ element });
|
||||
case "video": {
|
||||
const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
|
||||
return getVideoConfig({ element, mediaAsset });
|
||||
}
|
||||
case "image":
|
||||
return getImageConfig({ element });
|
||||
case "sticker":
|
||||
return getStickerConfig({ element });
|
||||
case "graphic":
|
||||
return getGraphicConfig({ element });
|
||||
case "audio":
|
||||
return getAudioConfig({ element });
|
||||
case "effect":
|
||||
return getEffectConfig({ element });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
||||
import { isSourceAudioSeparated } from "@/lib/timeline/audio-separation";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import {
|
||||
clamp,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
} from "@/utils/math";
|
||||
import type { AudioElement, VideoElement } from "@/lib/timeline";
|
||||
import { resolveNumberAtTime } from "@/lib/animation";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
import { KeyframeToggle } from "../components/keyframe-toggle";
|
||||
@@ -34,6 +37,7 @@ export function AudioTab({
|
||||
element: AudioElement | VideoElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
@@ -80,46 +84,68 @@ export function AudioTab({
|
||||
rightValue: DEFAULTS.element.volume,
|
||||
})
|
||||
: (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
|
||||
const isSeparated =
|
||||
element.type === "video" && isSourceAudioSeparated({ element });
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.id}:audio`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Audio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField
|
||||
label="Volume"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={volume.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle volume keyframe"
|
||||
onToggle={volume.toggleKeyframe}
|
||||
/>
|
||||
<>
|
||||
{isSeparated && (
|
||||
<div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
|
||||
<p className="text-sm">Audio has been separated.</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
editor.timeline.toggleSourceAudioSeparation({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
|
||||
value={volume.displayValue}
|
||||
onFocus={volume.onFocus}
|
||||
onChange={volume.onChange}
|
||||
onBlur={volume.onBlur}
|
||||
dragSensitivity="slow"
|
||||
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
|
||||
onScrub={volume.scrubTo}
|
||||
onScrubEnd={volume.commitScrub}
|
||||
onReset={() =>
|
||||
volume.commitValue({
|
||||
value: DEFAULTS.element.volume,
|
||||
})
|
||||
Recover audio
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Section collapsible sectionKey={`${element.id}:audio`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Audio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField
|
||||
label="Volume"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={volume.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle volume keyframe"
|
||||
onToggle={volume.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
isDefault={isDefault}
|
||||
suffix="dB"
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
>
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
|
||||
value={volume.displayValue}
|
||||
onFocus={volume.onFocus}
|
||||
onChange={volume.onChange}
|
||||
onBlur={volume.onBlur}
|
||||
dragSensitivity="slow"
|
||||
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
|
||||
onScrub={volume.scrubTo}
|
||||
onScrubEnd={volume.commitScrub}
|
||||
onReset={() =>
|
||||
volume.commitValue({
|
||||
value: DEFAULTS.element.volume,
|
||||
})
|
||||
}
|
||||
isDefault={isDefault}
|
||||
suffix="dB"
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,230 +1,230 @@
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { OcCheckerboardIcon } from "@opencut/ui/icons";
|
||||
import { Fragment, useRef } from "react";
|
||||
import { useMenuPreview } from "@/hooks/use-menu-preview";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionField,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BlendMode } from "@/lib/rendering";
|
||||
import type { ElementType } from "@/lib/timeline";
|
||||
import type { ElementAnimations } from "@/lib/animation/types";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { RainDropIcon } from "@hugeicons/core-free-icons";
|
||||
import { KeyframeToggle } from "../components/keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import { resolveOpacityAtTime } from "@/lib/animation";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { isPropertyAtDefault } from "./transform-tab";
|
||||
|
||||
type BlendingElement = {
|
||||
id: string;
|
||||
opacity: number;
|
||||
type: ElementType;
|
||||
blendMode?: BlendMode;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
|
||||
const BLEND_MODE_GROUPS = [
|
||||
[{ value: "normal", label: "Normal" }],
|
||||
[
|
||||
{ value: "darken", label: "Darken" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
{ value: "color-burn", label: "Color Burn" },
|
||||
],
|
||||
[
|
||||
{ value: "lighten", label: "Lighten" },
|
||||
{ value: "screen", label: "Screen" },
|
||||
{ value: "plus-lighter", label: "Plus Lighter" },
|
||||
{ value: "color-dodge", label: "Color Dodge" },
|
||||
],
|
||||
[
|
||||
{ value: "overlay", label: "Overlay" },
|
||||
{ value: "soft-light", label: "Soft Light" },
|
||||
{ value: "hard-light", label: "Hard Light" },
|
||||
],
|
||||
[
|
||||
{ value: "difference", label: "Difference" },
|
||||
{ value: "exclusion", label: "Exclusion" },
|
||||
],
|
||||
[
|
||||
{ value: "hue", label: "Hue" },
|
||||
{ value: "saturation", label: "Saturation" },
|
||||
{ value: "color", label: "Color" },
|
||||
{ value: "luminosity", label: "Luminosity" },
|
||||
],
|
||||
];
|
||||
|
||||
export function BlendingTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: BlendingElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
|
||||
const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
|
||||
const committedBlendModeRef = useRef(blendMode);
|
||||
if (!isPreviewActive) {
|
||||
committedBlendModeRef.current = blendMode;
|
||||
}
|
||||
|
||||
const {
|
||||
onPointerLeave,
|
||||
onOpenChange: handleBlendModeOpenChange,
|
||||
markCommitted,
|
||||
} = useMenuPreview();
|
||||
|
||||
const previewBlendMode = ({ value }: { value: BlendMode }) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { blendMode: value } },
|
||||
],
|
||||
});
|
||||
|
||||
const commitBlendMode = (value: string) => {
|
||||
if (editor.timeline.isPreviewActive()) {
|
||||
editor.timeline.commitPreview();
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { blendMode: value as BlendMode },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
markCommitted();
|
||||
};
|
||||
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedOpacity = resolveOpacityAtTime({
|
||||
baseOpacity: element.opacity,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const opacity = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "opacity",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedOpacity * 100).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
|
||||
},
|
||||
valueAtPlayhead: resolvedOpacity,
|
||||
step: 0.01,
|
||||
buildBaseUpdates: ({ value }) => ({ opacity: value }),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.id}:blending`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Blending</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex items-start gap-2">
|
||||
<SectionField
|
||||
label="Opacity"
|
||||
className="w-1/2"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={opacity.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle opacity keyframe"
|
||||
onToggle={opacity.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
className="w-full"
|
||||
icon={
|
||||
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
|
||||
}
|
||||
value={opacity.displayValue}
|
||||
min={0}
|
||||
max={100}
|
||||
onFocus={opacity.onFocus}
|
||||
onChange={opacity.onChange}
|
||||
onBlur={opacity.onBlur}
|
||||
onScrub={opacity.scrubTo}
|
||||
onScrubEnd={opacity.commitScrub}
|
||||
onReset={() =>
|
||||
opacity.commitValue({ value: DEFAULTS.element.opacity })
|
||||
}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedOpacity,
|
||||
staticValue: element.opacity,
|
||||
defaultValue: DEFAULTS.element.opacity,
|
||||
})}
|
||||
dragSensitivity="slow"
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Blend mode" className="w-1/2">
|
||||
<Select
|
||||
value={committedBlendModeRef.current}
|
||||
onOpenChange={handleBlendModeOpenChange}
|
||||
onValueChange={commitBlendMode}
|
||||
>
|
||||
<SelectTrigger
|
||||
icon={<HugeiconsIcon icon={RainDropIcon} />}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder="Select blend mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-36" onPointerLeave={onPointerLeave}>
|
||||
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
|
||||
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
|
||||
{group.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onPointerEnter={() =>
|
||||
previewBlendMode({ value: option.value as BlendMode })
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
|
||||
<SelectSeparator />
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SectionField>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { OcCheckerboardIcon } from "@/components/icons";
|
||||
import { Fragment, useRef } from "react";
|
||||
import { useMenuPreview } from "@/hooks/use-menu-preview";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionField,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BlendMode } from "@/lib/rendering";
|
||||
import type { ElementType } from "@/lib/timeline";
|
||||
import type { ElementAnimations } from "@/lib/animation/types";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { RainDropIcon } from "@hugeicons/core-free-icons";
|
||||
import { KeyframeToggle } from "../components/keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import { resolveOpacityAtTime } from "@/lib/animation";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { isPropertyAtDefault } from "./transform-tab";
|
||||
|
||||
type BlendingElement = {
|
||||
id: string;
|
||||
opacity: number;
|
||||
type: ElementType;
|
||||
blendMode?: BlendMode;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
animations?: ElementAnimations;
|
||||
};
|
||||
|
||||
const BLEND_MODE_GROUPS = [
|
||||
[{ value: "normal", label: "Normal" }],
|
||||
[
|
||||
{ value: "darken", label: "Darken" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
{ value: "color-burn", label: "Color Burn" },
|
||||
],
|
||||
[
|
||||
{ value: "lighten", label: "Lighten" },
|
||||
{ value: "screen", label: "Screen" },
|
||||
{ value: "plus-lighter", label: "Plus Lighter" },
|
||||
{ value: "color-dodge", label: "Color Dodge" },
|
||||
],
|
||||
[
|
||||
{ value: "overlay", label: "Overlay" },
|
||||
{ value: "soft-light", label: "Soft Light" },
|
||||
{ value: "hard-light", label: "Hard Light" },
|
||||
],
|
||||
[
|
||||
{ value: "difference", label: "Difference" },
|
||||
{ value: "exclusion", label: "Exclusion" },
|
||||
],
|
||||
[
|
||||
{ value: "hue", label: "Hue" },
|
||||
{ value: "saturation", label: "Saturation" },
|
||||
{ value: "color", label: "Color" },
|
||||
{ value: "luminosity", label: "Luminosity" },
|
||||
],
|
||||
];
|
||||
|
||||
export function BlendingTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: BlendingElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
|
||||
const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
|
||||
const committedBlendModeRef = useRef(blendMode);
|
||||
if (!isPreviewActive) {
|
||||
committedBlendModeRef.current = blendMode;
|
||||
}
|
||||
|
||||
const {
|
||||
onPointerLeave,
|
||||
onOpenChange: handleBlendModeOpenChange,
|
||||
markCommitted,
|
||||
} = useMenuPreview();
|
||||
|
||||
const previewBlendMode = ({ value }: { value: BlendMode }) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { blendMode: value } },
|
||||
],
|
||||
});
|
||||
|
||||
const commitBlendMode = (value: string) => {
|
||||
if (editor.timeline.isPreviewActive()) {
|
||||
editor.timeline.commitPreview();
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { blendMode: value as BlendMode },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
markCommitted();
|
||||
};
|
||||
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedOpacity = resolveOpacityAtTime({
|
||||
baseOpacity: element.opacity,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const opacity = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "opacity",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedOpacity * 100).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
|
||||
},
|
||||
valueAtPlayhead: resolvedOpacity,
|
||||
step: 0.01,
|
||||
buildBaseUpdates: ({ value }) => ({ opacity: value }),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.id}:blending`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Blending</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="flex items-start gap-2">
|
||||
<SectionField
|
||||
label="Opacity"
|
||||
className="w-1/2"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={opacity.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle opacity keyframe"
|
||||
onToggle={opacity.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
className="w-full"
|
||||
icon={
|
||||
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
|
||||
}
|
||||
value={opacity.displayValue}
|
||||
min={0}
|
||||
max={100}
|
||||
onFocus={opacity.onFocus}
|
||||
onChange={opacity.onChange}
|
||||
onBlur={opacity.onBlur}
|
||||
onScrub={opacity.scrubTo}
|
||||
onScrubEnd={opacity.commitScrub}
|
||||
onReset={() =>
|
||||
opacity.commitValue({ value: DEFAULTS.element.opacity })
|
||||
}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedOpacity,
|
||||
staticValue: element.opacity,
|
||||
defaultValue: DEFAULTS.element.opacity,
|
||||
})}
|
||||
dragSensitivity="slow"
|
||||
/>
|
||||
</SectionField>
|
||||
<SectionField label="Blend mode" className="w-1/2">
|
||||
<Select
|
||||
value={committedBlendModeRef.current}
|
||||
onOpenChange={handleBlendModeOpenChange}
|
||||
onValueChange={commitBlendMode}
|
||||
>
|
||||
<SelectTrigger
|
||||
icon={<HugeiconsIcon icon={RainDropIcon} />}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder="Select blend mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-36" onPointerLeave={onPointerLeave}>
|
||||
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
|
||||
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
|
||||
{group.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onPointerEnter={() =>
|
||||
previewBlendMode({ value: option.value as BlendMode })
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
|
||||
<SelectSeparator />
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SectionField>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons";
|
||||
import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
type MasksTabProps = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ import {
|
||||
TIMELINE_BOOKMARK_ROW_HEIGHT,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||
import { getSnappedSeekTime } from "@/lib/time";
|
||||
import { getSnappedSeekTime } from "opencut-wasm";
|
||||
import {
|
||||
ArrowTurnBackwardIcon,
|
||||
Delete02Icon,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
VolumeOffIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
|
||||
import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/icons";
|
||||
import { OcShapesIcon, OcVideoIcon } from "@/components/icons";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -49,10 +49,10 @@ import {
|
||||
getTotalTracksHeight,
|
||||
canTracktHaveAudio,
|
||||
canTrackBeHidden,
|
||||
isMainTrack,
|
||||
getTimelineZoomMin,
|
||||
getTimelinePaddingPx,
|
||||
} from "@/lib/timeline";
|
||||
import { isMainTrack } from "@/lib/timeline/placement";
|
||||
import { TimelineToolbar } from "./timeline-toolbar";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
|
||||
|
||||
@@ -39,6 +39,11 @@ import type {
|
||||
} from "@/lib/timeline";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
||||
import {
|
||||
canToggleSourceAudio,
|
||||
getSourceAudioActionLabel,
|
||||
isSourceAudioSeparated,
|
||||
} from "@/lib/timeline/audio-separation";
|
||||
import {
|
||||
getActionDefinition,
|
||||
type TAction,
|
||||
@@ -61,7 +66,9 @@ import {
|
||||
Search01Icon,
|
||||
Exchange01Icon,
|
||||
KeyframeIcon,
|
||||
Link02Icon,
|
||||
MagicWand05Icon,
|
||||
Unlink02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { uppercase } from "@/utils/string";
|
||||
@@ -280,6 +287,19 @@ export function TimelineElement({
|
||||
};
|
||||
|
||||
const isMuted = canElementHaveAudio(element) && element.muted === true;
|
||||
const canToggleCurrentSourceAudio =
|
||||
selectedElements.length === 1 &&
|
||||
isCurrentElementSelected &&
|
||||
canToggleSourceAudio({
|
||||
element,
|
||||
mediaAsset,
|
||||
});
|
||||
const sourceAudioLabel =
|
||||
element.type === "video"
|
||||
? getSourceAudioActionLabel({ element })
|
||||
: "Extract audio";
|
||||
const isElementSourceAudioSeparated =
|
||||
element.type === "video" && isSourceAudioSeparated({ element });
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
@@ -334,6 +354,21 @@ export function TimelineElement({
|
||||
isMuted={isMuted}
|
||||
/>
|
||||
)}
|
||||
{canToggleCurrentSourceAudio && (
|
||||
<ContextMenuItem
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={isElementSourceAudioSeparated ? Unlink02Icon : Link02Icon}
|
||||
/>
|
||||
}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
invokeAction("toggle-source-audio");
|
||||
}}
|
||||
>
|
||||
{sourceAudioLabel}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{canElementBeHidden(element) && (
|
||||
<VisibilityMenuItem
|
||||
element={element}
|
||||
@@ -566,7 +601,7 @@ function KeyframeIndicators({
|
||||
<HugeiconsIcon
|
||||
icon={KeyframeIcon}
|
||||
className={cn(
|
||||
"size-3.5 mt-1.5 text-black",
|
||||
"size-3.5 text-black",
|
||||
isIndicatorSelected ? "fill-primary" : "fill-white",
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SplitSquareHorizontal } from "lucide-react";
|
||||
import {
|
||||
SplitButton,
|
||||
SplitButtonLeft,
|
||||
@@ -18,6 +18,12 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
|
||||
import { ScenesView } from "@/components/editor/scenes-view";
|
||||
import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions";
|
||||
import {
|
||||
canToggleSourceAudio,
|
||||
getSourceAudioActionLabel,
|
||||
isSourceAudioSeparated,
|
||||
} from "@/lib/timeline/audio-separation";
|
||||
import { hasMediaId } from "@/lib/timeline";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -33,9 +39,13 @@ import {
|
||||
Copy01Icon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
Link02Icon,
|
||||
Layers01Icon,
|
||||
Chart03Icon,
|
||||
Unlink02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { OcRippleIcon } from "@/components/icons";
|
||||
|
||||
export function TimelineToolbar({
|
||||
zoomLevel,
|
||||
@@ -76,9 +86,47 @@ export function TimelineToolbar({
|
||||
}
|
||||
|
||||
function ToolbarLeftSection() {
|
||||
const editor = useEditor();
|
||||
const mediaAssets = useEditor((currentEditor) => currentEditor.media.getAssets());
|
||||
const { selectedElements } = useElementSelection();
|
||||
const isCurrentlyBookmarked = useEditor((e) =>
|
||||
e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
|
||||
);
|
||||
const selectedElement =
|
||||
selectedElements.length === 1
|
||||
? (editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
})[0] ?? null)
|
||||
: null;
|
||||
const selectedMediaAsset = (() => {
|
||||
if (!selectedElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { element } = selectedElement;
|
||||
if (!hasMediaId(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
|
||||
})();
|
||||
const canToggleSelectedSourceAudio =
|
||||
!!selectedElement &&
|
||||
canToggleSourceAudio({
|
||||
element: selectedElement.element,
|
||||
mediaAsset: selectedMediaAsset,
|
||||
});
|
||||
const sourceAudioLabel =
|
||||
selectedElement?.element.type === "video"
|
||||
? getSourceAudioActionLabel({
|
||||
element: selectedElement.element,
|
||||
})
|
||||
: "Extract audio";
|
||||
const isSelectedSourceAudioSeparated =
|
||||
selectedElement?.element.type === "video" &&
|
||||
isSourceAudioSeparated({
|
||||
element: selectedElement.element,
|
||||
});
|
||||
|
||||
const handleAction = ({
|
||||
action,
|
||||
@@ -115,10 +163,16 @@ function ToolbarLeftSection() {
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={<SplitSquareHorizontal />}
|
||||
tooltip="Separate audio (coming soon)"
|
||||
disabled={true}
|
||||
onClick={({ event: _event }) => {}}
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
|
||||
/>
|
||||
}
|
||||
tooltip={sourceAudioLabel}
|
||||
disabled={!canToggleSelectedSourceAudio}
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "toggle-source-audio", event })
|
||||
}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
@@ -156,6 +210,14 @@ function ToolbarLeftSection() {
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={Chart03Icon} />}
|
||||
tooltip="Graph"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
@@ -207,7 +269,7 @@ function ToolbarRightSection({
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />}
|
||||
icon={<OcRippleIcon size={24} className="scale-110" />}
|
||||
isActive={rippleEditingEnabled}
|
||||
tooltip="Ripple editing"
|
||||
onClick={() => toggleRippleEditing()}
|
||||
@@ -265,6 +327,7 @@ function ToolbarButton({
|
||||
<Button
|
||||
variant={isActive ? "secondary" : "text"}
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
onClick={(event) => onClick({ event })}
|
||||
className={cn(
|
||||
"rounded-sm",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export function OcVercelIcon({ className }: { className?: string }) {
|
||||
import type { IconProps } from "./types";
|
||||
|
||||
export function OcVercelIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
@@ -17,10 +19,7 @@ export function OcVercelIcon({ className }: { className?: string }) {
|
||||
export function OcMarbleIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -43,10 +42,7 @@ export function OcMarbleIcon({
|
||||
export function OcDataBuddyIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface IconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { IconProps } from "./types";
|
||||
|
||||
export function OcVideoIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
@@ -34,10 +33,7 @@ export function OcVideoIcon({
|
||||
export function OcCheckerboardIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
@@ -68,10 +64,7 @@ export function OcCheckerboardIcon({
|
||||
export function OcSlidersVerticalIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
@@ -113,10 +106,7 @@ export function OcSlidersVerticalIcon({
|
||||
export function OcSocialIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={Math.round(size * (38 / 24))}
|
||||
@@ -162,10 +152,7 @@ export function OcSocialIcon({
|
||||
export function OcTextWidthIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={Math.round((size * 17) / 24)}
|
||||
@@ -190,10 +177,7 @@ export function OcTextWidthIcon({
|
||||
export function OcTextHeightIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={Math.round((size * 20) / 23)}
|
||||
@@ -218,10 +202,7 @@ export function OcTextHeightIcon({
|
||||
export function OcFontIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
@@ -244,11 +225,7 @@ export function OcShapesIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
@@ -288,10 +265,7 @@ export function OcShapesIcon({
|
||||
export function OcMirrorIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
@@ -329,11 +303,7 @@ export function OcSquarePlusIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
strokeWidth = 1.5,
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
@@ -383,3 +353,52 @@ export function OcSquarePlusIcon({
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function OcRippleIcon({
|
||||
className = "",
|
||||
size = 24,
|
||||
strokeWidth = 1.5,
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className={className}
|
||||
>
|
||||
<title>Ripple</title>
|
||||
<path
|
||||
d="M5 9C4.39316 9.58984 2 11.1597 2 12C2 12.8403 4.39316 14.4102 5 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M19 9C19.6068 9.58984 22 11.1597 22 12C22 12.8403 19.6068 14.4102 19 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2.42285 11.9795H21.868"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.1454 21.7021L12.1454 2.25692"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { type PropsWithChildren, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type HandlebarsProps = PropsWithChildren;
|
||||
|
||||
const MIN_HANDLE_SEPARATION_PX = 60;
|
||||
const MASK_GRADIENT_EDGE_PADDING_PX = 10;
|
||||
const HANDLEBARS_ROTATE_DEG = 2.76;
|
||||
const RIGHT_HANDLE_LEFT_OFFSET_PX = -30;
|
||||
|
||||
export function Handlebars({ children }: HandlebarsProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const leftHandleRef = useRef<HTMLDivElement>(null);
|
||||
@@ -31,18 +42,18 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
initialPosition: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
const newWidth = el.offsetWidth;
|
||||
const newWidth = container.offsetWidth;
|
||||
setWidth(newWidth);
|
||||
setRightHandle(newWidth);
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(updateWidth);
|
||||
observer.observe(el);
|
||||
observer.observe(container);
|
||||
updateWidth();
|
||||
|
||||
return () => observer.disconnect();
|
||||
@@ -66,7 +77,10 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
const deltaX = event.clientX - startX;
|
||||
|
||||
if (side === "left") {
|
||||
const maxLeft = Math.max(0, rightHandlePositionRef.current - 60);
|
||||
const maxLeft = Math.max(
|
||||
0,
|
||||
rightHandlePositionRef.current - MIN_HANDLE_SEPARATION_PX,
|
||||
);
|
||||
const nextLeftHandle = Math.max(
|
||||
0,
|
||||
Math.min(maxLeft, initialPosition + deltaX),
|
||||
@@ -77,7 +91,7 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
|
||||
const minRight = Math.min(
|
||||
widthRef.current,
|
||||
leftHandlePositionRef.current + 60,
|
||||
leftHandlePositionRef.current + MIN_HANDLE_SEPARATION_PX,
|
||||
);
|
||||
const nextRightHandle = Math.max(
|
||||
minRight,
|
||||
@@ -106,13 +120,30 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const leftGradientPercent = width > 0 ? (leftHandle / (width - 10)) * 100 : 0;
|
||||
const rightGradientPercent =
|
||||
width > 0 ? (rightHandle / (width + 10)) * 100 : 0;
|
||||
const hasMeasuredWidth = width > 0;
|
||||
const leftGradientPercent = hasMeasuredWidth
|
||||
? (leftHandle / (width - MASK_GRADIENT_EDGE_PADDING_PX)) * 100
|
||||
: 0;
|
||||
const rightGradientPercent = hasMeasuredWidth
|
||||
? (rightHandle / (width + MASK_GRADIENT_EDGE_PADDING_PX)) * 100
|
||||
: 100;
|
||||
const textMask = hasMeasuredWidth
|
||||
? `linear-gradient(90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0) ${leftGradientPercent}%,
|
||||
rgba(0, 0, 0) ${leftGradientPercent}%,
|
||||
rgba(0, 0, 0) ${rightGradientPercent}%,
|
||||
rgba(255, 255, 255, 0) ${rightGradientPercent}%,
|
||||
rgba(255, 255, 255, 0) 100%)`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center gap-4 leading-16">
|
||||
<div ref={containerRef} className="relative mt-0.5 -rotate-[2.76deg]">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative mt-0.5"
|
||||
style={{ transform: `rotate(-${HANDLEBARS_ROTATE_DEG}deg)` }}
|
||||
>
|
||||
<div className="absolute inset-0 z-10 flex size-full justify-between rounded-2xl border border-yellow-500">
|
||||
<div
|
||||
ref={leftHandleRef}
|
||||
@@ -135,9 +166,13 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
|
||||
<div
|
||||
ref={rightHandleRef}
|
||||
className="bg-background absolute -left-[30px] z-20 flex h-full w-7 cursor-ew-resize touch-none items-center justify-center rounded-full border border-yellow-500 select-none"
|
||||
className="bg-background absolute z-20 flex h-full w-7 cursor-ew-resize touch-none items-center justify-center rounded-full border border-yellow-500 select-none"
|
||||
style={{
|
||||
translate: `${rightHandle}px 0`,
|
||||
left: hasMeasuredWidth
|
||||
? `${RIGHT_HANDLE_LEFT_OFFSET_PX}px`
|
||||
: undefined,
|
||||
right: hasMeasuredWidth ? undefined : "0px",
|
||||
translate: hasMeasuredWidth ? `${rightHandle}px 0` : undefined,
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -156,13 +191,8 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
<span
|
||||
className="relative z-0 inline-flex size-full items-center justify-center rounded-2xl px-9 will-change-auto"
|
||||
style={{
|
||||
mask: `linear-gradient(90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0) ${leftGradientPercent}%,
|
||||
rgba(0, 0, 0) ${leftGradientPercent}%,
|
||||
rgba(0, 0, 0) ${rightGradientPercent}%,
|
||||
rgba(255, 255, 255, 0) ${rightGradientPercent}%,
|
||||
rgba(255, 255, 255, 0) 100%)`,
|
||||
mask: textMask,
|
||||
WebkitMask: textMask,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
import { OcDataBuddyIcon, OcMarbleIcon } from "@opencut/ui/icons";
|
||||
|
||||
export const SITE_URL = "https://opencut.app";
|
||||
|
||||
export const SITE_INFO = {
|
||||
title: "OpenCut",
|
||||
description:
|
||||
"A simple but powerful video editor that gets the job done. In your browser.",
|
||||
url: SITE_URL,
|
||||
openGraphImage: "/open-graph/default.jpg",
|
||||
twitterImage: "/open-graph/default.jpg",
|
||||
favicon: "/favicon.ico",
|
||||
};
|
||||
|
||||
export type ExternalTool = {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
icon: React.ElementType;
|
||||
};
|
||||
|
||||
export const EXTERNAL_TOOLS: ExternalTool[] = [
|
||||
{
|
||||
name: "Marble",
|
||||
description:
|
||||
"Modern headless CMS for content management and the blog for OpenCut",
|
||||
url: "https://marblecms.com?utm_source=opencut",
|
||||
icon: OcMarbleIcon,
|
||||
},
|
||||
{
|
||||
name: "Databuddy",
|
||||
description: "GDPR compliant analytics and user insights for OpenCut",
|
||||
url: "https://databuddy.cc?utm_source=opencut",
|
||||
icon: OcDataBuddyIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg";
|
||||
|
||||
export const SOCIAL_LINKS = {
|
||||
x: "https://x.com/opencutapp",
|
||||
github: "https://github.com/OpenCut-app/OpenCut",
|
||||
discord: "https://discord.com/invite/Mu3acKZvCp",
|
||||
};
|
||||
|
||||
export type Sponsor = {
|
||||
name: string;
|
||||
url: string;
|
||||
logo: string;
|
||||
description: string;
|
||||
invertOnDark?: boolean;
|
||||
};
|
||||
|
||||
export const SPONSORS: Sponsor[] = [
|
||||
{
|
||||
name: "Fal.ai",
|
||||
url: "https://fal.ai?utm_source=opencut",
|
||||
logo: "/logos/others/fal.svg",
|
||||
description: "Generative image, video, and audio models all in one place.",
|
||||
invertOnDark: true,
|
||||
},
|
||||
{
|
||||
name: "Vercel",
|
||||
url: "https://vercel.com?utm_source=opencut",
|
||||
logo: "/logos/others/vercel.svg",
|
||||
description: "Platform where we deploy and host OpenCut.",
|
||||
invertOnDark: true,
|
||||
},
|
||||
];
|
||||
import { OcDataBuddyIcon, OcMarbleIcon } from "@/components/icons";
|
||||
|
||||
export const SITE_URL = "https://opencut.app";
|
||||
|
||||
export const SITE_INFO = {
|
||||
title: "OpenCut",
|
||||
description:
|
||||
"A simple but powerful video editor that gets the job done. In your browser.",
|
||||
url: SITE_URL,
|
||||
openGraphImage: "/open-graph/default.jpg",
|
||||
twitterImage: "/open-graph/default.jpg",
|
||||
favicon: "/favicon.ico",
|
||||
};
|
||||
|
||||
export type ExternalTool = {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
icon: React.ElementType;
|
||||
};
|
||||
|
||||
export const EXTERNAL_TOOLS: ExternalTool[] = [
|
||||
{
|
||||
name: "Marble",
|
||||
description:
|
||||
"Modern headless CMS for content management and the blog for OpenCut",
|
||||
url: "https://marblecms.com?utm_source=opencut",
|
||||
icon: OcMarbleIcon,
|
||||
},
|
||||
{
|
||||
name: "Databuddy",
|
||||
description: "GDPR compliant analytics and user insights for OpenCut",
|
||||
url: "https://databuddy.cc?utm_source=opencut",
|
||||
icon: OcDataBuddyIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg";
|
||||
|
||||
export const SOCIAL_LINKS = {
|
||||
x: "https://x.com/opencutapp",
|
||||
github: "https://github.com/OpenCut-app/OpenCut",
|
||||
discord: "https://discord.com/invite/Mu3acKZvCp",
|
||||
};
|
||||
|
||||
export type Sponsor = {
|
||||
name: string;
|
||||
url: string;
|
||||
logo: string;
|
||||
description: string;
|
||||
invertOnDark?: boolean;
|
||||
};
|
||||
|
||||
export const SPONSORS: Sponsor[] = [
|
||||
{
|
||||
name: "Fal.ai",
|
||||
url: "https://fal.ai?utm_source=opencut",
|
||||
logo: "/logos/others/fal.svg",
|
||||
description: "Generative image, video, and audio models all in one place.",
|
||||
invertOnDark: true,
|
||||
},
|
||||
{
|
||||
name: "Vercel",
|
||||
url: "https://vercel.com?utm_source=opencut",
|
||||
logo: "/logos/others/vercel.svg",
|
||||
description: "Platform where we deploy and host OpenCut.",
|
||||
invertOnDark: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ElementType, TrackType } from "@/lib/timeline";
|
||||
import type { TrackType } from "@/lib/timeline";
|
||||
|
||||
export const TRACK_CONFIG: Record<
|
||||
TrackType,
|
||||
@@ -46,16 +46,6 @@ export const ELEMENT_TYPE_CONFIG: Record<
|
||||
effect: { background: "bg-[#5d93ba]" },
|
||||
} as const;
|
||||
|
||||
export const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = {
|
||||
audio: "audio",
|
||||
text: "text",
|
||||
sticker: "graphic",
|
||||
graphic: "graphic",
|
||||
effect: "effect",
|
||||
video: "video",
|
||||
image: "video",
|
||||
};
|
||||
|
||||
export const TRACK_GAP = 6;
|
||||
export const TRACK_LABELS_WIDTH_PX = 112;
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ export const TRANSCRIPTION_MODELS: TranscriptionModel[] = [
|
||||
export const DEFAULT_TRANSCRIPTION_MODEL: TranscriptionModelId =
|
||||
"whisper-small";
|
||||
|
||||
export const DEFAULT_TRANSCRIPTION_SAMPLE_RATE = 16000;
|
||||
|
||||
export const DEFAULT_CHUNK_LENGTH_SECONDS = 30;
|
||||
export const DEFAULT_STRIDE_SECONDS = 5;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||
import { SceneExporter } from "@/services/renderer/scene-exporter";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { createTimelineAudioBuffer } from "@/lib/media/audio";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { formatTimeCode } from "opencut-wasm";
|
||||
import { downloadBlob } from "@/utils/browser";
|
||||
|
||||
type SnapshotResult =
|
||||
@@ -107,10 +107,7 @@ export class RendererManager {
|
||||
return { success: false, error: "Failed to create image" };
|
||||
}
|
||||
|
||||
const timecode = formatTimeCode({
|
||||
timeInSeconds: renderTime,
|
||||
fps,
|
||||
}).replace(/:/g, "-");
|
||||
const timecode = formatTimeCode({ timeInSeconds: renderTime, fps })!.replace(/:/g, "-");
|
||||
const safeName =
|
||||
activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() ||
|
||||
"snapshot";
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getFrameTime,
|
||||
isBookmarkAtTime,
|
||||
} from "@/lib/timeline/bookmarks";
|
||||
import { ensureMainTrack } from "@/lib/timeline/track-utils";
|
||||
import { ensureMainTrack } from "@/lib/timeline/placement";
|
||||
import {
|
||||
CreateSceneCommand,
|
||||
DeleteSceneCommand,
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
AnimationValue,
|
||||
} from "@/lib/animation/types";
|
||||
import { calculateTotalDuration } from "@/lib/timeline";
|
||||
import { getLastFrameTime } from "@/lib/time";
|
||||
import { getLastFrameTime } from "opencut-wasm";
|
||||
import {
|
||||
AddTrackCommand,
|
||||
RemoveTrackCommand,
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
ToggleMaskInvertedCommand,
|
||||
UpsertEffectParamKeyframeCommand,
|
||||
RemoveEffectParamKeyframeCommand,
|
||||
ToggleSourceAudioSeparationCommand,
|
||||
} from "@/lib/commands/timeline";
|
||||
import { BatchCommand } from "@/lib/commands";
|
||||
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
||||
@@ -281,6 +282,20 @@ export class TimelineManager {
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
toggleSourceAudioSeparation({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
}): void {
|
||||
const command = new ToggleSourceAudioSeparationCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
updateElements({
|
||||
updates,
|
||||
pushHistory = true,
|
||||
|
||||
@@ -6,9 +6,13 @@ import { useActionHandler } from "@/hooks/actions/use-action-handler";
|
||||
import { useEditor } from "../use-editor";
|
||||
import { useElementSelection } from "../timeline/element/use-element-selection";
|
||||
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
|
||||
import { getElementsAtTime } from "@/lib/timeline";
|
||||
import {
|
||||
getElementsAtTime,
|
||||
hasMediaId,
|
||||
} from "@/lib/timeline";
|
||||
import { cancelInteraction } from "@/lib/cancel-interaction";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { canToggleSourceAudio } from "@/lib/timeline/audio-separation";
|
||||
import {
|
||||
activateScope,
|
||||
clearActiveScope,
|
||||
@@ -266,6 +270,48 @@ export function useEditorActions() {
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"toggle-source-audio",
|
||||
() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedElement = editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
})[0];
|
||||
if (!selectedElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaAsset = (() => {
|
||||
const { element } = selectedElement;
|
||||
if (!hasMediaId(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
|
||||
null
|
||||
);
|
||||
})();
|
||||
if (
|
||||
!canToggleSourceAudio({
|
||||
element: selectedElement.element,
|
||||
mediaAsset,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.toggleSourceAudioSeparation({
|
||||
trackId: selectedElement.track.id,
|
||||
elementId: selectedElement.element.id,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"select-all",
|
||||
() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time";
|
||||
import { snapTimeToFrame } from "opencut-wasm";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
@@ -302,10 +302,7 @@ export function useElementInteraction({
|
||||
0,
|
||||
mouseTime - pendingDragRef.current.clickOffsetTime,
|
||||
);
|
||||
const snappedTime = snapTimeToFrame({
|
||||
time: adjustedTime,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
const snappedTime = snapTimeToFrame({ time: adjustedTime, fps: activeProject.settings.fps });
|
||||
startDrag({
|
||||
...pendingDragRef.current,
|
||||
initialCurrentTime: snappedTime,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time";
|
||||
import { snapTimeToFrame } from "opencut-wasm";
|
||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
@@ -285,26 +285,14 @@ export function useTimelineElementResize({
|
||||
resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime);
|
||||
|
||||
if (calculated >= 0 && calculated <= maxAllowed) {
|
||||
const newTrimStart = snapTimeToFrame({
|
||||
time: Math.min(
|
||||
maxAllowed,
|
||||
Math.max(minTrimStartForNeighbor, calculated),
|
||||
),
|
||||
fps: projectFps,
|
||||
});
|
||||
const newTrimStart = snapTimeToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), fps: projectFps });
|
||||
const visibleSourceSpan = Math.max(
|
||||
0,
|
||||
sourceDuration - newTrimStart - resizing.initialTrimEnd,
|
||||
);
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
|
||||
fps: projectFps,
|
||||
});
|
||||
const trimDelta = resizing.initialDuration - newDuration;
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime + trimDelta,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
|
||||
const trimDelta = resizing.initialDuration - newDuration;
|
||||
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps });
|
||||
|
||||
setCurrentTrimStart(newTrimStart);
|
||||
setCurrentStartTime(newStartTime);
|
||||
@@ -326,14 +314,8 @@ export function useTimelineElementResize({
|
||||
)
|
||||
: Math.min(extensionAmount, maxExtension),
|
||||
);
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time: resizing.initialStartTime - actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: resizing.initialDuration + actualExtension,
|
||||
fps: projectFps,
|
||||
});
|
||||
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps });
|
||||
const newDuration = snapTimeToFrame({ time: resizing.initialDuration + actualExtension, fps: projectFps });
|
||||
|
||||
setCurrentTrimStart(0);
|
||||
setCurrentStartTime(newStartTime);
|
||||
@@ -359,16 +341,8 @@ export function useTimelineElementResize({
|
||||
0,
|
||||
sourceDuration - newTrimStart - resizing.initialTrimEnd,
|
||||
);
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
|
||||
fps: projectFps,
|
||||
});
|
||||
const newStartTime = snapTimeToFrame({
|
||||
time:
|
||||
resizing.initialStartTime +
|
||||
(resizing.initialDuration - newDuration),
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
|
||||
const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), fps: projectFps });
|
||||
|
||||
setCurrentTrimStart(newTrimStart);
|
||||
setCurrentStartTime(newStartTime);
|
||||
@@ -395,13 +369,7 @@ export function useTimelineElementResize({
|
||||
const extensionNeeded = Math.abs(newTrimEnd);
|
||||
const baseDuration =
|
||||
resizing.initialDuration + resizing.initialTrimEnd;
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: Math.min(
|
||||
baseDuration + extensionNeeded,
|
||||
maxAllowedDuration,
|
||||
),
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps });
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(0);
|
||||
@@ -411,10 +379,7 @@ export function useTimelineElementResize({
|
||||
const unclampedDuration = getDurationForVisibleSourceSpan(
|
||||
Math.max(0, sourceDuration - resizing.initialTrimStart),
|
||||
);
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: Math.min(unclampedDuration, maxAllowedDuration),
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), fps: projectFps });
|
||||
|
||||
setCurrentDuration(newDuration);
|
||||
setCurrentTrimEnd(0);
|
||||
@@ -438,18 +403,12 @@ export function useTimelineElementResize({
|
||||
maxTrimEnd,
|
||||
Math.max(minTrimEndForNeighbor, newTrimEnd),
|
||||
);
|
||||
const finalTrimEnd = snapTimeToFrame({
|
||||
time: clampedTrimEnd,
|
||||
fps: projectFps,
|
||||
});
|
||||
const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps });
|
||||
const visibleSourceSpan = Math.max(
|
||||
0,
|
||||
sourceDuration - resizing.initialTrimStart - finalTrimEnd,
|
||||
);
|
||||
const newDuration = snapTimeToFrame({
|
||||
time: getDurationForVisibleSourceSpan(visibleSourceSpan),
|
||||
fps: projectFps,
|
||||
});
|
||||
const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps });
|
||||
|
||||
setCurrentTrimEnd(finalTrimEnd);
|
||||
setCurrentDuration(newDuration);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useKeyframeSelection } from "./use-keyframe-selection";
|
||||
import { snapTimeToFrame, getSnappedSeekTime } from "@/lib/time";
|
||||
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
|
||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||
import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
@@ -254,11 +254,7 @@ export function useKeyframeDrag({
|
||||
if (wasDrag) return;
|
||||
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const seekTime = getSnappedSeekTime({
|
||||
rawTime: displayedStartTime + indicatorTime,
|
||||
duration,
|
||||
fps,
|
||||
});
|
||||
const seekTime = getSnappedSeekTime({ rawTime: displayedStartTime + indicatorTime, duration, fps });
|
||||
editor.playback.seek({ time: seekTime });
|
||||
|
||||
if (event.shiftKey) {
|
||||
|
||||
@@ -1,281 +1,275 @@
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time";
|
||||
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||
import {
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
type SnapPoint,
|
||||
} from "@/lib/timeline/snap-utils";
|
||||
import type { Bookmark } from "@/lib/timeline";
|
||||
|
||||
export interface BookmarkDragState {
|
||||
isDragging: boolean;
|
||||
bookmarkTime: number | null;
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
interface PendingBookmarkDrag {
|
||||
bookmarkTime: number;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
}
|
||||
|
||||
interface UseBookmarkDragProps {
|
||||
zoomLevel: number;
|
||||
scrollRef: RefObject<HTMLElement | null>;
|
||||
snappingEnabled: boolean;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}
|
||||
|
||||
export function useBookmarkDrag({
|
||||
zoomLevel,
|
||||
scrollRef,
|
||||
snappingEnabled,
|
||||
onSnapPointChange,
|
||||
}: UseBookmarkDragProps) {
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
const bookmarks = activeScene?.bookmarks ?? [];
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
|
||||
const [dragState, setDragState] = useState<BookmarkDragState>({
|
||||
isDragging: false,
|
||||
bookmarkTime: null,
|
||||
currentTime: 0,
|
||||
});
|
||||
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
||||
const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
|
||||
const lastMouseXRef = useRef(0);
|
||||
|
||||
const startDrag = useCallback(
|
||||
({
|
||||
bookmarkTime,
|
||||
initialCurrentTime,
|
||||
}: {
|
||||
bookmarkTime: number;
|
||||
initialCurrentTime: number;
|
||||
}) => {
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
bookmarkTime,
|
||||
currentTime: initialCurrentTime,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setDragState({
|
||||
isDragging: false,
|
||||
bookmarkTime: null,
|
||||
currentTime: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getSnapResult = useCallback(
|
||||
({
|
||||
rawTime,
|
||||
excludeBookmarkTime,
|
||||
}: {
|
||||
rawTime: number;
|
||||
excludeBookmarkTime: number;
|
||||
}): { snappedTime: number; snapPoint: SnapPoint | null } => {
|
||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||
if (!shouldSnap) {
|
||||
return { snappedTime: rawTime, snapPoint: null };
|
||||
}
|
||||
|
||||
const snapPoints = findSnapPoints({
|
||||
tracks,
|
||||
playheadTime,
|
||||
bookmarks,
|
||||
excludeBookmarkTime,
|
||||
});
|
||||
const result = snapToNearestPoint({
|
||||
targetTime: rawTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
});
|
||||
return {
|
||||
snappedTime: result.snappedTime,
|
||||
snapPoint: result.snapPoint,
|
||||
};
|
||||
},
|
||||
[snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging && !isPendingDrag) return;
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
lastMouseXRef.current = event.clientX;
|
||||
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
if (isPendingDrag && pendingDragRef.current) {
|
||||
const { startMouseX, startMouseY, bookmarkTime } =
|
||||
pendingDragRef.current;
|
||||
const deltaX = Math.abs(event.clientX - startMouseX);
|
||||
const deltaY = Math.abs(event.clientY - startMouseY);
|
||||
|
||||
if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
const mouseTime = getMouseTimeFromClientX({
|
||||
clientX: event.clientX,
|
||||
containerRect: scrollContainer.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
});
|
||||
const frameSnappedTime = snapTimeToFrame({
|
||||
time: Math.max(0, Math.min(mouseTime, duration)),
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
const { snappedTime: initialTime } = getSnapResult({
|
||||
rawTime: frameSnappedTime,
|
||||
excludeBookmarkTime: bookmarkTime,
|
||||
});
|
||||
|
||||
startDrag({
|
||||
bookmarkTime,
|
||||
initialCurrentTime: initialTime,
|
||||
});
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dragState.isDragging || dragState.bookmarkTime === null) return;
|
||||
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
const mouseTime = getMouseTimeFromClientX({
|
||||
clientX: event.clientX,
|
||||
containerRect: scrollContainer.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
});
|
||||
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
|
||||
const frameSnappedTime = snapTimeToFrame({
|
||||
time: clampedTime,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
const snapResult = getSnapResult({
|
||||
rawTime: frameSnappedTime,
|
||||
excludeBookmarkTime: dragState.bookmarkTime,
|
||||
});
|
||||
|
||||
setDragState((previousDragState) => ({
|
||||
...previousDragState,
|
||||
currentTime: snapResult.snappedTime,
|
||||
}));
|
||||
onSnapPointChange?.(snapResult.snapPoint);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
return () => document.removeEventListener("mousemove", handleMouseMove);
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.bookmarkTime,
|
||||
zoomLevel,
|
||||
duration,
|
||||
editor.project,
|
||||
scrollRef,
|
||||
isPendingDrag,
|
||||
startDrag,
|
||||
getSnapResult,
|
||||
onSnapPointChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (dragState.bookmarkTime === null) {
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
Math.min(dragState.currentTime, duration),
|
||||
);
|
||||
|
||||
editor.scenes.moveBookmark({
|
||||
fromTime: dragState.bookmarkTime,
|
||||
toTime: clampedTime,
|
||||
});
|
||||
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.bookmarkTime,
|
||||
dragState.currentTime,
|
||||
duration,
|
||||
endDrag,
|
||||
onSnapPointChange,
|
||||
editor.scenes,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPendingDrag) return;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
onSnapPointChange?.(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [isPendingDrag, onSnapPointChange]);
|
||||
|
||||
const handleBookmarkMouseDown = useCallback(
|
||||
({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => {
|
||||
if (event.button !== 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
pendingDragRef.current = {
|
||||
bookmarkTime: bookmark.time,
|
||||
startMouseX: event.clientX,
|
||||
startMouseY: event.clientY,
|
||||
};
|
||||
setIsPendingDrag(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
dragState,
|
||||
handleBookmarkMouseDown,
|
||||
lastMouseXRef,
|
||||
};
|
||||
}
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "opencut-wasm";
|
||||
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||
import {
|
||||
findSnapPoints,
|
||||
snapToNearestPoint,
|
||||
type SnapPoint,
|
||||
} from "@/lib/timeline/snap-utils";
|
||||
import type { Bookmark } from "@/lib/timeline";
|
||||
|
||||
export interface BookmarkDragState {
|
||||
isDragging: boolean;
|
||||
bookmarkTime: number | null;
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
interface PendingBookmarkDrag {
|
||||
bookmarkTime: number;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
}
|
||||
|
||||
interface UseBookmarkDragProps {
|
||||
zoomLevel: number;
|
||||
scrollRef: RefObject<HTMLElement | null>;
|
||||
snappingEnabled: boolean;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}
|
||||
|
||||
export function useBookmarkDrag({
|
||||
zoomLevel,
|
||||
scrollRef,
|
||||
snappingEnabled,
|
||||
onSnapPointChange,
|
||||
}: UseBookmarkDragProps) {
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
const activeScene = editor.scenes.getActiveScene();
|
||||
const bookmarks = activeScene?.bookmarks ?? [];
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
|
||||
const [dragState, setDragState] = useState<BookmarkDragState>({
|
||||
isDragging: false,
|
||||
bookmarkTime: null,
|
||||
currentTime: 0,
|
||||
});
|
||||
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
||||
const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
|
||||
const lastMouseXRef = useRef(0);
|
||||
|
||||
const startDrag = useCallback(
|
||||
({
|
||||
bookmarkTime,
|
||||
initialCurrentTime,
|
||||
}: {
|
||||
bookmarkTime: number;
|
||||
initialCurrentTime: number;
|
||||
}) => {
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
bookmarkTime,
|
||||
currentTime: initialCurrentTime,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setDragState({
|
||||
isDragging: false,
|
||||
bookmarkTime: null,
|
||||
currentTime: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getSnapResult = useCallback(
|
||||
({
|
||||
rawTime,
|
||||
excludeBookmarkTime,
|
||||
}: {
|
||||
rawTime: number;
|
||||
excludeBookmarkTime: number;
|
||||
}): { snappedTime: number; snapPoint: SnapPoint | null } => {
|
||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||
if (!shouldSnap) {
|
||||
return { snappedTime: rawTime, snapPoint: null };
|
||||
}
|
||||
|
||||
const snapPoints = findSnapPoints({
|
||||
tracks,
|
||||
playheadTime,
|
||||
bookmarks,
|
||||
excludeBookmarkTime,
|
||||
});
|
||||
const result = snapToNearestPoint({
|
||||
targetTime: rawTime,
|
||||
snapPoints,
|
||||
zoomLevel,
|
||||
});
|
||||
return {
|
||||
snappedTime: result.snappedTime,
|
||||
snapPoint: result.snapPoint,
|
||||
};
|
||||
},
|
||||
[snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging && !isPendingDrag) return;
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
lastMouseXRef.current = event.clientX;
|
||||
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
if (isPendingDrag && pendingDragRef.current) {
|
||||
const { startMouseX, startMouseY, bookmarkTime } =
|
||||
pendingDragRef.current;
|
||||
const deltaX = Math.abs(event.clientX - startMouseX);
|
||||
const deltaY = Math.abs(event.clientY - startMouseY);
|
||||
|
||||
if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
const mouseTime = getMouseTimeFromClientX({
|
||||
clientX: event.clientX,
|
||||
containerRect: scrollContainer.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
});
|
||||
const frameSnappedTime = snapTimeToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), fps: activeProject.settings.fps });
|
||||
const { snappedTime: initialTime } = getSnapResult({
|
||||
rawTime: frameSnappedTime,
|
||||
excludeBookmarkTime: bookmarkTime,
|
||||
});
|
||||
|
||||
startDrag({
|
||||
bookmarkTime,
|
||||
initialCurrentTime: initialTime,
|
||||
});
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dragState.isDragging || dragState.bookmarkTime === null) return;
|
||||
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
const mouseTime = getMouseTimeFromClientX({
|
||||
clientX: event.clientX,
|
||||
containerRect: scrollContainer.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
});
|
||||
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
|
||||
const frameSnappedTime = snapTimeToFrame({ time: clampedTime, fps: activeProject.settings.fps });
|
||||
const snapResult = getSnapResult({
|
||||
rawTime: frameSnappedTime,
|
||||
excludeBookmarkTime: dragState.bookmarkTime,
|
||||
});
|
||||
|
||||
setDragState((previousDragState) => ({
|
||||
...previousDragState,
|
||||
currentTime: snapResult.snappedTime,
|
||||
}));
|
||||
onSnapPointChange?.(snapResult.snapPoint);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
return () => document.removeEventListener("mousemove", handleMouseMove);
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.bookmarkTime,
|
||||
zoomLevel,
|
||||
duration,
|
||||
editor.project,
|
||||
scrollRef,
|
||||
isPendingDrag,
|
||||
startDrag,
|
||||
getSnapResult,
|
||||
onSnapPointChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (dragState.bookmarkTime === null) {
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
Math.min(dragState.currentTime, duration),
|
||||
);
|
||||
|
||||
editor.scenes.moveBookmark({
|
||||
fromTime: dragState.bookmarkTime,
|
||||
toTime: clampedTime,
|
||||
});
|
||||
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.bookmarkTime,
|
||||
dragState.currentTime,
|
||||
duration,
|
||||
endDrag,
|
||||
onSnapPointChange,
|
||||
editor.scenes,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPendingDrag) return;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
onSnapPointChange?.(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [isPendingDrag, onSnapPointChange]);
|
||||
|
||||
const handleBookmarkMouseDown = useCallback(
|
||||
({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => {
|
||||
if (event.button !== 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
pendingDragRef.current = {
|
||||
bookmarkTime: bookmark.time,
|
||||
startMouseX: event.clientX,
|
||||
startMouseY: event.clientY,
|
||||
};
|
||||
setIsPendingDrag(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
dragState,
|
||||
handleBookmarkMouseDown,
|
||||
lastMouseXRef,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { getSnappedSeekTime } from "@/lib/time";
|
||||
import { getSnappedSeekTime } from "opencut-wasm";
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||
import { useEditor } from "../use-editor";
|
||||
@@ -83,11 +83,7 @@ export function useTimelinePlayhead({
|
||||
);
|
||||
|
||||
const framesPerSecond = activeProject.settings.fps;
|
||||
const frameTime = getSnappedSeekTime({
|
||||
rawTime,
|
||||
duration,
|
||||
fps: framesPerSecond,
|
||||
});
|
||||
const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond });
|
||||
|
||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||
const time = (() => {
|
||||
|
||||
@@ -1,197 +1,193 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { getSnappedSeekTime } from "@/lib/time";
|
||||
import { useEditor } from "../use-editor";
|
||||
|
||||
interface UseTimelineSeekProps {
|
||||
playheadRef: RefObject<HTMLDivElement | null>;
|
||||
trackLabelsRef: RefObject<HTMLDivElement | null>;
|
||||
rulerScrollRef: RefObject<HTMLDivElement | null>;
|
||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
isSelecting: boolean;
|
||||
clearSelectedElements: () => void;
|
||||
seek: (time: number) => void;
|
||||
}
|
||||
|
||||
function resetMouseTracking({
|
||||
mouseTrackingRef,
|
||||
}: {
|
||||
mouseTrackingRef: MutableRefObject<{
|
||||
isMouseDown: boolean;
|
||||
downX: number;
|
||||
downY: number;
|
||||
downTime: number;
|
||||
}>;
|
||||
}) {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function setMouseTracking({
|
||||
mouseTrackingRef,
|
||||
event,
|
||||
}: {
|
||||
mouseTrackingRef: MutableRefObject<{
|
||||
isMouseDown: boolean;
|
||||
downX: number;
|
||||
downY: number;
|
||||
downTime: number;
|
||||
}>;
|
||||
event: React.MouseEvent;
|
||||
}) {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: true,
|
||||
downX: event.clientX,
|
||||
downY: event.clientY,
|
||||
downTime: event.timeStamp,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTimelineSeek({
|
||||
playheadRef,
|
||||
trackLabelsRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
duration,
|
||||
isSelecting,
|
||||
clearSelectedElements,
|
||||
seek,
|
||||
}: UseTimelineSeekProps) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const mouseTrackingRef = useRef({
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
});
|
||||
|
||||
const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
setMouseTracking({ mouseTrackingRef, event });
|
||||
}, []);
|
||||
|
||||
const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
setMouseTracking({ mouseTrackingRef, event });
|
||||
}, []);
|
||||
|
||||
const shouldProcessTimelineClick = useCallback(
|
||||
({ event }: { event: React.MouseEvent }) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
|
||||
const deltaX = Math.abs(event.clientX - downX);
|
||||
const deltaY = Math.abs(event.clientY - downY);
|
||||
const deltaTime = event.timeStamp - downTime;
|
||||
const isPlayhead = !!playheadRef.current?.contains(target);
|
||||
const isTrackLabels = !!trackLabelsRef.current?.contains(target);
|
||||
const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
|
||||
|
||||
if (!isMouseDown) return false;
|
||||
if (shouldBlockForDrag) return false;
|
||||
if (isSelecting) return false;
|
||||
if (isPlayhead) return false;
|
||||
if (isTrackLabels) {
|
||||
clearSelectedElements();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
|
||||
);
|
||||
|
||||
const handleTimelineSeek = useCallback(
|
||||
({
|
||||
event,
|
||||
source,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
source: "ruler" | "tracks";
|
||||
}) => {
|
||||
const scrollContainer =
|
||||
source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const rect = scrollContainer.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
|
||||
const rawTime = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
(mouseX + scrollLeft) /
|
||||
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
|
||||
const projectFps = activeProject?.settings.fps || 30;
|
||||
const time = getSnappedSeekTime({
|
||||
rawTime,
|
||||
duration,
|
||||
fps: projectFps,
|
||||
});
|
||||
seek(time);
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft: scrollContainer.scrollLeft,
|
||||
playheadTime: time,
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
duration,
|
||||
zoomLevel,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
seek,
|
||||
editor,
|
||||
activeProject?.settings.fps,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTracksClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
||||
resetMouseTracking({ mouseTrackingRef });
|
||||
|
||||
if (shouldProcess) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek({ event, source: "tracks" });
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
|
||||
const handleRulerClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
||||
resetMouseTracking({ mouseTrackingRef });
|
||||
|
||||
if (shouldProcess) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek({ event, source: "ruler" });
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
|
||||
return {
|
||||
handleTracksMouseDown,
|
||||
handleTracksClick,
|
||||
handleRulerMouseDown,
|
||||
handleRulerClick,
|
||||
};
|
||||
}
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { getSnappedSeekTime } from "opencut-wasm";
|
||||
import { useEditor } from "../use-editor";
|
||||
|
||||
interface UseTimelineSeekProps {
|
||||
playheadRef: RefObject<HTMLDivElement | null>;
|
||||
trackLabelsRef: RefObject<HTMLDivElement | null>;
|
||||
rulerScrollRef: RefObject<HTMLDivElement | null>;
|
||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
isSelecting: boolean;
|
||||
clearSelectedElements: () => void;
|
||||
seek: (time: number) => void;
|
||||
}
|
||||
|
||||
function resetMouseTracking({
|
||||
mouseTrackingRef,
|
||||
}: {
|
||||
mouseTrackingRef: MutableRefObject<{
|
||||
isMouseDown: boolean;
|
||||
downX: number;
|
||||
downY: number;
|
||||
downTime: number;
|
||||
}>;
|
||||
}) {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function setMouseTracking({
|
||||
mouseTrackingRef,
|
||||
event,
|
||||
}: {
|
||||
mouseTrackingRef: MutableRefObject<{
|
||||
isMouseDown: boolean;
|
||||
downX: number;
|
||||
downY: number;
|
||||
downTime: number;
|
||||
}>;
|
||||
event: React.MouseEvent;
|
||||
}) {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: true,
|
||||
downX: event.clientX,
|
||||
downY: event.clientY,
|
||||
downTime: event.timeStamp,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTimelineSeek({
|
||||
playheadRef,
|
||||
trackLabelsRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
duration,
|
||||
isSelecting,
|
||||
clearSelectedElements,
|
||||
seek,
|
||||
}: UseTimelineSeekProps) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const mouseTrackingRef = useRef({
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
});
|
||||
|
||||
const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
setMouseTracking({ mouseTrackingRef, event });
|
||||
}, []);
|
||||
|
||||
const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
setMouseTracking({ mouseTrackingRef, event });
|
||||
}, []);
|
||||
|
||||
const shouldProcessTimelineClick = useCallback(
|
||||
({ event }: { event: React.MouseEvent }) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
|
||||
const deltaX = Math.abs(event.clientX - downX);
|
||||
const deltaY = Math.abs(event.clientY - downY);
|
||||
const deltaTime = event.timeStamp - downTime;
|
||||
const isPlayhead = !!playheadRef.current?.contains(target);
|
||||
const isTrackLabels = !!trackLabelsRef.current?.contains(target);
|
||||
const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
|
||||
|
||||
if (!isMouseDown) return false;
|
||||
if (shouldBlockForDrag) return false;
|
||||
if (isSelecting) return false;
|
||||
if (isPlayhead) return false;
|
||||
if (isTrackLabels) {
|
||||
clearSelectedElements();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
|
||||
);
|
||||
|
||||
const handleTimelineSeek = useCallback(
|
||||
({
|
||||
event,
|
||||
source,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
source: "ruler" | "tracks";
|
||||
}) => {
|
||||
const scrollContainer =
|
||||
source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const rect = scrollContainer.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
|
||||
const rawTime = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
(mouseX + scrollLeft) /
|
||||
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
|
||||
const projectFps = activeProject?.settings.fps || 30;
|
||||
const time = getSnappedSeekTime({ rawTime, duration, fps: projectFps });
|
||||
seek(time);
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft: scrollContainer.scrollLeft,
|
||||
playheadTime: time,
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
duration,
|
||||
zoomLevel,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
seek,
|
||||
editor,
|
||||
activeProject?.settings.fps,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTracksClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
||||
resetMouseTracking({ mouseTrackingRef });
|
||||
|
||||
if (shouldProcess) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek({ event, source: "tracks" });
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
|
||||
const handleRulerClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
||||
resetMouseTracking({ mouseTrackingRef });
|
||||
|
||||
if (shouldProcess) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek({ event, source: "ruler" });
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
|
||||
return {
|
||||
handleTracksMouseDown,
|
||||
handleTracksClick,
|
||||
handleRulerMouseDown,
|
||||
handleRulerClick,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,6 +101,10 @@ export const ACTIONS = {
|
||||
description: "Toggle ripple editing",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-source-audio": {
|
||||
description: "Extract or recover source audio",
|
||||
category: "editing",
|
||||
},
|
||||
"select-all": {
|
||||
description: "Select all elements",
|
||||
category: "selection",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
|
||||
export const { signIn, signUp, useSession } = createAuthClient({
|
||||
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
||||
});
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
|
||||
export const { signIn, signUp, useSession } = createAuthClient({
|
||||
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
||||
});
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
import { betterAuth, type RateLimit } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { Redis } from "@upstash/redis";
|
||||
import { db } from "@/lib/db";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
|
||||
const redis = new Redis({
|
||||
url: webEnv.UPSTASH_REDIS_REST_URL,
|
||||
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
|
||||
});
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
usePlural: true,
|
||||
}),
|
||||
secret: webEnv.BETTER_AUTH_SECRET,
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
rateLimit: {
|
||||
storage: "secondary-storage",
|
||||
customStorage: {
|
||||
get: async (key) => {
|
||||
const value = await redis.get(key);
|
||||
return value as RateLimit | undefined;
|
||||
},
|
||||
set: async (key, value) => {
|
||||
await redis.set(key, value);
|
||||
},
|
||||
},
|
||||
},
|
||||
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
||||
appName: "OpenCut",
|
||||
trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
|
||||
});
|
||||
|
||||
export type Auth = typeof auth;
|
||||
import { betterAuth, type RateLimit } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { Redis } from "@upstash/redis";
|
||||
import { db } from "@/lib/db";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
|
||||
const redis = new Redis({
|
||||
url: webEnv.UPSTASH_REDIS_REST_URL,
|
||||
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
|
||||
});
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
usePlural: true,
|
||||
}),
|
||||
secret: webEnv.BETTER_AUTH_SECRET,
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
rateLimit: {
|
||||
storage: "secondary-storage",
|
||||
customStorage: {
|
||||
get: async (key) => {
|
||||
const value = await redis.get(key);
|
||||
return value as RateLimit | undefined;
|
||||
},
|
||||
set: async (key, value) => {
|
||||
await redis.set(key, value);
|
||||
},
|
||||
},
|
||||
},
|
||||
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
||||
appName: "OpenCut",
|
||||
trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
|
||||
});
|
||||
|
||||
export type Auth = typeof auth;
|
||||
|
||||
@@ -6,13 +6,12 @@ import type {
|
||||
ClipboardItem,
|
||||
} from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
getHighestInsertIndexForTrack,
|
||||
applyPlacement,
|
||||
resolveTrackPlacement,
|
||||
isMainTrack,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
} from "@/lib/timeline/placement";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
|
||||
export class PasteCommand extends Command {
|
||||
@@ -59,17 +58,22 @@ export class PasteCommand extends Command {
|
||||
const sourceTrackIndex = updatedTracks.findIndex(
|
||||
(track) => track.id === trackId,
|
||||
);
|
||||
const resolvedTargetIndex = resolveTargetTrackIndex({
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks: updatedTracks,
|
||||
sourceTrackIndex,
|
||||
trackType,
|
||||
elements: elementsToAdd,
|
||||
timeSpans: elementsToAdd.map((element) => ({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
})),
|
||||
strategy: { type: "aboveSource", sourceTrackIndex },
|
||||
});
|
||||
if (!placementResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolvedTargetIndex >= 0) {
|
||||
const targetTrack = updatedTracks[resolvedTargetIndex];
|
||||
let adjustedElements = elementsToAdd;
|
||||
|
||||
let elementsForPlacement = elementsToAdd;
|
||||
if (placementResult.kind === "existingTrack") {
|
||||
const targetTrack = updatedTracks[placementResult.trackIndex];
|
||||
if (isMainTrack(targetTrack)) {
|
||||
const earliestElement = elementsToAdd.reduce((earliest, element) =>
|
||||
element.startTime < earliest.startTime ? element : earliest,
|
||||
@@ -82,39 +86,28 @@ export class PasteCommand extends Command {
|
||||
const delta = adjustedEarliestStartTime - earliestElement.startTime;
|
||||
|
||||
if (delta !== 0) {
|
||||
adjustedElements = elementsToAdd.map((element) => ({
|
||||
elementsForPlacement = elementsToAdd.map((element) => ({
|
||||
...element,
|
||||
startTime: Math.max(0, element.startTime + delta),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedTracks[resolvedTargetIndex] = {
|
||||
...targetTrack,
|
||||
elements: [...targetTrack.elements, ...adjustedElements],
|
||||
} as TimelineTrack;
|
||||
for (const element of adjustedElements) {
|
||||
this.pastedElements.push({
|
||||
trackId: targetTrack.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
const applied = applyPlacement({
|
||||
tracks: updatedTracks,
|
||||
placementResult,
|
||||
elements: elementsForPlacement,
|
||||
});
|
||||
if (!applied) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const insertIndex = resolveInsertIndexForNewTrack({
|
||||
tracks: updatedTracks,
|
||||
sourceTrackIndex,
|
||||
trackType,
|
||||
});
|
||||
const newTrack = buildTrackWithElements({
|
||||
trackType,
|
||||
elements: elementsToAdd,
|
||||
});
|
||||
updatedTracks.splice(insertIndex, 0, newTrack);
|
||||
for (const element of elementsToAdd) {
|
||||
updatedTracks = applied.updatedTracks;
|
||||
|
||||
for (const element of elementsForPlacement) {
|
||||
this.pastedElements.push({
|
||||
trackId: newTrack.id,
|
||||
trackId: applied.targetTrackId,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
@@ -187,107 +180,3 @@ function buildPastedElements({
|
||||
return elementsToAdd;
|
||||
}
|
||||
|
||||
function resolveTargetTrackIndex({
|
||||
tracks,
|
||||
sourceTrackIndex,
|
||||
trackType,
|
||||
elements,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
sourceTrackIndex: number;
|
||||
trackType: ClipboardItem["trackType"];
|
||||
elements: TimelineElement[];
|
||||
}): number {
|
||||
if (sourceTrackIndex >= 0) {
|
||||
const aboveIndex = sourceTrackIndex - 1;
|
||||
if (aboveIndex < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const aboveTrack = tracks[aboveIndex];
|
||||
if (aboveTrack.type !== trackType) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const canPlaceOnAbove = canPlaceElementsOnTrack({
|
||||
track: aboveTrack,
|
||||
elements,
|
||||
});
|
||||
return canPlaceOnAbove ? aboveIndex : -1;
|
||||
}
|
||||
|
||||
const highestCompatibleIndex = tracks.findIndex(
|
||||
(track) => track.type === trackType,
|
||||
);
|
||||
if (highestCompatibleIndex < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const highestCompatibleTrack = tracks[highestCompatibleIndex];
|
||||
const canPlaceOnHighest = canPlaceElementsOnTrack({
|
||||
track: highestCompatibleTrack,
|
||||
elements,
|
||||
});
|
||||
|
||||
return canPlaceOnHighest ? highestCompatibleIndex : -1;
|
||||
}
|
||||
|
||||
function resolveInsertIndexForNewTrack({
|
||||
tracks,
|
||||
sourceTrackIndex,
|
||||
trackType,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
sourceTrackIndex: number;
|
||||
trackType: ClipboardItem["trackType"];
|
||||
}): number {
|
||||
if (sourceTrackIndex >= 0) {
|
||||
return sourceTrackIndex;
|
||||
}
|
||||
|
||||
const highestCompatibleIndex = tracks.findIndex(
|
||||
(track) => track.type === trackType,
|
||||
);
|
||||
if (highestCompatibleIndex >= 0) {
|
||||
return highestCompatibleIndex;
|
||||
}
|
||||
|
||||
return getHighestInsertIndexForTrack({ tracks, trackType });
|
||||
}
|
||||
|
||||
function canPlaceElementsOnTrack({
|
||||
track,
|
||||
elements,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
elements: TimelineElement[];
|
||||
}): boolean {
|
||||
for (const element of elements) {
|
||||
const endTime = element.startTime + element.duration;
|
||||
const hasOverlap = wouldElementOverlap({
|
||||
elements: track.elements,
|
||||
startTime: element.startTime,
|
||||
endTime,
|
||||
});
|
||||
if (hasOverlap) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildTrackWithElements({
|
||||
trackType,
|
||||
elements,
|
||||
}: {
|
||||
trackType: ClipboardItem["trackType"];
|
||||
elements: TimelineElement[];
|
||||
}): TimelineTrack {
|
||||
const newTrackId = generateUUID();
|
||||
const newTrackBase = buildEmptyTrack({ id: newTrackId, type: trackType });
|
||||
return {
|
||||
...newTrackBase,
|
||||
elements,
|
||||
} as TimelineTrack;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
|
||||
import { rippleShiftElements } from "@/lib/timeline";
|
||||
import { isMainTrack } from "@/lib/timeline/placement";
|
||||
|
||||
export class DeleteElementsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
@@ -2,10 +2,7 @@ import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
getHighestInsertIndexForTrack,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
|
||||
interface DuplicateElementsParams {
|
||||
@@ -45,22 +42,12 @@ export class DuplicateElementsCommand extends Command {
|
||||
);
|
||||
const newTrackElements: TimelineElement[] = [];
|
||||
|
||||
const newTrackId = generateUUID();
|
||||
const newTrackBase = buildEmptyTrack({
|
||||
id: newTrackId,
|
||||
type: track.type,
|
||||
});
|
||||
|
||||
for (const element of track.elements) {
|
||||
if (!elementIdsToDuplicate.has(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newId = generateUUID();
|
||||
this.duplicatedElements.push({
|
||||
trackId: newTrackId,
|
||||
elementId: newId,
|
||||
});
|
||||
newTrackElements.push(
|
||||
buildDuplicateElement({
|
||||
element,
|
||||
@@ -70,16 +57,33 @@ export class DuplicateElementsCommand extends Command {
|
||||
);
|
||||
}
|
||||
|
||||
const newTrack = {
|
||||
...newTrackBase,
|
||||
elements: newTrackElements,
|
||||
} as TimelineTrack;
|
||||
|
||||
const insertIndex = getHighestInsertIndexForTrack({
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks: updatedTracks,
|
||||
trackType: track.type,
|
||||
timeSpans: [],
|
||||
strategy: { type: "alwaysNew", position: "highest" },
|
||||
});
|
||||
updatedTracks.splice(insertIndex, 0, newTrack);
|
||||
if (!placementResult || placementResult.kind !== "newTrack") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const applied = applyPlacement({
|
||||
tracks: updatedTracks,
|
||||
placementResult,
|
||||
elements: newTrackElements,
|
||||
});
|
||||
if (!applied) {
|
||||
continue;
|
||||
}
|
||||
|
||||
updatedTracks = applied.updatedTracks;
|
||||
|
||||
for (const element of newTrackElements) {
|
||||
this.duplicatedElements.push({
|
||||
trackId: applied.targetTrackId,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
@@ -8,6 +8,7 @@ export { SplitElementsCommand } from "./split-elements";
|
||||
export { UpdateElementCommand } from "./update-element";
|
||||
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
|
||||
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
|
||||
export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation";
|
||||
export { MoveElementCommand } from "./move-elements";
|
||||
export * from "./keyframes";
|
||||
export * from "./effects";
|
||||
|
||||
@@ -5,23 +5,18 @@ import type {
|
||||
TimelineTrack,
|
||||
TimelineElement,
|
||||
TrackType,
|
||||
ElementType,
|
||||
} from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import {
|
||||
requiresMediaId,
|
||||
wouldElementOverlap,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
canElementGoOnTrack,
|
||||
getDefaultInsertIndexForTrack,
|
||||
validateElementTrackCompatibility,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { requiresMediaId } from "@/lib/timeline/element-utils";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
|
||||
import {
|
||||
applyPlacement,
|
||||
canElementGoOnTrack,
|
||||
resolveTrackPlacement,
|
||||
validateElementTrackCompatibility,
|
||||
} from "@/lib/timeline/placement";
|
||||
|
||||
type InsertElementPlacement =
|
||||
| { mode: "explicit"; trackId: string }
|
||||
@@ -67,7 +62,7 @@ export class InsertElementCommand extends Command {
|
||||
const isFirstElement = totalElementsInTimeline === 0;
|
||||
|
||||
const newElement = this.buildElement({ element: this.element });
|
||||
const updateResult = this.resolveTracksWithElement({
|
||||
const updateResult = this.applyPlacementResult({
|
||||
tracks: this.savedState,
|
||||
element: newElement,
|
||||
});
|
||||
@@ -190,7 +185,7 @@ export class InsertElementCommand extends Command {
|
||||
return true;
|
||||
}
|
||||
|
||||
private resolveTracksWithElement({
|
||||
private applyPlacementResult({
|
||||
tracks,
|
||||
element,
|
||||
}: {
|
||||
@@ -199,160 +194,71 @@ export class InsertElementCommand extends Command {
|
||||
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
|
||||
const placement = this.placement;
|
||||
|
||||
if (placement.mode === "explicit") {
|
||||
const targetTrack = tracks.find(
|
||||
(track) => track.id === placement.trackId,
|
||||
);
|
||||
|
||||
if (!targetTrack) {
|
||||
console.error("Track not found:", placement.trackId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const validation = validateElementTrackCompatibility({
|
||||
element,
|
||||
track: targetTrack,
|
||||
});
|
||||
|
||||
if (!validation.isValid) {
|
||||
console.error(validation.errorMessage);
|
||||
return null;
|
||||
}
|
||||
|
||||
const adjustedElement = this.adjustElementForMainTrack({
|
||||
tracks,
|
||||
targetTrackId: targetTrack.id,
|
||||
element,
|
||||
});
|
||||
|
||||
const updatedTracks = tracks.map((track) =>
|
||||
track.id === targetTrack.id
|
||||
? {
|
||||
...track,
|
||||
elements: [...track.elements, adjustedElement],
|
||||
}
|
||||
: track,
|
||||
) as TimelineTrack[];
|
||||
|
||||
return { updatedTracks, targetTrackId: targetTrack.id };
|
||||
}
|
||||
|
||||
const trackType =
|
||||
placement.trackType ?? this.getTrackTypeForElement({ element });
|
||||
|
||||
if (
|
||||
placement.mode === "auto" &&
|
||||
placement.trackType &&
|
||||
!canElementGoOnTrack({
|
||||
elementType: element.type,
|
||||
trackType,
|
||||
trackType: placement.trackType,
|
||||
})
|
||||
) {
|
||||
console.error(
|
||||
`${element.type} elements cannot be placed on ${trackType} tracks`,
|
||||
`${element.type} elements cannot be placed on ${placement.trackType} tracks`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const elementEndTime = element.startTime + element.duration;
|
||||
const existingTrack = tracks.find((track) => {
|
||||
if (
|
||||
!canElementGoOnTrack({
|
||||
elementType: element.type,
|
||||
trackType: track.type,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
...(placement.mode === "auto" && placement.trackType
|
||||
? { trackType: placement.trackType }
|
||||
: { elementType: element.type }),
|
||||
timeSpans: [
|
||||
{
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
},
|
||||
],
|
||||
strategy:
|
||||
placement.mode === "explicit"
|
||||
? { type: "explicit", trackId: placement.trackId }
|
||||
: { type: "firstAvailable" },
|
||||
});
|
||||
if (!placementResult) {
|
||||
if (placement.mode === "explicit") {
|
||||
const targetTrack = tracks.find((track) => track.id === placement.trackId);
|
||||
if (!targetTrack) {
|
||||
console.error("Track not found:", placement.trackId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const validation = validateElementTrackCompatibility({
|
||||
element,
|
||||
track: targetTrack,
|
||||
});
|
||||
console.error(validation.errorMessage);
|
||||
}
|
||||
|
||||
return !wouldElementOverlap({
|
||||
elements: track.elements,
|
||||
startTime: element.startTime,
|
||||
endTime: elementEndTime,
|
||||
});
|
||||
});
|
||||
|
||||
if (existingTrack) {
|
||||
const adjustedElement = this.adjustElementForMainTrack({
|
||||
tracks,
|
||||
targetTrackId: existingTrack.id,
|
||||
element,
|
||||
});
|
||||
|
||||
const updatedTracks = tracks.map((track) =>
|
||||
track.id === existingTrack.id
|
||||
? {
|
||||
...track,
|
||||
elements: [...track.elements, adjustedElement],
|
||||
}
|
||||
: track,
|
||||
) as TimelineTrack[];
|
||||
|
||||
return { updatedTracks, targetTrackId: existingTrack.id };
|
||||
return null;
|
||||
}
|
||||
|
||||
const newTrackId = generateUUID();
|
||||
const newTrack = buildEmptyTrack({
|
||||
id: newTrackId,
|
||||
type: trackType,
|
||||
});
|
||||
const newTrackWithElement = {
|
||||
...newTrack,
|
||||
elements: [...newTrack.elements, element],
|
||||
} as TimelineTrack;
|
||||
const elementToPlace =
|
||||
placementResult.kind === "existingTrack"
|
||||
? {
|
||||
...element,
|
||||
startTime:
|
||||
placementResult.adjustedStartTime ?? element.startTime,
|
||||
}
|
||||
: element;
|
||||
|
||||
const updatedTracks = [...tracks];
|
||||
const insertIndex =
|
||||
placement.insertIndex ??
|
||||
this.getAutoInsertIndex({ tracks: updatedTracks, trackType });
|
||||
updatedTracks.splice(insertIndex, 0, newTrackWithElement);
|
||||
|
||||
return { updatedTracks, targetTrackId: newTrackId };
|
||||
}
|
||||
|
||||
private getAutoInsertIndex({
|
||||
tracks,
|
||||
trackType,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
trackType: TrackType;
|
||||
}): number {
|
||||
if (trackType === "text") {
|
||||
const firstVideoTrackIndex = tracks.findIndex(
|
||||
(track) => track.type === "video",
|
||||
);
|
||||
if (firstVideoTrackIndex >= 0) {
|
||||
return firstVideoTrackIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return getDefaultInsertIndexForTrack({
|
||||
return applyPlacement({
|
||||
tracks,
|
||||
trackType,
|
||||
placementResult,
|
||||
elements: [elementToPlace],
|
||||
newTrackInsertIndexOverride:
|
||||
placement.mode === "auto" && typeof placement.insertIndex === "number"
|
||||
? placement.insertIndex
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private adjustElementForMainTrack({
|
||||
tracks,
|
||||
targetTrackId,
|
||||
element,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
targetTrackId: string;
|
||||
element: TimelineElement;
|
||||
}): TimelineElement {
|
||||
const adjustedStartTime = enforceMainTrackStart({
|
||||
tracks,
|
||||
targetTrackId,
|
||||
requestedStartTime: element.startTime,
|
||||
});
|
||||
return { ...element, startTime: adjustedStartTime };
|
||||
}
|
||||
|
||||
private getTrackTypeForElement({
|
||||
element,
|
||||
}: {
|
||||
element: { type: ElementType };
|
||||
}): TrackType {
|
||||
return ELEMENT_TRACK_MAP[element.type];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
isMainTrack,
|
||||
validateElementTrackCompatibility,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
} from "@/lib/timeline/placement";
|
||||
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
|
||||
|
||||
export class MoveElementCommand extends Command {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import {
|
||||
buildSeparatedAudioElement,
|
||||
canExtractSourceAudio,
|
||||
canRecoverSourceAudio,
|
||||
} from "@/lib/timeline/audio-separation";
|
||||
import {
|
||||
applyPlacement,
|
||||
resolveTrackPlacement,
|
||||
} from "@/lib/timeline/placement";
|
||||
import { updateElementInTracks } from "@/lib/timeline/track-element-update";
|
||||
import type { TimelineTrack, VideoElement } from "@/lib/timeline/types";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
|
||||
export class ToggleSourceAudioSeparationCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly params: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const trackIndex = this.savedState.findIndex(
|
||||
(track) => track.id === this.params.trackId,
|
||||
);
|
||||
if (trackIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTrack = this.savedState[trackIndex];
|
||||
const sourceElement = sourceTrack.elements.find(
|
||||
(element) => element.id === this.params.elementId,
|
||||
);
|
||||
if (!sourceElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canRecoverSourceAudio({ element: sourceElement })) {
|
||||
editor.timeline.updateTracks(
|
||||
updateSourceAudioEnabled({
|
||||
tracks: this.savedState,
|
||||
trackId: this.params.trackId,
|
||||
elementId: this.params.elementId,
|
||||
isSourceAudioEnabled: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaAsset = editor
|
||||
.media
|
||||
.getAssets()
|
||||
.find((asset) =>
|
||||
sourceElement.type === "video" ? asset.id === sourceElement.mediaId : false,
|
||||
);
|
||||
if (!canExtractSourceAudio({ element: sourceElement, mediaAsset })) {
|
||||
return;
|
||||
}
|
||||
if (sourceElement.duration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const separatedAudioElement = {
|
||||
...buildSeparatedAudioElement({
|
||||
sourceElement,
|
||||
}),
|
||||
id: generateUUID(),
|
||||
};
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks: this.savedState,
|
||||
trackType: "audio",
|
||||
timeSpans: [
|
||||
{
|
||||
startTime: separatedAudioElement.startTime,
|
||||
duration: separatedAudioElement.duration,
|
||||
},
|
||||
],
|
||||
strategy: { type: "aboveSource", sourceTrackIndex: trackIndex },
|
||||
});
|
||||
if (!placementResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const appliedPlacement = applyPlacement({
|
||||
tracks: this.savedState,
|
||||
placementResult,
|
||||
elements: [separatedAudioElement],
|
||||
});
|
||||
if (!appliedPlacement) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.updateTracks(
|
||||
updateSourceAudioEnabled({
|
||||
tracks: appliedPlacement.updatedTracks,
|
||||
trackId: this.params.trackId,
|
||||
elementId: this.params.elementId,
|
||||
isSourceAudioEnabled: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.savedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSourceAudioEnabled({
|
||||
tracks,
|
||||
trackId,
|
||||
elementId,
|
||||
isSourceAudioEnabled,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
isSourceAudioEnabled: boolean;
|
||||
}): TimelineTrack[] {
|
||||
return updateElementInTracks({
|
||||
tracks,
|
||||
trackId,
|
||||
elementId,
|
||||
elementPredicate: (element): element is VideoElement => element.type === "video",
|
||||
update: (element) => ({
|
||||
...element,
|
||||
isSourceAudioEnabled,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
|
||||
import { enforceMainTrackStart } from "@/lib/timeline/placement";
|
||||
|
||||
export class UpdateElementStartTimeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
|
||||
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
|
||||
import { enforceMainTrackStart } from "@/lib/timeline/placement";
|
||||
|
||||
export class UpdateElementTrimCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { EditorCore } from "@/core";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
getDefaultInsertIndexForTrack,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
} from "@/lib/timeline/placement";
|
||||
|
||||
export class AddTrackCommand extends Command {
|
||||
private trackId: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { getMainTrack } from "@/lib/timeline";
|
||||
import { getMainTrack } from "@/lib/timeline/placement";
|
||||
|
||||
export class RemoveTrackCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
|
||||
+5
-4
@@ -11,10 +11,11 @@ const webEnvSchema = z.object({
|
||||
NEXT_PUBLIC_MARBLE_API_URL: z.url(),
|
||||
|
||||
// Server
|
||||
DATABASE_URL: z
|
||||
.string()
|
||||
.startsWith("postgres://")
|
||||
.or(z.string().startsWith("postgresql://")),
|
||||
DATABASE_URL: z.string().refine(
|
||||
(url) =>
|
||||
url.startsWith("postgres://") || url.startsWith("postgresql://"),
|
||||
"DATABASE_URL must be a postgres:// or postgresql:// URL",
|
||||
),
|
||||
|
||||
BETTER_AUTH_SECRET: z.string(),
|
||||
UPSTASH_REDIS_REST_URL: z.url(),
|
||||
+755
-743
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { isMainTrack } from "@/lib/timeline";
|
||||
import { isMainTrack } from "@/lib/timeline/placement";
|
||||
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
|
||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
|
||||
import { measureTextElement } from "@/lib/text/measure-element";
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Redis } from "@upstash/redis";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
|
||||
const redis = new Redis({
|
||||
url: webEnv.UPSTASH_REDIS_REST_URL,
|
||||
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
|
||||
});
|
||||
|
||||
export const baseRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
|
||||
analytics: true,
|
||||
prefix: "rate-limit",
|
||||
});
|
||||
|
||||
export async function checkRateLimit({ request }: { request: Request }) {
|
||||
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
|
||||
const { success } = await baseRateLimit.limit(ip);
|
||||
return { success, limited: !success };
|
||||
}
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Redis } from "@upstash/redis";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
|
||||
const redis = new Redis({
|
||||
url: webEnv.UPSTASH_REDIS_REST_URL,
|
||||
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
|
||||
});
|
||||
|
||||
export const baseRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
|
||||
analytics: true,
|
||||
prefix: "rate-limit",
|
||||
});
|
||||
|
||||
export async function checkRateLimit({ request }: { request: Request }) {
|
||||
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
|
||||
const { success } = await baseRateLimit.limit(ip);
|
||||
return { success, limited: !success };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { calculateTotalDuration } from "@/lib/timeline";
|
||||
import { ensureMainTrack } from "@/lib/timeline/track-utils";
|
||||
import { ensureMainTrack } from "@/lib/timeline/placement";
|
||||
|
||||
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
|
||||
return scenes.find((scene) => scene.isMain) || null;
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
export type TTimeCode =
|
||||
| "MM:SS"
|
||||
| "HH:MM:SS"
|
||||
| "HH:MM:SS:CS"
|
||||
| "HH:MM:SS:FF";
|
||||
|
||||
export function roundToFrame({
|
||||
time,
|
||||
fps,
|
||||
}: {
|
||||
time: number;
|
||||
fps: number;
|
||||
}): number {
|
||||
return Math.round(time * fps) / fps;
|
||||
}
|
||||
|
||||
export function formatTimeCode({
|
||||
timeInSeconds,
|
||||
format = "HH:MM:SS:CS",
|
||||
fps,
|
||||
}: {
|
||||
timeInSeconds: number;
|
||||
format?: TTimeCode;
|
||||
fps?: number;
|
||||
}): string {
|
||||
const hours = Math.floor(timeInSeconds / 3600);
|
||||
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(timeInSeconds % 60);
|
||||
const centiseconds = Math.floor((timeInSeconds % 1) * 100);
|
||||
const frames = fps ? Math.floor((timeInSeconds % 1) * fps) : 0;
|
||||
|
||||
switch (format) {
|
||||
case "MM:SS":
|
||||
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
case "HH:MM:SS":
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
case "HH:MM:SS:CS":
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
|
||||
case "HH:MM:SS:FF":
|
||||
if (!fps) throw new Error("FPS is required for HH:MM:SS:FF format");
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTimeCode({
|
||||
timeCode,
|
||||
format = "HH:MM:SS:CS",
|
||||
fps,
|
||||
}: {
|
||||
timeCode: string;
|
||||
format?: TTimeCode;
|
||||
fps: number;
|
||||
}): number | null {
|
||||
if (!timeCode || typeof timeCode !== "string") return null;
|
||||
|
||||
const cleanTimeCode = timeCode.trim();
|
||||
|
||||
try {
|
||||
switch (format) {
|
||||
case "MM:SS": {
|
||||
const parts = cleanTimeCode.split(":");
|
||||
if (parts.length !== 2) return null;
|
||||
const [minutes, seconds] = parts.map((part) => parseInt(part, 10));
|
||||
if (Number.isNaN(minutes) || Number.isNaN(seconds)) return null;
|
||||
if (minutes < 0 || seconds < 0 || seconds >= 60) return null;
|
||||
return minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
case "HH:MM:SS": {
|
||||
const parts = cleanTimeCode.split(":");
|
||||
if (parts.length !== 3) return null;
|
||||
const [hours, minutes, seconds] = parts.map((part) =>
|
||||
parseInt(part, 10),
|
||||
);
|
||||
if (
|
||||
Number.isNaN(hours) ||
|
||||
Number.isNaN(minutes) ||
|
||||
Number.isNaN(seconds)
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
hours < 0 ||
|
||||
minutes < 0 ||
|
||||
seconds < 0 ||
|
||||
minutes >= 60 ||
|
||||
seconds >= 60
|
||||
)
|
||||
return null;
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
case "HH:MM:SS:CS": {
|
||||
const parts = cleanTimeCode.split(":");
|
||||
if (parts.length !== 4) return null;
|
||||
const [hours, minutes, seconds, centiseconds] = parts.map((part) =>
|
||||
parseInt(part, 10),
|
||||
);
|
||||
if (
|
||||
Number.isNaN(hours) ||
|
||||
Number.isNaN(minutes) ||
|
||||
Number.isNaN(seconds) ||
|
||||
Number.isNaN(centiseconds)
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
hours < 0 ||
|
||||
minutes < 0 ||
|
||||
seconds < 0 ||
|
||||
centiseconds < 0 ||
|
||||
minutes >= 60 ||
|
||||
seconds >= 60 ||
|
||||
centiseconds >= 100
|
||||
)
|
||||
return null;
|
||||
return hours * 3600 + minutes * 60 + seconds + centiseconds / 100;
|
||||
}
|
||||
|
||||
case "HH:MM:SS:FF": {
|
||||
const parts = cleanTimeCode.split(":");
|
||||
if (parts.length !== 4) return null;
|
||||
const [hours, minutes, seconds, frames] = parts.map((part) =>
|
||||
parseInt(part, 10),
|
||||
);
|
||||
if (
|
||||
Number.isNaN(hours) ||
|
||||
Number.isNaN(minutes) ||
|
||||
Number.isNaN(seconds) ||
|
||||
Number.isNaN(frames)
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
hours < 0 ||
|
||||
minutes < 0 ||
|
||||
seconds < 0 ||
|
||||
frames < 0 ||
|
||||
minutes >= 60 ||
|
||||
seconds >= 60 ||
|
||||
frames >= fps
|
||||
)
|
||||
return null;
|
||||
return hours * 3600 + minutes * 60 + seconds + frames / fps;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function guessTimeCodeFormat({
|
||||
timeCode,
|
||||
}: {
|
||||
timeCode: string;
|
||||
}): TTimeCode | null {
|
||||
if (!timeCode || typeof timeCode !== "string") return null;
|
||||
|
||||
const numbers = timeCode.split(":");
|
||||
|
||||
if (!numbers.every((n) => !Number.isNaN(Number(n)))) return null;
|
||||
|
||||
if (numbers.length === 2) return "MM:SS";
|
||||
if (numbers.length === 3) return "HH:MM:SS";
|
||||
// todo: how to tell frames apart from cs?
|
||||
if (numbers.length === 4) return "HH:MM:SS:FF";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function timeToFrame({
|
||||
time,
|
||||
fps,
|
||||
}: {
|
||||
time: number;
|
||||
fps: number;
|
||||
}): number {
|
||||
return Math.round(time * fps);
|
||||
}
|
||||
|
||||
export function frameToTime({
|
||||
frame,
|
||||
fps,
|
||||
}: {
|
||||
frame: number;
|
||||
fps: number;
|
||||
}): number {
|
||||
return frame / fps;
|
||||
}
|
||||
|
||||
export function snapTimeToFrame({
|
||||
time,
|
||||
fps,
|
||||
}: {
|
||||
time: number;
|
||||
fps: number;
|
||||
}): number {
|
||||
if (fps <= 0) return time;
|
||||
const frame = timeToFrame({ time, fps });
|
||||
return frameToTime({ frame, fps });
|
||||
}
|
||||
|
||||
export function getSnappedSeekTime({
|
||||
rawTime,
|
||||
duration,
|
||||
fps,
|
||||
}: {
|
||||
rawTime: number;
|
||||
duration: number;
|
||||
fps: number;
|
||||
}): number {
|
||||
const snappedTime = snapTimeToFrame({ time: rawTime, fps });
|
||||
const lastFrame = getLastFrameTime({ duration, fps });
|
||||
return Math.max(0, Math.min(lastFrame, snappedTime));
|
||||
}
|
||||
|
||||
export function getLastFrameTime({
|
||||
duration,
|
||||
fps,
|
||||
}: {
|
||||
duration: number;
|
||||
fps: number;
|
||||
}): number {
|
||||
if (duration <= 0) return 0;
|
||||
if (fps <= 0) return duration;
|
||||
const frameOffset = 1 / fps;
|
||||
return Math.max(0, duration - frameOffset);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { AudioElement, VideoElement } from "@/lib/timeline";
|
||||
import {
|
||||
buildSeparatedAudioElement,
|
||||
doesElementHaveEnabledAudio,
|
||||
isSourceAudioEnabled,
|
||||
isSourceAudioSeparated,
|
||||
} from "..";
|
||||
|
||||
describe("audio separation", () => {
|
||||
test("treats missing source audio state as enabled", () => {
|
||||
const element = buildVideoElement({});
|
||||
|
||||
expect(isSourceAudioEnabled({ element })).toBe(true);
|
||||
expect(isSourceAudioSeparated({ element })).toBe(false);
|
||||
});
|
||||
|
||||
test("builds a detached audio element from the source clip audio state", () => {
|
||||
const element = buildVideoElement({
|
||||
duration: 8,
|
||||
startTime: 3,
|
||||
trimStart: 1.5,
|
||||
trimEnd: 0.5,
|
||||
sourceDuration: 12,
|
||||
volume: -6,
|
||||
muted: true,
|
||||
retime: { rate: 1.25, maintainPitch: true },
|
||||
animations: {
|
||||
channels: {
|
||||
volume: {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{
|
||||
id: "volume-keyframe",
|
||||
time: 2,
|
||||
value: -12,
|
||||
interpolation: "linear",
|
||||
},
|
||||
],
|
||||
},
|
||||
opacity: {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{
|
||||
id: "opacity-keyframe",
|
||||
time: 1,
|
||||
value: 0.5,
|
||||
interpolation: "linear",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const separatedAudioElement = buildSeparatedAudioElement({
|
||||
sourceElement: element,
|
||||
});
|
||||
|
||||
expect(separatedAudioElement).toMatchObject({
|
||||
type: "audio",
|
||||
sourceType: "upload",
|
||||
mediaId: element.mediaId,
|
||||
name: element.name,
|
||||
duration: 8,
|
||||
startTime: 3,
|
||||
trimStart: 1.5,
|
||||
trimEnd: 0.5,
|
||||
sourceDuration: 12,
|
||||
volume: -6,
|
||||
muted: true,
|
||||
retime: { rate: 1.25, maintainPitch: true },
|
||||
});
|
||||
expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([
|
||||
"volume",
|
||||
]);
|
||||
expect(
|
||||
separatedAudioElement.animations?.channels.volume?.keyframes[0]?.id,
|
||||
).not.toBe("volume-keyframe");
|
||||
});
|
||||
|
||||
test("skips source audio collection when the source clip is separated", () => {
|
||||
const mediaAsset = {
|
||||
id: "media-1",
|
||||
type: "video",
|
||||
name: "Clip",
|
||||
size: 1,
|
||||
lastModified: 1,
|
||||
file: new File(["video"], "clip.mp4", { type: "video/mp4" }),
|
||||
url: "blob:clip",
|
||||
hasAudio: true,
|
||||
};
|
||||
const videoElement = buildVideoElement({
|
||||
isSourceAudioEnabled: false,
|
||||
});
|
||||
const audioElement = {
|
||||
id: "audio-1",
|
||||
type: "audio",
|
||||
sourceType: "upload",
|
||||
mediaId: "audio-media-1",
|
||||
name: "Detached audio",
|
||||
duration: 5,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
volume: 0,
|
||||
} as AudioElement;
|
||||
|
||||
expect(
|
||||
doesElementHaveEnabledAudio({
|
||||
element: videoElement,
|
||||
mediaAsset,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
doesElementHaveEnabledAudio({
|
||||
element: audioElement,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function buildVideoElement(
|
||||
overrides: Partial<VideoElement>,
|
||||
): VideoElement {
|
||||
return {
|
||||
id: "video-1",
|
||||
type: "video",
|
||||
mediaId: "media-1",
|
||||
name: "Clip",
|
||||
duration: 5,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
volume: 0,
|
||||
muted: false,
|
||||
isSourceAudioEnabled: true,
|
||||
transform: {
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { cloneAnimations, getChannel } from "@/lib/animation";
|
||||
import type { ElementAnimations } from "@/lib/animation/types";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import type {
|
||||
CreateUploadAudioElement,
|
||||
TimelineElement,
|
||||
AudioElement,
|
||||
VideoElement,
|
||||
} from "../types";
|
||||
|
||||
export function isSourceAudioEnabled({
|
||||
element,
|
||||
}: {
|
||||
element: VideoElement;
|
||||
}): boolean {
|
||||
return element.isSourceAudioEnabled !== false;
|
||||
}
|
||||
|
||||
export function isSourceAudioSeparated({
|
||||
element,
|
||||
}: {
|
||||
element: VideoElement;
|
||||
}): boolean {
|
||||
return !isSourceAudioEnabled({ element });
|
||||
}
|
||||
|
||||
export function canExtractSourceAudio({
|
||||
element,
|
||||
mediaAsset,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
mediaAsset: MediaAsset | null | undefined;
|
||||
}): element is VideoElement {
|
||||
return (
|
||||
element.type === "video" &&
|
||||
isSourceAudioEnabled({ element }) &&
|
||||
!!mediaAsset &&
|
||||
mediaAsset.hasAudio !== false
|
||||
);
|
||||
}
|
||||
|
||||
export function canRecoverSourceAudio({
|
||||
element,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
}): element is VideoElement {
|
||||
return element.type === "video" && isSourceAudioSeparated({ element });
|
||||
}
|
||||
|
||||
export function canToggleSourceAudio({
|
||||
element,
|
||||
mediaAsset,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
mediaAsset: MediaAsset | null | undefined;
|
||||
}): element is VideoElement {
|
||||
return (
|
||||
canRecoverSourceAudio({ element }) ||
|
||||
canExtractSourceAudio({ element, mediaAsset })
|
||||
);
|
||||
}
|
||||
|
||||
export function doesElementHaveEnabledAudio({
|
||||
element,
|
||||
mediaAsset,
|
||||
}: {
|
||||
element: AudioElement | VideoElement;
|
||||
mediaAsset?: MediaAsset | null;
|
||||
}): boolean {
|
||||
if (element.type === "audio") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!mediaAsset && mediaAsset.hasAudio !== false && isSourceAudioEnabled({ element });
|
||||
}
|
||||
|
||||
export function buildSeparatedAudioElement({
|
||||
sourceElement,
|
||||
}: {
|
||||
sourceElement: VideoElement;
|
||||
}): CreateUploadAudioElement {
|
||||
return {
|
||||
type: "audio",
|
||||
sourceType: "upload",
|
||||
mediaId: sourceElement.mediaId,
|
||||
name: sourceElement.name,
|
||||
duration: sourceElement.duration,
|
||||
startTime: sourceElement.startTime,
|
||||
trimStart: sourceElement.trimStart,
|
||||
trimEnd: sourceElement.trimEnd,
|
||||
sourceDuration: sourceElement.sourceDuration,
|
||||
volume: sourceElement.volume ?? DEFAULTS.element.volume,
|
||||
muted: sourceElement.muted ?? false,
|
||||
retime: sourceElement.retime
|
||||
? {
|
||||
rate: sourceElement.retime.rate,
|
||||
maintainPitch: sourceElement.retime.maintainPitch,
|
||||
}
|
||||
: undefined,
|
||||
animations: cloneVolumeAnimations({
|
||||
animations: sourceElement.animations,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSourceAudioActionLabel({
|
||||
element,
|
||||
}: {
|
||||
element: VideoElement;
|
||||
}): "Extract audio" | "Recover audio" {
|
||||
return isSourceAudioSeparated({ element }) ? "Recover audio" : "Extract audio";
|
||||
}
|
||||
|
||||
function cloneVolumeAnimations({
|
||||
animations,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
}): ElementAnimations | undefined {
|
||||
const volumeChannel = getChannel({ animations, propertyPath: "volume" });
|
||||
if (!volumeChannel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return cloneAnimations({
|
||||
animations: {
|
||||
channels: {
|
||||
volume: volumeChannel,
|
||||
},
|
||||
},
|
||||
shouldRegenerateKeyframeIds: true,
|
||||
});
|
||||
}
|
||||
@@ -1,149 +1,149 @@
|
||||
import type { Bookmark } from "@/lib/timeline";
|
||||
import { roundToFrame } from "@/lib/time";
|
||||
|
||||
export const BOOKMARK_TIME_EPSILON = 0.001;
|
||||
|
||||
function bookmarkTimeEqual({
|
||||
bookmarkTime,
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarkTime: number;
|
||||
frameTime: number;
|
||||
}): boolean {
|
||||
return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON;
|
||||
}
|
||||
|
||||
export function findBookmarkIndex({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
}): number {
|
||||
return bookmarks.findIndex((bookmark) =>
|
||||
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
|
||||
);
|
||||
}
|
||||
|
||||
export function isBookmarkAtTime({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
}): boolean {
|
||||
return bookmarks.some((bookmark) =>
|
||||
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
|
||||
);
|
||||
}
|
||||
|
||||
export function toggleBookmarkInArray({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
}): Bookmark[] {
|
||||
const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
|
||||
|
||||
if (bookmarkIndex !== -1) {
|
||||
return bookmarks.filter((_, index) => index !== bookmarkIndex);
|
||||
}
|
||||
|
||||
const newBookmarks = [...bookmarks, { time: frameTime }];
|
||||
return newBookmarks.slice().sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
export function removeBookmarkFromArray({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
}): Bookmark[] {
|
||||
return bookmarks.filter(
|
||||
(bookmark) =>
|
||||
!bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
|
||||
);
|
||||
}
|
||||
|
||||
export function updateBookmarkInArray({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
updates,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
updates: Partial<Omit<Bookmark, "time">>;
|
||||
}): Bookmark[] {
|
||||
const index = findBookmarkIndex({ bookmarks, frameTime });
|
||||
if (index === -1) {
|
||||
return bookmarks;
|
||||
}
|
||||
|
||||
const updated = { ...bookmarks[index], ...updates };
|
||||
const result = [...bookmarks];
|
||||
result[index] = updated;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function moveBookmarkInArray({
|
||||
bookmarks,
|
||||
fromTime,
|
||||
toTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
fromTime: number;
|
||||
toTime: number;
|
||||
}): Bookmark[] {
|
||||
const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
|
||||
if (index === -1) {
|
||||
return bookmarks;
|
||||
}
|
||||
|
||||
const updated = { ...bookmarks[index], time: toTime };
|
||||
const result = [...bookmarks];
|
||||
result[index] = updated;
|
||||
return result.slice().sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
export function getFrameTime({
|
||||
time,
|
||||
fps,
|
||||
}: {
|
||||
time: number;
|
||||
fps: number;
|
||||
}): number {
|
||||
return roundToFrame({ time, fps });
|
||||
}
|
||||
|
||||
export function getBookmarkAtTime({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
}): Bookmark | null {
|
||||
const index = findBookmarkIndex({ bookmarks, frameTime });
|
||||
return index === -1 ? null : bookmarks[index];
|
||||
}
|
||||
|
||||
export function getBookmarksActiveAtTime({
|
||||
bookmarks,
|
||||
time,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
time: number;
|
||||
}): Bookmark[] {
|
||||
return bookmarks.filter((bookmark) => {
|
||||
const start = bookmark.time;
|
||||
const end =
|
||||
bookmark.duration != null && bookmark.duration > 0
|
||||
? start + bookmark.duration
|
||||
: start;
|
||||
return (
|
||||
time >= start - BOOKMARK_TIME_EPSILON &&
|
||||
time <= end + BOOKMARK_TIME_EPSILON
|
||||
);
|
||||
});
|
||||
}
|
||||
import type { Bookmark } from "@/lib/timeline";
|
||||
import { roundToFrame } from "opencut-wasm";
|
||||
|
||||
export const BOOKMARK_TIME_EPSILON = 0.001;
|
||||
|
||||
function bookmarkTimeEqual({
|
||||
bookmarkTime,
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarkTime: number;
|
||||
frameTime: number;
|
||||
}): boolean {
|
||||
return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON;
|
||||
}
|
||||
|
||||
export function findBookmarkIndex({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
}): number {
|
||||
return bookmarks.findIndex((bookmark) =>
|
||||
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
|
||||
);
|
||||
}
|
||||
|
||||
export function isBookmarkAtTime({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
}): boolean {
|
||||
return bookmarks.some((bookmark) =>
|
||||
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
|
||||
);
|
||||
}
|
||||
|
||||
export function toggleBookmarkInArray({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
}): Bookmark[] {
|
||||
const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
|
||||
|
||||
if (bookmarkIndex !== -1) {
|
||||
return bookmarks.filter((_, index) => index !== bookmarkIndex);
|
||||
}
|
||||
|
||||
const newBookmarks = [...bookmarks, { time: frameTime }];
|
||||
return newBookmarks.slice().sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
export function removeBookmarkFromArray({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
}): Bookmark[] {
|
||||
return bookmarks.filter(
|
||||
(bookmark) =>
|
||||
!bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
|
||||
);
|
||||
}
|
||||
|
||||
export function updateBookmarkInArray({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
updates,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
updates: Partial<Omit<Bookmark, "time">>;
|
||||
}): Bookmark[] {
|
||||
const index = findBookmarkIndex({ bookmarks, frameTime });
|
||||
if (index === -1) {
|
||||
return bookmarks;
|
||||
}
|
||||
|
||||
const updated = { ...bookmarks[index], ...updates };
|
||||
const result = [...bookmarks];
|
||||
result[index] = updated;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function moveBookmarkInArray({
|
||||
bookmarks,
|
||||
fromTime,
|
||||
toTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
fromTime: number;
|
||||
toTime: number;
|
||||
}): Bookmark[] {
|
||||
const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
|
||||
if (index === -1) {
|
||||
return bookmarks;
|
||||
}
|
||||
|
||||
const updated = { ...bookmarks[index], time: toTime };
|
||||
const result = [...bookmarks];
|
||||
result[index] = updated;
|
||||
return result.slice().sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
export function getFrameTime({
|
||||
time,
|
||||
fps,
|
||||
}: {
|
||||
time: number;
|
||||
fps: number;
|
||||
}): number {
|
||||
return roundToFrame({ time, fps });
|
||||
}
|
||||
|
||||
export function getBookmarkAtTime({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
frameTime: number;
|
||||
}): Bookmark | null {
|
||||
const index = findBookmarkIndex({ bookmarks, frameTime });
|
||||
return index === -1 ? null : bookmarks[index];
|
||||
}
|
||||
|
||||
export function getBookmarksActiveAtTime({
|
||||
bookmarks,
|
||||
time,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
time: number;
|
||||
}): Bookmark[] {
|
||||
return bookmarks.filter((bookmark) => {
|
||||
const start = bookmark.time;
|
||||
const end =
|
||||
bookmark.duration != null && bookmark.duration > 0
|
||||
? start + bookmark.duration
|
||||
: start;
|
||||
return (
|
||||
time >= start - BOOKMARK_TIME_EPSILON &&
|
||||
time <= end + BOOKMARK_TIME_EPSILON
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import type {
|
||||
TimelineTrack,
|
||||
ElementType,
|
||||
TimelineElement,
|
||||
} from "@/lib/timeline";
|
||||
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
|
||||
import { wouldElementOverlap } from "./element-utils";
|
||||
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
|
||||
import {
|
||||
canElementGoOnTrack,
|
||||
isMainTrack,
|
||||
enforceMainTrackStart,
|
||||
} from "./track-utils";
|
||||
import { resolveTrackPlacement } from "@/lib/timeline/placement";
|
||||
|
||||
function findElementAtPosition({
|
||||
mouseX,
|
||||
@@ -82,57 +76,22 @@ function getTrackAtY({
|
||||
return null;
|
||||
}
|
||||
|
||||
function isCompatible({
|
||||
elementType,
|
||||
trackType,
|
||||
const EMPTY_TARGET_ELEMENT = null;
|
||||
|
||||
function fallbackNewTrackDropTarget({
|
||||
xPosition,
|
||||
}: {
|
||||
elementType: ElementType;
|
||||
trackType: TimelineTrack["type"];
|
||||
}): boolean {
|
||||
return canElementGoOnTrack({ elementType, trackType });
|
||||
}
|
||||
|
||||
function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
|
||||
return tracks.findIndex((track) => isMainTrack(track));
|
||||
}
|
||||
|
||||
function findInsertIndex({
|
||||
elementType,
|
||||
tracks,
|
||||
preferredIndex,
|
||||
insertAbove,
|
||||
}: {
|
||||
elementType: ElementType;
|
||||
tracks: TimelineTrack[];
|
||||
preferredIndex: number;
|
||||
insertAbove: boolean;
|
||||
}): { index: number; position: "above" | "below" } {
|
||||
const mainTrackIndex = getMainTrackIndex({ tracks });
|
||||
|
||||
if (elementType === "audio") {
|
||||
if (preferredIndex <= mainTrackIndex) {
|
||||
return { index: mainTrackIndex + 1, position: "below" };
|
||||
}
|
||||
return {
|
||||
index: insertAbove ? preferredIndex : preferredIndex + 1,
|
||||
position: insertAbove ? "above" : "below",
|
||||
};
|
||||
}
|
||||
|
||||
const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1;
|
||||
|
||||
if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) {
|
||||
return { index: mainTrackIndex, position: "above" };
|
||||
}
|
||||
|
||||
xPosition: number;
|
||||
}): DropTarget {
|
||||
return {
|
||||
index: overlayInsertIndex,
|
||||
position: insertAbove ? "above" : "below",
|
||||
trackIndex: 0,
|
||||
isNewTrack: true,
|
||||
insertPosition: null,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_TARGET_ELEMENT = null;
|
||||
|
||||
export function computeDropTarget({
|
||||
elementType,
|
||||
mouseX,
|
||||
@@ -155,22 +114,28 @@ export function computeDropTarget({
|
||||
? playheadTime
|
||||
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
||||
|
||||
const mainTrackIndex = getMainTrackIndex({ tracks });
|
||||
|
||||
if (tracks.length === 0) {
|
||||
if (elementType === "audio") {
|
||||
return {
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: 0,
|
||||
isNewTrack: true,
|
||||
insertPosition: "below",
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
hoverDirection: "below",
|
||||
createNewTrackOnly: true,
|
||||
},
|
||||
});
|
||||
const emptyTimelineResult =
|
||||
placementResult?.kind === "newTrack" ? placementResult : null;
|
||||
if (!emptyTimelineResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: 0,
|
||||
trackIndex: emptyTimelineResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: null,
|
||||
insertPosition: emptyTimelineResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
@@ -181,30 +146,27 @@ export function computeDropTarget({
|
||||
if (!trackAtMouse) {
|
||||
const isAboveAllTracks = mouseY < 0;
|
||||
|
||||
if (elementType === "audio") {
|
||||
return {
|
||||
trackIndex: tracks.length,
|
||||
isNewTrack: true,
|
||||
insertPosition: "below",
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
if (isAboveAllTracks) {
|
||||
return {
|
||||
trackIndex: 0,
|
||||
isNewTrack: true,
|
||||
insertPosition: "above",
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
|
||||
hoverDirection: isAboveAllTracks ? "above" : "below",
|
||||
createNewTrackOnly: true,
|
||||
},
|
||||
});
|
||||
const outOfBoundsResult =
|
||||
placementResult?.kind === "newTrack" ? placementResult : null;
|
||||
if (!outOfBoundsResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: Math.max(0, mainTrackIndex),
|
||||
trackIndex: outOfBoundsResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: "above",
|
||||
insertPosition: outOfBoundsResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
@@ -237,34 +199,27 @@ export function computeDropTarget({
|
||||
}
|
||||
|
||||
const trackHeight = TRACK_CONFIG[track.type].height;
|
||||
const isInUpperHalf = relativeY < trackHeight / 2;
|
||||
|
||||
const isTrackCompatible = isCompatible({
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
trackType: track.type,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex,
|
||||
hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
|
||||
verticalDragDirection,
|
||||
},
|
||||
});
|
||||
if (!placementResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
const endTime = xPosition + elementDuration;
|
||||
const hasOverlap = wouldElementOverlap({
|
||||
elements: track.elements,
|
||||
startTime: xPosition,
|
||||
endTime,
|
||||
excludeElementId,
|
||||
});
|
||||
|
||||
if (isTrackCompatible && !hasOverlap) {
|
||||
const targetTrack = tracks[trackIndex];
|
||||
// safe: snap to 0 only happens when element becomes the new earliest,
|
||||
// meaning the space before the current earliest is empty
|
||||
const adjustedXPosition = enforceMainTrackStart({
|
||||
tracks,
|
||||
targetTrackId: targetTrack.id,
|
||||
requestedStartTime: xPosition,
|
||||
excludeElementId,
|
||||
});
|
||||
if (placementResult.kind === "existingTrack") {
|
||||
const adjustedXPosition =
|
||||
placementResult.adjustedStartTime ?? xPosition;
|
||||
|
||||
return {
|
||||
trackIndex,
|
||||
trackIndex: placementResult.trackIndex,
|
||||
isNewTrack: false,
|
||||
insertPosition: null,
|
||||
xPosition: adjustedXPosition,
|
||||
@@ -272,22 +227,10 @@ export function computeDropTarget({
|
||||
};
|
||||
}
|
||||
|
||||
let insertAbove = isInUpperHalf;
|
||||
if (!isTrackCompatible && verticalDragDirection) {
|
||||
insertAbove = verticalDragDirection === "up";
|
||||
}
|
||||
|
||||
const { index, position } = findInsertIndex({
|
||||
elementType,
|
||||
tracks,
|
||||
preferredIndex: trackIndex,
|
||||
insertAbove,
|
||||
});
|
||||
|
||||
return {
|
||||
trackIndex: index,
|
||||
trackIndex: placementResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: position,
|
||||
insertPosition: placementResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
|
||||
@@ -86,24 +86,6 @@ export function requiresMediaId({
|
||||
);
|
||||
}
|
||||
|
||||
export function wouldElementOverlap({
|
||||
elements,
|
||||
startTime,
|
||||
endTime,
|
||||
excludeElementId,
|
||||
}: {
|
||||
elements: TimelineElement[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
excludeElementId?: string;
|
||||
}): boolean {
|
||||
return elements.some((element) => {
|
||||
if (excludeElementId && element.id === excludeElementId) return false;
|
||||
const elementEnd = element.startTime + element.duration;
|
||||
return startTime < elementEnd && endTime > element.startTime;
|
||||
});
|
||||
}
|
||||
|
||||
function buildTextBackground(
|
||||
raw: Partial<TextBackground> | undefined,
|
||||
): TextBackground {
|
||||
@@ -260,6 +242,7 @@ function buildVideoElement({
|
||||
trimEnd: 0,
|
||||
sourceDuration: duration,
|
||||
muted: false,
|
||||
isSourceAudioEnabled: true,
|
||||
hidden: false,
|
||||
transform: {
|
||||
...DEFAULTS.element.transform,
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from "./drag";
|
||||
export * from "./track-utils";
|
||||
export * from "./track-element-update";
|
||||
export * from "./element-utils";
|
||||
export * from "./audio-separation";
|
||||
export * from "./zoom-utils";
|
||||
export * from "./ruler-utils";
|
||||
export * from "./ripple-utils";
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ElementType, TimelineElement, TimelineTrack, TrackType } from "@/lib/timeline";
|
||||
import { resolveTrackPlacement } from "@/lib/timeline/placement";
|
||||
|
||||
function buildElement({
|
||||
id,
|
||||
type,
|
||||
startTime,
|
||||
duration,
|
||||
}: {
|
||||
id: string;
|
||||
type: ElementType;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
}): TimelineElement {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name: id,
|
||||
startTime,
|
||||
duration,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
} as TimelineElement;
|
||||
}
|
||||
|
||||
function buildTrack({
|
||||
id,
|
||||
type,
|
||||
elements = [],
|
||||
isMain = false,
|
||||
}: {
|
||||
id: string;
|
||||
type: TrackType;
|
||||
elements?: TimelineElement[];
|
||||
isMain?: boolean;
|
||||
}): TimelineTrack {
|
||||
if (type === "video") {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name: id,
|
||||
elements: elements as TimelineTrack["elements"],
|
||||
isMain,
|
||||
muted: false,
|
||||
hidden: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "audio") {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name: id,
|
||||
elements: elements as TimelineTrack["elements"],
|
||||
muted: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name: id,
|
||||
elements: elements as TimelineTrack["elements"],
|
||||
hidden: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTimeSpan({
|
||||
startTime,
|
||||
duration,
|
||||
excludeElementId,
|
||||
}: {
|
||||
startTime: number;
|
||||
duration: number;
|
||||
excludeElementId?: string;
|
||||
}) {
|
||||
return { startTime, duration, excludeElementId };
|
||||
}
|
||||
|
||||
describe("resolveTrackPlacement", () => {
|
||||
test("explicit returns the requested compatible track", () => {
|
||||
const tracks = [buildTrack({ id: "text-1", type: "text" })];
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "text",
|
||||
timeSpans: [buildTimeSpan({ startTime: 2, duration: 3 })],
|
||||
strategy: { type: "explicit", trackId: "text-1" },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "existingTrack",
|
||||
trackId: "text-1",
|
||||
trackIndex: 0,
|
||||
trackType: "text",
|
||||
});
|
||||
});
|
||||
|
||||
test("explicit rejects missing and incompatible tracks", () => {
|
||||
const tracks = [buildTrack({ id: "video-1", type: "video", isMain: true })];
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "text",
|
||||
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
|
||||
strategy: { type: "explicit", trackId: "missing" },
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "text",
|
||||
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
|
||||
strategy: { type: "explicit", trackId: "video-1" },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("firstAvailable picks the first compatible track without overlap", () => {
|
||||
const tracks = [
|
||||
buildTrack({
|
||||
id: "text-1",
|
||||
type: "text",
|
||||
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
|
||||
}),
|
||||
buildTrack({ id: "text-2", type: "text" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "text",
|
||||
timeSpans: [buildTimeSpan({ startTime: 2, duration: 1 })],
|
||||
strategy: { type: "firstAvailable" },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "existingTrack",
|
||||
trackId: "text-2",
|
||||
trackIndex: 1,
|
||||
trackType: "text",
|
||||
});
|
||||
});
|
||||
|
||||
test("firstAvailable creates a new track when all compatible tracks are full", () => {
|
||||
const tracks = [
|
||||
buildTrack({
|
||||
id: "graphic-1",
|
||||
type: "graphic",
|
||||
elements: [
|
||||
buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }),
|
||||
],
|
||||
}),
|
||||
buildTrack({
|
||||
id: "video-main",
|
||||
type: "video",
|
||||
isMain: true,
|
||||
}),
|
||||
buildTrack({ id: "audio-1", type: "audio" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "graphic",
|
||||
timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
|
||||
strategy: { type: "firstAvailable" },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "newTrack",
|
||||
trackType: "graphic",
|
||||
insertIndex: 1,
|
||||
insertPosition: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("preferIndex uses the preferred track when it fits", () => {
|
||||
const tracks = [buildTrack({ id: "audio-1", type: "audio" })];
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "audio",
|
||||
timeSpans: [buildTimeSpan({ startTime: 3, duration: 2 })],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: 0,
|
||||
hoverDirection: "below",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "existingTrack",
|
||||
trackId: "audio-1",
|
||||
trackIndex: 0,
|
||||
trackType: "audio",
|
||||
});
|
||||
});
|
||||
|
||||
test("preferIndex creates a new overlay track above the main track", () => {
|
||||
const tracks = [
|
||||
buildTrack({ id: "video-main", type: "video", isMain: true }),
|
||||
buildTrack({ id: "audio-1", type: "audio" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "graphic",
|
||||
timeSpans: [buildTimeSpan({ startTime: 1, duration: 2 })],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: 1,
|
||||
hoverDirection: "below",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "newTrack",
|
||||
trackType: "graphic",
|
||||
insertIndex: 0,
|
||||
insertPosition: "above",
|
||||
});
|
||||
});
|
||||
|
||||
test("preferIndex keeps audio tracks below the main track", () => {
|
||||
const tracks = [
|
||||
buildTrack({ id: "text-1", type: "text" }),
|
||||
buildTrack({ id: "video-main", type: "video", isMain: true }),
|
||||
buildTrack({ id: "audio-1", type: "audio" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "audio",
|
||||
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: 0,
|
||||
hoverDirection: "above",
|
||||
createNewTrackOnly: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "newTrack",
|
||||
trackType: "audio",
|
||||
insertIndex: 2,
|
||||
insertPosition: "below",
|
||||
});
|
||||
});
|
||||
|
||||
test("aboveSource tries the track above source, then any compatible track", () => {
|
||||
const tracks = [
|
||||
buildTrack({ id: "text-top", type: "text" }),
|
||||
buildTrack({
|
||||
id: "text-middle",
|
||||
type: "text",
|
||||
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
|
||||
}),
|
||||
buildTrack({ id: "text-source", type: "text" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "text",
|
||||
timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
|
||||
strategy: { type: "aboveSource", sourceTrackIndex: 2 },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "existingTrack",
|
||||
trackId: "text-top",
|
||||
trackIndex: 0,
|
||||
trackType: "text",
|
||||
});
|
||||
});
|
||||
|
||||
test("aboveSource creates a new track near the source when none fit", () => {
|
||||
const tracks = [
|
||||
buildTrack({
|
||||
id: "text-top",
|
||||
type: "text",
|
||||
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
|
||||
}),
|
||||
buildTrack({
|
||||
id: "text-source",
|
||||
type: "text",
|
||||
elements: [buildElement({ id: "b", type: "text", startTime: 0, duration: 5 })],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "text",
|
||||
timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
|
||||
strategy: { type: "aboveSource", sourceTrackIndex: 1 },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "newTrack",
|
||||
trackType: "text",
|
||||
insertIndex: 1,
|
||||
insertPosition: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("alwaysNew honors highest and default insertion rules", () => {
|
||||
const tracks = [
|
||||
buildTrack({ id: "video-main", type: "video", isMain: true }),
|
||||
buildTrack({ id: "audio-1", type: "audio" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "audio",
|
||||
timeSpans: [],
|
||||
strategy: { type: "alwaysNew", position: "highest" },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "newTrack",
|
||||
trackType: "audio",
|
||||
insertIndex: 1,
|
||||
insertPosition: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "audio",
|
||||
timeSpans: [],
|
||||
strategy: { type: "alwaysNew", position: "default" },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "newTrack",
|
||||
trackType: "audio",
|
||||
insertIndex: 2,
|
||||
insertPosition: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("batch time spans reject tracks when any span overlaps", () => {
|
||||
const tracks = [
|
||||
buildTrack({
|
||||
id: "audio-1",
|
||||
type: "audio",
|
||||
elements: [
|
||||
buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }),
|
||||
buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "audio",
|
||||
timeSpans: [
|
||||
buildTimeSpan({ startTime: 2.5, duration: 1 }),
|
||||
buildTimeSpan({ startTime: 5.5, duration: 1 }),
|
||||
],
|
||||
strategy: { type: "firstAvailable" },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "newTrack",
|
||||
trackType: "audio",
|
||||
insertIndex: 1,
|
||||
insertPosition: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("handles empty timelines, single tracks, and track-type derivation", () => {
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks: [],
|
||||
elementType: "video",
|
||||
timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: 0,
|
||||
hoverDirection: "below",
|
||||
createNewTrackOnly: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "newTrack",
|
||||
trackType: "video",
|
||||
insertIndex: 0,
|
||||
insertPosition: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks: [buildTrack({ id: "audio-1", type: "audio" })],
|
||||
elementType: "audio",
|
||||
timeSpans: [],
|
||||
strategy: { type: "alwaysNew", position: "default" },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "newTrack",
|
||||
trackType: "audio",
|
||||
insertIndex: 1,
|
||||
insertPosition: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("existingTrack on main video includes adjustedStartTime when start snaps", () => {
|
||||
const tracks = [
|
||||
buildTrack({
|
||||
id: "video-main",
|
||||
type: "video",
|
||||
isMain: true,
|
||||
elements: [
|
||||
buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "video",
|
||||
timeSpans: [buildTimeSpan({ startTime: 2, duration: 2 })],
|
||||
strategy: { type: "explicit", trackId: "video-main" },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "existingTrack",
|
||||
trackId: "video-main",
|
||||
trackIndex: 0,
|
||||
trackType: "video",
|
||||
adjustedStartTime: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("preferIndex uses vertical drag direction when hovered track is incompatible", () => {
|
||||
const tracks = [
|
||||
buildTrack({ id: "text-1", type: "text" }),
|
||||
buildTrack({ id: "video-main", type: "video", isMain: true }),
|
||||
buildTrack({ id: "audio-1", type: "audio" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType: "audio",
|
||||
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: 0,
|
||||
hoverDirection: "above",
|
||||
verticalDragDirection: "down",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "newTrack",
|
||||
trackType: "audio",
|
||||
insertIndex: 2,
|
||||
insertPosition: "below",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { buildEmptyTrack } from "./track-factory";
|
||||
import type { PlacementResult } from "./types";
|
||||
|
||||
export function applyPlacement({
|
||||
tracks,
|
||||
placementResult,
|
||||
elements,
|
||||
newTrackInsertIndexOverride,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
placementResult: PlacementResult;
|
||||
elements: TimelineElement[];
|
||||
newTrackInsertIndexOverride?: number;
|
||||
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
|
||||
if (placementResult.kind === "existingTrack") {
|
||||
const targetTrack = tracks[placementResult.trackIndex];
|
||||
if (!targetTrack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updatedTracks = tracks.map((track, trackIndex) =>
|
||||
trackIndex === placementResult.trackIndex
|
||||
? {
|
||||
...track,
|
||||
elements: [...track.elements, ...elements],
|
||||
}
|
||||
: track,
|
||||
) as TimelineTrack[];
|
||||
|
||||
return { updatedTracks, targetTrackId: targetTrack.id };
|
||||
}
|
||||
|
||||
const newTrackId = generateUUID();
|
||||
const newTrack = {
|
||||
...buildEmptyTrack({ id: newTrackId, type: placementResult.trackType }),
|
||||
elements,
|
||||
} as TimelineTrack;
|
||||
const insertIndex =
|
||||
newTrackInsertIndexOverride ?? placementResult.insertIndex;
|
||||
const updatedTracks = [...tracks];
|
||||
updatedTracks.splice(insertIndex, 0, newTrack);
|
||||
return { updatedTracks, targetTrackId: newTrackId };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ElementType, TrackType } from "@/lib/timeline";
|
||||
|
||||
const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = {
|
||||
audio: "audio",
|
||||
text: "text",
|
||||
sticker: "graphic",
|
||||
graphic: "graphic",
|
||||
effect: "effect",
|
||||
video: "video",
|
||||
image: "video",
|
||||
};
|
||||
|
||||
export function getTrackTypeForElementType({
|
||||
elementType,
|
||||
}: {
|
||||
elementType: ElementType;
|
||||
}): TrackType {
|
||||
return ELEMENT_TRACK_MAP[elementType];
|
||||
}
|
||||
|
||||
export function canElementGoOnTrack({
|
||||
elementType,
|
||||
trackType,
|
||||
}: {
|
||||
elementType: ElementType;
|
||||
trackType: TrackType;
|
||||
}): boolean {
|
||||
return getTrackTypeForElementType({ elementType }) === trackType;
|
||||
}
|
||||
|
||||
export function validateElementTrackCompatibility({
|
||||
element,
|
||||
track,
|
||||
}: {
|
||||
element: { type: ElementType };
|
||||
track: { type: TrackType };
|
||||
}): { isValid: boolean; errorMessage?: string } {
|
||||
const isValid = canElementGoOnTrack({
|
||||
elementType: element.type,
|
||||
trackType: track.type,
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export { applyPlacement } from "./apply";
|
||||
export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility";
|
||||
export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index";
|
||||
export {
|
||||
enforceMainTrackStart,
|
||||
ensureMainTrack,
|
||||
getEarliestMainTrackElement,
|
||||
getMainTrack,
|
||||
isMainTrack,
|
||||
} from "./main-track";
|
||||
export { resolveTrackPlacement } from "./resolve";
|
||||
export { buildEmptyTrack } from "./track-factory";
|
||||
export type {
|
||||
PlacementResult,
|
||||
PlacementStrategy,
|
||||
PlacementSubject,
|
||||
PlacementTimeSpan,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { TrackType, TimelineTrack } from "@/lib/timeline";
|
||||
import { isMainTrack } from "./main-track";
|
||||
|
||||
export function getDefaultInsertIndexForTrack({
|
||||
tracks,
|
||||
trackType,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
trackType: TrackType;
|
||||
}): number {
|
||||
if (trackType === "audio") {
|
||||
return tracks.length;
|
||||
}
|
||||
|
||||
if (trackType === "effect") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
|
||||
if (mainTrackIndex >= 0) {
|
||||
return mainTrackIndex;
|
||||
}
|
||||
|
||||
const firstAudioTrackIndex = tracks.findIndex((track) => track.type === "audio");
|
||||
if (firstAudioTrackIndex >= 0) {
|
||||
return firstAudioTrackIndex;
|
||||
}
|
||||
|
||||
return tracks.length;
|
||||
}
|
||||
|
||||
export function getHighestInsertIndexForTrack({
|
||||
tracks,
|
||||
trackType,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
trackType: TrackType;
|
||||
}): number {
|
||||
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
|
||||
if (trackType === "audio") {
|
||||
return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function resolvePreferredNewTrackPlacement({
|
||||
tracks,
|
||||
trackType,
|
||||
preferredIndex,
|
||||
direction,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
trackType: TrackType;
|
||||
preferredIndex: number;
|
||||
direction: "above" | "below";
|
||||
}): { insertIndex: number; insertPosition: "above" | "below" | null } {
|
||||
if (tracks.length === 0) {
|
||||
return {
|
||||
insertIndex: 0,
|
||||
insertPosition: trackType === "audio" ? "below" : null,
|
||||
};
|
||||
}
|
||||
|
||||
const safePreferredIndex = Math.min(
|
||||
Math.max(preferredIndex, 0),
|
||||
tracks.length - 1,
|
||||
);
|
||||
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
|
||||
|
||||
if (trackType === "audio") {
|
||||
if (safePreferredIndex <= mainTrackIndex) {
|
||||
return {
|
||||
insertIndex: mainTrackIndex + 1,
|
||||
insertPosition: "below",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
insertIndex:
|
||||
direction === "above" ? safePreferredIndex : safePreferredIndex + 1,
|
||||
insertPosition: direction,
|
||||
};
|
||||
}
|
||||
|
||||
const insertIndex =
|
||||
direction === "above" ? safePreferredIndex : safePreferredIndex + 1;
|
||||
if (mainTrackIndex >= 0 && insertIndex > mainTrackIndex) {
|
||||
return {
|
||||
insertIndex: mainTrackIndex,
|
||||
insertPosition: "above",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
insertIndex,
|
||||
insertPosition: direction,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { TimelineElement, TimelineTrack, VideoTrack } from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
|
||||
const MAIN_TRACK_NAME = "Main Track";
|
||||
|
||||
export function isMainTrack(track: TimelineTrack): track is VideoTrack {
|
||||
return track.type === "video" && track.isMain === true;
|
||||
}
|
||||
|
||||
export function getMainTrack({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
}): VideoTrack | null {
|
||||
return tracks.find((track) => isMainTrack(track)) ?? null;
|
||||
}
|
||||
|
||||
export function ensureMainTrack({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
}): TimelineTrack[] {
|
||||
if (tracks.some((track) => isMainTrack(track))) {
|
||||
return tracks;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: generateUUID(),
|
||||
name: MAIN_TRACK_NAME,
|
||||
type: "video",
|
||||
elements: [],
|
||||
muted: false,
|
||||
isMain: true,
|
||||
hidden: false,
|
||||
},
|
||||
...tracks,
|
||||
];
|
||||
}
|
||||
|
||||
export function getEarliestMainTrackElement({
|
||||
tracks,
|
||||
excludeElementId,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
excludeElementId?: string;
|
||||
}): TimelineElement | null {
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
if (!mainTrack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elements = mainTrack.elements.filter((element) => {
|
||||
return !excludeElementId || element.id !== excludeElementId;
|
||||
});
|
||||
if (elements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return elements.reduce((earliestElement, element) => {
|
||||
return element.startTime < earliestElement.startTime
|
||||
? element
|
||||
: earliestElement;
|
||||
});
|
||||
}
|
||||
|
||||
export function enforceMainTrackStart({
|
||||
tracks,
|
||||
targetTrackId,
|
||||
requestedStartTime,
|
||||
excludeElementId,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
targetTrackId: string;
|
||||
requestedStartTime: number;
|
||||
excludeElementId?: string;
|
||||
}): number {
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
if (!mainTrack || mainTrack.id !== targetTrackId) {
|
||||
return requestedStartTime;
|
||||
}
|
||||
|
||||
const earliestElement = getEarliestMainTrackElement({
|
||||
tracks,
|
||||
excludeElementId,
|
||||
});
|
||||
if (!earliestElement) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (requestedStartTime <= earliestElement.startTime) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return requestedStartTime;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||
import type { PlacementTimeSpan } from "./types";
|
||||
|
||||
function wouldElementOverlap({
|
||||
elements,
|
||||
startTime,
|
||||
endTime,
|
||||
excludeElementId,
|
||||
}: {
|
||||
elements: TimelineElement[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
excludeElementId?: string;
|
||||
}): boolean {
|
||||
return elements.some((element) => {
|
||||
if (excludeElementId && element.id === excludeElementId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const elementEnd = element.startTime + element.duration;
|
||||
return startTime < elementEnd && endTime > element.startTime;
|
||||
});
|
||||
}
|
||||
|
||||
export function canPlaceTimeSpansOnTrack({
|
||||
track,
|
||||
timeSpans,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
timeSpans: PlacementTimeSpan[];
|
||||
}): boolean {
|
||||
return timeSpans.every(({ startTime, duration, excludeElementId }) => {
|
||||
return !wouldElementOverlap({
|
||||
elements: track.elements,
|
||||
startTime,
|
||||
endTime: startTime + duration,
|
||||
excludeElementId,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { TrackType, TimelineTrack } from "@/lib/timeline";
|
||||
import {
|
||||
getDefaultInsertIndexForTrack,
|
||||
getHighestInsertIndexForTrack,
|
||||
resolvePreferredNewTrackPlacement,
|
||||
} from "./insert-index";
|
||||
import { getTrackTypeForElementType } from "./compatibility";
|
||||
import { enforceMainTrackStart } from "./main-track";
|
||||
import { canPlaceTimeSpansOnTrack } from "./overlap";
|
||||
import type {
|
||||
PlacementResult,
|
||||
PlacementStrategy,
|
||||
PlacementSubject,
|
||||
PlacementTimeSpan,
|
||||
} from "./types";
|
||||
|
||||
type ResolveTrackPlacementParams = PlacementSubject & {
|
||||
tracks: TimelineTrack[];
|
||||
timeSpans: PlacementTimeSpan[];
|
||||
strategy: PlacementStrategy;
|
||||
};
|
||||
|
||||
function buildExistingTrackResult({
|
||||
track,
|
||||
trackIndex,
|
||||
tracks,
|
||||
timeSpans,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
trackIndex: number;
|
||||
tracks: TimelineTrack[];
|
||||
timeSpans: PlacementTimeSpan[];
|
||||
}): PlacementResult {
|
||||
const firstSpan = timeSpans[0];
|
||||
const requestedStartTime = firstSpan?.startTime ?? 0;
|
||||
const adjustedStartTime = enforceMainTrackStart({
|
||||
tracks,
|
||||
targetTrackId: track.id,
|
||||
requestedStartTime,
|
||||
excludeElementId: firstSpan?.excludeElementId,
|
||||
});
|
||||
return {
|
||||
kind: "existingTrack",
|
||||
trackId: track.id,
|
||||
trackIndex,
|
||||
trackType: track.type,
|
||||
...(adjustedStartTime !== requestedStartTime
|
||||
? { adjustedStartTime }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildNewTrackResult({
|
||||
trackType,
|
||||
insertIndex,
|
||||
insertPosition,
|
||||
}: {
|
||||
trackType: TrackType;
|
||||
insertIndex: number;
|
||||
insertPosition: "above" | "below" | null;
|
||||
}): PlacementResult {
|
||||
return {
|
||||
kind: "newTrack",
|
||||
trackType,
|
||||
insertIndex,
|
||||
insertPosition,
|
||||
};
|
||||
}
|
||||
|
||||
function findFirstAvailableTrackIndex({
|
||||
tracks,
|
||||
trackType,
|
||||
timeSpans,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
trackType: TrackType;
|
||||
timeSpans: PlacementTimeSpan[];
|
||||
}): number {
|
||||
return tracks.findIndex((track) => {
|
||||
return (
|
||||
track.type === trackType &&
|
||||
canPlaceTimeSpansOnTrack({
|
||||
track,
|
||||
timeSpans,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAlwaysNewTrack({
|
||||
tracks,
|
||||
trackType,
|
||||
position,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
trackType: TrackType;
|
||||
position: "highest" | "default";
|
||||
}): PlacementResult {
|
||||
const insertIndex =
|
||||
position === "highest"
|
||||
? getHighestInsertIndexForTrack({
|
||||
tracks,
|
||||
trackType,
|
||||
})
|
||||
: getDefaultInsertIndexForTrack({
|
||||
tracks,
|
||||
trackType,
|
||||
});
|
||||
|
||||
return buildNewTrackResult({
|
||||
trackType,
|
||||
insertIndex,
|
||||
insertPosition: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveTrackPlacement({
|
||||
tracks,
|
||||
...placement
|
||||
}: ResolveTrackPlacementParams): PlacementResult | null {
|
||||
const trackType =
|
||||
"trackType" in placement
|
||||
? placement.trackType
|
||||
: getTrackTypeForElementType({
|
||||
elementType: placement.elementType,
|
||||
});
|
||||
const { timeSpans, strategy } = placement;
|
||||
|
||||
if (strategy.type === "explicit") {
|
||||
const trackIndex = tracks.findIndex((track) => track.id === strategy.trackId);
|
||||
if (trackIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const track = tracks[trackIndex];
|
||||
if (track.type !== trackType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildExistingTrackResult({ track, trackIndex, tracks, timeSpans });
|
||||
}
|
||||
|
||||
if (strategy.type === "firstAvailable") {
|
||||
const existingTrackIndex = findFirstAvailableTrackIndex({
|
||||
tracks,
|
||||
trackType,
|
||||
timeSpans,
|
||||
});
|
||||
if (existingTrackIndex >= 0) {
|
||||
return buildExistingTrackResult({
|
||||
track: tracks[existingTrackIndex],
|
||||
trackIndex: existingTrackIndex,
|
||||
tracks,
|
||||
timeSpans,
|
||||
});
|
||||
}
|
||||
|
||||
return resolveAlwaysNewTrack({
|
||||
tracks,
|
||||
trackType,
|
||||
position: "default",
|
||||
});
|
||||
}
|
||||
|
||||
if (strategy.type === "preferIndex") {
|
||||
const preferredTrack = tracks[strategy.trackIndex];
|
||||
const isPreferredTrackCompatible =
|
||||
!!preferredTrack && preferredTrack.type === trackType;
|
||||
const canUseExistingTrack =
|
||||
!strategy.createNewTrackOnly &&
|
||||
isPreferredTrackCompatible &&
|
||||
canPlaceTimeSpansOnTrack({
|
||||
track: preferredTrack,
|
||||
timeSpans,
|
||||
});
|
||||
if (canUseExistingTrack) {
|
||||
return buildExistingTrackResult({
|
||||
track: preferredTrack,
|
||||
trackIndex: strategy.trackIndex,
|
||||
tracks,
|
||||
timeSpans,
|
||||
});
|
||||
}
|
||||
|
||||
const insertDirection =
|
||||
!isPreferredTrackCompatible && strategy.verticalDragDirection
|
||||
? strategy.verticalDragDirection
|
||||
: strategy.hoverDirection;
|
||||
const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({
|
||||
tracks,
|
||||
trackType,
|
||||
preferredIndex: strategy.trackIndex,
|
||||
direction: insertDirection,
|
||||
});
|
||||
return buildNewTrackResult({
|
||||
trackType,
|
||||
insertIndex,
|
||||
insertPosition,
|
||||
});
|
||||
}
|
||||
|
||||
if (strategy.type === "aboveSource") {
|
||||
const aboveTrackIndex = strategy.sourceTrackIndex - 1;
|
||||
const aboveTrack = tracks[aboveTrackIndex];
|
||||
if (
|
||||
aboveTrack &&
|
||||
aboveTrack.type === trackType &&
|
||||
canPlaceTimeSpansOnTrack({
|
||||
track: aboveTrack,
|
||||
timeSpans,
|
||||
})
|
||||
) {
|
||||
return buildExistingTrackResult({
|
||||
track: aboveTrack,
|
||||
trackIndex: aboveTrackIndex,
|
||||
tracks,
|
||||
timeSpans,
|
||||
});
|
||||
}
|
||||
|
||||
const firstAvailableTrackIndex = findFirstAvailableTrackIndex({
|
||||
tracks,
|
||||
trackType,
|
||||
timeSpans,
|
||||
});
|
||||
if (firstAvailableTrackIndex >= 0) {
|
||||
return buildExistingTrackResult({
|
||||
track: tracks[firstAvailableTrackIndex],
|
||||
trackIndex: firstAvailableTrackIndex,
|
||||
tracks,
|
||||
timeSpans,
|
||||
});
|
||||
}
|
||||
|
||||
const insertIndex =
|
||||
strategy.sourceTrackIndex >= 0
|
||||
? strategy.sourceTrackIndex
|
||||
: getHighestInsertIndexForTrack({
|
||||
tracks,
|
||||
trackType,
|
||||
});
|
||||
|
||||
return buildNewTrackResult({
|
||||
trackType,
|
||||
insertIndex,
|
||||
insertPosition: null,
|
||||
});
|
||||
}
|
||||
|
||||
return resolveAlwaysNewTrack({
|
||||
tracks,
|
||||
trackType,
|
||||
position: strategy.position,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { TRACK_CONFIG } from "@/constants/timeline-constants";
|
||||
import type { TrackType, TimelineTrack } from "@/lib/timeline";
|
||||
|
||||
export function buildEmptyTrack({
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
}: {
|
||||
id: string;
|
||||
type: TrackType;
|
||||
name?: string;
|
||||
}): TimelineTrack {
|
||||
const trackName = name ?? TRACK_CONFIG[type].defaultName;
|
||||
|
||||
switch (type) {
|
||||
case "video":
|
||||
return {
|
||||
id,
|
||||
name: trackName,
|
||||
type: "video",
|
||||
elements: [],
|
||||
hidden: false,
|
||||
muted: false,
|
||||
isMain: false,
|
||||
};
|
||||
case "text":
|
||||
return {
|
||||
id,
|
||||
name: trackName,
|
||||
type: "text",
|
||||
elements: [],
|
||||
hidden: false,
|
||||
};
|
||||
case "graphic":
|
||||
return {
|
||||
id,
|
||||
name: trackName,
|
||||
type: "graphic",
|
||||
elements: [],
|
||||
hidden: false,
|
||||
};
|
||||
case "audio":
|
||||
return {
|
||||
id,
|
||||
name: trackName,
|
||||
type: "audio",
|
||||
elements: [],
|
||||
muted: false,
|
||||
};
|
||||
case "effect":
|
||||
return {
|
||||
id,
|
||||
name: trackName,
|
||||
type: "effect",
|
||||
elements: [],
|
||||
hidden: false,
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unsupported track type: ${type}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ElementType, TrackType } from "@/lib/timeline";
|
||||
|
||||
export interface PlacementTimeSpan {
|
||||
startTime: number;
|
||||
duration: number;
|
||||
excludeElementId?: string;
|
||||
}
|
||||
|
||||
export type PlacementSubject =
|
||||
| { elementType: ElementType }
|
||||
| { trackType: TrackType };
|
||||
|
||||
export type PlacementStrategy =
|
||||
| { type: "explicit"; trackId: string }
|
||||
| { type: "firstAvailable" }
|
||||
| {
|
||||
type: "preferIndex";
|
||||
trackIndex: number;
|
||||
hoverDirection: "above" | "below";
|
||||
verticalDragDirection?: "up" | "down" | null;
|
||||
createNewTrackOnly?: boolean;
|
||||
}
|
||||
| { type: "aboveSource"; sourceTrackIndex: number }
|
||||
| { type: "alwaysNew"; position: "highest" | "default" };
|
||||
|
||||
export type PlacementResult =
|
||||
| {
|
||||
kind: "existingTrack";
|
||||
trackId: string;
|
||||
trackIndex: number;
|
||||
trackType: TrackType;
|
||||
adjustedStartTime?: number;
|
||||
}
|
||||
| {
|
||||
kind: "newTrack";
|
||||
insertIndex: number;
|
||||
insertPosition: "above" | "below" | null;
|
||||
trackType: TrackType;
|
||||
};
|
||||
@@ -1,21 +1,17 @@
|
||||
import type {
|
||||
TrackType,
|
||||
TimelineTrack,
|
||||
ElementType,
|
||||
VideoTrack,
|
||||
AudioTrack,
|
||||
GraphicTrack,
|
||||
TextTrack,
|
||||
EffectTrack,
|
||||
TimelineElement,
|
||||
} from "@/lib/timeline";
|
||||
import {
|
||||
ELEMENT_TRACK_MAP,
|
||||
TRACK_CONFIG,
|
||||
ELEMENT_TYPE_CONFIG,
|
||||
TRACK_GAP,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
|
||||
export function canTracktHaveAudio(
|
||||
track: TimelineTrack,
|
||||
@@ -64,234 +60,3 @@ export function getTotalTracksHeight({
|
||||
const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
|
||||
return tracksHeight + gapsHeight;
|
||||
}
|
||||
|
||||
export function buildEmptyTrack({
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
}: {
|
||||
id: string;
|
||||
type: TrackType;
|
||||
name?: string;
|
||||
}): TimelineTrack {
|
||||
const trackName = name ?? TRACK_CONFIG[type].defaultName;
|
||||
|
||||
switch (type) {
|
||||
case "video":
|
||||
return {
|
||||
id,
|
||||
name: trackName,
|
||||
type: "video",
|
||||
elements: [],
|
||||
hidden: false,
|
||||
muted: false,
|
||||
isMain: false,
|
||||
};
|
||||
case "text":
|
||||
return {
|
||||
id,
|
||||
name: trackName,
|
||||
type: "text",
|
||||
elements: [],
|
||||
hidden: false,
|
||||
};
|
||||
case "graphic":
|
||||
return {
|
||||
id,
|
||||
name: trackName,
|
||||
type: "graphic",
|
||||
elements: [],
|
||||
hidden: false,
|
||||
};
|
||||
case "audio":
|
||||
return {
|
||||
id,
|
||||
name: trackName,
|
||||
type: "audio",
|
||||
elements: [],
|
||||
muted: false,
|
||||
};
|
||||
case "effect":
|
||||
return {
|
||||
id,
|
||||
name: trackName,
|
||||
type: "effect",
|
||||
elements: [],
|
||||
hidden: false,
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unsupported track type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultInsertIndexForTrack({
|
||||
tracks,
|
||||
trackType,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
trackType: TrackType;
|
||||
}): number {
|
||||
if (trackType === "audio") {
|
||||
return tracks.length;
|
||||
}
|
||||
|
||||
if (trackType === "effect") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
|
||||
if (mainTrackIndex >= 0) {
|
||||
return mainTrackIndex;
|
||||
}
|
||||
|
||||
const firstAudioTrackIndex = tracks.findIndex(
|
||||
(track) => track.type === "audio",
|
||||
);
|
||||
if (firstAudioTrackIndex >= 0) {
|
||||
return firstAudioTrackIndex;
|
||||
}
|
||||
|
||||
return tracks.length;
|
||||
}
|
||||
|
||||
export function getHighestInsertIndexForTrack({
|
||||
tracks,
|
||||
trackType,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
trackType: TrackType;
|
||||
}): number {
|
||||
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
|
||||
|
||||
if (trackType === "audio") {
|
||||
return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isMainTrack(track: TimelineTrack): track is VideoTrack {
|
||||
return track.type === "video" && track.isMain === true;
|
||||
}
|
||||
|
||||
export function getMainTrack({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
}): TimelineTrack | null {
|
||||
return tracks.find((track) => isMainTrack(track)) ?? null;
|
||||
}
|
||||
|
||||
export function ensureMainTrack({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
}): TimelineTrack[] {
|
||||
const hasMainTrack = tracks.some((track) => isMainTrack(track));
|
||||
|
||||
if (!hasMainTrack) {
|
||||
const mainTrack: TimelineTrack = {
|
||||
id: generateUUID(),
|
||||
name: "Main Track",
|
||||
type: "video",
|
||||
elements: [],
|
||||
muted: false,
|
||||
isMain: true,
|
||||
hidden: false,
|
||||
};
|
||||
return [mainTrack, ...tracks];
|
||||
}
|
||||
|
||||
return tracks;
|
||||
}
|
||||
|
||||
export function canElementGoOnTrack({
|
||||
elementType,
|
||||
trackType,
|
||||
}: {
|
||||
elementType: ElementType;
|
||||
trackType: TrackType;
|
||||
}): boolean {
|
||||
return ELEMENT_TRACK_MAP[elementType] === trackType;
|
||||
}
|
||||
|
||||
export function validateElementTrackCompatibility({
|
||||
element,
|
||||
track,
|
||||
}: {
|
||||
element: { type: ElementType };
|
||||
track: { type: TrackType };
|
||||
}): { isValid: boolean; errorMessage?: string } {
|
||||
const isValid = canElementGoOnTrack({
|
||||
elementType: element.type,
|
||||
trackType: track.type,
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function getEarliestMainTrackElement({
|
||||
tracks,
|
||||
excludeElementId,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
excludeElementId?: string;
|
||||
}): TimelineElement | null {
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
if (!mainTrack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elements = mainTrack.elements.filter(
|
||||
(element) => !excludeElementId || element.id !== excludeElementId,
|
||||
);
|
||||
|
||||
if (elements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return elements.reduce((earliest, element) =>
|
||||
element.startTime < earliest.startTime ? element : earliest,
|
||||
);
|
||||
}
|
||||
|
||||
export function enforceMainTrackStart({
|
||||
tracks,
|
||||
targetTrackId,
|
||||
requestedStartTime,
|
||||
excludeElementId,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
targetTrackId: string;
|
||||
requestedStartTime: number;
|
||||
excludeElementId?: string;
|
||||
}): number {
|
||||
const mainTrack = getMainTrack({ tracks });
|
||||
if (!mainTrack || mainTrack.id !== targetTrackId) {
|
||||
return requestedStartTime;
|
||||
}
|
||||
|
||||
const earliestElement = getEarliestMainTrackElement({
|
||||
tracks,
|
||||
excludeElementId,
|
||||
});
|
||||
|
||||
if (!earliestElement) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// main track must always start at time 0; if this element would
|
||||
// become the earliest, pin it to the start
|
||||
if (requestedStartTime <= earliestElement.startTime) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return requestedStartTime;
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ export interface VideoElement extends BaseTimelineElement {
|
||||
mediaId: string;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
isSourceAudioEnabled?: boolean;
|
||||
hidden?: boolean;
|
||||
retime?: RetimeConfig;
|
||||
transform: Transform;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function middleware() {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
"/((?!api|_next/static|_next/image|favicon.ico).*)",
|
||||
],
|
||||
};
|
||||
@@ -12,7 +12,7 @@ import { EffectLayerNode } from "./nodes/effect-layer-node";
|
||||
import type { BaseNode } from "./nodes/base-node";
|
||||
import type { TBackground, TCanvasSize } from "@/lib/project/types";
|
||||
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
|
||||
import { isMainTrack } from "@/lib/timeline";
|
||||
import { isMainTrack } from "@/lib/timeline/placement";
|
||||
|
||||
const PREVIEW_MAX_IMAGE_SIZE = 2048;
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { transformProjectV19ToV20 } from "../transformers/v19-to-v20";
|
||||
|
||||
describe("V19 to V20 Migration", () => {
|
||||
test("backfills source audio enabled state on video elements", () => {
|
||||
const result = transformProjectV19ToV20({
|
||||
project: {
|
||||
id: "project-v19-source-audio",
|
||||
version: 19,
|
||||
metadata: {
|
||||
id: "project-v19-source-audio",
|
||||
name: "Project",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
settings: {
|
||||
fps: 30,
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
background: { type: "color", color: "#000000" },
|
||||
},
|
||||
currentSceneId: "scene-main",
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-main",
|
||||
name: "Main",
|
||||
isMain: true,
|
||||
bookmarks: [],
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
tracks: [
|
||||
{
|
||||
id: "track-video",
|
||||
type: "video",
|
||||
name: "Video",
|
||||
isMain: true,
|
||||
muted: false,
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "video-1",
|
||||
type: "video",
|
||||
name: "Clip",
|
||||
mediaId: "media-1",
|
||||
duration: 5,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
position: { x: 0, y: 0 },
|
||||
scale: { x: 1, y: 1 },
|
||||
rotation: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "track-audio",
|
||||
type: "audio",
|
||||
name: "Audio",
|
||||
muted: false,
|
||||
elements: [
|
||||
{
|
||||
id: "audio-1",
|
||||
type: "audio",
|
||||
sourceType: "upload",
|
||||
mediaId: "media-audio-1",
|
||||
name: "Audio",
|
||||
duration: 5,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
volume: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(20);
|
||||
expect(
|
||||
((result.project.scenes as Array<Record<string, unknown>>)[0]
|
||||
.tracks as Array<Record<string, unknown>>)[0].elements,
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "video-1",
|
||||
isSourceAudioEnabled: true,
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
(((result.project.scenes as Array<Record<string, unknown>>)[0]
|
||||
.tracks as Array<Record<string, unknown>>)[1]
|
||||
.elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("preserves existing explicit source audio state", () => {
|
||||
const result = transformProjectV19ToV20({
|
||||
project: {
|
||||
id: "project-v19-existing-state",
|
||||
version: 19,
|
||||
scenes: [
|
||||
{
|
||||
tracks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
type: "video",
|
||||
isSourceAudioEnabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
(((result.project.scenes as Array<Record<string, unknown>>)[0]
|
||||
.tracks as Array<Record<string, unknown>>)[0]
|
||||
.elements as Array<Record<string, unknown>>)[0].isSourceAudioEnabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("skips projects already on v20", () => {
|
||||
const result = transformProjectV19ToV20({
|
||||
project: {
|
||||
id: "project-v20",
|
||||
version: 20,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("already v20");
|
||||
});
|
||||
});
|
||||
@@ -18,10 +18,11 @@ import { V15toV16Migration } from "./v15-to-v16";
|
||||
import { V16toV17Migration } from "./v16-to-v17";
|
||||
import { V17toV18Migration } from "./v17-to-v18";
|
||||
import { V18toV19Migration } from "./v18-to-v19";
|
||||
import { V19toV20Migration } from "./v19-to-v20";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 19;
|
||||
export const CURRENT_PROJECT_VERSION = 20;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
@@ -43,4 +44,5 @@ export const migrations = [
|
||||
new V16toV17Migration(),
|
||||
new V17toV18Migration(),
|
||||
new V18toV19Migration(),
|
||||
new V19toV20Migration(),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
import { getProjectId, isRecord } from "./utils";
|
||||
|
||||
export function transformProjectV19ToV20({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): MigrationResult<ProjectRecord> {
|
||||
if (!getProjectId({ project })) {
|
||||
return { project, skipped: true, reason: "no project id" };
|
||||
}
|
||||
|
||||
if (typeof project.version === "number" && project.version >= 20) {
|
||||
return { project, skipped: true, reason: "already v20" };
|
||||
}
|
||||
|
||||
return {
|
||||
project: {
|
||||
...migrateVideoSourceAudioState({ project }),
|
||||
version: 20,
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateVideoSourceAudioState({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
if (!Array.isArray(project.scenes)) {
|
||||
return project;
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
scenes: project.scenes.map((scene) => migrateScene({ scene })),
|
||||
};
|
||||
}
|
||||
|
||||
function migrateScene({
|
||||
scene,
|
||||
}: {
|
||||
scene: unknown;
|
||||
}): unknown {
|
||||
if (!isRecord(scene) || !Array.isArray(scene.tracks)) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
return {
|
||||
...scene,
|
||||
tracks: scene.tracks.map((track) => migrateTrack({ track })),
|
||||
};
|
||||
}
|
||||
|
||||
function migrateTrack({
|
||||
track,
|
||||
}: {
|
||||
track: unknown;
|
||||
}): unknown {
|
||||
if (!isRecord(track) || !Array.isArray(track.elements)) {
|
||||
return track;
|
||||
}
|
||||
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.map((element) => migrateElement({ element })),
|
||||
};
|
||||
}
|
||||
|
||||
function migrateElement({
|
||||
element,
|
||||
}: {
|
||||
element: unknown;
|
||||
}): unknown {
|
||||
if (!isRecord(element) || element.type !== "video") {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
isSourceAudioEnabled:
|
||||
typeof element.isSourceAudioEnabled === "boolean"
|
||||
? element.isSourceAudioEnabled
|
||||
: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StorageMigration } from "./base";
|
||||
import type { ProjectRecord } from "./transformers/types";
|
||||
import { transformProjectV19ToV20 } from "./transformers/v19-to-v20";
|
||||
|
||||
export class V19toV20Migration extends StorageMigration {
|
||||
from = 19;
|
||||
to = 20;
|
||||
|
||||
async transform(project: ProjectRecord): Promise<{
|
||||
project: ProjectRecord;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
return transformProjectV19ToV20({ project });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user