2 Commits

Author SHA1 Message Date
99965c0674 Update README.md 2025-06-24 20:04:49 +02:00
87c4bd4c95 Fix rendering issue in README 2025-06-24 20:04:22 +02:00
85 changed files with 1857 additions and 3742 deletions

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: '[BUG] '
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -1,70 +0,0 @@
name: Bug report
description: Create a report to help us improve
title: '[BUG] '
labels: bug
body:
- type: input
id: Platform
attributes:
label: Platform
description: Please enter the platform on which you encountered the bug.
placeholder: e.g. Windows 11, Ubuntu 14.04
validations:
required: true
- type: input
id: Browser
attributes:
label: Browser
description: Please enter the browser on which you encountered the bug.
placeholder: e.g. Chrome 137, Firefox 137, Safari 17
validations:
required: true
- type: textarea
id: current-behavior
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: dropdown
id: recurrence-probability
attributes:
label: Recurrence Probability
description: How often does this bug occur?
options:
- Always
- Usually
- Sometimes
- Seldom
default: 0
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false

View File

@ -0,0 +1,19 @@
---
name: Feature request
about: Suggest an idea for this project
title: '[FEATURE] '
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -1,42 +0,0 @@
name: Feature request
description: Suggest an idea for OpenCut
title: '[FEATURE] '
labels: enhancement
body:
- type: markdown
attributes:
value: Please make sure that no duplicated issues has already been delivered.
- type: textarea
id: problem
attributes:
label: Problem
placeholder: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution
attributes:
label: Solution
placeholder: Describe the solution you'd like.
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternative
attributes:
label: Alternative
placeholder: Describe alternatives you've considered.
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false

View File

@ -31,13 +31,13 @@ jobs:
- name: Install Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
with:
bun-version: 1.2.17
bun-version: 1.2.2
- name: Cache Bun modules
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-1.2.17-${{ hashFiles('apps/web/bun.lock') }}
key: ${{ runner.os }}-bun-${{ hashFiles('apps/web/bun.lock') }}
- name: Install dependencies
working-directory: apps/web

7
.gitignore vendored
View File

@ -27,9 +27,4 @@ node_modules
.cursorignore
.turbo
*.env
# cursor
.cursor/
bun.lockb
*.env

View File

@ -1,19 +1,17 @@
<table width="100%">
<tr>
<td align="left" width="120">
<img src="apps/web/public/logo.png" alt="OpenCut Logo" width="100" />
</td>
<td align="right">
<h1>OpenCut <span style="font-size: 0.7em; font-weight: normal;">(prev AppCut)</span></h1>
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
</td>
</tr>
<table>
<tr>
<td width="130">
<img src="apps/web/public/logo.png" width="130" height="130">
</td>
<td align="right">
# OpenCut (prev AppCut)
### A free, open-source video editor for web, desktop, and mobile.
</td>
</tr>
</table>
## Why?
- **Privacy**: Your videos stay on your device
@ -26,7 +24,6 @@
- Multi-track support
- Real-time preview
- No watermarks or subscriptions
- Analytics provided by [Databuddy](https://www.databuddy.cc?utm_source=opencut), 100% Anonymized & Non-invasive.
## Project Structure
@ -65,16 +62,7 @@ Before you begin, ensure you have the following installed on your system:
Navigate into the web app's directory and create a `.env` file from the example:
```bash
cd apps/web
# Unix/Linux/Mac
cp .env.example .env.local
# Windows Command Prompt
copy .env.example .env.local
# Windows PowerShell
Copy-Item .env.example .env.local
cp .env.example .env
```
*The default values in the `.env` file should work for local development.*
@ -109,13 +97,13 @@ Before you begin, ensure you have the following installed on your system:
The application will be available at [http://localhost:3000](http://localhost:3000).
---
=======
## Contributing
Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
---
=======
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
Quick start for contributors:

View File

@ -1,45 +1,30 @@
FROM oven/bun:alpine AS base
# Install dependencies and build the application
FROM base AS builder
FROM oven/bun:latest AS base
# Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY package.json package.json
COPY bun.lock bun.lock
COPY turbo.json turbo.json
COPY apps/web/package.json apps/web/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/auth/package.json packages/auth/package.json
RUN bun install
COPY apps/web/ apps/web/
COPY packages/db/ packages/db/
COPY packages/auth/ packages/auth/
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
WORKDIR /app/apps/web
# Build the application
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build
# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
RUN chown nextjs:nodejs apps
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
@ -48,4 +33,4 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["bun", "apps/web/server.js"]
CMD ["bun", "server.js"]

View File

@ -13,7 +13,7 @@ if (!process.env.DATABASE_URL) {
}
export default {
schema: "../../packages/db/src/schema.ts",
schema: "./src/lib/db/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,

View File

@ -1,10 +1,13 @@
# Next.js plugin
[build]
base = "../.."
command = "bun install && bunx turbo build --filter=opencut"
publish = "apps/web/.next"
[[plugins]]
package = "@netlify/plugin-nextjs"
# Redirects for domain migration
[[redirects]]
from = "https://appcut.app/*"
to = "https://opencut.app/:splat"
status = 301
force = true
force = true

View File

@ -6,7 +6,6 @@ const nextConfig: NextConfig = {
},
reactStrictMode: true,
productionBrowserSourceMaps: true,
output: "standalone",
};
export default nextConfig;

View File

@ -21,6 +21,7 @@
"@hookform/resolvers": "^3.9.1",
"@opencut/auth": "workspace:*",
"@opencut/db": "workspace:*",
"@types/pg": "^8.15.4",
"@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.35.0",
"@vercel/analytics": "^1.4.1",
@ -56,7 +57,6 @@
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/pg": "^8.15.4",
"@types/bun": "latest",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
@ -68,4 +68,4 @@
"tsx": "^4.7.1",
"typescript": "^5"
}
}
}

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="/icons/ms-icon-70x70.png"/>
<square150x150logo src="/icons/ms-icon-150x150.png"/>
<square310x310logo src="/icons/ms-icon-310x310.png"/>
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,10 +0,0 @@
<svg width="459" height="77" viewBox="0 0 459 77" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="1.15625" width="458" height="75" rx="15.5" fill="#101010"/>
<rect x="0.5" y="1.15625" width="458" height="75" rx="15.5" stroke="#FFCC00"/>
<rect x="0.5" y="1.15625" width="31" height="75" rx="15.5" fill="#2A2A2A"/>
<rect x="0.5" y="1.15625" width="31" height="75" rx="15.5" stroke="#FFCC00"/>
<rect x="13" y="20.6562" width="6" height="36" rx="3" fill="#FFCC00"/>
<rect x="427.5" y="1.15625" width="31" height="75" rx="15.5" fill="#2A2A2A"/>
<rect x="427.5" y="1.15625" width="31" height="75" rx="15.5" stroke="#FFCC00"/>
<rect x="440" y="20.6562" width="6" height="36" rx="3" fill="#FFCC00"/>
</svg>

Before

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 741 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 802 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 906 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 747 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 906 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

View File

@ -1,10 +0,0 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_10_2)">
<path d="M32 9.37305V22.627L22.627 32H9.37305L0 22.627V9.37305L9.37305 0H22.627L32 9.37305ZM8 8V24H24V8H8Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_10_2">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 362 B

View File

