Compare commits

..
Author SHA1 Message Date
Maze Winther 70a6d58b84 refactor: centralize track placement into its own module 2026-03-31 15:47:30 +02:00
Maze Winther 5cc6abc3a4 style: swap ripple icon 2026-03-29 22:31:26 +02:00
Maze Winther 77a14b1023 refactor: standardize icon component props 2026-03-29 22:30:11 +02:00
Maze Winther 1c3b22edec feat: add keyframe graphs button 2026-03-29 21:48:33 +02:00
Maze Winther 41562baeb5 style: remove unnecessary margin from keyframe 2026-03-29 21:48:08 +02:00
Maze Winther df207e0f83 refactor: name magic numbers and rename el in landing handlebars 2026-03-29 21:18:07 +02:00
Maze Winther dc5956900e fix: eliminate first-paint flicker in landing handlebars 2026-03-29 20:45:48 +02:00
Maze Winther b095862cc0 fix: improve DATABASE_URL validation in web environment schema 2026-03-29 18:58:35 +02:00
Maze Winther 0a6ede73ae chore: update deps 2026-03-29 18:57:05 +02:00
Maze Winther 7ce0018c9b chore: update .gitignore 2026-03-29 18:55:56 +02:00
Maze Winther 9528d0053b chore: untrack files 2026-03-29 18:55:10 +02:00
Maze Winther 689853312d fix: remove OpenNext Cloudflare initialization 2026-03-29 18:35:58 +02:00
Maze Winther 5a1d9ef3f4 chore: remove unused middleware file 2026-03-29 18:30:16 +02:00
Rayan SalhabGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Mazecoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
eb66e03f0e fix(security): replace new Function() with safe math expression parser (#753)
* fix(security): replace new Function() with safe math expression parser

Replace the dangerous new Function() constructor in evaluateMathExpression()
with a recursive descent parser that safely evaluates basic arithmetic
expressions.

The Function constructor is a security anti-pattern that could enable
arbitrary code execution if the validation regex is ever bypassed or
relaxed. The new parser:

- Only supports numbers, +, -, *, /, parentheses, and whitespace
- Has no eval-like functionality that could execute arbitrary code
- Maintains backward compatibility with existing expressions
- Handles operator precedence and parentheses correctly

Fixes #725

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Maze <167211895+mazeincoding@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-29 18:27:05 +02:00
Maze Winther a2418751e6 chore: remove accidentally committed Zone.Identifier stream 2026-03-29 18:25:43 +02:00
Maze Winther 5777a555f6 feat: add shared Rust crates for cross-platform time utilities
Introduce `bridge` proc-macro crate with `#[export]` attribute that conditionally applies `wasm_bindgen` for WASM builds, and `time` crate porting all timecode/frame utilities from TypeScript to Rust.
2026-03-29 15:58:46 +02:00
Maze Winther 220ec757d4 refactor: move packages sub-folders into web app 2026-03-29 15:58:46 +02:00
Maze Winther 585e28d49b feat: add published status to changelog entries 2026-03-29 15:58:46 +02:00
Maze Winther 1261e663e1 docs: rewrite agents file 2026-03-29 15:58:46 +02:00
Maze Winther c34e01c63e refactor: clean up number formatting and step handling in property tabs 2026-03-29 15:58:46 +02:00
Maze Winther 3e75852ffe chore: remove dead songs tab 2026-03-29 15:58:46 +02:00
Maze Winther e95992b406 chore: add missing open-next config 2026-03-29 15:58:46 +02:00
Maze Winther 6df9d70b9b chore: update dep 2026-03-29 15:58:46 +02:00
Maze Winther f8a0390827 chore: rename proxy to middleware 2026-03-29 15:58:46 +02:00
Maze Winther d862e56e34 feat: init desktop app 2026-03-29 15:58:42 +02:00
Maze Winther d08fbd73e1 fix: add missing file 2026-03-29 15:57:05 +02:00
Maze Winther 4d127a038b chore: normalize line endings 2026-03-29 15:56:48 +02:00
Maze Winther 82817cb50c chore: next-env 2026-03-29 15:49:30 +02:00
Maze Winther a54b3ef076 chore: update botid 2026-03-29 15:49:30 +02:00
Maze Winther 6dd8191f4b chore: init cloudflare 2026-03-29 15:49:30 +02:00
Maze Winther 722dcdd88f fix: resolve typescript/lint errors 2026-03-29 15:49:30 +02:00
Maze Winther 8db3bead13 feat: masks, properties refactor, shaders, storage migrations, and more 2026-03-29 15:48:22 +02:00
Maze Winther 39ea298a9c chore: gitignore 2026-03-29 15:46:35 +02:00
Maze Winther 2c0fd4f86b untrack .cursor/ 2026-03-29 15:46:35 +02:00
Maze Winther 5e2b829f8a changelog: add already-completed item 2026-03-29 15:46:35 +02:00
Maze Winther b09635064a fix: assets having unreasonable widths and heights 2026-03-29 15:41:10 +02:00
Maze Winther 6018efc5ac changelog: new version with already-made changes 2026-03-29 15:41:10 +02:00
AspenandGitHub 7e2a913e13 Update OpenCut logo in README (#741) 2026-03-28 19:07:36 +01:00
kcfancherandGitHub 63496d05a2 Fix transcription sample-rate handling (#740)
Fix for #703.

Adds an optional sampleRate parameter to the decodeAudioToFloat32() function. The caption transcriber passes in a value of 16000 to prepare audio in the format Whisper expects.
2026-03-28 19:06:40 +01:00
91 changed files with 6435 additions and 5195 deletions
+9 -113
View File
@@ -1,121 +1,17 @@
# AGENTS.md
# Agents.md
## Overview
## Apps
Privacy-first video editor, with a focus on simplicity and ease of use.
- Web
- Desktop
## Lib vs Utils
## Rust
- `lib/` - domain logic (specific to this app)
- `utils/` - small helper utils (generic, could be copy-pasted into any other app)
Shared code between apps live in `rust/`, not in `packages/`
## Core Editor System
## Web
The editor uses a **singleton EditorCore** that manages all editor state through specialized managers.
### React
### Architecture
- Read components before using them. They may already apply classes, which affects what you need to pass and how to override them.
```
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)".
+2
View File
@@ -2,4 +2,6 @@
resolver = "2"
members = [
"apps/desktop",
"rust/crates/time"
"rust/crates/bridge"
]
+129 -129
View File
@@ -1,129 +1,129 @@
<table width="100%">
<tr>
<td align="left" width="120">
<img src="apps/web/public/logos/opencut/1k/logo-white-black.png" alt="OpenCut Logo" width="100" />
</td>
<td align="right">
<h1>OpenCut</span></h1>
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
</td>
</tr>
</table>
## Sponsors
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
<a href="https://vercel.com/oss">
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a>
<a href="https://fal.ai">
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
</a>
## Why?
- **Privacy**: Your videos stay on your device
- **Free features**: Most basic CapCut features are now paywalled
- **Simple**: People want editors that are easy to use - CapCut proved that
## Features
- Timeline-based editing
- Multi-track support
- Real-time preview
- No watermarks or subscriptions
- Analytics provided by [Databuddy](https://www.databuddy.cc?utm_source=opencut), 100% Anonymized & Non-invasive.
- Blog powered by [Marble](https://marblecms.com?utm_source=opencut), Headless CMS.
## Project Structure
- `apps/web/` – Main Next.js web application
- `apps/desktop/` – Native desktop app (in progress)
- `src/components/` – UI and editor components
- `src/hooks/` – Custom React hooks
- `src/lib/` – Utility and API logic
- `src/stores/` – State management (Zustand, etc.)
- `src/types/` – TypeScript types
## Getting Started
### Prerequisites
- [Bun](https://bun.sh/docs/installation)
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it.
### Setup
1. Fork and clone the repository
2. Copy the environment file:
```bash
# Unix/Linux/Mac
cp apps/web/.env.example apps/web/.env.local
# Windows PowerShell
Copy-Item apps/web/.env.example apps/web/.env.local
```
3. Start the database and Redis:
```bash
docker compose up -d db redis serverless-redis-http
```
4. Install dependencies and start the dev server:
```bash
bun install
bun dev:web
```
The application will be available at [http://localhost:3000](http://localhost:3000).
The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
### Desktop setup
Desktop is opt-in. If you're only working on the web app, skip this entirely.
If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps/desktop/README.md). It's a two-step setup: Rust toolchain first, then desktop native dependencies.
### Self-Hosting with Docker
To run everything (including a production build of the app) in Docker:
```bash
docker compose up -d
```
The app will be available at [http://localhost:3100](http://localhost:3100).
## Contributing
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
**Quick start for contributors:**
- Fork the repo and clone locally
- Follow the setup instructions in CONTRIBUTING.md
- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup
- Create a feature branch and submit a PR
## License
[MIT LICENSE](LICENSE)
---
![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/icon.svg" alt="OpenCut Logo" width="100" />
</td>
<td align="right">
<h1>OpenCut</h1>
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
</td>
</tr>
</table>
## Sponsors
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
<a href="https://vercel.com/oss">
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a>
<a href="https://fal.ai">
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
</a>
## Why?
- **Privacy**: Your videos stay on your device
- **Free features**: Most basic CapCut features are now paywalled
- **Simple**: People want editors that are easy to use - CapCut proved that
## Features
- Timeline-based editing
- Multi-track support
- Real-time preview
- No watermarks or subscriptions
- Analytics provided by [Databuddy](https://www.databuddy.cc?utm_source=opencut), 100% Anonymized & Non-invasive.
- Blog powered by [Marble](https://marblecms.com?utm_source=opencut), Headless CMS.
## Project Structure
- `apps/web/` – Main Next.js web application
- `apps/desktop/` – Native desktop app (in progress)
- `src/components/` – UI and editor components
- `src/hooks/` – Custom React hooks
- `src/lib/` – Utility and API logic
- `src/stores/` – State management (Zustand, etc.)
- `src/types/` – TypeScript types
## Getting Started
### Prerequisites
- [Bun](https://bun.sh/docs/installation)
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
> **Note:** Docker is optional but recommended for running the local database and Redis. If you only want to work on frontend features, you can skip it.
### Setup
1. Fork and clone the repository
2. Copy the environment file:
```bash
# Unix/Linux/Mac
cp apps/web/.env.example apps/web/.env.local
# Windows PowerShell
Copy-Item apps/web/.env.example apps/web/.env.local
```
3. Start the database and Redis:
```bash
docker compose up -d db redis serverless-redis-http
```
4. Install dependencies and start the dev server:
```bash
bun install
bun dev:web
```
The application will be available at [http://localhost:3000](http://localhost:3000).
The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
### Desktop setup
Desktop is opt-in. If you're only working on the web app, skip this entirely.
If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps/desktop/README.md). It's a two-step setup: Rust toolchain first, then desktop native dependencies.
### Self-Hosting with Docker
To run everything (including a production build of the app) in Docker:
```bash
docker compose up -d
```
The app will be available at [http://localhost:3100](http://localhost:3100).
## Contributing
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
**Quick start for contributors:**
- Fork the repo and clone locally
- Follow the setup instructions in CONTRIBUTING.md
- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup
- Create a feature branch and submit a PR
## License
[MIT LICENSE](LICENSE)
---
![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)
+5 -1
View File
@@ -7,4 +7,8 @@
.next/
.font-cache/
.font-cache/
next-env.d.ts
*.tsbuildinfo
-5
View File
@@ -14,14 +14,9 @@ COPY bun.lock bun.lock
COPY turbo.json turbo.json
COPY apps/web/package.json apps/web/package.json
COPY packages/env/package.json packages/env/package.json
COPY packages/ui/package.json packages/ui/package.json
RUN bun install
COPY apps/web/ apps/web/
COPY packages/env/ packages/env/
COPY packages/ui/ packages/ui/
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
+4 -2
View File
@@ -9,6 +9,7 @@ const changelog = defineCollection({
content: z.string(),
version: z.string(),
date: z.string(),
published: z.boolean().default(true),
title: z.string(),
description: z.string().optional(),
changes: z.array(
@@ -20,10 +21,11 @@ const changelog = defineCollection({
}),
transform: async (doc, { collection }) => {
const allDocs = await collection.documents();
const sorted = [...allDocs].sort((a, b) =>
const publishedDocs = allDocs.filter((entry) => entry.published !== false);
const sorted = [...publishedDocs].sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
const isLatest = sorted[0]?.version === doc.version;
const isLatest = doc.published !== false && sorted[0]?.version === doc.version;
return { ...doc, isLatest };
},
});
+1
View File
@@ -1,6 +1,7 @@
---
version: "0.1.0"
date: "2026-02-23"
published: true
title: "Editor foundation"
description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use."
changes:
+1
View File
@@ -1,6 +1,7 @@
---
version: "0.2.0"
date: "2026-03-01"
published: true
title: "Motion & effects"
description: "This release adds the foundation for motion and effects, alongside many improvements & fixes."
changes:
+1
View File
@@ -1,6 +1,7 @@
---
version: "0.3.0"
date: "2026-03-04"
published: false
title: "Brand & polish"
description: "This release adds a brand page with downloadable assets, alongside a few fixes and improvements."
changes:
+23 -23
View File
@@ -1,23 +1,23 @@
import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv";
import { webEnv } from "@opencut/env/web";
// Load the right env file based on environment
if (webEnv.NODE_ENV === "production") {
dotenv.config({ path: ".env.production" });
} else {
dotenv.config({ path: ".env.local" });
}
export default {
schema: "./src/schema.ts",
dialect: "postgresql",
migrations: {
table: "drizzle_migrations",
},
dbCredentials: {
url: webEnv.DATABASE_URL,
},
out: "./migrations",
strict: webEnv.NODE_ENV === "production",
} satisfies Config;
import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv";
import { webEnv } from "@/lib/env/web";
// Load the right env file based on environment
if (webEnv.NODE_ENV === "production") {
dotenv.config({ path: ".env.production" });
} else {
dotenv.config({ path: ".env.local" });
}
export default {
schema: "./src/schema.ts",
dialect: "postgresql",
migrations: {
table: "drizzle_migrations",
},
dbCredentials: {
url: webEnv.DATABASE_URL,
},
out: "./migrations",
strict: webEnv.NODE_ENV === "production",
} satisfies Config;
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-3
View File
@@ -1,9 +1,6 @@
import type { NextConfig } from "next";
import { withBotId } from "botid/next/config";
import { withContentCollections } from "@content-collections/next";
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
initOpenNextCloudflareForDev();
const nextConfig: NextConfig = {
turbopack: {
+3
View File
@@ -0,0 +1,3 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig();
+2 -4
View File
@@ -22,9 +22,7 @@
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@opennextjs/cloudflare": "^1.17.3",
"@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -77,7 +75,7 @@
"unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
"wavesurfer.js": "^7.9.8",
"zod": "^3.25.67",
"zod": "4.3.6",
"zustand": "^5.0.2"
},
"devDependencies": {
+280 -280
View File
@@ -1,280 +1,280 @@
import { webEnv } from "@opencut/env/web";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit";
const searchParamsSchema = z.object({
q: z.string().max(500, "Query too long").optional(),
type: z.enum(["songs", "effects"]).optional(),
page: z.coerce.number().int().min(1).max(1000).default(1),
page_size: z.coerce.number().int().min(1).max(150).default(20),
sort: z
.enum(["downloads", "rating", "created", "score"])
.default("downloads"),
min_rating: z.coerce.number().min(0).max(5).default(3),
commercial_only: z.coerce.boolean().default(true),
});
const freesoundResultSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
url: z.string().url(),
previews: z
.object({
"preview-hq-mp3": z.string().url(),
"preview-lq-mp3": z.string().url(),
"preview-hq-ogg": z.string().url(),
"preview-lq-ogg": z.string().url(),
})
.optional(),
download: z.string().url().optional(),
duration: z.number(),
filesize: z.number(),
type: z.string(),
channels: z.number(),
bitrate: z.number(),
bitdepth: z.number(),
samplerate: z.number(),
username: z.string(),
tags: z.array(z.string()),
license: z.string(),
created: z.string(),
num_downloads: z.number().optional(),
avg_rating: z.number().optional(),
num_ratings: z.number().optional(),
});
const freesoundResponseSchema = z.object({
count: z.number(),
next: z.string().url().nullable(),
previous: z.string().url().nullable(),
results: z.array(freesoundResultSchema),
});
const transformedResultSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
url: z.string(),
previewUrl: z.string().optional(),
downloadUrl: z.string().optional(),
duration: z.number(),
filesize: z.number(),
type: z.string(),
channels: z.number(),
bitrate: z.number(),
bitdepth: z.number(),
samplerate: z.number(),
username: z.string(),
tags: z.array(z.string()),
license: z.string(),
created: z.string(),
downloads: z.number().optional(),
rating: z.number().optional(),
ratingCount: z.number().optional(),
});
const apiResponseSchema = z.object({
count: z.number(),
next: z.string().nullable(),
previous: z.string().nullable(),
results: z.array(transformedResultSchema),
query: z.string().optional(),
type: z.string(),
page: z.number(),
pageSize: z.number(),
sort: z.string(),
minRating: z.number().optional(),
});
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
if (!query) return `${sort}_desc`;
return sort === "score" ? "score" : `${sort}_desc`;
}
function applyEffectsFilters({
params,
min_rating,
commercial_only,
}: {
params: URLSearchParams;
min_rating: number;
commercial_only: boolean;
}) {
params.append("filter", "duration:[* TO 30.0]");
params.append("filter", `avg_rating:[${min_rating} TO *]`);
if (commercial_only) {
params.append(
"filter",
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
);
}
params.append(
"filter",
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
);
}
function transformFreesoundResult(
result: z.infer<typeof freesoundResultSchema>,
) {
return {
id: result.id,
name: result.name,
description: result.description,
url: result.url,
previewUrl:
result.previews?.["preview-hq-mp3"] ||
result.previews?.["preview-lq-mp3"],
downloadUrl: result.download,
duration: result.duration,
filesize: result.filesize,
type: result.type,
channels: result.channels,
bitrate: result.bitrate,
bitdepth: result.bitdepth,
samplerate: result.samplerate,
username: result.username,
tags: result.tags,
license: result.license,
created: result.created,
downloads: result.num_downloads || 0,
rating: result.avg_rating || 0,
ratingCount: result.num_ratings || 0,
};
}
export async function GET(request: NextRequest) {
try {
const { limited } = await checkRateLimit({ request });
if (limited) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
const { searchParams } = new URL(request.url);
const validationResult = searchParamsSchema.safeParse({
q: searchParams.get("q") || undefined,
type: searchParams.get("type") || undefined,
page: searchParams.get("page") || undefined,
page_size: searchParams.get("page_size") || undefined,
sort: searchParams.get("sort") || undefined,
min_rating: searchParams.get("min_rating") || undefined,
});
if (!validationResult.success) {
return NextResponse.json(
{
error: "Invalid parameters",
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 },
);
}
const {
q: query,
type,
page,
page_size: pageSize,
sort,
min_rating,
commercial_only,
} = validationResult.data;
if (type === "songs") {
return NextResponse.json(
{
error: "Songs are not available yet",
message:
"Song search functionality is coming soon. Try searching for sound effects instead.",
},
{ status: 501 },
);
}
const baseUrl = "https://freesound.org/apiv2/search/text/";
const sortParam = buildSortParameter({ query, sort });
const params = new URLSearchParams({
query: query || "",
token: webEnv.FREESOUND_API_KEY,
page: page.toString(),
page_size: pageSize.toString(),
sort: sortParam,
fields:
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
});
const isEffectsSearch = type === "effects" || !type;
if (isEffectsSearch) {
applyEffectsFilters({ params, min_rating, commercial_only });
}
const response = await fetch(`${baseUrl}?${params.toString()}`);
if (!response.ok) {
const errorText = await response.text();
console.error("Freesound API error:", response.status, errorText);
return NextResponse.json(
{ error: "Failed to search sounds" },
{ status: response.status },
);
}
const rawData = await response.json();
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
if (!freesoundValidation.success) {
console.error(
"Invalid Freesound API response:",
freesoundValidation.error,
);
return NextResponse.json(
{ error: "Invalid response from Freesound API" },
{ status: 502 },
);
}
const data = freesoundValidation.data;
const transformedResults = data.results.map(transformFreesoundResult);
const responseData = {
count: data.count,
next: data.next,
previous: data.previous,
results: transformedResults,
query: query || "",
type: type || "effects",
page,
pageSize,
sort,
minRating: min_rating,
};
const responseValidation = apiResponseSchema.safeParse(responseData);
if (!responseValidation.success) {
console.error(
"Invalid API response structure:",
responseValidation.error,
);
return NextResponse.json(
{ error: "Internal response formatting error" },
{ status: 500 },
);
}
return NextResponse.json(responseValidation.data);
} catch (error) {
console.error("Error searching sounds:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
import { webEnv } from "@/lib/env/web";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit";
const searchParamsSchema = z.object({
q: z.string().max(500, "Query too long").optional(),
type: z.enum(["songs", "effects"]).optional(),
page: z.coerce.number().int().min(1).max(1000).default(1),
page_size: z.coerce.number().int().min(1).max(150).default(20),
sort: z
.enum(["downloads", "rating", "created", "score"])
.default("downloads"),
min_rating: z.coerce.number().min(0).max(5).default(3),
commercial_only: z.coerce.boolean().default(true),
});
const freesoundResultSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
url: z.string().url(),
previews: z
.object({
"preview-hq-mp3": z.string().url(),
"preview-lq-mp3": z.string().url(),
"preview-hq-ogg": z.string().url(),
"preview-lq-ogg": z.string().url(),
})
.optional(),
download: z.string().url().optional(),
duration: z.number(),
filesize: z.number(),
type: z.string(),
channels: z.number(),
bitrate: z.number(),
bitdepth: z.number(),
samplerate: z.number(),
username: z.string(),
tags: z.array(z.string()),
license: z.string(),
created: z.string(),
num_downloads: z.number().optional(),
avg_rating: z.number().optional(),
num_ratings: z.number().optional(),
});
const freesoundResponseSchema = z.object({
count: z.number(),
next: z.string().url().nullable(),
previous: z.string().url().nullable(),
results: z.array(freesoundResultSchema),
});
const transformedResultSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
url: z.string(),
previewUrl: z.string().optional(),
downloadUrl: z.string().optional(),
duration: z.number(),
filesize: z.number(),
type: z.string(),
channels: z.number(),
bitrate: z.number(),
bitdepth: z.number(),
samplerate: z.number(),
username: z.string(),
tags: z.array(z.string()),
license: z.string(),
created: z.string(),
downloads: z.number().optional(),
rating: z.number().optional(),
ratingCount: z.number().optional(),
});
const apiResponseSchema = z.object({
count: z.number(),
next: z.string().nullable(),
previous: z.string().nullable(),
results: z.array(transformedResultSchema),
query: z.string().optional(),
type: z.string(),
page: z.number(),
pageSize: z.number(),
sort: z.string(),
minRating: z.number().optional(),
});
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
if (!query) return `${sort}_desc`;
return sort === "score" ? "score" : `${sort}_desc`;
}
function applyEffectsFilters({
params,
min_rating,
commercial_only,
}: {
params: URLSearchParams;
min_rating: number;
commercial_only: boolean;
}) {
params.append("filter", "duration:[* TO 30.0]");
params.append("filter", `avg_rating:[${min_rating} TO *]`);
if (commercial_only) {
params.append(
"filter",
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
);
}
params.append(
"filter",
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
);
}
function transformFreesoundResult(
result: z.infer<typeof freesoundResultSchema>,
) {
return {
id: result.id,
name: result.name,
description: result.description,
url: result.url,
previewUrl:
result.previews?.["preview-hq-mp3"] ||
result.previews?.["preview-lq-mp3"],
downloadUrl: result.download,
duration: result.duration,
filesize: result.filesize,
type: result.type,
channels: result.channels,
bitrate: result.bitrate,
bitdepth: result.bitdepth,
samplerate: result.samplerate,
username: result.username,
tags: result.tags,
license: result.license,
created: result.created,
downloads: result.num_downloads || 0,
rating: result.avg_rating || 0,
ratingCount: result.num_ratings || 0,
};
}
export async function GET(request: NextRequest) {
try {
const { limited } = await checkRateLimit({ request });
if (limited) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
const { searchParams } = new URL(request.url);
const validationResult = searchParamsSchema.safeParse({
q: searchParams.get("q") || undefined,
type: searchParams.get("type") || undefined,
page: searchParams.get("page") || undefined,
page_size: searchParams.get("page_size") || undefined,
sort: searchParams.get("sort") || undefined,
min_rating: searchParams.get("min_rating") || undefined,
});
if (!validationResult.success) {
return NextResponse.json(
{
error: "Invalid parameters",
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 },
);
}
const {
q: query,
type,
page,
page_size: pageSize,
sort,
min_rating,
commercial_only,
} = validationResult.data;
if (type === "songs") {
return NextResponse.json(
{
error: "Songs are not available yet",
message:
"Song search functionality is coming soon. Try searching for sound effects instead.",
},
{ status: 501 },
);
}
const baseUrl = "https://freesound.org/apiv2/search/text/";
const sortParam = buildSortParameter({ query, sort });
const params = new URLSearchParams({
query: query || "",
token: webEnv.FREESOUND_API_KEY,
page: page.toString(),
page_size: pageSize.toString(),
sort: sortParam,
fields:
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
});
const isEffectsSearch = type === "effects" || !type;
if (isEffectsSearch) {
applyEffectsFilters({ params, min_rating, commercial_only });
}
const response = await fetch(`${baseUrl}?${params.toString()}`);
if (!response.ok) {
const errorText = await response.text();
console.error("Freesound API error:", response.status, errorText);
return NextResponse.json(
{ error: "Failed to search sounds" },
{ status: response.status },
);
}
const rawData = await response.json();
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
if (!freesoundValidation.success) {
console.error(
"Invalid Freesound API response:",
freesoundValidation.error,
);
return NextResponse.json(
{ error: "Invalid response from Freesound API" },
{ status: 502 },
);
}
const data = freesoundValidation.data;
const transformedResults = data.results.map(transformFreesoundResult);
const responseData = {
count: data.count,
next: data.next,
previous: data.previous,
results: transformedResults,
query: query || "",
type: type || "effects",
page,
pageSize,
sort,
minRating: min_rating,
};
const responseValidation = apiResponseSchema.safeParse(responseData);
if (!responseValidation.success) {
console.error(
"Invalid API response structure:",
responseValidation.error,
);
return NextResponse.json(
{ error: "Internal response formatting error" },
{ status: 500 },
);
}
return NextResponse.json(responseValidation.data);
} catch (error) {
console.error("Error searching sounds:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+17 -20
View File
@@ -2,9 +2,8 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { BasePage } from "@/app/base-page";
import { allChangelogs } from "content-collections";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { getSortedReleases } from "../utils";
import { getReleaseByVersion, getSortedReleases } from "../utils";
import {
ReleaseArticle,
ReleaseMeta,
@@ -17,12 +16,12 @@ import { CopyMarkdownButton } from "../components/copy-markdown-button";
type Props = { params: Promise<{ version: string }> };
export async function generateStaticParams() {
return allChangelogs.map((r) => ({ version: r.version }));
return getSortedReleases().map((release) => ({ version: release.version }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { version } = await params;
const release = allChangelogs.find((r) => r.version === version);
const release = getReleaseByVersion({ version });
if (!release) return {};
return {
title: `${release.title} (${release.version}) - OpenCut Changelog`,
@@ -33,10 +32,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function ReleaseDetailPage({ params }: Props) {
const { version } = await params;
const releases = getSortedReleases();
const index = releases.findIndex((r) => r.version === version);
const index = releases.findIndex((entry) => entry.version === version);
if (index === -1) notFound();
const release = releases[index];
const newer = index > 0 ? releases[index - 1] : null;
const older = index < releases.length - 1 ? releases[index + 1] : null;
@@ -52,20 +49,20 @@ export default async function ReleaseDetailPage({ params }: Props) {
All releases
</Link>
<ReleaseArticle variant="detail">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<ReleaseMeta release={release} />
<CopyMarkdownButton
description={release.description}
changes={release.changes}
/>
<ReleaseArticle variant="detail">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<ReleaseMeta release={release} />
<CopyMarkdownButton
description={release.description}
changes={release.changes}
/>
</div>
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
{release.description && (
<ReleaseDescription>{release.description}</ReleaseDescription>
)}
</div>
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
{release.description && (
<ReleaseDescription>{release.description}</ReleaseDescription>
)}
</div>
<ReleaseChanges release={release} />
</ReleaseArticle>
@@ -23,7 +23,7 @@ function buildMarkdown({
const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
for (const type of orderedTypes) {
lines.push(`## ${getSectionTitle(type)}`);
lines.push(`## ${getSectionTitle({ type })}`);
for (const change of grouped[type]) {
lines.push(`- ${change.text}`);
}
@@ -87,7 +87,7 @@ export function ReleaseChanges({ release }: { release: Release }) {
{orderedTypes.map((type) => (
<div key={type} className="flex flex-col gap-1.5">
<h3 className="text-base font-semibold text-foreground">
{getSectionTitle(type)}:
{getSectionTitle({ type })}:
</h3>
<ul className="list-disc pl-5 space-y-1.5">
{grouped[type].map((change) => (
+14 -4
View File
@@ -12,7 +12,7 @@ const knownSectionTitles: Record<string, string> = {
breaking: "Breaking Changes",
};
export function getSectionTitle(type: string): string {
export function getSectionTitle({ type }: { type: string }): string {
return (
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
);
@@ -36,8 +36,18 @@ export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
return { grouped, orderedTypes };
}
function isPublishedRelease({ published }: Release) {
return published !== false;
}
export function getSortedReleases() {
return [...allChangelogs].sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
return allChangelogs
.filter(isPublishedRelease)
.sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }),
);
}
export function getReleaseByVersion({ version }: { version: string }) {
return getSortedReleases().find((release) => release.version === version);
}
+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 "@opencut/env/web";
import { webEnv } from "@/lib/env/web";
import { Inter } from "next/font/google";
const siteFont = Inter({ subsets: ["latin"] });
File diff suppressed because it is too large Load Diff
@@ -11,11 +11,9 @@ import { useState, useRef } from "react";
import { extractTimelineAudio } from "@/lib/media/mediabunny";
import { useEditor } from "@/hooks/use-editor";
import {
BatchCommand,
AddTrackCommand,
InsertElementCommand,
} from "@/lib/commands";
import { TRANSCRIPTION_LANGUAGES } from "@/constants/transcription-constants";
DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
TRANSCRIPTION_LANGUAGES,
} from "@/constants/transcription-constants";
import type {
TranscriptionLanguage,
TranscriptionProgress,
@@ -31,6 +29,11 @@ import {
SectionFields,
} from "@/components/section";
import { DEFAULTS } from "@/lib/timeline/defaults";
import {
AddTrackCommand,
BatchCommand,
InsertElementCommand,
} from "@/lib/commands";
export function Captions() {
const [selectedLanguage, setSelectedLanguage] =
@@ -62,7 +65,10 @@ export function Captions() {
});
setProcessingStep("Preparing audio...");
const { samples } = await decodeAudioToFloat32({ audioBlob });
const { samples } = await decodeAudioToFloat32({
audioBlob,
sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
});
const result = await transcriptionService.transcribe({
audioData: samples,
@@ -1,369 +1,369 @@
"use client";
import { useRef, useState } from "react";
import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FPS_PRESETS } from "@/constants/project-constants";
import { useEditor } from "@/hooks/use-editor";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
} from "@/components/section";
import { BackgroundContent } from "./background";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { NumberField } from "@/components/ui/number-field";
import { useEditorStore } from "@/stores/editor-store";
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
import { HugeiconsIcon } from "@hugeicons/react";
import { Tick02Icon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
import { dimensionToAspectRatio } from "@/utils/geometry";
import { formatNumberForDisplay } from "@/utils/math";
import { OcSquarePlusIcon } from "@opencut/ui/icons";
import type { TCanvasSize } from "@/lib/project/types";
type SettingsView = "project-info" | "background";
const PRESET_LABELS: Record<string, string> = {
"1:1": "1:1",
"16:9": "16:9",
"9:16": "9:16",
"4:3": "4:3",
};
function areCanvasSizesEqual({
left,
right,
}: {
left: TCanvasSize;
right: TCanvasSize;
}) {
return left.width === right.width && left.height === right.height;
}
function formatCanvasDimension({ value }: { value: number }) {
return formatNumberForDisplay({ value, maxFractionDigits: 0 });
}
function parseCanvasDimension({ input }: { input: string }): number | null {
const trimmed = input.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) return null;
const rounded = Math.round(parsed);
return rounded > 0 ? rounded : null;
}
function useCanvasDimensionDraft({
value,
onCommit,
}: {
value: number;
onCommit: (value: number) => void;
}) {
const pendingValueRef = useRef(value);
const syncedValueRef = useRef(value);
if (syncedValueRef.current !== value) {
syncedValueRef.current = value;
pendingValueRef.current = value;
}
return usePropertyDraft({
displayValue: formatCanvasDimension({ value }),
parse: (input) => parseCanvasDimension({ input }),
onPreview: (nextValue) => {
pendingValueRef.current = nextValue;
},
onCommit: () => {
if (pendingValueRef.current !== value) {
onCommit(pendingValueRef.current);
}
},
});
}
export function SettingsView() {
const [view, setView] = useState<SettingsView>("project-info");
const editor = useEditor();
const activeProject = useEditor((e) => e.project.getActive());
const { canvasPresets } = useEditorStore();
const currentCanvasSize = activeProject.settings.canvasSize;
const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
const lastCustomCanvasSize =
activeProject.settings.lastCustomCanvasSize ?? null;
const presetItems = canvasPresets.map((preset, index) => {
const ratio = dimensionToAspectRatio(preset);
return {
id: index.toString(),
label: PRESET_LABELS[ratio] ?? ratio,
ratio,
canvasSize: preset,
};
});
const selectedPresetId = canvasSizeMode === "preset"
? (presetItems.find((preset) =>
areCanvasSizesEqual({
left: preset.canvasSize,
right: currentCanvasSize,
}),
)?.id ?? null)
: null;
const updateCustomCanvasSize = ({
canvasSize,
}: {
canvasSize: TCanvasSize;
}) => {
const shouldUpdateCanvasSize = !areCanvasSizesEqual({
left: canvasSize,
right: currentCanvasSize,
});
const shouldUpdateLastCustomCanvasSize =
lastCustomCanvasSize === null ||
!areCanvasSizesEqual({
left: canvasSize,
right: lastCustomCanvasSize,
});
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
if (
!shouldUpdateCanvasSize &&
!shouldUpdateLastCustomCanvasSize &&
!shouldUpdateCanvasSizeMode
) {
return;
}
editor.project.updateSettings({
settings: {
...(shouldUpdateCanvasSize ? { canvasSize } : {}),
...(shouldUpdateCanvasSizeMode
? { canvasSizeMode: "custom" as const }
: {}),
lastCustomCanvasSize: canvasSize,
},
});
};
const selectPresetCanvasSize = ({
canvasSize,
}: {
canvasSize: TCanvasSize;
}) => {
const shouldUpdateCanvasSize = !areCanvasSizesEqual({
left: canvasSize,
right: currentCanvasSize,
});
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
editor.project.updateSettings({
settings: {
...(shouldUpdateCanvasSize ? { canvasSize } : {}),
...(shouldUpdateCanvasSizeMode
? { canvasSizeMode: "preset" as const }
: {}),
},
});
};
const selectCustomCanvasSize = () => {
updateCustomCanvasSize({
canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
});
};
const widthDraft = useCanvasDimensionDraft({
value: currentCanvasSize.width,
onCommit: (width) =>
updateCustomCanvasSize({
canvasSize: { width, height: currentCanvasSize.height },
}),
});
const heightDraft = useCanvasDimensionDraft({
value: currentCanvasSize.height,
onCommit: (height) =>
updateCustomCanvasSize({
canvasSize: { width: currentCanvasSize.width, height },
}),
});
const isCustomSelected = canvasSizeMode === "custom";
return (
<PanelView
contentClassName="px-0"
scrollClassName="pt-0"
actions={
<Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
<TabsList>
<TabsTrigger value="project-info">Project info</TabsTrigger>
<TabsTrigger value="background">Background</TabsTrigger>
</TabsList>
</Tabs>
}
>
{view === "project-info" && (
<div className="flex flex-col">
<Section showTopBorder={false}>
<SectionHeader>
<SectionTitle className="flex-1">Name</SectionTitle>
<span className="text-sm truncate">
{activeProject.metadata.name}
</span>
</SectionHeader>
</Section>
<Section showTopBorder={false}>
<SectionHeader className="justify-between">
<SectionTitle className="flex-1">Frame rate</SectionTitle>
<Select
value={activeProject.settings.fps.toString()}
onValueChange={(value) => {
const fps = parseFloat(value);
editor.project.updateSettings({ settings: { fps } });
}}
>
<SelectTrigger className="bg-transparent border-none p-1 h-auto">
<SelectValue placeholder="Select a frame rate" />
</SelectTrigger>
<SelectContent>
{FPS_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SectionHeader>
</Section>
<Section
showTopBorder={false}
collapsible
sectionKey="settings:aspect-ratio"
>
<SectionHeader>
<SectionTitle className="flex-1">Aspect ratio</SectionTitle>
</SectionHeader>
<SectionContent className="px-2 flex flex-col gap-1 pb-2">
{presetItems.map((preset) => (
<AspectRatioItem
key={preset.id}
label={preset.label}
previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
isSelected={selectedPresetId === preset.id}
onClick={() => {
selectPresetCanvasSize({
canvasSize: preset.canvasSize,
});
}}
/>
))}
<div className="pb-2">
<AspectRatioItem
key="custom"
label="Custom"
previewIcon={<OcSquarePlusIcon />}
isSelected={isCustomSelected}
onClick={selectCustomCanvasSize}
uiOptions={
<div className=" flex items-center gap-2 text-foreground">
<NumberField
value={widthDraft.displayValue}
className="w-full"
aria-label="Canvas width"
onFocus={widthDraft.onFocus}
onChange={widthDraft.onChange}
onBlur={widthDraft.onBlur}
/>
<NumberField
value={heightDraft.displayValue}
className="w-full"
aria-label="Canvas height"
onFocus={heightDraft.onFocus}
onChange={heightDraft.onChange}
onBlur={heightDraft.onBlur}
/>
</div>
}
/>
</div>
</SectionContent>
</Section>
</div>
)}
{view === "background" && <BackgroundContent />}
</PanelView>
);
}
function AspectRatioItem({
label,
previewIcon,
isSelected,
onClick,
uiOptions,
}: {
label: string;
previewIcon: React.ReactNode;
isSelected: boolean;
onClick: () => void;
uiOptions?: React.ReactNode;
}) {
return (
<Button
variant={isSelected ? "secondary" : "ghost"}
className={cn(
"px-2 py-0 flex flex-col h-fit w-full",
!isSelected && "border border-transparent opacity-75!",
)}
onClick={onClick}
>
<div className="w-full flex justify-between items-center h-8">
<div className="flex-1 flex items-center gap-2">
<div className="flex items-center justify-center size-5">
{previewIcon}
</div>
<span className="text-sm truncate">{label}</span>
</div>
<div>
{isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
</div>
</div>
{uiOptions && isSelected && (
<div className="w-full pb-2">{uiOptions}</div>
)}
</Button>
);
}
function AspectRatioPreview({ ratio }: { ratio?: string }) {
if (!ratio) return null;
const [w, h] = ratio.split(":").map(Number);
const maxSize = 16;
const width = w >= h ? maxSize : (w / h) * maxSize;
const height = h >= w ? maxSize : (h / w) * maxSize;
return (
<div
style={{ width, height, borderWidth: 1.5 }}
className="rounded-xs border-current opacity-60"
/>
);
}
"use client";
import { useRef, useState } from "react";
import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FPS_PRESETS } from "@/constants/project-constants";
import { useEditor } from "@/hooks/use-editor";
import {
Section,
SectionContent,
SectionHeader,
SectionTitle,
} from "@/components/section";
import { BackgroundContent } from "./background";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { NumberField } from "@/components/ui/number-field";
import { useEditorStore } from "@/stores/editor-store";
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
import { HugeiconsIcon } from "@hugeicons/react";
import { Tick02Icon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
import { dimensionToAspectRatio } from "@/utils/geometry";
import { formatNumberForDisplay } from "@/utils/math";
import { OcSquarePlusIcon } from "@/components/icons";
import type { TCanvasSize } from "@/lib/project/types";
type SettingsView = "project-info" | "background";
const PRESET_LABELS: Record<string, string> = {
"1:1": "1:1",
"16:9": "16:9",
"9:16": "9:16",
"4:3": "4:3",
};
function areCanvasSizesEqual({
left,
right,
}: {
left: TCanvasSize;
right: TCanvasSize;
}) {
return left.width === right.width && left.height === right.height;
}
function formatCanvasDimension({ value }: { value: number }) {
return formatNumberForDisplay({ value, maxFractionDigits: 0 });
}
function parseCanvasDimension({ input }: { input: string }): number | null {
const trimmed = input.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) return null;
const rounded = Math.round(parsed);
return rounded > 0 ? rounded : null;
}
function useCanvasDimensionDraft({
value,
onCommit,
}: {
value: number;
onCommit: (value: number) => void;
}) {
const pendingValueRef = useRef(value);
const syncedValueRef = useRef(value);
if (syncedValueRef.current !== value) {
syncedValueRef.current = value;
pendingValueRef.current = value;
}
return usePropertyDraft({
displayValue: formatCanvasDimension({ value }),
parse: (input) => parseCanvasDimension({ input }),
onPreview: (nextValue) => {
pendingValueRef.current = nextValue;
},
onCommit: () => {
if (pendingValueRef.current !== value) {
onCommit(pendingValueRef.current);
}
},
});
}
export function SettingsView() {
const [view, setView] = useState<SettingsView>("project-info");
const editor = useEditor();
const activeProject = useEditor((e) => e.project.getActive());
const { canvasPresets } = useEditorStore();
const currentCanvasSize = activeProject.settings.canvasSize;
const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
const lastCustomCanvasSize =
activeProject.settings.lastCustomCanvasSize ?? null;
const presetItems = canvasPresets.map((preset, index) => {
const ratio = dimensionToAspectRatio(preset);
return {
id: index.toString(),
label: PRESET_LABELS[ratio] ?? ratio,
ratio,
canvasSize: preset,
};
});
const selectedPresetId = canvasSizeMode === "preset"
? (presetItems.find((preset) =>
areCanvasSizesEqual({
left: preset.canvasSize,
right: currentCanvasSize,
}),
)?.id ?? null)
: null;
const updateCustomCanvasSize = ({
canvasSize,
}: {
canvasSize: TCanvasSize;
}) => {
const shouldUpdateCanvasSize = !areCanvasSizesEqual({
left: canvasSize,
right: currentCanvasSize,
});
const shouldUpdateLastCustomCanvasSize =
lastCustomCanvasSize === null ||
!areCanvasSizesEqual({
left: canvasSize,
right: lastCustomCanvasSize,
});
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
if (
!shouldUpdateCanvasSize &&
!shouldUpdateLastCustomCanvasSize &&
!shouldUpdateCanvasSizeMode
) {
return;
}
editor.project.updateSettings({
settings: {
...(shouldUpdateCanvasSize ? { canvasSize } : {}),
...(shouldUpdateCanvasSizeMode
? { canvasSizeMode: "custom" as const }
: {}),
lastCustomCanvasSize: canvasSize,
},
});
};
const selectPresetCanvasSize = ({
canvasSize,
}: {
canvasSize: TCanvasSize;
}) => {
const shouldUpdateCanvasSize = !areCanvasSizesEqual({
left: canvasSize,
right: currentCanvasSize,
});
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
editor.project.updateSettings({
settings: {
...(shouldUpdateCanvasSize ? { canvasSize } : {}),
...(shouldUpdateCanvasSizeMode
? { canvasSizeMode: "preset" as const }
: {}),
},
});
};
const selectCustomCanvasSize = () => {
updateCustomCanvasSize({
canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
});
};
const widthDraft = useCanvasDimensionDraft({
value: currentCanvasSize.width,
onCommit: (width) =>
updateCustomCanvasSize({
canvasSize: { width, height: currentCanvasSize.height },
}),
});
const heightDraft = useCanvasDimensionDraft({
value: currentCanvasSize.height,
onCommit: (height) =>
updateCustomCanvasSize({
canvasSize: { width: currentCanvasSize.width, height },
}),
});
const isCustomSelected = canvasSizeMode === "custom";
return (
<PanelView
contentClassName="px-0"
scrollClassName="pt-0"
actions={
<Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
<TabsList>
<TabsTrigger value="project-info">Project info</TabsTrigger>
<TabsTrigger value="background">Background</TabsTrigger>
</TabsList>
</Tabs>
}
>
{view === "project-info" && (
<div className="flex flex-col">
<Section showTopBorder={false}>
<SectionHeader>
<SectionTitle className="flex-1">Name</SectionTitle>
<span className="text-sm truncate">
{activeProject.metadata.name}
</span>
</SectionHeader>
</Section>
<Section showTopBorder={false}>
<SectionHeader className="justify-between">
<SectionTitle className="flex-1">Frame rate</SectionTitle>
<Select
value={activeProject.settings.fps.toString()}
onValueChange={(value) => {
const fps = parseFloat(value);
editor.project.updateSettings({ settings: { fps } });
}}
>
<SelectTrigger className="bg-transparent border-none p-1 h-auto">
<SelectValue placeholder="Select a frame rate" />
</SelectTrigger>
<SelectContent>
{FPS_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SectionHeader>
</Section>
<Section
showTopBorder={false}
collapsible
sectionKey="settings:aspect-ratio"
>
<SectionHeader>
<SectionTitle className="flex-1">Aspect ratio</SectionTitle>
</SectionHeader>
<SectionContent className="px-2 flex flex-col gap-1 pb-2">
{presetItems.map((preset) => (
<AspectRatioItem
key={preset.id}
label={preset.label}
previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
isSelected={selectedPresetId === preset.id}
onClick={() => {
selectPresetCanvasSize({
canvasSize: preset.canvasSize,
});
}}
/>
))}
<div className="pb-2">
<AspectRatioItem
key="custom"
label="Custom"
previewIcon={<OcSquarePlusIcon />}
isSelected={isCustomSelected}
onClick={selectCustomCanvasSize}
uiOptions={
<div className=" flex items-center gap-2 text-foreground">
<NumberField
value={widthDraft.displayValue}
className="w-full"
aria-label="Canvas width"
onFocus={widthDraft.onFocus}
onChange={widthDraft.onChange}
onBlur={widthDraft.onBlur}
/>
<NumberField
value={heightDraft.displayValue}
className="w-full"
aria-label="Canvas height"
onFocus={heightDraft.onFocus}
onChange={heightDraft.onChange}
onBlur={heightDraft.onBlur}
/>
</div>
}
/>
</div>
</SectionContent>
</Section>
</div>
)}
{view === "background" && <BackgroundContent />}
</PanelView>
);
}
function AspectRatioItem({
label,
previewIcon,
isSelected,
onClick,
uiOptions,
}: {
label: string;
previewIcon: React.ReactNode;
isSelected: boolean;
onClick: () => void;
uiOptions?: React.ReactNode;
}) {
return (
<Button
variant={isSelected ? "secondary" : "ghost"}
className={cn(
"px-2 py-0 flex flex-col h-fit w-full",
!isSelected && "border border-transparent opacity-75!",
)}
onClick={onClick}
>
<div className="w-full flex justify-between items-center h-8">
<div className="flex-1 flex items-center gap-2">
<div className="flex items-center justify-center size-5">
{previewIcon}
</div>
<span className="text-sm truncate">{label}</span>
</div>
<div>
{isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
</div>
</div>
{uiOptions && isSelected && (
<div className="w-full pb-2">{uiOptions}</div>
)}
</Button>
);
}
function AspectRatioPreview({ ratio }: { ratio?: string }) {
if (!ratio) return null;
const [w, h] = ratio.split(":").map(Number);
const maxSize = 16;
const width = w >= h ? maxSize : (w / h) * maxSize;
const height = h >= w ? maxSize : (h / w) * maxSize;
return (
<div
style={{ width, height, borderWidth: 1.5 }}
className="rounded-xs border-current opacity-60"
/>
);
}
@@ -42,7 +42,6 @@ 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>
@@ -59,12 +58,6 @@ 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>
);
@@ -485,10 +478,6 @@ function SavedSoundsView() {
);
}
function SongsView() {
return <div>Songs</div>;
}
interface AudioItemProps {
sound: SoundEffect;
isPlaying: boolean;
@@ -1,7 +1,11 @@
"use client";
import type { ParamDefinition, NumberParamDefinition } from "@/lib/params";
import { formatNumberForDisplay, snapToStep } from "@/utils/math";
import {
formatNumberForDisplay,
getFractionDigitsForStep,
snapToStep,
} from "@/utils/math";
import { SectionField } from "@/components/section";
import { NumberField } from "@/components/ui/number-field";
import { Switch } from "@/components/ui/switch";
@@ -156,7 +160,7 @@ function NumberParamField({
onPreview(clamped / displayMultiplier);
};
const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
const maxFractionDigits = getFractionDigitsForStep({ step });
const draft = usePropertyDraft({
displayValue: formatNumberForDisplay({
@@ -1,303 +1,303 @@
import type { ReactNode } from "react";
import type {
EffectElement,
GraphicElement,
ImageElement,
MaskableElement,
RetimableElement,
StickerElement,
TextElement,
VisualElement,
VideoElement,
AudioElement,
TimelineElement,
} from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types";
import { HugeiconsIcon } from "@hugeicons/react";
import {
TextFontIcon,
ArrowExpandIcon,
RainDropIcon,
MusicNote03Icon,
MagicWand05Icon,
DashboardSpeed02Icon,
} from "@hugeicons/core-free-icons";
import { TransformTab } from "./tabs/transform-tab";
import { BlendingTab } from "./tabs/blending-tab";
import { AudioTab } from "./tabs/audio-tab";
import { TextTab } from "./tabs/text-tab";
import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
import { MasksTab } from "./tabs/masks-tab";
import { SpeedTab } from "./tabs/speed-tab";
import { GraphicTab } from "./tabs/graphic-tab";
import { OcShapesIcon } from "@opencut/ui/icons";
export type TabContentProps = {
trackId: string;
};
export type PropertiesTabDef = {
id: string;
label: string;
icon: ReactNode;
content: (props: TabContentProps) => ReactNode;
};
export type ElementPropertiesConfig = {
defaultTab: string;
tabs: PropertiesTabDef[];
};
function buildTransformTab({
element,
}: {
element: VisualElement;
}): PropertiesTabDef {
return {
id: "transform",
label: "Transform",
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
content: ({ trackId }) => (
<TransformTab element={element} trackId={trackId} />
),
};
}
function buildBlendingTab({
element,
}: {
element: VisualElement;
}): PropertiesTabDef {
return {
id: "blending",
label: "Blending",
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
content: ({ trackId }) => (
<BlendingTab element={element} trackId={trackId} />
),
};
}
function buildAudioTab({
element,
}: {
element: AudioElement | VideoElement;
}): PropertiesTabDef {
return {
id: "audio",
label: "Audio",
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
};
}
function buildSpeedTab({
element,
}: {
element: RetimableElement;
}): PropertiesTabDef {
return {
id: "speed",
label: "Speed",
icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
};
}
function buildMasksTab({
element,
}: {
element: MaskableElement;
}): PropertiesTabDef {
return {
id: "masks",
label: "Masks",
icon: <OcShapesIcon size={16} />,
content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
};
}
function buildClipEffectsTab({
element,
}: {
element: VisualElement;
}): PropertiesTabDef {
return {
id: "effects",
label: "Effects",
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
content: ({ trackId }) => (
<ClipEffectsTab element={element} trackId={trackId} />
),
};
}
function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
return {
id: "text",
label: "Text",
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
};
}
function buildGraphicTab({
element,
}: {
element: GraphicElement;
}): PropertiesTabDef {
return {
id: "graphic",
label: "Graphic",
icon: <OcShapesIcon size={16} />,
content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
};
}
function buildStandaloneEffectTab({
element,
}: {
element: EffectElement;
}): PropertiesTabDef {
return {
id: "effects",
label: "Effects",
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
content: ({ trackId }) => (
<StandaloneEffectTab element={element} trackId={trackId} />
),
};
}
function getTextConfig({
element,
}: {
element: TextElement;
}): ElementPropertiesConfig {
return {
defaultTab: "text",
tabs: [
buildTextTab({ element }),
buildTransformTab({ element }),
buildBlendingTab({ element }),
],
};
}
function getVideoConfig({
element,
mediaAsset,
}: {
element: VideoElement;
mediaAsset: MediaAsset | undefined;
}): ElementPropertiesConfig {
const showAudioTab = mediaAsset?.hasAudio !== false;
return {
defaultTab: "transform",
tabs: [
buildTransformTab({ element }),
...(showAudioTab ? [buildAudioTab({ element })] : []),
buildSpeedTab({ element }),
buildBlendingTab({ element }),
buildMasksTab({ element }),
buildClipEffectsTab({ element }),
],
};
}
function getImageConfig({
element,
}: {
element: ImageElement;
}): ElementPropertiesConfig {
return {
defaultTab: "transform",
tabs: [
buildTransformTab({ element }),
buildBlendingTab({ element }),
buildMasksTab({ element }),
buildClipEffectsTab({ element }),
],
};
}
function getStickerConfig({
element,
}: {
element: StickerElement;
}): ElementPropertiesConfig {
return {
defaultTab: "transform",
tabs: [
buildTransformTab({ element }),
buildBlendingTab({ element }),
buildClipEffectsTab({ element }),
],
};
}
function getGraphicConfig({
element,
}: {
element: GraphicElement;
}): ElementPropertiesConfig {
return {
defaultTab: "graphic",
tabs: [
buildGraphicTab({ element }),
buildTransformTab({ element }),
buildBlendingTab({ element }),
buildMasksTab({ element }),
buildClipEffectsTab({ element }),
],
};
}
function getAudioConfig({
element,
}: {
element: AudioElement;
}): ElementPropertiesConfig {
return {
defaultTab: "audio",
tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
};
}
function getEffectConfig({
element,
}: {
element: EffectElement;
}): ElementPropertiesConfig {
return {
defaultTab: "effects",
tabs: [buildStandaloneEffectTab({ element })],
};
}
export function getPropertiesConfig({
element,
mediaAssets,
}: {
element: TimelineElement;
mediaAssets: MediaAsset[];
}): ElementPropertiesConfig {
switch (element.type) {
case "text":
return getTextConfig({ element });
case "video": {
const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
return getVideoConfig({ element, mediaAsset });
}
case "image":
return getImageConfig({ element });
case "sticker":
return getStickerConfig({ element });
case "graphic":
return getGraphicConfig({ element });
case "audio":
return getAudioConfig({ element });
case "effect":
return getEffectConfig({ element });
}
}
import type { ReactNode } from "react";
import type {
EffectElement,
GraphicElement,
ImageElement,
MaskableElement,
RetimableElement,
StickerElement,
TextElement,
VisualElement,
VideoElement,
AudioElement,
TimelineElement,
} from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types";
import { HugeiconsIcon } from "@hugeicons/react";
import {
TextFontIcon,
ArrowExpandIcon,
RainDropIcon,
MusicNote03Icon,
MagicWand05Icon,
DashboardSpeed02Icon,
} from "@hugeicons/core-free-icons";
import { TransformTab } from "./tabs/transform-tab";
import { BlendingTab } from "./tabs/blending-tab";
import { AudioTab } from "./tabs/audio-tab";
import { TextTab } from "./tabs/text-tab";
import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
import { MasksTab } from "./tabs/masks-tab";
import { SpeedTab } from "./tabs/speed-tab";
import { GraphicTab } from "./tabs/graphic-tab";
import { OcShapesIcon } from "@/components/icons";
export type TabContentProps = {
trackId: string;
};
export type PropertiesTabDef = {
id: string;
label: string;
icon: ReactNode;
content: (props: TabContentProps) => ReactNode;
};
export type ElementPropertiesConfig = {
defaultTab: string;
tabs: PropertiesTabDef[];
};
function buildTransformTab({
element,
}: {
element: VisualElement;
}): PropertiesTabDef {
return {
id: "transform",
label: "Transform",
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
content: ({ trackId }) => (
<TransformTab element={element} trackId={trackId} />
),
};
}
function buildBlendingTab({
element,
}: {
element: VisualElement;
}): PropertiesTabDef {
return {
id: "blending",
label: "Blending",
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
content: ({ trackId }) => (
<BlendingTab element={element} trackId={trackId} />
),
};
}
function buildAudioTab({
element,
}: {
element: AudioElement | VideoElement;
}): PropertiesTabDef {
return {
id: "audio",
label: "Audio",
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
};
}
function buildSpeedTab({
element,
}: {
element: RetimableElement;
}): PropertiesTabDef {
return {
id: "speed",
label: "Speed",
icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
};
}
function buildMasksTab({
element,
}: {
element: MaskableElement;
}): PropertiesTabDef {
return {
id: "masks",
label: "Masks",
icon: <OcShapesIcon size={16} />,
content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
};
}
function buildClipEffectsTab({
element,
}: {
element: VisualElement;
}): PropertiesTabDef {
return {
id: "effects",
label: "Effects",
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
content: ({ trackId }) => (
<ClipEffectsTab element={element} trackId={trackId} />
),
};
}
function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
return {
id: "text",
label: "Text",
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
};
}
function buildGraphicTab({
element,
}: {
element: GraphicElement;
}): PropertiesTabDef {
return {
id: "graphic",
label: "Graphic",
icon: <OcShapesIcon size={16} />,
content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
};
}
function buildStandaloneEffectTab({
element,
}: {
element: EffectElement;
}): PropertiesTabDef {
return {
id: "effects",
label: "Effects",
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
content: ({ trackId }) => (
<StandaloneEffectTab element={element} trackId={trackId} />
),
};
}
function getTextConfig({
element,
}: {
element: TextElement;
}): ElementPropertiesConfig {
return {
defaultTab: "text",
tabs: [
buildTextTab({ element }),
buildTransformTab({ element }),
buildBlendingTab({ element }),
],
};
}
function getVideoConfig({
element,
mediaAsset,
}: {
element: VideoElement;
mediaAsset: MediaAsset | undefined;
}): ElementPropertiesConfig {
const showAudioTab = mediaAsset?.hasAudio !== false;
return {
defaultTab: "transform",
tabs: [
buildTransformTab({ element }),
...(showAudioTab ? [buildAudioTab({ element })] : []),
buildSpeedTab({ element }),
buildBlendingTab({ element }),
buildMasksTab({ element }),
buildClipEffectsTab({ element }),
],
};
}
function getImageConfig({
element,
}: {
element: ImageElement;
}): ElementPropertiesConfig {
return {
defaultTab: "transform",
tabs: [
buildTransformTab({ element }),
buildBlendingTab({ element }),
buildMasksTab({ element }),
buildClipEffectsTab({ element }),
],
};
}
function getStickerConfig({
element,
}: {
element: StickerElement;
}): ElementPropertiesConfig {
return {
defaultTab: "transform",
tabs: [
buildTransformTab({ element }),
buildBlendingTab({ element }),
buildClipEffectsTab({ element }),
],
};
}
function getGraphicConfig({
element,
}: {
element: GraphicElement;
}): ElementPropertiesConfig {
return {
defaultTab: "graphic",
tabs: [
buildGraphicTab({ element }),
buildTransformTab({ element }),
buildBlendingTab({ element }),
buildMasksTab({ element }),
buildClipEffectsTab({ element }),
],
};
}
function getAudioConfig({
element,
}: {
element: AudioElement;
}): ElementPropertiesConfig {
return {
defaultTab: "audio",
tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
};
}
function getEffectConfig({
element,
}: {
element: EffectElement;
}): ElementPropertiesConfig {
return {
defaultTab: "effects",
tabs: [buildStandaloneEffectTab({ element })],
};
}
export function getPropertiesConfig({
element,
mediaAssets,
}: {
element: TimelineElement;
mediaAssets: MediaAsset[];
}): ElementPropertiesConfig {
switch (element.type) {
case "text":
return getTextConfig({ element });
case "video": {
const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
return getVideoConfig({ element, mediaAsset });
}
case "image":
return getImageConfig({ element });
case "sticker":
return getStickerConfig({ element });
case "graphic":
return getGraphicConfig({ element });
case "audio":
return getAudioConfig({ element });
case "effect":
return getEffectConfig({ element });
}
}
@@ -1,7 +1,13 @@
import { NumberField } from "@/components/ui/number-field";
import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { clamp, isNearlyEqual } from "@/utils/math";
import {
clamp,
formatNumberForDisplay,
getFractionDigitsForStep,
isNearlyEqual,
snapToStep,
} from "@/utils/math";
import type { AudioElement, VideoElement } from "@/lib/timeline";
import { resolveNumberAtTime } from "@/lib/animation";
import { useElementPlayhead } from "../hooks/use-element-playhead";
@@ -18,6 +24,9 @@ import {
SectionTitle,
} from "@/components/section";
const VOLUME_STEP = 0.1;
const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
export function AudioTab({
element,
trackId,
@@ -42,17 +51,24 @@ export function AudioTab({
propertyPath: "volume",
localTime,
isPlayheadWithinElementRange,
displayValue: resolvedVolume.toFixed(1),
displayValue: formatNumberForDisplay({
value: resolvedVolume,
fractionDigits: VOLUME_FRACTION_DIGITS,
}),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) {
return null;
}
return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX });
return clamp({
value: snapToStep({ value: parsed, step: VOLUME_STEP }),
min: VOLUME_DB_MIN,
max: VOLUME_DB_MAX,
});
},
valueAtPlayhead: resolvedVolume,
step: 0.1,
step: VOLUME_STEP,
buildBaseUpdates: ({ value }) => ({
volume: value,
}),
@@ -1,230 +1,230 @@
import { useEditor } from "@/hooks/use-editor";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react";
import { useMenuPreview } from "@/hooks/use-menu-preview";
import {
Section,
SectionContent,
SectionField,
SectionHeader,
SectionTitle,
} from "@/components/section";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BlendMode } from "@/lib/rendering";
import type { ElementType } from "@/lib/timeline";
import type { ElementAnimations } from "@/lib/animation/types";
import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons";
import { KeyframeToggle } from "../components/keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { resolveOpacityAtTime } from "@/lib/animation";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { isPropertyAtDefault } from "./transform-tab";
type BlendingElement = {
id: string;
opacity: number;
type: ElementType;
blendMode?: BlendMode;
startTime: number;
duration: number;
animations?: ElementAnimations;
};
const BLEND_MODE_GROUPS = [
[{ value: "normal", label: "Normal" }],
[
{ value: "darken", label: "Darken" },
{ value: "multiply", label: "Multiply" },
{ value: "color-burn", label: "Color Burn" },
],
[
{ value: "lighten", label: "Lighten" },
{ value: "screen", label: "Screen" },
{ value: "plus-lighter", label: "Plus Lighter" },
{ value: "color-dodge", label: "Color Dodge" },
],
[
{ value: "overlay", label: "Overlay" },
{ value: "soft-light", label: "Soft Light" },
{ value: "hard-light", label: "Hard Light" },
],
[
{ value: "difference", label: "Difference" },
{ value: "exclusion", label: "Exclusion" },
],
[
{ value: "hue", label: "Hue" },
{ value: "saturation", label: "Saturation" },
{ value: "color", label: "Color" },
{ value: "luminosity", label: "Luminosity" },
],
];
export function BlendingTab({
element,
trackId,
}: {
element: BlendingElement;
trackId: string;
}) {
const editor = useEditor();
const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
const committedBlendModeRef = useRef(blendMode);
if (!isPreviewActive) {
committedBlendModeRef.current = blendMode;
}
const {
onPointerLeave,
onOpenChange: handleBlendModeOpenChange,
markCommitted,
} = useMenuPreview();
const previewBlendMode = ({ value }: { value: BlendMode }) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { blendMode: value } },
],
});
const commitBlendMode = (value: string) => {
if (editor.timeline.isPreviewActive()) {
editor.timeline.commitPreview();
} else {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { blendMode: value as BlendMode },
},
],
});
}
markCommitted();
};
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const resolvedOpacity = resolveOpacityAtTime({
baseOpacity: element.opacity,
animations: element.animations,
localTime,
});
const opacity = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "opacity",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedOpacity * 100).toString(),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
},
valueAtPlayhead: resolvedOpacity,
step: 0.01,
buildBaseUpdates: ({ value }) => ({ opacity: value }),
});
return (
<Section collapsible sectionKey={`${element.id}:blending`}>
<SectionHeader>
<SectionTitle>Blending</SectionTitle>
</SectionHeader>
<SectionContent>
<div className="flex items-start gap-2">
<SectionField
label="Opacity"
className="w-1/2"
beforeLabel={
<KeyframeToggle
isActive={opacity.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle opacity keyframe"
onToggle={opacity.toggleKeyframe}
/>
}
>
<NumberField
className="w-full"
icon={
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
}
value={opacity.displayValue}
min={0}
max={100}
onFocus={opacity.onFocus}
onChange={opacity.onChange}
onBlur={opacity.onBlur}
onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub}
onReset={() =>
opacity.commitValue({ value: DEFAULTS.element.opacity })
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedOpacity,
staticValue: element.opacity,
defaultValue: DEFAULTS.element.opacity,
})}
dragSensitivity="slow"
/>
</SectionField>
<SectionField label="Blend mode" className="w-1/2">
<Select
value={committedBlendModeRef.current}
onOpenChange={handleBlendModeOpenChange}
onValueChange={commitBlendMode}
>
<SelectTrigger
icon={<HugeiconsIcon icon={RainDropIcon} />}
className="w-full"
>
<SelectValue placeholder="Select blend mode" />
</SelectTrigger>
<SelectContent className="w-36" onPointerLeave={onPointerLeave}>
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
{group.map((option) => (
<SelectItem
key={option.value}
value={option.value}
onPointerEnter={() =>
previewBlendMode({ value: option.value as BlendMode })
}
>
{option.label}
</SelectItem>
))}
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
<SelectSeparator />
) : null}
</Fragment>
))}
</SelectContent>
</Select>
</SectionField>
</div>
</SectionContent>
</Section>
);
}
import { useEditor } from "@/hooks/use-editor";
import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field";
import { OcCheckerboardIcon } from "@/components/icons";
import { Fragment, useRef } from "react";
import { useMenuPreview } from "@/hooks/use-menu-preview";
import {
Section,
SectionContent,
SectionField,
SectionHeader,
SectionTitle,
} from "@/components/section";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { BlendMode } from "@/lib/rendering";
import type { ElementType } from "@/lib/timeline";
import type { ElementAnimations } from "@/lib/animation/types";
import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons";
import { KeyframeToggle } from "../components/keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { useElementPlayhead } from "../hooks/use-element-playhead";
import { resolveOpacityAtTime } from "@/lib/animation";
import { DEFAULTS } from "@/lib/timeline/defaults";
import { isPropertyAtDefault } from "./transform-tab";
type BlendingElement = {
id: string;
opacity: number;
type: ElementType;
blendMode?: BlendMode;
startTime: number;
duration: number;
animations?: ElementAnimations;
};
const BLEND_MODE_GROUPS = [
[{ value: "normal", label: "Normal" }],
[
{ value: "darken", label: "Darken" },
{ value: "multiply", label: "Multiply" },
{ value: "color-burn", label: "Color Burn" },
],
[
{ value: "lighten", label: "Lighten" },
{ value: "screen", label: "Screen" },
{ value: "plus-lighter", label: "Plus Lighter" },
{ value: "color-dodge", label: "Color Dodge" },
],
[
{ value: "overlay", label: "Overlay" },
{ value: "soft-light", label: "Soft Light" },
{ value: "hard-light", label: "Hard Light" },
],
[
{ value: "difference", label: "Difference" },
{ value: "exclusion", label: "Exclusion" },
],
[
{ value: "hue", label: "Hue" },
{ value: "saturation", label: "Saturation" },
{ value: "color", label: "Color" },
{ value: "luminosity", label: "Luminosity" },
],
];
export function BlendingTab({
element,
trackId,
}: {
element: BlendingElement;
trackId: string;
}) {
const editor = useEditor();
const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
const committedBlendModeRef = useRef(blendMode);
if (!isPreviewActive) {
committedBlendModeRef.current = blendMode;
}
const {
onPointerLeave,
onOpenChange: handleBlendModeOpenChange,
markCommitted,
} = useMenuPreview();
const previewBlendMode = ({ value }: { value: BlendMode }) =>
editor.timeline.previewElements({
updates: [
{ trackId, elementId: element.id, updates: { blendMode: value } },
],
});
const commitBlendMode = (value: string) => {
if (editor.timeline.isPreviewActive()) {
editor.timeline.commitPreview();
} else {
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
updates: { blendMode: value as BlendMode },
},
],
});
}
markCommitted();
};
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime,
duration: element.duration,
});
const resolvedOpacity = resolveOpacityAtTime({
baseOpacity: element.opacity,
animations: element.animations,
localTime,
});
const opacity = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "opacity",
localTime,
isPlayheadWithinElementRange,
displayValue: Math.round(resolvedOpacity * 100).toString(),
parse: (input) => {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
},
valueAtPlayhead: resolvedOpacity,
step: 0.01,
buildBaseUpdates: ({ value }) => ({ opacity: value }),
});
return (
<Section collapsible sectionKey={`${element.id}:blending`}>
<SectionHeader>
<SectionTitle>Blending</SectionTitle>
</SectionHeader>
<SectionContent>
<div className="flex items-start gap-2">
<SectionField
label="Opacity"
className="w-1/2"
beforeLabel={
<KeyframeToggle
isActive={opacity.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle opacity keyframe"
onToggle={opacity.toggleKeyframe}
/>
}
>
<NumberField
className="w-full"
icon={
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
}
value={opacity.displayValue}
min={0}
max={100}
onFocus={opacity.onFocus}
onChange={opacity.onChange}
onBlur={opacity.onBlur}
onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub}
onReset={() =>
opacity.commitValue({ value: DEFAULTS.element.opacity })
}
isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
isPlayheadWithinElementRange,
resolvedValue: resolvedOpacity,
staticValue: element.opacity,
defaultValue: DEFAULTS.element.opacity,
})}
dragSensitivity="slow"
/>
</SectionField>
<SectionField label="Blend mode" className="w-1/2">
<Select
value={committedBlendModeRef.current}
onOpenChange={handleBlendModeOpenChange}
onValueChange={commitBlendMode}
>
<SelectTrigger
icon={<HugeiconsIcon icon={RainDropIcon} />}
className="w-full"
>
<SelectValue placeholder="Select blend mode" />
</SelectTrigger>
<SelectContent className="w-36" onPointerLeave={onPointerLeave}>
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
{group.map((option) => (
<SelectItem
key={option.value}
value={option.value}
onPointerEnter={() =>
previewBlendMode({ value: option.value as BlendMode })
}
>
{option.label}
</SelectItem>
))}
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
<SelectSeparator />
) : null}
</Fragment>
))}
</SelectContent>
</Select>
</SectionField>
</div>
</SectionContent>
</Section>
);
}
@@ -38,7 +38,12 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { clamp, formatNumberForDisplay, snapToStep } from "@/utils/math";
import {
clamp,
formatNumberForDisplay,
getFractionDigitsForStep,
snapToStep,
} from "@/utils/math";
import {
Section,
SectionContent,
@@ -48,7 +53,7 @@ import {
SectionTitle,
} from "@/components/section";
import { usePropertyDraft } from "../hooks/use-property-draft";
import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons";
import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
import { cn } from "@/utils/ui";
type MasksTabProps = {
@@ -599,7 +604,7 @@ function MaskNumberField({
const max = isPercent ? 100 : param.max;
const step = isPercent ? 1 : param.step;
const displayValue = value * displayMultiplier;
const maxFractionDigits = step >= 1 ? 0 : Math.round(-Math.log10(step));
const maxFractionDigits = getFractionDigitsForStep({ step });
const clampDisplay = (nextDisplayValue: number) =>
max !== undefined
@@ -22,16 +22,28 @@ 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 rate.toFixed(2);
return formatNumberForDisplay({
value: rate,
fractionDigits: SPEED_FRACTION_DIGITS,
});
}
function parseSpeedInput({ input }: { input: string }): number | null {
const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null;
const rounded = Math.round(parsed * 100) / 100;
return clampRetimeRate({ rate: rounded });
return clampRetimeRate({
rate: snapToStep({ value: parsed, step: SPEED_STEP }),
});
}
function buildRetime({
@@ -56,24 +68,21 @@ export function SpeedTab({
const rate = clampRetimeRate({
rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
});
const maintainPitchAvailable = canMaintainPitch({ rate });
const isPitchPreserveAvailable = canMaintainPitch({ rate });
const maintainPitch = element.retime?.maintainPitch ?? false;
const pendingRateRef = useRef(rate);
const applyRetime = ({
const commitRetime = ({
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,
});
};
@@ -82,10 +91,20 @@ export function SpeedTab({
parse: (input) => parseSpeedInput({ input }),
onPreview: (nextRate) => {
pendingRateRef.current = nextRate;
applyRetime({ rate: nextRate, maintainPitch, pushHistory: false });
editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
updates: {
retime: buildRetime({ rate: nextRate, maintainPitch }),
},
},
],
});
},
onCommit: () => {
applyRetime({ rate: pendingRateRef.current, maintainPitch });
commitRetime({ rate: pendingRateRef.current, maintainPitch });
},
});
@@ -115,20 +134,20 @@ export function SpeedTab({
onScrub={speedDraft.scrubTo}
onScrubEnd={speedDraft.commitScrub}
onReset={() =>
applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
commitRetime({ 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={!maintainPitchAvailable}
onCheckedChange={(checked) =>
applyRetime({ rate, maintainPitch: !checked })
}
/>
<span className="text-sm">Change pitch</span>
<Switch
checked={!maintainPitch}
disabled={!isPitchPreserveAvailable}
onCheckedChange={(checked) =>
commitRetime({ rate, maintainPitch: !checked })
}
/>
</div>
</SectionFields>
</SectionContent>
File diff suppressed because it is too large Load Diff
@@ -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 } from "@/utils/math";
import { clamp, formatNumberForDisplay } from "@/utils/math";
import {
timelineTimeToPixels,
timelineTimeToSnappedPixels,
@@ -188,7 +188,7 @@ function TimelineBookmark({
left: `${bookmarkLeft}px`,
width: `${bookmarkWidth}px`,
}}
aria-label={`Bookmark at ${time.toFixed(1)}s`}
aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`}
type="button"
onMouseDown={handleMouseDown}
onClick={handleClick}
@@ -13,7 +13,7 @@ import {
VolumeOffIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/icons";
import { OcShapesIcon, OcVideoIcon } from "@/components/icons";
import {
ContextMenu,
ContextMenuContent,
@@ -49,10 +49,10 @@ import {
getTotalTracksHeight,
canTracktHaveAudio,
canTrackBeHidden,
isMainTrack,
getTimelineZoomMin,
getTimelinePaddingPx,
} from "@/lib/timeline";
import { isMainTrack } from "@/lib/track-placement";
import { TimelineToolbar } from "./timeline-toolbar";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
@@ -566,7 +566,7 @@ function KeyframeIndicators({
<HugeiconsIcon
icon={KeyframeIcon}
className={cn(
"size-3.5 mt-1.5 text-black",
"size-3.5 text-black",
isIndicatorSelected ? "fill-primary" : "fill-white",
)}
strokeWidth={1.5}
@@ -34,8 +34,10 @@ import {
AlignLeftIcon,
AlignRightIcon,
Layers01Icon,
Chart03Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { OcRippleIcon } from "@/components/icons";
export function TimelineToolbar({
zoomLevel,
@@ -156,6 +158,14 @@ function ToolbarLeftSection() {
}
/>
</Tooltip>
<Tooltip>
<ToolbarButton
icon={<HugeiconsIcon icon={Chart03Icon} />}
tooltip="Graph"
onClick={() => {}}
/>
</Tooltip>
</TooltipProvider>
</div>
);
@@ -207,7 +217,7 @@ function ToolbarRightSection({
/>
<ToolbarButton
icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />}
icon={<OcRippleIcon size={24} className="scale-110" />}
isActive={rippleEditingEnabled}
tooltip="Ripple editing"
onClick={() => toggleRippleEditing()}
@@ -1,4 +1,6 @@
export function OcVercelIcon({ className }: { className?: string }) {
import type { IconProps } from "./types";
export function OcVercelIcon({ className }: IconProps) {
return (
<svg
className={className}
@@ -17,10 +19,7 @@ export function OcVercelIcon({ className }: { className?: string }) {
export function OcMarbleIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -43,10 +42,7 @@ export function OcMarbleIcon({
export function OcDataBuddyIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
+5
View File
@@ -0,0 +1,5 @@
export interface IconProps {
size?: number;
className?: string;
strokeWidth?: number;
}
@@ -1,10 +1,9 @@
import type { IconProps } from "./types";
export function OcVideoIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -34,10 +33,7 @@ export function OcVideoIcon({
export function OcCheckerboardIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -68,10 +64,7 @@ export function OcCheckerboardIcon({
export function OcSlidersVerticalIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -113,10 +106,7 @@ export function OcSlidersVerticalIcon({
export function OcSocialIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={Math.round(size * (38 / 24))}
@@ -162,10 +152,7 @@ export function OcSocialIcon({
export function OcTextWidthIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={Math.round((size * 17) / 24)}
@@ -190,10 +177,7 @@ export function OcTextWidthIcon({
export function OcTextHeightIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={Math.round((size * 20) / 23)}
@@ -218,10 +202,7 @@ export function OcTextHeightIcon({
export function OcFontIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -244,11 +225,7 @@ export function OcShapesIcon({
className = "",
size = 32,
strokeWidth = 1.5,
}: {
className?: string;
size?: number;
strokeWidth?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -288,10 +265,7 @@ export function OcShapesIcon({
export function OcMirrorIcon({
className = "",
size = 32,
}: {
className?: string;
size?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -329,11 +303,7 @@ export function OcSquarePlusIcon({
className = "",
size = 32,
strokeWidth = 1.5,
}: {
className?: string;
size?: number;
strokeWidth?: number;
}) {
}: IconProps) {
return (
<svg
width={size}
@@ -383,3 +353,52 @@ export function OcSquarePlusIcon({
</svg>
);
}
export function OcRippleIcon({
className = "",
size = 24,
strokeWidth = 1.5,
}: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
className={className}
>
<title>Ripple</title>
<path
d="M5 9C4.39316 9.58984 2 11.1597 2 12C2 12.8403 4.39316 14.4102 5 15"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19 9C19.6068 9.58984 22 11.1597 22 12C22 12.8403 19.6068 14.4102 19 15"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2.42285 11.9795H21.868"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12.1454 21.7021L12.1454 2.25692"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+51 -21
View File
@@ -1,9 +1,20 @@
"use client";
import { type PropsWithChildren, useEffect, useRef, useState } from "react";
import {
type PropsWithChildren,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
type HandlebarsProps = PropsWithChildren;
const MIN_HANDLE_SEPARATION_PX = 60;
const MASK_GRADIENT_EDGE_PADDING_PX = 10;
const HANDLEBARS_ROTATE_DEG = 2.76;
const RIGHT_HANDLE_LEFT_OFFSET_PX = -30;
export function Handlebars({ children }: HandlebarsProps) {
const containerRef = useRef<HTMLDivElement>(null);
const leftHandleRef = useRef<HTMLDivElement>(null);
@@ -31,18 +42,18 @@ export function Handlebars({ children }: HandlebarsProps) {
initialPosition: 0,
});
useEffect(() => {
const el = containerRef.current;
if (!el) return;
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateWidth = () => {
const newWidth = el.offsetWidth;
const newWidth = container.offsetWidth;
setWidth(newWidth);
setRightHandle(newWidth);
};
const observer = new ResizeObserver(updateWidth);
observer.observe(el);
observer.observe(container);
updateWidth();
return () => observer.disconnect();
@@ -66,7 +77,10 @@ export function Handlebars({ children }: HandlebarsProps) {
const deltaX = event.clientX - startX;
if (side === "left") {
const maxLeft = Math.max(0, rightHandlePositionRef.current - 60);
const maxLeft = Math.max(
0,
rightHandlePositionRef.current - MIN_HANDLE_SEPARATION_PX,
);
const nextLeftHandle = Math.max(
0,
Math.min(maxLeft, initialPosition + deltaX),
@@ -77,7 +91,7 @@ export function Handlebars({ children }: HandlebarsProps) {
const minRight = Math.min(
widthRef.current,
leftHandlePositionRef.current + 60,
leftHandlePositionRef.current + MIN_HANDLE_SEPARATION_PX,
);
const nextRightHandle = Math.max(
minRight,
@@ -106,13 +120,30 @@ export function Handlebars({ children }: HandlebarsProps) {
};
}, []);
const leftGradientPercent = width > 0 ? (leftHandle / (width - 10)) * 100 : 0;
const rightGradientPercent =
width > 0 ? (rightHandle / (width + 10)) * 100 : 0;
const hasMeasuredWidth = width > 0;
const leftGradientPercent = hasMeasuredWidth
? (leftHandle / (width - MASK_GRADIENT_EDGE_PADDING_PX)) * 100
: 0;
const rightGradientPercent = hasMeasuredWidth
? (rightHandle / (width + MASK_GRADIENT_EDGE_PADDING_PX)) * 100
: 100;
const textMask = hasMeasuredWidth
? `linear-gradient(90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) 100%)`
: undefined;
return (
<div className="flex justify-center gap-4 leading-16">
<div ref={containerRef} className="relative mt-0.5 -rotate-[2.76deg]">
<div
ref={containerRef}
className="relative mt-0.5"
style={{ transform: `rotate(-${HANDLEBARS_ROTATE_DEG}deg)` }}
>
<div className="absolute inset-0 z-10 flex size-full justify-between rounded-2xl border border-yellow-500">
<div
ref={leftHandleRef}
@@ -135,9 +166,13 @@ export function Handlebars({ children }: HandlebarsProps) {
<div
ref={rightHandleRef}
className="bg-background absolute -left-[30px] z-20 flex h-full w-7 cursor-ew-resize touch-none items-center justify-center rounded-full border border-yellow-500 select-none"
className="bg-background absolute z-20 flex h-full w-7 cursor-ew-resize touch-none items-center justify-center rounded-full border border-yellow-500 select-none"
style={{
translate: `${rightHandle}px 0`,
left: hasMeasuredWidth
? `${RIGHT_HANDLE_LEFT_OFFSET_PX}px`
: undefined,
right: hasMeasuredWidth ? undefined : "0px",
translate: hasMeasuredWidth ? `${rightHandle}px 0` : undefined,
}}
onPointerDown={(event) => {
event.preventDefault();
@@ -156,13 +191,8 @@ export function Handlebars({ children }: HandlebarsProps) {
<span
className="relative z-0 inline-flex size-full items-center justify-center rounded-2xl px-9 will-change-auto"
style={{
mask: `linear-gradient(90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) 100%)`,
mask: textMask,
WebkitMask: textMask,
}}
>
{children}
+69 -69
View File
@@ -1,69 +1,69 @@
import { OcDataBuddyIcon, OcMarbleIcon } from "@opencut/ui/icons";
export const SITE_URL = "https://opencut.app";
export const SITE_INFO = {
title: "OpenCut",
description:
"A simple but powerful video editor that gets the job done. In your browser.",
url: SITE_URL,
openGraphImage: "/open-graph/default.jpg",
twitterImage: "/open-graph/default.jpg",
favicon: "/favicon.ico",
};
export type ExternalTool = {
name: string;
description: string;
url: string;
icon: React.ElementType;
};
export const EXTERNAL_TOOLS: ExternalTool[] = [
{
name: "Marble",
description:
"Modern headless CMS for content management and the blog for OpenCut",
url: "https://marblecms.com?utm_source=opencut",
icon: OcMarbleIcon,
},
{
name: "Databuddy",
description: "GDPR compliant analytics and user insights for OpenCut",
url: "https://databuddy.cc?utm_source=opencut",
icon: OcDataBuddyIcon,
},
];
export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg";
export const SOCIAL_LINKS = {
x: "https://x.com/opencutapp",
github: "https://github.com/OpenCut-app/OpenCut",
discord: "https://discord.com/invite/Mu3acKZvCp",
};
export type Sponsor = {
name: string;
url: string;
logo: string;
description: string;
invertOnDark?: boolean;
};
export const SPONSORS: Sponsor[] = [
{
name: "Fal.ai",
url: "https://fal.ai?utm_source=opencut",
logo: "/logos/others/fal.svg",
description: "Generative image, video, and audio models all in one place.",
invertOnDark: true,
},
{
name: "Vercel",
url: "https://vercel.com?utm_source=opencut",
logo: "/logos/others/vercel.svg",
description: "Platform where we deploy and host OpenCut.",
invertOnDark: true,
},
];
import { OcDataBuddyIcon, OcMarbleIcon } from "@/components/icons";
export const SITE_URL = "https://opencut.app";
export const SITE_INFO = {
title: "OpenCut",
description:
"A simple but powerful video editor that gets the job done. In your browser.",
url: SITE_URL,
openGraphImage: "/open-graph/default.jpg",
twitterImage: "/open-graph/default.jpg",
favicon: "/favicon.ico",
};
export type ExternalTool = {
name: string;
description: string;
url: string;
icon: React.ElementType;
};
export const EXTERNAL_TOOLS: ExternalTool[] = [
{
name: "Marble",
description:
"Modern headless CMS for content management and the blog for OpenCut",
url: "https://marblecms.com?utm_source=opencut",
icon: OcMarbleIcon,
},
{
name: "Databuddy",
description: "GDPR compliant analytics and user insights for OpenCut",
url: "https://databuddy.cc?utm_source=opencut",
icon: OcDataBuddyIcon,
},
];
export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg";
export const SOCIAL_LINKS = {
x: "https://x.com/opencutapp",
github: "https://github.com/OpenCut-app/OpenCut",
discord: "https://discord.com/invite/Mu3acKZvCp",
};
export type Sponsor = {
name: string;
url: string;
logo: string;
description: string;
invertOnDark?: boolean;
};
export const SPONSORS: Sponsor[] = [
{
name: "Fal.ai",
url: "https://fal.ai?utm_source=opencut",
logo: "/logos/others/fal.svg",
description: "Generative image, video, and audio models all in one place.",
invertOnDark: true,
},
{
name: "Vercel",
url: "https://vercel.com?utm_source=opencut",
logo: "/logos/others/vercel.svg",
description: "Platform where we deploy and host OpenCut.",
invertOnDark: true,
},
];
+1 -11
View File
@@ -1,4 +1,4 @@
import type { ElementType, TrackType } from "@/lib/timeline";
import type { TrackType } from "@/lib/timeline";
export const TRACK_CONFIG: Record<
TrackType,
@@ -46,16 +46,6 @@ export const ELEMENT_TYPE_CONFIG: Record<
effect: { background: "bg-[#5d93ba]" },
} as const;
export const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = {
audio: "audio",
text: "text",
sticker: "graphic",
graphic: "graphic",
effect: "effect",
video: "video",
image: "video",
};
export const TRACK_GAP = 6;
export const TRACK_LABELS_WIDTH_PX = 112;
@@ -51,6 +51,8 @@ export const TRANSCRIPTION_MODELS: TranscriptionModel[] = [
export const DEFAULT_TRANSCRIPTION_MODEL: TranscriptionModelId =
"whisper-small";
export const DEFAULT_TRANSCRIPTION_SAMPLE_RATE = 16000;
export const DEFAULT_CHUNK_LENGTH_SECONDS = 30;
export const DEFAULT_STRIDE_SECONDS = 5;
+1 -1
View File
@@ -12,7 +12,7 @@ import {
getFrameTime,
isBookmarkAtTime,
} from "@/lib/timeline/bookmarks";
import { ensureMainTrack } from "@/lib/timeline/track-utils";
import { ensureMainTrack } from "@/lib/track-placement";
import {
CreateSceneCommand,
DeleteSceneCommand,
@@ -16,7 +16,7 @@ import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getDragData, hasDragData } from "@/lib/drag-data";
import { isMainTrack } from "@/lib/timeline/track-utils";
import { isMainTrack } from "@/lib/track-placement";
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
import type {
MediaDragData,
+6 -6
View File
@@ -1,6 +1,6 @@
import { createAuthClient } from "better-auth/react";
import { webEnv } from "@opencut/env/web";
export const { signIn, signUp, useSession } = createAuthClient({
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
});
import { createAuthClient } from "better-auth/react";
import { webEnv } from "@/lib/env/web";
export const { signIn, signUp, useSession } = createAuthClient({
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
});
+43 -43
View File
@@ -1,43 +1,43 @@
import { betterAuth, type RateLimit } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { Redis } from "@upstash/redis";
import { db } from "@/lib/db";
import { webEnv } from "@opencut/env/web";
const redis = new Redis({
url: webEnv.UPSTASH_REDIS_REST_URL,
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
});
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
}),
secret: webEnv.BETTER_AUTH_SECRET,
user: {
deleteUser: {
enabled: true,
},
},
emailAndPassword: {
enabled: true,
},
rateLimit: {
storage: "secondary-storage",
customStorage: {
get: async (key) => {
const value = await redis.get(key);
return value as RateLimit | undefined;
},
set: async (key, value) => {
await redis.set(key, value);
},
},
},
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
appName: "OpenCut",
trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
});
export type Auth = typeof auth;
import { betterAuth, type RateLimit } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { Redis } from "@upstash/redis";
import { db } from "@/lib/db";
import { webEnv } from "@/lib/env/web";
const redis = new Redis({
url: webEnv.UPSTASH_REDIS_REST_URL,
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
});
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
}),
secret: webEnv.BETTER_AUTH_SECRET,
user: {
deleteUser: {
enabled: true,
},
},
emailAndPassword: {
enabled: true,
},
rateLimit: {
storage: "secondary-storage",
customStorage: {
get: async (key) => {
const value = await redis.get(key);
return value as RateLimit | undefined;
},
set: async (key, value) => {
await redis.set(key, value);
},
},
},
baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
appName: "OpenCut",
trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
});
export type Auth = typeof auth;
@@ -6,13 +6,12 @@ import type {
ClipboardItem,
} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
applyPlacement,
resolveTrackPlacement,
isMainTrack,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
} from "@/lib/track-placement";
import { cloneAnimations } from "@/lib/animation";
export class PasteCommand extends Command {
@@ -59,17 +58,22 @@ export class PasteCommand extends Command {
const sourceTrackIndex = updatedTracks.findIndex(
(track) => track.id === trackId,
);
const resolvedTargetIndex = resolveTargetTrackIndex({
const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
sourceTrackIndex,
trackType,
elements: elementsToAdd,
timeSpans: elementsToAdd.map((element) => ({
startTime: element.startTime,
duration: element.duration,
})),
strategy: { type: "aboveSource", sourceTrackIndex },
});
if (!placementResult) {
continue;
}
if (resolvedTargetIndex >= 0) {
const targetTrack = updatedTracks[resolvedTargetIndex];
let adjustedElements = elementsToAdd;
let elementsForPlacement = elementsToAdd;
if (placementResult.kind === "existingTrack") {
const targetTrack = updatedTracks[placementResult.trackIndex];
if (isMainTrack(targetTrack)) {
const earliestElement = elementsToAdd.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest,
@@ -82,39 +86,28 @@ export class PasteCommand extends Command {
const delta = adjustedEarliestStartTime - earliestElement.startTime;
if (delta !== 0) {
adjustedElements = elementsToAdd.map((element) => ({
elementsForPlacement = elementsToAdd.map((element) => ({
...element,
startTime: Math.max(0, element.startTime + delta),
}));
}
}
}
updatedTracks[resolvedTargetIndex] = {
...targetTrack,
elements: [...targetTrack.elements, ...adjustedElements],
} as TimelineTrack;
for (const element of adjustedElements) {
this.pastedElements.push({
trackId: targetTrack.id,
elementId: element.id,
});
}
const applied = applyPlacement({
tracks: updatedTracks,
placementResult,
elements: elementsForPlacement,
});
if (!applied) {
continue;
}
const insertIndex = resolveInsertIndexForNewTrack({
tracks: updatedTracks,
sourceTrackIndex,
trackType,
});
const newTrack = buildTrackWithElements({
trackType,
elements: elementsToAdd,
});
updatedTracks.splice(insertIndex, 0, newTrack);
for (const element of elementsToAdd) {
updatedTracks = applied.updatedTracks;
for (const element of elementsForPlacement) {
this.pastedElements.push({
trackId: newTrack.id,
trackId: applied.targetTrackId,
elementId: element.id,
});
}
@@ -187,107 +180,3 @@ function buildPastedElements({
return elementsToAdd;
}
function resolveTargetTrackIndex({
tracks,
sourceTrackIndex,
trackType,
elements,
}: {
tracks: TimelineTrack[];
sourceTrackIndex: number;
trackType: ClipboardItem["trackType"];
elements: TimelineElement[];
}): number {
if (sourceTrackIndex >= 0) {
const aboveIndex = sourceTrackIndex - 1;
if (aboveIndex < 0) {
return -1;
}
const aboveTrack = tracks[aboveIndex];
if (aboveTrack.type !== trackType) {
return -1;
}
const canPlaceOnAbove = canPlaceElementsOnTrack({
track: aboveTrack,
elements,
});
return canPlaceOnAbove ? aboveIndex : -1;
}
const highestCompatibleIndex = tracks.findIndex(
(track) => track.type === trackType,
);
if (highestCompatibleIndex < 0) {
return -1;
}
const highestCompatibleTrack = tracks[highestCompatibleIndex];
const canPlaceOnHighest = canPlaceElementsOnTrack({
track: highestCompatibleTrack,
elements,
});
return canPlaceOnHighest ? highestCompatibleIndex : -1;
}
function resolveInsertIndexForNewTrack({
tracks,
sourceTrackIndex,
trackType,
}: {
tracks: TimelineTrack[];
sourceTrackIndex: number;
trackType: ClipboardItem["trackType"];
}): number {
if (sourceTrackIndex >= 0) {
return sourceTrackIndex;
}
const highestCompatibleIndex = tracks.findIndex(
(track) => track.type === trackType,
);
if (highestCompatibleIndex >= 0) {
return highestCompatibleIndex;
}
return getHighestInsertIndexForTrack({ tracks, trackType });
}
function canPlaceElementsOnTrack({
track,
elements,
}: {
track: TimelineTrack;
elements: TimelineElement[];
}): boolean {
for (const element of elements) {
const endTime = element.startTime + element.duration;
const hasOverlap = wouldElementOverlap({
elements: track.elements,
startTime: element.startTime,
endTime,
});
if (hasOverlap) {
return false;
}
}
return true;
}
function buildTrackWithElements({
trackType,
elements,
}: {
trackType: ClipboardItem["trackType"];
elements: TimelineElement[];
}): TimelineTrack {
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({ id: newTrackId, type: trackType });
return {
...newTrackBase,
elements,
} as TimelineTrack;
}
@@ -1,7 +1,8 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
import { rippleShiftElements } from "@/lib/timeline";
import { isMainTrack } from "@/lib/track-placement";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -2,10 +2,7 @@ import { Command } from "@/lib/commands/base-command";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
import { applyPlacement, resolveTrackPlacement } from "@/lib/track-placement";
import { cloneAnimations } from "@/lib/animation";
interface DuplicateElementsParams {
@@ -45,22 +42,12 @@ export class DuplicateElementsCommand extends Command {
);
const newTrackElements: TimelineElement[] = [];
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({
id: newTrackId,
type: track.type,
});
for (const element of track.elements) {
if (!elementIdsToDuplicate.has(element.id)) {
continue;
}
const newId = generateUUID();
this.duplicatedElements.push({
trackId: newTrackId,
elementId: newId,
});
newTrackElements.push(
buildDuplicateElement({
element,
@@ -70,16 +57,33 @@ export class DuplicateElementsCommand extends Command {
);
}
const newTrack = {
...newTrackBase,
elements: newTrackElements,
} as TimelineTrack;
const insertIndex = getHighestInsertIndexForTrack({
const placementResult = resolveTrackPlacement({
tracks: updatedTracks,
trackType: track.type,
timeSpans: [],
strategy: { type: "alwaysNew", position: "highest" },
});
updatedTracks.splice(insertIndex, 0, newTrack);
if (!placementResult || placementResult.kind !== "newTrack") {
continue;
}
const applied = applyPlacement({
tracks: updatedTracks,
placementResult,
elements: newTrackElements,
});
if (!applied) {
continue;
}
updatedTracks = applied.updatedTracks;
for (const element of newTrackElements) {
this.duplicatedElements.push({
trackId: applied.targetTrackId,
elementId: element.id,
});
}
}
editor.timeline.updateTracks(updatedTracks);
@@ -5,23 +5,18 @@ import type {
TimelineTrack,
TimelineElement,
TrackType,
ElementType,
} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import {
requiresMediaId,
wouldElementOverlap,
} from "@/lib/timeline/element-utils";
import {
buildEmptyTrack,
canElementGoOnTrack,
getDefaultInsertIndexForTrack,
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
import { requiresMediaId } from "@/lib/timeline/element-utils";
import type { MediaAsset } from "@/lib/media/types";
import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
import {
applyPlacement,
canElementGoOnTrack,
resolveTrackPlacement,
validateElementTrackCompatibility,
} from "@/lib/track-placement";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
@@ -67,7 +62,7 @@ export class InsertElementCommand extends Command {
const isFirstElement = totalElementsInTimeline === 0;
const newElement = this.buildElement({ element: this.element });
const updateResult = this.resolveTracksWithElement({
const updateResult = this.applyPlacementResult({
tracks: this.savedState,
element: newElement,
});
@@ -190,7 +185,7 @@ export class InsertElementCommand extends Command {
return true;
}
private resolveTracksWithElement({
private applyPlacementResult({
tracks,
element,
}: {
@@ -199,160 +194,71 @@ export class InsertElementCommand extends Command {
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
const placement = this.placement;
if (placement.mode === "explicit") {
const targetTrack = tracks.find(
(track) => track.id === placement.trackId,
);
if (!targetTrack) {
console.error("Track not found:", placement.trackId);
return null;
}
const validation = validateElementTrackCompatibility({
element,
track: targetTrack,
});
if (!validation.isValid) {
console.error(validation.errorMessage);
return null;
}
const adjustedElement = this.adjustElementForMainTrack({
tracks,
targetTrackId: targetTrack.id,
element,
});
const updatedTracks = tracks.map((track) =>
track.id === targetTrack.id
? {
...track,
elements: [...track.elements, adjustedElement],
}
: track,
) as TimelineTrack[];
return { updatedTracks, targetTrackId: targetTrack.id };
}
const trackType =
placement.trackType ?? this.getTrackTypeForElement({ element });
if (
placement.mode === "auto" &&
placement.trackType &&
!canElementGoOnTrack({
elementType: element.type,
trackType,
trackType: placement.trackType,
})
) {
console.error(
`${element.type} elements cannot be placed on ${trackType} tracks`,
`${element.type} elements cannot be placed on ${placement.trackType} tracks`,
);
return null;
}
const elementEndTime = element.startTime + element.duration;
const existingTrack = tracks.find((track) => {
if (
!canElementGoOnTrack({
elementType: element.type,
trackType: track.type,
})
) {
return false;
const placementResult = resolveTrackPlacement({
tracks,
...(placement.mode === "auto" && placement.trackType
? { trackType: placement.trackType }
: { elementType: element.type }),
timeSpans: [
{
startTime: element.startTime,
duration: element.duration,
},
],
strategy:
placement.mode === "explicit"
? { type: "explicit", trackId: placement.trackId }
: { type: "firstAvailable" },
});
if (!placementResult) {
if (placement.mode === "explicit") {
const targetTrack = tracks.find((track) => track.id === placement.trackId);
if (!targetTrack) {
console.error("Track not found:", placement.trackId);
return null;
}
const validation = validateElementTrackCompatibility({
element,
track: targetTrack,
});
console.error(validation.errorMessage);
}
return !wouldElementOverlap({
elements: track.elements,
startTime: element.startTime,
endTime: elementEndTime,
});
});
if (existingTrack) {
const adjustedElement = this.adjustElementForMainTrack({
tracks,
targetTrackId: existingTrack.id,
element,
});
const updatedTracks = tracks.map((track) =>
track.id === existingTrack.id
? {
...track,
elements: [...track.elements, adjustedElement],
}
: track,
) as TimelineTrack[];
return { updatedTracks, targetTrackId: existingTrack.id };
return null;
}
const newTrackId = generateUUID();
const newTrack = buildEmptyTrack({
id: newTrackId,
type: trackType,
});
const newTrackWithElement = {
...newTrack,
elements: [...newTrack.elements, element],
} as TimelineTrack;
const elementToPlace =
placementResult.kind === "existingTrack"
? {
...element,
startTime:
placementResult.adjustedStartTime ?? element.startTime,
}
: element;
const updatedTracks = [...tracks];
const insertIndex =
placement.insertIndex ??
this.getAutoInsertIndex({ tracks: updatedTracks, trackType });
updatedTracks.splice(insertIndex, 0, newTrackWithElement);
return { updatedTracks, targetTrackId: newTrackId };
}
private getAutoInsertIndex({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
if (trackType === "text") {
const firstVideoTrackIndex = tracks.findIndex(
(track) => track.type === "video",
);
if (firstVideoTrackIndex >= 0) {
return firstVideoTrackIndex;
}
}
return getDefaultInsertIndexForTrack({
return applyPlacement({
tracks,
trackType,
placementResult,
elements: [elementToPlace],
newTrackInsertIndexOverride:
placement.mode === "auto" && typeof placement.insertIndex === "number"
? placement.insertIndex
: undefined,
});
}
private adjustElementForMainTrack({
tracks,
targetTrackId,
element,
}: {
tracks: TimelineTrack[];
targetTrackId: string;
element: TimelineElement;
}): TimelineElement {
const adjustedStartTime = enforceMainTrackStart({
tracks,
targetTrackId,
requestedStartTime: element.startTime,
});
return { ...element, startTime: adjustedStartTime };
}
private getTrackTypeForElement({
element,
}: {
element: { type: ElementType };
}): TrackType {
return ELEMENT_TRACK_MAP[element.type];
}
}
@@ -10,7 +10,7 @@ import {
isMainTrack,
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
} from "@/lib/track-placement";
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
export class MoveElementCommand extends Command {
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
import { enforceMainTrackStart } from "@/lib/track-placement";
export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -3,7 +3,7 @@ import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
import { enforceMainTrackStart } from "@/lib/track-placement";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -5,7 +5,7 @@ import { EditorCore } from "@/core";
import {
buildEmptyTrack,
getDefaultInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
} from "@/lib/track-placement";
export class AddTrackCommand extends Command {
private trackId: string;
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import type { TimelineTrack } from "@/lib/timeline";
import { getMainTrack } from "@/lib/timeline";
import { getMainTrack } from "@/lib/track-placement";
export class RemoveTrackCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+1 -1
View File
@@ -1,7 +1,7 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
import { webEnv } from "@opencut/env/web";
import { webEnv } from "@/lib/env/web";
let _db: ReturnType<typeof drizzle> | null = null;
+5 -4
View File
@@ -11,10 +11,11 @@ const webEnvSchema = z.object({
NEXT_PUBLIC_MARBLE_API_URL: z.url(),
// Server
DATABASE_URL: z
.string()
.startsWith("postgres://")
.or(z.string().startsWith("postgresql://")),
DATABASE_URL: z.string().refine(
(url) =>
url.startsWith("postgres://") || url.startsWith("postgresql://"),
"DATABASE_URL must be a postgres:// or postgresql:// URL",
),
BETTER_AUTH_SECRET: z.string(),
UPSTASH_REDIS_REST_URL: z.url(),
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types";
import { isMainTrack } from "@/lib/timeline";
import { isMainTrack } from "@/lib/track-placement";
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
import { measureTextElement } from "@/lib/text/measure-element";
+21 -21
View File
@@ -1,21 +1,21 @@
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { webEnv } from "@opencut/env/web";
const redis = new Redis({
url: webEnv.UPSTASH_REDIS_REST_URL,
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
});
export const baseRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
analytics: true,
prefix: "rate-limit",
});
export async function checkRateLimit({ request }: { request: Request }) {
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await baseRateLimit.limit(ip);
return { success, limited: !success };
}
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { webEnv } from "@/lib/env/web";
const redis = new Redis({
url: webEnv.UPSTASH_REDIS_REST_URL,
token: webEnv.UPSTASH_REDIS_REST_TOKEN,
});
export const baseRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
analytics: true,
prefix: "rate-limit",
});
export async function checkRateLimit({ request }: { request: Request }) {
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await baseRateLimit.limit(ip);
return { success, limited: !success };
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { TScene } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/lib/timeline";
import { ensureMainTrack } from "@/lib/timeline/track-utils";
import { ensureMainTrack } from "@/lib/track-placement";
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
return scenes.find((scene) => scene.isMain) || null;
+64 -121
View File
@@ -1,16 +1,10 @@
import type {
TimelineTrack,
ElementType,
TimelineElement,
} from "@/lib/timeline";
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
import { wouldElementOverlap } from "./element-utils";
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
import {
canElementGoOnTrack,
isMainTrack,
enforceMainTrackStart,
} from "./track-utils";
import { resolveTrackPlacement } from "@/lib/track-placement";
function findElementAtPosition({
mouseX,
@@ -82,57 +76,22 @@ function getTrackAtY({
return null;
}
function isCompatible({
elementType,
trackType,
const EMPTY_TARGET_ELEMENT = null;
function fallbackNewTrackDropTarget({
xPosition,
}: {
elementType: ElementType;
trackType: TimelineTrack["type"];
}): boolean {
return canElementGoOnTrack({ elementType, trackType });
}
function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
return tracks.findIndex((track) => isMainTrack(track));
}
function findInsertIndex({
elementType,
tracks,
preferredIndex,
insertAbove,
}: {
elementType: ElementType;
tracks: TimelineTrack[];
preferredIndex: number;
insertAbove: boolean;
}): { index: number; position: "above" | "below" } {
const mainTrackIndex = getMainTrackIndex({ tracks });
if (elementType === "audio") {
if (preferredIndex <= mainTrackIndex) {
return { index: mainTrackIndex + 1, position: "below" };
}
return {
index: insertAbove ? preferredIndex : preferredIndex + 1,
position: insertAbove ? "above" : "below",
};
}
const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1;
if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) {
return { index: mainTrackIndex, position: "above" };
}
xPosition: number;
}): DropTarget {
return {
index: overlayInsertIndex,
position: insertAbove ? "above" : "below",
trackIndex: 0,
isNewTrack: true,
insertPosition: null,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
const EMPTY_TARGET_ELEMENT = null;
export function computeDropTarget({
elementType,
mouseX,
@@ -155,22 +114,28 @@ export function computeDropTarget({
? playheadTime
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
const mainTrackIndex = getMainTrackIndex({ tracks });
if (tracks.length === 0) {
if (elementType === "audio") {
return {
const placementResult = resolveTrackPlacement({
tracks,
elementType,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex: 0,
isNewTrack: true,
insertPosition: "below",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
hoverDirection: "below",
createNewTrackOnly: true,
},
});
const emptyTimelineResult =
placementResult?.kind === "newTrack" ? placementResult : null;
if (!emptyTimelineResult) {
return fallbackNewTrackDropTarget({ xPosition });
}
return {
trackIndex: 0,
trackIndex: emptyTimelineResult.insertIndex,
isNewTrack: true,
insertPosition: null,
insertPosition: emptyTimelineResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
@@ -181,30 +146,27 @@ export function computeDropTarget({
if (!trackAtMouse) {
const isAboveAllTracks = mouseY < 0;
if (elementType === "audio") {
return {
trackIndex: tracks.length,
isNewTrack: true,
insertPosition: "below",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
if (isAboveAllTracks) {
return {
trackIndex: 0,
isNewTrack: true,
insertPosition: "above",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
const placementResult = resolveTrackPlacement({
tracks,
elementType,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1,
hoverDirection: isAboveAllTracks ? "above" : "below",
createNewTrackOnly: true,
},
});
const outOfBoundsResult =
placementResult?.kind === "newTrack" ? placementResult : null;
if (!outOfBoundsResult) {
return fallbackNewTrackDropTarget({ xPosition });
}
return {
trackIndex: Math.max(0, mainTrackIndex),
trackIndex: outOfBoundsResult.insertIndex,
isNewTrack: true,
insertPosition: "above",
insertPosition: outOfBoundsResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
@@ -237,34 +199,27 @@ export function computeDropTarget({
}
const trackHeight = TRACK_CONFIG[track.type].height;
const isInUpperHalf = relativeY < trackHeight / 2;
const isTrackCompatible = isCompatible({
const placementResult = resolveTrackPlacement({
tracks,
elementType,
trackType: track.type,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex,
hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
verticalDragDirection,
},
});
if (!placementResult) {
return fallbackNewTrackDropTarget({ xPosition });
}
const endTime = xPosition + elementDuration;
const hasOverlap = wouldElementOverlap({
elements: track.elements,
startTime: xPosition,
endTime,
excludeElementId,
});
if (isTrackCompatible && !hasOverlap) {
const targetTrack = tracks[trackIndex];
// safe: snap to 0 only happens when element becomes the new earliest,
// meaning the space before the current earliest is empty
const adjustedXPosition = enforceMainTrackStart({
tracks,
targetTrackId: targetTrack.id,
requestedStartTime: xPosition,
excludeElementId,
});
if (placementResult.kind === "existingTrack") {
const adjustedXPosition =
placementResult.adjustedStartTime ?? xPosition;
return {
trackIndex,
trackIndex: placementResult.trackIndex,
isNewTrack: false,
insertPosition: null,
xPosition: adjustedXPosition,
@@ -272,22 +227,10 @@ export function computeDropTarget({
};
}
let insertAbove = isInUpperHalf;
if (!isTrackCompatible && verticalDragDirection) {
insertAbove = verticalDragDirection === "up";
}
const { index, position } = findInsertIndex({
elementType,
tracks,
preferredIndex: trackIndex,
insertAbove,
});
return {
trackIndex: index,
trackIndex: placementResult.insertIndex,
isNewTrack: true,
insertPosition: position,
insertPosition: placementResult.insertPosition,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
@@ -86,24 +86,6 @@ export function requiresMediaId({
);
}
export function wouldElementOverlap({
elements,
startTime,
endTime,
excludeElementId,
}: {
elements: TimelineElement[];
startTime: number;
endTime: number;
excludeElementId?: string;
}): boolean {
return elements.some((element) => {
if (excludeElementId && element.id === excludeElementId) return false;
const elementEnd = element.startTime + element.duration;
return startTime < elementEnd && endTime > element.startTime;
});
}
function buildTextBackground(
raw: Partial<TextBackground> | undefined,
): TextBackground {
@@ -0,0 +1,462 @@
import { describe, expect, test } from "bun:test";
import type { ElementType, TimelineElement, TimelineTrack, TrackType } from "@/lib/timeline";
import { resolveTrackPlacement } from "@/lib/track-placement";
function buildElement({
id,
type,
startTime,
duration,
}: {
id: string;
type: ElementType;
startTime: number;
duration: number;
}): TimelineElement {
return {
id,
type,
name: id,
startTime,
duration,
trimStart: 0,
trimEnd: 0,
} as TimelineElement;
}
function buildTrack({
id,
type,
elements = [],
isMain = false,
}: {
id: string;
type: TrackType;
elements?: TimelineElement[];
isMain?: boolean;
}): TimelineTrack {
if (type === "video") {
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
isMain,
muted: false,
hidden: false,
};
}
if (type === "audio") {
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
muted: false,
};
}
return {
id,
type,
name: id,
elements: elements as TimelineTrack["elements"],
hidden: false,
};
}
function buildTimeSpan({
startTime,
duration,
excludeElementId,
}: {
startTime: number;
duration: number;
excludeElementId?: string;
}) {
return { startTime, duration, excludeElementId };
}
describe("resolveTrackPlacement", () => {
test("explicit returns the requested compatible track", () => {
const tracks = [buildTrack({ id: "text-1", type: "text" })];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 2, duration: 3 })],
strategy: { type: "explicit", trackId: "text-1" },
}),
).toEqual({
kind: "existingTrack",
trackId: "text-1",
trackIndex: 0,
trackType: "text",
});
});
test("explicit rejects missing and incompatible tracks", () => {
const tracks = [buildTrack({ id: "video-1", type: "video", isMain: true })];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
strategy: { type: "explicit", trackId: "missing" },
}),
).toBeNull();
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
strategy: { type: "explicit", trackId: "video-1" },
}),
).toBeNull();
});
test("firstAvailable picks the first compatible track without overlap", () => {
const tracks = [
buildTrack({
id: "text-1",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
}),
buildTrack({ id: "text-2", type: "text" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 2, duration: 1 })],
strategy: { type: "firstAvailable" },
}),
).toEqual({
kind: "existingTrack",
trackId: "text-2",
trackIndex: 1,
trackType: "text",
});
});
test("firstAvailable creates a new track when all compatible tracks are full", () => {
const tracks = [
buildTrack({
id: "graphic-1",
type: "graphic",
elements: [
buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }),
],
}),
buildTrack({
id: "video-main",
type: "video",
isMain: true,
}),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "graphic",
timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
strategy: { type: "firstAvailable" },
}),
).toEqual({
kind: "newTrack",
trackType: "graphic",
insertIndex: 1,
insertPosition: null,
});
});
test("preferIndex uses the preferred track when it fits", () => {
const tracks = [buildTrack({ id: "audio-1", type: "audio" })];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [buildTimeSpan({ startTime: 3, duration: 2 })],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "below",
},
}),
).toEqual({
kind: "existingTrack",
trackId: "audio-1",
trackIndex: 0,
trackType: "audio",
});
});
test("preferIndex creates a new overlay track above the main track", () => {
const tracks = [
buildTrack({ id: "video-main", type: "video", isMain: true }),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "graphic",
timeSpans: [buildTimeSpan({ startTime: 1, duration: 2 })],
strategy: {
type: "preferIndex",
trackIndex: 1,
hoverDirection: "below",
},
}),
).toEqual({
kind: "newTrack",
trackType: "graphic",
insertIndex: 0,
insertPosition: "above",
});
});
test("preferIndex keeps audio tracks below the main track", () => {
const tracks = [
buildTrack({ id: "text-1", type: "text" }),
buildTrack({ id: "video-main", type: "video", isMain: true }),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "above",
createNewTrackOnly: true,
},
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 2,
insertPosition: "below",
});
});
test("aboveSource tries the track above source, then any compatible track", () => {
const tracks = [
buildTrack({ id: "text-top", type: "text" }),
buildTrack({
id: "text-middle",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
}),
buildTrack({ id: "text-source", type: "text" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
strategy: { type: "aboveSource", sourceTrackIndex: 2 },
}),
).toEqual({
kind: "existingTrack",
trackId: "text-top",
trackIndex: 0,
trackType: "text",
});
});
test("aboveSource creates a new track near the source when none fit", () => {
const tracks = [
buildTrack({
id: "text-top",
type: "text",
elements: [buildElement({ id: "a", type: "text", startTime: 0, duration: 5 })],
}),
buildTrack({
id: "text-source",
type: "text",
elements: [buildElement({ id: "b", type: "text", startTime: 0, duration: 5 })],
}),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "text",
timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
strategy: { type: "aboveSource", sourceTrackIndex: 1 },
}),
).toEqual({
kind: "newTrack",
trackType: "text",
insertIndex: 1,
insertPosition: null,
});
});
test("alwaysNew honors highest and default insertion rules", () => {
const tracks = [
buildTrack({ id: "video-main", type: "video", isMain: true }),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [],
strategy: { type: "alwaysNew", position: "highest" },
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 1,
insertPosition: null,
});
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [],
strategy: { type: "alwaysNew", position: "default" },
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 2,
insertPosition: null,
});
});
test("batch time spans reject tracks when any span overlaps", () => {
const tracks = [
buildTrack({
id: "audio-1",
type: "audio",
elements: [
buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }),
buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }),
],
}),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [
buildTimeSpan({ startTime: 2.5, duration: 1 }),
buildTimeSpan({ startTime: 5.5, duration: 1 }),
],
strategy: { type: "firstAvailable" },
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 1,
insertPosition: null,
});
});
test("handles empty timelines, single tracks, and track-type derivation", () => {
expect(
resolveTrackPlacement({
tracks: [],
elementType: "video",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "below",
createNewTrackOnly: true,
},
}),
).toEqual({
kind: "newTrack",
trackType: "video",
insertIndex: 0,
insertPosition: null,
});
expect(
resolveTrackPlacement({
tracks: [buildTrack({ id: "audio-1", type: "audio" })],
elementType: "audio",
timeSpans: [],
strategy: { type: "alwaysNew", position: "default" },
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 1,
insertPosition: null,
});
});
test("existingTrack on main video includes adjustedStartTime when start snaps", () => {
const tracks = [
buildTrack({
id: "video-main",
type: "video",
isMain: true,
elements: [
buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }),
],
}),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "video",
timeSpans: [buildTimeSpan({ startTime: 2, duration: 2 })],
strategy: { type: "explicit", trackId: "video-main" },
}),
).toEqual({
kind: "existingTrack",
trackId: "video-main",
trackIndex: 0,
trackType: "video",
adjustedStartTime: 0,
});
});
test("preferIndex uses vertical drag direction when hovered track is incompatible", () => {
const tracks = [
buildTrack({ id: "text-1", type: "text" }),
buildTrack({ id: "video-main", type: "video", isMain: true }),
buildTrack({ id: "audio-1", type: "audio" }),
];
expect(
resolveTrackPlacement({
tracks,
elementType: "audio",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "above",
verticalDragDirection: "down",
},
}),
).toEqual({
kind: "newTrack",
trackType: "audio",
insertIndex: 2,
insertPosition: "below",
});
});
});
@@ -0,0 +1,45 @@
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { buildEmptyTrack } from "./track-factory";
import type { PlacementResult } from "./types";
export function applyPlacement({
tracks,
placementResult,
elements,
newTrackInsertIndexOverride,
}: {
tracks: TimelineTrack[];
placementResult: PlacementResult;
elements: TimelineElement[];
newTrackInsertIndexOverride?: number;
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
if (placementResult.kind === "existingTrack") {
const targetTrack = tracks[placementResult.trackIndex];
if (!targetTrack) {
return null;
}
const updatedTracks = tracks.map((track, trackIndex) =>
trackIndex === placementResult.trackIndex
? {
...track,
elements: [...track.elements, ...elements],
}
: track,
) as TimelineTrack[];
return { updatedTracks, targetTrackId: targetTrack.id };
}
const newTrackId = generateUUID();
const newTrack = {
...buildEmptyTrack({ id: newTrackId, type: placementResult.trackType }),
elements,
} as TimelineTrack;
const insertIndex =
newTrackInsertIndexOverride ?? placementResult.insertIndex;
const updatedTracks = [...tracks];
updatedTracks.splice(insertIndex, 0, newTrack);
return { updatedTracks, targetTrackId: newTrackId };
}
@@ -0,0 +1,51 @@
import type { ElementType, TrackType } from "@/lib/timeline";
const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = {
audio: "audio",
text: "text",
sticker: "graphic",
graphic: "graphic",
effect: "effect",
video: "video",
image: "video",
};
export function getTrackTypeForElementType({
elementType,
}: {
elementType: ElementType;
}): TrackType {
return ELEMENT_TRACK_MAP[elementType];
}
export function canElementGoOnTrack({
elementType,
trackType,
}: {
elementType: ElementType;
trackType: TrackType;
}): boolean {
return getTrackTypeForElementType({ elementType }) === trackType;
}
export function validateElementTrackCompatibility({
element,
track,
}: {
element: { type: ElementType };
track: { type: TrackType };
}): { isValid: boolean; errorMessage?: string } {
const isValid = canElementGoOnTrack({
elementType: element.type,
trackType: track.type,
});
if (!isValid) {
return {
isValid: false,
errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
};
}
return { isValid: true };
}
@@ -0,0 +1,18 @@
export { applyPlacement } from "./apply";
export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility";
export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index";
export {
enforceMainTrackStart,
ensureMainTrack,
getEarliestMainTrackElement,
getMainTrack,
isMainTrack,
} from "./main-track";
export { resolveTrackPlacement } from "./resolve";
export { buildEmptyTrack } from "./track-factory";
export type {
PlacementResult,
PlacementStrategy,
PlacementSubject,
PlacementTimeSpan,
} from "./types";
@@ -0,0 +1,99 @@
import type { TrackType, TimelineTrack } from "@/lib/timeline";
import { isMainTrack } from "./main-track";
export function getDefaultInsertIndexForTrack({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
if (trackType === "audio") {
return tracks.length;
}
if (trackType === "effect") {
return 0;
}
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (mainTrackIndex >= 0) {
return mainTrackIndex;
}
const firstAudioTrackIndex = tracks.findIndex((track) => track.type === "audio");
if (firstAudioTrackIndex >= 0) {
return firstAudioTrackIndex;
}
return tracks.length;
}
export function getHighestInsertIndexForTrack({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (trackType === "audio") {
return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
}
return 0;
}
export function resolvePreferredNewTrackPlacement({
tracks,
trackType,
preferredIndex,
direction,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
preferredIndex: number;
direction: "above" | "below";
}): { insertIndex: number; insertPosition: "above" | "below" | null } {
if (tracks.length === 0) {
return {
insertIndex: 0,
insertPosition: trackType === "audio" ? "below" : null,
};
}
const safePreferredIndex = Math.min(
Math.max(preferredIndex, 0),
tracks.length - 1,
);
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (trackType === "audio") {
if (safePreferredIndex <= mainTrackIndex) {
return {
insertIndex: mainTrackIndex + 1,
insertPosition: "below",
};
}
return {
insertIndex:
direction === "above" ? safePreferredIndex : safePreferredIndex + 1,
insertPosition: direction,
};
}
const insertIndex =
direction === "above" ? safePreferredIndex : safePreferredIndex + 1;
if (mainTrackIndex >= 0 && insertIndex > mainTrackIndex) {
return {
insertIndex: mainTrackIndex,
insertPosition: "above",
};
}
return {
insertIndex,
insertPosition: direction,
};
}
@@ -0,0 +1,96 @@
import type { TimelineElement, TimelineTrack, VideoTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
const MAIN_TRACK_NAME = "Main Track";
export function isMainTrack(track: TimelineTrack): track is VideoTrack {
return track.type === "video" && track.isMain === true;
}
export function getMainTrack({
tracks,
}: {
tracks: TimelineTrack[];
}): VideoTrack | null {
return tracks.find((track) => isMainTrack(track)) ?? null;
}
export function ensureMainTrack({
tracks,
}: {
tracks: TimelineTrack[];
}): TimelineTrack[] {
if (tracks.some((track) => isMainTrack(track))) {
return tracks;
}
return [
{
id: generateUUID(),
name: MAIN_TRACK_NAME,
type: "video",
elements: [],
muted: false,
isMain: true,
hidden: false,
},
...tracks,
];
}
export function getEarliestMainTrackElement({
tracks,
excludeElementId,
}: {
tracks: TimelineTrack[];
excludeElementId?: string;
}): TimelineElement | null {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack) {
return null;
}
const elements = mainTrack.elements.filter((element) => {
return !excludeElementId || element.id !== excludeElementId;
});
if (elements.length === 0) {
return null;
}
return elements.reduce((earliestElement, element) => {
return element.startTime < earliestElement.startTime
? element
: earliestElement;
});
}
export function enforceMainTrackStart({
tracks,
targetTrackId,
requestedStartTime,
excludeElementId,
}: {
tracks: TimelineTrack[];
targetTrackId: string;
requestedStartTime: number;
excludeElementId?: string;
}): number {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack || mainTrack.id !== targetTrackId) {
return requestedStartTime;
}
const earliestElement = getEarliestMainTrackElement({
tracks,
excludeElementId,
});
if (!earliestElement) {
return 0;
}
if (requestedStartTime <= earliestElement.startTime) {
return 0;
}
return requestedStartTime;
}
@@ -0,0 +1,40 @@
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import type { PlacementTimeSpan } from "./types";
function wouldElementOverlap({
elements,
startTime,
endTime,
excludeElementId,
}: {
elements: TimelineElement[];
startTime: number;
endTime: number;
excludeElementId?: string;
}): boolean {
return elements.some((element) => {
if (excludeElementId && element.id === excludeElementId) {
return false;
}
const elementEnd = element.startTime + element.duration;
return startTime < elementEnd && endTime > element.startTime;
});
}
export function canPlaceTimeSpansOnTrack({
track,
timeSpans,
}: {
track: TimelineTrack;
timeSpans: PlacementTimeSpan[];
}): boolean {
return timeSpans.every(({ startTime, duration, excludeElementId }) => {
return !wouldElementOverlap({
elements: track.elements,
startTime,
endTime: startTime + duration,
excludeElementId,
});
});
}
@@ -0,0 +1,255 @@
import type { TrackType, TimelineTrack } from "@/lib/timeline";
import {
getDefaultInsertIndexForTrack,
getHighestInsertIndexForTrack,
resolvePreferredNewTrackPlacement,
} from "./insert-index";
import { getTrackTypeForElementType } from "./compatibility";
import { enforceMainTrackStart } from "./main-track";
import { canPlaceTimeSpansOnTrack } from "./overlap";
import type {
PlacementResult,
PlacementStrategy,
PlacementSubject,
PlacementTimeSpan,
} from "./types";
type ResolveTrackPlacementParams = PlacementSubject & {
tracks: TimelineTrack[];
timeSpans: PlacementTimeSpan[];
strategy: PlacementStrategy;
};
function buildExistingTrackResult({
track,
trackIndex,
tracks,
timeSpans,
}: {
track: TimelineTrack;
trackIndex: number;
tracks: TimelineTrack[];
timeSpans: PlacementTimeSpan[];
}): PlacementResult {
const firstSpan = timeSpans[0];
const requestedStartTime = firstSpan?.startTime ?? 0;
const adjustedStartTime = enforceMainTrackStart({
tracks,
targetTrackId: track.id,
requestedStartTime,
excludeElementId: firstSpan?.excludeElementId,
});
return {
kind: "existingTrack",
trackId: track.id,
trackIndex,
trackType: track.type,
...(adjustedStartTime !== requestedStartTime
? { adjustedStartTime }
: {}),
};
}
function buildNewTrackResult({
trackType,
insertIndex,
insertPosition,
}: {
trackType: TrackType;
insertIndex: number;
insertPosition: "above" | "below" | null;
}): PlacementResult {
return {
kind: "newTrack",
trackType,
insertIndex,
insertPosition,
};
}
function findFirstAvailableTrackIndex({
tracks,
trackType,
timeSpans,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
timeSpans: PlacementTimeSpan[];
}): number {
return tracks.findIndex((track) => {
return (
track.type === trackType &&
canPlaceTimeSpansOnTrack({
track,
timeSpans,
})
);
});
}
function resolveAlwaysNewTrack({
tracks,
trackType,
position,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
position: "highest" | "default";
}): PlacementResult {
const insertIndex =
position === "highest"
? getHighestInsertIndexForTrack({
tracks,
trackType,
})
: getDefaultInsertIndexForTrack({
tracks,
trackType,
});
return buildNewTrackResult({
trackType,
insertIndex,
insertPosition: null,
});
}
export function resolveTrackPlacement({
tracks,
...placement
}: ResolveTrackPlacementParams): PlacementResult | null {
const trackType =
"trackType" in placement
? placement.trackType
: getTrackTypeForElementType({
elementType: placement.elementType,
});
const { timeSpans, strategy } = placement;
if (strategy.type === "explicit") {
const trackIndex = tracks.findIndex((track) => track.id === strategy.trackId);
if (trackIndex < 0) {
return null;
}
const track = tracks[trackIndex];
if (track.type !== trackType) {
return null;
}
return buildExistingTrackResult({ track, trackIndex, tracks, timeSpans });
}
if (strategy.type === "firstAvailable") {
const existingTrackIndex = findFirstAvailableTrackIndex({
tracks,
trackType,
timeSpans,
});
if (existingTrackIndex >= 0) {
return buildExistingTrackResult({
track: tracks[existingTrackIndex],
trackIndex: existingTrackIndex,
tracks,
timeSpans,
});
}
return resolveAlwaysNewTrack({
tracks,
trackType,
position: "default",
});
}
if (strategy.type === "preferIndex") {
const preferredTrack = tracks[strategy.trackIndex];
const isPreferredTrackCompatible =
!!preferredTrack && preferredTrack.type === trackType;
const canUseExistingTrack =
!strategy.createNewTrackOnly &&
isPreferredTrackCompatible &&
canPlaceTimeSpansOnTrack({
track: preferredTrack,
timeSpans,
});
if (canUseExistingTrack) {
return buildExistingTrackResult({
track: preferredTrack,
trackIndex: strategy.trackIndex,
tracks,
timeSpans,
});
}
const insertDirection =
!isPreferredTrackCompatible && strategy.verticalDragDirection
? strategy.verticalDragDirection
: strategy.hoverDirection;
const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({
tracks,
trackType,
preferredIndex: strategy.trackIndex,
direction: insertDirection,
});
return buildNewTrackResult({
trackType,
insertIndex,
insertPosition,
});
}
if (strategy.type === "aboveSource") {
const aboveTrackIndex = strategy.sourceTrackIndex - 1;
const aboveTrack = tracks[aboveTrackIndex];
if (
aboveTrack &&
aboveTrack.type === trackType &&
canPlaceTimeSpansOnTrack({
track: aboveTrack,
timeSpans,
})
) {
return buildExistingTrackResult({
track: aboveTrack,
trackIndex: aboveTrackIndex,
tracks,
timeSpans,
});
}
const firstAvailableTrackIndex = findFirstAvailableTrackIndex({
tracks,
trackType,
timeSpans,
});
if (firstAvailableTrackIndex >= 0) {
return buildExistingTrackResult({
track: tracks[firstAvailableTrackIndex],
trackIndex: firstAvailableTrackIndex,
tracks,
timeSpans,
});
}
const insertIndex =
strategy.sourceTrackIndex >= 0
? strategy.sourceTrackIndex
: getHighestInsertIndexForTrack({
tracks,
trackType,
});
return buildNewTrackResult({
trackType,
insertIndex,
insertPosition: null,
});
}
return resolveAlwaysNewTrack({
tracks,
trackType,
position: strategy.position,
});
}
@@ -0,0 +1,61 @@
import { TRACK_CONFIG } from "@/constants/timeline-constants";
import type { TrackType, TimelineTrack } from "@/lib/timeline";
export function buildEmptyTrack({
id,
type,
name,
}: {
id: string;
type: TrackType;
name?: string;
}): TimelineTrack {
const trackName = name ?? TRACK_CONFIG[type].defaultName;
switch (type) {
case "video":
return {
id,
name: trackName,
type: "video",
elements: [],
hidden: false,
muted: false,
isMain: false,
};
case "text":
return {
id,
name: trackName,
type: "text",
elements: [],
hidden: false,
};
case "graphic":
return {
id,
name: trackName,
type: "graphic",
elements: [],
hidden: false,
};
case "audio":
return {
id,
name: trackName,
type: "audio",
elements: [],
muted: false,
};
case "effect":
return {
id,
name: trackName,
type: "effect",
elements: [],
hidden: false,
};
default:
throw new Error(`Unsupported track type: ${type}`);
}
}
@@ -0,0 +1,39 @@
import type { ElementType, TrackType } from "@/lib/timeline";
export interface PlacementTimeSpan {
startTime: number;
duration: number;
excludeElementId?: string;
}
export type PlacementSubject =
| { elementType: ElementType }
| { trackType: TrackType };
export type PlacementStrategy =
| { type: "explicit"; trackId: string }
| { type: "firstAvailable" }
| {
type: "preferIndex";
trackIndex: number;
hoverDirection: "above" | "below";
verticalDragDirection?: "up" | "down" | null;
createNewTrackOnly?: boolean;
}
| { type: "aboveSource"; sourceTrackIndex: number }
| { type: "alwaysNew"; position: "highest" | "default" };
export type PlacementResult =
| {
kind: "existingTrack";
trackId: string;
trackIndex: number;
trackType: TrackType;
adjustedStartTime?: number;
}
| {
kind: "newTrack";
insertIndex: number;
insertPosition: "above" | "below" | null;
trackType: TrackType;
};
-235
View File
@@ -1,21 +1,17 @@
import type {
TrackType,
TimelineTrack,
ElementType,
VideoTrack,
AudioTrack,
GraphicTrack,
TextTrack,
EffectTrack,
TimelineElement,
} from "@/lib/timeline";
import {
ELEMENT_TRACK_MAP,
TRACK_CONFIG,
ELEMENT_TYPE_CONFIG,
TRACK_GAP,
} from "@/constants/timeline-constants";
import { generateUUID } from "@/utils/id";
export function canTracktHaveAudio(
track: TimelineTrack,
@@ -64,234 +60,3 @@ export function getTotalTracksHeight({
const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
return tracksHeight + gapsHeight;
}
export function buildEmptyTrack({
id,
type,
name,
}: {
id: string;
type: TrackType;
name?: string;
}): TimelineTrack {
const trackName = name ?? TRACK_CONFIG[type].defaultName;
switch (type) {
case "video":
return {
id,
name: trackName,
type: "video",
elements: [],
hidden: false,
muted: false,
isMain: false,
};
case "text":
return {
id,
name: trackName,
type: "text",
elements: [],
hidden: false,
};
case "graphic":
return {
id,
name: trackName,
type: "graphic",
elements: [],
hidden: false,
};
case "audio":
return {
id,
name: trackName,
type: "audio",
elements: [],
muted: false,
};
case "effect":
return {
id,
name: trackName,
type: "effect",
elements: [],
hidden: false,
};
default:
throw new Error(`Unsupported track type: ${type}`);
}
}
export function getDefaultInsertIndexForTrack({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
if (trackType === "audio") {
return tracks.length;
}
if (trackType === "effect") {
return 0;
}
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (mainTrackIndex >= 0) {
return mainTrackIndex;
}
const firstAudioTrackIndex = tracks.findIndex(
(track) => track.type === "audio",
);
if (firstAudioTrackIndex >= 0) {
return firstAudioTrackIndex;
}
return tracks.length;
}
export function getHighestInsertIndexForTrack({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (trackType === "audio") {
return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
}
return 0;
}
export function isMainTrack(track: TimelineTrack): track is VideoTrack {
return track.type === "video" && track.isMain === true;
}
export function getMainTrack({
tracks,
}: {
tracks: TimelineTrack[];
}): TimelineTrack | null {
return tracks.find((track) => isMainTrack(track)) ?? null;
}
export function ensureMainTrack({
tracks,
}: {
tracks: TimelineTrack[];
}): TimelineTrack[] {
const hasMainTrack = tracks.some((track) => isMainTrack(track));
if (!hasMainTrack) {
const mainTrack: TimelineTrack = {
id: generateUUID(),
name: "Main Track",
type: "video",
elements: [],
muted: false,
isMain: true,
hidden: false,
};
return [mainTrack, ...tracks];
}
return tracks;
}
export function canElementGoOnTrack({
elementType,
trackType,
}: {
elementType: ElementType;
trackType: TrackType;
}): boolean {
return ELEMENT_TRACK_MAP[elementType] === trackType;
}
export function validateElementTrackCompatibility({
element,
track,
}: {
element: { type: ElementType };
track: { type: TrackType };
}): { isValid: boolean; errorMessage?: string } {
const isValid = canElementGoOnTrack({
elementType: element.type,
trackType: track.type,
});
if (!isValid) {
return {
isValid: false,
errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
};
}
return { isValid: true };
}
export function getEarliestMainTrackElement({
tracks,
excludeElementId,
}: {
tracks: TimelineTrack[];
excludeElementId?: string;
}): TimelineElement | null {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack) {
return null;
}
const elements = mainTrack.elements.filter(
(element) => !excludeElementId || element.id !== excludeElementId,
);
if (elements.length === 0) {
return null;
}
return elements.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest,
);
}
export function enforceMainTrackStart({
tracks,
targetTrackId,
requestedStartTime,
excludeElementId,
}: {
tracks: TimelineTrack[];
targetTrackId: string;
requestedStartTime: number;
excludeElementId?: string;
}): number {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack || mainTrack.id !== targetTrackId) {
return requestedStartTime;
}
const earliestElement = getEarliestMainTrackElement({
tracks,
excludeElementId,
});
if (!earliestElement) {
return 0;
}
// main track must always start at time 0; if this element would
// become the earliest, pin it to the start
if (requestedStartTime <= earliestElement.startTime) {
return 0;
}
return requestedStartTime;
}
-18
View File
@@ -1,18 +0,0 @@
import { NextResponse } from "next/server";
export async function proxy() {
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};
@@ -12,7 +12,7 @@ import { EffectLayerNode } from "./nodes/effect-layer-node";
import type { BaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/lib/project/types";
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
import { isMainTrack } from "@/lib/timeline";
import { isMainTrack } from "@/lib/track-placement";
const PREVIEW_MAX_IMAGE_SIZE = 2048;
+3 -1
View File
@@ -1,3 +1,5 @@
import { formatNumberForDisplay } from "@/utils/math";
const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
export const STORAGE_HEADROOM_RESERVE_BYTES = 50 * 1024 * 1024;
@@ -37,7 +39,7 @@ export function formatStorageBytes({ bytes }: { bytes: number }): string {
}
const precision = value >= 10 || unitIndex === 0 ? 0 : 1;
return `${value.toFixed(precision)} ${BYTE_UNITS[unitIndex]}`;
return `${formatNumberForDisplay({ value, fractionDigits: precision })} ${BYTE_UNITS[unitIndex]}`;
}
export async function readStorageQuotaStatus(): Promise<StorageQuotaStatus> {
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from "bun:test";
import { evaluateMathExpression } from "../math";
describe("evaluateMathExpression", () => {
describe("basic arithmetic", () => {
it("should add numbers", () => {
expect(evaluateMathExpression({ input: "1+2" })).toBe(3);
expect(evaluateMathExpression({ input: "10 + 20" })).toBe(30);
});
it("should subtract numbers", () => {
expect(evaluateMathExpression({ input: "5-3" })).toBe(2);
expect(evaluateMathExpression({ input: "100 - 50" })).toBe(50);
});
it("should multiply numbers", () => {
expect(evaluateMathExpression({ input: "4*5" })).toBe(20);
expect(evaluateMathExpression({ input: "10 * 10" })).toBe(100);
});
it("should divide numbers", () => {
expect(evaluateMathExpression({ input: "20/4" })).toBe(5);
expect(evaluateMathExpression({ input: "100 / 10" })).toBe(10);
});
});
describe("operator precedence", () => {
it("should respect multiplication over addition", () => {
expect(evaluateMathExpression({ input: "2+3*4" })).toBe(14);
expect(evaluateMathExpression({ input: "10+5*2" })).toBe(20);
});
it("should respect division over subtraction", () => {
expect(evaluateMathExpression({ input: "10-6/2" })).toBe(7);
});
});
describe("parentheses", () => {
it("should evaluate expressions in parentheses first", () => {
expect(evaluateMathExpression({ input: "(2+3)*4" })).toBe(20);
expect(evaluateMathExpression({ input: "(10-5)*2" })).toBe(10);
});
it("should handle nested parentheses", () => {
expect(evaluateMathExpression({ input: "((2+3)*4)" })).toBe(20);
expect(evaluateMathExpression({ input: "(1+(2*3))" })).toBe(7);
});
});
describe("decimal numbers", () => {
it("should handle decimal numbers", () => {
expect(evaluateMathExpression({ input: "3.5+2.5" })).toBe(6);
expect(evaluateMathExpression({ input: "10.5 * 2" })).toBe(21);
});
});
describe("negative numbers", () => {
it("should handle negative numbers", () => {
expect(evaluateMathExpression({ input: "-5+3" })).toBe(-2);
expect(evaluateMathExpression({ input: "10+-5" })).toBe(5);
});
});
describe("whitespace handling", () => {
it("should handle various whitespace", () => {
expect(evaluateMathExpression({ input: " 1 + 2 " })).toBe(3);
expect(evaluateMathExpression({ input: "10* 5" })).toBe(50);
});
});
describe("edge cases", () => {
it("should handle single number", () => {
expect(evaluateMathExpression({ input: "42" })).toBe(42);
expect(evaluateMathExpression({ input: "3.14" })).toBe(3.14);
});
it("should return null for empty string", () => {
expect(evaluateMathExpression({ input: "" })).toBeNull();
});
it("should return null for invalid characters", () => {
expect(evaluateMathExpression({ input: "1+2a" })).toBeNull();
expect(evaluateMathExpression({ input: "alert(1)" })).toBeNull();
expect(evaluateMathExpression({ input: "1+2; doSomething()" })).toBeNull();
});
it("should return null for unbalanced parentheses", () => {
expect(evaluateMathExpression({ input: "(1+2" })).toBeNull();
expect(evaluateMathExpression({ input: "1+2)" })).toBeNull();
});
it("should return null for division by zero", () => {
expect(evaluateMathExpression({ input: "1/0" })).toBeNull();
});
it("should return null for incomplete expressions", () => {
expect(evaluateMathExpression({ input: "1+" })).toBeNull();
expect(evaluateMathExpression({ input: "*2" })).toBeNull();
});
});
describe("security - code injection prevention", () => {
it("should reject code injection attempts", () => {
// These would be dangerous with new Function() but are safe here
expect(evaluateMathExpression({ input: "alert('xss')" })).toBeNull();
expect(evaluateMathExpression({ input: "process.exit()" })).toBeNull();
expect(evaluateMathExpression({ input: "require('fs')" })).toBeNull();
expect(evaluateMathExpression({ input: "eval('1+1')" })).toBeNull();
expect(evaluateMathExpression({ input: "Function('return 1')()" })).toBeNull();
expect(evaluateMathExpression({ input: "constructor.constructor('return 1')()" })).toBeNull();
expect(evaluateMathExpression({ input: "this.toString" })).toBeNull();
expect(evaluateMathExpression({ input: "__proto__" })).toBeNull();
});
it("should reject prototype pollution attempts", () => {
expect(evaluateMathExpression({ input: "[].constructor" })).toBeNull();
expect(evaluateMathExpression({ input: "({}).constructor" })).toBeNull();
});
});
});
+276 -84
View File
@@ -1,84 +1,276 @@
export function clamp({
value,
min,
max,
}: {
value: number;
min: number;
max: number;
}): number {
return Math.max(min, Math.min(max, value));
}
export function clampRound({
value,
min,
max,
}: {
value: number;
min: number;
max: number;
}): number {
return Math.round(clamp({ value, min, max }));
}
function getFractionDigitsForStep({ step }: { step: number }): number {
const normalizedStep = step.toString().toLowerCase();
if (normalizedStep.includes("e-")) {
return Number(normalizedStep.split("e-")[1] ?? 0);
}
const [, fractionalPart = ""] = normalizedStep.split(".");
return fractionalPart.length;
}
export function snapToStep({
value,
step,
}: {
value: number;
step: number;
}): number {
if (step <= 0) return value;
const snappedValue = Math.round(value / step) * step;
return Number(
snappedValue.toFixed(getFractionDigitsForStep({ step })),
);
}
export function isNearlyEqual({
leftValue,
rightValue,
epsilon = 0.0001,
}: {
leftValue: number;
rightValue: number;
epsilon?: number;
}): boolean {
return Math.abs(leftValue - rightValue) <= epsilon;
}
export function formatNumberForDisplay({
value,
maxFractionDigits = 6,
}: {
value: number;
maxFractionDigits?: number;
}): string {
return Number(value.toFixed(maxFractionDigits)).toString();
}
export function evaluateMathExpression({
input,
}: {
input: string;
}): number | null {
const sanitized = input.trim();
if (!/^[\d.\s+\-*/()]+$/.test(sanitized)) return null;
try {
const result = new Function(`return (${sanitized})`)();
if (typeof result !== "number" || !Number.isFinite(result)) return null;
return result;
} catch {
return null;
}
}
export function clamp({
value,
min,
max,
}: {
value: number;
min: number;
max: number;
}): number {
return Math.max(min, Math.min(max, value));
}
export function clampRound({
value,
min,
max,
}: {
value: number;
min: number;
max: number;
}): number {
return Math.round(clamp({ value, min, max }));
}
export function getFractionDigitsForStep({ step }: { step: number }): number {
const normalizedStep = step.toString().toLowerCase();
if (normalizedStep.includes("e-")) {
return Number(normalizedStep.split("e-")[1] ?? 0);
}
const [, fractionalPart = ""] = normalizedStep.split(".");
return fractionalPart.length;
}
export function snapToStep({
value,
step,
}: {
value: number;
step: number;
}): number {
if (step <= 0) return value;
const snappedValue = Math.round(value / step) * step;
return Number(
snappedValue.toFixed(getFractionDigitsForStep({ step })),
);
}
export function isNearlyEqual({
leftValue,
rightValue,
epsilon = 0.0001,
}: {
leftValue: number;
rightValue: number;
epsilon?: number;
}): boolean {
return Math.abs(leftValue - rightValue) <= epsilon;
}
export function formatNumberForDisplay({
value,
fractionDigits,
minFractionDigits = 0,
maxFractionDigits = 6,
}: {
value: number;
fractionDigits?: number;
minFractionDigits?: number;
maxFractionDigits?: number;
}): string {
const resolvedMaxFractionDigits = Math.max(
0,
fractionDigits ?? maxFractionDigits,
);
const resolvedMinFractionDigits = Math.min(
Math.max(0, fractionDigits ?? minFractionDigits),
resolvedMaxFractionDigits,
);
const fixedValue = value.toFixed(resolvedMaxFractionDigits);
if (resolvedMaxFractionDigits === 0) {
return Number(fixedValue) === 0 ? "0" : fixedValue;
}
const [integerPart, fractionPart = ""] = fixedValue.split(".");
const normalizedIntegerPart = Number(fixedValue) === 0 ? "0" : integerPart;
let trimmedFractionPart = fractionPart;
while (
trimmedFractionPart.length > resolvedMinFractionDigits &&
trimmedFractionPart.endsWith("0")
) {
trimmedFractionPart = trimmedFractionPart.slice(0, -1);
}
return trimmedFractionPart
? `${normalizedIntegerPart}.${trimmedFractionPart}`
: normalizedIntegerPart;
}
/**
* Safely evaluates a mathematical expression without using eval() or new Function().
* Supports +, -, *, /, parentheses, and decimal numbers.
*/
export function evaluateMathExpression({
input,
}: {
input: string;
}): number | null {
const sanitized = input.trim();
if (!/^[\d.\s+\-*/()]+$/.test(sanitized)) return null;
try {
return parseExpression(sanitized);
} catch {
return null;
}
}
/**
* Recursive descent parser for basic arithmetic expressions.
* Grammar:
* expression = term { ('+' | '-') term }
* term = factor { ('*' | '/') factor }
* factor = number | '(' expression ')'
*/
function parseExpression(input: string): number | null {
const tokens = tokenize(input);
if (tokens.length === 0) return null;
let index = 0;
function peek(): Token | null {
return tokens[index] ?? null;
}
function consume(): Token | null {
return tokens[index++] ?? null;
}
function parseExpressionLevel(): number {
let left = parseTerm();
while (true) {
const token = peek();
if (token?.type === "PLUS") {
consume();
left = left + parseTerm();
} else if (token?.type === "MINUS") {
consume();
left = left - parseTerm();
} else {
break;
}
}
return left;
}
function parseTerm(): number {
let left = parseFactor();
while (true) {
const token = peek();
if (token?.type === "MULTIPLY") {
consume();
left = left * parseFactor();
} else if (token?.type === "DIVIDE") {
consume();
const right = parseFactor();
if (right === 0) throw new Error("Division by zero");
left = left / right;
} else {
break;
}
}
return left;
}
function parseFactor(): number {
const token = peek();
if (!token) throw new Error("Unexpected end of expression");
if (token.type === "NUMBER") {
consume();
return token.value;
}
if (token.type === "MINUS") {
consume();
return -parseFactor();
}
if (token.type === "LPAREN") {
consume();
const value = parseExpressionLevel();
const next = peek();
if (next?.type !== "RPAREN") {
throw new Error("Missing closing parenthesis");
}
consume();
return value;
}
throw new Error(`Unexpected token: ${token.type}`);
}
const result = parseExpressionLevel();
if (index !== tokens.length) {
return null;
}
if (!Number.isFinite(result)) return null;
return result;
}
type Token =
| { type: "NUMBER"; value: number }
| { type: "PLUS" }
| { type: "MINUS" }
| { type: "MULTIPLY" }
| { type: "DIVIDE" }
| { type: "LPAREN" }
| { type: "RPAREN" };
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let i = 0;
const str = input.trim();
while (i < str.length) {
const char = str[i];
if (/\s/.test(char)) {
i++;
continue;
}
if (/\d/.test(char)) {
let numStr = "";
while (i < str.length && (/\d/.test(str[i]) || str[i] === ".")) {
numStr += str[i];
i++;
}
const value = Number(numStr);
if (!Number.isFinite(value)) {
throw new Error(`Invalid number: ${numStr}`);
}
tokens.push({ type: "NUMBER", value });
continue;
}
switch (char) {
case "+":
tokens.push({ type: "PLUS" });
break;
case "-":
tokens.push({ type: "MINUS" });
break;
case "*":
tokens.push({ type: "MULTIPLY" });
break;
case "/":
tokens.push({ type: "DIVIDE" });
break;
case "(":
tokens.push({ type: "LPAREN" });
break;
case ")":
tokens.push({ type: "RPAREN" });
break;
default:
throw new Error(`Invalid character: ${char}`);
}
i++;
}
return tokens;
}
File diff suppressed because one or more lines are too long
+8 -42
View File
@@ -24,9 +24,7 @@
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@opennextjs/cloudflare": "^1.17.3",
"@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
@@ -79,7 +77,7 @@
"unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
"wavesurfer.js": "^7.9.8",
"zod": "^3.25.67",
"zod": "4.3.6",
"zustand": "^5.0.2",
},
"devDependencies": {
@@ -104,32 +102,6 @@
"wrangler": "^4.77.0",
},
},
"packages/env": {
"name": "@opencut/env",
"version": "0.0.0",
"dependencies": {
"zod": "^4.0.5",
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^24.2.1",
"dotenv": "^16.4.7",
"typescript": "^5.8.3",
},
},
"packages/ui": {
"name": "@opencut/ui",
"version": "0.0.0",
"dependencies": {
"@iconify/react": "^6.0.2",
"@types/react": "^19.2.7",
"react": "^19.2.0",
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.8.3",
},
},
},
"trustedDependencies": [
"@tailwindcss/oxide",
@@ -385,10 +357,6 @@
"@huggingface/transformers": ["@huggingface/transformers@3.8.1", "", { "dependencies": { "@huggingface/jinja": "^0.5.3", "onnxruntime-node": "1.21.0", "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", "sharp": "^0.34.1" } }, "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA=="],
"@iconify/react": ["@iconify/react@6.0.2", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
@@ -509,15 +477,11 @@
"@node-minify/utils": ["@node-minify/utils@8.0.6", "", { "dependencies": { "gzip-size": "6.0.0" } }, "sha512-csY4qcR7jUwiZmkreNTJhcypQfts2aY2CK+a+rXgXUImZiZiySh0FvwHjRnlqWKvg+y6ae9lHFzDRjBTmqlTIQ=="],
"@opencut/env": ["@opencut/env@workspace:packages/env"],
"@opencut/ui": ["@opencut/ui@workspace:packages/ui"],
"@opencut/web": ["@opencut/web@workspace:apps/web"],
"@opennextjs/aws": ["@opennextjs/aws@3.9.16", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5" }, "bin": { "open-next": "dist/index.js" } }, "sha512-jQQStCysIllNCPqz5W2KSguXpr+ETlOcD8SyNu+h9zwpRVYk4uEPQge+ErG3avI5xsT8vKA7EGLYG59dhj/B6Q=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.17.3", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.9.16", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5", "wrangler": "^4.65.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-BQT8R/CEqrZS2sYydJ6uqv3U+yZAxbTeDUfgqQIlOnhu3wDPsB/QuuUBBseWOZiOdZ8gLegK6F2SLKia74Nv6g=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.18.0", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.9.16", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5", "wrangler": "^4.65.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-JM236YHnKzroFAZqst1t28ZGOShvnkVUDtjrp7TJ/W2P3RLo4b6npJ8VEXOn6frs6lsUfR5rvsKYLYb7h1GIJQ=="],
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
@@ -1835,7 +1799,7 @@
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zustand": ["zustand@5.0.6", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A=="],
@@ -1851,6 +1815,8 @@
"@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
"@better-auth/core/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
@@ -1871,8 +1837,6 @@
"@node-minify/terser/terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
"@opencut/web/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
@@ -1989,6 +1953,8 @@
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"better-auth/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"body-parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"browserslist/caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
-21
View File
@@ -1,21 +0,0 @@
{
"name": "@opencut/env",
"version": "0.0.0",
"description": "Environment package for OpenCut",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./web": "./src/web.ts",
"./tools": "./src/tools.ts"
},
"dependencies": {
"zod": "^4.0.5"
},
"devDependencies": {
"dotenv": "^16.4.7",
"@types/bun": "latest",
"@types/node": "^24.2.1",
"typescript": "^5.8.3"
}
}
-19
View File
@@ -1,19 +0,0 @@
{
"name": "@opencut/ui",
"version": "0.0.0",
"description": "UI package for OpenCut",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
"./icons": "./src/icons/index.tsx"
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@types/react": "^19.2.7",
"react": "^19.2.0"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.8.3"
}
}
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
+35
View File
@@ -0,0 +1,35 @@
# rust/
Shared Rust crates that power OpenCut across platforms (web via WASM, desktop natively).
## Adding a new crate
1. Create it under `rust/crates/`
2. Add `bridge` as a dependency
3. Annotate public functions with `#[export]`
## How `#[export]` works
```rust
use bridge::export;
#[export]
pub fn round_to_frame(time: f64, fps: f64) -> f64 {
(time * fps).round() / fps
}
```
Without the `wasm` feature, the macro is a no-op. With `--features wasm`, it expands to:
```rust
#[wasm_bindgen(js_name = "roundToFrame")]
pub fn round_to_frame(time: f64, fps: f64) -> f64 { ... }
```
Desktop uses the crates directly as Cargo dependencies.
## Testing
```bash
cargo test -p <crate>
```
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "bridge"
version = "0.1.0"
edition = "2024"
[lib]
path = "src/bridge.rs"
proc-macro = true
[dependencies]
quote = "1.0.45"
syn = { version = "2.0.117", features = ["full"] }
+49
View File
@@ -0,0 +1,49 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{ItemFn, parse_macro_input};
#[proc_macro_attribute]
pub fn export(_attr: TokenStream, item: TokenStream) -> TokenStream {
let function = parse_macro_input!(item as ItemFn);
let js_name = snake_to_camel(&function.sig.ident.to_string());
quote! {
#[cfg_attr(feature = "wasm", ::wasm_bindgen::prelude::wasm_bindgen(js_name = #js_name))]
#function
}
.into()
}
fn snake_to_camel(name: &str) -> String {
let mut camel = String::with_capacity(name.len());
let mut should_uppercase_next = false;
for character in name.chars() {
if character == '_' {
should_uppercase_next = true;
continue;
}
if should_uppercase_next {
camel.push(character.to_ascii_uppercase());
should_uppercase_next = false;
continue;
}
camel.push(character);
}
camel
}
#[cfg(test)]
mod tests {
use super::snake_to_camel;
#[test]
fn converts_snake_case_to_camel_case() {
assert_eq!(snake_to_camel("do_something"), "doSomething");
assert_eq!(snake_to_camel("a_b_c"), "aBC");
assert_eq!(snake_to_camel("already"), "already");
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "time"
version = "0.1.0"
edition = "2024"
[lib]
path = "src/time.rs"
crate-type = ["rlib", "cdylib"]
[dependencies]
bridge = { version = "0.1.0", path = "../bridge" }
wasm-bindgen = { version = "0.2.115", optional = true }
[features]
wasm = ["dep:wasm-bindgen"]
+274
View File
@@ -0,0 +1,274 @@
use bridge::export;
const DEFAULT_TIME_CODE_FORMAT: &str = "HH:MM:SS:CS";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TimeCodeFormat {
MmSs,
HhMmSs,
HhMmSsCs,
HhMmSsFf,
}
impl TimeCodeFormat {
fn parse(format: Option<&str>) -> Option<Self> {
match format.unwrap_or(DEFAULT_TIME_CODE_FORMAT) {
"MM:SS" => Some(Self::MmSs),
"HH:MM:SS" => Some(Self::HhMmSs),
"HH:MM:SS:CS" => Some(Self::HhMmSsCs),
"HH:MM:SS:FF" => Some(Self::HhMmSsFf),
_ => None,
}
}
fn as_str(self) -> &'static str {
match self {
Self::MmSs => "MM:SS",
Self::HhMmSs => "HH:MM:SS",
Self::HhMmSsCs => "HH:MM:SS:CS",
Self::HhMmSsFf => "HH:MM:SS:FF",
}
}
}
#[export]
pub fn round_to_frame(time: f64, fps: f64) -> f64 {
(time * fps).round() / fps
}
#[export]
pub fn format_time_code(
time_in_seconds: f64,
format: Option<String>,
fps: Option<f64>,
) -> Option<String> {
let format = TimeCodeFormat::parse(format.as_deref())?;
let hours = (time_in_seconds / 3600.0).floor() as u64;
let minutes = ((time_in_seconds % 3600.0) / 60.0).floor() as u64;
let seconds = (time_in_seconds % 60.0).floor() as u64;
let centiseconds = ((time_in_seconds % 1.0) * 100.0).floor() as u64;
match format {
TimeCodeFormat::MmSs => Some(format!("{minutes:02}:{seconds:02}")),
TimeCodeFormat::HhMmSs => Some(format!("{hours:02}:{minutes:02}:{seconds:02}")),
TimeCodeFormat::HhMmSsCs => Some(format!(
"{hours:02}:{minutes:02}:{seconds:02}:{centiseconds:02}",
)),
TimeCodeFormat::HhMmSsFf => {
let fps = fps?;
if fps <= 0.0 {
return None;
}
let frames = ((time_in_seconds % 1.0) * fps).floor() as u64;
Some(format!(
"{hours:02}:{minutes:02}:{seconds:02}:{frames:02}",
))
}
}
}
#[export]
pub fn parse_time_code(time_code: &str, format: Option<String>, fps: Option<f64>) -> Option<f64> {
if time_code.trim().is_empty() {
return None;
}
let format = TimeCodeFormat::parse(format.as_deref())?;
let parts = time_code
.trim()
.split(':')
.map(|part| part.parse::<u32>().ok())
.collect::<Option<Vec<_>>>()?;
match format {
TimeCodeFormat::MmSs => {
let [minutes, seconds] = parts.as_slice() else {
return None;
};
if *seconds >= 60 {
return None;
}
Some((*minutes as f64 * 60.0) + *seconds as f64)
}
TimeCodeFormat::HhMmSs => {
let [hours, minutes, seconds] = parts.as_slice() else {
return None;
};
if *minutes >= 60 || *seconds >= 60 {
return None;
}
Some((*hours as f64 * 3600.0) + (*minutes as f64 * 60.0) + *seconds as f64)
}
TimeCodeFormat::HhMmSsCs => {
let [hours, minutes, seconds, centiseconds] = parts.as_slice() else {
return None;
};
if *minutes >= 60 || *seconds >= 60 || *centiseconds >= 100 {
return None;
}
Some(
(*hours as f64 * 3600.0)
+ (*minutes as f64 * 60.0)
+ *seconds as f64
+ (*centiseconds as f64 / 100.0),
)
}
TimeCodeFormat::HhMmSsFf => {
let fps = fps?;
if fps <= 0.0 {
return None;
}
let [hours, minutes, seconds, frames] = parts.as_slice() else {
return None;
};
if *minutes >= 60 || *seconds >= 60 || *frames as f64 >= fps {
return None;
}
Some(
(*hours as f64 * 3600.0)
+ (*minutes as f64 * 60.0)
+ *seconds as f64
+ (*frames as f64 / fps),
)
}
}
}
#[export]
pub fn guess_time_code_format(time_code: &str) -> Option<String> {
if time_code.trim().is_empty() {
return None;
}
let part_count = time_code
.split(':')
.try_fold(0usize, |count, part| {
part.parse::<u32>().ok().map(|_| count + 1)
})?;
match part_count {
2 => Some(TimeCodeFormat::MmSs.as_str().to_string()),
3 => Some(TimeCodeFormat::HhMmSs.as_str().to_string()),
4 => Some(TimeCodeFormat::HhMmSsFf.as_str().to_string()),
_ => None,
}
}
#[export]
pub fn time_to_frame(time: f64, fps: f64) -> f64 {
(time * fps).round()
}
#[export]
pub fn frame_to_time(frame: f64, fps: f64) -> f64 {
frame / fps
}
#[export]
pub fn snap_time_to_frame(time: f64, fps: f64) -> f64 {
if fps <= 0.0 {
return time;
}
frame_to_time(time_to_frame(time, fps), fps)
}
#[export]
pub fn get_snapped_seek_time(raw_time: f64, duration: f64, fps: f64) -> f64 {
let snapped_time = snap_time_to_frame(raw_time, fps);
let last_frame = get_last_frame_time(duration, fps);
snapped_time.clamp(0.0, last_frame)
}
#[export]
pub fn get_last_frame_time(duration: f64, fps: f64) -> f64 {
if duration <= 0.0 {
return 0.0;
}
if fps <= 0.0 {
return duration;
}
let frame_offset = 1.0 / fps;
(duration - frame_offset).max(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rounds_to_the_nearest_frame() {
assert_eq!(round_to_frame(1.24, 10.0), 1.2);
assert_eq!(round_to_frame(1.26, 10.0), 1.3);
}
#[test]
fn formats_default_time_codes() {
assert_eq!(
format_time_code(3723.45, None, None),
Some("01:02:03:44".to_string()),
);
assert_eq!(
format_time_code(65.0, Some("MM:SS".to_string()), None),
Some("01:05".to_string()),
);
}
#[test]
fn formats_frame_based_time_codes() {
assert_eq!(
format_time_code(1.5, Some("HH:MM:SS:FF".to_string()), Some(30.0)),
Some("00:00:01:15".to_string()),
);
assert_eq!(
format_time_code(1.5, Some("HH:MM:SS:FF".to_string()), None),
None,
);
}
#[test]
fn parses_time_codes() {
assert_eq!(
parse_time_code("01:05", Some("MM:SS".to_string()), None),
Some(65.0),
);
assert_eq!(
parse_time_code("00:00:01:15", Some("HH:MM:SS:FF".to_string()), Some(30.0)),
Some(1.5),
);
assert_eq!(
parse_time_code("00:00:01:30", Some("HH:MM:SS:FF".to_string()), Some(30.0)),
None,
);
}
#[test]
fn guesses_time_code_formats() {
assert_eq!(guess_time_code_format("01:05"), Some("MM:SS".to_string()));
assert_eq!(
guess_time_code_format("00:00:01"),
Some("HH:MM:SS".to_string()),
);
assert_eq!(
guess_time_code_format("00:00:01:15"),
Some("HH:MM:SS:FF".to_string()),
);
}
#[test]
fn snaps_and_clamps_seek_time() {
assert_eq!(time_to_frame(1.26, 10.0), 13.0);
assert_eq!(frame_to_time(13.0, 10.0), 1.3);
assert_eq!(snap_time_to_frame(1.26, 10.0), 1.3);
assert_eq!(get_last_frame_time(10.0, 5.0), 9.8);
assert_eq!(get_snapped_seek_time(10.0, 10.0, 5.0), 9.8);
}
}