mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8918a87c33 | ||
|
|
f635403108 | ||
|
|
fc0ba2d37f | ||
|
|
1660f1c76c | ||
|
|
b89f016069 | ||
|
|
271cc27cbf | ||
|
|
b62833384d | ||
|
|
08f5b92c3d | ||
|
|
4aca4e7756 | ||
|
|
b7e6db90b0 | ||
|
|
fe623357ef | ||
|
|
5b3c048e20 | ||
|
|
5638974f4b | ||
|
|
a2580216f8 | ||
|
|
dd4e6780d8 |
@@ -45,9 +45,10 @@ Thank you for your interest in contributing to OpenCut! This document provides g
|
||||
5. Install dependencies: `bun install`
|
||||
6. Start the development server: `bun run dev`
|
||||
|
||||
> **Note:** Web development uses the published `opencut-wasm` package by default, so a fresh clone does not need a local WASM build.
|
||||
> **Note:** If you see an error like `Unsupported URL Type "workspace:*"` when running `npm install`, you have two options:
|
||||
>
|
||||
> If you are editing `rust/wasm`, run `bun run build:wasm`, then `cd rust/wasm/pkg && bun link`, then `cd ../../../apps/web && bun link opencut-wasm`.
|
||||
> 1. Upgrade to a recent npm version (v9 or later), which has full workspace protocol support.
|
||||
> 2. Use an alternative package manager such as **bun** or **pnpm**.
|
||||
|
||||
### Desktop setup
|
||||
|
||||
|
||||
@@ -31,31 +31,16 @@ jobs:
|
||||
MARBLE_WORKSPACE_KEY: "placeholder"
|
||||
FREESOUND_CLIENT_ID: "placeholder"
|
||||
FREESOUND_API_KEY: "placeholder"
|
||||
CLOUDFLARE_ACCOUNT_ID: "placeholder"
|
||||
R2_ACCESS_KEY_ID: "placeholder"
|
||||
R2_SECRET_ACCESS_KEY: "placeholder"
|
||||
R2_BUCKET_NAME: "placeholder"
|
||||
MODAL_TRANSCRIPTION_URL: "https://placeholder.example.com"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
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
+34
-644
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,4 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"apps/desktop",
|
||||
"rust/crates/time",
|
||||
"rust/crates/bridge",
|
||||
"rust/wasm", "rust/crates/gpu",
|
||||
]
|
||||
|
||||
@@ -1,166 +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.
|
||||
|
||||
### Local WASM development
|
||||
|
||||
Only needed if you're editing `rust/wasm` and want the web app to use your local build instead of the published package.
|
||||
|
||||
1. Build the package once from the repo root:
|
||||
|
||||
```bash
|
||||
bun run build:wasm
|
||||
```
|
||||
|
||||
2. Register the generated package for linking:
|
||||
|
||||
```bash
|
||||
cd rust/wasm/pkg
|
||||
bun link
|
||||
```
|
||||
|
||||
3. Link `apps/web` to the local package:
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
bun link opencut-wasm
|
||||
```
|
||||
|
||||
4. Rebuild on changes while you work:
|
||||
|
||||
```bash
|
||||
bun dev:wasm
|
||||
```
|
||||
|
||||
To switch `apps/web` back to the published package, run:
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
bun add opencut-wasm
|
||||
```
|
||||
|
||||
### Self-Hosting with Docker
|
||||
|
||||
To run everything (including a production build of the app) in Docker:
|
||||
|
||||
```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)
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
@@ -19,3 +19,10 @@ MARBLE_WORKSPACE_KEY=your_workspace_key_here
|
||||
|
||||
FREESOUND_CLIENT_ID=your_client_id_here
|
||||
FREESOUND_API_KEY=your_api_key_here
|
||||
|
||||
CLOUDFLARE_ACCOUNT_ID=your_account_id_here
|
||||
R2_ACCESS_KEY_ID=your_access_key_here
|
||||
R2_SECRET_ACCESS_KEY=your_secret_key_here
|
||||
R2_BUCKET_NAME=opencut-transcription # whatever you named your r2 bucket
|
||||
|
||||
MODAL_TRANSCRIPTION_URL=your_modal_url_here
|
||||
+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
|
||||
@@ -29,6 +34,11 @@ ENV UPSTASH_REDIS_REST_TOKEN="example_token"
|
||||
ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||
ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL
|
||||
ENV MARBLE_WORKSPACE_KEY=$MARBLE_WORKSPACE_KEY
|
||||
ENV CLOUDFLARE_ACCOUNT_ID="build-placeholder"
|
||||
ENV R2_ACCESS_KEY_ID="build-placeholder"
|
||||
ENV R2_SECRET_ACCESS_KEY="build-placeholder"
|
||||
ENV R2_BUCKET_NAME="build-placeholder"
|
||||
ENV MODAL_TRANSCRIPTION_URL="http://localhost:0"
|
||||
|
||||
ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID
|
||||
ENV FREESOUND_API_KEY=$FREESOUND_API_KEY
|
||||
|
||||
@@ -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,8 +1,19 @@
|
||||
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: {
|
||||
rules: {
|
||||
"*.glsl": {
|
||||
loaders: [require.resolve("raw-loader")],
|
||||
as: "*.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||
|
||||
export default defineCloudflareConfig();
|
||||
@@ -22,7 +22,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",
|
||||
@@ -52,7 +54,6 @@
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"opencut-wasm": "^0.1.3",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -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": {
|
||||
@@ -94,6 +95,7 @@
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"postcss": "^8",
|
||||
"raw-loader": "^4.0.2",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.8.3",
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
@@ -257,7 +257,7 @@ function MediaAssetDraggable({
|
||||
startTime: number;
|
||||
}) => {
|
||||
const duration =
|
||||
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
||||
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: asset.id,
|
||||
mediaType: asset.type,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import {
|
||||
BACKGROUND_BLUR_INTENSITY_PRESETS,
|
||||
DEFAULT_BACKGROUND_BLUR_INTENSITY,
|
||||
DEFAULT_BACKGROUND_COLOR,
|
||||
} from "@/lib/background/constants";
|
||||
BLUR_INTENSITY_PRESETS,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_COLOR,
|
||||
} from "@/constants/project-constants";
|
||||
import { patternCraftGradients } from "@/data/colors/pattern-craft";
|
||||
import { colors } from "@/data/colors/solid";
|
||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
@@ -164,16 +164,16 @@ export function BackgroundContent() {
|
||||
const currentBlurIntensity = isBlurBackground
|
||||
? (activeProject.settings.background as { blurIntensity: number })
|
||||
.blurIntensity
|
||||
: DEFAULT_BACKGROUND_BLUR_INTENSITY;
|
||||
: DEFAULT_BLUR_INTENSITY;
|
||||
|
||||
const currentBackgroundColor = isColorBackground
|
||||
? (activeProject.settings.background as { color: string }).color
|
||||
: DEFAULT_BACKGROUND_COLOR;
|
||||
: DEFAULT_COLOR;
|
||||
const canvasSize = activeProject.settings.canvasSize;
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
BACKGROUND_BLUR_INTENSITY_PRESETS.map((blur) => (
|
||||
BLUR_INTENSITY_PRESETS.map((blur) => (
|
||||
<BlurPreview
|
||||
key={blur.value}
|
||||
blur={blur}
|
||||
|
||||
@@ -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 "@/lib/fps/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;
|
||||
|
||||
@@ -8,7 +8,6 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { resolveStickerIntrinsicSize } from "@/lib/stickers";
|
||||
import {
|
||||
@@ -71,25 +70,41 @@ export function StickersView() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={selectedCategory}
|
||||
onValueChange={(value) => {
|
||||
setSelectedCategory({ category: value as StickerCategory });
|
||||
}}
|
||||
variant="underline"
|
||||
className="mt-2 flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<TabsList aria-label="Sticker categories">
|
||||
{Object.entries(STICKER_CATEGORIES).map(([key, label]) => (
|
||||
<TabsTrigger key={key} value={key}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<div className="mt-2 flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b border-border px-2">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Sticker categories"
|
||||
className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0"
|
||||
>
|
||||
{Object.entries(STICKER_CATEGORIES).map(([key, label]) => {
|
||||
const isActive = key === selectedCategory;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() =>
|
||||
setSelectedCategory({
|
||||
category: key as StickerCategory,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px",
|
||||
isActive && "text-primary border-primary",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
|
||||
<StickersContentView />
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
screenToCanvas,
|
||||
} from "@/lib/preview/preview-coords";
|
||||
import { clamp, isNearlyEqual } from "@/utils/math";
|
||||
import { PREVIEW_ZOOM } from "@/lib/preview/constants";
|
||||
import { PREVIEW_ZOOM } from "@/constants/editor-constants";
|
||||
|
||||
const MIDDLE_MOUSE_BUTTON = 1;
|
||||
const IS_AT_FIT_EPSILON = 0.001;
|
||||
|
||||
@@ -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 "@/lib/preview/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({
|
||||
|
||||
@@ -9,7 +9,8 @@ export function useElementPlayhead({
|
||||
startTime: number;
|
||||
duration: number;
|
||||
}) {
|
||||
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
|
||||
const editor = useEditor();
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: playheadTime,
|
||||
elementStartTime: startTime,
|
||||
|
||||
+148
-150
@@ -1,150 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
buildGraphicParamPath,
|
||||
coerceAnimationValueForParam,
|
||||
getKeyframeAtTime,
|
||||
getParamDefaultInterpolation,
|
||||
getParamValueKind,
|
||||
hasKeyframesForPath,
|
||||
upsertPathKeyframe,
|
||||
} from "@/lib/animation";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
|
||||
export interface KeyframedParamPropertyResult {
|
||||
hasAnimatedKeyframes: boolean;
|
||||
isKeyframedAtTime: boolean;
|
||||
keyframeIdAtTime: string | null;
|
||||
onPreview: (value: number | string | boolean) => void;
|
||||
onCommit: () => void;
|
||||
toggleKeyframe: () => void;
|
||||
}
|
||||
|
||||
export function useKeyframedParamProperty({
|
||||
param,
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue,
|
||||
buildBaseUpdates,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedValue: number | string | boolean;
|
||||
buildBaseUpdates: ({
|
||||
value,
|
||||
}: {
|
||||
value: number | string | boolean;
|
||||
}) => Partial<TimelineElement>;
|
||||
}): KeyframedParamPropertyResult {
|
||||
const editor = useEditor();
|
||||
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
});
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
})
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
|
||||
if (shouldUseAnimatedChannel) {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
animations: upsertPathKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value,
|
||||
kind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({
|
||||
param,
|
||||
}),
|
||||
coerceValue: (nextValue) =>
|
||||
coerceAnimationValueForParam({
|
||||
param,
|
||||
value: nextValue,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: buildBaseUpdates({ value }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId: keyframeIdAtTime,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: resolvedValue,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
hasAnimatedKeyframes,
|
||||
isKeyframedAtTime,
|
||||
keyframeIdAtTime,
|
||||
onPreview: previewValue,
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
toggleKeyframe,
|
||||
};
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
buildGraphicParamPath,
|
||||
getKeyframeAtTime,
|
||||
getParamDefaultInterpolation,
|
||||
getParamValueKind,
|
||||
hasKeyframesForPath,
|
||||
upsertPathKeyframe,
|
||||
} from "@/lib/animation";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
import type { ParamDefinition } from "@/lib/params";
|
||||
import type { TimelineElement } from "@/lib/timeline";
|
||||
|
||||
export interface KeyframedParamPropertyResult {
|
||||
hasAnimatedKeyframes: boolean;
|
||||
isKeyframedAtTime: boolean;
|
||||
keyframeIdAtTime: string | null;
|
||||
onPreview: (value: number | string | boolean) => void;
|
||||
onCommit: () => void;
|
||||
toggleKeyframe: () => void;
|
||||
}
|
||||
|
||||
export function useKeyframedParamProperty({
|
||||
param,
|
||||
trackId,
|
||||
elementId,
|
||||
animations,
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue,
|
||||
buildBaseUpdates,
|
||||
}: {
|
||||
param: ParamDefinition;
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
animations: ElementAnimations | undefined;
|
||||
localTime: number;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedValue: number | string | boolean;
|
||||
buildBaseUpdates: ({
|
||||
value,
|
||||
}: {
|
||||
value: number | string | boolean;
|
||||
}) => Partial<TimelineElement>;
|
||||
}): KeyframedParamPropertyResult {
|
||||
const editor = useEditor();
|
||||
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
|
||||
const hasAnimatedKeyframes = hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
});
|
||||
const keyframeAtTime = isPlayheadWithinElementRange
|
||||
? getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
})
|
||||
: null;
|
||||
const keyframeIdAtTime = keyframeAtTime?.id ?? null;
|
||||
const isKeyframedAtTime = keyframeAtTime !== null;
|
||||
const shouldUseAnimatedChannel =
|
||||
hasAnimatedKeyframes && isPlayheadWithinElementRange;
|
||||
|
||||
const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
|
||||
if (shouldUseAnimatedChannel) {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
animations: upsertPathKeyframe({
|
||||
animations,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value,
|
||||
valueKind: getParamValueKind({ param }),
|
||||
defaultInterpolation: getParamDefaultInterpolation({
|
||||
param,
|
||||
}),
|
||||
numericRange:
|
||||
param.type === "number"
|
||||
? { min: param.min, max: param.max, step: param.step }
|
||||
: undefined,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: buildBaseUpdates({ value }),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const toggleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyframeIdAtTime) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
keyframeId: keyframeIdAtTime,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
time: localTime,
|
||||
value: resolvedValue,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
hasAnimatedKeyframes,
|
||||
isKeyframedAtTime,
|
||||
keyframeIdAtTime,
|
||||
onPreview: previewValue,
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
toggleKeyframe,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,18 +1,9 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
|
||||
import { isSourceAudioSeparated } from "@/lib/timeline/audio-separation";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import {
|
||||
clamp,
|
||||
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 { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
import { KeyframeToggle } from "../components/keyframe-toggle";
|
||||
@@ -27,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,
|
||||
@@ -37,7 +25,6 @@ export function AudioTab({
|
||||
element: AudioElement | VideoElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
@@ -55,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,
|
||||
}),
|
||||
@@ -84,68 +64,46 @@ export function AudioTab({
|
||||
rightValue: DEFAULTS.element.volume,
|
||||
})
|
||||
: (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
|
||||
const isSeparated =
|
||||
element.type === "video" && isSourceAudioSeparated({ element });
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSeparated && (
|
||||
<div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
|
||||
<p className="text-sm">Audio has been separated.</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
editor.timeline.toggleSourceAudioSeparation({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
})
|
||||
<Section collapsible sectionKey={`${element.id}:audio`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Audio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField
|
||||
label="Volume"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={volume.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle volume keyframe"
|
||||
onToggle={volume.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Recover audio
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Section collapsible sectionKey={`${element.id}:audio`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Audio</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<SectionField
|
||||
label="Volume"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={volume.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle volume keyframe"
|
||||
onToggle={volume.toggleKeyframe}
|
||||
/>
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
|
||||
value={volume.displayValue}
|
||||
onFocus={volume.onFocus}
|
||||
onChange={volume.onChange}
|
||||
onBlur={volume.onBlur}
|
||||
dragSensitivity="slow"
|
||||
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
|
||||
onScrub={volume.scrubTo}
|
||||
onScrubEnd={volume.commitScrub}
|
||||
onReset={() =>
|
||||
volume.commitValue({
|
||||
value: DEFAULTS.element.volume,
|
||||
})
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={VolumeHighIcon} />}
|
||||
value={volume.displayValue}
|
||||
onFocus={volume.onFocus}
|
||||
onChange={volume.onChange}
|
||||
onBlur={volume.onBlur}
|
||||
dragSensitivity="slow"
|
||||
scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
|
||||
onScrub={volume.scrubTo}
|
||||
onScrubEnd={volume.commitScrub}
|
||||
onReset={() =>
|
||||
volume.commitValue({
|
||||
value: DEFAULTS.element.volume,
|
||||
})
|
||||
}
|
||||
isDefault={isDefault}
|
||||
suffix="dB"
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</>
|
||||
isDefault={isDefault}
|
||||
suffix="dB"
|
||||
/>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,230 +1,230 @@
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { OcCheckerboardIcon } from "@/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
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
MAX_RETIME_RATE,
|
||||
clampRetimeRate,
|
||||
canMaintainPitch,
|
||||
} from "@/lib/retime/rate";
|
||||
} from "@/constants/retime-constants";
|
||||
import type { AudioElement, VideoElement } from "@/lib/timeline";
|
||||
import {
|
||||
Section,
|
||||
@@ -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
@@ -6,11 +6,11 @@ import { useEditor } from "@/hooks/use-editor";
|
||||
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
|
||||
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
|
||||
import {
|
||||
DEFAULT_TIMELINE_BOOKMARK_COLOR,
|
||||
} from "./theme";
|
||||
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout";
|
||||
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
||||
import { getSnappedSeekTime } from "opencut-wasm";
|
||||
DEFAULT_BOOKMARK_COLOR,
|
||||
TIMELINE_BOOKMARK_ROW_HEIGHT,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||
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,
|
||||
@@ -83,12 +83,12 @@ export function TimelineBookmarksRow({
|
||||
return (
|
||||
<div
|
||||
className="relative flex-1 overflow-hidden"
|
||||
style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX }}
|
||||
style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT }}
|
||||
>
|
||||
<button
|
||||
className="relative w-full cursor-default select-none border-0 bg-transparent p-0"
|
||||
style={{
|
||||
height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX,
|
||||
height: TIMELINE_BOOKMARK_ROW_HEIGHT,
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
aria-label="Timeline ruler"
|
||||
@@ -153,7 +153,7 @@ function TimelineBookmark({
|
||||
const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel });
|
||||
const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
|
||||
const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
|
||||
const iconColor = bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR;
|
||||
const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR;
|
||||
|
||||
const handleSeek = () => seekToBookmarkTime({ editor, time });
|
||||
|
||||
@@ -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}
|
||||
@@ -298,16 +298,12 @@ function BookmarkPopoverContent({
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const [draftColorHex, setDraftColorHex] = useState(
|
||||
(bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
|
||||
.replace("#", "")
|
||||
.toUpperCase(),
|
||||
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftColorHex(
|
||||
(bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
|
||||
.replace("#", "")
|
||||
.toUpperCase(),
|
||||
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
|
||||
);
|
||||
}, [bookmark.color]);
|
||||
|
||||
@@ -361,7 +357,7 @@ function BookmarkPopoverContent({
|
||||
/>
|
||||
{bookmark.color &&
|
||||
bookmark.color.replace(/^#/, "").toUpperCase() !==
|
||||
DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
|
||||
DEFAULT_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getDropLineY } from "./drop-target";
|
||||
import { getDropLineY } from "@/lib/timeline/drop-utils";
|
||||
import type { TimelineTrack, DropTarget } from "@/lib/timeline";
|
||||
import { TIMELINE_LAYERS } from "./layers";
|
||||
import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
|
||||
|
||||
interface DragLineProps {
|
||||
dropTarget: DropTarget | null;
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
|
||||
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
|
||||
import { resolveTrackPlacement } from "@/lib/timeline/placement";
|
||||
import { TIMELINE_TRACK_GAP_PX } from "./layout";
|
||||
import { getTrackHeight } from "./track-layout";
|
||||
|
||||
function findElementAtPosition({
|
||||
mouseX,
|
||||
tracks,
|
||||
trackIndex,
|
||||
targetElementTypes,
|
||||
pixelsPerSecond,
|
||||
zoomLevel,
|
||||
}: {
|
||||
mouseX: number;
|
||||
tracks: TimelineTrack[];
|
||||
trackIndex: number;
|
||||
targetElementTypes: string[];
|
||||
pixelsPerSecond: number;
|
||||
zoomLevel: number;
|
||||
}): { elementId: string; trackId: string } | null {
|
||||
const time = mouseX / (pixelsPerSecond * zoomLevel);
|
||||
const track = tracks[trackIndex];
|
||||
if (!track || !("elements" in track)) return null;
|
||||
|
||||
const hit = track.elements.find(
|
||||
(element: TimelineElement) =>
|
||||
targetElementTypes.includes(element.type) &&
|
||||
element.startTime <= time &&
|
||||
time < element.startTime + element.duration,
|
||||
);
|
||||
if (!hit) return null;
|
||||
return { elementId: hit.id, trackId: track.id };
|
||||
}
|
||||
|
||||
function getTrackAtY({
|
||||
mouseY,
|
||||
tracks,
|
||||
verticalDragDirection,
|
||||
}: {
|
||||
mouseY: number;
|
||||
tracks: TimelineTrack[];
|
||||
verticalDragDirection?: "up" | "down" | null;
|
||||
}): { trackIndex: number; relativeY: number } | null {
|
||||
let cumulativeHeight = 0;
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const trackHeight = getTrackHeight({ type: tracks[i].type });
|
||||
const trackTop = cumulativeHeight;
|
||||
const trackBottom = trackTop + trackHeight;
|
||||
|
||||
if (mouseY >= trackTop && mouseY < trackBottom) {
|
||||
return {
|
||||
trackIndex: i,
|
||||
relativeY: mouseY - trackTop,
|
||||
};
|
||||
}
|
||||
|
||||
if (i < tracks.length - 1 && verticalDragDirection) {
|
||||
const gapTop = trackBottom;
|
||||
const gapBottom = gapTop + TIMELINE_TRACK_GAP_PX;
|
||||
if (mouseY >= gapTop && mouseY < gapBottom) {
|
||||
const isDraggingUp = verticalDragDirection === "up";
|
||||
return {
|
||||
trackIndex: isDraggingUp ? i : i + 1,
|
||||
relativeY: isDraggingUp ? trackHeight - 1 : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
cumulativeHeight += trackHeight + TIMELINE_TRACK_GAP_PX;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const EMPTY_TARGET_ELEMENT = null;
|
||||
|
||||
function fallbackNewTrackDropTarget({
|
||||
xPosition,
|
||||
}: {
|
||||
xPosition: number;
|
||||
}): DropTarget {
|
||||
return {
|
||||
trackIndex: 0,
|
||||
isNewTrack: true,
|
||||
insertPosition: null,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeDropTarget({
|
||||
elementType,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks,
|
||||
playheadTime,
|
||||
isExternalDrop,
|
||||
elementDuration,
|
||||
pixelsPerSecond,
|
||||
zoomLevel,
|
||||
verticalDragDirection,
|
||||
startTimeOverride,
|
||||
excludeElementId,
|
||||
targetElementTypes,
|
||||
}: ComputeDropTargetParams): DropTarget {
|
||||
const xPosition =
|
||||
typeof startTimeOverride === "number"
|
||||
? startTimeOverride
|
||||
: isExternalDrop
|
||||
? playheadTime
|
||||
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
|
||||
|
||||
if (tracks.length === 0) {
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: 0,
|
||||
hoverDirection: "below",
|
||||
createNewTrackOnly: true,
|
||||
},
|
||||
});
|
||||
const emptyTimelineResult =
|
||||
placementResult?.kind === "newTrack" ? placementResult : null;
|
||||
if (!emptyTimelineResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: emptyTimelineResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: emptyTimelineResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
|
||||
|
||||
if (!trackAtMouse) {
|
||||
const isAboveAllTracks = mouseY < 0;
|
||||
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
|
||||
hoverDirection: isAboveAllTracks ? "above" : "below",
|
||||
createNewTrackOnly: true,
|
||||
},
|
||||
});
|
||||
const outOfBoundsResult =
|
||||
placementResult?.kind === "newTrack" ? placementResult : null;
|
||||
if (!outOfBoundsResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: outOfBoundsResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: outOfBoundsResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
const { trackIndex, relativeY } = trackAtMouse;
|
||||
const track = tracks[trackIndex];
|
||||
|
||||
if (targetElementTypes && targetElementTypes.length > 0) {
|
||||
const targetElement = findElementAtPosition({
|
||||
mouseX,
|
||||
tracks,
|
||||
trackIndex,
|
||||
targetElementTypes,
|
||||
pixelsPerSecond,
|
||||
zoomLevel,
|
||||
});
|
||||
if (targetElement) {
|
||||
return {
|
||||
trackIndex,
|
||||
isNewTrack: false,
|
||||
insertPosition: null,
|
||||
xPosition,
|
||||
targetElement,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const trackHeight = getTrackHeight({ type: track.type });
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks,
|
||||
elementType,
|
||||
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
|
||||
strategy: {
|
||||
type: "preferIndex",
|
||||
trackIndex,
|
||||
hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
|
||||
verticalDragDirection,
|
||||
},
|
||||
});
|
||||
if (!placementResult) {
|
||||
return fallbackNewTrackDropTarget({ xPosition });
|
||||
}
|
||||
|
||||
if (placementResult.kind === "existingTrack") {
|
||||
const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition;
|
||||
|
||||
return {
|
||||
trackIndex: placementResult.trackIndex,
|
||||
isNewTrack: false,
|
||||
insertPosition: null,
|
||||
xPosition: adjustedXPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
trackIndex: placementResult.insertIndex,
|
||||
isNewTrack: true,
|
||||
insertPosition: placementResult.insertPosition,
|
||||
xPosition,
|
||||
targetElement: EMPTY_TARGET_ELEMENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDropLineY({
|
||||
dropTarget,
|
||||
tracks,
|
||||
}: {
|
||||
dropTarget: DropTarget;
|
||||
tracks: TimelineTrack[];
|
||||
}): number {
|
||||
const safeTrackIndex = Math.min(
|
||||
Math.max(dropTarget.trackIndex, 0),
|
||||
tracks.length,
|
||||
);
|
||||
let y = 0;
|
||||
|
||||
for (let i = 0; i < safeTrackIndex; i++) {
|
||||
y += getTrackHeight({ type: tracks[i].type }) + TIMELINE_TRACK_GAP_PX;
|
||||
}
|
||||
|
||||
return y;
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type PointerEvent } from "react";
|
||||
import { getBezierPoint } from "@/lib/animation/bezier";
|
||||
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const GRAPH_WIDTH = 140;
|
||||
const GRAPH_HEIGHT = 94;
|
||||
const GRAPH_PADDING = 12;
|
||||
const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2;
|
||||
const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2;
|
||||
const HANDLE_RADIUS = 3.5;
|
||||
const ENDPOINT_RADIUS = 2;
|
||||
const SNAP_THRESHOLD = 0.06;
|
||||
const SNAP_TARGETS = [0, 1];
|
||||
const CURVE_SEGMENTS = 64;
|
||||
const Y_CLAMP_MIN = -0.5;
|
||||
const Y_CLAMP_MAX = 1.5;
|
||||
|
||||
type BezierHandle = "c1" | "c2";
|
||||
|
||||
export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT;
|
||||
|
||||
function snap({
|
||||
value,
|
||||
targets,
|
||||
isEnabled,
|
||||
}: {
|
||||
value: number;
|
||||
targets: number[];
|
||||
isEnabled: boolean;
|
||||
}) {
|
||||
if (!isEnabled) return value;
|
||||
for (const target of targets) {
|
||||
if (Math.abs(value - target) < SNAP_THRESHOLD) return target;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function toSvgX({ value }: { value: number }) {
|
||||
return GRAPH_PADDING + value * GRAPH_WIDTH;
|
||||
}
|
||||
|
||||
function toSvgY({ value }: { value: number }) {
|
||||
return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT;
|
||||
}
|
||||
|
||||
function fromSvgX({ svgX }: { svgX: number }) {
|
||||
return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH));
|
||||
}
|
||||
|
||||
function fromSvgY({ svgY }: { svgY: number }) {
|
||||
return Math.max(
|
||||
Y_CLAMP_MIN,
|
||||
Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT),
|
||||
);
|
||||
}
|
||||
|
||||
function curvePath({ curve }: { curve: NormalizedCubicBezier }) {
|
||||
const points: string[] = [];
|
||||
for (let i = 0; i <= CURVE_SEGMENTS; i++) {
|
||||
const progress = i / CURVE_SEGMENTS;
|
||||
points.push(
|
||||
`${toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) })},${toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) })}`,
|
||||
);
|
||||
}
|
||||
return `M${points.join("L")}`;
|
||||
}
|
||||
|
||||
function clampHandleY({ svgY }: { svgY: number }) {
|
||||
return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY));
|
||||
}
|
||||
|
||||
export function BezierGraph({
|
||||
value,
|
||||
onChange,
|
||||
onChangeEnd,
|
||||
onCancel,
|
||||
}: {
|
||||
value: NormalizedCubicBezier;
|
||||
onChange?: (value: NormalizedCubicBezier) => void;
|
||||
onChangeEnd?: (value: NormalizedCubicBezier) => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null);
|
||||
const isShiftPressedRef = useRef(false);
|
||||
const latestValueRef = useRef(value);
|
||||
|
||||
latestValueRef.current = value;
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Shift") isShiftPressedRef.current = true;
|
||||
};
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === "Shift") isShiftPressedRef.current = false;
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("keyup", onKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function getPointerPosition({
|
||||
event,
|
||||
}: {
|
||||
event: PointerEvent;
|
||||
}): { x: number; y: number } {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return { x: 0, y: 0 };
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const scale = SVG_WIDTH / rect.width;
|
||||
return {
|
||||
x: (event.clientX - rect.left) * scale,
|
||||
y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height),
|
||||
};
|
||||
}
|
||||
|
||||
function onHandlePointerDown({ handle }: { handle: BezierHandle }) {
|
||||
return (event: PointerEvent<SVGCircleElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setActiveHandle(handle);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
}
|
||||
|
||||
function onPointerMove({ event }: { event: PointerEvent<SVGSVGElement> }) {
|
||||
if (!activeHandle) return;
|
||||
const pointerPos = getPointerPosition({ event });
|
||||
const x = fromSvgX({ svgX: pointerPos.x });
|
||||
const y = snap({
|
||||
value: fromSvgY({ svgY: pointerPos.y }),
|
||||
targets: SNAP_TARGETS,
|
||||
isEnabled: !isShiftPressedRef.current,
|
||||
});
|
||||
const next: NormalizedCubicBezier = [...value];
|
||||
if (activeHandle === "c1") {
|
||||
next[0] = x;
|
||||
next[1] = y;
|
||||
} else {
|
||||
next[2] = x;
|
||||
next[3] = y;
|
||||
}
|
||||
latestValueRef.current = next;
|
||||
onChange?.(next);
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (!activeHandle) return;
|
||||
setActiveHandle(null);
|
||||
onChangeEnd?.(latestValueRef.current);
|
||||
}
|
||||
|
||||
function onPointerCancel() {
|
||||
if (!activeHandle) return;
|
||||
setActiveHandle(null);
|
||||
onCancel?.();
|
||||
}
|
||||
|
||||
const path = curvePath({ curve: value });
|
||||
const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) };
|
||||
const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) };
|
||||
const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) };
|
||||
const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) };
|
||||
const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) };
|
||||
const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) };
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
|
||||
className="bg-foreground/3 w-full cursor-crosshair select-none"
|
||||
onPointerMove={(event) => onPointerMove({ event })}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
>
|
||||
<title>Bezier curve editor</title>
|
||||
<line
|
||||
x1={p0.x}
|
||||
y1={p0.y}
|
||||
x2={p1.x}
|
||||
y2={p1.y}
|
||||
className="stroke-foreground/8"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<line
|
||||
x1={p0.x}
|
||||
y1={p0.y}
|
||||
x2={c1Clamped.x}
|
||||
y2={c1Clamped.y}
|
||||
className="stroke-primary/30"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<line
|
||||
x1={p1.x}
|
||||
y1={p1.y}
|
||||
x2={c2Clamped.x}
|
||||
y2={c2Clamped.y}
|
||||
className="stroke-primary/30"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
className="stroke-primary"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle
|
||||
cx={p0.x}
|
||||
cy={p0.y}
|
||||
r={ENDPOINT_RADIUS}
|
||||
className="fill-foreground/20"
|
||||
/>
|
||||
<circle
|
||||
cx={p1.x}
|
||||
cy={p1.y}
|
||||
r={ENDPOINT_RADIUS}
|
||||
className="fill-foreground/20"
|
||||
/>
|
||||
<circle
|
||||
cx={c1Clamped.x}
|
||||
cy={c1Clamped.y}
|
||||
r={HANDLE_RADIUS}
|
||||
className={cn(
|
||||
"fill-primary cursor-grab",
|
||||
activeHandle === "c1" && "cursor-grabbing",
|
||||
)}
|
||||
onPointerDown={onHandlePointerDown({ handle: "c1" })}
|
||||
/>
|
||||
<circle
|
||||
cx={c2Clamped.x}
|
||||
cy={c2Clamped.y}
|
||||
r={HANDLE_RADIUS}
|
||||
className={cn(
|
||||
"fill-primary cursor-grab",
|
||||
activeHandle === "c2" && "cursor-grabbing",
|
||||
)}
|
||||
onPointerDown={onHandlePointerDown({ handle: "c2" })}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Popover, PopoverContent } from "@/components/ui/popover";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
Delete02Icon,
|
||||
PlusSignIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { getBezierPoint } from "@/lib/animation/bezier";
|
||||
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||
import type { GraphEditorComponentOption } from "./session";
|
||||
import {
|
||||
BUILTIN_PRESETS,
|
||||
PRESET_MATCH_TOLERANCE,
|
||||
removePreset,
|
||||
savePreset,
|
||||
type EasingPreset,
|
||||
useCustomPresets,
|
||||
} from "./presets";
|
||||
import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph";
|
||||
|
||||
const COLLAPSED_MAX = 6;
|
||||
const THUMB_SEGMENTS = 24;
|
||||
const THUMB_WIDTH = 40;
|
||||
const THUMB_HEIGHT = 22;
|
||||
const THUMB_PADDING_X = 4;
|
||||
const THUMB_PADDING_Y = 3;
|
||||
const COLLAPSED_GRID_MAX_HEIGHT = 120;
|
||||
const EXPANDED_GRID_MAX_HEIGHT = 240;
|
||||
|
||||
export function GraphEditorPopover({
|
||||
children,
|
||||
side,
|
||||
open,
|
||||
onOpenChange,
|
||||
value,
|
||||
message,
|
||||
componentOptions,
|
||||
activeComponentKey,
|
||||
onActiveComponentKeyChange,
|
||||
onPreviewValue,
|
||||
onCommitValue,
|
||||
onCancelPreview,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
value: NormalizedCubicBezier | null;
|
||||
message: string;
|
||||
componentOptions: GraphEditorComponentOption[];
|
||||
activeComponentKey: string | null;
|
||||
onActiveComponentKeyChange?: (componentKey: string) => void;
|
||||
onPreviewValue?: (value: NormalizedCubicBezier) => void;
|
||||
onCommitValue?: (value: NormalizedCubicBezier) => void;
|
||||
onCancelPreview?: () => void;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const custom = useCustomPresets();
|
||||
const allPresets = [...BUILTIN_PRESETS, ...custom];
|
||||
const canEdit = value !== null;
|
||||
const activePresetId =
|
||||
value == null
|
||||
? null
|
||||
: (allPresets.find((preset) =>
|
||||
preset.value.every(
|
||||
(presetValue, index) =>
|
||||
Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE,
|
||||
),
|
||||
)?.id ?? null);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
onCancelPreview?.();
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<PopoverContent
|
||||
side={side}
|
||||
sideOffset={8}
|
||||
className="w-60 overflow-hidden px-0"
|
||||
>
|
||||
{componentOptions.length > 1 && (
|
||||
<div className="border-b px-3 py-2">
|
||||
<div className="bg-muted/40 inline-flex rounded-md p-0.5">
|
||||
{componentOptions.map((component) => (
|
||||
<button
|
||||
key={component.key}
|
||||
type="button"
|
||||
onClick={() => onActiveComponentKeyChange?.(component.key)}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-sm px-2 py-1 text-xs font-medium",
|
||||
activeComponentKey === component.key
|
||||
? "bg-background text-foreground shadow-xs"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{component.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-3">
|
||||
{value ? (
|
||||
<BezierGraph
|
||||
value={value}
|
||||
onChange={onPreviewValue}
|
||||
onChangeEnd={onCommitValue}
|
||||
onCancel={onCancelPreview}
|
||||
/>
|
||||
) : (
|
||||
<GraphEditorEmptyState message={message} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs variant="underline" defaultValue="presets" className="flex flex-col gap-2">
|
||||
<TabsList className="px-3">
|
||||
<TabsTrigger value="presets" className="text-xs">
|
||||
Presets
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="saved" className="text-xs">
|
||||
Saved
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="presets" className="px-3 pb-0">
|
||||
<ExpandableGrid
|
||||
isExpanded={isExpanded}
|
||||
shouldExpand={BUILTIN_PRESETS.length > COLLAPSED_MAX}
|
||||
onExpand={() => setIsExpanded(true)}
|
||||
>
|
||||
{BUILTIN_PRESETS.map((preset) => (
|
||||
<PresetItem
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isActive={activePresetId === preset.id}
|
||||
disabled={!canEdit}
|
||||
onSelect={() => onCommitValue?.(preset.value)}
|
||||
/>
|
||||
))}
|
||||
</ExpandableGrid>
|
||||
</TabsContent>
|
||||
<TabsContent value="saved" className="px-3">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{custom.map((preset) => (
|
||||
<PresetItem
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isActive={activePresetId === preset.id}
|
||||
disabled={!canEdit}
|
||||
onSelect={() => onCommitValue?.(preset.value)}
|
||||
onDelete={() => removePreset(preset.id)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => value && savePreset(value)}
|
||||
disabled={!canEdit}
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1",
|
||||
canEdit
|
||||
? "hover:bg-foreground/5 cursor-pointer"
|
||||
: "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
<div className="border-foreground/10 flex aspect-video w-full items-center justify-center rounded-sm border border-dashed">
|
||||
<HugeiconsIcon
|
||||
icon={PlusSignIcon}
|
||||
className="size-3.5 opacity-40"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function GraphEditorEmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{ minHeight: BEZIER_GRAPH_MIN_HEIGHT }}
|
||||
className="bg-muted/20 text-muted-foreground flex items-center justify-center rounded-sm border border-dashed px-3 text-center text-xs leading-relaxed"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandableGrid({
|
||||
children,
|
||||
isExpanded,
|
||||
shouldExpand,
|
||||
onExpand,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isExpanded: boolean;
|
||||
shouldExpand: boolean;
|
||||
onExpand: () => void;
|
||||
}) {
|
||||
const gridStyle = shouldExpand
|
||||
? isExpanded
|
||||
? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const }
|
||||
: { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="grid grid-cols-3 gap-1" style={gridStyle}>
|
||||
{children}
|
||||
</div>
|
||||
{!isExpanded && shouldExpand && (
|
||||
<div className="from-popover/0 to-popover absolute inset-x-0 bottom-0 flex h-8 items-center justify-center bg-linear-to-b">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5"
|
||||
onClick={onExpand}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDown01Icon}
|
||||
className="text-muted-foreground size-3"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetItem({
|
||||
preset,
|
||||
isActive,
|
||||
onSelect,
|
||||
onDelete,
|
||||
disabled,
|
||||
}: {
|
||||
preset: EasingPreset;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete?: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-center gap-1 rounded-sm px-1 py-1",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "hover:bg-foreground/5 cursor-pointer",
|
||||
isActive && "bg-primary/5! text-primary",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex aspect-video w-full items-center justify-center rounded-sm bg-foreground/5",
|
||||
isActive && "bg-primary/5!",
|
||||
)}
|
||||
>
|
||||
<CurveThumb value={preset.value} />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] leading-tight",
|
||||
isActive ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</span>
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute -right-0.5 -top-0.5 hidden size-4.5 rounded-full [&_svg]:size-3 group-hover:flex"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} />
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CurveThumb({ value }: { value: NormalizedCubicBezier }) {
|
||||
const points: string[] = [];
|
||||
for (let i = 0; i <= THUMB_SEGMENTS; i++) {
|
||||
const progress = i / THUMB_SEGMENTS;
|
||||
points.push(
|
||||
`${THUMB_PADDING_X + getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) * (THUMB_WIDTH - THUMB_PADDING_X * 2)},${THUMB_PADDING_Y + (1 - getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 })) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2)}`,
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
width={THUMB_WIDTH}
|
||||
height={THUMB_HEIGHT}
|
||||
viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`}
|
||||
>
|
||||
<title>Curve preset preview</title>
|
||||
<path
|
||||
d={`M${points.join("L")}`}
|
||||
fill="none"
|
||||
className="stroke-current"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||
|
||||
const STORAGE_KEY = "opencut:graph-editor-presets";
|
||||
export const PRESET_MATCH_TOLERANCE = 0.02;
|
||||
|
||||
export interface EasingPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
value: NormalizedCubicBezier;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
export const BUILTIN_PRESETS: EasingPreset[] = [
|
||||
{ id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] },
|
||||
{ id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] },
|
||||
{ id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] },
|
||||
{ id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] },
|
||||
{ id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] },
|
||||
{ id: "linear", label: "Linear", value: [0, 0, 1, 1] },
|
||||
];
|
||||
|
||||
let cachedPresets: EasingPreset[] | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function readFromStorage(): EasingPreset[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
// JSON.parse can throw if the stored value is corrupted
|
||||
return raw ? (JSON.parse(raw) as EasingPreset[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeToStorage(presets: EasingPreset[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
|
||||
}
|
||||
|
||||
function getSnapshot(): EasingPreset[] {
|
||||
cachedPresets ??= readFromStorage();
|
||||
return cachedPresets;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): EasingPreset[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
cachedPresets = null;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === STORAGE_KEY) notify();
|
||||
});
|
||||
}
|
||||
|
||||
export function useCustomPresets(): EasingPreset[] {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
export function savePreset(value: NormalizedCubicBezier): void {
|
||||
const current = getSnapshot();
|
||||
writeToStorage([
|
||||
...current,
|
||||
{
|
||||
id: `custom-${Date.now()}`,
|
||||
label: `Custom ${current.length + 1}`,
|
||||
value,
|
||||
isCustom: true,
|
||||
},
|
||||
]);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function removePreset(id: string): void {
|
||||
writeToStorage(getSnapshot().filter((preset) => preset.id !== id));
|
||||
notify();
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getCurveHandlesForNormalizedCubicBezier,
|
||||
getEditableScalarChannels,
|
||||
getNormalizedCubicBezierForScalarSegment,
|
||||
getScalarKeyframeContext,
|
||||
updateScalarKeyframeCurve,
|
||||
} from "@/lib/animation";
|
||||
import type {
|
||||
AnimationPath,
|
||||
ElementAnimations,
|
||||
NormalizedCubicBezier,
|
||||
ScalarCurveKeyframePatch,
|
||||
ScalarGraphKeyframeContext,
|
||||
SelectedKeyframeRef,
|
||||
} from "@/lib/animation/types";
|
||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||
|
||||
const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
|
||||
const FLAT_VALUE_EPSILON = 1e-6;
|
||||
const LINEAR_CURVE_EPSILON = 1e-6;
|
||||
|
||||
export type GraphEditorUnavailableReason =
|
||||
| "no-keyframe-selected"
|
||||
| "multiple-keyframes-selected"
|
||||
| "selected-element-missing"
|
||||
| "selected-element-has-no-animations"
|
||||
| "selected-keyframe-has-no-scalar-channel"
|
||||
| "selected-keyframe-missing-on-channel"
|
||||
| "selected-keyframe-has-no-next-segment"
|
||||
| "selected-segment-is-hold"
|
||||
| "selected-segment-is-flat";
|
||||
|
||||
export interface GraphEditorComponentOption {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface GraphEditorBaseSelectionState {
|
||||
componentOptions: GraphEditorComponentOption[];
|
||||
activeComponentKey: string | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GraphEditorUnavailableState
|
||||
extends GraphEditorBaseSelectionState {
|
||||
status: "unavailable";
|
||||
reason: GraphEditorUnavailableReason;
|
||||
}
|
||||
|
||||
export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
|
||||
status: "ready";
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: SelectedKeyframeRef["propertyPath"];
|
||||
keyframeId: string;
|
||||
element: TimelineElement;
|
||||
context: ScalarGraphKeyframeContext;
|
||||
cubicBezier: NormalizedCubicBezier;
|
||||
}
|
||||
|
||||
export type GraphEditorSelectionState =
|
||||
| GraphEditorUnavailableState
|
||||
| GraphEditorReadyState;
|
||||
|
||||
export interface GraphEditorCurvePatch {
|
||||
keyframeId: string;
|
||||
patch: ScalarCurveKeyframePatch;
|
||||
}
|
||||
|
||||
function createUnavailableState({
|
||||
reason,
|
||||
message,
|
||||
componentOptions = [],
|
||||
activeComponentKey = null,
|
||||
}: {
|
||||
reason: GraphEditorUnavailableReason;
|
||||
message: string;
|
||||
componentOptions?: GraphEditorComponentOption[];
|
||||
activeComponentKey?: string | null;
|
||||
}): GraphEditorUnavailableState {
|
||||
return {
|
||||
status: "unavailable",
|
||||
reason,
|
||||
message,
|
||||
componentOptions,
|
||||
activeComponentKey,
|
||||
};
|
||||
}
|
||||
|
||||
function findElementByKeyframe({
|
||||
tracks,
|
||||
keyframe,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
keyframe: SelectedKeyframeRef;
|
||||
}): { element: TimelineElement; trackId: string; elementId: string } | null {
|
||||
for (const track of tracks) {
|
||||
if (track.id !== keyframe.trackId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const element = track.elements.find(
|
||||
(trackElement) => trackElement.id === keyframe.elementId,
|
||||
);
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
element,
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findKeyframeTime({
|
||||
animations,
|
||||
propertyPath,
|
||||
keyframeId,
|
||||
}: {
|
||||
animations: ElementAnimations;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
}): number | null {
|
||||
const binding = animations.bindings[propertyPath];
|
||||
if (!binding) return null;
|
||||
|
||||
for (const component of binding.components) {
|
||||
const channel = animations.channels[component.channelId];
|
||||
if (channel?.kind !== "scalar") continue;
|
||||
const key = channel.keys.find((k) => k.id === keyframeId);
|
||||
if (key !== undefined) return key.time;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getComponentLabel({ componentKey }: { componentKey: string }): string {
|
||||
switch (componentKey) {
|
||||
case "value":
|
||||
return "Value";
|
||||
default:
|
||||
return componentKey.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
function isFlatSegment({
|
||||
context,
|
||||
}: {
|
||||
context: ScalarGraphKeyframeContext;
|
||||
}): boolean {
|
||||
if (!context.nextKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
Math.abs(context.nextKey.value - context.keyframe.value) <=
|
||||
FLAT_VALUE_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
function isLinearCurve({
|
||||
cubicBezier,
|
||||
}: {
|
||||
cubicBezier: NormalizedCubicBezier;
|
||||
}): boolean {
|
||||
return (
|
||||
Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON &&
|
||||
Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON &&
|
||||
Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON &&
|
||||
Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveGraphEditorSelectionState({
|
||||
tracks,
|
||||
selectedKeyframes,
|
||||
preferredComponentKey,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
preferredComponentKey?: string | null;
|
||||
}): GraphEditorSelectionState {
|
||||
if (selectedKeyframes.length === 0) {
|
||||
return createUnavailableState({
|
||||
reason: "no-keyframe-selected",
|
||||
message: "Select a keyframe to edit its curve.",
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedKeyframes.length > 2) {
|
||||
return createUnavailableState({
|
||||
reason: "multiple-keyframes-selected",
|
||||
message: "Select one or two adjacent keyframes to edit a curve.",
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedKeyframes.length === 2) {
|
||||
const [kf1, kf2] = selectedKeyframes;
|
||||
if (
|
||||
kf1.trackId !== kf2.trackId ||
|
||||
kf1.elementId !== kf2.elementId ||
|
||||
kf1.propertyPath !== kf2.propertyPath
|
||||
) {
|
||||
return createUnavailableState({
|
||||
reason: "multiple-keyframes-selected",
|
||||
message: "Selected keyframes must be on the same element and property.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const primaryKeyframe = selectedKeyframes[0];
|
||||
const secondaryKeyframeId =
|
||||
selectedKeyframes.length === 2 ? selectedKeyframes[1].keyframeId : null;
|
||||
|
||||
const selectedElement = findElementByKeyframe({
|
||||
tracks,
|
||||
keyframe: primaryKeyframe,
|
||||
});
|
||||
if (!selectedElement) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-element-missing",
|
||||
message: "The selected keyframe could not be resolved.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!selectedElement.element.animations) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-element-has-no-animations",
|
||||
message: "The selected keyframe has no editable graph.",
|
||||
});
|
||||
}
|
||||
|
||||
const scalarChannels = getEditableScalarChannels({
|
||||
animations: selectedElement.element.animations,
|
||||
propertyPath: primaryKeyframe.propertyPath,
|
||||
});
|
||||
if (scalarChannels.length === 0) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-keyframe-has-no-scalar-channel",
|
||||
message: "The selected keyframe has no editable graph channel.",
|
||||
});
|
||||
}
|
||||
|
||||
// When 2 keyframes are selected, resolve the earlier one as the outgoing-segment
|
||||
// anchor so the graph editor edits the curve between the two selected keyframes.
|
||||
let resolvedKeyframeId = primaryKeyframe.keyframeId;
|
||||
if (secondaryKeyframeId !== null) {
|
||||
const time1 = findKeyframeTime({
|
||||
animations: selectedElement.element.animations,
|
||||
propertyPath: primaryKeyframe.propertyPath,
|
||||
keyframeId: primaryKeyframe.keyframeId,
|
||||
});
|
||||
const time2 = findKeyframeTime({
|
||||
animations: selectedElement.element.animations,
|
||||
propertyPath: primaryKeyframe.propertyPath,
|
||||
keyframeId: secondaryKeyframeId,
|
||||
});
|
||||
if (time2 !== null && (time1 === null || time2 < time1)) {
|
||||
resolvedKeyframeId = secondaryKeyframeId;
|
||||
}
|
||||
}
|
||||
|
||||
const contexts = scalarChannels.flatMap((channel) => {
|
||||
const context = getScalarKeyframeContext({
|
||||
animations: selectedElement.element.animations,
|
||||
propertyPath: primaryKeyframe.propertyPath,
|
||||
componentKey: channel.componentKey,
|
||||
keyframeId: resolvedKeyframeId,
|
||||
});
|
||||
if (!context) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
context,
|
||||
option: {
|
||||
key: channel.componentKey,
|
||||
label: getComponentLabel({ componentKey: channel.componentKey }),
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (contexts.length === 0) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-keyframe-missing-on-channel",
|
||||
message: "The selected keyframe is not editable as a graph segment.",
|
||||
});
|
||||
}
|
||||
|
||||
const nextSegmentContexts = contexts.filter(
|
||||
({ context }) => context.nextKey !== null,
|
||||
);
|
||||
const preferredContext =
|
||||
contexts.find(({ option }) => option.key === preferredComponentKey) ?? null;
|
||||
const activeContext =
|
||||
preferredContext ?? nextSegmentContexts[0] ?? contexts[0];
|
||||
const componentOptions = contexts.map(({ option }) => option);
|
||||
|
||||
if (!activeContext.context.nextKey) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-keyframe-has-no-next-segment",
|
||||
message: "Select a keyframe that has an outgoing segment.",
|
||||
componentOptions,
|
||||
activeComponentKey: activeContext.option.key,
|
||||
});
|
||||
}
|
||||
|
||||
if (isFlatSegment({ context: activeContext.context })) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-segment-is-flat",
|
||||
message: "Flat segments are not graph-editable in this popover yet.",
|
||||
componentOptions,
|
||||
activeComponentKey: activeContext.option.key,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeContext.context.keyframe.segmentToNext === "step") {
|
||||
return createUnavailableState({
|
||||
reason: "selected-segment-is-hold",
|
||||
message: "Hold segments are not graph-editable in this popover yet.",
|
||||
componentOptions,
|
||||
activeComponentKey: activeContext.option.key,
|
||||
});
|
||||
}
|
||||
|
||||
const cubicBezier =
|
||||
activeContext.context.keyframe.segmentToNext === "linear"
|
||||
? GRAPH_LINEAR_CURVE
|
||||
: getNormalizedCubicBezierForScalarSegment({
|
||||
leftKey: activeContext.context.keyframe,
|
||||
rightKey: activeContext.context.nextKey,
|
||||
});
|
||||
if (!cubicBezier) {
|
||||
return createUnavailableState({
|
||||
reason: "selected-segment-is-flat",
|
||||
message: "The selected segment cannot be represented in this graph view.",
|
||||
componentOptions,
|
||||
activeComponentKey: activeContext.option.key,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ready",
|
||||
message: "Edit graph",
|
||||
componentOptions,
|
||||
activeComponentKey: activeContext.option.key,
|
||||
trackId: selectedElement.trackId,
|
||||
elementId: selectedElement.elementId,
|
||||
propertyPath: primaryKeyframe.propertyPath,
|
||||
keyframeId: resolvedKeyframeId,
|
||||
element: selectedElement.element,
|
||||
context: activeContext.context,
|
||||
cubicBezier,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGraphEditorCurvePatches({
|
||||
context,
|
||||
cubicBezier,
|
||||
}: {
|
||||
context: ScalarGraphKeyframeContext;
|
||||
cubicBezier: NormalizedCubicBezier;
|
||||
}): GraphEditorCurvePatch[] | null {
|
||||
if (!context.nextKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLinearCurve({ cubicBezier })) {
|
||||
return [
|
||||
{
|
||||
keyframeId: context.keyframe.id,
|
||||
patch: {
|
||||
segmentToNext: "linear",
|
||||
rightHandle: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
keyframeId: context.nextKey.id,
|
||||
patch: {
|
||||
leftHandle: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const handles = getCurveHandlesForNormalizedCubicBezier({
|
||||
leftKey: context.keyframe,
|
||||
rightKey: context.nextKey,
|
||||
cubicBezier,
|
||||
});
|
||||
if (!handles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
keyframeId: context.keyframe.id,
|
||||
patch: {
|
||||
segmentToNext: "bezier",
|
||||
rightHandle: handles.rightHandle,
|
||||
},
|
||||
},
|
||||
{
|
||||
keyframeId: context.nextKey.id,
|
||||
patch: {
|
||||
leftHandle: handles.leftHandle,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function applyGraphEditorCurvePreview({
|
||||
animations,
|
||||
context,
|
||||
cubicBezier,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
context: ScalarGraphKeyframeContext;
|
||||
cubicBezier: NormalizedCubicBezier;
|
||||
}): ElementAnimations | undefined {
|
||||
const patches = buildGraphEditorCurvePatches({
|
||||
context,
|
||||
cubicBezier,
|
||||
});
|
||||
if (!patches) {
|
||||
return animations;
|
||||
}
|
||||
|
||||
return patches.reduce<ElementAnimations | undefined>(
|
||||
(nextAnimations, { keyframeId, patch }) =>
|
||||
updateScalarKeyframeCurve({
|
||||
animations: nextAnimations,
|
||||
propertyPath: context.propertyPath,
|
||||
componentKey: context.componentKey,
|
||||
keyframeId,
|
||||
patch,
|
||||
}),
|
||||
animations,
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { registerCanceller } from "@/lib/cancel-interaction";
|
||||
import type { NormalizedCubicBezier } from "@/lib/animation/types";
|
||||
import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
|
||||
import {
|
||||
applyGraphEditorCurvePreview,
|
||||
buildGraphEditorCurvePatches,
|
||||
resolveGraphEditorSelectionState,
|
||||
type GraphEditorSelectionState,
|
||||
} from "./session";
|
||||
|
||||
export function useGraphEditorController() {
|
||||
const editor = useEditor();
|
||||
const renderTracks = useEditor((currentEditor) =>
|
||||
currentEditor.timeline.getRenderTracks(),
|
||||
);
|
||||
const { selectedKeyframes } = useKeyframeSelection();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeComponentKey, setActiveComponentKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const hasPreviewRef = useRef(false);
|
||||
|
||||
const state = useMemo<GraphEditorSelectionState>(
|
||||
() =>
|
||||
resolveGraphEditorSelectionState({
|
||||
tracks: renderTracks,
|
||||
selectedKeyframes,
|
||||
preferredComponentKey: activeComponentKey,
|
||||
}),
|
||||
[activeComponentKey, renderTracks, selectedKeyframes],
|
||||
);
|
||||
|
||||
const stateKey =
|
||||
state.status === "ready"
|
||||
? `${state.trackId}:${state.elementId}:${state.propertyPath}:${state.keyframeId}:${state.activeComponentKey}`
|
||||
: `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`;
|
||||
const previousStateKeyRef = useRef(stateKey);
|
||||
|
||||
const discardPreview = useCallback(() => {
|
||||
if (!hasPreviewRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.discardPreview();
|
||||
hasPreviewRef.current = false;
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) {
|
||||
discardPreview();
|
||||
}
|
||||
|
||||
previousStateKeyRef.current = stateKey;
|
||||
}, [discardPreview, stateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
return registerCanceller({
|
||||
fn: () => {
|
||||
discardPreview();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
}, [discardPreview, open]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
discardPreview();
|
||||
}
|
||||
|
||||
setOpen(nextOpen);
|
||||
},
|
||||
[discardPreview],
|
||||
);
|
||||
|
||||
const handleActiveComponentKeyChange = useCallback(
|
||||
(nextComponentKey: string) => {
|
||||
discardPreview();
|
||||
setActiveComponentKey(nextComponentKey);
|
||||
},
|
||||
[discardPreview],
|
||||
);
|
||||
|
||||
function handlePreviewValue(nextValue: NormalizedCubicBezier) {
|
||||
if (state.status !== "ready") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextAnimations = applyGraphEditorCurvePreview({
|
||||
animations: state.element.animations,
|
||||
context: state.context,
|
||||
cubicBezier: nextValue,
|
||||
});
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId: state.trackId,
|
||||
elementId: state.elementId,
|
||||
updates: {
|
||||
animations: nextAnimations,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
hasPreviewRef.current = true;
|
||||
}
|
||||
|
||||
function handleCommitValue(nextValue: NormalizedCubicBezier) {
|
||||
if (state.status !== "ready") {
|
||||
return;
|
||||
}
|
||||
|
||||
const patches = buildGraphEditorCurvePatches({
|
||||
context: state.context,
|
||||
cubicBezier: nextValue,
|
||||
});
|
||||
if (!patches) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.updateKeyframeCurves({
|
||||
keyframes: patches.map(({ keyframeId, patch }) => ({
|
||||
trackId: state.trackId,
|
||||
elementId: state.elementId,
|
||||
propertyPath: state.propertyPath,
|
||||
componentKey: state.context.componentKey,
|
||||
keyframeId,
|
||||
patch,
|
||||
})),
|
||||
});
|
||||
hasPreviewRef.current = false;
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
onOpenChange: handleOpenChange,
|
||||
canOpen: state.status === "ready",
|
||||
tooltip: state.status === "ready" ? "Open graph editor" : state.message,
|
||||
state,
|
||||
onActiveComponentKeyChange: handleActiveComponentKeyChange,
|
||||
onPreviewValue: handlePreviewValue,
|
||||
onCommitValue: handleCommitValue,
|
||||
onCancelPreview: discardPreview,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
@@ -38,27 +38,21 @@ import { SnapIndicator } from "./snap-indicator";
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import {
|
||||
TIMELINE_CONSTANTS,
|
||||
TRACK_GAP,
|
||||
TIMELINE_SCROLLBAR_SIZE_PX,
|
||||
TIMELINE_CONTENT_TOP_PADDING_PX,
|
||||
TIMELINE_TRACK_GAP_PX,
|
||||
TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX,
|
||||
} from "./layout";
|
||||
} from "@/constants/timeline-constants";
|
||||
import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
|
||||
import {
|
||||
canTrackHaveAudio,
|
||||
canTrackBeHidden,
|
||||
getTimelineZoomMin,
|
||||
getTimelinePaddingPx,
|
||||
} from "@/lib/timeline";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import {
|
||||
getTrackHeight,
|
||||
getCumulativeHeightBefore,
|
||||
getTotalTracksHeight,
|
||||
} from "./track-layout";
|
||||
import { SELECTED_TRACK_ROW_CLASS } from "./theme";
|
||||
import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
|
||||
import { isMainTrack } from "@/lib/timeline/placement";
|
||||
canTracktHaveAudio,
|
||||
canTrackBeHidden,
|
||||
isMainTrack,
|
||||
getTimelineZoomMin,
|
||||
getTimelinePaddingPx,
|
||||
} from "@/lib/timeline";
|
||||
import { TimelineToolbar } from "./timeline-toolbar";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
|
||||
@@ -73,7 +67,7 @@ import { useEditor } from "@/hooks/use-editor";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
import { DragLine } from "./drag-line";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { resolveTimelineElementIntersections } from "./selection-hit-testing";
|
||||
import { resolveTimelineElementIntersections } from "@/lib/timeline/selection-hit-testing";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const TRACKS_CONTAINER_MAX_HEIGHT = 800;
|
||||
@@ -236,7 +230,7 @@ export function Timeline() {
|
||||
Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
||||
const clamped =
|
||||
Math.sign(raw) *
|
||||
Math.min(Math.abs(raw), TIMELINE_HORIZONTAL_WHEEL_STEP_PX);
|
||||
Math.min(Math.abs(raw), TIMELINE_CONSTANTS.HORIZONTAL_WHEEL_STEP_PX);
|
||||
tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped);
|
||||
} else {
|
||||
tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY);
|
||||
@@ -341,7 +335,7 @@ export function Timeline() {
|
||||
const containerWidth =
|
||||
tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
|
||||
const contentWidth =
|
||||
timelineDuration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const paddingPx = getTimelinePaddingPx({
|
||||
containerWidth,
|
||||
zoomLevel,
|
||||
@@ -389,7 +383,7 @@ export function Timeline() {
|
||||
|
||||
const timelineHeaderHeight =
|
||||
(timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) +
|
||||
TIMELINE_CONTENT_TOP_PADDING_PX || 0;
|
||||
TIMELINE_CONSTANTS.PADDING_TOP_PX || 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -489,7 +483,7 @@ export function Timeline() {
|
||||
TRACKS_CONTAINER_HEIGHT.max,
|
||||
getTotalTracksHeight({ tracks }),
|
||||
),
|
||||
) + TIMELINE_CONTENT_TOP_PADDING_PX
|
||||
) + TIMELINE_CONSTANTS.PADDING_TOP_PX
|
||||
}px`,
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
@@ -583,10 +577,7 @@ function TrackLabelsPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 flex-col border-r"
|
||||
style={{ width: `${TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX}px` }}
|
||||
>
|
||||
<div className="flex w-28 shrink-0 flex-col border-r">
|
||||
<div
|
||||
className="shrink-0"
|
||||
style={{ height: timelineHeaderHeight || 48 }}
|
||||
@@ -594,24 +585,21 @@ function TrackLabelsPanel({
|
||||
<div ref={trackLabelsRef} className="flex-1 overflow-hidden">
|
||||
<div ref={trackLabelsScrollRef} className="size-full overflow-hidden">
|
||||
{tracks.length > 0 && (
|
||||
<div
|
||||
className="flex flex-col"
|
||||
style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }}
|
||||
>
|
||||
<div className="flex flex-col" style={{ gap: `${TRACK_GAP}px` }}>
|
||||
{tracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className={cn(
|
||||
"group flex items-center px-3",
|
||||
tracksWithSelection.has(track.id) &&
|
||||
SELECTED_TRACK_ROW_CLASS,
|
||||
TIMELINE_CONSTANTS.TRACK_SELECTED_BG,
|
||||
)}
|
||||
style={{
|
||||
height: `${getTrackHeight({ type: track.type })}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
|
||||
{canTrackHaveAudio(track) && (
|
||||
{canTracktHaveAudio(track) && (
|
||||
<TrackToggleIcon
|
||||
isOff={track.muted}
|
||||
icons={{ on: VolumeHighIcon, off: VolumeOffIcon }}
|
||||
@@ -719,10 +707,10 @@ function TimelineTrackRows({
|
||||
className={cn(
|
||||
"absolute right-0 left-0 transition-colors",
|
||||
tracksWithSelection.has(track.id) &&
|
||||
SELECTED_TRACK_ROW_CLASS,
|
||||
TIMELINE_CONSTANTS.TRACK_SELECTED_BG,
|
||||
)}
|
||||
style={{
|
||||
top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`,
|
||||
top: `${TIMELINE_CONSTANTS.PADDING_TOP_PX + getCumulativeHeightBefore({ tracks, trackIndex: index })}px`,
|
||||
height: `${getTrackHeight({ type: track.type })}px`,
|
||||
}}
|
||||
>
|
||||
@@ -765,7 +753,7 @@ function TimelineTrackRows({
|
||||
timeline.toggleTrackMute({ trackId: track.id });
|
||||
}}
|
||||
>
|
||||
{canTrackHaveAudio(track) && track.muted
|
||||
{canTracktHaveAudio(track) && track.muted
|
||||
? "Unmute track"
|
||||
: "Mute track"}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export const TIMELINE_DRAG_THRESHOLD_PX = 5;
|
||||
export const TIMELINE_HORIZONTAL_WHEEL_STEP_PX = 40;
|
||||
export const TIMELINE_ZOOM_BUTTON_FACTOR = 1.7;
|
||||
export const TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD = 0.15;
|
||||
@@ -1,6 +0,0 @@
|
||||
export const TIMELINE_LAYERS = {
|
||||
trackContent: 10,
|
||||
dragLine: 20,
|
||||
playhead: 30,
|
||||
snapIndicator: 40,
|
||||
} as const;
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { TrackType } from "@/lib/timeline";
|
||||
|
||||
export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = {
|
||||
video: 65,
|
||||
text: 25,
|
||||
audio: 50,
|
||||
graphic: 25,
|
||||
effect: 25,
|
||||
} as const;
|
||||
|
||||
export const TIMELINE_TRACK_GAP_PX = 6;
|
||||
export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112;
|
||||
export const TIMELINE_RULER_HEIGHT_PX = 22;
|
||||
export const TIMELINE_BOOKMARK_ROW_HEIGHT_PX = 16;
|
||||
export const TIMELINE_SCROLLBAR_SIZE_PX = 12;
|
||||
export const TIMELINE_CONTENT_TOP_PADDING_PX = 2;
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getCenteredLineLeft,
|
||||
TIMELINE_INDICATOR_LINE_WIDTH_PX,
|
||||
} from "@/lib/timeline";
|
||||
import { TIMELINE_LAYERS } from "./layers";
|
||||
import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
|
||||
interface SnapIndicatorProps {
|
||||
snapPoint: SnapPoint | null;
|
||||
zoomLevel: number;
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { TrackType } from "@/lib/timeline";
|
||||
|
||||
export const TIMELINE_TRACK_THEME: Record<
|
||||
TrackType,
|
||||
{
|
||||
elementClassName: string;
|
||||
waveformColor?: string;
|
||||
}
|
||||
> = {
|
||||
video: { elementClassName: "transparent" },
|
||||
text: { elementClassName: "bg-[#5DBAA0]" },
|
||||
audio: {
|
||||
elementClassName: "bg-[#8F5DBA]",
|
||||
waveformColor: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
graphic: { elementClassName: "bg-[#BA5D7A]" },
|
||||
effect: { elementClassName: "bg-[#5d93ba]" },
|
||||
} as const;
|
||||
|
||||
export const SELECTED_TRACK_ROW_CLASS = "bg-accent/50";
|
||||
export const DEFAULT_TIMELINE_BOOKMARK_COLOR = "#009dff";
|
||||
|
||||
export function getTimelineElementClassName({
|
||||
type,
|
||||
}: {
|
||||
type: TrackType;
|
||||
}): string {
|
||||
return TIMELINE_TRACK_THEME[type].elementClassName.trim();
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-sele
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import { getElementKeyframes } from "@/lib/animation";
|
||||
import {
|
||||
getElementClasses,
|
||||
getTrackHeight,
|
||||
canElementHaveAudio,
|
||||
canElementBeHidden,
|
||||
hasElementEffects,
|
||||
@@ -19,11 +21,7 @@ import {
|
||||
timelineTimeToPixels,
|
||||
timelineTimeToSnappedPixels,
|
||||
} from "@/lib/timeline";
|
||||
import { getTrackHeight } from "./track-layout";
|
||||
import {
|
||||
getTimelineElementClassName,
|
||||
TIMELINE_TRACK_THEME,
|
||||
} from "./theme";
|
||||
import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -41,11 +39,6 @@ import type {
|
||||
} from "@/lib/timeline";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
||||
import {
|
||||
canToggleSourceAudio,
|
||||
getSourceAudioActionLabel,
|
||||
isSourceAudioSeparated,
|
||||
} from "@/lib/timeline/audio-separation";
|
||||
import {
|
||||
getActionDefinition,
|
||||
type TAction,
|
||||
@@ -68,9 +61,7 @@ import {
|
||||
Search01Icon,
|
||||
Exchange01Icon,
|
||||
KeyframeIcon,
|
||||
Link02Icon,
|
||||
MagicWand05Icon,
|
||||
Unlink02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { uppercase } from "@/utils/string";
|
||||
@@ -289,16 +280,6 @@ export function TimelineElement({
|
||||
};
|
||||
|
||||
const isMuted = canElementHaveAudio(element) && element.muted === true;
|
||||
const canToggleCurrentSourceAudio =
|
||||
selectedElements.length === 1 &&
|
||||
isCurrentElementSelected &&
|
||||
canToggleSourceAudio(element, mediaAsset);
|
||||
const sourceAudioLabel =
|
||||
element.type === "video"
|
||||
? getSourceAudioActionLabel({ element })
|
||||
: "Extract audio";
|
||||
const isElementSourceAudioSeparated =
|
||||
element.type === "video" && isSourceAudioSeparated({ element });
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
@@ -353,21 +334,6 @@ export function TimelineElement({
|
||||
isMuted={isMuted}
|
||||
/>
|
||||
)}
|
||||
{canToggleCurrentSourceAudio && (
|
||||
<ContextMenuItem
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={isElementSourceAudioSeparated ? Unlink02Icon : Link02Icon}
|
||||
/>
|
||||
}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
invokeAction("toggle-source-audio");
|
||||
}}
|
||||
>
|
||||
{sourceAudioLabel}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{canElementBeHidden(element) && (
|
||||
<VisibilityMenuItem
|
||||
element={element}
|
||||
@@ -463,7 +429,7 @@ function ElementInner({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-hidden rounded-sm",
|
||||
getTimelineElementClassName({ type: track.type }),
|
||||
getElementClasses({ type: track.type }),
|
||||
isReducedOpacity && "opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -600,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}
|
||||
@@ -709,7 +675,7 @@ function AudioElementContent({ element }: { element: AudioElement }) {
|
||||
<AudioWaveform
|
||||
audioBuffer={audioBuffer}
|
||||
audioUrl={audioUrl}
|
||||
color={TIMELINE_TRACK_THEME.audio.waveformColor}
|
||||
color={ELEMENT_TYPE_CONFIG.audio.waveformColor}
|
||||
/>
|
||||
<MediaElementHeader name={mediaLabel} hasFade={false} />
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import {
|
||||
TIMELINE_LAYERS,
|
||||
TIMELINE_SCROLLBAR_SIZE_PX,
|
||||
} from "./layout";
|
||||
import { TIMELINE_LAYERS } from "./layers";
|
||||
} from "@/constants/timeline-constants";
|
||||
|
||||
interface TimelinePlayheadProps {
|
||||
zoomLevel: number;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type JSX, useLayoutEffect, useRef } from "react";
|
||||
import {
|
||||
BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
} from "@/lib/timeline/scale";
|
||||
import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
|
||||
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
||||
TIMELINE_CONSTANTS,
|
||||
TIMELINE_RULER_HEIGHT,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
|
||||
import { useScrollPosition } from "@/hooks/timeline/use-scroll-position";
|
||||
@@ -31,7 +31,7 @@ export function TimelineRuler({
|
||||
handleRulerMouseDown,
|
||||
}: TimelineRulerProps) {
|
||||
const duration = useEditor((e) => e.timeline.getTotalDuration());
|
||||
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
|
||||
const effectiveDuration = Math.max(duration, visibleDuration);
|
||||
const fps =
|
||||
@@ -107,7 +107,7 @@ export function TimelineRuler({
|
||||
aria-valuemax={effectiveDuration}
|
||||
aria-valuenow={0}
|
||||
className="relative flex-1 overflow-x-visible"
|
||||
style={{ height: TIMELINE_RULER_HEIGHT_PX }}
|
||||
style={{ height: TIMELINE_RULER_HEIGHT }}
|
||||
onWheel={handleWheel}
|
||||
onClick={(event) => {
|
||||
// Ruler seek already happens on mousedown via playhead scrubbing.
|
||||
@@ -124,7 +124,7 @@ export function TimelineRuler({
|
||||
ref={rulerRef}
|
||||
className="relative cursor-default select-none"
|
||||
style={{
|
||||
height: TIMELINE_RULER_HEIGHT_PX,
|
||||
height: TIMELINE_RULER_HEIGHT,
|
||||
width: `${dynamicTimelineWidth}px`,
|
||||
}}
|
||||
onMouseDown={handleRulerMouseDown}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SplitSquareHorizontal } from "lucide-react";
|
||||
import {
|
||||
SplitButton,
|
||||
SplitButtonLeft,
|
||||
@@ -14,17 +14,10 @@ import {
|
||||
SplitButtonSeparator,
|
||||
} from "@/components/ui/split-button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction";
|
||||
import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils";
|
||||
import { ScenesView } from "@/components/editor/scenes-view";
|
||||
import { type TActionWithOptionalArgs, invokeAction } from "@/lib/actions";
|
||||
import {
|
||||
canToggleSourceAudio,
|
||||
getSourceAudioActionLabel,
|
||||
isSourceAudioSeparated,
|
||||
} from "@/lib/timeline/audio-separation";
|
||||
import { hasMediaId } from "@/lib/timeline";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -34,21 +27,15 @@ import {
|
||||
SnowIcon,
|
||||
ScissorIcon,
|
||||
MagnetIcon,
|
||||
Link04Icon,
|
||||
SearchAddIcon,
|
||||
SearchMinusIcon,
|
||||
Copy01Icon,
|
||||
AlignLeftIcon,
|
||||
AlignRightIcon,
|
||||
Link02Icon,
|
||||
Layers01Icon,
|
||||
Chart03Icon,
|
||||
Unlink02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { OcRippleIcon } from "@/components/icons";
|
||||
import { GraphEditorPopover } from "./graph-editor/popover";
|
||||
import { PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useGraphEditorController } from "./graph-editor/use-controller";
|
||||
|
||||
export function TimelineToolbar({
|
||||
zoomLevel,
|
||||
@@ -62,8 +49,11 @@ export function TimelineToolbar({
|
||||
const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
|
||||
const newZoomLevel =
|
||||
direction === "in"
|
||||
? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR)
|
||||
: Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR);
|
||||
? Math.min(
|
||||
TIMELINE_CONSTANTS.ZOOM_MAX,
|
||||
zoomLevel * TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR,
|
||||
)
|
||||
: Math.max(minZoom, zoomLevel / TIMELINE_CONSTANTS.ZOOM_BUTTON_FACTOR);
|
||||
setZoomLevel({ zoom: newZoomLevel });
|
||||
};
|
||||
|
||||
@@ -86,47 +76,9 @@ export function TimelineToolbar({
|
||||
}
|
||||
|
||||
function ToolbarLeftSection() {
|
||||
const editor = useEditor();
|
||||
const mediaAssets = useEditor((currentEditor) =>
|
||||
currentEditor.media.getAssets(),
|
||||
);
|
||||
const { selectedElements } = useElementSelection();
|
||||
const graphEditor = useGraphEditorController();
|
||||
const isCurrentlyBookmarked = useEditor((e) =>
|
||||
e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
|
||||
);
|
||||
const selectedElement =
|
||||
selectedElements.length === 1
|
||||
? (editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
})[0] ?? null)
|
||||
: null;
|
||||
const selectedMediaAsset = (() => {
|
||||
if (!selectedElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { element } = selectedElement;
|
||||
if (!hasMediaId(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
|
||||
})();
|
||||
const canToggleSelectedSourceAudio =
|
||||
!!selectedElement &&
|
||||
canToggleSourceAudio(selectedElement.element, selectedMediaAsset);
|
||||
const sourceAudioLabel =
|
||||
selectedElement?.element.type === "video"
|
||||
? getSourceAudioActionLabel({
|
||||
element: selectedElement.element,
|
||||
})
|
||||
: "Extract audio";
|
||||
const isSelectedSourceAudioSeparated =
|
||||
selectedElement?.element.type === "video" &&
|
||||
isSourceAudioSeparated({
|
||||
element: selectedElement.element,
|
||||
});
|
||||
|
||||
const handleAction = ({
|
||||
action,
|
||||
@@ -163,16 +115,10 @@ function ToolbarLeftSection() {
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
icon={
|
||||
<HugeiconsIcon
|
||||
icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
|
||||
/>
|
||||
}
|
||||
tooltip={sourceAudioLabel}
|
||||
disabled={!canToggleSelectedSourceAudio}
|
||||
onClick={({ event }) =>
|
||||
handleAction({ action: "toggle-source-audio", event })
|
||||
}
|
||||
icon={<SplitSquareHorizontal />}
|
||||
tooltip="Separate audio (coming soon)"
|
||||
disabled={true}
|
||||
onClick={({ event: _event }) => {}}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
@@ -210,36 +156,6 @@ function ToolbarLeftSection() {
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<GraphEditorPopover
|
||||
open={graphEditor.open}
|
||||
onOpenChange={graphEditor.onOpenChange}
|
||||
value={
|
||||
graphEditor.state.status === "ready"
|
||||
? graphEditor.state.cubicBezier
|
||||
: null
|
||||
}
|
||||
message={graphEditor.state.message}
|
||||
componentOptions={graphEditor.state.componentOptions}
|
||||
activeComponentKey={graphEditor.state.activeComponentKey}
|
||||
onActiveComponentKeyChange={graphEditor.onActiveComponentKeyChange}
|
||||
onPreviewValue={graphEditor.onPreviewValue}
|
||||
onCommitValue={graphEditor.onCommitValue}
|
||||
onCancelPreview={graphEditor.onCancelPreview}
|
||||
>
|
||||
<ToolbarButton
|
||||
icon={<HugeiconsIcon icon={Chart03Icon} />}
|
||||
tooltip={graphEditor.tooltip}
|
||||
disabled={!graphEditor.canOpen}
|
||||
buttonWrapper={(button) =>
|
||||
graphEditor.canOpen ? (
|
||||
<PopoverTrigger asChild>{button}</PopoverTrigger>
|
||||
) : (
|
||||
button
|
||||
)
|
||||
}
|
||||
/>
|
||||
</GraphEditorPopover>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
@@ -291,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()}
|
||||
@@ -336,40 +252,28 @@ function ToolbarButton({
|
||||
onClick,
|
||||
disabled,
|
||||
isActive,
|
||||
buttonWrapper,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
tooltip: string;
|
||||
onClick?: ({ event }: { event: React.MouseEvent }) => void;
|
||||
onClick: ({ event }: { event: React.MouseEvent }) => void;
|
||||
disabled?: boolean;
|
||||
isActive?: boolean;
|
||||
buttonWrapper?: (button: React.ReactElement) => React.ReactElement;
|
||||
}) {
|
||||
const button = (
|
||||
<Button
|
||||
variant={isActive ? "secondary" : "text"}
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
onClick={onClick ? (event) => onClick({ event }) : undefined}
|
||||
className={cn(
|
||||
"rounded-sm",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
const trigger = disabled ? (
|
||||
<span className="inline-flex">{button}</span>
|
||||
) : buttonWrapper ? (
|
||||
buttonWrapper(button)
|
||||
) : (
|
||||
button
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isActive ? "secondary" : "text"}
|
||||
size="icon"
|
||||
onClick={(event) => onClick({ event })}
|
||||
className={cn(
|
||||
"rounded-sm",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,7 @@ import { TimelineElement } from "./timeline-element";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import { TIMELINE_LAYERS } from "./layers";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants";
|
||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||
import type { ElementDragState } from "@/lib/timeline";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
@@ -60,7 +59,7 @@ export function TimelineTrackContent({
|
||||
getMouseClientX: () => lastMouseXRef.current ?? 0,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
|
||||
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { TrackType } from "@/lib/timeline";
|
||||
import {
|
||||
TIMELINE_TRACK_GAP_PX,
|
||||
TIMELINE_TRACK_HEIGHTS_PX,
|
||||
} from "./layout";
|
||||
|
||||
export function getTrackHeight({ type }: { type: TrackType }): number {
|
||||
return TIMELINE_TRACK_HEIGHTS_PX[type];
|
||||
}
|
||||
|
||||
export function getCumulativeHeightBefore({
|
||||
tracks,
|
||||
trackIndex,
|
||||
}: {
|
||||
tracks: Array<{ type: TrackType }>;
|
||||
trackIndex: number;
|
||||
}): number {
|
||||
return tracks
|
||||
.slice(0, trackIndex)
|
||||
.reduce(
|
||||
(sum, track) => sum + getTrackHeight({ type: track.type }) + TIMELINE_TRACK_GAP_PX,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export function getTotalTracksHeight({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: Array<{ type: TrackType }>;
|
||||
}): number {
|
||||
const tracksHeight = tracks.reduce(
|
||||
(sum, track) => sum + getTrackHeight({ type: track.type }),
|
||||
0,
|
||||
);
|
||||
const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX;
|
||||
return tracksHeight + gapsHeight;
|
||||
}
|
||||
@@ -1,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,135 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { EditorCore } from "@/core";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useKeybindingsListener } from "@/hooks/use-keybindings";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
|
||||
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
|
||||
import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer";
|
||||
|
||||
interface EditorProviderProps {
|
||||
projectId: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
||||
const activeProject = useEditor((e) => e.project.getActiveOrNull());
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { setLoadingProject } = useKeybindingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingProject(isLoading);
|
||||
}, [isLoading, setLoadingProject]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const editor = EditorCore.getInstance();
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await initializeGpuRenderer();
|
||||
await editor.project.loadProject({ id: projectId });
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setIsLoading(false);
|
||||
loadFontAtlas();
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
const isNotFound =
|
||||
err instanceof Error &&
|
||||
(err.message.includes("not found") ||
|
||||
err.message.includes("does not exist"));
|
||||
|
||||
if (isNotFound) {
|
||||
try {
|
||||
const newProjectId = await editor.project.createNewProject({
|
||||
name: "Untitled Project",
|
||||
});
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
} catch (_createErr) {
|
||||
setError("Failed to create project");
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load project",
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProject();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, router]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground size-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Loading project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeProject) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground size-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Exiting project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorRuntimeBindings />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorRuntimeBindings() {
|
||||
const editor = useEditor();
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!editor.save.getIsDirty()) return;
|
||||
event.preventDefault();
|
||||
(event as unknown as { returnValue: string }).returnValue = "";
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [editor]);
|
||||
|
||||
useEditorActions();
|
||||
useKeybindingsListener();
|
||||
return null;
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { EditorCore } from "@/core";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useKeybindingsListener } from "@/hooks/use-keybindings";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
|
||||
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
|
||||
|
||||
interface EditorProviderProps {
|
||||
projectId: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
||||
const activeProject = useEditor((e) => e.project.getActiveOrNull());
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { setLoadingProject } = useKeybindingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingProject(isLoading);
|
||||
}, [isLoading, setLoadingProject]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const editor = EditorCore.getInstance();
|
||||
|
||||
const loadProject = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await editor.project.loadProject({ id: projectId });
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setIsLoading(false);
|
||||
loadFontAtlas();
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
const isNotFound =
|
||||
err instanceof Error &&
|
||||
(err.message.includes("not found") ||
|
||||
err.message.includes("does not exist"));
|
||||
|
||||
if (isNotFound) {
|
||||
try {
|
||||
const newProjectId = await editor.project.createNewProject({
|
||||
name: "Untitled Project",
|
||||
});
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
} catch (_createErr) {
|
||||
setError("Failed to create project");
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load project",
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadProject();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, router]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground size-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Loading project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeProject) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen w-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="text-muted-foreground size-8 animate-spin" />
|
||||
<p className="text-muted-foreground text-sm">Exiting project...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorRuntimeBindings />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorRuntimeBindings() {
|
||||
const editor = useEditor();
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!editor.save.getIsDirty()) return;
|
||||
event.preventDefault();
|
||||
(event as unknown as { returnValue: string }).returnValue = "";
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [editor]);
|
||||
|
||||
useEditorActions();
|
||||
useKeybindingsListener();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -50,11 +50,11 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50",
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent",
|
||||
variant === "default" &&
|
||||
"border border-transparent data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5",
|
||||
"data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5",
|
||||
variant === "underline" &&
|
||||
"text-muted-foreground data-[state=active]:text-primary border-x-0 border-t-0 border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px",
|
||||
"text-muted-foreground data-[state=active]:text-primary border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -72,7 +72,7 @@ const TabsContent = React.forwardRef<
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
|
||||
"ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
|
||||
variant === "underline" && "px-4",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -16,12 +16,12 @@ export const FPS_PRESETS = [
|
||||
] as const;
|
||||
|
||||
export const BLUR_INTENSITY_PRESETS: { label: string; value: number }[] = [
|
||||
{ label: "Light", value: 50 },
|
||||
{ label: "Medium", value: 250 },
|
||||
{ label: "Heavy", value: 500 },
|
||||
{ label: "Light", value: 10 },
|
||||
{ label: "Medium", value: 50 },
|
||||
{ label: "Heavy", value: 100 },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };
|
||||
export const DEFAULT_FPS = 30;
|
||||
export const DEFAULT_BLUR_INTENSITY = 50;
|
||||
export const DEFAULT_BLUR_INTENSITY = 10;
|
||||
export const DEFAULT_COLOR = "#000000";
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
export const DEFAULT_RETIME_RATE = 1;
|
||||
export const MIN_RETIME_RATE = 0.01;
|
||||
export const MAX_RETIME_RATE = 5;
|
||||
|
||||
export function clampRetimeRate({ rate }: { rate: number }): number {
|
||||
if (!Number.isFinite(rate) || rate <= 0) {
|
||||
return DEFAULT_RETIME_RATE;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(rate, MIN_RETIME_RATE), MAX_RETIME_RATE);
|
||||
}
|
||||
|
||||
export function canMaintainPitch({ rate }: { rate: number }): boolean {
|
||||
return Number.isFinite(rate) && rate > 0;
|
||||
}
|
||||
|
||||
export function shouldMaintainPitch({
|
||||
rate,
|
||||
maintainPitch,
|
||||
}: {
|
||||
rate: number;
|
||||
maintainPitch?: boolean;
|
||||
}): boolean {
|
||||
return maintainPitch === true && canMaintainPitch({ rate });
|
||||
}
|
||||
export const DEFAULT_RETIME_RATE = 1;
|
||||
export const MIN_RETIME_RATE = 0.01;
|
||||
export const MAX_RETIME_RATE = 5;
|
||||
export const MAX_PITCH_PRESERVE_RATE = 10;
|
||||
|
||||
export function clampRetimeRate({ rate }: { rate: number }): number {
|
||||
if (!Number.isFinite(rate) || rate <= 0) {
|
||||
return DEFAULT_RETIME_RATE;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(rate, MIN_RETIME_RATE), MAX_RETIME_RATE);
|
||||
}
|
||||
|
||||
export function canMaintainPitch({ rate }: { rate: number }): boolean {
|
||||
if (!Number.isFinite(rate) || rate <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clampRetimeRate({ rate }) <= MAX_PITCH_PRESERVE_RATE;
|
||||
}
|
||||
|
||||
export function shouldMaintainPitch({
|
||||
rate,
|
||||
maintainPitch,
|
||||
}: {
|
||||
rate: number;
|
||||
maintainPitch?: boolean;
|
||||
}): boolean {
|
||||
return maintainPitch === true && canMaintainPitch({ rate });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
+53
-63
@@ -1,63 +1,53 @@
|
||||
import { PlaybackManager } from "./managers/playback-manager";
|
||||
import { TimelineManager } from "./managers/timeline-manager";
|
||||
import { ScenesManager } from "./managers/scenes-manager";
|
||||
import { ProjectManager } from "./managers/project-manager";
|
||||
import { MediaManager } from "./managers/media-manager";
|
||||
import { RendererManager } from "./managers/renderer-manager";
|
||||
import { CommandManager } from "./managers/commands";
|
||||
import { SaveManager } from "./managers/save-manager";
|
||||
import { AudioManager } from "./managers/audio-manager";
|
||||
import { SelectionManager } from "./managers/selection-manager";
|
||||
import { registerDefaultEffects } from "@/lib/effects";
|
||||
import { registerDefaultMasks } from "@/lib/masks";
|
||||
import { isMainTrack } from "@/lib/timeline/placement";
|
||||
|
||||
export class EditorCore {
|
||||
private static instance: EditorCore | null = null;
|
||||
public readonly timeline: TimelineManager;
|
||||
public readonly command: CommandManager;
|
||||
public readonly playback: PlaybackManager;
|
||||
public readonly scenes: ScenesManager;
|
||||
public readonly project: ProjectManager;
|
||||
public readonly media: MediaManager;
|
||||
public readonly renderer: RendererManager;
|
||||
public readonly save: SaveManager;
|
||||
public readonly audio: AudioManager;
|
||||
public readonly selection: SelectionManager;
|
||||
|
||||
private constructor() {
|
||||
registerDefaultEffects();
|
||||
registerDefaultMasks();
|
||||
this.command = new CommandManager(this);
|
||||
this.timeline = new TimelineManager(this);
|
||||
this.playback = new PlaybackManager(this);
|
||||
this.scenes = new ScenesManager(this);
|
||||
this.project = new ProjectManager(this);
|
||||
this.media = new MediaManager(this);
|
||||
this.renderer = new RendererManager(this);
|
||||
this.save = new SaveManager(this);
|
||||
this.audio = new AudioManager(this);
|
||||
this.selection = new SelectionManager(this);
|
||||
this.command.registerReactor(() => {
|
||||
const tracks = this.timeline.getTracks();
|
||||
const prunedTracks = tracks.filter(
|
||||
(track) => track.elements.length > 0 || isMainTrack(track),
|
||||
);
|
||||
if (prunedTracks.length !== tracks.length) {
|
||||
this.timeline.updateTracks(prunedTracks);
|
||||
}
|
||||
});
|
||||
this.save.start();
|
||||
}
|
||||
|
||||
static getInstance(): EditorCore {
|
||||
if (!EditorCore.instance) {
|
||||
EditorCore.instance = new EditorCore();
|
||||
}
|
||||
return EditorCore.instance;
|
||||
}
|
||||
|
||||
static reset(): void {
|
||||
EditorCore.instance = null;
|
||||
}
|
||||
}
|
||||
import { PlaybackManager } from "./managers/playback-manager";
|
||||
import { TimelineManager } from "./managers/timeline-manager";
|
||||
import { ScenesManager } from "./managers/scenes-manager";
|
||||
import { ProjectManager } from "./managers/project-manager";
|
||||
import { MediaManager } from "./managers/media-manager";
|
||||
import { RendererManager } from "./managers/renderer-manager";
|
||||
import { CommandManager } from "./managers/commands";
|
||||
import { SaveManager } from "./managers/save-manager";
|
||||
import { AudioManager } from "./managers/audio-manager";
|
||||
import { SelectionManager } from "./managers/selection-manager";
|
||||
import { registerDefaultEffects } from "@/lib/effects";
|
||||
import { registerDefaultMasks } from "@/lib/masks";
|
||||
|
||||
export class EditorCore {
|
||||
private static instance: EditorCore | null = null;
|
||||
public readonly timeline: TimelineManager;
|
||||
public readonly command: CommandManager;
|
||||
public readonly playback: PlaybackManager;
|
||||
public readonly scenes: ScenesManager;
|
||||
public readonly project: ProjectManager;
|
||||
public readonly media: MediaManager;
|
||||
public readonly renderer: RendererManager;
|
||||
public readonly save: SaveManager;
|
||||
public readonly audio: AudioManager;
|
||||
public readonly selection: SelectionManager;
|
||||
|
||||
private constructor() {
|
||||
registerDefaultEffects();
|
||||
registerDefaultMasks();
|
||||
this.command = new CommandManager();
|
||||
this.timeline = new TimelineManager(this);
|
||||
this.playback = new PlaybackManager(this);
|
||||
this.scenes = new ScenesManager(this);
|
||||
this.project = new ProjectManager(this);
|
||||
this.media = new MediaManager(this);
|
||||
this.renderer = new RendererManager(this);
|
||||
this.save = new SaveManager(this);
|
||||
this.audio = new AudioManager(this);
|
||||
this.selection = new SelectionManager(this);
|
||||
this.save.start();
|
||||
}
|
||||
|
||||
static getInstance(): EditorCore {
|
||||
if (!EditorCore.instance) {
|
||||
EditorCore.instance = new EditorCore();
|
||||
}
|
||||
return EditorCore.instance;
|
||||
}
|
||||
|
||||
static reset(): void {
|
||||
EditorCore.instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { EditorCore } from "@/core";
|
||||
import {
|
||||
clampRetimeRate,
|
||||
shouldMaintainPitch,
|
||||
} from "@/lib/retime/rate";
|
||||
} from "@/constants/retime-constants";
|
||||
import type { AudioClipSource } from "@/lib/media/audio";
|
||||
import { createAudioContext, collectAudioClips } from "@/lib/media/audio";
|
||||
import {
|
||||
|
||||
@@ -1,116 +1,49 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { Command, CommandResult } from "@/lib/commands";
|
||||
import type { ElementRef } from "@/lib/timeline/types";
|
||||
|
||||
interface CommandHistoryEntry {
|
||||
command: Command;
|
||||
previousSelection: ElementRef[];
|
||||
selectionOverride?: ElementRef[];
|
||||
}
|
||||
|
||||
export class CommandManager {
|
||||
private history: CommandHistoryEntry[] = [];
|
||||
private redoStack: CommandHistoryEntry[] = [];
|
||||
private reactors: Array<() => void> = [];
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
execute({ command }: { command: Command }): Command {
|
||||
const previousSelection = this.getSelectionSnapshot();
|
||||
const result = command.execute();
|
||||
const selectionOverride = this.applySelectionOverride(result);
|
||||
this.runReactors();
|
||||
this.history.push({
|
||||
command,
|
||||
previousSelection,
|
||||
selectionOverride,
|
||||
});
|
||||
this.redoStack = [];
|
||||
return command;
|
||||
}
|
||||
|
||||
push({ command }: { command: Command }): void {
|
||||
this.history.push({
|
||||
command,
|
||||
previousSelection: this.getSelectionSnapshot(),
|
||||
});
|
||||
this.redoStack = [];
|
||||
}
|
||||
|
||||
registerReactor(reactor: () => void): void {
|
||||
this.reactors.push(reactor);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.history.length === 0) return;
|
||||
const entry = this.history.pop();
|
||||
entry?.command.undo();
|
||||
if (entry) {
|
||||
// Only restore selection for commands that explicitly changed it.
|
||||
// Commands without selection intent leave selection untouched,
|
||||
// preserving any UI-driven selection changes (clicks, box select)
|
||||
// that happened between commands. Commands that remove elements
|
||||
// must declare { select: [] } to clear stale refs.
|
||||
if (entry.selectionOverride !== undefined) {
|
||||
this.editor.selection.setSelectedElements({
|
||||
elements: [...entry.previousSelection],
|
||||
});
|
||||
}
|
||||
this.redoStack.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
if (this.redoStack.length === 0) return;
|
||||
const entry = this.redoStack.pop();
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSelection = this.getSelectionSnapshot();
|
||||
const result = entry.command.redo();
|
||||
const selectionOverride = this.applySelectionOverride(result);
|
||||
this.runReactors();
|
||||
|
||||
this.history.push({
|
||||
command: entry.command,
|
||||
previousSelection,
|
||||
selectionOverride,
|
||||
});
|
||||
}
|
||||
|
||||
canUndo(): boolean {
|
||||
return this.history.length > 0;
|
||||
}
|
||||
|
||||
canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.history = [];
|
||||
this.redoStack = [];
|
||||
}
|
||||
|
||||
private getSelectionSnapshot(): ElementRef[] {
|
||||
return [...this.editor.selection.getSelectedElements()];
|
||||
}
|
||||
|
||||
private applySelectionOverride(
|
||||
result: CommandResult | undefined,
|
||||
): ElementRef[] | undefined {
|
||||
if (result?.select === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selectionOverride = [...result.select];
|
||||
this.editor.selection.setSelectedElements({ elements: selectionOverride });
|
||||
return selectionOverride;
|
||||
}
|
||||
|
||||
private runReactors(): void {
|
||||
for (const reactor of this.reactors) {
|
||||
reactor();
|
||||
}
|
||||
}
|
||||
}
|
||||
import type { Command } from "@/lib/commands";
|
||||
|
||||
export class CommandManager {
|
||||
private history: Command[] = [];
|
||||
private redoStack: Command[] = [];
|
||||
|
||||
execute({ command }: { command: Command }): Command {
|
||||
command.execute();
|
||||
this.history.push(command);
|
||||
this.redoStack = [];
|
||||
return command;
|
||||
}
|
||||
|
||||
push({ command }: { command: Command }): void {
|
||||
this.history.push(command);
|
||||
this.redoStack = [];
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.history.length === 0) return;
|
||||
const command = this.history.pop();
|
||||
command?.undo();
|
||||
if (command) {
|
||||
this.redoStack.push(command);
|
||||
}
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
if (this.redoStack.length === 0) return;
|
||||
const command = this.redoStack.pop();
|
||||
command?.redo();
|
||||
if (command) {
|
||||
this.history.push(command);
|
||||
}
|
||||
}
|
||||
|
||||
canUndo(): boolean {
|
||||
return this.history.length > 0;
|
||||
}
|
||||
|
||||
canRedo(): boolean {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.history = [];
|
||||
this.redoStack = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ import { storageService } from "@/services/storage/service";
|
||||
import { toast } from "sonner";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
|
||||
import { DEFAULT_BACKGROUND_COLOR } from "@/lib/background/constants";
|
||||
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
|
||||
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
||||
import {
|
||||
DEFAULT_FPS,
|
||||
DEFAULT_CANVAS_SIZE,
|
||||
DEFAULT_COLOR,
|
||||
} from "@/constants/project-constants";
|
||||
import { buildDefaultScene, getProjectDurationFromScenes } from "@/lib/scenes";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||
@@ -27,7 +29,7 @@ import {
|
||||
import { loadFonts } from "@/lib/fonts/google-fonts";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { getElementFontFamilies } from "@/lib/timeline/element-utils";
|
||||
import { getRaisedProjectFpsForImportedMedia } from "@/lib/fps/utils";
|
||||
import { getRaisedProjectFpsForImportedMedia } from "@/lib/project/fps";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
|
||||
export interface MigrationState {
|
||||
@@ -99,7 +101,7 @@ export class ProjectManager {
|
||||
originalCanvasSize: null,
|
||||
background: {
|
||||
type: "color",
|
||||
color: DEFAULT_BACKGROUND_COLOR,
|
||||
color: DEFAULT_COLOR,
|
||||
},
|
||||
},
|
||||
version: CURRENT_PROJECT_VERSION,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,10 +11,9 @@ import type {
|
||||
AnimationPath,
|
||||
AnimationInterpolation,
|
||||
AnimationValue,
|
||||
ScalarCurveKeyframePatch,
|
||||
} from "@/lib/animation/types";
|
||||
import { calculateTotalDuration } from "@/lib/timeline";
|
||||
import { getLastFrameTime } from "opencut-wasm";
|
||||
import { getLastFrameTime } from "@/lib/time";
|
||||
import {
|
||||
AddTrackCommand,
|
||||
RemoveTrackCommand,
|
||||
@@ -37,7 +36,6 @@ import {
|
||||
UpsertKeyframeCommand,
|
||||
RemoveKeyframeCommand,
|
||||
RetimeKeyframeCommand,
|
||||
UpdateScalarKeyframeCurveCommand,
|
||||
AddClipEffectCommand,
|
||||
RemoveClipEffectCommand,
|
||||
UpdateClipEffectParamsCommand,
|
||||
@@ -47,7 +45,6 @@ import {
|
||||
ToggleMaskInvertedCommand,
|
||||
UpsertEffectParamKeyframeCommand,
|
||||
RemoveEffectParamKeyframeCommand,
|
||||
ToggleSourceAudioSeparationCommand,
|
||||
} from "@/lib/commands/timeline";
|
||||
import { BatchCommand } from "@/lib/commands";
|
||||
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
||||
@@ -268,7 +265,7 @@ export class TimelineManager {
|
||||
time: number;
|
||||
clipboardItems: ClipboardItem[];
|
||||
}): { trackId: string; elementId: string }[] {
|
||||
const command = new PasteCommand({ time, clipboardItems });
|
||||
const command = new PasteCommand(time, clipboardItems);
|
||||
this.editor.command.execute({ command });
|
||||
return command.getPastedElements();
|
||||
}
|
||||
@@ -284,20 +281,6 @@ export class TimelineManager {
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
toggleSourceAudioSeparation({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
}): void {
|
||||
const command = new ToggleSourceAudioSeparationCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
});
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
updateElements({
|
||||
updates,
|
||||
pushHistory = true,
|
||||
@@ -551,45 +534,6 @@ export class TimelineManager {
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
updateKeyframeCurves({
|
||||
keyframes,
|
||||
}: {
|
||||
keyframes: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPath;
|
||||
componentKey: string;
|
||||
keyframeId: string;
|
||||
patch: ScalarCurveKeyframePatch;
|
||||
}>;
|
||||
}): void {
|
||||
if (keyframes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commands = keyframes.map(
|
||||
({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
componentKey,
|
||||
keyframeId,
|
||||
patch,
|
||||
}) =>
|
||||
new UpdateScalarKeyframeCurveCommand({
|
||||
trackId,
|
||||
elementId,
|
||||
propertyPath,
|
||||
componentKey,
|
||||
keyframeId,
|
||||
patch,
|
||||
}),
|
||||
);
|
||||
const command =
|
||||
commands.length === 1 ? commands[0] : new BatchCommand(commands);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
upsertEffectParamKeyframe({
|
||||
trackId,
|
||||
elementId,
|
||||
|
||||
@@ -6,13 +6,9 @@ import { useActionHandler } from "@/hooks/actions/use-action-handler";
|
||||
import { useEditor } from "../use-editor";
|
||||
import { useElementSelection } from "../timeline/element/use-element-selection";
|
||||
import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection";
|
||||
import {
|
||||
getElementsAtTime,
|
||||
hasMediaId,
|
||||
} from "@/lib/timeline";
|
||||
import { getElementsAtTime } from "@/lib/timeline";
|
||||
import { cancelInteraction } from "@/lib/cancel-interaction";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { canToggleSourceAudio } from "@/lib/timeline/audio-separation";
|
||||
import {
|
||||
activateScope,
|
||||
clearActiveScope,
|
||||
@@ -265,43 +261,7 @@ export function useEditorActions() {
|
||||
elements: selectedElements,
|
||||
rippleEnabled: rippleEditingEnabled,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
useActionHandler(
|
||||
"toggle-source-audio",
|
||||
() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedElement = editor.timeline.getElementsWithTracks({
|
||||
elements: selectedElements,
|
||||
})[0];
|
||||
if (!selectedElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaAsset = (() => {
|
||||
const { element } = selectedElement;
|
||||
if (!hasMediaId(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
editor.media.getAssets().find((asset) => asset.id === element.mediaId) ??
|
||||
null
|
||||
);
|
||||
})();
|
||||
if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.toggleSourceAudioSeparation({
|
||||
trackId: selectedElement.track.id,
|
||||
elementId: selectedElement.element.id,
|
||||
});
|
||||
editor.selection.clearSelection();
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -10,10 +10,12 @@ import { useEditor } from "@/hooks/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
|
||||
import { snapTimeToFrame } from "opencut-wasm";
|
||||
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
|
||||
import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { snapTimeToFrame } from "@/lib/time";
|
||||
import { computeDropTarget } from "@/lib/timeline/drop-utils";
|
||||
import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
@@ -68,7 +70,7 @@ function getClickOffsetTime({
|
||||
zoomLevel: number;
|
||||
}): number {
|
||||
const clickOffsetX = clientX - elementRect.left;
|
||||
return clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
|
||||
return clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
}
|
||||
|
||||
function getVerticalDragDirection({
|
||||
@@ -134,7 +136,7 @@ function getDragDropTarget({
|
||||
playheadTime: snappedTime,
|
||||
isExternalDrop: false,
|
||||
elementDuration,
|
||||
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
startTimeOverride: snappedTime,
|
||||
excludeElementId: movingElement.id,
|
||||
@@ -286,10 +288,7 @@ export function useElementInteraction({
|
||||
if (isPendingDrag && pendingDragRef.current) {
|
||||
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
|
||||
const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY);
|
||||
if (
|
||||
deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
|
||||
deltaY > TIMELINE_DRAG_THRESHOLD_PX
|
||||
) {
|
||||
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
@@ -303,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,
|
||||
@@ -417,10 +419,7 @@ export function useElementInteraction({
|
||||
if (mouseDownLocationRef.current) {
|
||||
const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x);
|
||||
const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y);
|
||||
if (
|
||||
deltaX <= TIMELINE_DRAG_THRESHOLD_PX &&
|
||||
deltaY <= TIMELINE_DRAG_THRESHOLD_PX
|
||||
) {
|
||||
if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
|
||||
mouseDownLocationRef.current = null;
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
@@ -600,10 +599,7 @@ export function useElementInteraction({
|
||||
if (mouseDownLocationRef.current) {
|
||||
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
|
||||
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
|
||||
if (
|
||||
deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
|
||||
deltaY > TIMELINE_DRAG_THRESHOLD_PX
|
||||
) {
|
||||
if (deltaX > DRAG_THRESHOLD_PX || deltaY > DRAG_THRESHOLD_PX) {
|
||||
mouseDownLocationRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import { snapTimeToFrame } from "opencut-wasm";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
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";
|
||||
@@ -190,7 +190,7 @@ export function useTimelineElementResize({
|
||||
|
||||
const deltaX = clientX - resizing.startX;
|
||||
let deltaTime =
|
||||
deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
|
||||
deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
let resizeSnapPoint: SnapPoint | null = null;
|
||||
|
||||
const projectFps = editor.project.getActive().settings.fps;
|
||||
@@ -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);
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { getKeyframeById } from "@/lib/animation";
|
||||
import { useKeyframeSelection } from "./use-keyframe-selection";
|
||||
import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm";
|
||||
import { snapTimeToFrame, getSnappedSeekTime } from "@/lib/time";
|
||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
|
||||
import {
|
||||
DRAG_THRESHOLD_PX,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
|
||||
import { BatchCommand } from "@/lib/commands";
|
||||
import type { SelectedKeyframeRef } from "@/lib/animation/types";
|
||||
@@ -63,7 +64,7 @@ export function useKeyframeDrag({
|
||||
const activeProject = editor.project.getActive();
|
||||
const fps = activeProject.settings.fps;
|
||||
|
||||
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setDragState(initialDragState);
|
||||
@@ -85,11 +86,10 @@ export function useKeyframeDrag({
|
||||
deltaTime: number;
|
||||
}) => {
|
||||
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
|
||||
const keyframe = getKeyframeById({
|
||||
animations: element.animations,
|
||||
propertyPath: keyframeRef.propertyPath,
|
||||
keyframeId: keyframeRef.keyframeId,
|
||||
});
|
||||
const channel = element.animations?.channels[keyframeRef.propertyPath];
|
||||
const keyframe = channel?.keyframes.find(
|
||||
(keyframe) => keyframe.id === keyframeRef.keyframeId,
|
||||
);
|
||||
if (!keyframe) return [];
|
||||
const nextTime = Math.max(
|
||||
0,
|
||||
@@ -127,7 +127,7 @@ export function useKeyframeDrag({
|
||||
const handleMouseMove = ({ clientX }: MouseEvent) => {
|
||||
if (isPendingDrag && pendingDragRef.current) {
|
||||
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
|
||||
if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return;
|
||||
if (deltaX <= DRAG_THRESHOLD_PX) return;
|
||||
|
||||
const pending = pendingDragRef.current;
|
||||
pendingDragRef.current = null;
|
||||
@@ -248,14 +248,17 @@ export function useKeyframeDrag({
|
||||
|
||||
const wasDrag =
|
||||
mouseDownXRef.current !== null &&
|
||||
Math.abs(event.clientX - mouseDownXRef.current) >
|
||||
TIMELINE_DRAG_THRESHOLD_PX;
|
||||
Math.abs(event.clientX - mouseDownXRef.current) > DRAG_THRESHOLD_PX;
|
||||
mouseDownXRef.current = null;
|
||||
|
||||
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,278 +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 { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
|
||||
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 <= TIMELINE_DRAG_THRESHOLD_PX &&
|
||||
deltaY <= TIMELINE_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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { timelineTimeToSnappedPixels } from "@/lib/timeline";
|
||||
import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/components/editor/panels/timeline/layout";
|
||||
import { TRACK_LABELS_WIDTH_PX } from "@/constants/timeline-constants";
|
||||
interface UseSnapIndicatorPositionParams {
|
||||
snapPoint: { time: number } | null;
|
||||
zoomLevel: number;
|
||||
@@ -44,8 +44,7 @@ export function useSnapIndicatorPosition({
|
||||
time: snapPoint?.time ?? 0,
|
||||
zoomLevel,
|
||||
});
|
||||
const leftPosition =
|
||||
TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX + timelinePosition - scrollLeft;
|
||||
const leftPosition = TRACK_LABELS_WIDTH_PX + timelinePosition - scrollLeft;
|
||||
|
||||
return {
|
||||
leftPosition,
|
||||
|
||||
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";
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getCenteredLineLeft,
|
||||
timelineTimeToSnappedPixels,
|
||||
} from "@/lib/timeline";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
zoomLevel: number;
|
||||
@@ -67,7 +67,7 @@ export function useTimelinePlayhead({
|
||||
const relativeMouseX = event.clientX - rulerRect.left;
|
||||
|
||||
const timelineContentWidth =
|
||||
duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
const clampedMouseX = Math.max(
|
||||
0,
|
||||
@@ -78,12 +78,16 @@ export function useTimelinePlayhead({
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||
clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
|
||||
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 = (() => {
|
||||
@@ -161,7 +165,7 @@ export function useTimelinePlayhead({
|
||||
getMouseClientX: () => lastMouseXRef.current,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
|
||||
contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -248,7 +252,7 @@ export function useTimelinePlayhead({
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
|
||||
const playheadPixels =
|
||||
time * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevelRef.current;
|
||||
time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevelRef.current;
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const scrollMinimum = 0;
|
||||
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
|
||||
|
||||
@@ -1,193 +1,197 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
|
||||
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) /
|
||||
(BASE_TIMELINE_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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,14 +7,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD,
|
||||
} from "@/components/editor/panels/timeline/interaction";
|
||||
import {
|
||||
TIMELINE_ZOOM_MAX,
|
||||
TIMELINE_ZOOM_MIN,
|
||||
BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
} from "@/lib/timeline/scale";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { zoomToSlider } from "@/lib/timeline/zoom-utils";
|
||||
|
||||
@@ -37,7 +30,7 @@ interface UseTimelineZoomReturn {
|
||||
|
||||
export function useTimelineZoom({
|
||||
containerRef,
|
||||
minZoom = TIMELINE_ZOOM_MIN,
|
||||
minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
|
||||
initialZoom,
|
||||
initialScrollLeft,
|
||||
initialPlayheadTime,
|
||||
@@ -56,7 +49,7 @@ export function useTimelineZoom({
|
||||
hasInitializedRef.current = true;
|
||||
return Math.max(
|
||||
minZoom,
|
||||
Math.min(TIMELINE_ZOOM_MAX, initialZoom),
|
||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom),
|
||||
);
|
||||
}
|
||||
return minZoom;
|
||||
@@ -98,7 +91,7 @@ export function useTimelineZoom({
|
||||
setZoomLevel((prev) => {
|
||||
const nextZoom = Math.max(
|
||||
minZoom,
|
||||
Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor),
|
||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, prev * zoomFactor),
|
||||
);
|
||||
return nextZoom;
|
||||
});
|
||||
@@ -112,7 +105,7 @@ export function useTimelineZoom({
|
||||
if (initialZoom !== undefined && !hasInitializedRef.current) {
|
||||
hasInitializedRef.current = true;
|
||||
setZoomLevel(
|
||||
Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)),
|
||||
Math.max(minZoom, Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, initialZoom)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -133,7 +126,7 @@ export function useTimelineZoom({
|
||||
: zoomLevelOrUpdater;
|
||||
const clampedZoom = Math.max(
|
||||
minZoom,
|
||||
Math.min(TIMELINE_ZOOM_MAX, nextZoom),
|
||||
Math.min(TIMELINE_CONSTANTS.ZOOM_MAX, nextZoom),
|
||||
);
|
||||
return clampedZoom;
|
||||
});
|
||||
@@ -160,12 +153,12 @@ export function useTimelineZoom({
|
||||
});
|
||||
const isCrossingThresholdUp =
|
||||
previousSliderPercent <
|
||||
TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
const isCrossingThresholdDown =
|
||||
previousSliderPercent >=
|
||||
TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent < TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
|
||||
const syncScroll = (scrollLeft: number) => {
|
||||
scrollElement.scrollLeft = scrollLeft;
|
||||
@@ -185,11 +178,11 @@ export function useTimelineZoom({
|
||||
isInPlayheadAnchorModeRef.current = true;
|
||||
}
|
||||
|
||||
if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
|
||||
if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
|
||||
const playheadPixelsBefore =
|
||||
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * previousZoom;
|
||||
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * previousZoom;
|
||||
const playheadPixelsAfter =
|
||||
playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
playheadTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
|
||||
const newScrollLeft = playheadPixelsAfter - viewportOffset;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { buildElementFromMedia } from "@/lib/timeline/element-utils";
|
||||
import { AddMediaAssetCommand } from "@/lib/commands/media";
|
||||
import { InsertElementCommand } from "@/lib/commands/timeline";
|
||||
import { BatchCommand } from "@/lib/commands";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { isTypableDOMElement } from "@/utils/browser";
|
||||
import type { MediaType } from "@/lib/media/types";
|
||||
|
||||
@@ -74,7 +74,7 @@ export function usePasteMedia() {
|
||||
);
|
||||
const assetId = addMediaCmd.getAssetId();
|
||||
const duration =
|
||||
asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS;
|
||||
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
const trackType = asset.type === "audio" ? "audio" : "video";
|
||||
|
||||
const element = buildElementFromMedia({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -101,10 +101,6 @@ export const ACTIONS = {
|
||||
description: "Toggle ripple editing",
|
||||
category: "editing",
|
||||
},
|
||||
"toggle-source-audio": {
|
||||
description: "Extract or recover source audio",
|
||||
category: "editing",
|
||||
},
|
||||
"select-all": {
|
||||
description: "Select all elements",
|
||||
category: "selection",
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
composeAnimationValue,
|
||||
createAnimationBinding,
|
||||
} from "@/lib/animation/binding-values";
|
||||
|
||||
describe("binding values", () => {
|
||||
test("formats composed animated colors as hex", () => {
|
||||
const binding = createAnimationBinding({
|
||||
path: "color",
|
||||
kind: "color",
|
||||
});
|
||||
|
||||
expect(
|
||||
composeAnimationValue({
|
||||
binding,
|
||||
componentValues: {
|
||||
r: 1,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 1,
|
||||
},
|
||||
}),
|
||||
).toBe("#ff0000");
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
getElementKeyframes,
|
||||
getKeyframeById,
|
||||
getKeyframeAtTime,
|
||||
} from "@/lib/animation/keyframe-query";
|
||||
import type {
|
||||
ElementAnimations,
|
||||
ScalarAnimationKey,
|
||||
} from "@/lib/animation/types";
|
||||
|
||||
function createScalarKey({
|
||||
id,
|
||||
time,
|
||||
value,
|
||||
}: {
|
||||
id: string;
|
||||
time: number;
|
||||
value: number;
|
||||
}): ScalarAnimationKey {
|
||||
return {
|
||||
id,
|
||||
time,
|
||||
value,
|
||||
segmentToNext: "linear",
|
||||
tangentMode: "flat",
|
||||
};
|
||||
}
|
||||
|
||||
function buildPositionAnimations({
|
||||
xKeys,
|
||||
yKeys,
|
||||
}: {
|
||||
xKeys: ScalarAnimationKey[];
|
||||
yKeys: ScalarAnimationKey[];
|
||||
}): ElementAnimations {
|
||||
return {
|
||||
bindings: {
|
||||
"transform.position": {
|
||||
path: "transform.position",
|
||||
kind: "vector2",
|
||||
components: [
|
||||
{ key: "x", channelId: "transform.position:x" },
|
||||
{ key: "y", channelId: "transform.position:y" },
|
||||
],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
"transform.position:x": {
|
||||
kind: "scalar",
|
||||
keys: xKeys,
|
||||
},
|
||||
"transform.position:y": {
|
||||
kind: "scalar",
|
||||
keys: yKeys,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("keyframe query", () => {
|
||||
test("returns keyframes from any component channel", () => {
|
||||
const animations = buildPositionAnimations({
|
||||
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
|
||||
yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
|
||||
});
|
||||
|
||||
expect(
|
||||
getElementKeyframes({ animations }).map(({ id, time }) => ({
|
||||
id,
|
||||
time,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "x-1", time: 1 },
|
||||
{ id: "y-2", time: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds a keyframe at time on a non-primary component", () => {
|
||||
const animations = buildPositionAnimations({
|
||||
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
|
||||
yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
|
||||
});
|
||||
|
||||
expect(
|
||||
getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.position",
|
||||
time: 2,
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: "y-2",
|
||||
time: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("finds a keyframe by id on a non-primary component", () => {
|
||||
const animations = buildPositionAnimations({
|
||||
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
|
||||
yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })],
|
||||
});
|
||||
|
||||
expect(
|
||||
getKeyframeById({
|
||||
animations,
|
||||
propertyPath: "transform.position",
|
||||
keyframeId: "y-2",
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: "y-2",
|
||||
time: 2,
|
||||
value: { x: 10, y: 20 },
|
||||
});
|
||||
});
|
||||
|
||||
test("prefers the primary component when multiple components share a time", () => {
|
||||
const animations = buildPositionAnimations({
|
||||
xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })],
|
||||
yKeys: [createScalarKey({ id: "y-1", time: 1, value: 20 })],
|
||||
});
|
||||
|
||||
expect(
|
||||
getElementKeyframes({ animations }).map(({ id, time }) => ({
|
||||
id,
|
||||
time,
|
||||
})),
|
||||
).toEqual([{ id: "x-1", time: 1 }]);
|
||||
expect(
|
||||
getKeyframeAtTime({
|
||||
animations,
|
||||
propertyPath: "transform.position",
|
||||
time: 1,
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: "x-1",
|
||||
time: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { ScalarAnimationKey } from "@/lib/animation/types";
|
||||
|
||||
export function getBezierPoint({
|
||||
progress,
|
||||
p0,
|
||||
p1,
|
||||
p2,
|
||||
p3,
|
||||
}: {
|
||||
progress: number;
|
||||
p0: number;
|
||||
p1: number;
|
||||
p2: number;
|
||||
p3: number;
|
||||
}) {
|
||||
const mt = 1 - progress;
|
||||
return (
|
||||
mt * mt * mt * p0 +
|
||||
3 * mt * mt * progress * p1 +
|
||||
3 * mt * progress * progress * p2 +
|
||||
progress * progress * progress * p3
|
||||
);
|
||||
}
|
||||
|
||||
export function getDefaultRightHandle({
|
||||
leftKey,
|
||||
rightKey,
|
||||
}: {
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
}) {
|
||||
const span = rightKey.time - leftKey.time;
|
||||
const valueDelta = rightKey.value - leftKey.value;
|
||||
return {
|
||||
dt: span / 3,
|
||||
dv: valueDelta / 3,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultLeftHandle({
|
||||
leftKey,
|
||||
rightKey,
|
||||
}: {
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
}) {
|
||||
const span = rightKey.time - leftKey.time;
|
||||
const valueDelta = rightKey.value - leftKey.value;
|
||||
return {
|
||||
dt: -span / 3,
|
||||
dv: -valueDelta / 3,
|
||||
};
|
||||
}
|
||||
|
||||
export function solveBezierProgressForTime({
|
||||
time,
|
||||
leftKey,
|
||||
rightKey,
|
||||
}: {
|
||||
time: number;
|
||||
leftKey: ScalarAnimationKey;
|
||||
rightKey: ScalarAnimationKey;
|
||||
}) {
|
||||
let lower = 0;
|
||||
let upper = 1;
|
||||
const rightHandle =
|
||||
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
|
||||
const leftHandle =
|
||||
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
|
||||
|
||||
for (let iteration = 0; iteration < 20; iteration++) {
|
||||
const mid = (lower + upper) / 2;
|
||||
const estimate = getBezierPoint({
|
||||
progress: mid,
|
||||
p0: leftKey.time,
|
||||
p1: leftKey.time + rightHandle.dt,
|
||||
p2: rightKey.time + leftHandle.dt,
|
||||
p3: rightKey.time,
|
||||
});
|
||||
if (estimate < time) {
|
||||
lower = mid;
|
||||
} else {
|
||||
upper = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return (lower + upper) / 2;
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
import { converter, formatHex, formatHex8, parse } from "culori";
|
||||
import type {
|
||||
AnimationBindingComponent,
|
||||
AnimationBindingOfKind,
|
||||
AnimationBindingInstance,
|
||||
AnimationBindingKind,
|
||||
ColorAnimationBinding,
|
||||
DiscreteAnimationBinding,
|
||||
NumberAnimationBinding,
|
||||
AnimationPath,
|
||||
AnimationValue,
|
||||
DiscreteValue,
|
||||
Vector2AnimationBinding,
|
||||
VectorValue,
|
||||
} from "@/lib/animation/types";
|
||||
|
||||
interface LinearRgba {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
export type AnimationComponentValue = number | DiscreteValue;
|
||||
|
||||
const toRgb = converter("rgb");
|
||||
|
||||
function clamp01({ value }: { value: number }): number {
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function srgbToLinear({ value }: { value: number }): number {
|
||||
return value <= 0.04045
|
||||
? value / 12.92
|
||||
: Math.pow((value + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
function linearToSrgb({ value }: { value: number }): number {
|
||||
const clamped = clamp01({ value });
|
||||
return clamped <= 0.0031308
|
||||
? clamped * 12.92
|
||||
: 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function isVectorValue(value: unknown): value is VectorValue {
|
||||
return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
|
||||
}
|
||||
|
||||
export function getBindingComponentKeys({
|
||||
kind,
|
||||
}: {
|
||||
kind: AnimationBindingKind;
|
||||
}): string[] {
|
||||
if (kind === "vector2") {
|
||||
return ["x", "y"];
|
||||
}
|
||||
|
||||
if (kind === "color") {
|
||||
return ["r", "g", "b", "a"];
|
||||
}
|
||||
|
||||
return ["value"];
|
||||
}
|
||||
|
||||
export function buildBindingChannelId({
|
||||
path,
|
||||
componentKey,
|
||||
}: {
|
||||
path: AnimationPath;
|
||||
componentKey: string;
|
||||
}): string {
|
||||
return `${path}:${componentKey}`;
|
||||
}
|
||||
|
||||
function createBindingComponent<TKey extends string>({
|
||||
path,
|
||||
key,
|
||||
}: {
|
||||
path: AnimationPath;
|
||||
key: TKey;
|
||||
}): AnimationBindingComponent<TKey> {
|
||||
return {
|
||||
key,
|
||||
channelId: buildBindingChannelId({ path, componentKey: key }),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneBindingComponents<TKey extends string>({
|
||||
components,
|
||||
}: {
|
||||
components: AnimationBindingComponent<TKey>[];
|
||||
}): AnimationBindingComponent<TKey>[] {
|
||||
return components.map((component) => ({ ...component }));
|
||||
}
|
||||
|
||||
const animationBindingFactories = {
|
||||
color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({
|
||||
path,
|
||||
kind: "color",
|
||||
colorSpace: "srgb-linear",
|
||||
components: [
|
||||
createBindingComponent({ path, key: "r" }),
|
||||
createBindingComponent({ path, key: "g" }),
|
||||
createBindingComponent({ path, key: "b" }),
|
||||
createBindingComponent({ path, key: "a" }),
|
||||
],
|
||||
}),
|
||||
vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({
|
||||
path,
|
||||
kind: "vector2",
|
||||
components: [
|
||||
createBindingComponent({ path, key: "x" }),
|
||||
createBindingComponent({ path, key: "y" }),
|
||||
],
|
||||
}),
|
||||
number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({
|
||||
path,
|
||||
kind: "number",
|
||||
components: [createBindingComponent({ path, key: "value" })],
|
||||
}),
|
||||
discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({
|
||||
path,
|
||||
kind: "discrete",
|
||||
components: [createBindingComponent({ path, key: "value" })],
|
||||
}),
|
||||
} satisfies {
|
||||
[K in AnimationBindingKind]: ({
|
||||
path,
|
||||
}: {
|
||||
path: AnimationPath;
|
||||
}) => AnimationBindingOfKind<K>;
|
||||
};
|
||||
|
||||
export function createAnimationBinding<TKind extends AnimationBindingKind>({
|
||||
path,
|
||||
kind,
|
||||
}: {
|
||||
path: AnimationPath;
|
||||
kind: TKind;
|
||||
}): AnimationBindingOfKind<TKind>;
|
||||
export function createAnimationBinding({
|
||||
path,
|
||||
kind,
|
||||
}: {
|
||||
path: AnimationPath;
|
||||
kind: AnimationBindingKind;
|
||||
}): AnimationBindingInstance {
|
||||
return animationBindingFactories[kind]({ path });
|
||||
}
|
||||
|
||||
const animationBindingCloners = {
|
||||
color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({
|
||||
...binding,
|
||||
components: cloneBindingComponents({
|
||||
components: binding.components,
|
||||
}),
|
||||
}),
|
||||
vector2: ({
|
||||
binding,
|
||||
}: {
|
||||
binding: Vector2AnimationBinding;
|
||||
}): Vector2AnimationBinding => ({
|
||||
...binding,
|
||||
components: cloneBindingComponents({
|
||||
components: binding.components,
|
||||
}),
|
||||
}),
|
||||
number: ({
|
||||
binding,
|
||||
}: {
|
||||
binding: NumberAnimationBinding;
|
||||
}): NumberAnimationBinding => ({
|
||||
...binding,
|
||||
components: cloneBindingComponents({
|
||||
components: binding.components,
|
||||
}),
|
||||
}),
|
||||
discrete: ({
|
||||
binding,
|
||||
}: {
|
||||
binding: DiscreteAnimationBinding;
|
||||
}): DiscreteAnimationBinding => ({
|
||||
...binding,
|
||||
components: cloneBindingComponents({
|
||||
components: binding.components,
|
||||
}),
|
||||
}),
|
||||
} satisfies {
|
||||
[K in AnimationBindingKind]: ({
|
||||
binding,
|
||||
}: {
|
||||
binding: AnimationBindingOfKind<K>;
|
||||
}) => AnimationBindingOfKind<K>;
|
||||
};
|
||||
|
||||
export function cloneAnimationBinding<TKind extends AnimationBindingKind>({
|
||||
binding,
|
||||
}: {
|
||||
binding: AnimationBindingOfKind<TKind>;
|
||||
}): AnimationBindingOfKind<TKind>;
|
||||
export function cloneAnimationBinding({
|
||||
binding,
|
||||
}: {
|
||||
binding: AnimationBindingInstance;
|
||||
}): AnimationBindingInstance {
|
||||
switch (binding.kind) {
|
||||
case "color":
|
||||
return animationBindingCloners.color({ binding });
|
||||
case "vector2":
|
||||
return animationBindingCloners.vector2({ binding });
|
||||
case "number":
|
||||
return animationBindingCloners.number({ binding });
|
||||
case "discrete":
|
||||
return animationBindingCloners.discrete({ binding });
|
||||
}
|
||||
}
|
||||
|
||||
export function parseColorToLinearRgba({
|
||||
color,
|
||||
}: {
|
||||
color: string;
|
||||
}): LinearRgba | null {
|
||||
const parsed = parse(color);
|
||||
const rgb = parsed ? toRgb(parsed) : null;
|
||||
if (!rgb) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
r: srgbToLinear({ value: rgb.r ?? 0 }),
|
||||
g: srgbToLinear({ value: rgb.g ?? 0 }),
|
||||
b: srgbToLinear({ value: rgb.b ?? 0 }),
|
||||
a: clamp01({ value: rgb.alpha ?? 1 }),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatLinearRgba({
|
||||
color,
|
||||
}: {
|
||||
color: LinearRgba;
|
||||
}): string {
|
||||
const rgb = {
|
||||
mode: "rgb",
|
||||
r: linearToSrgb({ value: color.r }),
|
||||
g: linearToSrgb({ value: color.g }),
|
||||
b: linearToSrgb({ value: color.b }),
|
||||
alpha: clamp01({ value: color.a }),
|
||||
} as const;
|
||||
return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
|
||||
}
|
||||
|
||||
export function decomposeAnimationValue({
|
||||
kind,
|
||||
value,
|
||||
}: {
|
||||
kind: AnimationBindingKind;
|
||||
value: AnimationValue;
|
||||
}): Record<string, AnimationComponentValue> | null {
|
||||
if (kind === "number") {
|
||||
return typeof value === "number" ? { value } : null;
|
||||
}
|
||||
|
||||
if (kind === "vector2") {
|
||||
return isVectorValue(value) ? { x: value.x, y: value.y } : null;
|
||||
}
|
||||
|
||||
if (kind === "color") {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseColorToLinearRgba({ color: value });
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
r: parsed.r,
|
||||
g: parsed.g,
|
||||
b: parsed.b,
|
||||
a: parsed.a,
|
||||
};
|
||||
}
|
||||
|
||||
return typeof value === "string" || typeof value === "boolean"
|
||||
? { value }
|
||||
: null;
|
||||
}
|
||||
|
||||
export function composeAnimationValue({
|
||||
binding,
|
||||
componentValues,
|
||||
}: {
|
||||
binding: AnimationBindingInstance;
|
||||
componentValues: Record<string, AnimationComponentValue | undefined>;
|
||||
}): AnimationValue | null {
|
||||
if (binding.kind === "number") {
|
||||
const value = componentValues.value;
|
||||
return typeof value === "number" ? value : null;
|
||||
}
|
||||
|
||||
if (binding.kind === "vector2") {
|
||||
const x = componentValues.x;
|
||||
const y = componentValues.y;
|
||||
return typeof x === "number" && typeof y === "number" ? { x, y } : null;
|
||||
}
|
||||
|
||||
if (binding.kind === "color") {
|
||||
const r = componentValues.r;
|
||||
const g = componentValues.g;
|
||||
const b = componentValues.b;
|
||||
const a = componentValues.a;
|
||||
if (
|
||||
typeof r !== "number" ||
|
||||
typeof g !== "number" ||
|
||||
typeof b !== "number" ||
|
||||
typeof a !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return formatLinearRgba({
|
||||
color: {
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
a,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const value = componentValues.value;
|
||||
return typeof value === "string" || typeof value === "boolean" ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type {
|
||||
AnimationPropertyPath,
|
||||
ColorAnimationChannel,
|
||||
ElementAnimations,
|
||||
} from "@/lib/animation/types";
|
||||
|
||||
export function getColorChannelForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
}): ColorAnimationChannel | undefined {
|
||||
const channel = animations?.channels[propertyPath];
|
||||
if (!channel || channel.valueKind !== "color") {
|
||||
return undefined;
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user