@ -1,44 +0,0 @@
{
"name": "OpenCut",
"description": "A simple but powerful video editor that gets the job done. In your browser.",
"display": "standalone",
"start_url": "/",
"icons": [
{
"src": "/icons/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "/icons/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "/icons/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "/icons/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "/icons/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "/icons/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View File

@ -52,8 +52,8 @@ function LoginForm() {
try {
await signIn.social({
provider: "google",
callbackURL: "/editor",
});
router.push("/editor");
} catch (error) {
setError("Failed to sign in with Google. Please try again.");
setIsGoogleLoading(false);

View File

@ -1,5 +0,0 @@
import { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
return new Response("OK", { status: 200 });
}

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db, eq } from "@opencut/db";
import { db } from "@opencut/db";
import { waitlist } from "@opencut/db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import { waitlistRateLimit } from "@/lib/rate-limit";
import { z } from "zod";

View File

@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { GithubIcon } from "@/components/icons";
import { Badge } from "@/components/ui/badge";
export const metadata: Metadata = {
title: "Contributors - OpenCut",
@ -50,7 +49,7 @@ async function getContributors(): Promise<Contributor[]> {
const contributors = await response.json();
const filteredContributors = contributors.filter(
(contributor: Contributor) => contributor.type === "User"
(contributor: any) => contributor.type === "User"
);
return filteredContributors;
@ -62,8 +61,8 @@ async function getContributors(): Promise<Contributor[]> {
export default async function ContributorsPage() {
const contributors = await getContributors();
const topContributors = contributors.slice(0, 2);
const otherContributors = contributors.slice(2);
const topContributor = contributors[0];
const otherContributors = contributors.slice(1);
return (
<div className="min-h-screen bg-background">
@ -78,10 +77,10 @@ export default async function ContributorsPage() {
<div className="relative container mx-auto px-4 py-16">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-20">
<Badge variant="secondary" className="gap-2 mb-6">
<div className="inline-flex items-center gap-2 bg-muted/50 text-muted-foreground px-3 py-1 rounded-full text-sm mb-6">
<GithubIcon className="h-3 w-3" />
Open Source
</Badge>
</div>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Contributors
</h1>
@ -106,59 +105,54 @@ export default async function ContributorsPage() {
</div>
</div>
{topContributors.length > 0 && (
{topContributor && (
<div className="mb-20">
<div className="text-center mb-12">
<h2 className="text-2xl font-semibold mb-2">
Top Contributors
Top Contributor
</h2>
<p className="text-muted-foreground">
Leading the way in contributions
</p>
</div>
<div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto">
{topContributors.map((contributor, index) => (
<Link
key={contributor.id}
href={contributor.html_url}
target="_blank"
rel="noopener noreferrer"
className="group block flex-1"
>
<div className="relative mx-auto max-w-md">
<div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
<Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
<CardContent className="p-8 text-center">
<div className="relative mb-6">
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
<AvatarImage
src={contributor.avatar_url}
alt={`${contributor.login}'s avatar`}
/>
<AvatarFallback className="text-lg font-semibold">
{contributor.login.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold">
{index + 1}
</div>
</div>
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
{contributor.login}
</h3>
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<span className="font-medium text-foreground">
{contributor.contributions}
</span>
<span>contributions</span>
</div>
</CardContent>
</Card>
</div>
</Link>
))}
</div>
<Link
href={topContributor.html_url}
target="_blank"
rel="noopener noreferrer"
className="group block"
>
<div className="relative mx-auto max-w-md">
<div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
<Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
<CardContent className="p-8 text-center">
<div className="relative mb-6">
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
<AvatarImage
src={topContributor.avatar_url}
alt={`${topContributor.login}'s avatar`}
/>
<AvatarFallback className="text-lg font-semibold">
{topContributor.login.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold">
1
</div>
</div>
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
{topContributor.login}
</h3>
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<span className="font-medium text-foreground">
{topContributor.contributions}
</span>
<span>contributions</span>
</div>
</CardContent>
</Card>
</div>
</Link>
</div>
)}
@ -173,7 +167,7 @@ export default async function ContributorsPage() {
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{otherContributors.map((contributor, index) => (
<Link
key={contributor.id}
@ -185,8 +179,8 @@ export default async function ContributorsPage() {
animationDelay: `${index * 50}ms`,
}}
>
<div className="text-center p-2 rounded-xl transition-all duration-300 hover:opacity-50">
<Avatar className="h-16 w-16 mx-auto mb-3">
<div className="text-center p-4 rounded-xl hover:bg-muted/50 transition-all duration-300 group-hover:scale-105">
<Avatar className="h-16 w-16 mx-auto mb-3 ring-2 ring-transparent group-hover:ring-muted-foreground/20 transition-all duration-300">
<AvatarImage
src={contributor.avatar_url}
alt={`${contributor.login}'s avatar`}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -55,7 +55,7 @@
--accent-foreground: 0 0% 98%;
--destructive: 0 100% 60%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 17%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;

View File

@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { Analytics } from "@vercel/analytics/react";
@ -5,15 +6,17 @@ import Script from "next/script";
import "./globals.css";
import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
import { DevelopmentDebug } from "../components/development-debug";
import { baseMetaData } from "./metadata";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export const metadata = baseMetaData;
export const metadata: Metadata = {
title: "OpenCut",
description:
"A simple but powerful video editor that gets the job done. In your browser.",
};
export default function RootLayout({
children,
@ -28,7 +31,6 @@ export default function RootLayout({
{children}
<Analytics />
<Toaster />
<DevelopmentDebug />
<Script
src="https://app.databuddy.cc/databuddy.js"
strategy="afterInteractive"

View File

@ -1,66 +0,0 @@
import { Metadata } from "next";
const title = "OpenCut";
const description = "A simple but powerful video editor that gets the job done. In your browser.";
const openGraphImageUrl = "https://opencut.app/opengraph-image.jpg";
const twitterImageUrl = "/opengraph-image.jpg";
export const baseMetaData: Metadata = {
title: title,
description: description,
openGraph: {
title: title,
description: description,
url: "https://opencut.app",
siteName: "OpenCut",
locale: "en_US",
type: "website",
images: [
{
url: openGraphImageUrl,
width: 1200,
height: 630,
alt: "OpenCut",
},
],
},
twitter: {
card: "summary_large_image",
title: title,
description: description,
creator: "@opencutapp",
images: [twitterImageUrl],
},
robots: {
index: true,
follow: true,
},
icons: {
icon: [
{ url: "/favicon.ico" },
{ url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
{ url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
],
apple: [
{ url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
{ url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
{ url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
{ url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
{ url: "/icons/apple-icon-114x114.png", sizes: "114x114", type: "image/png" },
{ url: "/icons/apple-icon-120x120.png", sizes: "120x120", type: "image/png" },
{ url: "/icons/apple-icon-144x144.png", sizes: "144x144", type: "image/png" },
{ url: "/icons/apple-icon-152x152.png", sizes: "152x152", type: "image/png" },
{ url: "/icons/apple-icon-180x180.png", sizes: "180x180", type: "image/png" },
],
shortcut: ["/favicon.ico"]
},
appleWebApp: {
capable: true,
title: title,
},
manifest: "/manifest.json",
other: {
"msapplication-config": "/browserconfig.xml"
}
};

View File

@ -1,27 +1,17 @@
import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import { getWaitlistCount } from "@/lib/waitlist";
import Image from "next/image";
// Force dynamic rendering so waitlist count updates in real-time
export const dynamic = "force-dynamic";
export default async function Home() {
const signupCount = await getWaitlistCount();
return (
<div>
<Image
className="fixed top-0 left-0 -z-50 size-full object-cover"
src="/landing-page-bg.png"
height={1903.5}
width={1269}
alt="landing-page.bg"
/>
<Header />
<Hero signupCount={signupCount} />
<Footer />
</div>
);
}
import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header";
import { getWaitlistCount } from "@/lib/waitlist";
// Force dynamic rendering so waitlist count updates in real-time
export const dynamic = "force-dynamic";
export default async function Home() {
const signupCount = await getWaitlistCount();
return (
<div>
<Header />
<Hero signupCount={signupCount} />
</div>
);
}

View File

@ -0,0 +1,398 @@
"use client";
import { useRouter } from "next/navigation";
import { signUp, signIn } from "@opencut/auth/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ArrowLeft, Loader2 } from "lucide-react";
import { GoogleIcon } from "@/components/icons";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
// Zod schemas
const loginSchema = z.object({
email: z.string().email("Please enter a valid email address"),
password: z.string().min(1, "Password is required"),
});
const signupSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Please enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
type LoginFormData = z.infer<typeof loginSchema>;
type SignupFormData = z.infer<typeof signupSchema>;
interface AuthFormProps {
mode: "login" | "signup";
}
const authConfig = {
login: {
title: "Welcome back",
description: "Sign in to your account to continue",
buttonText: "Sign in",
linkText: "Don't have an account?",
linkHref: "/signup",
linkLabel: "Sign up",
successRedirect: "/editor",
},
signup: {
title: "Create your account",
description: "Get started with your free account today",
buttonText: "Create account",
linkText: "Already have an account?",
linkHref: "/login",
linkLabel: "Sign in",
successRedirect: "/login",
},
} as const;
interface AuthFormContentProps {
error: string | null;
setError: (error: string | null) => void;
isGoogleLoading: boolean;
config: typeof authConfig.login | typeof authConfig.signup;
router: ReturnType<typeof useRouter>;
}
function LoginFormContent({
error,
setError,
isGoogleLoading,
config,
router,
}: AuthFormContentProps) {
const form = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
});
const { isSubmitting } = form.formState;
const isAnyLoading = isSubmitting || isGoogleLoading;
const onSubmit = async (data: LoginFormData) => {
setError(null);
try {
const { error } = await signIn.email({
email: data.email,
password: data.password,
});
if (error) {
setError(error.message || "An unexpected error occurred.");
return;
}
router.push(config.successRedirect);
} catch (error) {
setError("An unexpected error occurred. Please try again.");
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="m@example.com"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isAnyLoading}
className="w-full h-11"
size="lg"
>
{isSubmitting ? (
<Loader2 className="animate-spin" />
) : (
config.buttonText
)}
</Button>
</form>
</Form>
);
}
function SignupFormContent({
error,
setError,
isGoogleLoading,
config,
router,
}: AuthFormContentProps) {
const form = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
defaultValues: { email: "", password: "", name: "" },
});
const { isSubmitting } = form.formState;
const isAnyLoading = isSubmitting || isGoogleLoading;
const onSubmit = async (data: SignupFormData) => {
setError(null);
try {
const { error } = await signUp.email({
name: data.name,
email: data.email,
password: data.password,
});
if (error) {
setError(error.message || "An unexpected error occurred.");
return;
}
router.push(config.successRedirect);
} catch (error) {
setError("An unexpected error occurred. Please try again.");
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input
placeholder="John Doe"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="m@example.com"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Create a strong password"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isAnyLoading}
className="w-full h-11"
size="lg"
>
{isSubmitting ? (
<Loader2 className="animate-spin" />
) : (
config.buttonText
)}
</Button>
</form>
</Form>
);
}
export function AuthForm({ mode }: AuthFormProps) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const config = authConfig[mode];
const handleGoogleAuth = async () => {
setError(null);
setIsGoogleLoading(true);
try {
await signIn.social({
provider: "google",
});
router.push(config.successRedirect);
} catch (error) {
setError(
`Failed to ${mode === "login" ? "sign in" : "sign up"} with Google. Please try again.`
);
setIsGoogleLoading(false);
}
};
return (
<div className="flex h-screen items-center justify-center relative">
<Button
variant="text"
onClick={() => router.back()}
className="absolute top-6 left-6"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] shadow-lg border-0">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-semibold">
{config.title}
</CardTitle>
<CardDescription className="text-base">
{config.description}
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-col space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
onClick={handleGoogleAuth}
variant="outline"
size="lg"
disabled={isGoogleLoading}
>
{isGoogleLoading ? (
<Loader2 className="animate-spin" />
) : (
<GoogleIcon />
)}
Continue with Google
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
{mode === "login" ? (
<LoginFormContent
error={error}
setError={setError}
isGoogleLoading={isGoogleLoading}
config={config}
router={router}
/>
) : (
<SignupFormContent
error={error}
setError={setError}
isGoogleLoading={isGoogleLoading}
config={config}
router={router}
/>
)}
</div>
<div className="mt-6 text-center text-sm">
{config.linkText}{" "}
<Link
href={config.linkHref}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{config.linkLabel}
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,107 +0,0 @@
"use client";
import {
useTimelineStore,
type TimelineClip,
type TimelineTrack,
} from "@/stores/timeline-store";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { Button } from "@/components/ui/button";
import { useState } from "react";
// Only show in development
const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
interface ActiveClip {
clip: TimelineClip;
track: TimelineTrack;
mediaItem: MediaItem | null;
}
export function DevelopmentDebug() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { currentTime } = usePlaybackStore();
const [showDebug, setShowDebug] = useState(false);
// Don't render anything in production
if (!SHOW_DEBUG_INFO) return null;
// Get active clips at current time
const getActiveClips = (): ActiveClip[] => {
const activeClips: ActiveClip[] = [];
tracks.forEach((track) => {
track.clips.forEach((clip) => {
const clipStart = clip.startTime;
const clipEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) {
const mediaItem =
clip.mediaId === "test"
? null // Test clips don't have a real media item
: mediaItems.find((item) => item.id === clip.mediaId) || null;
activeClips.push({ clip, track, mediaItem });
}
});
});
return activeClips;
};
const activeClips = getActiveClips();
return (
<div className="fixed bottom-4 right-4 z-50">
<div className="flex flex-col items-end gap-2">
{/* Toggle Button */}
<Button
variant="outline"
size="sm"
onClick={() => setShowDebug(!showDebug)}
className="text-xs backdrop-blur-md bg-background/80 border-border/50"
>
Debug {showDebug ? "ON" : "OFF"}
</Button>
{/* Debug Info Panel */}
{showDebug && (
<div className="backdrop-blur-md bg-background/90 border border-border/50 rounded-lg p-3 max-w-sm">
<div className="text-xs font-medium mb-2 text-foreground">
Active Clips ({activeClips.length})
</div>
<div className="space-y-1 max-h-40 overflow-y-auto">
{activeClips.map((clipData, index) => (
<div
key={clipData.clip.id}
className="flex items-center gap-2 px-2 py-1 bg-muted/60 rounded text-xs"
>
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4 flex-shrink-0">
{index + 1}
</span>
<div className="min-w-0 flex-1">
<div className="truncate">{clipData.clip.name}</div>
<div className="text-muted-foreground text-[10px]">
{clipData.mediaItem?.type || "test"}
</div>
</div>
</div>
))}
{activeClips.length === 0 && (
<div className="text-muted-foreground text-xs py-2 text-center">
No active clips
</div>
)}
</div>
<div className="mt-2 pt-2 border-t border-border/30 text-[10px] text-muted-foreground">
Time: {currentTime.toFixed(2)}s
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -3,11 +3,12 @@
import Link from "next/link";
import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
import { ProjectNameEditor } from "./editor/project-name-editor";
export function EditorHeader() {
const { activeProject } = useProjectStore();
const { getTotalDuration } = useTimelineStore();
const handleExport = () => {
@ -23,15 +24,13 @@ export function EditorHeader() {
};
const leftContent = (
<div className="flex items-center gap-2">
<Link
href="/"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<ChevronLeft className="h-4 w-4" />
</Link>
<ProjectNameEditor />
</div>
<Link
href="/"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<ChevronLeft className="h-4 w-4" />
<span className="text-sm">{activeProject?.name || "Loading..."}</span>
</Link>
);
const centerContent = (

View File

@ -1,115 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
interface AudioWaveformProps {
audioUrl: string;
height?: number;
className?: string;
}
const AudioWaveform: React.FC<AudioWaveformProps> = ({
audioUrl,
height = 32,
className = ''
}) => {
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurfer = useRef<WaveSurfer | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let mounted = true;
const initWaveSurfer = async () => {
if (!waveformRef.current || !audioUrl) return;
try {
// Clean up any existing instance
if (wavesurfer.current) {
try {
wavesurfer.current.destroy();
} catch (e) {
// Silently ignore destroy errors
}
wavesurfer.current = null;
}
wavesurfer.current = WaveSurfer.create({
container: waveformRef.current,
waveColor: 'rgba(255, 255, 255, 0.6)',
progressColor: 'rgba(255, 255, 255, 0.9)',
cursorColor: 'transparent',
barWidth: 2,
barGap: 1,
height: height,
normalize: true,
interact: false,
});
// Event listeners
wavesurfer.current.on('ready', () => {
if (mounted) {
setIsLoading(false);
setError(false);
}
});
wavesurfer.current.on('error', (err) => {
console.error('WaveSurfer error:', err);
if (mounted) {
setError(true);
setIsLoading(false);
}
});
await wavesurfer.current.load(audioUrl);
} catch (err) {
console.error('Failed to initialize WaveSurfer:', err);
if (mounted) {
setError(true);
setIsLoading(false);
}
}
};
initWaveSurfer();
return () => {
mounted = false;
if (wavesurfer.current) {
try {
wavesurfer.current.destroy();
} catch (e) {
// Silently ignore destroy errors
}
wavesurfer.current = null;
}
};
}, [audioUrl, height]);
if (error) {
return (
<div className={`flex items-center justify-center ${className}`} style={{ height }}>
<span className="text-xs text-foreground/60">Audio unavailable</span>
</div>
);
}
return (
<div className={`relative ${className}`}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs text-foreground/60">Loading...</span>
</div>
)}
<div
ref={waveformRef}
className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}
style={{ height }}
/>
</div>
);
};
export default AudioWaveform;

View File

@ -1,15 +1,14 @@
"use client";
import { useDragDrop } from "@/hooks/use-drag-drop";
import { Button } from "../ui/button";
import { AspectRatio } from "../ui/aspect-ratio";
import { DragOverlay } from "../ui/drag-overlay";
import { useMediaStore } from "@/stores/media-store";
import { processMediaFiles } from "@/lib/media-processing";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { Image, Music, Plus, Trash2, Upload, Video } from "lucide-react";
import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
import { useDragDrop } from "@/hooks/use-drag-drop";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { AspectRatio } from "../ui/aspect-ratio";
import { Button } from "../ui/button";
import { DragOverlay } from "../ui/drag-overlay";
// MediaPanel lets users add, view, and drag media (images, videos, audio) into the project.
// You can upload files or drag them from your computer. Dragging from here to the timeline adds them to your video project.
@ -18,28 +17,27 @@ export function MediaPanel() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
// If no files, do nothing
if (!files?.length) return;
setIsProcessing(true);
setProgress(0);
try {
// Process files (extract metadata, generate thumbnails, etc.)
const processedItems = await processMediaFiles(files, (p) =>
setProgress(p)
);
const items = await processMediaFiles(files);
// Add each processed media item to the store
processedItems.forEach((item) => addMediaItem(item));
items.forEach((item) => {
addMediaItem(item);
});
} catch (error) {
// Show error toast if processing fails
console.error("Error processing files:", error);
// Show error if processing fails
console.error("File processing failed:", error);
toast.error("Failed to process files");
} finally {
setIsProcessing(false);
setProgress(0);
}
};
@ -59,21 +57,6 @@ export function MediaPanel() {
const handleRemove = (e: React.MouseEvent, id: string) => {
// Remove a media item from the store
e.stopPropagation();
// Remove tracks automatically when delete media
const { tracks, removeTrack } = useTimelineStore.getState();
tracks.forEach((track) => {
const clipsToRemove = track.clips.filter((clip) => clip.mediaId === id);
clipsToRemove.forEach((clip) => {
useTimelineStore.getState().removeClipFromTrack(track.id, clip.id);
});
// Only remove track if it becomes empty and has no other clips
const updatedTrack = useTimelineStore.getState().tracks.find(t => t.id === track.id);
if (updatedTrack && updatedTrack.clips.length === 0) {
removeTrack(track.id);
}
});
removeMediaItem(id);
};
@ -84,7 +67,7 @@ export function MediaPanel() {
return `${min}:${sec.toString().padStart(2, "0")}`;
};
const startDrag = (e: React.DragEvent, item: MediaItem) => {
const startDrag = (e: React.DragEvent, item: any) => {
// When dragging a media item, set drag data for timeline to read
e.dataTransfer.setData(
"application/x-media-item",
@ -101,24 +84,21 @@ export function MediaPanel() {
useEffect(() => {
const filtered = mediaItems.filter((item) => {
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
if (mediaFilter && mediaFilter !== 'all' && item.type !== mediaFilter) {
return false;
}
if (
searchQuery &&
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
if (searchQuery && !item.name.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
return true;
});
setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]);
const renderPreview = (item: MediaItem) => {
const renderPreview = (item: any) => {
// Render a preview for each media type (image, video, audio, unknown)
// Each preview is draggable to the timeline
const baseDragProps = {
@ -229,23 +209,23 @@ export function MediaPanel() {
{/* Button to add/upload media */}
<div className="flex gap-2">
{/* Search and filter controls */}
<select
value={mediaFilter}
onChange={(e) => setMediaFilter(e.target.value)}
className="px-2 py-1 text-xs border rounded bg-background"
>
<option value="all">All</option>
<option value="video">Video</option>
<option value="audio">Audio</option>
<option value="image">Image</option>
</select>
<input
type="text"
placeholder="Search media..."
className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<select
value={mediaFilter}
onChange={(e) => setMediaFilter(e.target.value)}
className="px-2 py-1 text-xs border rounded bg-background"
>
<option value="all">All</option>
<option value="video">Video</option>
<option value="audio">Audio</option>
<option value="image">Image</option>
</select>
<input
type="text"
placeholder="Search media..."
className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{/* Add media button */}
<Button
@ -253,23 +233,21 @@ export function MediaPanel() {
size="sm"
onClick={handleFileSelect}
disabled={isProcessing}
className="flex-none min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
className="flex-none min-w-[80px] whitespace-nowrap"
>
{isProcessing ? (
<>
<Upload className="h-4 w-4 animate-spin" />
<span className="hidden md:inline ml-2">{progress}%</span>
<Upload className="h-4 w-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline ml-2" aria-label="Add file">
Add
</span>
Add
</>
)}
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
@ -298,15 +276,7 @@ export function MediaPanel() {
<AspectRatio ratio={item.aspectRatio}>
{renderPreview(item)}
</AspectRatio>
<span
className="text-xs truncate px-1 max-w-full"
aria-label={item.name}
title={item.name}
>
{item.name.length > 8
? `${item.name.slice(0, 4)}...${item.name.slice(-3)}`
: item.name}
</span>
<span className="text-xs truncate px-1">{item.name}</span>
</Button>
{/* Show remove button on hover */}

View File

@ -1,283 +1,213 @@
"use client";
import {
useTimelineStore,
type TimelineClip,
type TimelineTrack,
} from "@/stores/timeline-store";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
import { Play, Pause, Volume2, VolumeX, Plus } from "lucide-react";
import { useState, useRef, useEffect } from "react";
interface ActiveClip {
clip: TimelineClip;
track: TimelineTrack;
mediaItem: MediaItem | null;
}
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { currentTime, muted, toggleMute, volume } = usePlaybackStore();
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
const previewRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [previewDimensions, setPreviewDimensions] = useState({
width: 0,
height: 0,
});
// Calculate optimal preview size that fits in container while maintaining aspect ratio
useEffect(() => {
const updatePreviewSize = () => {
if (!containerRef.current) return;
const container = containerRef.current.getBoundingClientRect();
const computedStyle = getComputedStyle(containerRef.current);
// Get padding values
const paddingTop = parseFloat(computedStyle.paddingTop);
const paddingBottom = parseFloat(computedStyle.paddingBottom);
const paddingLeft = parseFloat(computedStyle.paddingLeft);
const paddingRight = parseFloat(computedStyle.paddingRight);
// Get gap value (gap-4 = 1rem = 16px)
const gap = parseFloat(computedStyle.gap) || 16;
// Get toolbar height if it exists
const toolbar = containerRef.current.querySelector("[data-toolbar]");
const toolbarHeight = toolbar
? toolbar.getBoundingClientRect().height
: 0;
// Calculate available space after accounting for padding, gap, and toolbar
const availableWidth = container.width - paddingLeft - paddingRight;
const availableHeight =
container.height -
paddingTop -
paddingBottom -
toolbarHeight -
(toolbarHeight > 0 ? gap : 0);
const targetRatio = canvasSize.width / canvasSize.height;
const containerRatio = availableWidth / availableHeight;
let width, height;
if (containerRatio > targetRatio) {
// Container is wider - constrain by height
height = availableHeight;
width = height * targetRatio;
} else {
// Container is taller - constrain by width
width = availableWidth;
height = width / targetRatio;
}
setPreviewDimensions({ width, height });
};
updatePreviewSize();
const resizeObserver = new ResizeObserver(updatePreviewSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => resizeObserver.disconnect();
}, [canvasSize.width, canvasSize.height]);
// Get active clips at current time
const getActiveClips = (): ActiveClip[] => {
const activeClips: ActiveClip[] = [];
tracks.forEach((track) => {
track.clips.forEach((clip) => {
const clipStart = clip.startTime;
const clipEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) {
const mediaItem =
clip.mediaId === "test"
? null // Test clips don't have a real media item
: mediaItems.find((item) => item.id === clip.mediaId) || null;
activeClips.push({ clip, track, mediaItem });
}
});
});
return activeClips;
};
const activeClips = getActiveClips();
// Render a clip
const renderClip = (clipData: ActiveClip, index: number) => {
const { clip, mediaItem } = clipData;
// Test clips
if (!mediaItem || clip.mediaId === "test") {
return (
<div
key={clip.id}
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
>
<div className="text-center">
<div className="text-2xl mb-2">🎬</div>
<p className="text-xs text-white">{clip.name}</p>
</div>
</div>
);
}
// Video clips
if (mediaItem.type === "video") {
return (
<div key={clip.id} className="absolute inset-0">
<VideoPlayer
src={mediaItem.url}
poster={mediaItem.thumbnailUrl}
clipStartTime={clip.startTime}
trimStart={clip.trimStart}
trimEnd={clip.trimEnd}
clipDuration={clip.duration}
/>
</div>
);
}
// Image clips
if (mediaItem.type === "image") {
return (
<div key={clip.id} className="absolute inset-0">
<img
src={mediaItem.url}
alt={mediaItem.name}
className="w-full h-full object-cover"
draggable={false}
/>
</div>
);
}
// Audio clips (visual representation)
if (mediaItem.type === "audio") {
return (
<div
key={clip.id}
className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
>
<div className="text-center">
<div className="text-2xl mb-2">🎵</div>
<p className="text-xs text-white">{mediaItem.name}</p>
</div>
</div>
);
}
return null;
};
// Canvas presets
const canvasPresets = [
{ name: "16:9 HD", width: 1920, height: 1080 },
{ name: "16:9 4K", width: 3840, height: 2160 },
{ name: "9:16 Mobile", width: 1080, height: 1920 },
{ name: "1:1 Square", width: 1080, height: 1080 },
{ name: "4:3 Standard", width: 1440, height: 1080 },
];
return (
<div className="h-full w-full flex flex-col min-h-0 min-w-0">
{/* Controls */}
<div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
<span className="text-muted-foreground">Canvas:</span>
<select
value={`${canvasSize.width}x${canvasSize.height}`}
onChange={(e) => {
const preset = canvasPresets.find(
(p) => `${p.width}x${p.height}` === e.target.value
);
if (preset)
setCanvasSize({ width: preset.width, height: preset.height });
}}
className="bg-background border rounded px-2 py-1 text-xs"
>
{canvasPresets.map((preset) => (
<option
key={preset.name}
value={`${preset.width}x${preset.height}`}
>
{preset.name} ({preset.width}×{preset.height})
</option>
))}
</select>
<Button
variant="outline"
size="sm"
onClick={toggleMute}
className="ml-auto"
>
{muted || volume === 0 ? (
<VolumeX className="h-3 w-3 mr-1" />
) : (
<Volume2 className="h-3 w-3 mr-1" />
)}
{muted || volume === 0 ? "Unmute" : "Mute"}
</Button>
</div>
{/* Preview Area */}
<div
ref={containerRef}
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
>
<div
ref={previewRef}
className="relative overflow-hidden rounded-sm bg-black border"
style={{
width: previewDimensions.width,
height: previewDimensions.height,
}}
>
{activeClips.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
{tracks.length === 0
? "No media added to timeline"
: "No clips at current time"}
</div>
) : (
activeClips.map((clipData, index) => renderClip(clipData, index))
)}
</div>
<PreviewToolbar />
</div>
</div>
);
}
function PreviewToolbar() {
const { isPlaying, toggle } = usePlaybackStore();
return (
<div
data-toolbar
className="flex items-center justify-center gap-2 px-4 pt-2 bg-background-500 w-full"
>
<Button variant="text" size="icon" onClick={toggle}>
{isPlaying ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
</div>
);
}
"use client";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
import { Play, Pause } from "lucide-react";
import { useState, useRef } from "react";
// Debug flag - set to false to hide active clips info
const SHOW_DEBUG_INFO = process.env.NODE_ENV === 'development';
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { isPlaying, toggle, currentTime } = usePlaybackStore();
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO);
const previewRef = useRef<HTMLDivElement>(null);
// Get active clips at current time
const getActiveClips = () => {
const activeClips: Array<{
clip: any;
track: any;
mediaItem: any;
}> = [];
tracks.forEach((track) => {
track.clips.forEach((clip) => {
const clipStart = clip.startTime;
const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) {
const mediaItem = clip.mediaId === "test"
? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
: mediaItems.find((item) => item.id === clip.mediaId);
if (mediaItem || clip.mediaId === "test") {
activeClips.push({ clip, track, mediaItem });
}
}
});
});
return activeClips;
};
const activeClips = getActiveClips();
const aspectRatio = canvasSize.width / canvasSize.height;
// Render a clip
const renderClip = (clipData: any, index: number) => {
const { clip, mediaItem } = clipData;
// Test clips
if (!mediaItem || clip.mediaId === "test") {
return (
<div
key={clip.id}
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
>
<div className="text-center">
<div className="text-2xl mb-2">🎬</div>
<p className="text-xs text-white">{clip.name}</p>
</div>
</div>
);
}
// Video clips
if (mediaItem.type === "video") {
return (
<div key={clip.id} className="absolute inset-0">
<VideoPlayer
src={mediaItem.url}
poster={mediaItem.thumbnailUrl}
clipStartTime={clip.startTime}
trimStart={clip.trimStart}
trimEnd={clip.trimEnd}
clipDuration={clip.duration}
/>
</div>
);
}
// Image clips
if (mediaItem.type === "image") {
return (
<div key={clip.id} className="absolute inset-0">
<img
src={mediaItem.url}
alt={mediaItem.name}
className="w-full h-full object-cover"
draggable={false}
/>
</div>
);
}
// Audio clips (visual representation)
if (mediaItem.type === "audio") {
return (
<div
key={clip.id}
className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
>
<div className="text-center">
<div className="text-2xl mb-2">🎵</div>
<p className="text-xs text-white">{mediaItem.name}</p>
</div>
</div>
);
}
return null;
};
// Canvas presets
const canvasPresets = [
{ name: "16:9 HD", width: 1920, height: 1080 },
{ name: "16:9 4K", width: 3840, height: 2160 },
{ name: "9:16 Mobile", width: 1080, height: 1920 },
{ name: "1:1 Square", width: 1080, height: 1080 },
{ name: "4:3 Standard", width: 1440, height: 1080 },
];
return (
<div className="h-full w-full flex flex-col min-h-0 min-w-0">
{/* Controls */}
<div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
<span className="text-muted-foreground">Canvas:</span>
<select
value={`${canvasSize.width}x${canvasSize.height}`}
onChange={(e) => {
const preset = canvasPresets.find(p => `${p.width}x${p.height}` === e.target.value);
if (preset) setCanvasSize({ width: preset.width, height: preset.height });
}}
className="bg-background border rounded px-2 py-1 text-xs"
>
{canvasPresets.map(preset => (
<option key={preset.name} value={`${preset.width}x${preset.height}`}>
{preset.name} ({preset.width}×{preset.height})
</option>
))}
</select>
{/* Debug Toggle - Only show in development */}
{SHOW_DEBUG_INFO && (
<Button
variant="text"
size="sm"
onClick={() => setShowDebug(!showDebug)}
className="text-xs"
>
Debug {showDebug ? 'ON' : 'OFF'}
</Button>
)}
<Button variant="outline" size="sm" onClick={toggle} className="ml-auto">
{isPlaying ? <Pause className="h-3 w-3 mr-1" /> : <Play className="h-3 w-3 mr-1" />}
{isPlaying ? "Pause" : "Play"}
</Button>
</div>
{/* Preview Area */}
<div className="flex-1 flex items-center justify-center p-2 sm:p-4 bg-gray-900 min-h-0 min-w-0">
<div
ref={previewRef}
className="relative overflow-hidden rounded-sm max-w-full max-h-full bg-black border border-gray-600"
style={{
aspectRatio: aspectRatio.toString(),
width: "100%",
height: "100%",
}}
>
{activeClips.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-white/50">
{tracks.length === 0 ? "Drop media to start editing" : "No clips at current time"}
</div>
) : (
activeClips.map((clipData, index) => renderClip(clipData, index))
)}
</div>
</div>
{/* Debug Info Panel - Conditionally rendered */}
{showDebug && (
<div className="border-t bg-background p-2 flex-shrink-0">
<div className="text-xs font-medium mb-1">Debug: Active Clips ({activeClips.length})</div>
<div className="flex gap-2 overflow-x-auto">
{activeClips.map((clipData, index) => (
<div
key={clipData.clip.id}
className="flex items-center gap-1 px-2 py-1 bg-muted rounded text-xs whitespace-nowrap"
>
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4">
{index + 1}
</span>
<span>{clipData.clip.name}</span>
<span className="text-muted-foreground">({clipData.mediaItem?.type || 'test'})</span>
</div>
))}
{activeClips.length === 0 && (
<span className="text-muted-foreground">No active clips</span>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -1,110 +0,0 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Input } from "../ui/input";
import { useProjectStore } from "@/stores/project-store";
import { Edit2, Check, X } from "lucide-react";
import { Button } from "../ui/button";
interface ProjectNameEditorProps {
className?: string;
}
export function ProjectNameEditor({ className }: ProjectNameEditorProps) {
const { activeProject, updateProjectName } = useProjectStore();
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (activeProject) {
setEditValue(activeProject.name);
}
}, [activeProject]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const handleStartEdit = () => {
if (activeProject) {
setEditValue(activeProject.name);
setIsEditing(true);
}
};
const handleSave = () => {
if (editValue.trim()) {
updateProjectName(editValue.trim());
setIsEditing(false);
}
};
const handleCancel = () => {
if (activeProject) {
setEditValue(activeProject.name);
}
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSave();
} else if (e.key === "Escape") {
handleCancel();
}
};
if (!activeProject) {
return <span className="text-sm text-muted-foreground">Loading...</span>;
}
if (isEditing) {
return (
<div className="flex items-center gap-1">
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
className="h-7 text-sm px-3 py-1 min-w-[200px]"
size={1}
/>
<Button
size="sm"
variant="text"
onClick={handleSave}
className="h-7 w-7 p-0"
disabled={!editValue.trim()}
>
<Check className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="text"
onClick={handleCancel}
className="h-7 w-7 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
);
}
return (
<div className="flex items-center gap-1 group">
<span className="text-sm font-medium">{activeProject.name}</span>
<Button
size="sm"
variant="text"
onClick={handleStartEdit}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Edit2 className="h-3 w-3" />
</Button>
</div>
);
}

View File

@ -17,12 +17,13 @@ import { useMediaStore } from "@/stores/media-store";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
import { useState } from "react";
import { SpeedControl } from "./speed-control";
import type { BackgroundType } from "@/types/editor";
export function PropertiesPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
const [backgroundType, setBackgroundType] = useState<
"blur" | "mirror" | "color"
>("blur");
const [backgroundColor, setBackgroundColor] = useState("#000000");
// Get the first video clip for preview (simplified)
@ -77,9 +78,7 @@ export function PropertiesPanel() {
<Label htmlFor="bg-type">Background Type</Label>
<Select
value={backgroundType}
onValueChange={(value: BackgroundType) =>
setBackgroundType(value)
}
onValueChange={(value: any) => setBackgroundType(value)}
>
<SelectTrigger>
<SelectValue placeholder="Select background type" />

View File

@ -1,380 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "../ui/button";
import {
MoreVertical,
Scissors,
Trash2,
SplitSquareHorizontal,
Music,
ChevronRight,
ChevronLeft,
} from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "./audio-waveform";
import { toast } from "sonner";
import { TimelineClipProps, ResizeState } from "@/types/timeline";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "../ui/dropdown-menu";
import { isDragging } from "motion/react";
export function TimelineClip({
clip,
track,
zoomLevel,
isSelected,
onContextMenu,
onClipMouseDown,
onClipClick,
}: TimelineClipProps) {
const { mediaItems } = useMediaStore();
const {
updateClipTrim,
addClipToTrack,
removeClipFromTrack,
dragState,
splitClip,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [clipMenuOpen, setClipMenuOpen] = useState(false);
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
// Use real-time position during drag, otherwise use stored position
const isBeingDragged = dragState.clipId === clip.id;
const clipStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
: clip.startTime;
const clipLeft = clipStartTime * 50 * zoomLevel;
const getTrackColor = (type: string) => {
switch (type) {
case "video":
return "bg-blue-500/20 border-blue-500/30";
case "audio":
return "bg-green-500/20 border-green-500/30";
case "effects":
return "bg-purple-500/20 border-purple-500/30";
default:
return "bg-gray-500/20 border-gray-500/30";
}
};
// Resize handles for trimming clips
const handleResizeStart = (
e: React.MouseEvent,
clipId: string,
side: "left" | "right"
) => {
e.stopPropagation();
e.preventDefault();
setResizing({
clipId,
side,
startX: e.clientX,
initialTrimStart: clip.trimStart,
initialTrimEnd: clip.trimEnd,
});
};
const updateTrimFromMouseMove = (e: { clientX: number }) => {
if (!resizing) return;
const deltaX = e.clientX - resizing.startX;
const deltaTime = deltaX / (50 * zoomLevel);
if (resizing.side === "left") {
const newTrimStart = Math.max(
0,
Math.min(
clip.duration - clip.trimEnd - 0.1,
resizing.initialTrimStart + deltaTime
)
);
updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd);
} else {
const newTrimEnd = Math.max(
0,
Math.min(
clip.duration - clip.trimStart - 0.1,
resizing.initialTrimEnd - deltaTime
)
);
updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd);
}
};
const handleResizeMove = (e: React.MouseEvent) => {
updateTrimFromMouseMove(e);
};
const handleResizeEnd = () => {
setResizing(null);
};
const handleDeleteClip = () => {
removeClipFromTrack(track.id, clip.id);
setClipMenuOpen(false);
toast.success("Clip deleted");
};
const handleSplitClip = () => {
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within clip to split");
return;
}
const secondClipId = splitClip(track.id, clip.id, currentTime);
if (secondClipId) {
toast.success("Clip split successfully");
} else {
toast.error("Failed to split clip");
}
setClipMenuOpen(false);
};
const handleSplitAndKeepLeft = () => {
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within clip");
return;
}
splitAndKeepLeft(track.id, clip.id, currentTime);
toast.success("Split and kept left portion");
setClipMenuOpen(false);
};
const handleSplitAndKeepRight = () => {
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within clip");
return;
}
splitAndKeepRight(track.id, clip.id, currentTime);
toast.success("Split and kept right portion");
setClipMenuOpen(false);
};
const handleSeparateAudio = () => {
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
if (!mediaItem || mediaItem.type !== "video") {
toast.error("Audio separation only available for video clips");
return;
}
const audioClipId = separateAudio(track.id, clip.id);
if (audioClipId) {
toast.success("Audio separated to audio track");
} else {
toast.error("Failed to separate audio");
}
setClipMenuOpen(false);
};
const canSplitAtPlayhead = () => {
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
return currentTime > effectiveStart && currentTime < effectiveEnd;
};
const canSeparateAudio = () => {
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
return mediaItem?.type === "video" && track.type === "video";
};
const renderClipContent = () => {
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
if (!mediaItem) {
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
}
if (mediaItem.type === "image") {
return (
<div className="w-full h-full flex items-center justify-center">
<img
src={mediaItem.url}
alt={mediaItem.name}
className="w-full h-full object-cover"
draggable={false}
/>
</div>
);
}
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
return (
<div className="w-full h-full flex items-center gap-2">
<div className="w-8 h-8 flex-shrink-0">
<img
src={mediaItem.thumbnailUrl}
alt={mediaItem.name}
className="w-full h-full object-cover rounded-sm"
draggable={false}
/>
</div>
<span className="text-xs text-foreground/80 truncate flex-1">
{clip.name}
</span>
</div>
);
}
if (mediaItem.type === "audio") {
return (
<div className="w-full h-full flex items-center gap-2">
<div className="flex-1 min-w-0">
<AudioWaveform
audioUrl={mediaItem.url}
height={24}
className="w-full"
/>
</div>
</div>
);
}
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
};
const handleClipMouseDown = (e: React.MouseEvent) => {
if (onClipMouseDown) {
onClipMouseDown(e, clip);
}
};
return (
<div
className={`absolute top-0 h-full select-none transition-all duration-75 ${
isBeingDragged ? "z-50" : "z-10"
} ${isSelected ? "ring-2 ring-primary" : ""}`}
style={{
left: `${clipLeft}px`,
width: `${clipWidth}px`,
}}
onMouseMove={resizing ? handleResizeMove : undefined}
onMouseUp={resizing ? handleResizeEnd : undefined}
onMouseLeave={resizing ? handleResizeEnd : undefined}
>
<div
className={`relative h-full rounded border cursor-pointer overflow-hidden ${getTrackColor(
track.type
)} ${isSelected ? "ring-2 ring-primary ring-offset-1" : ""}`}
onClick={(e) => onClipClick && onClipClick(e, clip)}
onMouseDown={handleClipMouseDown}
onContextMenu={(e) => onContextMenu && onContextMenu(e, clip.id)}
>
<div className="absolute inset-1 flex items-center p-1">
{renderClipContent()}
</div>
<div
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
/>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
/>
<div className="absolute top-1 right-1">
<DropdownMenu open={clipMenuOpen} onOpenChange={setClipMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 hover:bg-background"
onClick={(e) => {
e.stopPropagation();
setClipMenuOpen(true);
}}
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{/* Split operations - only available when playhead is within clip */}
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={!canSplitAtPlayhead()}>
<Scissors className="mr-2 h-4 w-4" />
Split
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={handleSplitClip}>
<SplitSquareHorizontal className="mr-2 h-4 w-4" />
Split at Playhead
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSplitAndKeepLeft}>
<ChevronLeft className="mr-2 h-4 w-4" />
Split and Keep Left
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSplitAndKeepRight}>
<ChevronRight className="mr-2 h-4 w-4" />
Split and Keep Right
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Audio separation - only available for video clips */}
{canSeparateAudio() && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSeparateAudio}>
<Music className="mr-2 h-4 w-4" />
Separate Audio
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDeleteClip}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Clip
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
);
}

View File

@ -1,219 +0,0 @@
"use client";
import type { TrackType } from "@/types/timeline";
import {
ArrowLeftToLine,
ArrowRightToLine,
Copy,
Pause,
Play,
Scissors,
Snowflake,
SplitSquareHorizontal,
Trash2,
} from "lucide-react";
import { Button } from "../ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
interface TimelineToolbarProps {
isPlaying: boolean;
currentTime: number;
duration: number;
speed: number;
tracks: any[];
toggle: () => void;
setSpeed: (speed: number) => void;
addTrack: (type: TrackType) => string;
addClipToTrack: (trackId: string, clip: any) => void;
handleSplitSelected: () => void;
handleDuplicateSelected: () => void;
handleFreezeSelected: () => void;
handleDeleteSelected: () => void;
}
export function TimelineToolbar({
isPlaying,
currentTime,
duration,
speed,
tracks,
toggle,
setSpeed,
addTrack,
addClipToTrack,
handleSplitSelected,
handleDuplicateSelected,
handleFreezeSelected,
handleDeleteSelected,
}: TimelineToolbarProps) {
return (
<div className="border-b flex items-center px-2 py-1 gap-1">
<TooltipProvider delayDuration={500}>
{/* Play/Pause Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={toggle}
className="mr-2"
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
<div
className="text-xs text-muted-foreground font-mono px-2"
style={{ minWidth: "18ch", textAlign: "center" }}
>
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
{/* Test Clip Button - for debugging */}
{tracks.length === 0 && (
<>
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
const trackId = addTrack("video");
addClipToTrack(trackId, {
mediaId: "test",
name: "Test Clip",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
});
}}
className="text-xs"
>
Add Test Clip
</Button>
</TooltipTrigger>
<TooltipContent>Add a test clip to try playback</TooltipContent>
</Tooltip>
</>
)}
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleSplitSelected}>
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split clip (S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<ArrowLeftToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep left (A)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<ArrowRightToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep right (D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (E)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleDuplicateSelected}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
<Snowflake className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Freeze frame (F)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleDeleteSelected}>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete clip (Delete)</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Speed Control */}
<Tooltip>
<TooltipTrigger asChild>
<Select
value={speed.toFixed(1)}
onValueChange={(value) => setSpeed(parseFloat(value))}
>
<SelectTrigger className="w-[90px] h-8">
<SelectValue placeholder="1.0x" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.5">0.5x</SelectItem>
<SelectItem value="1.0">1.0x</SelectItem>
<SelectItem value="1.5">1.5x</SelectItem>
<SelectItem value="2.0">2.0x</SelectItem>
</SelectContent>
</Select>
</TooltipTrigger>
<TooltipContent>Playback Speed</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,124 +0,0 @@
"use client";
import { motion } from "motion/react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { RiGithubLine, RiTwitterXLine } from "react-icons/ri";
import { getStars } from "@/lib/fetchGhStars";
import Image from "next/image";
export function Footer() {
const [star, setStar] = useState<string>();
useEffect(() => {
const fetchStars = async () => {
try {
const data = await getStars();
setStar(data);
} catch (err) {
console.error("Failed to fetch GitHub stars", err);
}
};
fetchStars();
}, []);
return (
<motion.footer
className="bg-background/80 backdrop-blur-sm border mt-16 m-6 rounded-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8, duration: 0.8 }}
>
<div className="max-w-5xl mx-auto px-4 py-10">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8">
{/* Brand Section */}
<div className="md:col-span-1 max-w-sm">
<div className="flex items-center gap-2 mb-4">
<Image src="/logo.svg" alt="OpenCut" width={24} height={24} />
<span className="font-bold text-lg">OpenCut</span>
</div>
<p className="text-sm text-muted-foreground mb-5">
The open source video editor that gets the job done. Simple,
powerful, and works on any platform.
</p>
<div className="flex gap-3">
<Link
href="https://github.com/OpenCut-app/OpenCut"
className="text-muted-foreground hover:text-foreground transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<RiGithubLine className="h-5 w-5" />
</Link>
<Link
href="https://x.com/OpenCutApp"
className="text-muted-foreground hover:text-foreground transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<RiTwitterXLine className="h-5 w-5" />
</Link>
</div>
</div>
<div className="flex gap-12 justify-end items-start py-2">
<div>
<h3 className="font-semibold text-foreground mb-4">Resources</h3>
<ul className="space-y-2 text-sm">
<li>
<Link
href="/privacy"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Privacy policy
</Link>
</li>
<li>
<Link
href="/terms"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Terms of use
</Link>
</li>
</ul>
</div>
{/* Company Links */}
<div>
<h3 className="font-semibold text-foreground mb-4">Company</h3>
<ul className="space-y-2 text-sm">
<li>
<Link
href="/contributors"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Contributors
</Link>
</li>
<li>
<Link
href="https://github.com/OpenCut-app/OpenCut/blob/main/README.md"
className="text-muted-foreground hover:text-foreground transition-colors"
target="_blank"
rel="noopener noreferrer"
>
About
</Link>
</li>
</ul>
</div>
</div>
</div>
{/* Bottom Section */}
<div className="pt-2 flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>© 2025 OpenCut, All Rights Reserved</span>
</div>
</div>
</div>
</motion.footer>
);
}

View File

@ -29,7 +29,7 @@ export function HeaderBase({
return (
<header
className={cn("px-6 h-14 flex justify-between items-center", className)}
className={cn("px-6 h-16 flex justify-between items-center", className)}
>
{leftContent && <div className="flex items-center">{leftContent}</div>}
{centerContent && (

View File

@ -1,13 +1,14 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
import { useSession } from "@opencut/auth/client";
import { getStars } from "@/lib/fetchGhStars";
import { Star } from "lucide-react";
import { useEffect, useState } from "react";
import Image from "next/image";
export function Header() {
const { data: session } = useSession();
@ -28,43 +29,31 @@ export function Header() {
const leftContent = (
<Link href="/" className="flex items-center gap-3">
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
<span className="text-xl font-medium hidden md:block">OpenCut</span>
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
<span className="font-medium tracking-tight">OpenCut</span>
</Link>
);
const rightContent = (
<nav className="flex items-center gap-3">
<nav className="flex items-center">
<Link href="/contributors">
<Button variant="text" className="text-sm p-0">
<Button variant="text" className="text-sm">
Contributors
</Button>
</Link>
{process.env.NODE_ENV === "development" ? (
<Link href="/editor">
<Button size="sm" className="text-sm ml-4">
Editor
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
) : (
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
<Button size="sm" className="text-sm ml-4">
GitHub {star}+
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
)}
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
<Button variant="text" className="text-sm">
GitHub
</Button>
</Link>
<Link href={session ? "/editor" : "/auth/login"}>
<Button size="sm" className="text-sm ml-4">
Start editing
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</nav>
);
return (
<div className="mx-4 md:mx-0">
<HeaderBase
className="bg-[#1D1D1D] border border-white/10 rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
leftContent={leftContent}
rightContent={rightContent}
/>
</div>
);
return <HeaderBase leftContent={leftContent} rightContent={rightContent} />;
}

View File

@ -29,7 +29,7 @@ export function GithubIcon({ className }: { className?: string }) {
viewBox="0 -3.5 256 256"
preserveAspectRatio="xMinYMin meet"
>
<g fill="currentColor">
<g fill="#161614">
<path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" />
<path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" />

View File

@ -1,153 +1,175 @@
"use client";
import { motion } from "motion/react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { ArrowRight } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import Image from "next/image";
interface HeroProps {
signupCount: number;
}
export function Hero({ signupCount }: HeroProps) {
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) {
toast({
title: "Email required",
description: "Please enter your email address.",
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
const response = await fetch("/api/waitlist", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: email.trim() }),
});
const data = await response.json();
if (response.ok) {
toast({
title: "Welcome to the waitlist! 🎉",
description: "You'll be notified when we launch.",
});
setEmail("");
} else {
toast({
title: "Oops!",
description: data.error || "Something went wrong. Please try again.",
variant: "destructive",
});
}
} catch (error) {
toast({
title: "Network error",
description: "Please check your connection and try again.",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-[calc(100vh-6rem)] supports-[height:100dvh]:min-h-[calc(100dvh-6rem)] flex flex-col justify-between items-center text-center px-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1 }}
className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center"
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.8 }}
className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
>
<h1>The Open Source</h1>
<div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
<div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2">
<Image src="/frame.svg" height={79} width={459} alt="frame" />
<span className="absolute inset-0 flex items-center justify-center">
Video Editor
</span>
</div>
</div>
</motion.div>
<motion.p
className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4, duration: 0.8 }}
>
A simple but powerful video editor that gets the job done. Works on
any platform.
</motion.p>
<motion.div
className="mt-12 flex gap-8 justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6, duration: 0.8 }}
>
<form
onSubmit={handleSubmit}
className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
>
<Input
type="email"
placeholder="Enter your email"
className="h-11 text-base flex-1"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting}
required
/>
<Button
type="submit"
size="lg"
className="px-6 h-11 text-base"
disabled={isSubmitting}
>
<span className="relative z-10">
{isSubmitting ? "Joining..." : "Join waitlist"}
</span>
<ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" />
</Button>
</form>
</motion.div>
{signupCount > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8, duration: 0.6 }}
className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center"
>
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span>{signupCount.toLocaleString()} people already joined</span>
</motion.div>
)}
</motion.div>
</div>
);
}
"use client";
import { motion } from "motion/react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { ArrowRight } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { getStars } from "@/lib/fetchGhStars";
interface HeroProps {
signupCount: number;
}
export function Hero({ signupCount }: HeroProps) {
const [star, setStar] = useState<string>();
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
useEffect(() => {
const fetchStars = async () => {
try {
const data = await getStars();
setStar(data);
} catch (err) {
console.error("Failed to fetch GitHub stars", err);
}
};
fetchStars();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) {
toast({
title: "Email required",
description: "Please enter your email address.",
variant: "destructive",
});
return;
}
setIsSubmitting(true);
try {
const response = await fetch("/api/waitlist", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: email.trim() }),
});
const data = await response.json();
if (response.ok) {
toast({
title: "Welcome to the waitlist! 🎉",
description: "You'll be notified when we launch.",
});
setEmail("");
} else {
toast({
title: "Oops!",
description: data.error || "Something went wrong. Please try again.",
variant: "destructive",
});
}
} catch (error) {
toast({
title: "Network error",
description: "Please check your connection and try again.",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<div className="relative min-h-[calc(100vh-4rem)] flex flex-col items-center justify-center text-center px-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1 }}
className="max-w-3xl mx-auto"
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.8 }}
className="inline-block"
>
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter">
The open source
</h1>
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
video editor
</h1>
</motion.div>
<motion.p
className="mt-10 text-lg sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4, duration: 0.8 }}
>
A simple but powerful video editor that gets the job done. Works on
any platform.
</motion.p>
<motion.div
className="mt-12 flex gap-8 justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6, duration: 0.8 }}
>
<form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg">
<Input
type="email"
placeholder="Enter your email"
className="h-11 text-base flex-1"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting}
required
/>
<Button
type="submit"
size="lg"
className="px-6 h-11 text-base"
disabled={isSubmitting}
>
<span className="relative z-10">
{isSubmitting ? "Joining..." : "Join waitlist"}
</span>
<ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" />
</Button>
</form>
</motion.div>
{signupCount > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8, duration: 0.6 }}
className="mt-6 inline-flex items-center gap-2 bg-muted/30 px-4 py-2 rounded-full text-sm text-muted-foreground"
>
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span>{signupCount.toLocaleString()} people already joined</span>
</motion.div>
)}
</motion.div>
<motion.div
className="absolute bottom-12 left-0 right-0 text-center text-sm text-muted-foreground/60"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8, duration: 0.8 }}
>
Currently in beta Open source on{" "}
<Link
href="https://github.com/OpenCut-app/OpenCut"
className="text-foreground underline"
>
GitHub {star}+
</Link>
</motion.div>
</div>
);
}

View File

@ -2,14 +2,13 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import type { BackgroundType } from "@/types/editor";
interface ImageTimelineTreatmentProps {
src: string;
alt: string;
targetAspectRatio?: number; // Default to 16:9 for video
className?: string;
backgroundType?: BackgroundType;
backgroundType?: "blur" | "mirror" | "color";
backgroundColor?: string;
}

View File

@ -4,128 +4,108 @@ import { useRef, useEffect } from "react";
import { usePlaybackStore } from "@/stores/playback-store";
interface VideoPlayerProps {
src: string;
poster?: string;
className?: string;
clipStartTime: number;
trimStart: number;
trimEnd: number;
clipDuration: number;
src: string;
poster?: string;
className?: string;
clipStartTime: number;
trimStart: number;
trimEnd: number;
clipDuration: number;
}
export function VideoPlayer({
src,
poster,
className = "",
clipStartTime,
trimStart,
trimEnd,
clipDuration,
src,
poster,
className = "",
clipStartTime,
trimStart,
trimEnd,
clipDuration
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
const videoRef = useRef<HTMLVideoElement>(null);
const { isPlaying, currentTime, volume, speed } = usePlaybackStore();
// Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
const isInClipRange =
currentTime >= clipStartTime && currentTime < clipEndTime;
// Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
const isInClipRange = currentTime >= clipStartTime && currentTime < clipEndTime;
// Sync playback events
useEffect(() => {
const video = videoRef.current;
if (!video || !isInClipRange) return;
// Sync playback events
useEffect(() => {
const video = videoRef.current;
if (!video || !isInClipRange) return;
const handleSeekEvent = (e: CustomEvent) => {
// Always update video time, even if outside clip range
const timelineTime = e.detail.time;
const videoTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
video.currentTime = videoTime;
};
const handleSeekEvent = (e: CustomEvent) => {
// Always update video time, even if outside clip range
const timelineTime = e.detail.time;
const videoTime = Math.max(trimStart, Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
));
video.currentTime = videoTime;
};
const handleUpdateEvent = (e: CustomEvent) => {
// Always update video time, even if outside clip range
const timelineTime = e.detail.time;
const targetTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
const handleUpdateEvent = (e: CustomEvent) => {
// Always update video time, even if outside clip range
const timelineTime = e.detail.time;
const targetTime = Math.max(trimStart, Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
));
if (Math.abs(video.currentTime - targetTime) > 0.5) {
video.currentTime = targetTime;
}
};
if (Math.abs(video.currentTime - targetTime) > 0.5) {
video.currentTime = targetTime;
}
};
const handleSpeed = (e: CustomEvent) => {
video.playbackRate = e.detail.speed;
};
const handleSpeed = (e: CustomEvent) => {
video.playbackRate = e.detail.speed;
};
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener(
"playback-update",
handleUpdateEvent as EventListener
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener("playback-update", handleUpdateEvent as EventListener);
window.addEventListener("playback-speed", handleSpeed as EventListener);
return () => {
window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
window.removeEventListener("playback-speed", handleSpeed as EventListener);
};
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
// Sync playback state
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (isPlaying && isInClipRange) {
video.play().catch(() => { });
} else {
video.pause();
}
}, [isPlaying, isInClipRange]);
// Sync volume and speed
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = volume;
video.playbackRate = speed;
}, [volume, speed]);
return (
<video
ref={videoRef}
src={src}
poster={poster}
className={`w-full h-full object-cover ${className}`}
playsInline
preload="auto"
controls={false}
disablePictureInPicture
disableRemotePlayback
style={{ pointerEvents: 'none' }}
onContextMenu={(e) => e.preventDefault()}
/>
);
window.addEventListener("playback-speed", handleSpeed as EventListener);
return () => {
window.removeEventListener(
"playback-seek",
handleSeekEvent as EventListener
);
window.removeEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
window.removeEventListener(
"playback-speed",
handleSpeed as EventListener
);
};
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
// Sync playback state
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (isPlaying && isInClipRange) {
video.play().catch(() => {});
} else {
video.pause();
}
}, [isPlaying, isInClipRange]);
// Sync volume and speed
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = volume;
video.muted = muted;
video.playbackRate = speed;
}, [volume, speed, muted]);
return (
<video
ref={videoRef}
src={src}
poster={poster}
className={`w-full h-full object-cover ${className}`}
playsInline
preload="auto"
controls={false}
disablePictureInPicture
disableRemotePlayback
style={{ pointerEvents: "none" }}
onContextMenu={(e) => e.preventDefault()}
/>
);
}
}

View File

@ -1,226 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
interface DragState {
isDragging: boolean;
clipId: string | null;
trackId: string | null;
startMouseX: number;
startClipTime: number;
clickOffsetTime: number;
currentTime: number;
}
export function useDragClip(zoomLevel: number) {
const { tracks, updateClipStartTime, moveClipToTrack } = useTimelineStore();
const [dragState, setDragState] = useState<DragState>({
isDragging: false,
clipId: null,
trackId: null,
startMouseX: 0,
startClipTime: 0,
clickOffsetTime: 0,
currentTime: 0,
});
const timelineRef = useRef<HTMLDivElement>(null);
const dragStateRef = useRef(dragState);
// Keep ref in sync with state
dragStateRef.current = dragState;
const startDrag = useCallback(
(
e: React.MouseEvent,
clipId: string,
trackId: string,
clipStartTime: number,
clickOffsetTime: number
) => {
e.preventDefault();
e.stopPropagation();
setDragState({
isDragging: true,
clipId,
trackId,
startMouseX: e.clientX,
startClipTime: clipStartTime,
clickOffsetTime,
currentTime: clipStartTime,
});
},
[]
);
const updateDrag = useCallback(
(e: MouseEvent) => {
if (!dragState.isDragging || !timelineRef.current) {
return;
}
const timelineRect = timelineRef.current.getBoundingClientRect();
const mouseX = e.clientX - timelineRect.left;
const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const snappedTime = Math.round(adjustedTime * 10) / 10;
setDragState((prev) => ({
...prev,
currentTime: snappedTime,
}));
},
[dragState.isDragging, dragState.clickOffsetTime, zoomLevel]
);
const endDrag = useCallback(
(targetTrackId?: string) => {
if (!dragState.isDragging || !dragState.clipId || !dragState.trackId)
return;
const finalTrackId = targetTrackId || dragState.trackId;
const finalTime = dragState.currentTime;
// Check for overlaps
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const targetTrack = tracks.find((t) => t.id === finalTrackId);
const movingClip = sourceTrack?.clips.find(
(c) => c.id === dragState.clipId
);
if (!movingClip || !targetTrack) {
setDragState((prev) => ({ ...prev, isDragging: false }));
return;
}
const movingClipDuration =
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
const movingClipEnd = finalTime + movingClipDuration;
const hasOverlap = targetTrack.clips.some((existingClip) => {
// Skip the clip being moved if it's on the same track
if (
dragState.trackId === finalTrackId &&
existingClip.id === dragState.clipId
) {
return false;
}
const existingStart = existingClip.startTime;
const existingEnd =
existingClip.startTime +
(existingClip.duration -
existingClip.trimStart -
existingClip.trimEnd);
return finalTime < existingEnd && movingClipEnd > existingStart;
});
if (!hasOverlap) {
if (dragState.trackId === finalTrackId) {
// Moving within same track
updateClipStartTime(finalTrackId, dragState.clipId!, finalTime);
} else {
// Moving to different track
moveClipToTrack(dragState.trackId!, finalTrackId, dragState.clipId!);
requestAnimationFrame(() => {
updateClipStartTime(finalTrackId, dragState.clipId!, finalTime);
});
}
}
setDragState({
isDragging: false,
clipId: null,
trackId: null,
startMouseX: 0,
startClipTime: 0,
clickOffsetTime: 0,
currentTime: 0,
});
},
[dragState, tracks, updateClipStartTime, moveClipToTrack]
);
const cancelDrag = useCallback(() => {
setDragState({
isDragging: false,
clipId: null,
trackId: null,
startMouseX: 0,
startClipTime: 0,
clickOffsetTime: 0,
currentTime: 0,
});
}, []);
// Global mouse events
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseMove = (e: MouseEvent) => updateDrag(e);
const handleMouseUp = () => endDrag();
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") cancelDrag();
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("keydown", handleEscape);
};
}, [dragState.isDragging, updateDrag, endDrag, cancelDrag]);
const getDraggedClipPosition = useCallback(
(clipId: string) => {
// Use ref to get current state, not stale closure
const currentDragState = dragStateRef.current;
const isMatch =
currentDragState.isDragging && currentDragState.clipId === clipId;
if (isMatch) {
return currentDragState.currentTime;
}
return null;
},
[] // No dependencies needed since we use ref
);
const isValidDropTarget = useCallback(
(trackId: string) => {
if (!dragState.isDragging) return false;
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const targetTrack = tracks.find((t) => t.id === trackId);
if (!sourceTrack || !targetTrack) return false;
// For now, allow drops on same track type
return sourceTrack.type === targetTrack.type;
},
[dragState.isDragging, dragState.trackId, tracks]
);
return {
// State
isDragging: dragState.isDragging,
draggedClipId: dragState.clipId,
currentDragTime: dragState.currentTime,
clickOffsetTime: dragState.clickOffsetTime,
// Methods
startDrag,
endDrag,
cancelDrag,
getDraggedClipPosition,
isValidDropTarget,
// Refs
timelineRef,
};
}

View File

@ -1,174 +1,18 @@
import { useEffect, useCallback } from "react";
import { useEffect } from "react";
import { usePlaybackStore } from "@/stores/playback-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { toast } from "sonner";
export const usePlaybackControls = () => {
const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore();
const {
selectedClips,
tracks,
splitClip,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
} = useTimelineStore();
const handleSplitSelectedClip = useCallback(() => {
if (selectedClips.length !== 1) {
toast.error("Select exactly one clip to split");
return;
}
const { trackId, clipId } = selectedClips[0];
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (!clip) return;
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within selected clip");
return;
}
splitClip(trackId, clipId, currentTime);
toast.success("Clip split at playhead");
}, [selectedClips, tracks, currentTime, splitClip]);
const handleSplitAndKeepLeftCallback = useCallback(() => {
if (selectedClips.length !== 1) {
toast.error("Select exactly one clip");
return;
}
const { trackId, clipId } = selectedClips[0];
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (!clip) return;
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within selected clip");
return;
}
splitAndKeepLeft(trackId, clipId, currentTime);
toast.success("Split and kept left portion");
}, [selectedClips, tracks, currentTime, splitAndKeepLeft]);
const handleSplitAndKeepRightCallback = useCallback(() => {
if (selectedClips.length !== 1) {
toast.error("Select exactly one clip");
return;
}
const { trackId, clipId } = selectedClips[0];
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (!clip) return;
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within selected clip");
return;
}
splitAndKeepRight(trackId, clipId, currentTime);
toast.success("Split and kept right portion");
}, [selectedClips, tracks, currentTime, splitAndKeepRight]);
const handleSeparateAudioCallback = useCallback(() => {
if (selectedClips.length !== 1) {
toast.error("Select exactly one video clip to separate audio");
return;
}
const { trackId, clipId } = selectedClips[0];
const track = tracks.find((t) => t.id === trackId);
if (!track || track.type !== "video") {
toast.error("Select a video clip to separate audio");
return;
}
separateAudio(trackId, clipId);
toast.success("Audio separated to audio track");
}, [selectedClips, tracks, separateAudio]);
const handleKeyPress = useCallback(
(e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return;
}
switch (e.key) {
case " ":
e.preventDefault();
if (isPlaying) {
pause();
} else {
play();
}
break;
case "s":
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
handleSplitSelectedClip();
}
break;
case "q":
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
handleSplitAndKeepLeftCallback();
}
break;
case "w":
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
handleSplitAndKeepRightCallback();
}
break;
case "d":
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
handleSeparateAudioCallback();
}
break;
}
},
[
isPlaying,
play,
pause,
handleSplitSelectedClip,
handleSplitAndKeepLeftCallback,
handleSplitAndKeepRightCallback,
handleSeparateAudioCallback,
]
);
export function usePlaybackControls() {
const { toggle } = usePlaybackStore();
useEffect(() => {
document.addEventListener("keydown", handleKeyPress);
return () => document.removeEventListener("keydown", handleKeyPress);
}, [handleKeyPress]);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Space" && e.target === document.body) {
e.preventDefault();
toggle();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [toggle]);
}

