mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8918a87c33 | ||
|
|
f635403108 | ||
|
|
fc0ba2d37f | ||
|
|
1660f1c76c | ||
|
|
b89f016069 | ||
|
|
271cc27cbf | ||
|
|
b62833384d | ||
|
|
08f5b92c3d | ||
|
|
4aca4e7756 | ||
|
|
b7e6db90b0 | ||
|
|
fe623357ef | ||
|
|
5b3c048e20 | ||
|
|
5638974f4b | ||
|
|
a2580216f8 | ||
|
|
dd4e6780d8 |
@@ -41,26 +41,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust wasm target
|
||||
run: rustup target add wasm32-unknown-unknown
|
||||
|
||||
- name: Install wasm-pack
|
||||
uses: jetli/wasm-pack-action@v0.4.0
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Cache Rust build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }}
|
||||
|
||||
- name: Build WASM
|
||||
run: wasm-pack build rust/wasm --target bundler --out-dir pkg
|
||||
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
|
||||
with:
|
||||
|
||||
@@ -22,5 +22,3 @@ bun.lockb
|
||||
.open-next
|
||||
|
||||
target/
|
||||
|
||||
.cargo-tools/
|
||||
@@ -1,17 +1,121 @@
|
||||
# Agents.md
|
||||
# AGENTS.md
|
||||
|
||||
## Apps
|
||||
## Overview
|
||||
|
||||
- Web
|
||||
- Desktop
|
||||
Privacy-first video editor, with a focus on simplicity and ease of use.
|
||||
|
||||
## Rust
|
||||
## Lib vs Utils
|
||||
|
||||
Shared code between apps live in `rust/`, not in `packages/`
|
||||
- `lib/` - domain logic (specific to this app)
|
||||
- `utils/` - small helper utils (generic, could be copy-pasted into any other app)
|
||||
|
||||
## Web
|
||||
## Core Editor System
|
||||
|
||||
### React
|
||||
The editor uses a **singleton EditorCore** that manages all editor state through specialized managers.
|
||||
|
||||
- Read components before using them. They may already apply classes, which affects what you need to pass and how to override them.
|
||||
### Architecture
|
||||
|
||||
```
|
||||
EditorCore (singleton)
|
||||
├── playback: PlaybackManager
|
||||
├── timeline: TimelineManager
|
||||
├── scene: SceneManager
|
||||
├── project: ProjectManager
|
||||
├── media: MediaManager
|
||||
└── renderer: RendererManager
|
||||
```
|
||||
|
||||
### When to Use What
|
||||
|
||||
#### In React Components
|
||||
|
||||
**Always use the `useEditor()` hook:**
|
||||
|
||||
```typescript
|
||||
import { useEditor } from '@/hooks/use-editor';
|
||||
|
||||
function MyComponent() {
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
|
||||
// Call methods
|
||||
editor.timeline.addTrack({ type: 'media' });
|
||||
|
||||
// Display data (auto re-renders on changes)
|
||||
return <div>{tracks.length} tracks</div>;
|
||||
}
|
||||
```
|
||||
|
||||
The hook:
|
||||
|
||||
- Returns the singleton instance
|
||||
- Subscribes to all manager changes
|
||||
- Automatically re-renders when state changes
|
||||
|
||||
#### Outside React Components
|
||||
|
||||
**Use `EditorCore.getInstance()` directly:**
|
||||
|
||||
```typescript
|
||||
// In utilities, event handlers, or non-React code
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
const editor = EditorCore.getInstance();
|
||||
await editor.export({ format: "mp4", quality: "high" });
|
||||
```
|
||||
|
||||
## Actions System
|
||||
|
||||
Actions are the trigger layer for user-initiated operations. The single source of truth is `@/lib/actions/definitions.ts`.
|
||||
|
||||
**To add a new action:**
|
||||
|
||||
1. Add it to `ACTIONS` in `@/lib/actions/definitions.ts`:
|
||||
|
||||
```typescript
|
||||
export const ACTIONS = {
|
||||
"my-action": {
|
||||
description: "What the action does",
|
||||
category: "editing",
|
||||
defaultShortcuts: ["ctrl+m"],
|
||||
},
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
2. Add handler in `@/hooks/use-editor-actions.ts`:
|
||||
|
||||
```typescript
|
||||
useActionHandler(
|
||||
"my-action",
|
||||
() => {
|
||||
// implementation
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
```
|
||||
|
||||
**In components, use `invokeAction()` for user-triggered operations:**
|
||||
|
||||
```typescript
|
||||
import { invokeAction } from '@/lib/actions';
|
||||
|
||||
// Good - uses action system
|
||||
const handleSplit = () => invokeAction("split-selected");
|
||||
|
||||
// Avoid - bypasses UX layer (toasts, validation feedback)
|
||||
const handleSplit = () => editor.timeline.splitElements({ ... });
|
||||
```
|
||||
|
||||
Direct `editor.xxx()` calls are for internal use (commands, tests, complex multi-step operations).
|
||||
|
||||
## Commands System
|
||||
|
||||
Commands handle undo/redo. They live in `@/lib/commands/` organized by domain (timeline, media, scene).
|
||||
|
||||
Each command extends `Command` from `@/lib/commands/base-command` and implements:
|
||||
|
||||
- `execute()` - saves current state, then does the mutation
|
||||
- `undo()` - restores the saved state
|
||||
|
||||
Actions and commands work together: actions are "what triggered this", commands are "how to do it (and undo it)".
|
||||
|
||||
Generated
+18
-91
@@ -642,14 +642,6 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
@@ -1985,19 +1977,6 @@ 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"
|
||||
@@ -2801,12 +2780,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.92"
|
||||
version = "0.3.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
|
||||
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
@@ -3563,13 +3540,6 @@ dependencies = [
|
||||
"gpui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opencut-wasm"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
@@ -4618,17 +4588,6 @@ 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"
|
||||
@@ -5294,16 +5253,6 @@ 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"
|
||||
@@ -5578,32 +5527,6 @@ 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"
|
||||
@@ -5927,9 +5850,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.115"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
|
||||
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -5940,19 +5863,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.65"
|
||||
version = "0.4.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
|
||||
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.115"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
|
||||
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -5960,9 +5887,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.115"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
|
||||
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -5973,9 +5900,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.115"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
|
||||
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -6126,9 +6053,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.92"
|
||||
version = "0.3.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
|
||||
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
|
||||
@@ -2,7 +2,4 @@
|
||||
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/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)
|
||||
|
||||
---
|
||||
|
||||

|
||||
<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)
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
+1
-5
@@ -7,8 +7,4 @@
|
||||
|
||||
.next/
|
||||
|
||||
.font-cache/
|
||||
|
||||
next-env.d.ts
|
||||
|
||||
*.tsbuildinfo
|
||||
.font-cache/
|
||||
@@ -14,9 +14,14 @@ 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,7 +9,6 @@ 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(
|
||||
@@ -21,11 +20,10 @@ const changelog = defineCollection({
|
||||
}),
|
||||
transform: async (doc, { collection }) => {
|
||||
const allDocs = await collection.documents();
|
||||
const publishedDocs = allDocs.filter((entry) => entry.published !== false);
|
||||
const sorted = [...publishedDocs].sort((a, b) =>
|
||||
const sorted = [...allDocs].sort((a, b) =>
|
||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||
);
|
||||
const isLatest = doc.published !== false && sorted[0]?.version === doc.version;
|
||||
const isLatest = sorted[0]?.version === doc.version;
|
||||
return { ...doc, isLatest };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
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,7 +1,6 @@
|
||||
---
|
||||
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,7 +1,6 @@
|
||||
---
|
||||
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 "@/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;
|
||||
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;
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <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,6 +1,9 @@
|
||||
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: {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||
|
||||
export default defineCloudflareConfig();
|
||||
@@ -22,8 +22,9 @@
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.6",
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"opencut-wasm": "workspace:*",
|
||||
"@opennextjs/cloudflare": "^1.18.0",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@opennextjs/cloudflare": "^1.17.3",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
@@ -76,7 +77,7 @@
|
||||
"unified": "^11.0.5",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"wavesurfer.js": "^7.9.8",
|
||||
"zod": "4.3.6",
|
||||
"zod": "^3.25.67",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Binary file not shown.
@@ -1,280 +1,280 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ 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 { getReleaseByVersion, getSortedReleases } from "../utils";
|
||||
import { getSortedReleases } from "../utils";
|
||||
import {
|
||||
ReleaseArticle,
|
||||
ReleaseMeta,
|
||||
@@ -16,12 +17,12 @@ import { CopyMarkdownButton } from "../components/copy-markdown-button";
|
||||
type Props = { params: Promise<{ version: string }> };
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return getSortedReleases().map((release) => ({ version: release.version }));
|
||||
return allChangelogs.map((r) => ({ version: r.version }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { version } = await params;
|
||||
const release = getReleaseByVersion({ version });
|
||||
const release = allChangelogs.find((r) => r.version === version);
|
||||
if (!release) return {};
|
||||
return {
|
||||
title: `${release.title} (${release.version}) - OpenCut Changelog`,
|
||||
@@ -32,8 +33,10 @@ 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((entry) => entry.version === version);
|
||||
const index = releases.findIndex((r) => r.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;
|
||||
@@ -49,20 +52,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}
|
||||
/>
|
||||
</div>
|
||||
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
|
||||
{release.description && (
|
||||
<ReleaseDescription>{release.description}</ReleaseDescription>
|
||||
)}
|
||||
<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>
|
||||
<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 }: { type: string }): string {
|
||||
export function getSectionTitle(type: string): string {
|
||||
return (
|
||||
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
|
||||
);
|
||||
@@ -36,18 +36,8 @@ export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
|
||||
return { grouped, orderedTypes };
|
||||
}
|
||||
|
||||
function isPublishedRelease({ published }: Release) {
|
||||
return published !== false;
|
||||
}
|
||||
|
||||
export function getSortedReleases() {
|
||||
return allChangelogs
|
||||
.filter(isPublishedRelease)
|
||||
.sort((a, b) =>
|
||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||
);
|
||||
}
|
||||
|
||||
export function getReleaseByVersion({ version }: { version: string }) {
|
||||
return getSortedReleases().find((release) => release.version === version);
|
||||
return [...allChangelogs].sort((a, b) =>
|
||||
b.version.localeCompare(a.version, undefined, { numeric: true }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 "@/lib/env/web";
|
||||
import { webEnv } from "@opencut/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,149 +1,150 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
"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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,80 +1,83 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import { useState, useRef } from "react";
|
||||
import { extractTimelineAudio } from "@/lib/media/mediabunny";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
|
||||
TRANSCRIPTION_LANGUAGES,
|
||||
} from "@/constants/transcription-constants";
|
||||
BatchCommand,
|
||||
AddTrackCommand,
|
||||
InsertElementCommand,
|
||||
} from "@/lib/commands";
|
||||
import { TRANSCRIPTION_LANGUAGES } from "@/constants/transcription-constants";
|
||||
import type {
|
||||
TranscriptionLanguage,
|
||||
TranscriptionProgress,
|
||||
@@ -29,11 +31,6 @@ import {
|
||||
SectionFields,
|
||||
} from "@/components/section";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import {
|
||||
AddTrackCommand,
|
||||
BatchCommand,
|
||||
InsertElementCommand,
|
||||
} from "@/lib/commands";
|
||||
|
||||
export function Captions() {
|
||||
const [selectedLanguage, setSelectedLanguage] =
|
||||
@@ -65,10 +62,7 @@ export function Captions() {
|
||||
});
|
||||
|
||||
setProcessingStep("Preparing audio...");
|
||||
const { samples } = await decodeAudioToFloat32({
|
||||
audioBlob,
|
||||
sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
|
||||
});
|
||||
const { samples } = await decodeAudioToFloat32({ audioBlob });
|
||||
|
||||
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 "@/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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
"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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export function SoundsView() {
|
||||
<div className="px-3 pt-4 pb-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="sound-effects">Sound effects</TabsTrigger>
|
||||
<TabsTrigger value="songs">Songs</TabsTrigger>
|
||||
<TabsTrigger value="saved">Saved</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
@@ -58,6 +59,12 @@ export function SoundsView() {
|
||||
>
|
||||
<SavedSoundsView />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="songs"
|
||||
className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
|
||||
>
|
||||
<SongsView />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
@@ -478,6 +485,10 @@ function SavedSoundsView() {
|
||||
);
|
||||
}
|
||||
|
||||
function SongsView() {
|
||||
return <div>Songs</div>;
|
||||
}
|
||||
|
||||
interface AudioItemProps {
|
||||
sound: SoundEffect;
|
||||
isPlaying: boolean;
|
||||
|
||||
@@ -1,147 +1,151 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
"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>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-6
@@ -1,11 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ParamDefinition, NumberParamDefinition } from "@/lib/params";
|
||||
import {
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
snapToStep,
|
||||
} from "@/utils/math";
|
||||
import { formatNumberForDisplay, snapToStep } from "@/utils/math";
|
||||
import { SectionField } from "@/components/section";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -160,7 +156,7 @@ function NumberParamField({
|
||||
onPreview(clamped / displayMultiplier);
|
||||
};
|
||||
|
||||
const maxFractionDigits = getFractionDigitsForStep({ step });
|
||||
const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
|
||||
|
||||
const draft = usePropertyDraft({
|
||||
displayValue: formatNumberForDisplay({
|
||||
|
||||
@@ -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 "@/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 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import {
|
||||
clamp,
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
isNearlyEqual,
|
||||
snapToStep,
|
||||
} from "@/utils/math";
|
||||
import { clamp, isNearlyEqual } from "@/utils/math";
|
||||
import type { AudioElement, VideoElement } from "@/lib/timeline";
|
||||
import { resolveNumberAtTime } from "@/lib/animation";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
@@ -24,9 +18,6 @@ import {
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
|
||||
const VOLUME_STEP = 0.1;
|
||||
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
|
||||
|
||||
export function AudioTab({
|
||||
element,
|
||||
trackId,
|
||||
@@ -51,24 +42,17 @@ export function AudioTab({
|
||||
propertyPath: "volume",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: formatNumberForDisplay({
|
||||
value: resolvedVolume,
|
||||
fractionDigits: VOLUME_FRACTION_DIGITS,
|
||||
}),
|
||||
displayValue: resolvedVolume.toFixed(1),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return clamp({
|
||||
value: snapToStep({ value: parsed, step: VOLUME_STEP }),
|
||||
min: VOLUME_DB_MIN,
|
||||
max: VOLUME_DB_MAX,
|
||||
});
|
||||
return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX });
|
||||
},
|
||||
valueAtPlayhead: resolvedVolume,
|
||||
step: VOLUME_STEP,
|
||||
step: 0.1,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
volume: value,
|
||||
}),
|
||||
|
||||
@@ -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 "@/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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,12 +38,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
clamp,
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
snapToStep,
|
||||
} from "@/utils/math";
|
||||
import { clamp, formatNumberForDisplay, snapToStep } from "@/utils/math";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
@@ -53,7 +48,7 @@ import {
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
|
||||
import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
type MasksTabProps = {
|
||||
@@ -604,7 +599,7 @@ function MaskNumberField({
|
||||
const max = isPercent ? 100 : param.max;
|
||||
const step = isPercent ? 1 : param.step;
|
||||
const displayValue = value * displayMultiplier;
|
||||
const maxFractionDigits = getFractionDigitsForStep({ step });
|
||||
const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
|
||||
|
||||
const clampDisplay = (nextDisplayValue: number) =>
|
||||
max !== undefined
|
||||
|
||||
@@ -22,28 +22,16 @@ import {
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import {
|
||||
formatNumberForDisplay,
|
||||
getFractionDigitsForStep,
|
||||
snapToStep,
|
||||
} from "@/utils/math";
|
||||
|
||||
const SPEED_STEP = 0.01;
|
||||
const SPEED_FRACTION_DIGITS = getFractionDigitsForStep({ step: SPEED_STEP });
|
||||
|
||||
function rateToDisplay({ rate }: { rate: number }): string {
|
||||
return formatNumberForDisplay({
|
||||
value: rate,
|
||||
fractionDigits: SPEED_FRACTION_DIGITS,
|
||||
});
|
||||
return rate.toFixed(2);
|
||||
}
|
||||
|
||||
function parseSpeedInput({ input }: { input: string }): number | null {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clampRetimeRate({
|
||||
rate: snapToStep({ value: parsed, step: SPEED_STEP }),
|
||||
});
|
||||
const rounded = Math.round(parsed * 100) / 100;
|
||||
return clampRetimeRate({ rate: rounded });
|
||||
}
|
||||
|
||||
function buildRetime({
|
||||
@@ -68,21 +56,24 @@ export function SpeedTab({
|
||||
const rate = clampRetimeRate({
|
||||
rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
|
||||
});
|
||||
const isPitchPreserveAvailable = canMaintainPitch({ rate });
|
||||
const maintainPitchAvailable = canMaintainPitch({ rate });
|
||||
const maintainPitch = element.retime?.maintainPitch ?? false;
|
||||
const pendingRateRef = useRef(rate);
|
||||
|
||||
const commitRetime = ({
|
||||
const applyRetime = ({
|
||||
rate: nextRate,
|
||||
maintainPitch: nextMaintainPitch,
|
||||
pushHistory = true,
|
||||
}: {
|
||||
rate: number;
|
||||
maintainPitch: boolean;
|
||||
pushHistory?: boolean;
|
||||
}) => {
|
||||
editor.timeline.updateElementRetime({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }),
|
||||
pushHistory,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -91,20 +82,10 @@ export function SpeedTab({
|
||||
parse: (input) => parseSpeedInput({ input }),
|
||||
onPreview: (nextRate) => {
|
||||
pendingRateRef.current = nextRate;
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
retime: buildRetime({ rate: nextRate, maintainPitch }),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
applyRetime({ rate: nextRate, maintainPitch, pushHistory: false });
|
||||
},
|
||||
onCommit: () => {
|
||||
commitRetime({ rate: pendingRateRef.current, maintainPitch });
|
||||
applyRetime({ rate: pendingRateRef.current, maintainPitch });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -134,20 +115,20 @@ export function SpeedTab({
|
||||
onScrub={speedDraft.scrubTo}
|
||||
onScrubEnd={speedDraft.commitScrub}
|
||||
onReset={() =>
|
||||
commitRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
|
||||
applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
|
||||
}
|
||||
isDefault={rate === DEFAULT_RETIME_RATE}
|
||||
/>
|
||||
</SectionField>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Change pitch</span>
|
||||
<Switch
|
||||
checked={!maintainPitch}
|
||||
disabled={!isPitchPreserveAvailable}
|
||||
onCheckedChange={(checked) =>
|
||||
commitRetime({ rate, maintainPitch: !checked })
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">Change pitch</span>
|
||||
<Switch
|
||||
checked={!maintainPitch}
|
||||
disabled={!maintainPitchAvailable}
|
||||
onCheckedChange={(checked) =>
|
||||
applyRetime({ rate, maintainPitch: !checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
|
||||
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 "opencut-wasm";
|
||||
import { getSnappedSeekTime } from "@/lib/time";
|
||||
import {
|
||||
ArrowTurnBackwardIcon,
|
||||
Delete02Icon,
|
||||
@@ -27,7 +27,7 @@ import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import { clamp, formatNumberForDisplay } from "@/utils/math";
|
||||
import { clamp } from "@/utils/math";
|
||||
import {
|
||||
timelineTimeToPixels,
|
||||
timelineTimeToSnappedPixels,
|
||||
@@ -188,7 +188,7 @@ function TimelineBookmark({
|
||||
left: `${bookmarkLeft}px`,
|
||||
width: `${bookmarkWidth}px`,
|
||||
}}
|
||||
aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`}
|
||||
aria-label={`Bookmark at ${time.toFixed(1)}s`}
|
||||
type="button"
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
VolumeOffIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
|
||||
import { OcShapesIcon, OcVideoIcon } from "@/components/icons";
|
||||
import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/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";
|
||||
|
||||
@@ -566,7 +566,7 @@ function KeyframeIndicators({
|
||||
<HugeiconsIcon
|
||||
icon={KeyframeIcon}
|
||||
className={cn(
|
||||
"size-3.5 text-black",
|
||||
"size-3.5 mt-1.5 text-black",
|
||||
isIndicatorSelected ? "fill-primary" : "fill-white",
|
||||
)}
|
||||
strokeWidth={1.5}
|
||||
|
||||
@@ -34,10 +34,8 @@ import {
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
Layers01Icon,
|
||||
Chart03Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { OcRippleIcon } from "@/components/icons";
|
||||
|
||||
export function TimelineToolbar({
|
||||
zoomLevel,
|
||||
@@ -158,14 +156,6 @@ function ToolbarLeftSection() {
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={Chart03Icon} />}
|
||||
tooltip="Graph"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
@@ -217,7 +207,7 @@ function ToolbarRightSection({
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={<OcRippleIcon size={24} className="scale-110" />}
|
||||
icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />}
|
||||
isActive={rippleEditingEnabled}
|
||||
tooltip="Ripple editing"
|
||||
onClick={() => toggleRippleEditing()}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface IconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
@@ -1,20 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type PropsWithChildren, useEffect, 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);
|
||||
@@ -42,18 +31,18 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
initialPosition: 0,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
const newWidth = container.offsetWidth;
|
||||
const newWidth = el.offsetWidth;
|
||||
setWidth(newWidth);
|
||||
setRightHandle(newWidth);
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(updateWidth);
|
||||
observer.observe(container);
|
||||
observer.observe(el);
|
||||
updateWidth();
|
||||
|
||||
return () => observer.disconnect();
|
||||
@@ -77,10 +66,7 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
const deltaX = event.clientX - startX;
|
||||
|
||||
if (side === "left") {
|
||||
const maxLeft = Math.max(
|
||||
0,
|
||||
rightHandlePositionRef.current - MIN_HANDLE_SEPARATION_PX,
|
||||
);
|
||||
const maxLeft = Math.max(0, rightHandlePositionRef.current - 60);
|
||||
const nextLeftHandle = Math.max(
|
||||
0,
|
||||
Math.min(maxLeft, initialPosition + deltaX),
|
||||
@@ -91,7 +77,7 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
|
||||
const minRight = Math.min(
|
||||
widthRef.current,
|
||||
leftHandlePositionRef.current + MIN_HANDLE_SEPARATION_PX,
|
||||
leftHandlePositionRef.current + 60,
|
||||
);
|
||||
const nextRightHandle = Math.max(
|
||||
minRight,
|
||||
@@ -120,30 +106,13 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
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;
|
||||
const leftGradientPercent = width > 0 ? (leftHandle / (width - 10)) * 100 : 0;
|
||||
const rightGradientPercent =
|
||||
width > 0 ? (rightHandle / (width + 10)) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex justify-center gap-4 leading-16">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative mt-0.5"
|
||||
style={{ transform: `rotate(-${HANDLEBARS_ROTATE_DEG}deg)` }}
|
||||
>
|
||||
<div ref={containerRef} className="relative mt-0.5 -rotate-[2.76deg]">
|
||||
<div className="absolute inset-0 z-10 flex size-full justify-between rounded-2xl border border-yellow-500">
|
||||
<div
|
||||
ref={leftHandleRef}
|
||||
@@ -166,13 +135,9 @@ export function Handlebars({ children }: HandlebarsProps) {
|
||||
|
||||
<div
|
||||
ref={rightHandleRef}
|
||||
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"
|
||||
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"
|
||||
style={{
|
||||
left: hasMeasuredWidth
|
||||
? `${RIGHT_HANDLE_LEFT_OFFSET_PX}px`
|
||||
: undefined,
|
||||
right: hasMeasuredWidth ? undefined : "0px",
|
||||
translate: hasMeasuredWidth ? `${rightHandle}px 0` : undefined,
|
||||
translate: `${rightHandle}px 0`,
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -191,8 +156,13 @@ 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: textMask,
|
||||
WebkitMask: textMask,
|
||||
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%)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
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,
|
||||
},
|
||||
];
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TrackType } from "@/lib/timeline";
|
||||
import type { ElementType, TrackType } from "@/lib/timeline";
|
||||
|
||||
export const TRACK_CONFIG: Record<
|
||||
TrackType,
|
||||
@@ -46,6 +46,16 @@ 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,8 +51,6 @@ 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 "opencut-wasm";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { downloadBlob } from "@/utils/browser";
|
||||
|
||||
type SnapshotResult =
|
||||
@@ -107,7 +107,10 @@ 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/placement";
|
||||
import { ensureMainTrack } from "@/lib/timeline/track-utils";
|
||||
import {
|
||||
CreateSceneCommand,
|
||||
DeleteSceneCommand,
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
AnimationValue,
|
||||
} from "@/lib/animation/types";
|
||||
import { calculateTotalDuration } from "@/lib/timeline";
|
||||
import { getLastFrameTime } from "opencut-wasm";
|
||||
import { getLastFrameTime } from "@/lib/time";
|
||||
import {
|
||||
AddTrackCommand,
|
||||
RemoveTrackCommand,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "opencut-wasm";
|
||||
import { snapTimeToFrame } from "@/lib/time";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
@@ -302,7 +302,10 @@ 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 "opencut-wasm";
|
||||
import { snapTimeToFrame } from "@/lib/time";
|
||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
@@ -285,14 +285,26 @@ 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);
|
||||
@@ -314,8 +326,14 @@ 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);
|
||||
@@ -341,8 +359,16 @@ 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);
|
||||
@@ -369,7 +395,13 @@ 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);
|
||||
@@ -379,7 +411,10 @@ 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);
|
||||
@@ -403,12 +438,18 @@ 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 "opencut-wasm";
|
||||
import { snapTimeToFrame, getSnappedSeekTime } from "@/lib/time";
|
||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||
import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
@@ -254,7 +254,11 @@ 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,275 +1,281 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { getSnappedSeekTime } from "opencut-wasm";
|
||||
import { getSnappedSeekTime } from "@/lib/time";
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||
import { useEditor } from "../use-editor";
|
||||
@@ -83,7 +83,11 @@ 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,193 +1,197 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { webEnv } from "@/lib/env/web";
|
||||
|
||||
export const { signIn, signUp, useSession } = createAuthClient({
|
||||
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
|
||||
});
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { webEnv } from "@opencut/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 "@/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;
|
||||
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;
|
||||
|
||||
@@ -6,12 +6,13 @@ import type {
|
||||
ClipboardItem,
|
||||
} from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
|
||||
import {
|
||||
applyPlacement,
|
||||
resolveTrackPlacement,
|
||||
buildEmptyTrack,
|
||||
getHighestInsertIndexForTrack,
|
||||
isMainTrack,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/placement";
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
|
||||
export class PasteCommand extends Command {
|
||||
@@ -38,7 +39,7 @@ export class PasteCommand extends Command {
|
||||
...this.clipboardItems.map((item) => item.element.startTime),
|
||||
);
|
||||
|
||||
let updatedTracks = [...this.savedState];
|
||||
const updatedTracks = [...this.savedState];
|
||||
const itemsByTrackId = groupClipboardItemsByTrackId({
|
||||
clipboardItems: this.clipboardItems,
|
||||
});
|
||||
@@ -58,22 +59,17 @@ export class PasteCommand extends Command {
|
||||
const sourceTrackIndex = updatedTracks.findIndex(
|
||||
(track) => track.id === trackId,
|
||||
);
|
||||
const placementResult = resolveTrackPlacement({
|
||||
const resolvedTargetIndex = resolveTargetTrackIndex({
|
||||
tracks: updatedTracks,
|
||||
sourceTrackIndex,
|
||||
trackType,
|
||||
timeSpans: elementsToAdd.map((element) => ({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
})),
|
||||
strategy: { type: "aboveSource", sourceTrackIndex },
|
||||
elements: elementsToAdd,
|
||||
});
|
||||
if (!placementResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let elementsForPlacement = elementsToAdd;
|
||||
if (placementResult.kind === "existingTrack") {
|
||||
const targetTrack = updatedTracks[placementResult.trackIndex];
|
||||
if (resolvedTargetIndex >= 0) {
|
||||
const targetTrack = updatedTracks[resolvedTargetIndex];
|
||||
let adjustedElements = elementsToAdd;
|
||||
|
||||
if (isMainTrack(targetTrack)) {
|
||||
const earliestElement = elementsToAdd.reduce((earliest, element) =>
|
||||
element.startTime < earliest.startTime ? element : earliest,
|
||||
@@ -86,28 +82,39 @@ export class PasteCommand extends Command {
|
||||
const delta = adjustedEarliestStartTime - earliestElement.startTime;
|
||||
|
||||
if (delta !== 0) {
|
||||
elementsForPlacement = elementsToAdd.map((element) => ({
|
||||
adjustedElements = elementsToAdd.map((element) => ({
|
||||
...element,
|
||||
startTime: Math.max(0, element.startTime + delta),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const applied = applyPlacement({
|
||||
tracks: updatedTracks,
|
||||
placementResult,
|
||||
elements: elementsForPlacement,
|
||||
});
|
||||
if (!applied) {
|
||||
updatedTracks[resolvedTargetIndex] = {
|
||||
...targetTrack,
|
||||
elements: [...targetTrack.elements, ...adjustedElements],
|
||||
} as TimelineTrack;
|
||||
for (const element of adjustedElements) {
|
||||
this.pastedElements.push({
|
||||
trackId: targetTrack.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
updatedTracks = applied.updatedTracks;
|
||||
|
||||
for (const element of elementsForPlacement) {
|
||||
const insertIndex = resolveInsertIndexForNewTrack({
|
||||
tracks: updatedTracks,
|
||||
sourceTrackIndex,
|
||||
trackType,
|
||||
});
|
||||
const newTrack = buildTrackWithElements({
|
||||
trackType,
|
||||
elements: elementsToAdd,
|
||||
});
|
||||
updatedTracks.splice(insertIndex, 0, newTrack);
|
||||
for (const element of elementsToAdd) {
|
||||
this.pastedElements.push({
|
||||
trackId: applied.targetTrackId,
|
||||
trackId: newTrack.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
@@ -180,3 +187,107 @@ 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,8 +1,7 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { rippleShiftElements } from "@/lib/timeline";
|
||||
import { isMainTrack } from "@/lib/timeline/placement";
|
||||
import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
|
||||
|
||||
export class DeleteElementsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
@@ -2,7 +2,10 @@ import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
getHighestInsertIndexForTrack,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
|
||||
interface DuplicateElementsParams {
|
||||
@@ -26,7 +29,7 @@ export class DuplicateElementsCommand extends Command {
|
||||
this.previousSelection = editor.selection.getSelectedElements();
|
||||
this.duplicatedElements = [];
|
||||
|
||||
let updatedTracks = [...this.savedState];
|
||||
const updatedTracks = [...this.savedState];
|
||||
|
||||
for (const track of this.savedState) {
|
||||
const elementsToDuplicate = this.elements.filter(
|
||||
@@ -42,12 +45,22 @@ 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,
|
||||
@@ -57,33 +70,16 @@ export class DuplicateElementsCommand extends Command {
|
||||
);
|
||||
}
|
||||
|
||||
const placementResult = resolveTrackPlacement({
|
||||
const newTrack = {
|
||||
...newTrackBase,
|
||||
elements: newTrackElements,
|
||||
} as TimelineTrack;
|
||||
|
||||
const insertIndex = getHighestInsertIndexForTrack({
|
||||
tracks: updatedTracks,
|
||||
trackType: track.type,
|
||||
timeSpans: [],
|
||||
strategy: { type: "alwaysNew", position: "highest" },
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
updatedTracks.splice(insertIndex, 0, newTrack);
|
||||
}
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
@@ -5,18 +5,23 @@ import type {
|
||||
TimelineTrack,
|
||||
TimelineElement,
|
||||
TrackType,
|
||||
ElementType,
|
||||
} from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { requiresMediaId } from "@/lib/timeline/element-utils";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
|
||||
import {
|
||||
applyPlacement,
|
||||
requiresMediaId,
|
||||
wouldElementOverlap,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
canElementGoOnTrack,
|
||||
resolveTrackPlacement,
|
||||
getDefaultInsertIndexForTrack,
|
||||
validateElementTrackCompatibility,
|
||||
} from "@/lib/timeline/placement";
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
|
||||
|
||||
type InsertElementPlacement =
|
||||
| { mode: "explicit"; trackId: string }
|
||||
@@ -62,7 +67,7 @@ export class InsertElementCommand extends Command {
|
||||
const isFirstElement = totalElementsInTimeline === 0;
|
||||
|
||||
const newElement = this.buildElement({ element: this.element });
|
||||
const updateResult = this.applyPlacementResult({
|
||||
const updateResult = this.resolveTracksWithElement({
|
||||
tracks: this.savedState,
|
||||
element: newElement,
|
||||
});
|
||||
@@ -185,7 +190,7 @@ export class InsertElementCommand extends Command {
|
||||
return true;
|
||||
}
|
||||
|
||||
private applyPlacementResult({
|
||||
private resolveTracksWithElement({
|
||||
tracks,
|
||||
element,
|
||||
}: {
|
||||
@@ -194,71 +199,160 @@ 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: placement.trackType,
|
||||
trackType,
|
||||
})
|
||||
) {
|
||||
console.error(
|
||||
`${element.type} elements cannot be placed on ${placement.trackType} tracks`,
|
||||
`${element.type} elements cannot be placed on ${trackType} tracks`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
const elementEndTime = element.startTime + element.duration;
|
||||
const existingTrack = tracks.find((track) => {
|
||||
if (
|
||||
!canElementGoOnTrack({
|
||||
elementType: element.type,
|
||||
trackType: track.type,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
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 };
|
||||
}
|
||||
|
||||
const elementToPlace =
|
||||
placementResult.kind === "existingTrack"
|
||||
? {
|
||||
...element,
|
||||
startTime:
|
||||
placementResult.adjustedStartTime ?? element.startTime,
|
||||
}
|
||||
: element;
|
||||
const newTrackId = generateUUID();
|
||||
const newTrack = buildEmptyTrack({
|
||||
id: newTrackId,
|
||||
type: trackType,
|
||||
});
|
||||
const newTrackWithElement = {
|
||||
...newTrack,
|
||||
elements: [...newTrack.elements, element],
|
||||
} as TimelineTrack;
|
||||
|
||||
return applyPlacement({
|
||||
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({
|
||||
tracks,
|
||||
placementResult,
|
||||
elements: [elementToPlace],
|
||||
newTrackInsertIndexOverride:
|
||||
placement.mode === "auto" && typeof placement.insertIndex === "number"
|
||||
? placement.insertIndex
|
||||
: undefined,
|
||||
trackType,
|
||||
});
|
||||
}
|
||||
|
||||
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/placement";
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
|
||||
|
||||
export class MoveElementCommand extends Command {
|
||||
|
||||
@@ -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/placement";
|
||||
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
|
||||
|
||||
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/placement";
|
||||
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
|
||||
|
||||
export class UpdateElementTrimCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { EditorCore } from "@/core";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
getDefaultInsertIndexForTrack,
|
||||
} from "@/lib/timeline/placement";
|
||||
} from "@/lib/timeline/track-utils";
|
||||
|
||||
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/placement";
|
||||
import { getMainTrack } from "@/lib/timeline";
|
||||
|
||||
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 "@/lib/env/web";
|
||||
import { webEnv } from "@opencut/env/web";
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
|
||||
+743
-745
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/placement";
|
||||
import { isMainTrack } from "@/lib/timeline";
|
||||
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 "@/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 };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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/placement";
|
||||
import { ensureMainTrack } from "@/lib/timeline/track-utils";
|
||||
|
||||
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
|
||||
return scenes.find((scene) => scene.isMain) || null;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,149 +1,149 @@
|
||||
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
|
||||
);
|
||||
});
|
||||
}
|
||||
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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
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 { resolveTrackPlacement } from "@/lib/timeline/placement";
|
||||
import {
|
||||
canElementGoOnTrack,
|
||||
isMainTrack,
|
||||
enforceMainTrackStart,
|
||||
} from "./track-utils";
|
||||
|
||||
function findElementAtPosition({
|
||||
mouseX,
|
||||
@@ -76,22 +82,57 @@ function getTrackAtY({
|
||||
return null;
|
||||
}
|
||||
|
||||
const EMPTY_TARGET_ELEMENT = null;
|
||||
|
||||
function fallbackNewTrackDropTarget({
|
||||
xPosition,
|
||||
function isCompatible({
|
||||
elementType,
|
||||
trackType,
|
||||
}: {
|
||||
xPosition: number;
|
||||
}): DropTarget {
|
||||
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" };
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: 0,
|
||||
isNewTrack: true,
|
||||
insertPosition: null,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
index: overlayInsertIndex,
|
||||
position: insertAbove ? "above" : "below",
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_TARGET_ELEMENT = null;
|
||||
|
||||
export function computeDropTarget({
|
||||
elementType,
|
||||
mouseX,
|
||||
@@ -114,28 +155,22 @@ export function computeDropTarget({
|
||||
? playheadTime
|
||||
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
||||
|
||||
if (tracks.length === 0) {
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: 0,
|
||||
hoverDirection: "below",
|
||||
createNewTrackOnly: true,
|
||||
},
|
||||
});
|
||||
const emptyTimelineResult =
|
||||
placementResult?.kind === "newTrack" ? placementResult : null;
|
||||
if (!emptyTimelineResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
const mainTrackIndex = getMainTrackIndex({ tracks });
|
||||
|
||||
if (tracks.length === 0) {
|
||||
if (elementType === "audio") {
|
||||
return {
|
||||
trackIndex: 0,
|
||||
isNewTrack: true,
|
||||
insertPosition: "below",
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
return {
|
||||
trackIndex: emptyTimelineResult.insertIndex,
|
||||
trackIndex: 0,
|
||||
isNewTrack: true,
|
||||
insertPosition: emptyTimelineResult.insertPosition,
|
||||
insertPosition: null,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
@@ -146,27 +181,30 @@ export function computeDropTarget({
|
||||
if (!trackAtMouse) {
|
||||
const isAboveAllTracks = mouseY < 0;
|
||||
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
|
||||
hoverDirection: isAboveAllTracks ? "above" : "below",
|
||||
createNewTrackOnly: true,
|
||||
},
|
||||
});
|
||||
const outOfBoundsResult =
|
||||
placementResult?.kind === "newTrack" ? placementResult : null;
|
||||
if (!outOfBoundsResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: outOfBoundsResult.insertIndex,
|
||||
trackIndex: Math.max(0, mainTrackIndex),
|
||||
isNewTrack: true,
|
||||
insertPosition: outOfBoundsResult.insertPosition,
|
||||
insertPosition: "above",
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
@@ -199,27 +237,34 @@ export function computeDropTarget({
|
||||
}
|
||||
|
||||
const trackHeight = TRACK_CONFIG[track.type].height;
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex,
|
||||
hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
|
||||
verticalDragDirection,
|
||||
},
|
||||
});
|
||||
if (!placementResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
const isInUpperHalf = relativeY < trackHeight / 2;
|
||||
|
||||
if (placementResult.kind === "existingTrack") {
|
||||
const adjustedXPosition =
|
||||
placementResult.adjustedStartTime ?? xPosition;
|
||||
const isTrackCompatible = isCompatible({
|
||||
elementType,
|
||||
trackType: track.type,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
return {
|
||||
trackIndex: placementResult.trackIndex,
|
||||
trackIndex,
|
||||
isNewTrack: false,
|
||||
insertPosition: null,
|
||||
xPosition: adjustedXPosition,
|
||||
@@ -227,10 +272,22 @@ export function computeDropTarget({
|
||||
};
|
||||
}
|
||||
|
||||
let insertAbove = isInUpperHalf;
|
||||
if (!isTrackCompatible && verticalDragDirection) {
|
||||
insertAbove = verticalDragDirection === "up";
|
||||
}
|
||||
|
||||
const { index, position } = findInsertIndex({
|
||||
elementType,
|
||||
tracks,
|
||||
preferredIndex: trackIndex,
|
||||
insertAbove,
|
||||
});
|
||||
|
||||
return {
|
||||
trackIndex: placementResult.insertIndex,
|
||||
trackIndex: index,
|
||||
isNewTrack: true,
|
||||
insertPosition: placementResult.insertPosition,
|
||||
insertPosition: position,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
|
||||
@@ -86,6 +86,24 @@ 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 {
|
||||
|
||||
@@ -1,626 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type {
|
||||
AudioElement,
|
||||
AudioTrack,
|
||||
GraphicElement,
|
||||
GraphicTrack,
|
||||
TextElement,
|
||||
TextTrack,
|
||||
TimelineTrack,
|
||||
VideoElement,
|
||||
VideoTrack,
|
||||
} from "@/lib/timeline";
|
||||
import type { Transform } from "@/lib/rendering";
|
||||
import { resolveTrackPlacement } from "@/lib/timeline/placement";
|
||||
|
||||
function buildTransform(): Transform {
|
||||
return {
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
rotate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
type TestElement = AudioElement | GraphicElement | TextElement | VideoElement;
|
||||
|
||||
function buildElement(params: {
|
||||
id: string;
|
||||
type: "audio";
|
||||
startTime: number;
|
||||
duration: number;
|
||||
}): AudioElement;
|
||||
function buildElement(params: {
|
||||
id: string;
|
||||
type: "graphic";
|
||||
startTime: number;
|
||||
duration: number;
|
||||
}): GraphicElement;
|
||||
function buildElement(params: {
|
||||
id: string;
|
||||
type: "text";
|
||||
startTime: number;
|
||||
duration: number;
|
||||
}): TextElement;
|
||||
function buildElement(params: {
|
||||
id: string;
|
||||
type: "video";
|
||||
startTime: number;
|
||||
duration: number;
|
||||
}): VideoElement;
|
||||
function buildElement({
|
||||
id,
|
||||
type,
|
||||
startTime,
|
||||
duration,
|
||||
}: {
|
||||
id: string;
|
||||
type: TestElement["type"];
|
||||
startTime: number;
|
||||
duration: number;
|
||||
}): TestElement {
|
||||
switch (type) {
|
||||
case "audio":
|
||||
return {
|
||||
id,
|
||||
type: "audio",
|
||||
name: id,
|
||||
startTime,
|
||||
duration,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
volume: 1,
|
||||
sourceType: "upload",
|
||||
mediaId: `media-${id}`,
|
||||
} satisfies AudioElement;
|
||||
case "graphic":
|
||||
return {
|
||||
id,
|
||||
type: "graphic",
|
||||
name: id,
|
||||
startTime,
|
||||
duration,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
definitionId: `graphic-${id}`,
|
||||
params: {},
|
||||
transform: buildTransform(),
|
||||
opacity: 1,
|
||||
} satisfies GraphicElement;
|
||||
case "text":
|
||||
return {
|
||||
id,
|
||||
type: "text",
|
||||
name: id,
|
||||
startTime,
|
||||
duration,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
content: id,
|
||||
fontSize: 32,
|
||||
fontFamily: "sans-serif",
|
||||
color: "#ffffff",
|
||||
background: {
|
||||
enabled: false,
|
||||
color: "#000000",
|
||||
},
|
||||
textAlign: "left",
|
||||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
transform: buildTransform(),
|
||||
opacity: 1,
|
||||
} satisfies TextElement;
|
||||
case "video":
|
||||
return {
|
||||
id,
|
||||
type: "video",
|
||||
name: id,
|
||||
startTime,
|
||||
duration,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
mediaId: `media-${id}`,
|
||||
transform: buildTransform(),
|
||||
opacity: 1,
|
||||
} satisfies VideoElement;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported test element type: ${type}`);
|
||||
}
|
||||
|
||||
type BuildTrackParams =
|
||||
| {
|
||||
id: string;
|
||||
type: "audio";
|
||||
elements?: AudioTrack["elements"];
|
||||
isMain?: boolean;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "graphic";
|
||||
elements?: GraphicTrack["elements"];
|
||||
isMain?: boolean;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "text";
|
||||
elements?: TextTrack["elements"];
|
||||
isMain?: boolean;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "video";
|
||||
elements?: VideoTrack["elements"];
|
||||
isMain?: boolean;
|
||||
};
|
||||
|
||||
function buildTrack(params: {
|
||||
id: string;
|
||||
type: "audio";
|
||||
elements?: AudioTrack["elements"];
|
||||
isMain?: boolean;
|
||||
}): AudioTrack;
|
||||
function buildTrack(params: {
|
||||
id: string;
|
||||
type: "graphic";
|
||||
elements?: GraphicTrack["elements"];
|
||||
isMain?: boolean;
|
||||
}): GraphicTrack;
|
||||
function buildTrack(params: {
|
||||
id: string;
|
||||
type: "text";
|
||||
elements?: TextTrack["elements"];
|
||||
isMain?: boolean;
|
||||
}): TextTrack;
|
||||
function buildTrack(params: {
|
||||
id: string;
|
||||
type: "video";
|
||||
elements?: VideoTrack["elements"];
|
||||
isMain?: boolean;
|
||||
}): VideoTrack;
|
||||
function buildTrack(params: BuildTrackParams): TimelineTrack {
|
||||
const { id, type, isMain = false } = params;
|
||||
|
||||
switch (type) {
|
||||
case "audio":
|
||||
return {
|
||||
id,
|
||||
type: "audio",
|
||||
name: id,
|
||||
elements: params.elements ?? [],
|
||||
muted: false,
|
||||
};
|
||||
case "graphic":
|
||||
return {
|
||||
id,
|
||||
type: "graphic",
|
||||
name: id,
|
||||
elements: params.elements ?? [],
|
||||
hidden: false,
|
||||
};
|
||||
case "text":
|
||||
return {
|
||||
id,
|
||||
type: "text",
|
||||
name: id,
|
||||
elements: params.elements ?? [],
|
||||
hidden: false,
|
||||
};
|
||||
case "video":
|
||||
return {
|
||||
id,
|
||||
type: "video",
|
||||
name: id,
|
||||
elements: params.elements ?? [],
|
||||
isMain,
|
||||
muted: false,
|
||||
hidden: false,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported test track type: ${type}`);
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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";
|
||||
@@ -1,99 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
function getInsertDirection({
|
||||
hoverDirection,
|
||||
verticalDragDirection,
|
||||
}: {
|
||||
hoverDirection: "above" | "below";
|
||||
verticalDragDirection?: "up" | "down" | null;
|
||||
}): "above" | "below" {
|
||||
if (verticalDragDirection === "up") {
|
||||
return "above";
|
||||
}
|
||||
|
||||
if (verticalDragDirection === "down") {
|
||||
return "below";
|
||||
}
|
||||
|
||||
return hoverDirection;
|
||||
}
|
||||
|
||||
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 { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({
|
||||
tracks,
|
||||
trackType,
|
||||
preferredIndex: strategy.trackIndex,
|
||||
direction: getInsertDirection({
|
||||
hoverDirection: strategy.hoverDirection,
|
||||
verticalDragDirection: !isPreferredTrackCompatible
|
||||
? strategy.verticalDragDirection
|
||||
: null,
|
||||
}),
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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,17 +1,21 @@
|
||||
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,
|
||||
@@ -60,3 +64,234 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function proxy() {
|
||||
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/placement";
|
||||
import { isMainTrack } from "@/lib/timeline";
|
||||
|
||||
const PREVIEW_MAX_IMAGE_SIZE = 2048;
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { formatNumberForDisplay } from "@/utils/math";
|
||||
|
||||
const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
|
||||
|
||||
export const STORAGE_HEADROOM_RESERVE_BYTES = 50 * 1024 * 1024;
|
||||
@@ -39,7 +37,7 @@ export function formatStorageBytes({ bytes }: { bytes: number }): string {
|
||||
}
|
||||
|
||||
const precision = value >= 10 || unitIndex === 0 ? 0 : 1;
|
||||
return `${formatNumberForDisplay({ value, fractionDigits: precision })} ${BYTE_UNITS[unitIndex]}`;
|
||||
return `${value.toFixed(precision)} ${BYTE_UNITS[unitIndex]}`;
|
||||
}
|
||||
|
||||
export async function readStorageQuotaStatus(): Promise<StorageQuotaStatus> {
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { evaluateMathExpression } from "../math";
|
||||
|
||||
describe("evaluateMathExpression", () => {
|
||||
describe("basic arithmetic", () => {
|
||||
it("should add numbers", () => {
|
||||
expect(evaluateMathExpression({ input: "1+2" })).toBe(3);
|
||||
expect(evaluateMathExpression({ input: "10 + 20" })).toBe(30);
|
||||
});
|
||||
|
||||
it("should subtract numbers", () => {
|
||||
expect(evaluateMathExpression({ input: "5-3" })).toBe(2);
|
||||
expect(evaluateMathExpression({ input: "100 - 50" })).toBe(50);
|
||||
});
|
||||
|
||||
it("should multiply numbers", () => {
|
||||
expect(evaluateMathExpression({ input: "4*5" })).toBe(20);
|
||||
expect(evaluateMathExpression({ input: "10 * 10" })).toBe(100);
|
||||
});
|
||||
|
||||
it("should divide numbers", () => {
|
||||
expect(evaluateMathExpression({ input: "20/4" })).toBe(5);
|
||||
expect(evaluateMathExpression({ input: "100 / 10" })).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("operator precedence", () => {
|
||||
it("should respect multiplication over addition", () => {
|
||||
expect(evaluateMathExpression({ input: "2+3*4" })).toBe(14);
|
||||
expect(evaluateMathExpression({ input: "10+5*2" })).toBe(20);
|
||||
});
|
||||
|
||||
it("should respect division over subtraction", () => {
|
||||
expect(evaluateMathExpression({ input: "10-6/2" })).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parentheses", () => {
|
||||
it("should evaluate expressions in parentheses first", () => {
|
||||
expect(evaluateMathExpression({ input: "(2+3)*4" })).toBe(20);
|
||||
expect(evaluateMathExpression({ input: "(10-5)*2" })).toBe(10);
|
||||
});
|
||||
|
||||
it("should handle nested parentheses", () => {
|
||||
expect(evaluateMathExpression({ input: "((2+3)*4)" })).toBe(20);
|
||||
expect(evaluateMathExpression({ input: "(1+(2*3))" })).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decimal numbers", () => {
|
||||
it("should handle decimal numbers", () => {
|
||||
expect(evaluateMathExpression({ input: "3.5+2.5" })).toBe(6);
|
||||
expect(evaluateMathExpression({ input: "10.5 * 2" })).toBe(21);
|
||||
});
|
||||
});
|
||||
|
||||
describe("negative numbers", () => {
|
||||
it("should handle negative numbers", () => {
|
||||
expect(evaluateMathExpression({ input: "-5+3" })).toBe(-2);
|
||||
expect(evaluateMathExpression({ input: "10+-5" })).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("whitespace handling", () => {
|
||||
it("should handle various whitespace", () => {
|
||||
expect(evaluateMathExpression({ input: " 1 + 2 " })).toBe(3);
|
||||
expect(evaluateMathExpression({ input: "10* 5" })).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle single number", () => {
|
||||
expect(evaluateMathExpression({ input: "42" })).toBe(42);
|
||||
expect(evaluateMathExpression({ input: "3.14" })).toBe(3.14);
|
||||
});
|
||||
|
||||
it("should return null for empty string", () => {
|
||||
expect(evaluateMathExpression({ input: "" })).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for invalid characters", () => {
|
||||
expect(evaluateMathExpression({ input: "1+2a" })).toBeNull();
|
||||
expect(evaluateMathExpression({ input: "alert(1)" })).toBeNull();
|
||||
expect(evaluateMathExpression({ input: "1+2; doSomething()" })).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for unbalanced parentheses", () => {
|
||||
expect(evaluateMathExpression({ input: "(1+2" })).toBeNull();
|
||||
expect(evaluateMathExpression({ input: "1+2)" })).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for division by zero", () => {
|
||||
expect(evaluateMathExpression({ input: "1/0" })).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for incomplete expressions", () => {
|
||||
expect(evaluateMathExpression({ input: "1+" })).toBeNull();
|
||||
expect(evaluateMathExpression({ input: "*2" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("security - code injection prevention", () => {
|
||||
it("should reject code injection attempts", () => {
|
||||
// These would be dangerous with new Function() but are safe here
|
||||
expect(evaluateMathExpression({ input: "alert('xss')" })).toBeNull();
|
||||
expect(evaluateMathExpression({ input: "process.exit()" })).toBeNull();
|
||||
expect(evaluateMathExpression({ input: "require('fs')" })).toBeNull();
|
||||
expect(evaluateMathExpression({ input: "eval('1+1')" })).toBeNull();
|
||||
expect(evaluateMathExpression({ input: "Function('return 1')()" })).toBeNull();
|
||||
expect(evaluateMathExpression({ input: "constructor.constructor('return 1')()" })).toBeNull();
|
||||
expect(evaluateMathExpression({ input: "this.toString" })).toBeNull();
|
||||
expect(evaluateMathExpression({ input: "__proto__" })).toBeNull();
|
||||
});
|
||||
|
||||
it("should reject prototype pollution attempts", () => {
|
||||
expect(evaluateMathExpression({ input: "[].constructor" })).toBeNull();
|
||||
expect(evaluateMathExpression({ input: "({}).constructor" })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
+84
-276
@@ -1,276 +1,84 @@
|
||||
export function clamp({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
export function clampRound({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}): number {
|
||||
return Math.round(clamp({ value, min, max }));
|
||||
}
|
||||
|
||||
export function getFractionDigitsForStep({ step }: { step: number }): number {
|
||||
const normalizedStep = step.toString().toLowerCase();
|
||||
if (normalizedStep.includes("e-")) {
|
||||
return Number(normalizedStep.split("e-")[1] ?? 0);
|
||||
}
|
||||
const [, fractionalPart = ""] = normalizedStep.split(".");
|
||||
return fractionalPart.length;
|
||||
}
|
||||
|
||||
export function snapToStep({
|
||||
value,
|
||||
step,
|
||||
}: {
|
||||
value: number;
|
||||
step: number;
|
||||
}): number {
|
||||
if (step <= 0) return value;
|
||||
const snappedValue = Math.round(value / step) * step;
|
||||
return Number(
|
||||
snappedValue.toFixed(getFractionDigitsForStep({ step })),
|
||||
);
|
||||
}
|
||||
|
||||
export function isNearlyEqual({
|
||||
leftValue,
|
||||
rightValue,
|
||||
epsilon = 0.0001,
|
||||
}: {
|
||||
leftValue: number;
|
||||
rightValue: number;
|
||||
epsilon?: number;
|
||||
}): boolean {
|
||||
return Math.abs(leftValue - rightValue) <= epsilon;
|
||||
}
|
||||
|
||||
export function formatNumberForDisplay({
|
||||
value,
|
||||
fractionDigits,
|
||||
minFractionDigits = 0,
|
||||
maxFractionDigits = 6,
|
||||
}: {
|
||||
value: number;
|
||||
fractionDigits?: number;
|
||||
minFractionDigits?: number;
|
||||
maxFractionDigits?: number;
|
||||
}): string {
|
||||
const resolvedMaxFractionDigits = Math.max(
|
||||
0,
|
||||
fractionDigits ?? maxFractionDigits,
|
||||
);
|
||||
const resolvedMinFractionDigits = Math.min(
|
||||
Math.max(0, fractionDigits ?? minFractionDigits),
|
||||
resolvedMaxFractionDigits,
|
||||
);
|
||||
const fixedValue = value.toFixed(resolvedMaxFractionDigits);
|
||||
|
||||
if (resolvedMaxFractionDigits === 0) {
|
||||
return Number(fixedValue) === 0 ? "0" : fixedValue;
|
||||
}
|
||||
|
||||
const [integerPart, fractionPart = ""] = fixedValue.split(".");
|
||||
const normalizedIntegerPart = Number(fixedValue) === 0 ? "0" : integerPart;
|
||||
let trimmedFractionPart = fractionPart;
|
||||
|
||||
while (
|
||||
trimmedFractionPart.length > resolvedMinFractionDigits &&
|
||||
trimmedFractionPart.endsWith("0")
|
||||
) {
|
||||
trimmedFractionPart = trimmedFractionPart.slice(0, -1);
|
||||
}
|
||||
|
||||
return trimmedFractionPart
|
||||
? `${normalizedIntegerPart}.${trimmedFractionPart}`
|
||||
: normalizedIntegerPart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely evaluates a mathematical expression without using eval() or new Function().
|
||||
* Supports +, -, *, /, parentheses, and decimal numbers.
|
||||
*/
|
||||
export function evaluateMathExpression({
|
||||
input,
|
||||
}: {
|
||||
input: string;
|
||||
}): number | null {
|
||||
const sanitized = input.trim();
|
||||
if (!/^[\d.\s+\-*/()]+$/.test(sanitized)) return null;
|
||||
try {
|
||||
return parseExpression(sanitized);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive descent parser for basic arithmetic expressions.
|
||||
* Grammar:
|
||||
* expression = term { ('+' | '-') term }
|
||||
* term = factor { ('*' | '/') factor }
|
||||
* factor = number | '(' expression ')'
|
||||
*/
|
||||
function parseExpression(input: string): number | null {
|
||||
const tokens = tokenize(input);
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
let index = 0;
|
||||
|
||||
function peek(): Token | null {
|
||||
return tokens[index] ?? null;
|
||||
}
|
||||
|
||||
function consume(): Token | null {
|
||||
return tokens[index++] ?? null;
|
||||
}
|
||||
|
||||
function parseExpressionLevel(): number {
|
||||
let left = parseTerm();
|
||||
while (true) {
|
||||
const token = peek();
|
||||
if (token?.type === "PLUS") {
|
||||
consume();
|
||||
left = left + parseTerm();
|
||||
} else if (token?.type === "MINUS") {
|
||||
consume();
|
||||
left = left - parseTerm();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
function parseTerm(): number {
|
||||
let left = parseFactor();
|
||||
while (true) {
|
||||
const token = peek();
|
||||
if (token?.type === "MULTIPLY") {
|
||||
consume();
|
||||
left = left * parseFactor();
|
||||
} else if (token?.type === "DIVIDE") {
|
||||
consume();
|
||||
const right = parseFactor();
|
||||
if (right === 0) throw new Error("Division by zero");
|
||||
left = left / right;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
function parseFactor(): number {
|
||||
const token = peek();
|
||||
if (!token) throw new Error("Unexpected end of expression");
|
||||
|
||||
if (token.type === "NUMBER") {
|
||||
consume();
|
||||
return token.value;
|
||||
}
|
||||
|
||||
if (token.type === "MINUS") {
|
||||
consume();
|
||||
return -parseFactor();
|
||||
}
|
||||
|
||||
if (token.type === "LPAREN") {
|
||||
consume();
|
||||
const value = parseExpressionLevel();
|
||||
const next = peek();
|
||||
if (next?.type !== "RPAREN") {
|
||||
throw new Error("Missing closing parenthesis");
|
||||
}
|
||||
consume();
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected token: ${token.type}`);
|
||||
}
|
||||
|
||||
const result = parseExpressionLevel();
|
||||
|
||||
if (index !== tokens.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(result)) return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
type Token =
|
||||
| { type: "NUMBER"; value: number }
|
||||
| { type: "PLUS" }
|
||||
| { type: "MINUS" }
|
||||
| { type: "MULTIPLY" }
|
||||
| { type: "DIVIDE" }
|
||||
| { type: "LPAREN" }
|
||||
| { type: "RPAREN" };
|
||||
|
||||
function tokenize(input: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
const str = input.trim();
|
||||
|
||||
while (i < str.length) {
|
||||
const char = str[i];
|
||||
|
||||
if (/\s/.test(char)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\d/.test(char)) {
|
||||
let numStr = "";
|
||||
while (i < str.length && (/\d/.test(str[i]) || str[i] === ".")) {
|
||||
numStr += str[i];
|
||||
i++;
|
||||
}
|
||||
const value = Number(numStr);
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error(`Invalid number: ${numStr}`);
|
||||
}
|
||||
tokens.push({ type: "NUMBER", value });
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (char) {
|
||||
case "+":
|
||||
tokens.push({ type: "PLUS" });
|
||||
break;
|
||||
case "-":
|
||||
tokens.push({ type: "MINUS" });
|
||||
break;
|
||||
case "*":
|
||||
tokens.push({ type: "MULTIPLY" });
|
||||
break;
|
||||
case "/":
|
||||
tokens.push({ type: "DIVIDE" });
|
||||
break;
|
||||
case "(":
|
||||
tokens.push({ type: "LPAREN" });
|
||||
break;
|
||||
case ")":
|
||||
tokens.push({ type: "RPAREN" });
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid character: ${char}`);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
export function clamp({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
export function clampRound({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}): number {
|
||||
return Math.round(clamp({ value, min, max }));
|
||||
}
|
||||
|
||||
function getFractionDigitsForStep({ step }: { step: number }): number {
|
||||
const normalizedStep = step.toString().toLowerCase();
|
||||
if (normalizedStep.includes("e-")) {
|
||||
return Number(normalizedStep.split("e-")[1] ?? 0);
|
||||
}
|
||||
const [, fractionalPart = ""] = normalizedStep.split(".");
|
||||
return fractionalPart.length;
|
||||
}
|
||||
|
||||
export function snapToStep({
|
||||
value,
|
||||
step,
|
||||
}: {
|
||||
value: number;
|
||||
step: number;
|
||||
}): number {
|
||||
if (step <= 0) return value;
|
||||
const snappedValue = Math.round(value / step) * step;
|
||||
return Number(
|
||||
snappedValue.toFixed(getFractionDigitsForStep({ step })),
|
||||
);
|
||||
}
|
||||
|
||||
export function isNearlyEqual({
|
||||
leftValue,
|
||||
rightValue,
|
||||
epsilon = 0.0001,
|
||||
}: {
|
||||
leftValue: number;
|
||||
rightValue: number;
|
||||
epsilon?: number;
|
||||
}): boolean {
|
||||
return Math.abs(leftValue - rightValue) <= epsilon;
|
||||
}
|
||||
|
||||
export function formatNumberForDisplay({
|
||||
value,
|
||||
maxFractionDigits = 6,
|
||||
}: {
|
||||
value: number;
|
||||
maxFractionDigits?: number;
|
||||
}): string {
|
||||
return Number(value.toFixed(maxFractionDigits)).toString();
|
||||
}
|
||||
|
||||
export function evaluateMathExpression({
|
||||
input,
|
||||
}: {
|
||||
input: string;
|
||||
}): number | null {
|
||||
const sanitized = input.trim();
|
||||
if (!/^[\d.\s+\-*/()]+$/.test(sanitized)) return null;
|
||||
try {
|
||||
const result = new Function(`return (${sanitized})`)();
|
||||
if (typeof result !== "number" || !Number.isFinite(result)) return null;
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -24,7 +24,9 @@
|
||||
"@hugeicons/core-free-icons": "^3.1.1",
|
||||
"@hugeicons/react": "^1.1.6",
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@opennextjs/cloudflare": "^1.18.0",
|
||||
"@opencut/env": "workspace:*",
|
||||
"@opencut/ui": "workspace:*",
|
||||
"@opennextjs/cloudflare": "^1.17.3",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
@@ -54,7 +56,6 @@
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"opencut-wasm": "workspace:*",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -78,7 +79,7 @@
|
||||
"unified": "^11.0.5",
|
||||
"use-deep-compare-effect": "^1.8.1",
|
||||
"wavesurfer.js": "^7.9.8",
|
||||
"zod": "4.3.6",
|
||||
"zod": "^3.25.67",
|
||||
"zustand": "^5.0.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -103,9 +104,31 @@
|
||||
"wrangler": "^4.77.0",
|
||||
},
|
||||
},
|
||||
"rust/wasm/pkg": {
|
||||
"name": "opencut-wasm",
|
||||
"version": "0.1.0",
|
||||
"packages/env": {
|
||||
"name": "@opencut/env",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"zod": "^4.0.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^24.2.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"typescript": "^5.8.3",
|
||||
},
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencut/ui",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"react": "^19.2.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.8.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
@@ -362,6 +385,10 @@
|
||||
|
||||
"@huggingface/transformers": ["@huggingface/transformers@3.8.1", "", { "dependencies": { "@huggingface/jinja": "^0.5.3", "onnxruntime-node": "1.21.0", "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", "sharp": "^0.34.1" } }, "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA=="],
|
||||
|
||||
"@iconify/react": ["@iconify/react@6.0.2", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg=="],
|
||||
|
||||
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
@@ -482,11 +509,15 @@
|
||||
|
||||
"@node-minify/utils": ["@node-minify/utils@8.0.6", "", { "dependencies": { "gzip-size": "6.0.0" } }, "sha512-csY4qcR7jUwiZmkreNTJhcypQfts2aY2CK+a+rXgXUImZiZiySh0FvwHjRnlqWKvg+y6ae9lHFzDRjBTmqlTIQ=="],
|
||||
|
||||
"@opencut/env": ["@opencut/env@workspace:packages/env"],
|
||||
|
||||
"@opencut/ui": ["@opencut/ui@workspace:packages/ui"],
|
||||
|
||||
"@opencut/web": ["@opencut/web@workspace:apps/web"],
|
||||
|
||||
"@opennextjs/aws": ["@opennextjs/aws@3.9.16", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5" }, "bin": { "open-next": "dist/index.js" } }, "sha512-jQQStCysIllNCPqz5W2KSguXpr+ETlOcD8SyNu+h9zwpRVYk4uEPQge+ErG3avI5xsT8vKA7EGLYG59dhj/B6Q=="],
|
||||
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.18.0", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.9.16", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5", "wrangler": "^4.65.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-JM236YHnKzroFAZqst1t28ZGOShvnkVUDtjrp7TJ/W2P3RLo4b6npJ8VEXOn6frs6lsUfR5rvsKYLYb7h1GIJQ=="],
|
||||
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.17.3", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.9.16", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5", "wrangler": "^4.65.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-BQT8R/CEqrZS2sYydJ6uqv3U+yZAxbTeDUfgqQIlOnhu3wDPsB/QuuUBBseWOZiOdZ8gLegK6F2SLKia74Nv6g=="],
|
||||
|
||||
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
|
||||
|
||||
@@ -1458,8 +1489,6 @@
|
||||
|
||||
"onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="],
|
||||
|
||||
"opencut-wasm": ["opencut-wasm@workspace:rust/wasm/pkg"],
|
||||
|
||||
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
@@ -1806,7 +1835,7 @@
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
|
||||
|
||||
"zustand": ["zustand@5.0.6", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A=="],
|
||||
|
||||
@@ -1822,8 +1851,6 @@
|
||||
|
||||
"@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
|
||||
|
||||
"@better-auth/core/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
|
||||
|
||||
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||
@@ -1844,6 +1871,8 @@
|
||||
|
||||
"@node-minify/terser/terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
|
||||
|
||||
"@opencut/web/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
|
||||
|
||||
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
@@ -1960,8 +1989,6 @@
|
||||
|
||||
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"better-auth/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
|
||||
|
||||
"body-parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"browserslist/caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
|
||||
|
||||
+1
-5
@@ -3,13 +3,9 @@
|
||||
"packageManager": "bun@1.2.18",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*",
|
||||
"rust/wasm/pkg"
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build:wasm": "wasm-pack build rust/wasm --target bundler --out-dir pkg",
|
||||
"publish:wasm": "bun run build:wasm && npm publish rust/wasm/pkg --access public",
|
||||
"dev:wasm": "cargo watch -w rust/crates -w rust/wasm/src -s 'wasm-pack build rust/wasm --target bundler --out-dir pkg'",
|
||||
"dev:web": "turbo run dev --filter=@opencut/web",
|
||||
"build:web": "turbo run build --filter=@opencut/web",
|
||||
"dev:tools": "turbo run dev --filter=@opencut/tools",
|
||||
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@opencut/env",
|
||||
"version": "0.0.0",
|
||||
"description": "Environment package for OpenCut",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./web": "./src/web.ts",
|
||||
"./tools": "./src/tools.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "^16.4.7",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^24.2.1",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -11,11 +11,10 @@ const webEnvSchema = z.object({
|
||||
NEXT_PUBLIC_MARBLE_API_URL: z.url(),
|
||||
|
||||
// Server
|
||||
DATABASE_URL: z.string().refine(
|
||||
(url) =>
|
||||
url.startsWith("postgres://") || url.startsWith("postgresql://"),
|
||||
"DATABASE_URL must be a postgres:// or postgresql:// URL",
|
||||
),
|
||||
DATABASE_URL: z
|
||||
.string()
|
||||
.startsWith("postgres://")
|
||||
.or(z.string().startsWith("postgresql://")),
|
||||
|
||||
BETTER_AUTH_SECRET: z.string(),
|
||||
UPSTASH_REDIS_REST_URL: z.url(),
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@opencut/ui",
|
||||
"version": "0.0.0",
|
||||
"description": "UI package for OpenCut",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
"./icons": "./src/icons/index.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { IconProps } from "./types";
|
||||
|
||||
export function OcVercelIcon({ className }: IconProps) {
|
||||
export function OcVercelIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
@@ -19,7 +17,10 @@ export function OcVercelIcon({ className }: IconProps) {
|
||||
export function OcMarbleIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
}: IconProps) {
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -42,7 +43,10 @@ export function OcMarbleIcon({
|
||||
export function OcDataBuddyIcon({
|
||||
className = "",
|
||||
size = 32,
|
||||
}: IconProps) {
|
||||
}: {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user