Compare commits

..
15 Commits
287 changed files with 14352 additions and 22305 deletions
+3 -2
View File
@@ -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
+5 -20
View File
@@ -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:
-2
View File
@@ -22,5 +22,3 @@ bun.lockb
.open-next
target/
.cargo-tools/
+113 -9
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -2,7 +2,4 @@
resolver = "2"
members = [
"apps/desktop",
"rust/crates/time",
"rust/crates/bridge",
"rust/wasm", "rust/crates/gpu",
]
+129 -166
View File
@@ -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)
---
![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)
<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)
---
![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)
+7
View File
@@ -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
View File
@@ -7,8 +7,4 @@
.next/
.font-cache/
next-env.d.ts
*.tsbuildinfo
.font-cache/
+10
View File
@@ -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
+2 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+6
View File
@@ -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.
+11
View File
@@ -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",
},
-3
View File
@@ -1,3 +0,0 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig();
+5 -3
View File
@@ -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",
+280 -280
View File
@@ -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 },
);
}
}
+20 -17
View File
@@ -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) => (
+4 -14
View File
@@ -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 }),
);
}
+1 -1
View File
@@ -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"] });
File diff suppressed because it is too large Load Diff
+150 -149
View File
@@ -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>
);
}
@@ -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,
@@ -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;
}
-5
View File
@@ -1,5 +0,0 @@
export interface IconProps {
size?: number;
className?: string;
strokeWidth?: number;
}
+21 -51
View File
@@ -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;
}
+4 -4
View File
@@ -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,
)}
+4 -4
View File
@@ -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 });
}
+69 -69
View File
@@ -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,
},
];
+11 -1
View File
@@ -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
View File
@@ -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;
}
}
+1 -1
View File
@@ -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 {
+49 -116
View File
@@ -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";
+1 -1
View File
@@ -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,
+2 -58
View File
@@ -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) {
+281 -278
View File
@@ -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;
+197 -193
View File
@@ -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;
+2 -2
View File
@@ -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
-4
View File
@@ -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,
});
});
});
-88
View File
@@ -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