View File

@ -11,15 +11,11 @@ import {
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
export async function processMediaFiles(
files: FileList | File[],
onProgress?: (progress: number) => void
files: FileList | File[]
): Promise<ProcessedMediaItem[]> {
const fileArray = Array.from(files);
const processedItems: ProcessedMediaItem[] = [];
const total = fileArray.length;
let completed = 0;
for (const file of fileArray) {
const fileType = getFileType(file);
@ -61,15 +57,6 @@ export async function processMediaFiles(
duration,
aspectRatio,
});
// Yield back to the event loop to keep the UI responsive
await new Promise((resolve) => setTimeout(resolve, 0));
completed += 1;
if (onProgress) {
const percent = Math.round((completed / total) * 100);
onProgress(percent);
}
} catch (error) {
console.error("Error processing file:", file.name, error);
toast.error(`Failed to process ${file.name}`);

View File

@ -1,5 +1,6 @@
import { db, sql } from "@opencut/db";
import { db } from "@opencut/db";
import { waitlist } from "@opencut/db/schema";
import { sql } from "drizzle-orm";
export async function getWaitlistCount() {
try {

View File

@ -8,9 +8,9 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
let playbackTimer: number | null = null;
const startTimer = (store: () => PlaybackStore) => {
const startTimer = (store: any) => {
if (playbackTimer) cancelAnimationFrame(playbackTimer);
// Use requestAnimationFrame for smoother updates
const updateTime = () => {
const state = store();
@ -18,22 +18,14 @@ const startTimer = (store: () => PlaybackStore) => {
const now = performance.now();
const delta = (now - lastUpdate) / 1000; // Convert to seconds
lastUpdate = now;
const newTime = state.currentTime + delta * state.speed;
const newTime = state.currentTime + (delta * state.speed);
if (newTime >= state.duration) {
// When video completes, pause and reset playhead to start
state.pause();
state.setCurrentTime(0);
// Notify video elements to sync with reset
window.dispatchEvent(
new CustomEvent("playback-seek", { detail: { time: 0 } })
);
} else {
state.setCurrentTime(newTime);
// Notify video elements to sync
window.dispatchEvent(
new CustomEvent("playback-update", { detail: { time: newTime } })
);
window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } }));
}
}
playbackTimer = requestAnimationFrame(updateTime);
@ -55,8 +47,6 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
currentTime: 0,
duration: 0,
volume: 1,
muted: false,
previousVolume: 1,
speed: 1.0,
play: () => {
@ -82,53 +72,22 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
const { duration } = get();
const clampedTime = Math.max(0, Math.min(duration, time));
set({ currentTime: clampedTime });
const event = new CustomEvent("playback-seek", {
detail: { time: clampedTime },
});
// Notify video elements to seek
const event = new CustomEvent('playback-seek', { detail: { time: clampedTime } });
window.dispatchEvent(event);
},
setVolume: (volume: number) =>
set((state) => ({
volume: Math.max(0, Math.min(1, volume)),
muted: volume === 0,
previousVolume: volume > 0 ? volume : state.previousVolume,
})),
setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }),
setSpeed: (speed: number) => {
const newSpeed = Math.max(0.1, Math.min(2.0, speed));
set({ speed: newSpeed });
const event = new CustomEvent("playback-speed", {
detail: { speed: newSpeed },
});
const event = new CustomEvent('playback-speed', { detail: { speed: newSpeed } });
window.dispatchEvent(event);
},
setDuration: (duration: number) => set({ duration }),
setCurrentTime: (time: number) => set({ currentTime: time }),
mute: () => {
const { volume, previousVolume } = get();
set({
muted: true,
previousVolume: volume > 0 ? volume : previousVolume,
volume: 0,
});
},
unmute: () => {
const { previousVolume } = get();
set({ muted: false, volume: previousVolume ?? 1 });
},
toggleMute: () => {
const { muted } = get();
if (muted) {
get().unmute();
} else {
get().mute();
}
},
}));
}));

