Compare commits

..
Author SHA1 Message Date
Maze Winther c2e2668701 docs: rewrite agents file 2026-03-29 12:53:37 +02:00
Maze Winther b949fda7bc refactor: clean up number formatting and step handling in property tabs 2026-03-27 21:24:59 +01:00
Maze Winther 8e936a7594 chore: remove dead songs tab 2026-03-27 21:23:38 +01:00
Maze Winther 96f87b050d chore: add missing open-next config 2026-03-27 21:22:46 +01:00
Maze Winther 4b9d5f8d23 chore: update dep 2026-03-27 21:21:31 +01:00
Maze Winther 6b81afc20b chore: rename proxy to middleware 2026-03-27 21:20:46 +01:00
Maze Winther 8918a87c33 feat: init desktop app 2026-03-27 21:15:00 +01:00
Maze Winther f635403108 fix: add missing file 2026-03-27 01:21:35 +01:00
Maze Winther fc0ba2d37f chore: normalize line endings 2026-03-27 00:55:15 +01:00
Maze Winther 1660f1c76c chore: next-env 2026-03-26 14:15:47 +01:00
Maze Winther b89f016069 chore: update botid 2026-03-26 14:13:28 +01:00
Maze Winther 271cc27cbf chore: init cloudflare 2026-03-26 13:54:36 +01:00
Maze Winther b62833384d fix: resolve typescript/lint errors 2026-03-26 13:52:30 +01:00
Maze Winther 08f5b92c3d feat: masks, properties refactor, shaders, storage migrations, and more 2026-03-25 16:47:22 +01:00
Maze Winther 4aca4e7756 Merge branch 'main' into dev 2026-03-25 16:24:22 +01:00
Maze Winther b7e6db90b0 chore: gitignore 2026-03-25 16:20:52 +01:00
Maze Winther fe623357ef untrack .cursor/ 2026-03-25 16:16:42 +01:00
Maze Winther 5b3c048e20 changelog: add already-completed item 2026-03-04 21:52:45 +01:00
Maze Winther 5638974f4b fix: captions view ui + use BatchCommand to add text elements in transcription at once 2026-03-04 21:52:22 +01:00
Maze Winther a2580216f8 fix: assets having unreasonable widths and heights 2026-03-04 18:27:02 +01:00
Maze Winther dd4e6780d8 changelog: new version with already-made changes 2026-03-04 17:26:54 +01:00
83 changed files with 5075 additions and 6347 deletions
-2
View File
@@ -2,6 +2,4 @@
resolver = "2" resolver = "2"
members = [ members = [
"apps/desktop", "apps/desktop",
"rust/crates/time"
"rust/crates/bridge"
] ]
+129 -129
View File
@@ -1,129 +1,129 @@
<table width="100%"> <table width="100%">
<tr> <tr>
<td align="left" width="120"> <td align="left" width="120">
<img src="apps/web/public/logos/opencut/icon.svg" alt="OpenCut Logo" width="100" /> <img src="apps/web/public/logos/opencut/1k/logo-white-black.png" alt="OpenCut Logo" width="100" />
</td> </td>
<td align="right"> <td align="right">
<h1>OpenCut</h1> <h1>OpenCut</span></h1>
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3> <h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
</td> </td>
</tr> </tr>
</table> </table>
## Sponsors ## 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. 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"> <a href="https://vercel.com/oss">
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" /> <img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a> </a>
<a href="https://fal.ai"> <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=" /> <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> </a>
## Why? ## Why?
- **Privacy**: Your videos stay on your device - **Privacy**: Your videos stay on your device
- **Free features**: Most basic CapCut features are now paywalled - **Free features**: Most basic CapCut features are now paywalled
- **Simple**: People want editors that are easy to use - CapCut proved that - **Simple**: People want editors that are easy to use - CapCut proved that
## Features ## Features
- Timeline-based editing - Timeline-based editing
- Multi-track support - Multi-track support
- Real-time preview - Real-time preview
- No watermarks or subscriptions - No watermarks or subscriptions
- Analytics provided by [Databuddy](https://www.databuddy.cc?utm_source=opencut), 100% Anonymized & Non-invasive. - 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. - Blog powered by [Marble](https://marblecms.com?utm_source=opencut), Headless CMS.
## Project Structure ## Project Structure
- `apps/web/` – Main Next.js web application - `apps/web/` – Main Next.js web application
- `apps/desktop/` – Native desktop app (in progress) - `apps/desktop/` – Native desktop app (in progress)
- `src/components/` – UI and editor components - `src/components/` – UI and editor components
- `src/hooks/` – Custom React hooks - `src/hooks/` – Custom React hooks
- `src/lib/` – Utility and API logic - `src/lib/` – Utility and API logic
- `src/stores/` – State management (Zustand, etc.) - `src/stores/` – State management (Zustand, etc.)
- `src/types/` – TypeScript types - `src/types/` – TypeScript types
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
- [Bun](https://bun.sh/docs/installation) - [Bun](https://bun.sh/docs/installation)
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) - [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. > **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 ### Setup
1. Fork and clone the repository 1. Fork and clone the repository
2. Copy the environment file: 2. Copy the environment file:
```bash ```bash
# Unix/Linux/Mac # Unix/Linux/Mac
cp apps/web/.env.example apps/web/.env.local cp apps/web/.env.example apps/web/.env.local
# Windows PowerShell # Windows PowerShell
Copy-Item apps/web/.env.example apps/web/.env.local Copy-Item apps/web/.env.example apps/web/.env.local
``` ```
3. Start the database and Redis: 3. Start the database and Redis:
```bash ```bash
docker compose up -d db redis serverless-redis-http docker compose up -d db redis serverless-redis-http
``` ```
4. Install dependencies and start the dev server: 4. Install dependencies and start the dev server:
```bash ```bash
bun install bun install
bun dev:web bun dev:web
``` ```
The application will be available at [http://localhost:3000](http://localhost:3000). 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. The `.env.example` has sensible defaults that match the Docker Compose config — it should work out of the box.
### Desktop setup ### Desktop setup
Desktop is opt-in. If you're only working on the web app, skip this entirely. 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. 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 ### Self-Hosting with Docker
To run everything (including a production build of the app) in Docker: To run everything (including a production build of the app) in Docker:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
The app will be available at [http://localhost:3100](http://localhost:3100). The app will be available at [http://localhost:3100](http://localhost:3100).
## Contributing ## Contributing
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively. 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. **🎯 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. **⚠️ 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. See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
**Quick start for contributors:** **Quick start for contributors:**
- Fork the repo and clone locally - Fork the repo and clone locally
- Follow the setup instructions in CONTRIBUTING.md - Follow the setup instructions in CONTRIBUTING.md
- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup - Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup
- Create a feature branch and submit a PR - Create a feature branch and submit a PR
## License ## License
[MIT LICENSE](LICENSE) [MIT LICENSE](LICENSE)
--- ---
![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date) ![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)
+1 -5
View File
@@ -7,8 +7,4 @@
.next/ .next/
.font-cache/ .font-cache/
next-env.d.ts
*.tsbuildinfo
+5
View File
@@ -14,9 +14,14 @@ COPY bun.lock bun.lock
COPY turbo.json turbo.json COPY turbo.json turbo.json
COPY apps/web/package.json apps/web/package.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 RUN bun install
COPY apps/web/ apps/web/ COPY apps/web/ apps/web/
COPY packages/env/ packages/env/
COPY packages/ui/ packages/ui/
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
+2 -4
View File
@@ -9,7 +9,6 @@ const changelog = defineCollection({
content: z.string(), content: z.string(),
version: z.string(), version: z.string(),
date: z.string(), date: z.string(),
published: z.boolean().default(true),
title: z.string(), title: z.string(),
description: z.string().optional(), description: z.string().optional(),
changes: z.array( changes: z.array(
@@ -21,11 +20,10 @@ const changelog = defineCollection({
}), }),
transform: async (doc, { collection }) => { transform: async (doc, { collection }) => {
const allDocs = await collection.documents(); const allDocs = await collection.documents();
const publishedDocs = allDocs.filter((entry) => entry.published !== false); const sorted = [...allDocs].sort((a, b) =>
const sorted = [...publishedDocs].sort((a, b) =>
b.version.localeCompare(a.version, undefined, { numeric: true }), b.version.localeCompare(a.version, undefined, { numeric: true }),
); );
const isLatest = doc.published !== false && sorted[0]?.version === doc.version; const isLatest = sorted[0]?.version === doc.version;
return { ...doc, isLatest }; return { ...doc, isLatest };
}, },
}); });
-1
View File
@@ -1,7 +1,6 @@
--- ---
version: "0.1.0" version: "0.1.0"
date: "2026-02-23" date: "2026-02-23"
published: true
title: "Editor foundation" title: "Editor foundation"
description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use." description: "This first release focuses on making editing faster, clearer, and more reliable for day-to-day use."
changes: changes:
-1
View File
@@ -1,7 +1,6 @@
--- ---
version: "0.2.0" version: "0.2.0"
date: "2026-03-01" date: "2026-03-01"
published: true
title: "Motion & effects" title: "Motion & effects"
description: "This release adds the foundation for motion and effects, alongside many improvements & fixes." description: "This release adds the foundation for motion and effects, alongside many improvements & fixes."
changes: changes:
-1
View File
@@ -1,7 +1,6 @@
--- ---
version: "0.3.0" version: "0.3.0"
date: "2026-03-04" date: "2026-03-04"
published: false
title: "Brand & polish" title: "Brand & polish"
description: "This release adds a brand page with downloadable assets, alongside a few fixes and improvements." description: "This release adds a brand page with downloadable assets, alongside a few fixes and improvements."
changes: changes:
+23 -23
View File
@@ -1,23 +1,23 @@
import type { Config } from "drizzle-kit"; import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv"; import * as dotenv from "dotenv";
import { webEnv } from "@/lib/env/web"; import { webEnv } from "@opencut/env/web";
// Load the right env file based on environment // Load the right env file based on environment
if (webEnv.NODE_ENV === "production") { if (webEnv.NODE_ENV === "production") {
dotenv.config({ path: ".env.production" }); dotenv.config({ path: ".env.production" });
} else { } else {
dotenv.config({ path: ".env.local" }); dotenv.config({ path: ".env.local" });
} }
export default { export default {
schema: "./src/schema.ts", schema: "./src/schema.ts",
dialect: "postgresql", dialect: "postgresql",
migrations: { migrations: {
table: "drizzle_migrations", table: "drizzle_migrations",
}, },
dbCredentials: { dbCredentials: {
url: webEnv.DATABASE_URL, url: webEnv.DATABASE_URL,
}, },
out: "./migrations", out: "./migrations",
strict: webEnv.NODE_ENV === "production", strict: webEnv.NODE_ENV === "production",
} satisfies Config; } satisfies Config;
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+3
View File
@@ -1,6 +1,9 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import { withBotId } from "botid/next/config"; import { withBotId } from "botid/next/config";
import { withContentCollections } from "@content-collections/next"; import { withContentCollections } from "@content-collections/next";
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
initOpenNextCloudflareForDev();
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
turbopack: { turbopack: {
+3 -1
View File
@@ -22,6 +22,8 @@
"@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.6", "@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1", "@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@opennextjs/cloudflare": "^1.18.0", "@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -75,7 +77,7 @@
"unified": "^11.0.5", "unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1", "use-deep-compare-effect": "^1.8.1",
"wavesurfer.js": "^7.9.8", "wavesurfer.js": "^7.9.8",
"zod": "4.3.6", "zod": "^3.25.67",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
+280 -280
View File
@@ -1,280 +1,280 @@
import { webEnv } from "@/lib/env/web"; import { webEnv } from "@opencut/env/web";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit"; import { checkRateLimit } from "@/lib/rate-limit";
const searchParamsSchema = z.object({ const searchParamsSchema = z.object({
q: z.string().max(500, "Query too long").optional(), q: z.string().max(500, "Query too long").optional(),
type: z.enum(["songs", "effects"]).optional(), type: z.enum(["songs", "effects"]).optional(),
page: z.coerce.number().int().min(1).max(1000).default(1), page: z.coerce.number().int().min(1).max(1000).default(1),
page_size: z.coerce.number().int().min(1).max(150).default(20), page_size: z.coerce.number().int().min(1).max(150).default(20),
sort: z sort: z
.enum(["downloads", "rating", "created", "score"]) .enum(["downloads", "rating", "created", "score"])
.default("downloads"), .default("downloads"),
min_rating: z.coerce.number().min(0).max(5).default(3), min_rating: z.coerce.number().min(0).max(5).default(3),
commercial_only: z.coerce.boolean().default(true), commercial_only: z.coerce.boolean().default(true),
}); });
const freesoundResultSchema = z.object({ const freesoundResultSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
url: z.string().url(), url: z.string().url(),
previews: z previews: z
.object({ .object({
"preview-hq-mp3": z.string().url(), "preview-hq-mp3": z.string().url(),
"preview-lq-mp3": z.string().url(), "preview-lq-mp3": z.string().url(),
"preview-hq-ogg": z.string().url(), "preview-hq-ogg": z.string().url(),
"preview-lq-ogg": z.string().url(), "preview-lq-ogg": z.string().url(),
}) })
.optional(), .optional(),
download: z.string().url().optional(), download: z.string().url().optional(),
duration: z.number(), duration: z.number(),
filesize: z.number(), filesize: z.number(),
type: z.string(), type: z.string(),
channels: z.number(), channels: z.number(),
bitrate: z.number(), bitrate: z.number(),
bitdepth: z.number(), bitdepth: z.number(),
samplerate: z.number(), samplerate: z.number(),
username: z.string(), username: z.string(),
tags: z.array(z.string()), tags: z.array(z.string()),
license: z.string(), license: z.string(),
created: z.string(), created: z.string(),
num_downloads: z.number().optional(), num_downloads: z.number().optional(),
avg_rating: z.number().optional(), avg_rating: z.number().optional(),
num_ratings: z.number().optional(), num_ratings: z.number().optional(),
}); });
const freesoundResponseSchema = z.object({ const freesoundResponseSchema = z.object({
count: z.number(), count: z.number(),
next: z.string().url().nullable(), next: z.string().url().nullable(),
previous: z.string().url().nullable(), previous: z.string().url().nullable(),
results: z.array(freesoundResultSchema), results: z.array(freesoundResultSchema),
}); });
const transformedResultSchema = z.object({ const transformedResultSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
url: z.string(), url: z.string(),
previewUrl: z.string().optional(), previewUrl: z.string().optional(),
downloadUrl: z.string().optional(), downloadUrl: z.string().optional(),
duration: z.number(), duration: z.number(),
filesize: z.number(), filesize: z.number(),
type: z.string(), type: z.string(),
channels: z.number(), channels: z.number(),
bitrate: z.number(), bitrate: z.number(),
bitdepth: z.number(), bitdepth: z.number(),
samplerate: z.number(), samplerate: z.number(),
username: z.string(), username: z.string(),
tags: z.array(z.string()), tags: z.array(z.string()),
license: z.string(), license: z.string(),
created: z.string(), created: z.string(),
downloads: z.number().optional(), downloads: z.number().optional(),
rating: z.number().optional(), rating: z.number().optional(),
ratingCount: z.number().optional(), ratingCount: z.number().optional(),
}); });
const apiResponseSchema = z.object({ const apiResponseSchema = z.object({
count: z.number(), count: z.number(),
next: z.string().nullable(), next: z.string().nullable(),
previous: z.string().nullable(), previous: z.string().nullable(),
results: z.array(transformedResultSchema), results: z.array(transformedResultSchema),
query: z.string().optional(), query: z.string().optional(),
type: z.string(), type: z.string(),
page: z.number(), page: z.number(),
pageSize: z.number(), pageSize: z.number(),
sort: z.string(), sort: z.string(),
minRating: z.number().optional(), minRating: z.number().optional(),
}); });
function buildSortParameter({ query, sort }: { query?: string; sort: string }) { function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
if (!query) return `${sort}_desc`; if (!query) return `${sort}_desc`;
return sort === "score" ? "score" : `${sort}_desc`; return sort === "score" ? "score" : `${sort}_desc`;
} }
function applyEffectsFilters({ function applyEffectsFilters({
params, params,
min_rating, min_rating,
commercial_only, commercial_only,
}: { }: {
params: URLSearchParams; params: URLSearchParams;
min_rating: number; min_rating: number;
commercial_only: boolean; commercial_only: boolean;
}) { }) {
params.append("filter", "duration:[* TO 30.0]"); params.append("filter", "duration:[* TO 30.0]");
params.append("filter", `avg_rating:[${min_rating} TO *]`); params.append("filter", `avg_rating:[${min_rating} TO *]`);
if (commercial_only) { if (commercial_only) {
params.append( params.append(
"filter", "filter",
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")', 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
); );
} }
params.append( params.append(
"filter", "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", "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( function transformFreesoundResult(
result: z.infer<typeof freesoundResultSchema>, result: z.infer<typeof freesoundResultSchema>,
) { ) {
return { return {
id: result.id, id: result.id,
name: result.name, name: result.name,
description: result.description, description: result.description,
url: result.url, url: result.url,
previewUrl: previewUrl:
result.previews?.["preview-hq-mp3"] || result.previews?.["preview-hq-mp3"] ||
result.previews?.["preview-lq-mp3"], result.previews?.["preview-lq-mp3"],
downloadUrl: result.download, downloadUrl: result.download,
duration: result.duration, duration: result.duration,
filesize: result.filesize, filesize: result.filesize,
type: result.type, type: result.type,
channels: result.channels, channels: result.channels,
bitrate: result.bitrate, bitrate: result.bitrate,
bitdepth: result.bitdepth, bitdepth: result.bitdepth,
samplerate: result.samplerate, samplerate: result.samplerate,
username: result.username, username: result.username,
tags: result.tags, tags: result.tags,
license: result.license, license: result.license,
created: result.created, created: result.created,
downloads: result.num_downloads || 0, downloads: result.num_downloads || 0,
rating: result.avg_rating || 0, rating: result.avg_rating || 0,
ratingCount: result.num_ratings || 0, ratingCount: result.num_ratings || 0,
}; };
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { limited } = await checkRateLimit({ request }); const { limited } = await checkRateLimit({ request });
if (limited) { if (limited) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 }); return NextResponse.json({ error: "Too many requests" }, { status: 429 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const validationResult = searchParamsSchema.safeParse({ const validationResult = searchParamsSchema.safeParse({
q: searchParams.get("q") || undefined, q: searchParams.get("q") || undefined,
type: searchParams.get("type") || undefined, type: searchParams.get("type") || undefined,
page: searchParams.get("page") || undefined, page: searchParams.get("page") || undefined,
page_size: searchParams.get("page_size") || undefined, page_size: searchParams.get("page_size") || undefined,
sort: searchParams.get("sort") || undefined, sort: searchParams.get("sort") || undefined,
min_rating: searchParams.get("min_rating") || undefined, min_rating: searchParams.get("min_rating") || undefined,
}); });
if (!validationResult.success) { if (!validationResult.success) {
return NextResponse.json( return NextResponse.json(
{ {
error: "Invalid parameters", error: "Invalid parameters",
details: validationResult.error.flatten().fieldErrors, details: validationResult.error.flatten().fieldErrors,
}, },
{ status: 400 }, { status: 400 },
); );
} }
const { const {
q: query, q: query,
type, type,
page, page,
page_size: pageSize, page_size: pageSize,
sort, sort,
min_rating, min_rating,
commercial_only, commercial_only,
} = validationResult.data; } = validationResult.data;
if (type === "songs") { if (type === "songs") {
return NextResponse.json( return NextResponse.json(
{ {
error: "Songs are not available yet", error: "Songs are not available yet",
message: message:
"Song search functionality is coming soon. Try searching for sound effects instead.", "Song search functionality is coming soon. Try searching for sound effects instead.",
}, },
{ status: 501 }, { status: 501 },
); );
} }
const baseUrl = "https://freesound.org/apiv2/search/text/"; const baseUrl = "https://freesound.org/apiv2/search/text/";
const sortParam = buildSortParameter({ query, sort }); const sortParam = buildSortParameter({ query, sort });
const params = new URLSearchParams({ const params = new URLSearchParams({
query: query || "", query: query || "",
token: webEnv.FREESOUND_API_KEY, token: webEnv.FREESOUND_API_KEY,
page: page.toString(), page: page.toString(),
page_size: pageSize.toString(), page_size: pageSize.toString(),
sort: sortParam, sort: sortParam,
fields: fields:
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings", "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; const isEffectsSearch = type === "effects" || !type;
if (isEffectsSearch) { if (isEffectsSearch) {
applyEffectsFilters({ params, min_rating, commercial_only }); applyEffectsFilters({ params, min_rating, commercial_only });
} }
const response = await fetch(`${baseUrl}?${params.toString()}`); const response = await fetch(`${baseUrl}?${params.toString()}`);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error("Freesound API error:", response.status, errorText); console.error("Freesound API error:", response.status, errorText);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to search sounds" }, { error: "Failed to search sounds" },
{ status: response.status }, { status: response.status },
); );
} }
const rawData = await response.json(); const rawData = await response.json();
const freesoundValidation = freesoundResponseSchema.safeParse(rawData); const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
if (!freesoundValidation.success) { if (!freesoundValidation.success) {
console.error( console.error(
"Invalid Freesound API response:", "Invalid Freesound API response:",
freesoundValidation.error, freesoundValidation.error,
); );
return NextResponse.json( return NextResponse.json(
{ error: "Invalid response from Freesound API" }, { error: "Invalid response from Freesound API" },
{ status: 502 }, { status: 502 },
); );
} }
const data = freesoundValidation.data; const data = freesoundValidation.data;
const transformedResults = data.results.map(transformFreesoundResult); const transformedResults = data.results.map(transformFreesoundResult);
const responseData = { const responseData = {
count: data.count, count: data.count,
next: data.next, next: data.next,
previous: data.previous, previous: data.previous,
results: transformedResults, results: transformedResults,
query: query || "", query: query || "",
type: type || "effects", type: type || "effects",
page, page,
pageSize, pageSize,
sort, sort,
minRating: min_rating, minRating: min_rating,
}; };
const responseValidation = apiResponseSchema.safeParse(responseData); const responseValidation = apiResponseSchema.safeParse(responseData);
if (!responseValidation.success) { if (!responseValidation.success) {
console.error( console.error(
"Invalid API response structure:", "Invalid API response structure:",
responseValidation.error, responseValidation.error,
); );
return NextResponse.json( return NextResponse.json(
{ error: "Internal response formatting error" }, { error: "Internal response formatting error" },
{ status: 500 }, { status: 500 },
); );
} }
return NextResponse.json(responseValidation.data); return NextResponse.json(responseValidation.data);
} catch (error) { } catch (error) {
console.error("Error searching sounds:", error); console.error("Error searching sounds:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },
{ status: 500 }, { status: 500 },
); );
} }
} }
+20 -17
View File
@@ -2,8 +2,9 @@ import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { BasePage } from "@/app/base-page"; import { BasePage } from "@/app/base-page";
import { allChangelogs } from "content-collections";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { getReleaseByVersion, getSortedReleases } from "../utils"; import { getSortedReleases } from "../utils";
import { import {
ReleaseArticle, ReleaseArticle,
ReleaseMeta, ReleaseMeta,
@@ -16,12 +17,12 @@ import { CopyMarkdownButton } from "../components/copy-markdown-button";
type Props = { params: Promise<{ version: string }> }; type Props = { params: Promise<{ version: string }> };
export async function generateStaticParams() { export async function generateStaticParams() {
return getSortedReleases().map((release) => ({ version: release.version })); return allChangelogs.map((r) => ({ version: r.version }));
} }
export async function generateMetadata({ params }: Props): Promise<Metadata> { export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { version } = await params; const { version } = await params;
const release = getReleaseByVersion({ version }); const release = allChangelogs.find((r) => r.version === version);
if (!release) return {}; if (!release) return {};
return { return {
title: `${release.title} (${release.version}) - OpenCut Changelog`, title: `${release.title} (${release.version}) - OpenCut Changelog`,
@@ -32,8 +33,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function ReleaseDetailPage({ params }: Props) { export default async function ReleaseDetailPage({ params }: Props) {
const { version } = await params; const { version } = await params;
const releases = getSortedReleases(); const releases = getSortedReleases();
const index = releases.findIndex((entry) => entry.version === version); const index = releases.findIndex((r) => r.version === version);
if (index === -1) notFound(); if (index === -1) notFound();
const release = releases[index]; const release = releases[index];
const newer = index > 0 ? releases[index - 1] : null; const newer = index > 0 ? releases[index - 1] : null;
const older = index < releases.length - 1 ? releases[index + 1] : null; const older = index < releases.length - 1 ? releases[index + 1] : null;
@@ -49,20 +52,20 @@ export default async function ReleaseDetailPage({ params }: Props) {
All releases All releases
</Link> </Link>
<ReleaseArticle variant="detail"> <ReleaseArticle variant="detail">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<ReleaseMeta release={release} /> <ReleaseMeta release={release} />
<CopyMarkdownButton <CopyMarkdownButton
description={release.description} description={release.description}
changes={release.changes} changes={release.changes}
/> />
</div>
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
{release.description && (
<ReleaseDescription>{release.description}</ReleaseDescription>
)}
</div> </div>
<ReleaseTitle as="h1">{release.title}</ReleaseTitle>
{release.description && (
<ReleaseDescription>{release.description}</ReleaseDescription>
)}
</div>
<ReleaseChanges release={release} /> <ReleaseChanges release={release} />
</ReleaseArticle> </ReleaseArticle>
@@ -23,7 +23,7 @@ function buildMarkdown({
const { grouped, orderedTypes } = groupAndOrderChanges({ changes }); const { grouped, orderedTypes } = groupAndOrderChanges({ changes });
for (const type of orderedTypes) { for (const type of orderedTypes) {
lines.push(`## ${getSectionTitle({ type })}`); lines.push(`## ${getSectionTitle(type)}`);
for (const change of grouped[type]) { for (const change of grouped[type]) {
lines.push(`- ${change.text}`); lines.push(`- ${change.text}`);
} }
@@ -87,7 +87,7 @@ export function ReleaseChanges({ release }: { release: Release }) {
{orderedTypes.map((type) => ( {orderedTypes.map((type) => (
<div key={type} className="flex flex-col gap-1.5"> <div key={type} className="flex flex-col gap-1.5">
<h3 className="text-base font-semibold text-foreground"> <h3 className="text-base font-semibold text-foreground">
{getSectionTitle({ type })}: {getSectionTitle(type)}:
</h3> </h3>
<ul className="list-disc pl-5 space-y-1.5"> <ul className="list-disc pl-5 space-y-1.5">
{grouped[type].map((change) => ( {grouped[type].map((change) => (
+4 -14
View File
@@ -12,7 +12,7 @@ const knownSectionTitles: Record<string, string> = {
breaking: "Breaking Changes", breaking: "Breaking Changes",
}; };
export function getSectionTitle({ type }: { type: string }): string { export function getSectionTitle(type: string): string {
return ( return (
knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1) knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1)
); );
@@ -36,18 +36,8 @@ export function groupAndOrderChanges({ changes }: { changes: Change[] }) {
return { grouped, orderedTypes }; return { grouped, orderedTypes };
} }
function isPublishedRelease({ published }: Release) {
return published !== false;
}
export function getSortedReleases() { export function getSortedReleases() {
return allChangelogs return [...allChangelogs].sort((a, b) =>
.filter(isPublishedRelease) b.version.localeCompare(a.version, undefined, { numeric: true }),
.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 { TooltipProvider } from "../components/ui/tooltip";
import { baseMetaData } from "./metadata"; import { baseMetaData } from "./metadata";
import { BotIdClient } from "botid/client"; import { BotIdClient } from "botid/client";
import { webEnv } from "@/lib/env/web"; import { webEnv } from "@opencut/env/web";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
const siteFont = Inter({ subsets: ["latin"] }); const siteFont = Inter({ subsets: ["latin"] });
File diff suppressed because it is too large Load Diff
@@ -11,9 +11,11 @@ import { useState, useRef } from "react";
import { extractTimelineAudio } from "@/lib/media/mediabunny"; import { extractTimelineAudio } from "@/lib/media/mediabunny";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { import {
DEFAULT_TRANSCRIPTION_SAMPLE_RATE, BatchCommand,
TRANSCRIPTION_LANGUAGES, AddTrackCommand,
} from "@/constants/transcription-constants"; InsertElementCommand,
} from "@/lib/commands";
import { TRANSCRIPTION_LANGUAGES } from "@/constants/transcription-constants";
import type { import type {
TranscriptionLanguage, TranscriptionLanguage,
TranscriptionProgress, TranscriptionProgress,
@@ -29,11 +31,6 @@ import {
SectionFields, SectionFields,
} from "@/components/section"; } from "@/components/section";
import { DEFAULTS } from "@/lib/timeline/defaults"; import { DEFAULTS } from "@/lib/timeline/defaults";
import {
AddTrackCommand,
BatchCommand,
InsertElementCommand,
} from "@/lib/commands";
export function Captions() { export function Captions() {
const [selectedLanguage, setSelectedLanguage] = const [selectedLanguage, setSelectedLanguage] =
@@ -65,10 +62,7 @@ export function Captions() {
}); });
setProcessingStep("Preparing audio..."); setProcessingStep("Preparing audio...");
const { samples } = await decodeAudioToFloat32({ const { samples } = await decodeAudioToFloat32({ audioBlob });
audioBlob,
sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
});
const result = await transcriptionService.transcribe({ const result = await transcriptionService.transcribe({
audioData: samples, audioData: samples,
@@ -1,369 +1,369 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { FPS_PRESETS } from "@/constants/project-constants"; import { FPS_PRESETS } from "@/constants/project-constants";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { import {
Section, Section,
SectionContent, SectionContent,
SectionHeader, SectionHeader,
SectionTitle, SectionTitle,
} from "@/components/section"; } from "@/components/section";
import { BackgroundContent } from "./background"; import { BackgroundContent } from "./background";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { NumberField } from "@/components/ui/number-field"; import { NumberField } from "@/components/ui/number-field";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { Tick02Icon } from "@hugeicons/core-free-icons"; import { Tick02Icon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { dimensionToAspectRatio } from "@/utils/geometry"; import { dimensionToAspectRatio } from "@/utils/geometry";
import { formatNumberForDisplay } from "@/utils/math"; import { formatNumberForDisplay } from "@/utils/math";
import { OcSquarePlusIcon } from "@/components/icons"; import { OcSquarePlusIcon } from "@opencut/ui/icons";
import type { TCanvasSize } from "@/lib/project/types"; import type { TCanvasSize } from "@/lib/project/types";
type SettingsView = "project-info" | "background"; type SettingsView = "project-info" | "background";
const PRESET_LABELS: Record<string, string> = { const PRESET_LABELS: Record<string, string> = {
"1:1": "1:1", "1:1": "1:1",
"16:9": "16:9", "16:9": "16:9",
"9:16": "9:16", "9:16": "9:16",
"4:3": "4:3", "4:3": "4:3",
}; };
function areCanvasSizesEqual({ function areCanvasSizesEqual({
left, left,
right, right,
}: { }: {
left: TCanvasSize; left: TCanvasSize;
right: TCanvasSize; right: TCanvasSize;
}) { }) {
return left.width === right.width && left.height === right.height; return left.width === right.width && left.height === right.height;
} }
function formatCanvasDimension({ value }: { value: number }) { function formatCanvasDimension({ value }: { value: number }) {
return formatNumberForDisplay({ value, maxFractionDigits: 0 }); return formatNumberForDisplay({ value, maxFractionDigits: 0 });
} }
function parseCanvasDimension({ input }: { input: string }): number | null { function parseCanvasDimension({ input }: { input: string }): number | null {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) return null; if (!trimmed) return null;
const parsed = Number(trimmed); const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) return null; if (!Number.isFinite(parsed)) return null;
const rounded = Math.round(parsed); const rounded = Math.round(parsed);
return rounded > 0 ? rounded : null; return rounded > 0 ? rounded : null;
} }
function useCanvasDimensionDraft({ function useCanvasDimensionDraft({
value, value,
onCommit, onCommit,
}: { }: {
value: number; value: number;
onCommit: (value: number) => void; onCommit: (value: number) => void;
}) { }) {
const pendingValueRef = useRef(value); const pendingValueRef = useRef(value);
const syncedValueRef = useRef(value); const syncedValueRef = useRef(value);
if (syncedValueRef.current !== value) { if (syncedValueRef.current !== value) {
syncedValueRef.current = value; syncedValueRef.current = value;
pendingValueRef.current = value; pendingValueRef.current = value;
} }
return usePropertyDraft({ return usePropertyDraft({
displayValue: formatCanvasDimension({ value }), displayValue: formatCanvasDimension({ value }),
parse: (input) => parseCanvasDimension({ input }), parse: (input) => parseCanvasDimension({ input }),
onPreview: (nextValue) => { onPreview: (nextValue) => {
pendingValueRef.current = nextValue; pendingValueRef.current = nextValue;
}, },
onCommit: () => { onCommit: () => {
if (pendingValueRef.current !== value) { if (pendingValueRef.current !== value) {
onCommit(pendingValueRef.current); onCommit(pendingValueRef.current);
} }
}, },
}); });
} }
export function SettingsView() { export function SettingsView() {
const [view, setView] = useState<SettingsView>("project-info"); const [view, setView] = useState<SettingsView>("project-info");
const editor = useEditor(); const editor = useEditor();
const activeProject = useEditor((e) => e.project.getActive()); const activeProject = useEditor((e) => e.project.getActive());
const { canvasPresets } = useEditorStore(); const { canvasPresets } = useEditorStore();
const currentCanvasSize = activeProject.settings.canvasSize; const currentCanvasSize = activeProject.settings.canvasSize;
const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset"; const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
const lastCustomCanvasSize = const lastCustomCanvasSize =
activeProject.settings.lastCustomCanvasSize ?? null; activeProject.settings.lastCustomCanvasSize ?? null;
const presetItems = canvasPresets.map((preset, index) => { const presetItems = canvasPresets.map((preset, index) => {
const ratio = dimensionToAspectRatio(preset); const ratio = dimensionToAspectRatio(preset);
return { return {
id: index.toString(), id: index.toString(),
label: PRESET_LABELS[ratio] ?? ratio, label: PRESET_LABELS[ratio] ?? ratio,
ratio, ratio,
canvasSize: preset, canvasSize: preset,
}; };
}); });
const selectedPresetId = canvasSizeMode === "preset" const selectedPresetId = canvasSizeMode === "preset"
? (presetItems.find((preset) => ? (presetItems.find((preset) =>
areCanvasSizesEqual({ areCanvasSizesEqual({
left: preset.canvasSize, left: preset.canvasSize,
right: currentCanvasSize, right: currentCanvasSize,
}), }),
)?.id ?? null) )?.id ?? null)
: null; : null;
const updateCustomCanvasSize = ({ const updateCustomCanvasSize = ({
canvasSize, canvasSize,
}: { }: {
canvasSize: TCanvasSize; canvasSize: TCanvasSize;
}) => { }) => {
const shouldUpdateCanvasSize = !areCanvasSizesEqual({ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
left: canvasSize, left: canvasSize,
right: currentCanvasSize, right: currentCanvasSize,
}); });
const shouldUpdateLastCustomCanvasSize = const shouldUpdateLastCustomCanvasSize =
lastCustomCanvasSize === null || lastCustomCanvasSize === null ||
!areCanvasSizesEqual({ !areCanvasSizesEqual({
left: canvasSize, left: canvasSize,
right: lastCustomCanvasSize, right: lastCustomCanvasSize,
}); });
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom"; const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
if ( if (
!shouldUpdateCanvasSize && !shouldUpdateCanvasSize &&
!shouldUpdateLastCustomCanvasSize && !shouldUpdateLastCustomCanvasSize &&
!shouldUpdateCanvasSizeMode !shouldUpdateCanvasSizeMode
) { ) {
return; return;
} }
editor.project.updateSettings({ editor.project.updateSettings({
settings: { settings: {
...(shouldUpdateCanvasSize ? { canvasSize } : {}), ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
...(shouldUpdateCanvasSizeMode ...(shouldUpdateCanvasSizeMode
? { canvasSizeMode: "custom" as const } ? { canvasSizeMode: "custom" as const }
: {}), : {}),
lastCustomCanvasSize: canvasSize, lastCustomCanvasSize: canvasSize,
}, },
}); });
}; };
const selectPresetCanvasSize = ({ const selectPresetCanvasSize = ({
canvasSize, canvasSize,
}: { }: {
canvasSize: TCanvasSize; canvasSize: TCanvasSize;
}) => { }) => {
const shouldUpdateCanvasSize = !areCanvasSizesEqual({ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
left: canvasSize, left: canvasSize,
right: currentCanvasSize, right: currentCanvasSize,
}); });
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset"; const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return; if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
editor.project.updateSettings({ editor.project.updateSettings({
settings: { settings: {
...(shouldUpdateCanvasSize ? { canvasSize } : {}), ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
...(shouldUpdateCanvasSizeMode ...(shouldUpdateCanvasSizeMode
? { canvasSizeMode: "preset" as const } ? { canvasSizeMode: "preset" as const }
: {}), : {}),
}, },
}); });
}; };
const selectCustomCanvasSize = () => { const selectCustomCanvasSize = () => {
updateCustomCanvasSize({ updateCustomCanvasSize({
canvasSize: lastCustomCanvasSize ?? currentCanvasSize, canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
}); });
}; };
const widthDraft = useCanvasDimensionDraft({ const widthDraft = useCanvasDimensionDraft({
value: currentCanvasSize.width, value: currentCanvasSize.width,
onCommit: (width) => onCommit: (width) =>
updateCustomCanvasSize({ updateCustomCanvasSize({
canvasSize: { width, height: currentCanvasSize.height }, canvasSize: { width, height: currentCanvasSize.height },
}), }),
}); });
const heightDraft = useCanvasDimensionDraft({ const heightDraft = useCanvasDimensionDraft({
value: currentCanvasSize.height, value: currentCanvasSize.height,
onCommit: (height) => onCommit: (height) =>
updateCustomCanvasSize({ updateCustomCanvasSize({
canvasSize: { width: currentCanvasSize.width, height }, canvasSize: { width: currentCanvasSize.width, height },
}), }),
}); });
const isCustomSelected = canvasSizeMode === "custom"; const isCustomSelected = canvasSizeMode === "custom";
return ( return (
<PanelView <PanelView
contentClassName="px-0" contentClassName="px-0"
scrollClassName="pt-0" scrollClassName="pt-0"
actions={ actions={
<Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}> <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
<TabsList> <TabsList>
<TabsTrigger value="project-info">Project info</TabsTrigger> <TabsTrigger value="project-info">Project info</TabsTrigger>
<TabsTrigger value="background">Background</TabsTrigger> <TabsTrigger value="background">Background</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
} }
> >
{view === "project-info" && ( {view === "project-info" && (
<div className="flex flex-col"> <div className="flex flex-col">
<Section showTopBorder={false}> <Section showTopBorder={false}>
<SectionHeader> <SectionHeader>
<SectionTitle className="flex-1">Name</SectionTitle> <SectionTitle className="flex-1">Name</SectionTitle>
<span className="text-sm truncate"> <span className="text-sm truncate">
{activeProject.metadata.name} {activeProject.metadata.name}
</span> </span>
</SectionHeader> </SectionHeader>
</Section> </Section>
<Section showTopBorder={false}> <Section showTopBorder={false}>
<SectionHeader className="justify-between"> <SectionHeader className="justify-between">
<SectionTitle className="flex-1">Frame rate</SectionTitle> <SectionTitle className="flex-1">Frame rate</SectionTitle>
<Select <Select
value={activeProject.settings.fps.toString()} value={activeProject.settings.fps.toString()}
onValueChange={(value) => { onValueChange={(value) => {
const fps = parseFloat(value); const fps = parseFloat(value);
editor.project.updateSettings({ settings: { fps } }); editor.project.updateSettings({ settings: { fps } });
}} }}
> >
<SelectTrigger className="bg-transparent border-none p-1 h-auto"> <SelectTrigger className="bg-transparent border-none p-1 h-auto">
<SelectValue placeholder="Select a frame rate" /> <SelectValue placeholder="Select a frame rate" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FPS_PRESETS.map((preset) => ( {FPS_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}> <SelectItem key={preset.value} value={preset.value}>
{preset.label} {preset.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</SectionHeader> </SectionHeader>
</Section> </Section>
<Section <Section
showTopBorder={false} showTopBorder={false}
collapsible collapsible
sectionKey="settings:aspect-ratio" sectionKey="settings:aspect-ratio"
> >
<SectionHeader> <SectionHeader>
<SectionTitle className="flex-1">Aspect ratio</SectionTitle> <SectionTitle className="flex-1">Aspect ratio</SectionTitle>
</SectionHeader> </SectionHeader>
<SectionContent className="px-2 flex flex-col gap-1 pb-2"> <SectionContent className="px-2 flex flex-col gap-1 pb-2">
{presetItems.map((preset) => ( {presetItems.map((preset) => (
<AspectRatioItem <AspectRatioItem
key={preset.id} key={preset.id}
label={preset.label} label={preset.label}
previewIcon={<AspectRatioPreview ratio={preset.ratio} />} previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
isSelected={selectedPresetId === preset.id} isSelected={selectedPresetId === preset.id}
onClick={() => { onClick={() => {
selectPresetCanvasSize({ selectPresetCanvasSize({
canvasSize: preset.canvasSize, canvasSize: preset.canvasSize,
}); });
}} }}
/> />
))} ))}
<div className="pb-2"> <div className="pb-2">
<AspectRatioItem <AspectRatioItem
key="custom" key="custom"
label="Custom" label="Custom"
previewIcon={<OcSquarePlusIcon />} previewIcon={<OcSquarePlusIcon />}
isSelected={isCustomSelected} isSelected={isCustomSelected}
onClick={selectCustomCanvasSize} onClick={selectCustomCanvasSize}
uiOptions={ uiOptions={
<div className=" flex items-center gap-2 text-foreground"> <div className=" flex items-center gap-2 text-foreground">
<NumberField <NumberField
value={widthDraft.displayValue} value={widthDraft.displayValue}
className="w-full" className="w-full"
aria-label="Canvas width" aria-label="Canvas width"
onFocus={widthDraft.onFocus} onFocus={widthDraft.onFocus}
onChange={widthDraft.onChange} onChange={widthDraft.onChange}
onBlur={widthDraft.onBlur} onBlur={widthDraft.onBlur}
/> />
<NumberField <NumberField
value={heightDraft.displayValue} value={heightDraft.displayValue}
className="w-full" className="w-full"
aria-label="Canvas height" aria-label="Canvas height"
onFocus={heightDraft.onFocus} onFocus={heightDraft.onFocus}
onChange={heightDraft.onChange} onChange={heightDraft.onChange}
onBlur={heightDraft.onBlur} onBlur={heightDraft.onBlur}
/> />
</div> </div>
} }
/> />
</div> </div>
</SectionContent> </SectionContent>
</Section> </Section>
</div> </div>
)} )}
{view === "background" && <BackgroundContent />} {view === "background" && <BackgroundContent />}
</PanelView> </PanelView>
); );
} }
function AspectRatioItem({ function AspectRatioItem({
label, label,
previewIcon, previewIcon,
isSelected, isSelected,
onClick, onClick,
uiOptions, uiOptions,
}: { }: {
label: string; label: string;
previewIcon: React.ReactNode; previewIcon: React.ReactNode;
isSelected: boolean; isSelected: boolean;
onClick: () => void; onClick: () => void;
uiOptions?: React.ReactNode; uiOptions?: React.ReactNode;
}) { }) {
return ( return (
<Button <Button
variant={isSelected ? "secondary" : "ghost"} variant={isSelected ? "secondary" : "ghost"}
className={cn( className={cn(
"px-2 py-0 flex flex-col h-fit w-full", "px-2 py-0 flex flex-col h-fit w-full",
!isSelected && "border border-transparent opacity-75!", !isSelected && "border border-transparent opacity-75!",
)} )}
onClick={onClick} onClick={onClick}
> >
<div className="w-full flex justify-between items-center h-8"> <div className="w-full flex justify-between items-center h-8">
<div className="flex-1 flex items-center gap-2"> <div className="flex-1 flex items-center gap-2">
<div className="flex items-center justify-center size-5"> <div className="flex items-center justify-center size-5">
{previewIcon} {previewIcon}
</div> </div>
<span className="text-sm truncate">{label}</span> <span className="text-sm truncate">{label}</span>
</div> </div>
<div> <div>
{isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />} {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
</div> </div>
</div> </div>
{uiOptions && isSelected && ( {uiOptions && isSelected && (
<div className="w-full pb-2">{uiOptions}</div> <div className="w-full pb-2">{uiOptions}</div>
)} )}
</Button> </Button>
); );
} }
function AspectRatioPreview({ ratio }: { ratio?: string }) { function AspectRatioPreview({ ratio }: { ratio?: string }) {
if (!ratio) return null; if (!ratio) return null;
const [w, h] = ratio.split(":").map(Number); const [w, h] = ratio.split(":").map(Number);
const maxSize = 16; const maxSize = 16;
const width = w >= h ? maxSize : (w / h) * maxSize; const width = w >= h ? maxSize : (w / h) * maxSize;
const height = h >= w ? maxSize : (h / w) * maxSize; const height = h >= w ? maxSize : (h / w) * maxSize;
return ( return (
<div <div
style={{ width, height, borderWidth: 1.5 }} style={{ width, height, borderWidth: 1.5 }}
className="rounded-xs border-current opacity-60" className="rounded-xs border-current opacity-60"
/> />
); );
} }
@@ -1,303 +1,303 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { import type {
EffectElement, EffectElement,
GraphicElement, GraphicElement,
ImageElement, ImageElement,
MaskableElement, MaskableElement,
RetimableElement, RetimableElement,
StickerElement, StickerElement,
TextElement, TextElement,
VisualElement, VisualElement,
VideoElement, VideoElement,
AudioElement, AudioElement,
TimelineElement, TimelineElement,
} from "@/lib/timeline"; } from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types"; import type { MediaAsset } from "@/lib/media/types";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { import {
TextFontIcon, TextFontIcon,
ArrowExpandIcon, ArrowExpandIcon,
RainDropIcon, RainDropIcon,
MusicNote03Icon, MusicNote03Icon,
MagicWand05Icon, MagicWand05Icon,
DashboardSpeed02Icon, DashboardSpeed02Icon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { TransformTab } from "./tabs/transform-tab"; import { TransformTab } from "./tabs/transform-tab";
import { BlendingTab } from "./tabs/blending-tab"; import { BlendingTab } from "./tabs/blending-tab";
import { AudioTab } from "./tabs/audio-tab"; import { AudioTab } from "./tabs/audio-tab";
import { TextTab } from "./tabs/text-tab"; import { TextTab } from "./tabs/text-tab";
import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab"; import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
import { MasksTab } from "./tabs/masks-tab"; import { MasksTab } from "./tabs/masks-tab";
import { SpeedTab } from "./tabs/speed-tab"; import { SpeedTab } from "./tabs/speed-tab";
import { GraphicTab } from "./tabs/graphic-tab"; import { GraphicTab } from "./tabs/graphic-tab";
import { OcShapesIcon } from "@/components/icons"; import { OcShapesIcon } from "@opencut/ui/icons";
export type TabContentProps = { export type TabContentProps = {
trackId: string; trackId: string;
}; };
export type PropertiesTabDef = { export type PropertiesTabDef = {
id: string; id: string;
label: string; label: string;
icon: ReactNode; icon: ReactNode;
content: (props: TabContentProps) => ReactNode; content: (props: TabContentProps) => ReactNode;
}; };
export type ElementPropertiesConfig = { export type ElementPropertiesConfig = {
defaultTab: string; defaultTab: string;
tabs: PropertiesTabDef[]; tabs: PropertiesTabDef[];
}; };
function buildTransformTab({ function buildTransformTab({
element, element,
}: { }: {
element: VisualElement; element: VisualElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "transform", id: "transform",
label: "Transform", label: "Transform",
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />, icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
content: ({ trackId }) => ( content: ({ trackId }) => (
<TransformTab element={element} trackId={trackId} /> <TransformTab element={element} trackId={trackId} />
), ),
}; };
} }
function buildBlendingTab({ function buildBlendingTab({
element, element,
}: { }: {
element: VisualElement; element: VisualElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "blending", id: "blending",
label: "Blending", label: "Blending",
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />, icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
content: ({ trackId }) => ( content: ({ trackId }) => (
<BlendingTab element={element} trackId={trackId} /> <BlendingTab element={element} trackId={trackId} />
), ),
}; };
} }
function buildAudioTab({ function buildAudioTab({
element, element,
}: { }: {
element: AudioElement | VideoElement; element: AudioElement | VideoElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "audio", id: "audio",
label: "Audio", label: "Audio",
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />, icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />, content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
}; };
} }
function buildSpeedTab({ function buildSpeedTab({
element, element,
}: { }: {
element: RetimableElement; element: RetimableElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "speed", id: "speed",
label: "Speed", label: "Speed",
icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />, icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />, content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
}; };
} }
function buildMasksTab({ function buildMasksTab({
element, element,
}: { }: {
element: MaskableElement; element: MaskableElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "masks", id: "masks",
label: "Masks", label: "Masks",
icon: <OcShapesIcon size={16} />, icon: <OcShapesIcon size={16} />,
content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />, content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
}; };
} }
function buildClipEffectsTab({ function buildClipEffectsTab({
element, element,
}: { }: {
element: VisualElement; element: VisualElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "effects", id: "effects",
label: "Effects", label: "Effects",
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />, icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
content: ({ trackId }) => ( content: ({ trackId }) => (
<ClipEffectsTab element={element} trackId={trackId} /> <ClipEffectsTab element={element} trackId={trackId} />
), ),
}; };
} }
function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
return { return {
id: "text", id: "text",
label: "Text", label: "Text",
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />, icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
content: ({ trackId }) => <TextTab element={element} trackId={trackId} />, content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
}; };
} }
function buildGraphicTab({ function buildGraphicTab({
element, element,
}: { }: {
element: GraphicElement; element: GraphicElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "graphic", id: "graphic",
label: "Graphic", label: "Graphic",
icon: <OcShapesIcon size={16} />, icon: <OcShapesIcon size={16} />,
content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />, content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
}; };
} }
function buildStandaloneEffectTab({ function buildStandaloneEffectTab({
element, element,
}: { }: {
element: EffectElement; element: EffectElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "effects", id: "effects",
label: "Effects", label: "Effects",
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />, icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
content: ({ trackId }) => ( content: ({ trackId }) => (
<StandaloneEffectTab element={element} trackId={trackId} /> <StandaloneEffectTab element={element} trackId={trackId} />
), ),
}; };
} }
function getTextConfig({ function getTextConfig({
element, element,
}: { }: {
element: TextElement; element: TextElement;
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
return { return {
defaultTab: "text", defaultTab: "text",
tabs: [ tabs: [
buildTextTab({ element }), buildTextTab({ element }),
buildTransformTab({ element }), buildTransformTab({ element }),
buildBlendingTab({ element }), buildBlendingTab({ element }),
], ],
}; };
} }
function getVideoConfig({ function getVideoConfig({
element, element,
mediaAsset, mediaAsset,
}: { }: {
element: VideoElement; element: VideoElement;
mediaAsset: MediaAsset | undefined; mediaAsset: MediaAsset | undefined;
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
const showAudioTab = mediaAsset?.hasAudio !== false; const showAudioTab = mediaAsset?.hasAudio !== false;
return { return {
defaultTab: "transform", defaultTab: "transform",
tabs: [ tabs: [
buildTransformTab({ element }), buildTransformTab({ element }),
...(showAudioTab ? [buildAudioTab({ element })] : []), ...(showAudioTab ? [buildAudioTab({ element })] : []),
buildSpeedTab({ element }), buildSpeedTab({ element }),
buildBlendingTab({ element }), buildBlendingTab({ element }),
buildMasksTab({ element }), buildMasksTab({ element }),
buildClipEffectsTab({ element }), buildClipEffectsTab({ element }),
], ],
}; };
} }
function getImageConfig({ function getImageConfig({
element, element,
}: { }: {
element: ImageElement; element: ImageElement;
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
return { return {
defaultTab: "transform", defaultTab: "transform",
tabs: [ tabs: [
buildTransformTab({ element }), buildTransformTab({ element }),
buildBlendingTab({ element }), buildBlendingTab({ element }),
buildMasksTab({ element }), buildMasksTab({ element }),
buildClipEffectsTab({ element }), buildClipEffectsTab({ element }),
], ],
}; };
} }
function getStickerConfig({ function getStickerConfig({
element, element,
}: { }: {
element: StickerElement; element: StickerElement;
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
return { return {
defaultTab: "transform", defaultTab: "transform",
tabs: [ tabs: [
buildTransformTab({ element }), buildTransformTab({ element }),
buildBlendingTab({ element }), buildBlendingTab({ element }),
buildClipEffectsTab({ element }), buildClipEffectsTab({ element }),
], ],
}; };
} }
function getGraphicConfig({ function getGraphicConfig({
element, element,
}: { }: {
element: GraphicElement; element: GraphicElement;
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
return { return {
defaultTab: "graphic", defaultTab: "graphic",
tabs: [ tabs: [
buildGraphicTab({ element }), buildGraphicTab({ element }),
buildTransformTab({ element }), buildTransformTab({ element }),
buildBlendingTab({ element }), buildBlendingTab({ element }),
buildMasksTab({ element }), buildMasksTab({ element }),
buildClipEffectsTab({ element }), buildClipEffectsTab({ element }),
], ],
}; };
} }
function getAudioConfig({ function getAudioConfig({
element, element,
}: { }: {
element: AudioElement; element: AudioElement;
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
return { return {
defaultTab: "audio", defaultTab: "audio",
tabs: [buildAudioTab({ element }), buildSpeedTab({ element })], tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
}; };
} }
function getEffectConfig({ function getEffectConfig({
element, element,
}: { }: {
element: EffectElement; element: EffectElement;
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
return { return {
defaultTab: "effects", defaultTab: "effects",
tabs: [buildStandaloneEffectTab({ element })], tabs: [buildStandaloneEffectTab({ element })],
}; };
} }
export function getPropertiesConfig({ export function getPropertiesConfig({
element, element,
mediaAssets, mediaAssets,
}: { }: {
element: TimelineElement; element: TimelineElement;
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
switch (element.type) { switch (element.type) {
case "text": case "text":
return getTextConfig({ element }); return getTextConfig({ element });
case "video": { case "video": {
const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId); const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
return getVideoConfig({ element, mediaAsset }); return getVideoConfig({ element, mediaAsset });
} }
case "image": case "image":
return getImageConfig({ element }); return getImageConfig({ element });
case "sticker": case "sticker":
return getStickerConfig({ element }); return getStickerConfig({ element });
case "graphic": case "graphic":
return getGraphicConfig({ element }); return getGraphicConfig({ element });
case "audio": case "audio":
return getAudioConfig({ element }); return getAudioConfig({ element });
case "effect": case "effect":
return getEffectConfig({ element }); return getEffectConfig({ element });
} }
} }
@@ -1,230 +1,230 @@
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { clamp } from "@/utils/math"; import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field"; import { NumberField } from "@/components/ui/number-field";
import { OcCheckerboardIcon } from "@/components/icons"; import { OcCheckerboardIcon } from "@opencut/ui/icons";
import { Fragment, useRef } from "react"; import { Fragment, useRef } from "react";
import { useMenuPreview } from "@/hooks/use-menu-preview"; import { useMenuPreview } from "@/hooks/use-menu-preview";
import { import {
Section, Section,
SectionContent, SectionContent,
SectionField, SectionField,
SectionHeader, SectionHeader,
SectionTitle, SectionTitle,
} from "@/components/section"; } from "@/components/section";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import type { BlendMode } from "@/lib/rendering"; import type { BlendMode } from "@/lib/rendering";
import type { ElementType } from "@/lib/timeline"; import type { ElementType } from "@/lib/timeline";
import type { ElementAnimations } from "@/lib/animation/types"; import type { ElementAnimations } from "@/lib/animation/types";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons"; import { RainDropIcon } from "@hugeicons/core-free-icons";
import { KeyframeToggle } from "../components/keyframe-toggle"; import { KeyframeToggle } from "../components/keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { useElementPlayhead } from "../hooks/use-element-playhead"; import { useElementPlayhead } from "../hooks/use-element-playhead";
import { resolveOpacityAtTime } from "@/lib/animation"; import { resolveOpacityAtTime } from "@/lib/animation";
import { DEFAULTS } from "@/lib/timeline/defaults"; import { DEFAULTS } from "@/lib/timeline/defaults";
import { isPropertyAtDefault } from "./transform-tab"; import { isPropertyAtDefault } from "./transform-tab";
type BlendingElement = { type BlendingElement = {
id: string; id: string;
opacity: number; opacity: number;
type: ElementType; type: ElementType;
blendMode?: BlendMode; blendMode?: BlendMode;
startTime: number; startTime: number;
duration: number; duration: number;
animations?: ElementAnimations; animations?: ElementAnimations;
}; };
const BLEND_MODE_GROUPS = [ const BLEND_MODE_GROUPS = [
[{ value: "normal", label: "Normal" }], [{ value: "normal", label: "Normal" }],
[ [
{ value: "darken", label: "Darken" }, { value: "darken", label: "Darken" },
{ value: "multiply", label: "Multiply" }, { value: "multiply", label: "Multiply" },
{ value: "color-burn", label: "Color Burn" }, { value: "color-burn", label: "Color Burn" },
], ],
[ [
{ value: "lighten", label: "Lighten" }, { value: "lighten", label: "Lighten" },
{ value: "screen", label: "Screen" }, { value: "screen", label: "Screen" },
{ value: "plus-lighter", label: "Plus Lighter" }, { value: "plus-lighter", label: "Plus Lighter" },
{ value: "color-dodge", label: "Color Dodge" }, { value: "color-dodge", label: "Color Dodge" },
], ],
[ [
{ value: "overlay", label: "Overlay" }, { value: "overlay", label: "Overlay" },
{ value: "soft-light", label: "Soft Light" }, { value: "soft-light", label: "Soft Light" },
{ value: "hard-light", label: "Hard Light" }, { value: "hard-light", label: "Hard Light" },
], ],
[ [
{ value: "difference", label: "Difference" }, { value: "difference", label: "Difference" },
{ value: "exclusion", label: "Exclusion" }, { value: "exclusion", label: "Exclusion" },
], ],
[ [
{ value: "hue", label: "Hue" }, { value: "hue", label: "Hue" },
{ value: "saturation", label: "Saturation" }, { value: "saturation", label: "Saturation" },
{ value: "color", label: "Color" }, { value: "color", label: "Color" },
{ value: "luminosity", label: "Luminosity" }, { value: "luminosity", label: "Luminosity" },
], ],
]; ];
export function BlendingTab({ export function BlendingTab({
element, element,
trackId, trackId,
}: { }: {
element: BlendingElement; element: BlendingElement;
trackId: string; trackId: string;
}) { }) {
const editor = useEditor(); const editor = useEditor();
const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive()); const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
const blendMode = element.blendMode ?? DEFAULTS.element.blendMode; const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
const committedBlendModeRef = useRef(blendMode); const committedBlendModeRef = useRef(blendMode);
if (!isPreviewActive) { if (!isPreviewActive) {
committedBlendModeRef.current = blendMode; committedBlendModeRef.current = blendMode;
} }
const { const {
onPointerLeave, onPointerLeave,
onOpenChange: handleBlendModeOpenChange, onOpenChange: handleBlendModeOpenChange,
markCommitted, markCommitted,
} = useMenuPreview(); } = useMenuPreview();
const previewBlendMode = ({ value }: { value: BlendMode }) => const previewBlendMode = ({ value }: { value: BlendMode }) =>
editor.timeline.previewElements({ editor.timeline.previewElements({
updates: [ updates: [
{ trackId, elementId: element.id, updates: { blendMode: value } }, { trackId, elementId: element.id, updates: { blendMode: value } },
], ],
}); });
const commitBlendMode = (value: string) => { const commitBlendMode = (value: string) => {
if (editor.timeline.isPreviewActive()) { if (editor.timeline.isPreviewActive()) {
editor.timeline.commitPreview(); editor.timeline.commitPreview();
} else { } else {
editor.timeline.updateElements({ editor.timeline.updateElements({
updates: [ updates: [
{ {
trackId, trackId,
elementId: element.id, elementId: element.id,
updates: { blendMode: value as BlendMode }, updates: { blendMode: value as BlendMode },
}, },
], ],
}); });
} }
markCommitted(); markCommitted();
}; };
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime, startTime: element.startTime,
duration: element.duration, duration: element.duration,
}); });
const resolvedOpacity = resolveOpacityAtTime({ const resolvedOpacity = resolveOpacityAtTime({
baseOpacity: element.opacity, baseOpacity: element.opacity,
animations: element.animations, animations: element.animations,
localTime, localTime,
}); });
const opacity = useKeyframedNumberProperty({ const opacity = useKeyframedNumberProperty({
trackId, trackId,
elementId: element.id, elementId: element.id,
animations: element.animations, animations: element.animations,
propertyPath: "opacity", propertyPath: "opacity",
localTime, localTime,
isPlayheadWithinElementRange, isPlayheadWithinElementRange,
displayValue: Math.round(resolvedOpacity * 100).toString(), displayValue: Math.round(resolvedOpacity * 100).toString(),
parse: (input) => { parse: (input) => {
const parsed = parseFloat(input); const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null; if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min: 0, max: 100 }) / 100; return clamp({ value: parsed, min: 0, max: 100 }) / 100;
}, },
valueAtPlayhead: resolvedOpacity, valueAtPlayhead: resolvedOpacity,
step: 0.01, step: 0.01,
buildBaseUpdates: ({ value }) => ({ opacity: value }), buildBaseUpdates: ({ value }) => ({ opacity: value }),
}); });
return ( return (
<Section collapsible sectionKey={`${element.id}:blending`}> <Section collapsible sectionKey={`${element.id}:blending`}>
<SectionHeader> <SectionHeader>
<SectionTitle>Blending</SectionTitle> <SectionTitle>Blending</SectionTitle>
</SectionHeader> </SectionHeader>
<SectionContent> <SectionContent>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<SectionField <SectionField
label="Opacity" label="Opacity"
className="w-1/2" className="w-1/2"
beforeLabel={ beforeLabel={
<KeyframeToggle <KeyframeToggle
isActive={opacity.isKeyframedAtTime} isActive={opacity.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange} isDisabled={!isPlayheadWithinElementRange}
title="Toggle opacity keyframe" title="Toggle opacity keyframe"
onToggle={opacity.toggleKeyframe} onToggle={opacity.toggleKeyframe}
/> />
} }
> >
<NumberField <NumberField
className="w-full" className="w-full"
icon={ icon={
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
} }
value={opacity.displayValue} value={opacity.displayValue}
min={0} min={0}
max={100} max={100}
onFocus={opacity.onFocus} onFocus={opacity.onFocus}
onChange={opacity.onChange} onChange={opacity.onChange}
onBlur={opacity.onBlur} onBlur={opacity.onBlur}
onScrub={opacity.scrubTo} onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub} onScrubEnd={opacity.commitScrub}
onReset={() => onReset={() =>
opacity.commitValue({ value: DEFAULTS.element.opacity }) opacity.commitValue({ value: DEFAULTS.element.opacity })
} }
isDefault={isPropertyAtDefault({ isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
isPlayheadWithinElementRange, isPlayheadWithinElementRange,
resolvedValue: resolvedOpacity, resolvedValue: resolvedOpacity,
staticValue: element.opacity, staticValue: element.opacity,
defaultValue: DEFAULTS.element.opacity, defaultValue: DEFAULTS.element.opacity,
})} })}
dragSensitivity="slow" dragSensitivity="slow"
/> />
</SectionField> </SectionField>
<SectionField label="Blend mode" className="w-1/2"> <SectionField label="Blend mode" className="w-1/2">
<Select <Select
value={committedBlendModeRef.current} value={committedBlendModeRef.current}
onOpenChange={handleBlendModeOpenChange} onOpenChange={handleBlendModeOpenChange}
onValueChange={commitBlendMode} onValueChange={commitBlendMode}
> >
<SelectTrigger <SelectTrigger
icon={<HugeiconsIcon icon={RainDropIcon} />} icon={<HugeiconsIcon icon={RainDropIcon} />}
className="w-full" className="w-full"
> >
<SelectValue placeholder="Select blend mode" /> <SelectValue placeholder="Select blend mode" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="w-36" onPointerLeave={onPointerLeave}> <SelectContent className="w-36" onPointerLeave={onPointerLeave}>
{BLEND_MODE_GROUPS.map((group, groupIndex) => ( {BLEND_MODE_GROUPS.map((group, groupIndex) => (
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}> <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
{group.map((option) => ( {group.map((option) => (
<SelectItem <SelectItem
key={option.value} key={option.value}
value={option.value} value={option.value}
onPointerEnter={() => onPointerEnter={() =>
previewBlendMode({ value: option.value as BlendMode }) previewBlendMode({ value: option.value as BlendMode })
} }
> >
{option.label} {option.label}
</SelectItem> </SelectItem>
))} ))}
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
<SelectSeparator /> <SelectSeparator />
) : null} ) : null}
</Fragment> </Fragment>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</SectionField> </SectionField>
</div> </div>
</SectionContent> </SectionContent>
</Section> </Section>
); );
} }
@@ -53,7 +53,7 @@ import {
SectionTitle, SectionTitle,
} from "@/components/section"; } from "@/components/section";
import { usePropertyDraft } from "../hooks/use-property-draft"; import { usePropertyDraft } from "../hooks/use-property-draft";
import { OcMirrorIcon, OcShapesIcon } from "@/components/icons"; import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
type MasksTabProps = { type MasksTabProps = {
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,7 @@ import {
VolumeOffIcon, VolumeOffIcon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import { OcShapesIcon, OcVideoIcon } from "@/components/icons"; import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/icons";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@@ -49,10 +49,10 @@ import {
getTotalTracksHeight, getTotalTracksHeight,
canTracktHaveAudio, canTracktHaveAudio,
canTrackBeHidden, canTrackBeHidden,
isMainTrack,
getTimelineZoomMin, getTimelineZoomMin,
getTimelinePaddingPx, getTimelinePaddingPx,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { isMainTrack } from "@/lib/track-placement";
import { TimelineToolbar } from "./timeline-toolbar"; import { TimelineToolbar } from "./timeline-toolbar";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
@@ -566,7 +566,7 @@ function KeyframeIndicators({
<HugeiconsIcon <HugeiconsIcon
icon={KeyframeIcon} icon={KeyframeIcon}
className={cn( className={cn(
"size-3.5 text-black", "size-3.5 mt-1.5 text-black",
isIndicatorSelected ? "fill-primary" : "fill-white", isIndicatorSelected ? "fill-primary" : "fill-white",
)} )}
strokeWidth={1.5} strokeWidth={1.5}
@@ -34,10 +34,8 @@ import {
AlignLeftIcon, AlignLeftIcon,
AlignRightIcon, AlignRightIcon,
Layers01Icon, Layers01Icon,
Chart03Icon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { OcRippleIcon } from "@/components/icons";
export function TimelineToolbar({ export function TimelineToolbar({
zoomLevel, zoomLevel,
@@ -158,14 +156,6 @@ function ToolbarLeftSection() {
} }
/> />
</Tooltip> </Tooltip>
<Tooltip>
<ToolbarButton
icon={<HugeiconsIcon icon={Chart03Icon} />}
tooltip="Graph"
onClick={() => {}}
/>
</Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>
); );
@@ -217,7 +207,7 @@ function ToolbarRightSection({
/> />
<ToolbarButton <ToolbarButton
icon={<OcRippleIcon size={24} className="scale-110" />} icon={<HugeiconsIcon icon={Link04Icon} className="scale-110" />}
isActive={rippleEditingEnabled} isActive={rippleEditingEnabled}
tooltip="Ripple editing" tooltip="Ripple editing"
onClick={() => toggleRippleEditing()} onClick={() => toggleRippleEditing()}
-5
View File
@@ -1,5 +0,0 @@
export interface IconProps {
size?: number;
className?: string;
strokeWidth?: number;
}
+21 -51
View File
@@ -1,20 +1,9 @@
"use client"; "use client";
import { import { type PropsWithChildren, useEffect, useRef, useState } from "react";
type PropsWithChildren,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
type HandlebarsProps = PropsWithChildren; 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) { export function Handlebars({ children }: HandlebarsProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const leftHandleRef = useRef<HTMLDivElement>(null); const leftHandleRef = useRef<HTMLDivElement>(null);
@@ -42,18 +31,18 @@ export function Handlebars({ children }: HandlebarsProps) {
initialPosition: 0, initialPosition: 0,
}); });
useLayoutEffect(() => { useEffect(() => {
const container = containerRef.current; const el = containerRef.current;
if (!container) return; if (!el) return;
const updateWidth = () => { const updateWidth = () => {
const newWidth = container.offsetWidth; const newWidth = el.offsetWidth;
setWidth(newWidth); setWidth(newWidth);
setRightHandle(newWidth); setRightHandle(newWidth);
}; };
const observer = new ResizeObserver(updateWidth); const observer = new ResizeObserver(updateWidth);
observer.observe(container); observer.observe(el);
updateWidth(); updateWidth();
return () => observer.disconnect(); return () => observer.disconnect();
@@ -77,10 +66,7 @@ export function Handlebars({ children }: HandlebarsProps) {
const deltaX = event.clientX - startX; const deltaX = event.clientX - startX;
if (side === "left") { if (side === "left") {
const maxLeft = Math.max( const maxLeft = Math.max(0, rightHandlePositionRef.current - 60);
0,
rightHandlePositionRef.current - MIN_HANDLE_SEPARATION_PX,
);
const nextLeftHandle = Math.max( const nextLeftHandle = Math.max(
0, 0,
Math.min(maxLeft, initialPosition + deltaX), Math.min(maxLeft, initialPosition + deltaX),
@@ -91,7 +77,7 @@ export function Handlebars({ children }: HandlebarsProps) {
const minRight = Math.min( const minRight = Math.min(
widthRef.current, widthRef.current,
leftHandlePositionRef.current + MIN_HANDLE_SEPARATION_PX, leftHandlePositionRef.current + 60,
); );
const nextRightHandle = Math.max( const nextRightHandle = Math.max(
minRight, minRight,
@@ -120,30 +106,13 @@ export function Handlebars({ children }: HandlebarsProps) {
}; };
}, []); }, []);
const hasMeasuredWidth = width > 0; const leftGradientPercent = width > 0 ? (leftHandle / (width - 10)) * 100 : 0;
const leftGradientPercent = hasMeasuredWidth const rightGradientPercent =
? (leftHandle / (width - MASK_GRADIENT_EDGE_PADDING_PX)) * 100 width > 0 ? (rightHandle / (width + 10)) * 100 : 0;
: 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 ( return (
<div className="flex justify-center gap-4 leading-16"> <div className="flex justify-center gap-4 leading-16">
<div <div ref={containerRef} className="relative mt-0.5 -rotate-[2.76deg]">
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 className="absolute inset-0 z-10 flex size-full justify-between rounded-2xl border border-yellow-500">
<div <div
ref={leftHandleRef} ref={leftHandleRef}
@@ -166,13 +135,9 @@ export function Handlebars({ children }: HandlebarsProps) {
<div <div
ref={rightHandleRef} ref={rightHandleRef}
className="bg-background absolute z-20 flex h-full w-7 cursor-ew-resize touch-none items-center justify-center rounded-full border border-yellow-500 select-none" className="bg-background absolute -left-[30px] z-20 flex h-full w-7 cursor-ew-resize touch-none items-center justify-center rounded-full border border-yellow-500 select-none"
style={{ style={{
left: hasMeasuredWidth translate: `${rightHandle}px 0`,
? `${RIGHT_HANDLE_LEFT_OFFSET_PX}px`
: undefined,
right: hasMeasuredWidth ? undefined : "0px",
translate: hasMeasuredWidth ? `${rightHandle}px 0` : undefined,
}} }}
onPointerDown={(event) => { onPointerDown={(event) => {
event.preventDefault(); event.preventDefault();
@@ -191,8 +156,13 @@ export function Handlebars({ children }: HandlebarsProps) {
<span <span
className="relative z-0 inline-flex size-full items-center justify-center rounded-2xl px-9 will-change-auto" className="relative z-0 inline-flex size-full items-center justify-center rounded-2xl px-9 will-change-auto"
style={{ style={{
mask: textMask, mask: `linear-gradient(90deg,
WebkitMask: textMask, rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${leftGradientPercent}%,
rgba(0, 0, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) ${rightGradientPercent}%,
rgba(255, 255, 255, 0) 100%)`,
}} }}
> >
{children} {children}
+69 -69
View File
@@ -1,69 +1,69 @@
import { OcDataBuddyIcon, OcMarbleIcon } from "@/components/icons"; import { OcDataBuddyIcon, OcMarbleIcon } from "@opencut/ui/icons";
export const SITE_URL = "https://opencut.app"; export const SITE_URL = "https://opencut.app";
export const SITE_INFO = { export const SITE_INFO = {
title: "OpenCut", title: "OpenCut",
description: description:
"A simple but powerful video editor that gets the job done. In your browser.", "A simple but powerful video editor that gets the job done. In your browser.",
url: SITE_URL, url: SITE_URL,
openGraphImage: "/open-graph/default.jpg", openGraphImage: "/open-graph/default.jpg",
twitterImage: "/open-graph/default.jpg", twitterImage: "/open-graph/default.jpg",
favicon: "/favicon.ico", favicon: "/favicon.ico",
}; };
export type ExternalTool = { export type ExternalTool = {
name: string; name: string;
description: string; description: string;
url: string; url: string;
icon: React.ElementType; icon: React.ElementType;
}; };
export const EXTERNAL_TOOLS: ExternalTool[] = [ export const EXTERNAL_TOOLS: ExternalTool[] = [
{ {
name: "Marble", name: "Marble",
description: description:
"Modern headless CMS for content management and the blog for OpenCut", "Modern headless CMS for content management and the blog for OpenCut",
url: "https://marblecms.com?utm_source=opencut", url: "https://marblecms.com?utm_source=opencut",
icon: OcMarbleIcon, icon: OcMarbleIcon,
}, },
{ {
name: "Databuddy", name: "Databuddy",
description: "GDPR compliant analytics and user insights for OpenCut", description: "GDPR compliant analytics and user insights for OpenCut",
url: "https://databuddy.cc?utm_source=opencut", url: "https://databuddy.cc?utm_source=opencut",
icon: OcDataBuddyIcon, icon: OcDataBuddyIcon,
}, },
]; ];
export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg"; export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg";
export const SOCIAL_LINKS = { export const SOCIAL_LINKS = {
x: "https://x.com/opencutapp", x: "https://x.com/opencutapp",
github: "https://github.com/OpenCut-app/OpenCut", github: "https://github.com/OpenCut-app/OpenCut",
discord: "https://discord.com/invite/Mu3acKZvCp", discord: "https://discord.com/invite/Mu3acKZvCp",
}; };
export type Sponsor = { export type Sponsor = {
name: string; name: string;
url: string; url: string;
logo: string; logo: string;
description: string; description: string;
invertOnDark?: boolean; invertOnDark?: boolean;
}; };
export const SPONSORS: Sponsor[] = [ export const SPONSORS: Sponsor[] = [
{ {
name: "Fal.ai", name: "Fal.ai",
url: "https://fal.ai?utm_source=opencut", url: "https://fal.ai?utm_source=opencut",
logo: "/logos/others/fal.svg", logo: "/logos/others/fal.svg",
description: "Generative image, video, and audio models all in one place.", description: "Generative image, video, and audio models all in one place.",
invertOnDark: true, invertOnDark: true,
}, },
{ {
name: "Vercel", name: "Vercel",
url: "https://vercel.com?utm_source=opencut", url: "https://vercel.com?utm_source=opencut",
logo: "/logos/others/vercel.svg", logo: "/logos/others/vercel.svg",
description: "Platform where we deploy and host OpenCut.", description: "Platform where we deploy and host OpenCut.",
invertOnDark: true, invertOnDark: true,
}, },
]; ];
+11 -1
View File
@@ -1,4 +1,4 @@
import type { TrackType } from "@/lib/timeline"; import type { ElementType, TrackType } from "@/lib/timeline";
export const TRACK_CONFIG: Record< export const TRACK_CONFIG: Record<
TrackType, TrackType,
@@ -46,6 +46,16 @@ export const ELEMENT_TYPE_CONFIG: Record<
effect: { background: "bg-[#5d93ba]" }, effect: { background: "bg-[#5d93ba]" },
} as const; } 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_GAP = 6;
export const TRACK_LABELS_WIDTH_PX = 112; export const TRACK_LABELS_WIDTH_PX = 112;
@@ -51,8 +51,6 @@ export const TRANSCRIPTION_MODELS: TranscriptionModel[] = [
export const DEFAULT_TRANSCRIPTION_MODEL: TranscriptionModelId = export const DEFAULT_TRANSCRIPTION_MODEL: TranscriptionModelId =
"whisper-small"; "whisper-small";
export const DEFAULT_TRANSCRIPTION_SAMPLE_RATE = 16000;
export const DEFAULT_CHUNK_LENGTH_SECONDS = 30; export const DEFAULT_CHUNK_LENGTH_SECONDS = 30;
export const DEFAULT_STRIDE_SECONDS = 5; export const DEFAULT_STRIDE_SECONDS = 5;
+1 -1
View File
@@ -12,7 +12,7 @@ import {
getFrameTime, getFrameTime,
isBookmarkAtTime, isBookmarkAtTime,
} from "@/lib/timeline/bookmarks"; } from "@/lib/timeline/bookmarks";
import { ensureMainTrack } from "@/lib/track-placement"; import { ensureMainTrack } from "@/lib/timeline/track-utils";
import { import {
CreateSceneCommand, CreateSceneCommand,
DeleteSceneCommand, DeleteSceneCommand,
@@ -16,7 +16,7 @@ import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands"; import { BatchCommand } from "@/lib/commands";
import { computeDropTarget } from "@/lib/timeline/drop-utils"; import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getDragData, hasDragData } from "@/lib/drag-data"; import { getDragData, hasDragData } from "@/lib/drag-data";
import { isMainTrack } from "@/lib/track-placement"; import { isMainTrack } from "@/lib/timeline/track-utils";
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline"; import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
import type { import type {
MediaDragData, MediaDragData,
+6 -6
View File
@@ -1,6 +1,6 @@
import { createAuthClient } from "better-auth/react"; import { createAuthClient } from "better-auth/react";
import { webEnv } from "@/lib/env/web"; import { webEnv } from "@opencut/env/web";
export const { signIn, signUp, useSession } = createAuthClient({ export const { signIn, signUp, useSession } = createAuthClient({
baseURL: webEnv.NEXT_PUBLIC_SITE_URL, baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
}); });
+43 -43
View File
@@ -1,43 +1,43 @@
import { betterAuth, type RateLimit } from "better-auth"; import { betterAuth, type RateLimit } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { Redis } from "@upstash/redis"; import { Redis } from "@upstash/redis";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { webEnv } from "@/lib/env/web"; import { webEnv } from "@opencut/env/web";
const redis = new Redis({ const redis = new Redis({
url: webEnv.UPSTASH_REDIS_REST_URL, url: webEnv.UPSTASH_REDIS_REST_URL,
token: webEnv.UPSTASH_REDIS_REST_TOKEN, token: webEnv.UPSTASH_REDIS_REST_TOKEN,
}); });
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "pg", provider: "pg",
usePlural: true, usePlural: true,
}), }),
secret: webEnv.BETTER_AUTH_SECRET, secret: webEnv.BETTER_AUTH_SECRET,
user: { user: {
deleteUser: { deleteUser: {
enabled: true, enabled: true,
}, },
}, },
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
rateLimit: { rateLimit: {
storage: "secondary-storage", storage: "secondary-storage",
customStorage: { customStorage: {
get: async (key) => { get: async (key) => {
const value = await redis.get(key); const value = await redis.get(key);
return value as RateLimit | undefined; return value as RateLimit | undefined;
}, },
set: async (key, value) => { set: async (key, value) => {
await redis.set(key, value); await redis.set(key, value);
}, },
}, },
}, },
baseURL: webEnv.NEXT_PUBLIC_SITE_URL, baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
appName: "OpenCut", appName: "OpenCut",
trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL], trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
}); });
export type Auth = typeof auth; export type Auth = typeof auth;
@@ -6,12 +6,13 @@ import type {
ClipboardItem, ClipboardItem,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import { import {
applyPlacement, buildEmptyTrack,
resolveTrackPlacement, getHighestInsertIndexForTrack,
isMainTrack, isMainTrack,
enforceMainTrackStart, enforceMainTrackStart,
} from "@/lib/track-placement"; } from "@/lib/timeline/track-utils";
import { cloneAnimations } from "@/lib/animation"; import { cloneAnimations } from "@/lib/animation";
export class PasteCommand extends Command { export class PasteCommand extends Command {
@@ -58,22 +59,17 @@ export class PasteCommand extends Command {
const sourceTrackIndex = updatedTracks.findIndex( const sourceTrackIndex = updatedTracks.findIndex(
(track) => track.id === trackId, (track) => track.id === trackId,
); );
const placementResult = resolveTrackPlacement({ const resolvedTargetIndex = resolveTargetTrackIndex({
tracks: updatedTracks, tracks: updatedTracks,
sourceTrackIndex,
trackType, trackType,
timeSpans: elementsToAdd.map((element) => ({ elements: elementsToAdd,
startTime: element.startTime,
duration: element.duration,
})),
strategy: { type: "aboveSource", sourceTrackIndex },
}); });
if (!placementResult) {
continue;
}
let elementsForPlacement = elementsToAdd; if (resolvedTargetIndex >= 0) {
if (placementResult.kind === "existingTrack") { const targetTrack = updatedTracks[resolvedTargetIndex];
const targetTrack = updatedTracks[placementResult.trackIndex]; let adjustedElements = elementsToAdd;
if (isMainTrack(targetTrack)) { if (isMainTrack(targetTrack)) {
const earliestElement = elementsToAdd.reduce((earliest, element) => const earliestElement = elementsToAdd.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest, element.startTime < earliest.startTime ? element : earliest,
@@ -86,28 +82,39 @@ export class PasteCommand extends Command {
const delta = adjustedEarliestStartTime - earliestElement.startTime; const delta = adjustedEarliestStartTime - earliestElement.startTime;
if (delta !== 0) { if (delta !== 0) {
elementsForPlacement = elementsToAdd.map((element) => ({ adjustedElements = elementsToAdd.map((element) => ({
...element, ...element,
startTime: Math.max(0, element.startTime + delta), startTime: Math.max(0, element.startTime + delta),
})); }));
} }
} }
}
const applied = applyPlacement({ updatedTracks[resolvedTargetIndex] = {
tracks: updatedTracks, ...targetTrack,
placementResult, elements: [...targetTrack.elements, ...adjustedElements],
elements: elementsForPlacement, } as TimelineTrack;
}); for (const element of adjustedElements) {
if (!applied) { this.pastedElements.push({
trackId: targetTrack.id,
elementId: element.id,
});
}
continue; continue;
} }
updatedTracks = applied.updatedTracks; const insertIndex = resolveInsertIndexForNewTrack({
tracks: updatedTracks,
for (const element of elementsForPlacement) { sourceTrackIndex,
trackType,
});
const newTrack = buildTrackWithElements({
trackType,
elements: elementsToAdd,
});
updatedTracks.splice(insertIndex, 0, newTrack);
for (const element of elementsToAdd) {
this.pastedElements.push({ this.pastedElements.push({
trackId: applied.targetTrackId, trackId: newTrack.id,
elementId: element.id, elementId: element.id,
}); });
} }
@@ -180,3 +187,107 @@ function buildPastedElements({
return elementsToAdd; return elementsToAdd;
} }
function resolveTargetTrackIndex({
tracks,
sourceTrackIndex,
trackType,
elements,
}: {
tracks: TimelineTrack[];
sourceTrackIndex: number;
trackType: ClipboardItem["trackType"];
elements: TimelineElement[];
}): number {
if (sourceTrackIndex >= 0) {
const aboveIndex = sourceTrackIndex - 1;
if (aboveIndex < 0) {
return -1;
}
const aboveTrack = tracks[aboveIndex];
if (aboveTrack.type !== trackType) {
return -1;
}
const canPlaceOnAbove = canPlaceElementsOnTrack({
track: aboveTrack,
elements,
});
return canPlaceOnAbove ? aboveIndex : -1;
}
const highestCompatibleIndex = tracks.findIndex(
(track) => track.type === trackType,
);
if (highestCompatibleIndex < 0) {
return -1;
}
const highestCompatibleTrack = tracks[highestCompatibleIndex];
const canPlaceOnHighest = canPlaceElementsOnTrack({
track: highestCompatibleTrack,
elements,
});
return canPlaceOnHighest ? highestCompatibleIndex : -1;
}
function resolveInsertIndexForNewTrack({
tracks,
sourceTrackIndex,
trackType,
}: {
tracks: TimelineTrack[];
sourceTrackIndex: number;
trackType: ClipboardItem["trackType"];
}): number {
if (sourceTrackIndex >= 0) {
return sourceTrackIndex;
}
const highestCompatibleIndex = tracks.findIndex(
(track) => track.type === trackType,
);
if (highestCompatibleIndex >= 0) {
return highestCompatibleIndex;
}
return getHighestInsertIndexForTrack({ tracks, trackType });
}
function canPlaceElementsOnTrack({
track,
elements,
}: {
track: TimelineTrack;
elements: TimelineElement[];
}): boolean {
for (const element of elements) {
const endTime = element.startTime + element.duration;
const hasOverlap = wouldElementOverlap({
elements: track.elements,
startTime: element.startTime,
endTime,
});
if (hasOverlap) {
return false;
}
}
return true;
}
function buildTrackWithElements({
trackType,
elements,
}: {
trackType: ClipboardItem["trackType"];
elements: TimelineElement[];
}): TimelineTrack {
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({ id: newTrackId, type: trackType });
return {
...newTrackBase,
elements,
} as TimelineTrack;
}
@@ -1,8 +1,7 @@
import { Command } from "@/lib/commands/base-command"; import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline"; import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { rippleShiftElements } from "@/lib/timeline"; import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
import { isMainTrack } from "@/lib/track-placement";
export class DeleteElementsCommand extends Command { export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
@@ -2,7 +2,10 @@ import { Command } from "@/lib/commands/base-command";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { applyPlacement, resolveTrackPlacement } from "@/lib/track-placement"; import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
import { cloneAnimations } from "@/lib/animation"; import { cloneAnimations } from "@/lib/animation";
interface DuplicateElementsParams { interface DuplicateElementsParams {
@@ -42,12 +45,22 @@ export class DuplicateElementsCommand extends Command {
); );
const newTrackElements: TimelineElement[] = []; const newTrackElements: TimelineElement[] = [];
const newTrackId = generateUUID();
const newTrackBase = buildEmptyTrack({
id: newTrackId,
type: track.type,
});
for (const element of track.elements) { for (const element of track.elements) {
if (!elementIdsToDuplicate.has(element.id)) { if (!elementIdsToDuplicate.has(element.id)) {
continue; continue;
} }
const newId = generateUUID(); const newId = generateUUID();
this.duplicatedElements.push({
trackId: newTrackId,
elementId: newId,
});
newTrackElements.push( newTrackElements.push(
buildDuplicateElement({ buildDuplicateElement({
element, element,
@@ -57,33 +70,16 @@ export class DuplicateElementsCommand extends Command {
); );
} }
const placementResult = resolveTrackPlacement({ const newTrack = {
...newTrackBase,
elements: newTrackElements,
} as TimelineTrack;
const insertIndex = getHighestInsertIndexForTrack({
tracks: updatedTracks, tracks: updatedTracks,
trackType: track.type, trackType: track.type,
timeSpans: [],
strategy: { type: "alwaysNew", position: "highest" },
}); });
if (!placementResult || placementResult.kind !== "newTrack") { updatedTracks.splice(insertIndex, 0, 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); editor.timeline.updateTracks(updatedTracks);
@@ -5,18 +5,23 @@ import type {
TimelineTrack, TimelineTrack,
TimelineElement, TimelineElement,
TrackType, TrackType,
ElementType,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { requiresMediaId } from "@/lib/timeline/element-utils";
import type { MediaAsset } from "@/lib/media/types";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
import { import {
applyPlacement, requiresMediaId,
wouldElementOverlap,
} from "@/lib/timeline/element-utils";
import {
buildEmptyTrack,
canElementGoOnTrack, canElementGoOnTrack,
resolveTrackPlacement, getDefaultInsertIndexForTrack,
validateElementTrackCompatibility, validateElementTrackCompatibility,
} from "@/lib/track-placement"; enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
import type { MediaAsset } from "@/lib/media/types";
import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
type InsertElementPlacement = type InsertElementPlacement =
| { mode: "explicit"; trackId: string } | { mode: "explicit"; trackId: string }
@@ -62,7 +67,7 @@ export class InsertElementCommand extends Command {
const isFirstElement = totalElementsInTimeline === 0; const isFirstElement = totalElementsInTimeline === 0;
const newElement = this.buildElement({ element: this.element }); const newElement = this.buildElement({ element: this.element });
const updateResult = this.applyPlacementResult({ const updateResult = this.resolveTracksWithElement({
tracks: this.savedState, tracks: this.savedState,
element: newElement, element: newElement,
}); });
@@ -185,7 +190,7 @@ export class InsertElementCommand extends Command {
return true; return true;
} }
private applyPlacementResult({ private resolveTracksWithElement({
tracks, tracks,
element, element,
}: { }: {
@@ -194,71 +199,160 @@ export class InsertElementCommand extends Command {
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null { }): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
const placement = this.placement; 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 ( if (
placement.mode === "auto" &&
placement.trackType && placement.trackType &&
!canElementGoOnTrack({ !canElementGoOnTrack({
elementType: element.type, elementType: element.type,
trackType: placement.trackType, trackType,
}) })
) { ) {
console.error( console.error(
`${element.type} elements cannot be placed on ${placement.trackType} tracks`, `${element.type} elements cannot be placed on ${trackType} tracks`,
); );
return null; return null;
} }
const placementResult = resolveTrackPlacement({ const elementEndTime = element.startTime + element.duration;
tracks, const existingTrack = tracks.find((track) => {
...(placement.mode === "auto" && placement.trackType if (
? { trackType: placement.trackType } !canElementGoOnTrack({
: { elementType: element.type }), elementType: element.type,
timeSpans: [ trackType: track.type,
{ })
startTime: element.startTime, ) {
duration: element.duration, return false;
},
],
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 null; return !wouldElementOverlap({
elements: track.elements,
startTime: element.startTime,
endTime: elementEndTime,
});
});
if (existingTrack) {
const adjustedElement = this.adjustElementForMainTrack({
tracks,
targetTrackId: existingTrack.id,
element,
});
const updatedTracks = tracks.map((track) =>
track.id === existingTrack.id
? {
...track,
elements: [...track.elements, adjustedElement],
}
: track,
) as TimelineTrack[];
return { updatedTracks, targetTrackId: existingTrack.id };
} }
const elementToPlace = const newTrackId = generateUUID();
placementResult.kind === "existingTrack" const newTrack = buildEmptyTrack({
? { id: newTrackId,
...element, type: trackType,
startTime: });
placementResult.adjustedStartTime ?? element.startTime, const newTrackWithElement = {
} ...newTrack,
: element; elements: [...newTrack.elements, element],
} as TimelineTrack;
return applyPlacement({ const updatedTracks = [...tracks];
const insertIndex =
placement.insertIndex ??
this.getAutoInsertIndex({ tracks: updatedTracks, trackType });
updatedTracks.splice(insertIndex, 0, newTrackWithElement);
return { updatedTracks, targetTrackId: newTrackId };
}
private getAutoInsertIndex({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
if (trackType === "text") {
const firstVideoTrackIndex = tracks.findIndex(
(track) => track.type === "video",
);
if (firstVideoTrackIndex >= 0) {
return firstVideoTrackIndex;
}
}
return getDefaultInsertIndexForTrack({
tracks, tracks,
placementResult, trackType,
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, isMainTrack,
validateElementTrackCompatibility, validateElementTrackCompatibility,
enforceMainTrackStart, enforceMainTrackStart,
} from "@/lib/track-placement"; } from "@/lib/timeline/track-utils";
import { rippleShiftElements } from "@/lib/timeline/ripple-utils"; import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
export class MoveElementCommand extends Command { export class MoveElementCommand extends Command {
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command"; import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline"; import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { enforceMainTrackStart } from "@/lib/track-placement"; import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
export class UpdateElementStartTimeCommand extends Command { export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
@@ -3,7 +3,7 @@ import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation"; import { clampAnimationsToDuration } from "@/lib/animation";
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline"; import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
import { enforceMainTrackStart } from "@/lib/track-placement"; import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
export class UpdateElementTrimCommand extends Command { export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
@@ -5,7 +5,7 @@ import { EditorCore } from "@/core";
import { import {
buildEmptyTrack, buildEmptyTrack,
getDefaultInsertIndexForTrack, getDefaultInsertIndexForTrack,
} from "@/lib/track-placement"; } from "@/lib/timeline/track-utils";
export class AddTrackCommand extends Command { export class AddTrackCommand extends Command {
private trackId: string; private trackId: string;
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command"; import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TimelineTrack } from "@/lib/timeline"; import type { TimelineTrack } from "@/lib/timeline";
import { getMainTrack } from "@/lib/track-placement"; import { getMainTrack } from "@/lib/timeline";
export class RemoveTrackCommand extends Command { export class RemoveTrackCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: TimelineTrack[] | null = null;
+1 -1
View File
@@ -1,7 +1,7 @@
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import * as schema from "./schema"; import * as schema from "./schema";
import { webEnv } from "@/lib/env/web"; import { webEnv } from "@opencut/env/web";
let _db: ReturnType<typeof drizzle> | null = null; let _db: ReturnType<typeof drizzle> | null = null;
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 { TimelineTrack, TimelineElement } from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types"; import type { MediaAsset } from "@/lib/media/types";
import { isMainTrack } from "@/lib/track-placement"; import { isMainTrack } from "@/lib/timeline";
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants"; import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics"; import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
import { measureTextElement } from "@/lib/text/measure-element"; import { measureTextElement } from "@/lib/text/measure-element";
+21 -21
View File
@@ -1,21 +1,21 @@
import { Ratelimit } from "@upstash/ratelimit"; import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis"; import { Redis } from "@upstash/redis";
import { webEnv } from "@/lib/env/web"; import { webEnv } from "@opencut/env/web";
const redis = new Redis({ const redis = new Redis({
url: webEnv.UPSTASH_REDIS_REST_URL, url: webEnv.UPSTASH_REDIS_REST_URL,
token: webEnv.UPSTASH_REDIS_REST_TOKEN, token: webEnv.UPSTASH_REDIS_REST_TOKEN,
}); });
export const baseRateLimit = new Ratelimit({ export const baseRateLimit = new Ratelimit({
redis, redis,
limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
analytics: true, analytics: true,
prefix: "rate-limit", prefix: "rate-limit",
}); });
export async function checkRateLimit({ request }: { request: Request }) { export async function checkRateLimit({ request }: { request: Request }) {
const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await baseRateLimit.limit(ip); const { success } = await baseRateLimit.limit(ip);
return { success, limited: !success }; return { success, limited: !success };
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import type { TScene } from "@/lib/timeline"; import type { TScene } from "@/lib/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/lib/timeline"; import { calculateTotalDuration } from "@/lib/timeline";
import { ensureMainTrack } from "@/lib/track-placement"; import { ensureMainTrack } from "@/lib/timeline/track-utils";
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null { export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
return scenes.find((scene) => scene.isMain) || null; return scenes.find((scene) => scene.isMain) || null;
+125 -68
View File
@@ -1,10 +1,16 @@
import type { import type {
TimelineTrack, TimelineTrack,
ElementType,
TimelineElement, TimelineElement,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants"; import { TRACK_CONFIG, TRACK_GAP } from "@/constants/timeline-constants";
import { wouldElementOverlap } from "./element-utils";
import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline"; import type { ComputeDropTargetParams, DropTarget } from "@/lib/timeline";
import { resolveTrackPlacement } from "@/lib/track-placement"; import {
canElementGoOnTrack,
isMainTrack,
enforceMainTrackStart,
} from "./track-utils";
function findElementAtPosition({ function findElementAtPosition({
mouseX, mouseX,
@@ -76,22 +82,57 @@ function getTrackAtY({
return null; return null;
} }
const EMPTY_TARGET_ELEMENT = null; function isCompatible({
elementType,
function fallbackNewTrackDropTarget({ trackType,
xPosition,
}: { }: {
xPosition: number; elementType: ElementType;
}): DropTarget { trackType: TimelineTrack["type"];
}): boolean {
return canElementGoOnTrack({ elementType, trackType });
}
function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
return tracks.findIndex((track) => isMainTrack(track));
}
function findInsertIndex({
elementType,
tracks,
preferredIndex,
insertAbove,
}: {
elementType: ElementType;
tracks: TimelineTrack[];
preferredIndex: number;
insertAbove: boolean;
}): { index: number; position: "above" | "below" } {
const mainTrackIndex = getMainTrackIndex({ tracks });
if (elementType === "audio") {
if (preferredIndex <= mainTrackIndex) {
return { index: mainTrackIndex + 1, position: "below" };
}
return {
index: insertAbove ? preferredIndex : preferredIndex + 1,
position: insertAbove ? "above" : "below",
};
}
const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1;
if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) {
return { index: mainTrackIndex, position: "above" };
}
return { return {
trackIndex: 0, index: overlayInsertIndex,
isNewTrack: true, position: insertAbove ? "above" : "below",
insertPosition: null,
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
}; };
} }
const EMPTY_TARGET_ELEMENT = null;
export function computeDropTarget({ export function computeDropTarget({
elementType, elementType,
mouseX, mouseX,
@@ -114,28 +155,22 @@ export function computeDropTarget({
? playheadTime ? playheadTime
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)); : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
if (tracks.length === 0) { const mainTrackIndex = getMainTrackIndex({ tracks });
const placementResult = resolveTrackPlacement({
tracks,
elementType,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex: 0,
hoverDirection: "below",
createNewTrackOnly: true,
},
});
const emptyTimelineResult =
placementResult?.kind === "newTrack" ? placementResult : null;
if (!emptyTimelineResult) {
return fallbackNewTrackDropTarget({ xPosition });
}
if (tracks.length === 0) {
if (elementType === "audio") {
return {
trackIndex: 0,
isNewTrack: true,
insertPosition: "below",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
}
return { return {
trackIndex: emptyTimelineResult.insertIndex, trackIndex: 0,
isNewTrack: true, isNewTrack: true,
insertPosition: emptyTimelineResult.insertPosition, insertPosition: null,
xPosition, xPosition,
targetElement: EMPTY_TARGET_ELEMENT, targetElement: EMPTY_TARGET_ELEMENT,
}; };
@@ -146,27 +181,30 @@ export function computeDropTarget({
if (!trackAtMouse) { if (!trackAtMouse) {
const isAboveAllTracks = mouseY < 0; const isAboveAllTracks = mouseY < 0;
const placementResult = resolveTrackPlacement({ if (elementType === "audio") {
tracks, return {
elementType, trackIndex: tracks.length,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], isNewTrack: true,
strategy: { insertPosition: "below",
type: "preferIndex", xPosition,
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1, targetElement: EMPTY_TARGET_ELEMENT,
hoverDirection: isAboveAllTracks ? "above" : "below", };
createNewTrackOnly: true, }
},
}); if (isAboveAllTracks) {
const outOfBoundsResult = return {
placementResult?.kind === "newTrack" ? placementResult : null; trackIndex: 0,
if (!outOfBoundsResult) { isNewTrack: true,
return fallbackNewTrackDropTarget({ xPosition }); insertPosition: "above",
xPosition,
targetElement: EMPTY_TARGET_ELEMENT,
};
} }
return { return {
trackIndex: outOfBoundsResult.insertIndex, trackIndex: Math.max(0, mainTrackIndex),
isNewTrack: true, isNewTrack: true,
insertPosition: outOfBoundsResult.insertPosition, insertPosition: "above",
xPosition, xPosition,
targetElement: EMPTY_TARGET_ELEMENT, targetElement: EMPTY_TARGET_ELEMENT,
}; };
@@ -199,27 +237,34 @@ export function computeDropTarget({
} }
const trackHeight = TRACK_CONFIG[track.type].height; const trackHeight = TRACK_CONFIG[track.type].height;
const placementResult = resolveTrackPlacement({ const isInUpperHalf = relativeY < trackHeight / 2;
tracks,
elementType,
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: {
type: "preferIndex",
trackIndex,
hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
verticalDragDirection,
},
});
if (!placementResult) {
return fallbackNewTrackDropTarget({ xPosition });
}
if (placementResult.kind === "existingTrack") { const isTrackCompatible = isCompatible({
const adjustedXPosition = elementType,
placementResult.adjustedStartTime ?? xPosition; trackType: track.type,
});
const endTime = xPosition + elementDuration;
const hasOverlap = wouldElementOverlap({
elements: track.elements,
startTime: xPosition,
endTime,
excludeElementId,
});
if (isTrackCompatible && !hasOverlap) {
const targetTrack = tracks[trackIndex];
// safe: snap to 0 only happens when element becomes the new earliest,
// meaning the space before the current earliest is empty
const adjustedXPosition = enforceMainTrackStart({
tracks,
targetTrackId: targetTrack.id,
requestedStartTime: xPosition,
excludeElementId,
});
return { return {
trackIndex: placementResult.trackIndex, trackIndex,
isNewTrack: false, isNewTrack: false,
insertPosition: null, insertPosition: null,
xPosition: adjustedXPosition, xPosition: adjustedXPosition,
@@ -227,10 +272,22 @@ export function computeDropTarget({
}; };
} }
let insertAbove = isInUpperHalf;
if (!isTrackCompatible && verticalDragDirection) {
insertAbove = verticalDragDirection === "up";
}
const { index, position } = findInsertIndex({
elementType,
tracks,
preferredIndex: trackIndex,
insertAbove,
});
return { return {
trackIndex: placementResult.insertIndex, trackIndex: index,
isNewTrack: true, isNewTrack: true,
insertPosition: placementResult.insertPosition, insertPosition: position,
xPosition, xPosition,
targetElement: EMPTY_TARGET_ELEMENT, targetElement: EMPTY_TARGET_ELEMENT,
}; };
@@ -86,6 +86,24 @@ export function requiresMediaId({
); );
} }
export function wouldElementOverlap({
elements,
startTime,
endTime,
excludeElementId,
}: {
elements: TimelineElement[];
startTime: number;
endTime: number;
excludeElementId?: string;
}): boolean {
return elements.some((element) => {
if (excludeElementId && element.id === excludeElementId) return false;
const elementEnd = element.startTime + element.duration;
return startTime < elementEnd && endTime > element.startTime;
});
}
function buildTextBackground( function buildTextBackground(
raw: Partial<TextBackground> | undefined, raw: Partial<TextBackground> | undefined,
): TextBackground { ): TextBackground {
@@ -1,462 +0,0 @@
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",
});
});
});
@@ -1,45 +0,0 @@
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { buildEmptyTrack } from "./track-factory";
import type { PlacementResult } from "./types";
export function applyPlacement({
tracks,
placementResult,
elements,
newTrackInsertIndexOverride,
}: {
tracks: TimelineTrack[];
placementResult: PlacementResult;
elements: TimelineElement[];
newTrackInsertIndexOverride?: number;
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
if (placementResult.kind === "existingTrack") {
const targetTrack = tracks[placementResult.trackIndex];
if (!targetTrack) {
return null;
}
const updatedTracks = tracks.map((track, trackIndex) =>
trackIndex === placementResult.trackIndex
? {
...track,
elements: [...track.elements, ...elements],
}
: track,
) as TimelineTrack[];
return { updatedTracks, targetTrackId: targetTrack.id };
}
const newTrackId = generateUUID();
const newTrack = {
...buildEmptyTrack({ id: newTrackId, type: placementResult.trackType }),
elements,
} as TimelineTrack;
const insertIndex =
newTrackInsertIndexOverride ?? placementResult.insertIndex;
const updatedTracks = [...tracks];
updatedTracks.splice(insertIndex, 0, newTrack);
return { updatedTracks, targetTrackId: newTrackId };
}
@@ -1,51 +0,0 @@
import type { ElementType, TrackType } from "@/lib/timeline";
const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = {
audio: "audio",
text: "text",
sticker: "graphic",
graphic: "graphic",
effect: "effect",
video: "video",
image: "video",
};
export function getTrackTypeForElementType({
elementType,
}: {
elementType: ElementType;
}): TrackType {
return ELEMENT_TRACK_MAP[elementType];
}
export function canElementGoOnTrack({
elementType,
trackType,
}: {
elementType: ElementType;
trackType: TrackType;
}): boolean {
return getTrackTypeForElementType({ elementType }) === trackType;
}
export function validateElementTrackCompatibility({
element,
track,
}: {
element: { type: ElementType };
track: { type: TrackType };
}): { isValid: boolean; errorMessage?: string } {
const isValid = canElementGoOnTrack({
elementType: element.type,
trackType: track.type,
});
if (!isValid) {
return {
isValid: false,
errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
};
}
return { isValid: true };
}
@@ -1,18 +0,0 @@
export { applyPlacement } from "./apply";
export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility";
export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index";
export {
enforceMainTrackStart,
ensureMainTrack,
getEarliestMainTrackElement,
getMainTrack,
isMainTrack,
} from "./main-track";
export { resolveTrackPlacement } from "./resolve";
export { buildEmptyTrack } from "./track-factory";
export type {
PlacementResult,
PlacementStrategy,
PlacementSubject,
PlacementTimeSpan,
} from "./types";
@@ -1,99 +0,0 @@
import type { TrackType, TimelineTrack } from "@/lib/timeline";
import { isMainTrack } from "./main-track";
export function getDefaultInsertIndexForTrack({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
if (trackType === "audio") {
return tracks.length;
}
if (trackType === "effect") {
return 0;
}
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (mainTrackIndex >= 0) {
return mainTrackIndex;
}
const firstAudioTrackIndex = tracks.findIndex((track) => track.type === "audio");
if (firstAudioTrackIndex >= 0) {
return firstAudioTrackIndex;
}
return tracks.length;
}
export function getHighestInsertIndexForTrack({
tracks,
trackType,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
}): number {
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (trackType === "audio") {
return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
}
return 0;
}
export function resolvePreferredNewTrackPlacement({
tracks,
trackType,
preferredIndex,
direction,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
preferredIndex: number;
direction: "above" | "below";
}): { insertIndex: number; insertPosition: "above" | "below" | null } {
if (tracks.length === 0) {
return {
insertIndex: 0,
insertPosition: trackType === "audio" ? "below" : null,
};
}
const safePreferredIndex = Math.min(
Math.max(preferredIndex, 0),
tracks.length - 1,
);
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (trackType === "audio") {
if (safePreferredIndex <= mainTrackIndex) {
return {
insertIndex: mainTrackIndex + 1,
insertPosition: "below",
};
}
return {
insertIndex:
direction === "above" ? safePreferredIndex : safePreferredIndex + 1,
insertPosition: direction,
};
}
const insertIndex =
direction === "above" ? safePreferredIndex : safePreferredIndex + 1;
if (mainTrackIndex >= 0 && insertIndex > mainTrackIndex) {
return {
insertIndex: mainTrackIndex,
insertPosition: "above",
};
}
return {
insertIndex,
insertPosition: direction,
};
}
@@ -1,96 +0,0 @@
import type { TimelineElement, TimelineTrack, VideoTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
const MAIN_TRACK_NAME = "Main Track";
export function isMainTrack(track: TimelineTrack): track is VideoTrack {
return track.type === "video" && track.isMain === true;
}
export function getMainTrack({
tracks,
}: {
tracks: TimelineTrack[];
}): VideoTrack | null {
return tracks.find((track) => isMainTrack(track)) ?? null;
}
export function ensureMainTrack({
tracks,
}: {
tracks: TimelineTrack[];
}): TimelineTrack[] {
if (tracks.some((track) => isMainTrack(track))) {
return tracks;
}
return [
{
id: generateUUID(),
name: MAIN_TRACK_NAME,
type: "video",
elements: [],
muted: false,
isMain: true,
hidden: false,
},
...tracks,
];
}
export function getEarliestMainTrackElement({
tracks,
excludeElementId,
}: {
tracks: TimelineTrack[];
excludeElementId?: string;
}): TimelineElement | null {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack) {
return null;
}
const elements = mainTrack.elements.filter((element) => {
return !excludeElementId || element.id !== excludeElementId;
});
if (elements.length === 0) {
return null;
}
return elements.reduce((earliestElement, element) => {
return element.startTime < earliestElement.startTime
? element
: earliestElement;
});
}
export function enforceMainTrackStart({
tracks,
targetTrackId,
requestedStartTime,
excludeElementId,
}: {
tracks: TimelineTrack[];
targetTrackId: string;
requestedStartTime: number;
excludeElementId?: string;
}): number {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack || mainTrack.id !== targetTrackId) {
return requestedStartTime;
}
const earliestElement = getEarliestMainTrackElement({
tracks,
excludeElementId,
});
if (!earliestElement) {
return 0;
}
if (requestedStartTime <= earliestElement.startTime) {
return 0;
}
return requestedStartTime;
}
@@ -1,40 +0,0 @@
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import type { PlacementTimeSpan } from "./types";
function wouldElementOverlap({
elements,
startTime,
endTime,
excludeElementId,
}: {
elements: TimelineElement[];
startTime: number;
endTime: number;
excludeElementId?: string;
}): boolean {
return elements.some((element) => {
if (excludeElementId && element.id === excludeElementId) {
return false;
}
const elementEnd = element.startTime + element.duration;
return startTime < elementEnd && endTime > element.startTime;
});
}
export function canPlaceTimeSpansOnTrack({
track,
timeSpans,
}: {
track: TimelineTrack;
timeSpans: PlacementTimeSpan[];
}): boolean {
return timeSpans.every(({ startTime, duration, excludeElementId }) => {
return !wouldElementOverlap({
elements: track.elements,
startTime,
endTime: startTime + duration,
excludeElementId,
});
});
}
@@ -1,255 +0,0 @@
import type { TrackType, TimelineTrack } from "@/lib/timeline";
import {
getDefaultInsertIndexForTrack,
getHighestInsertIndexForTrack,
resolvePreferredNewTrackPlacement,
} from "./insert-index";
import { getTrackTypeForElementType } from "./compatibility";
import { enforceMainTrackStart } from "./main-track";
import { canPlaceTimeSpansOnTrack } from "./overlap";
import type {
PlacementResult,
PlacementStrategy,
PlacementSubject,
PlacementTimeSpan,
} from "./types";
type ResolveTrackPlacementParams = PlacementSubject & {
tracks: TimelineTrack[];
timeSpans: PlacementTimeSpan[];
strategy: PlacementStrategy;
};
function buildExistingTrackResult({
track,
trackIndex,
tracks,
timeSpans,
}: {
track: TimelineTrack;
trackIndex: number;
tracks: TimelineTrack[];
timeSpans: PlacementTimeSpan[];
}): PlacementResult {
const firstSpan = timeSpans[0];
const requestedStartTime = firstSpan?.startTime ?? 0;
const adjustedStartTime = enforceMainTrackStart({
tracks,
targetTrackId: track.id,
requestedStartTime,
excludeElementId: firstSpan?.excludeElementId,
});
return {
kind: "existingTrack",
trackId: track.id,
trackIndex,
trackType: track.type,
...(adjustedStartTime !== requestedStartTime
? { adjustedStartTime }
: {}),
};
}
function buildNewTrackResult({
trackType,
insertIndex,
insertPosition,
}: {
trackType: TrackType;
insertIndex: number;
insertPosition: "above" | "below" | null;
}): PlacementResult {
return {
kind: "newTrack",
trackType,
insertIndex,
insertPosition,
};
}
function findFirstAvailableTrackIndex({
tracks,
trackType,
timeSpans,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
timeSpans: PlacementTimeSpan[];
}): number {
return tracks.findIndex((track) => {
return (
track.type === trackType &&
canPlaceTimeSpansOnTrack({
track,
timeSpans,
})
);
});
}
function resolveAlwaysNewTrack({
tracks,
trackType,
position,
}: {
tracks: TimelineTrack[];
trackType: TrackType;
position: "highest" | "default";
}): PlacementResult {
const insertIndex =
position === "highest"
? getHighestInsertIndexForTrack({
tracks,
trackType,
})
: getDefaultInsertIndexForTrack({
tracks,
trackType,
});
return buildNewTrackResult({
trackType,
insertIndex,
insertPosition: null,
});
}
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,
});
}
@@ -1,61 +0,0 @@
import { TRACK_CONFIG } from "@/constants/timeline-constants";
import type { TrackType, TimelineTrack } from "@/lib/timeline";
export function buildEmptyTrack({
id,
type,
name,
}: {
id: string;
type: TrackType;
name?: string;
}): TimelineTrack {
const trackName = name ?? TRACK_CONFIG[type].defaultName;
switch (type) {
case "video":
return {
id,
name: trackName,
type: "video",
elements: [],
hidden: false,
muted: false,
isMain: false,
};
case "text":
return {
id,
name: trackName,
type: "text",
elements: [],
hidden: false,
};
case "graphic":
return {
id,
name: trackName,
type: "graphic",
elements: [],
hidden: false,
};
case "audio":
return {
id,
name: trackName,
type: "audio",
elements: [],
muted: false,
};
case "effect":
return {
id,
name: trackName,
type: "effect",
elements: [],
hidden: false,
};
default:
throw new Error(`Unsupported track type: ${type}`);
}
}
@@ -1,39 +0,0 @@
import type { ElementType, TrackType } from "@/lib/timeline";
export interface PlacementTimeSpan {
startTime: number;
duration: number;
excludeElementId?: string;
}
export type PlacementSubject =
| { elementType: ElementType }
| { trackType: TrackType };
export type PlacementStrategy =
| { type: "explicit"; trackId: string }
| { type: "firstAvailable" }
| {
type: "preferIndex";
trackIndex: number;
hoverDirection: "above" | "below";
verticalDragDirection?: "up" | "down" | null;
createNewTrackOnly?: boolean;
}
| { type: "aboveSource"; sourceTrackIndex: number }
| { type: "alwaysNew"; position: "highest" | "default" };
export type PlacementResult =
| {
kind: "existingTrack";
trackId: string;
trackIndex: number;
trackType: TrackType;
adjustedStartTime?: number;
}
| {
kind: "newTrack";
insertIndex: number;
insertPosition: "above" | "below" | null;
trackType: TrackType;
};
+235
View File
@@ -1,17 +1,21 @@
import type { import type {
TrackType, TrackType,
TimelineTrack, TimelineTrack,
ElementType,
VideoTrack, VideoTrack,
AudioTrack, AudioTrack,
GraphicTrack, GraphicTrack,
TextTrack, TextTrack,
EffectTrack, EffectTrack,
TimelineElement,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { import {
ELEMENT_TRACK_MAP,
TRACK_CONFIG, TRACK_CONFIG,
ELEMENT_TYPE_CONFIG, ELEMENT_TYPE_CONFIG,
TRACK_GAP, TRACK_GAP,
} from "@/constants/timeline-constants"; } from "@/constants/timeline-constants";
import { generateUUID } from "@/utils/id";
export function canTracktHaveAudio( export function canTracktHaveAudio(
track: TimelineTrack, track: TimelineTrack,
@@ -60,3 +64,234 @@ export function getTotalTracksHeight({
const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP; const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
return tracksHeight + gapsHeight; 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
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
export async function middleware() {
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};
@@ -12,7 +12,7 @@ import { EffectLayerNode } from "./nodes/effect-layer-node";
import type { BaseNode } from "./nodes/base-node"; import type { BaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/lib/project/types"; import type { TBackground, TCanvasSize } from "@/lib/project/types";
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants"; import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
import { isMainTrack } from "@/lib/track-placement"; import { isMainTrack } from "@/lib/timeline";
const PREVIEW_MAX_IMAGE_SIZE = 2048; const PREVIEW_MAX_IMAGE_SIZE = 2048;
-120
View File
@@ -1,120 +0,0 @@
import { describe, expect, it } from "bun:test";
import { evaluateMathExpression } from "../math";
describe("evaluateMathExpression", () => {
describe("basic arithmetic", () => {
it("should add numbers", () => {
expect(evaluateMathExpression({ input: "1+2" })).toBe(3);
expect(evaluateMathExpression({ input: "10 + 20" })).toBe(30);
});
it("should subtract numbers", () => {
expect(evaluateMathExpression({ input: "5-3" })).toBe(2);
expect(evaluateMathExpression({ input: "100 - 50" })).toBe(50);
});
it("should multiply numbers", () => {
expect(evaluateMathExpression({ input: "4*5" })).toBe(20);
expect(evaluateMathExpression({ input: "10 * 10" })).toBe(100);
});
it("should divide numbers", () => {
expect(evaluateMathExpression({ input: "20/4" })).toBe(5);
expect(evaluateMathExpression({ input: "100 / 10" })).toBe(10);
});
});
describe("operator precedence", () => {
it("should respect multiplication over addition", () => {
expect(evaluateMathExpression({ input: "2+3*4" })).toBe(14);
expect(evaluateMathExpression({ input: "10+5*2" })).toBe(20);
});
it("should respect division over subtraction", () => {
expect(evaluateMathExpression({ input: "10-6/2" })).toBe(7);
});
});
describe("parentheses", () => {
it("should evaluate expressions in parentheses first", () => {
expect(evaluateMathExpression({ input: "(2+3)*4" })).toBe(20);
expect(evaluateMathExpression({ input: "(10-5)*2" })).toBe(10);
});
it("should handle nested parentheses", () => {
expect(evaluateMathExpression({ input: "((2+3)*4)" })).toBe(20);
expect(evaluateMathExpression({ input: "(1+(2*3))" })).toBe(7);
});
});
describe("decimal numbers", () => {
it("should handle decimal numbers", () => {
expect(evaluateMathExpression({ input: "3.5+2.5" })).toBe(6);
expect(evaluateMathExpression({ input: "10.5 * 2" })).toBe(21);
});
});
describe("negative numbers", () => {
it("should handle negative numbers", () => {
expect(evaluateMathExpression({ input: "-5+3" })).toBe(-2);
expect(evaluateMathExpression({ input: "10+-5" })).toBe(5);
});
});
describe("whitespace handling", () => {
it("should handle various whitespace", () => {
expect(evaluateMathExpression({ input: " 1 + 2 " })).toBe(3);
expect(evaluateMathExpression({ input: "10* 5" })).toBe(50);
});
});
describe("edge cases", () => {
it("should handle single number", () => {
expect(evaluateMathExpression({ input: "42" })).toBe(42);
expect(evaluateMathExpression({ input: "3.14" })).toBe(3.14);
});
it("should return null for empty string", () => {
expect(evaluateMathExpression({ input: "" })).toBeNull();
});
it("should return null for invalid characters", () => {
expect(evaluateMathExpression({ input: "1+2a" })).toBeNull();
expect(evaluateMathExpression({ input: "alert(1)" })).toBeNull();
expect(evaluateMathExpression({ input: "1+2; doSomething()" })).toBeNull();
});
it("should return null for unbalanced parentheses", () => {
expect(evaluateMathExpression({ input: "(1+2" })).toBeNull();
expect(evaluateMathExpression({ input: "1+2)" })).toBeNull();
});
it("should return null for division by zero", () => {
expect(evaluateMathExpression({ input: "1/0" })).toBeNull();
});
it("should return null for incomplete expressions", () => {
expect(evaluateMathExpression({ input: "1+" })).toBeNull();
expect(evaluateMathExpression({ input: "*2" })).toBeNull();
});
});
describe("security - code injection prevention", () => {
it("should reject code injection attempts", () => {
// These would be dangerous with new Function() but are safe here
expect(evaluateMathExpression({ input: "alert('xss')" })).toBeNull();
expect(evaluateMathExpression({ input: "process.exit()" })).toBeNull();
expect(evaluateMathExpression({ input: "require('fs')" })).toBeNull();
expect(evaluateMathExpression({ input: "eval('1+1')" })).toBeNull();
expect(evaluateMathExpression({ input: "Function('return 1')()" })).toBeNull();
expect(evaluateMathExpression({ input: "constructor.constructor('return 1')()" })).toBeNull();
expect(evaluateMathExpression({ input: "this.toString" })).toBeNull();
expect(evaluateMathExpression({ input: "__proto__" })).toBeNull();
});
it("should reject prototype pollution attempts", () => {
expect(evaluateMathExpression({ input: "[].constructor" })).toBeNull();
expect(evaluateMathExpression({ input: "({}).constructor" })).toBeNull();
});
});
});
+115 -276
View File
@@ -1,276 +1,115 @@
export function clamp({ export function clamp({
value, value,
min, min,
max, max,
}: { }: {
value: number; value: number;
min: number; min: number;
max: number; max: number;
}): number { }): number {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
} }
export function clampRound({ export function clampRound({
value, value,
min, min,
max, max,
}: { }: {
value: number; value: number;
min: number; min: number;
max: number; max: number;
}): number { }): number {
return Math.round(clamp({ value, min, max })); return Math.round(clamp({ value, min, max }));
} }
export function getFractionDigitsForStep({ step }: { step: number }): number { export function getFractionDigitsForStep({ step }: { step: number }): number {
const normalizedStep = step.toString().toLowerCase(); const normalizedStep = step.toString().toLowerCase();
if (normalizedStep.includes("e-")) { if (normalizedStep.includes("e-")) {
return Number(normalizedStep.split("e-")[1] ?? 0); return Number(normalizedStep.split("e-")[1] ?? 0);
} }
const [, fractionalPart = ""] = normalizedStep.split("."); const [, fractionalPart = ""] = normalizedStep.split(".");
return fractionalPart.length; return fractionalPart.length;
} }
export function snapToStep({ export function snapToStep({
value, value,
step, step,
}: { }: {
value: number; value: number;
step: number; step: number;
}): number { }): number {
if (step <= 0) return value; if (step <= 0) return value;
const snappedValue = Math.round(value / step) * step; const snappedValue = Math.round(value / step) * step;
return Number( return Number(
snappedValue.toFixed(getFractionDigitsForStep({ step })), snappedValue.toFixed(getFractionDigitsForStep({ step })),
); );
} }
export function isNearlyEqual({ export function isNearlyEqual({
leftValue, leftValue,
rightValue, rightValue,
epsilon = 0.0001, epsilon = 0.0001,
}: { }: {
leftValue: number; leftValue: number;
rightValue: number; rightValue: number;
epsilon?: number; epsilon?: number;
}): boolean { }): boolean {
return Math.abs(leftValue - rightValue) <= epsilon; return Math.abs(leftValue - rightValue) <= epsilon;
} }
export function formatNumberForDisplay({ export function formatNumberForDisplay({
value, value,
fractionDigits, fractionDigits,
minFractionDigits = 0, minFractionDigits = 0,
maxFractionDigits = 6, maxFractionDigits = 6,
}: { }: {
value: number; value: number;
fractionDigits?: number; fractionDigits?: number;
minFractionDigits?: number; minFractionDigits?: number;
maxFractionDigits?: number; maxFractionDigits?: number;
}): string { }): string {
const resolvedMaxFractionDigits = Math.max( const resolvedMaxFractionDigits = Math.max(
0, 0,
fractionDigits ?? maxFractionDigits, fractionDigits ?? maxFractionDigits,
); );
const resolvedMinFractionDigits = Math.min( const resolvedMinFractionDigits = Math.min(
Math.max(0, fractionDigits ?? minFractionDigits), Math.max(0, fractionDigits ?? minFractionDigits),
resolvedMaxFractionDigits, resolvedMaxFractionDigits,
); );
const fixedValue = value.toFixed(resolvedMaxFractionDigits); const fixedValue = value.toFixed(resolvedMaxFractionDigits);
if (resolvedMaxFractionDigits === 0) { if (resolvedMaxFractionDigits === 0) {
return Number(fixedValue) === 0 ? "0" : fixedValue; return Number(fixedValue) === 0 ? "0" : fixedValue;
} }
const [integerPart, fractionPart = ""] = fixedValue.split("."); const [integerPart, fractionPart = ""] = fixedValue.split(".");
const normalizedIntegerPart = Number(fixedValue) === 0 ? "0" : integerPart; const normalizedIntegerPart = Number(fixedValue) === 0 ? "0" : integerPart;
let trimmedFractionPart = fractionPart; let trimmedFractionPart = fractionPart;
while ( while (
trimmedFractionPart.length > resolvedMinFractionDigits && trimmedFractionPart.length > resolvedMinFractionDigits &&
trimmedFractionPart.endsWith("0") trimmedFractionPart.endsWith("0")
) { ) {
trimmedFractionPart = trimmedFractionPart.slice(0, -1); trimmedFractionPart = trimmedFractionPart.slice(0, -1);
} }
return trimmedFractionPart return trimmedFractionPart
? `${normalizedIntegerPart}.${trimmedFractionPart}` ? `${normalizedIntegerPart}.${trimmedFractionPart}`
: normalizedIntegerPart; : normalizedIntegerPart;
} }
/** export function evaluateMathExpression({
* Safely evaluates a mathematical expression without using eval() or new Function(). input,
* Supports +, -, *, /, parentheses, and decimal numbers. }: {
*/ input: string;
export function evaluateMathExpression({ }): number | null {
input, const sanitized = input.trim();
}: { if (!/^[\d.\s+\-*/()]+$/.test(sanitized)) return null;
input: string; try {
}): number | null { const result = new Function(`return (${sanitized})`)();
const sanitized = input.trim(); if (typeof result !== "number" || !Number.isFinite(result)) return null;
if (!/^[\d.\s+\-*/()]+$/.test(sanitized)) return null; return result;
try { } catch {
return parseExpression(sanitized); return null;
} 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
+40 -6
View File
@@ -24,6 +24,8 @@
"@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.6", "@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1", "@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@opennextjs/cloudflare": "^1.18.0", "@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -77,7 +79,7 @@
"unified": "^11.0.5", "unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1", "use-deep-compare-effect": "^1.8.1",
"wavesurfer.js": "^7.9.8", "wavesurfer.js": "^7.9.8",
"zod": "4.3.6", "zod": "^3.25.67",
"zustand": "^5.0.2", "zustand": "^5.0.2",
}, },
"devDependencies": { "devDependencies": {
@@ -102,6 +104,32 @@
"wrangler": "^4.77.0", "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": [ "trustedDependencies": [
"@tailwindcss/oxide", "@tailwindcss/oxide",
@@ -357,6 +385,10 @@
"@huggingface/transformers": ["@huggingface/transformers@3.8.1", "", { "dependencies": { "@huggingface/jinja": "^0.5.3", "onnxruntime-node": "1.21.0", "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", "sharp": "^0.34.1" } }, "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA=="], "@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/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=="], "@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=="],
@@ -477,6 +509,10 @@
"@node-minify/utils": ["@node-minify/utils@8.0.6", "", { "dependencies": { "gzip-size": "6.0.0" } }, "sha512-csY4qcR7jUwiZmkreNTJhcypQfts2aY2CK+a+rXgXUImZiZiySh0FvwHjRnlqWKvg+y6ae9lHFzDRjBTmqlTIQ=="], "@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"], "@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/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=="],
@@ -1799,7 +1835,7 @@
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"zustand": ["zustand@5.0.6", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A=="], "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=="],
@@ -1815,8 +1851,6 @@
"@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="], "@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=="], "@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=="], "@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
@@ -1837,6 +1871,8 @@
"@node-minify/terser/terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="], "@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=="], "@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=="], "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
@@ -1953,8 +1989,6 @@
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "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=="], "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=="], "browserslist/caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@opencut/env",
"version": "0.0.0",
"description": "Environment package for OpenCut",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./web": "./src/web.ts",
"./tools": "./src/tools.ts"
},
"dependencies": {
"zod": "^4.0.5"
},
"devDependencies": {
"dotenv": "^16.4.7",
"@types/bun": "latest",
"@types/node": "^24.2.1",
"typescript": "^5.8.3"
}
}
+4 -5
View File
@@ -11,11 +11,10 @@ const webEnvSchema = z.object({
NEXT_PUBLIC_MARBLE_API_URL: z.url(), NEXT_PUBLIC_MARBLE_API_URL: z.url(),
// Server // Server
DATABASE_URL: z.string().refine( DATABASE_URL: z
(url) => .string()
url.startsWith("postgres://") || url.startsWith("postgresql://"), .startsWith("postgres://")
"DATABASE_URL must be a postgres:// or postgresql:// URL", .or(z.string().startsWith("postgresql://")),
),
BETTER_AUTH_SECRET: z.string(), BETTER_AUTH_SECRET: z.string(),
UPSTASH_REDIS_REST_URL: z.url(), UPSTASH_REDIS_REST_URL: z.url(),
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@opencut/ui",
"version": "0.0.0",
"description": "UI package for OpenCut",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
"./icons": "./src/icons/index.tsx"
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@types/react": "^19.2.7",
"react": "^19.2.0"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.8.3"
}
}
@@ -1,6 +1,4 @@
import type { IconProps } from "./types"; export function OcVercelIcon({ className }: { className?: string }) {
export function OcVercelIcon({ className }: IconProps) {
return ( return (
<svg <svg
className={className} className={className}
@@ -19,7 +17,10 @@ export function OcVercelIcon({ className }: IconProps) {
export function OcMarbleIcon({ export function OcMarbleIcon({
className = "", className = "",
size = 32, size = 32,
}: IconProps) { }: {
className?: string;
size?: number;
}) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -42,7 +43,10 @@ export function OcMarbleIcon({
export function OcDataBuddyIcon({ export function OcDataBuddyIcon({
className = "", className = "",
size = 32, size = 32,
}: IconProps) { }: {
className?: string;
size?: number;
}) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -1,9 +1,10 @@
import type { IconProps } from "./types";
export function OcVideoIcon({ export function OcVideoIcon({
className = "", className = "",
size = 32, size = 32,
}: IconProps) { }: {
className?: string;
size?: number;
}) {
return ( return (
<svg <svg
width={size} width={size}
@@ -33,7 +34,10 @@ export function OcVideoIcon({
export function OcCheckerboardIcon({ export function OcCheckerboardIcon({
className = "", className = "",
size = 32, size = 32,
}: IconProps) { }: {
className?: string;
size?: number;
}) {
return ( return (
<svg <svg
width={size} width={size}
@@ -64,7 +68,10 @@ export function OcCheckerboardIcon({
export function OcSlidersVerticalIcon({ export function OcSlidersVerticalIcon({
className = "", className = "",
size = 32, size = 32,
}: IconProps) { }: {
className?: string;
size?: number;
}) {
return ( return (
<svg <svg
width={size} width={size}
@@ -106,7 +113,10 @@ export function OcSlidersVerticalIcon({
export function OcSocialIcon({ export function OcSocialIcon({
className = "", className = "",
size = 32, size = 32,
}: IconProps) { }: {
className?: string;
size?: number;
}) {
return ( return (
<svg <svg
width={Math.round(size * (38 / 24))} width={Math.round(size * (38 / 24))}
@@ -152,7 +162,10 @@ export function OcSocialIcon({
export function OcTextWidthIcon({ export function OcTextWidthIcon({
className = "", className = "",
size = 32, size = 32,
}: IconProps) { }: {
className?: string;
size?: number;
}) {
return ( return (
<svg <svg
width={Math.round((size * 17) / 24)} width={Math.round((size * 17) / 24)}
@@ -177,7 +190,10 @@ export function OcTextWidthIcon({
export function OcTextHeightIcon({ export function OcTextHeightIcon({
className = "", className = "",
size = 32, size = 32,
}: IconProps) { }: {
className?: string;
size?: number;
}) {
return ( return (
<svg <svg
width={Math.round((size * 20) / 23)} width={Math.round((size * 20) / 23)}
@@ -202,7 +218,10 @@ export function OcTextHeightIcon({
export function OcFontIcon({ export function OcFontIcon({
className = "", className = "",
size = 32, size = 32,
}: IconProps) { }: {
className?: string;
size?: number;
}) {
return ( return (
<svg <svg
width={size} width={size}
@@ -225,7 +244,11 @@ export function OcShapesIcon({
className = "", className = "",
size = 32, size = 32,
strokeWidth = 1.5, strokeWidth = 1.5,
}: IconProps) { }: {
className?: string;
size?: number;
strokeWidth?: number;
}) {
return ( return (
<svg <svg
width={size} width={size}
@@ -265,7 +288,10 @@ export function OcShapesIcon({
export function OcMirrorIcon({ export function OcMirrorIcon({
className = "", className = "",
size = 32, size = 32,
}: IconProps) { }: {
className?: string;
size?: number;
}) {
return ( return (
<svg <svg
width={size} width={size}
@@ -303,7 +329,11 @@ export function OcSquarePlusIcon({
className = "", className = "",
size = 32, size = 32,
strokeWidth = 1.5, strokeWidth = 1.5,
}: IconProps) { }: {
className?: string;
size?: number;
strokeWidth?: number;
}) {
return ( return (
<svg <svg
width={size} width={size}
@@ -353,52 +383,3 @@ export function OcSquarePlusIcon({
</svg> </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>
);
}
+20
View File
@@ -0,0 +1,20 @@
{
"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
@@ -1,35 +0,0 @@
# 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
@@ -1,12 +0,0 @@
[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
@@ -1,49 +0,0 @@
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
@@ -1,15 +0,0 @@
[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
@@ -1,274 +0,0 @@
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);
}
}