mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2e2668701 | ||
|
|
b949fda7bc | ||
|
|
8e936a7594 | ||
|
|
96f87b050d | ||
|
|
4b9d5f8d23 | ||
|
|
6b81afc20b | ||
|
|
8918a87c33 | ||
|
|
f635403108 | ||
|
|
fc0ba2d37f | ||
|
|
1660f1c76c | ||
|
|
b89f016069 | ||
|
|
271cc27cbf | ||
|
|
b62833384d | ||
|
|
08f5b92c3d | ||
|
|
4aca4e7756 | ||
|
|
b7e6db90b0 | ||
|
|
fe623357ef | ||
|
|
5b3c048e20 | ||
|
|
5638974f4b | ||
|
|
a2580216f8 | ||
|
|
dd4e6780d8 |
@@ -2,6 +2,4 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"apps/desktop",
|
"apps/desktop",
|
||||||
"rust/crates/time"
|
|
||||||
"rust/crates/bridge"
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
+1
-5
@@ -7,8 +7,4 @@
|
|||||||
|
|
||||||
.next/
|
.next/
|
||||||
|
|
||||||
.font-cache/
|
.font-cache/
|
||||||
|
|
||||||
next-env.d.ts
|
|
||||||
|
|
||||||
*.tsbuildinfo
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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,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,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
@@ -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;
|
||||||
|
|||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import 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: {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Binary file not shown.
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"] });
|
||||||
|
|||||||
+1015
-1015
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()}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export interface IconProps {
|
|
||||||
size?: number;
|
|
||||||
className?: string;
|
|
||||||
strokeWidth?: number;
|
|
||||||
}
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|
||||||
|
|||||||
+743
-745
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
|
import type { 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";
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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=="],
|
||||||
|
|||||||
Vendored
+21
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@opencut/env",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "Environment package for OpenCut",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./web": "./src/web.ts",
|
||||||
|
"./tools": "./src/tools.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.0.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/node": "^24.2.1",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-5
@@ -11,11 +11,10 @@ const webEnvSchema = z.object({
|
|||||||
NEXT_PUBLIC_MARBLE_API_URL: z.url(),
|
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(),
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
```
|
|
||||||
@@ -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"] }
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user