View File

@ -7,7 +7,6 @@ interface ProjectStore {
// Actions
createNewProject: (name: string) => void;
closeProject: () => void;
updateProjectName: (name: string) => void;
}
export const useProjectStore = create<ProjectStore>((set) => ({
@ -26,16 +25,4 @@ export const useProjectStore = create<ProjectStore>((set) => ({
closeProject: () => {
set({ activeProject: null });
},
updateProjectName: (name: string) => {
set((state) => ({
activeProject: state.activeProject
? {
...state.activeProject,
name,
updatedAt: new Date(),
}
: null,
}));
},
}));

View File

@ -1,567 +1,264 @@
import { create } from "zustand";
import type { TrackType } from "@/types/timeline";
// Helper function to manage clip naming with suffixes
const getClipNameWithSuffix = (
originalName: string,
suffix: string
): string => {
// Remove existing suffixes to prevent accumulation
const baseName = originalName
.replace(/ \(left\)$/, "")
.replace(/ \(right\)$/, "")
.replace(/ \(audio\)$/, "")
.replace(/ \(split \d+\)$/, "");
return `${baseName} (${suffix})`;
};
export interface TimelineClip {
id: string;
mediaId: string;
name: string;
duration: number;
startTime: number;
trimStart: number;
trimEnd: number;
}
export interface TimelineTrack {
id: string;
name: string;
type: TrackType;
clips: TimelineClip[];
muted?: boolean;
}
interface TimelineStore {
tracks: TimelineTrack[];
history: TimelineTrack[][];
redoStack: TimelineTrack[][];
// Multi-selection
selectedClips: { trackId: string; clipId: string }[];
selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
deselectClip: (trackId: string, clipId: string) => void;
clearSelectedClips: () => void;
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
// Drag state
dragState: {
isDragging: boolean;
clipId: string | null;
trackId: string | null;
startMouseX: number;
startClipTime: number;
clickOffsetTime: number;
currentTime: number;
};
setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
startDrag: (
clipId: string,
trackId: string,
startMouseX: number,
startClipTime: number,
clickOffsetTime: number
) => void;
updateDragTime: (currentTime: number) => void;
endDrag: () => void;
// Actions
addTrack: (type: TrackType) => string;
removeTrack: (trackId: string) => void;
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
removeClipFromTrack: (trackId: string, clipId: string) => void;
moveClipToTrack: (
fromTrackId: string,
toTrackId: string,
clipId: string
) => void;
updateClipTrim: (
trackId: string,
clipId: string,
trimStart: number,
trimEnd: number
) => void;
updateClipStartTime: (
trackId: string,
clipId: string,
startTime: number
) => void;
toggleTrackMute: (trackId: string) => void;
// Split operations for clips
splitClip: (
trackId: string,
clipId: string,
splitTime: number
) => string | null;
splitAndKeepLeft: (
trackId: string,
clipId: string,
splitTime: number
) => void;
splitAndKeepRight: (
trackId: string,
clipId: string,
splitTime: number
) => void;
separateAudio: (trackId: string, clipId: string) => string | null;
// Computed values
getTotalDuration: () => number;
// History actions
undo: () => void;
redo: () => void;
pushHistory: () => void;
}
export const useTimelineStore = create<TimelineStore>((set, get) => ({
tracks: [],
history: [],
redoStack: [],
selectedClips: [],
pushHistory: () => {
const { tracks, history, redoStack } = get();
set({
history: [...history, JSON.parse(JSON.stringify(tracks))],
redoStack: [],
});
},
undo: () => {
const { history, redoStack, tracks } = get();
if (history.length === 0) return;
const prev = history[history.length - 1];
set({
tracks: prev,
history: history.slice(0, -1),
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))],
});
},
selectClip: (trackId, clipId, multi = false) => {
set((state) => {
const exists = state.selectedClips.some(
(c) => c.trackId === trackId && c.clipId === clipId
);
if (multi) {
return exists
? {
selectedClips: state.selectedClips.filter(
(c) => !(c.trackId === trackId && c.clipId === clipId)
),
}
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
} else {
return { selectedClips: [{ trackId, clipId }] };
}
});
},
deselectClip: (trackId, clipId) => {
set((state) => ({
selectedClips: state.selectedClips.filter(
(c) => !(c.trackId === trackId && c.clipId === clipId)
),
}));
},
clearSelectedClips: () => {
set({ selectedClips: [] });
},
setSelectedClips: (clips) => set({ selectedClips: clips }),
addTrack: (type) => {
get().pushHistory();
const newTrack: TimelineTrack = {
id: crypto.randomUUID(),
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
type,
clips: [],
muted: false,
};
set((state) => ({
tracks: [...state.tracks, newTrack],
}));
return newTrack.id;
},
removeTrack: (trackId) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.filter((track) => track.id !== trackId),
}));
},
addClipToTrack: (trackId, clipData) => {
get().pushHistory();
const newClip: TimelineClip = {
...clipData,
id: crypto.randomUUID(),
startTime: clipData.startTime || 0,
trimStart: 0,
trimEnd: 0,
};
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? { ...track, clips: [...track.clips, newClip] }
: track
),
}));
},
removeClipFromTrack: (trackId, clipId) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks
.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.filter((clip) => clip.id !== clipId),
}
: track
)
.filter((track) => track.clips.length > 0),
}));
},
moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
get().pushHistory();
set((state) => {
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
if (!clipToMove) return state;
return {
tracks: state.tracks
.map((track) => {
if (track.id === fromTrackId) {
return {
...track,
clips: track.clips.filter((clip) => clip.id !== clipId),
};
} else if (track.id === toTrackId) {
return {
...track,
clips: [...track.clips, clipToMove],
};
}
return track;
})
.filter((track) => track.clips.length > 0),
};
});
},
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((clip) =>
clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
),
}
: track
),
}));
},
updateClipStartTime: (trackId, clipId, startTime) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((clip) =>
clip.id === clipId ? { ...clip, startTime } : clip
),
}
: track
),
}));
},
toggleTrackMute: (trackId) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId ? { ...track, muted: !track.muted } : track
),
}));
},
splitClip: (trackId, clipId, splitTime) => {
const { tracks } = get();
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (!clip) return null;
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
get().pushHistory();
const relativeTime = splitTime - clip.startTime;
const firstDuration = relativeTime;
const secondDuration =
clip.duration - clip.trimStart - clip.trimEnd - relativeTime;
const secondClipId = crypto.randomUUID();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.flatMap((c) =>
c.id === clipId
? [
{
...c,
trimEnd: c.trimEnd + secondDuration,
name: getClipNameWithSuffix(c.name, "left"),
},
{
...c,
id: secondClipId,
startTime: splitTime,
trimStart: c.trimStart + firstDuration,
name: getClipNameWithSuffix(c.name, "right"),
},
]
: [c]
),
}
: track
),
}));
return secondClipId;
},
// Split clip and keep only the left portion
splitAndKeepLeft: (trackId, clipId, splitTime) => {
const { tracks } = get();
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (!clip) return;
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
get().pushHistory();
const relativeTime = splitTime - clip.startTime;
const durationToRemove =
clip.duration - clip.trimStart - clip.trimEnd - relativeTime;
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((c) =>
c.id === clipId
? {
...c,
trimEnd: c.trimEnd + durationToRemove,
name: getClipNameWithSuffix(c.name, "left"),
}
: c
),
}
: track
),
}));
},
// Split clip and keep only the right portion
splitAndKeepRight: (trackId, clipId, splitTime) => {
const { tracks } = get();
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (!clip) return;
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
get().pushHistory();
const relativeTime = splitTime - clip.startTime;
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((c) =>
c.id === clipId
? {
...c,
startTime: splitTime,
trimStart: c.trimStart + relativeTime,
name: getClipNameWithSuffix(c.name, "right"),
}
: c
),
}
: track
),
}));
},
// Extract audio from video clip to an audio track
separateAudio: (trackId, clipId) => {
const { tracks } = get();
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (!clip || track?.type !== "video") return null;
get().pushHistory();
// Find existing audio track or prepare to create one
const existingAudioTrack = tracks.find((t) => t.type === "audio");
const audioClipId = crypto.randomUUID();
if (existingAudioTrack) {
// Add audio clip to existing audio track
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === existingAudioTrack.id
? {
...track,
clips: [
...track.clips,
{
...clip,
id: audioClipId,
name: getClipNameWithSuffix(clip.name, "audio"),
},
],
}
: track
),
}));
} else {
// Create new audio track with the audio clip in a single atomic update
const newAudioTrack: TimelineTrack = {
id: crypto.randomUUID(),
name: "Audio Track",
type: "audio",
clips: [
{
...clip,
id: audioClipId,
name: getClipNameWithSuffix(clip.name, "audio"),
},
],
muted: false,
};
set((state) => ({
tracks: [...state.tracks, newAudioTrack],
}));
}
return audioClipId;
},
getTotalDuration: () => {
const { tracks } = get();
if (tracks.length === 0) return 0;
const trackEndTimes = tracks.map((track) =>
track.clips.reduce((maxEnd, clip) => {
const clipEnd =
clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
return Math.max(maxEnd, clipEnd);
}, 0)
);
return Math.max(...trackEndTimes, 0);
},
redo: () => {
const { redoStack } = get();
if (redoStack.length === 0) return;
const next = redoStack[redoStack.length - 1];
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
},
dragState: {
isDragging: false,
clipId: null,
trackId: null,
startMouseX: 0,
startClipTime: 0,
clickOffsetTime: 0,
currentTime: 0,
},
setDragState: (dragState) =>
set((state) => ({
dragState: { ...state.dragState, ...dragState },
})),
startDrag: (clipId, trackId, startMouseX, startClipTime, clickOffsetTime) => {
set({
dragState: {
isDragging: true,
clipId,
trackId,
startMouseX,
startClipTime,
clickOffsetTime,
currentTime: startClipTime,
},
});
},
updateDragTime: (currentTime) => {
set((state) => ({
dragState: {
...state.dragState,
currentTime,
},
}));
},
endDrag: () => {
set({
dragState: {
isDragging: false,
clipId: null,
trackId: null,
startMouseX: 0,
startClipTime: 0,
clickOffsetTime: 0,
currentTime: 0,
},
});
},
}));
import { create } from "zustand";
export interface TimelineClip {
id: string;
mediaId: string;
name: string;
duration: number;
startTime: number;
trimStart: number;
trimEnd: number;
}
export interface TimelineTrack {
id: string;
name: string;
type: "video" | "audio" | "effects";
clips: TimelineClip[];
muted?: boolean;
}
interface TimelineStore {
tracks: TimelineTrack[];
history: TimelineTrack[][];
redoStack: TimelineTrack[][];
// Multi-selection
selectedClips: { trackId: string; clipId: string }[];
selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
deselectClip: (trackId: string, clipId: string) => void;
clearSelectedClips: () => void;
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
// Actions
addTrack: (type: "video" | "audio" | "effects") => string;
removeTrack: (trackId: string) => void;
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
removeClipFromTrack: (trackId: string, clipId: string) => void;
moveClipToTrack: (
fromTrackId: string,
toTrackId: string,
clipId: string
) => void;
updateClipTrim: (
trackId: string,
clipId: string,
trimStart: number,
trimEnd: number
) => void;
updateClipStartTime: (
trackId: string,
clipId: string,
startTime: number
) => void;
toggleTrackMute: (trackId: string) => void;
// Computed values
getTotalDuration: () => number;
// New actions
undo: () => void;
redo: () => void;
pushHistory: () => void;
}
export const useTimelineStore = create<TimelineStore>((set, get) => ({
tracks: [],
history: [],
redoStack: [],
selectedClips: [],
pushHistory: () => {
const { tracks, history, redoStack } = get();
// Deep copy tracks
set({
history: [...history, JSON.parse(JSON.stringify(tracks))],
redoStack: [] // Clear redo stack when new action is performed
});
},
undo: () => {
const { history, redoStack, tracks } = get();
if (history.length === 0) return;
const prev = history[history.length - 1];
set({
tracks: prev,
history: history.slice(0, -1),
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack
});
},
selectClip: (trackId, clipId, multi = false) => {
set((state) => {
const exists = state.selectedClips.some(
(c) => c.trackId === trackId && c.clipId === clipId
);
if (multi) {
// Toggle selection
return exists
? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) }
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
} else {
return { selectedClips: [{ trackId, clipId }] };
}
});
},
deselectClip: (trackId, clipId) => {
set((state) => ({
selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)),
}));
},
clearSelectedClips: () => {
set({ selectedClips: [] });
},
setSelectedClips: (clips) => set({ selectedClips: clips }),
addTrack: (type) => {
get().pushHistory();
const newTrack: TimelineTrack = {
id: crypto.randomUUID(),
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
type,
clips: [],
muted: false,
};
set((state) => ({
tracks: [...state.tracks, newTrack],
}));
return newTrack.id;
},
removeTrack: (trackId) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.filter((track) => track.id !== trackId),
}));
},
addClipToTrack: (trackId, clipData) => {
get().pushHistory();
const newClip: TimelineClip = {
...clipData,
id: crypto.randomUUID(),
startTime: clipData.startTime || 0,
trimStart: 0,
trimEnd: 0,
};
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? { ...track, clips: [...track.clips, newClip] }
: track
),
}));
},
removeClipFromTrack: (trackId, clipId) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks
.map((track) =>
track.id === trackId
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
: track
)
// Remove track if it becomes empty
.filter((track) => track.clips.length > 0),
}));
},
moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
get().pushHistory();
set((state) => {
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
if (!clipToMove) return state;
return {
tracks: state.tracks
.map((track) => {
if (track.id === fromTrackId) {
return {
...track,
clips: track.clips.filter((clip) => clip.id !== clipId),
};
} else if (track.id === toTrackId) {
return {
...track,
clips: [...track.clips, clipToMove],
};
}
return track;
})
// Remove track if it becomes empty
.filter((track) => track.clips.length > 0),
};
});
},
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((clip) =>
clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
),
}
: track
),
}));
},
updateClipStartTime: (trackId, clipId, startTime) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((clip) =>
clip.id === clipId ? { ...clip, startTime } : clip
),
}
: track
),
}));
},
toggleTrackMute: (trackId) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId ? { ...track, muted: !track.muted } : track
),
}));
},
getTotalDuration: () => {
const { tracks } = get();
if (tracks.length === 0) return 0;
const trackEndTimes = tracks.map((track) =>
track.clips.reduce((maxEnd, clip) => {
const clipEnd =
clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
return Math.max(maxEnd, clipEnd);
}, 0)
);
return Math.max(...trackEndTimes, 0);
},
redo: () => {
const { redoStack } = get();
if (redoStack.length === 0) return;
const next = redoStack[redoStack.length - 1];
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
},
}));

View File

@ -1 +0,0 @@
export type BackgroundType = "blur" | "mirror" | "color";

View File

@ -4,8 +4,6 @@ export interface PlaybackState {
duration: number;
volume: number;
speed: number;
muted: boolean;
previousVolume?: number;
}
export interface PlaybackControls {
@ -15,7 +13,4 @@ export interface PlaybackControls {
setVolume: (volume: number) => void;
setSpeed: (speed: number) => void;
toggle: () => void;
mute: () => void;
unmute: () => void;
toggleMute: () => void;
}
}

View File

@ -1,29 +0,0 @@
import { TimelineTrack, TimelineClip } from "@/stores/timeline-store";
export type TrackType = "video" | "audio" | "effects";
export interface TimelineClipProps {
clip: TimelineClip;
track: TimelineTrack;
zoomLevel: number;
isSelected: boolean;
onContextMenu: (e: React.MouseEvent, clipId: string) => void;
onClipMouseDown: (e: React.MouseEvent, clip: TimelineClip) => void;
onClipClick: (e: React.MouseEvent, clip: TimelineClip) => void;
}
export interface ResizeState {
clipId: string;
side: "left" | "right";
startX: number;
initialTrimStart: number;
initialTrimEnd: number;
}
export interface ContextMenuState {
type: "track" | "clip";
trackId: string;
clipId?: string;
x: number;
y: number;
}

View File

@ -69,7 +69,7 @@ export default {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 6px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {

View File

@ -4,7 +4,6 @@
"": {
"dependencies": {
"next": "^15.3.4",
"wavesurfer.js": "^7.9.8",
},
"devDependencies": {
"turbo": "^2.5.4",
@ -431,7 +430,7 @@
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/node": ["@types/node@22.15.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wzoocdnnpSxZ+6CjW4ADCK1jVmd1S/J3ArNWfn8FDDQtRm8dkDg7TA+mvek2wNrfCgwuZxqEOiB9B1XCJ6+dbw=="],
"@types/node": ["@types/node@22.15.32", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA=="],
"@types/pg": ["@types/pg@8.15.4", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg=="],
@ -903,8 +902,6 @@
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"wavesurfer.js": ["wavesurfer.js@7.9.8", "", {}, "sha512-Mxz6qRwkSmuWVxLzp0XQ6EzSv1FTvQgMEUJTirLN1Ox76sn0YeyQlI99WuE+B0IuxShPHXIhvEuoBSJdaQs7tA=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],

View File

@ -11,7 +11,7 @@ services:
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U opencut"]
test: ["CMD", "pg_isready -U opencut"]
interval: 30s
timeout: 10s
retries: 5
@ -48,7 +48,7 @@ services:
start_period: 10s
web:
build:
context: .
context: ./apps/web
dockerfile: ./apps/web/Dockerfile
restart: unless-stopped
ports:

View File

@ -1,5 +1,4 @@
{
"name": "opencut",
"packageManager": "bun@1.2.17",
"devDependencies": {
"turbo": "^2.5.4"
@ -16,7 +15,6 @@
"format": "turbo run format"
},
"dependencies": {
"next": "^15.3.4",
"wavesurfer.js": "^7.9.8"
"next": "^15.3.4"
}
}
}

View File

@ -16,7 +16,7 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
socialProviders: {
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
@ -26,4 +26,4 @@ export const auth = betterAuth({
trustedOrigins: ["http://localhost:3000"],
});
export type Auth = typeof auth;
export type Auth = typeof auth;

View File

@ -2,43 +2,15 @@ import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
// Create a lazy database instance that only initializes when accessed
let _db: ReturnType<typeof drizzle> | null = null;
function getDb() {
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not set");
}
if (!_db) {
const client = postgres(process.env.DATABASE_URL);
_db = drizzle(client, { schema });
}
return _db;
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not set");
}
// Export a proxy that forwards all calls to the actual db instance
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
get(target, prop) {
return getDb()[prop as keyof typeof _db];
},
});
// Create the postgres client
const client = postgres(process.env.DATABASE_URL);
// Create the drizzle instance
export const db = drizzle(client, { schema });
// Re-export schema for convenience
export * from "./schema";
// Re-export drizzle-orm functions to ensure version consistency
export {
eq,
and,
or,
not,
isNull,
isNotNull,
inArray,
notInArray,
exists,
notExists,
sql,
} from "drizzle-orm";
export * from "./schema";