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 - name: Install Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
with: with:
bun-version: 1.2.17 bun-version: 1.2.2
- name: Cache Bun modules - name: Cache Bun modules
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/.bun/install/cache 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 - name: Install dependencies
working-directory: apps/web working-directory: apps/web

7
.gitignore vendored
View File

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

View File

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

View File

@ -1,45 +1,30 @@
FROM oven/bun:alpine AS base FROM oven/bun:latest AS base
# Install dependencies and build the application
FROM base AS builder
# Install dependencies
FROM base AS deps
WORKDIR /app WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY package.json package.json # Build the application
COPY bun.lock bun.lock FROM base AS builder
COPY turbo.json turbo.json WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY apps/web/package.json apps/web/package.json COPY . .
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
RUN bun run build RUN bun run build
# Production image # Production image
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN chown nextjs:nodejs apps
USER nextjs USER nextjs
@ -48,4 +33,4 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" 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 { export default {
schema: "../../packages/db/src/schema.ts", schema: "./src/lib/db/schema.ts",
dialect: "postgresql", dialect: "postgresql",
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL, 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]] [[plugins]]
package = "@netlify/plugin-nextjs" package = "@netlify/plugin-nextjs"
# Redirects for domain migration
[[redirects]] [[redirects]]
from = "https://appcut.app/*" from = "https://appcut.app/*"
to = "https://opencut.app/:splat" to = "https://opencut.app/:splat"
status = 301 status = 301
force = true force = true

View File

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

View File

@ -21,6 +21,7 @@
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@opencut/auth": "workspace:*", "@opencut/auth": "workspace:*",
"@opencut/db": "workspace:*", "@opencut/db": "workspace:*",
"@types/pg": "^8.15.4",
"@upstash/ratelimit": "^2.0.5", "@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.35.0", "@upstash/redis": "^1.35.0",
"@vercel/analytics": "^1.4.1", "@vercel/analytics": "^1.4.1",
@ -56,7 +57,6 @@
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/pg": "^8.15.4",
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
@ -68,4 +68,4 @@
"tsx": "^4.7.1", "tsx": "^4.7.1",
"typescript": "^5" "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 { try {
await signIn.social({ await signIn.social({
provider: "google", provider: "google",
callbackURL: "/editor",
}); });
router.push("/editor");
} catch (error) { } catch (error) {
setError("Failed to sign in with Google. Please try again."); setError("Failed to sign in with Google. Please try again.");
setIsGoogleLoading(false); 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 { NextRequest, NextResponse } from "next/server";
import { db, eq } from "@opencut/db"; import { db } from "@opencut/db";
import { waitlist } from "@opencut/db/schema"; import { waitlist } from "@opencut/db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { waitlistRateLimit } from "@/lib/rate-limit"; import { waitlistRateLimit } from "@/lib/rate-limit";
import { z } from "zod"; import { z } from "zod";

View File

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

View File

@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { Analytics } from "@vercel/analytics/react"; import { Analytics } from "@vercel/analytics/react";
@ -5,15 +6,17 @@ import Script from "next/script";
import "./globals.css"; import "./globals.css";
import { Toaster } from "../components/ui/sonner"; import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip"; import { TooltipProvider } from "../components/ui/tooltip";
import { DevelopmentDebug } from "../components/development-debug";
import { baseMetaData } from "./metadata";
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-inter", 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({ export default function RootLayout({
children, children,
@ -28,7 +31,6 @@ export default function RootLayout({
{children} {children}
<Analytics /> <Analytics />
<Toaster /> <Toaster />
<DevelopmentDebug />
<Script <Script
src="https://app.databuddy.cc/databuddy.js" src="https://app.databuddy.cc/databuddy.js"
strategy="afterInteractive" 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 { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import { Footer } from "@/components/footer"; import { getWaitlistCount } from "@/lib/waitlist";
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";
// Force dynamic rendering so waitlist count updates in real-time
export const dynamic = "force-dynamic"; export default async function Home() {
const signupCount = await getWaitlistCount();
export default async function Home() {
const signupCount = await getWaitlistCount(); return (
<div>
return ( <Header />
<div> <Hero signupCount={signupCount} />
<Image </div>
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>
);
}

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 Link from "next/link";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react"; import { ChevronLeft, Download } from "lucide-react";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base"; import { HeaderBase } from "./header-base";
import { ProjectNameEditor } from "./editor/project-name-editor";
export function EditorHeader() { export function EditorHeader() {
const { activeProject } = useProjectStore();
const { getTotalDuration } = useTimelineStore(); const { getTotalDuration } = useTimelineStore();
const handleExport = () => { const handleExport = () => {
@ -23,15 +24,13 @@ export function EditorHeader() {
}; };
const leftContent = ( const leftContent = (
<div className="flex items-center gap-2"> <Link
<Link href="/"
href="/" className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity" >
> <ChevronLeft className="h-4 w-4" />
<ChevronLeft className="h-4 w-4" /> <span className="text-sm">{activeProject?.name || "Loading..."}</span>
</Link> </Link>
<ProjectNameEditor />
</div>
); );
const centerContent = ( 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"; "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 { processMediaFiles } from "@/lib/media-processing";
import { useMediaStore, type MediaItem } from "@/stores/media-store"; import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
import { useTimelineStore } from "@/stores/timeline-store"; import { useDragDrop } from "@/hooks/use-drag-drop";
import { Image, Music, Plus, Trash2, Upload, Video } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; 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. // 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. // 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 { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all"); const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => { const processFiles = async (files: FileList | File[]) => {
if (!files || files.length === 0) return; // If no files, do nothing
if (!files?.length) return;
setIsProcessing(true); setIsProcessing(true);
setProgress(0);
try { try {
// Process files (extract metadata, generate thumbnails, etc.) // Process files (extract metadata, generate thumbnails, etc.)
const processedItems = await processMediaFiles(files, (p) => const items = await processMediaFiles(files);
setProgress(p)
);
// Add each processed media item to the store // Add each processed media item to the store
processedItems.forEach((item) => addMediaItem(item)); items.forEach((item) => {
addMediaItem(item);
});
} catch (error) { } catch (error) {
// Show error toast if processing fails // Show error if processing fails
console.error("Error processing files:", error); console.error("File processing failed:", error);
toast.error("Failed to process files"); toast.error("Failed to process files");
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
setProgress(0);
} }
}; };
@ -59,21 +57,6 @@ export function MediaPanel() {
const handleRemove = (e: React.MouseEvent, id: string) => { const handleRemove = (e: React.MouseEvent, id: string) => {
// Remove a media item from the store // Remove a media item from the store
e.stopPropagation(); 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); removeMediaItem(id);
}; };
@ -84,7 +67,7 @@ export function MediaPanel() {
return `${min}:${sec.toString().padStart(2, "0")}`; 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 // When dragging a media item, set drag data for timeline to read
e.dataTransfer.setData( e.dataTransfer.setData(
"application/x-media-item", "application/x-media-item",
@ -101,24 +84,21 @@ export function MediaPanel() {
useEffect(() => { useEffect(() => {
const filtered = mediaItems.filter((item) => { const filtered = mediaItems.filter((item) => {
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { if (mediaFilter && mediaFilter !== 'all' && item.type !== mediaFilter) {
return false; return false;
} }
if ( if (searchQuery && !item.name.toLowerCase().includes(searchQuery.toLowerCase())) {
searchQuery &&
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false; return false;
} }
return true; return true;
}); });
setFilteredMediaItems(filtered); setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]); }, [mediaItems, mediaFilter, searchQuery]);
const renderPreview = (item: MediaItem) => { const renderPreview = (item: any) => {
// Render a preview for each media type (image, video, audio, unknown) // Render a preview for each media type (image, video, audio, unknown)
// Each preview is draggable to the timeline // Each preview is draggable to the timeline
const baseDragProps = { const baseDragProps = {
@ -229,23 +209,23 @@ export function MediaPanel() {
{/* Button to add/upload media */} {/* Button to add/upload media */}
<div className="flex gap-2"> <div className="flex gap-2">
{/* Search and filter controls */} {/* Search and filter controls */}
<select <select
value={mediaFilter} value={mediaFilter}
onChange={(e) => setMediaFilter(e.target.value)} onChange={(e) => setMediaFilter(e.target.value)}
className="px-2 py-1 text-xs border rounded bg-background" className="px-2 py-1 text-xs border rounded bg-background"
> >
<option value="all">All</option> <option value="all">All</option>
<option value="video">Video</option> <option value="video">Video</option>
<option value="audio">Audio</option> <option value="audio">Audio</option>
<option value="image">Image</option> <option value="image">Image</option>
</select> </select>
<input <input
type="text" type="text"
placeholder="Search media..." placeholder="Search media..."
className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background" className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
{/* Add media button */} {/* Add media button */}
<Button <Button
@ -253,23 +233,21 @@ export function MediaPanel() {
size="sm" size="sm"
onClick={handleFileSelect} onClick={handleFileSelect}
disabled={isProcessing} 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 ? ( {isProcessing ? (
<> <>
<Upload className="h-4 w-4 animate-spin" /> <Upload className="h-4 w-4 mr-2 animate-spin" />
<span className="hidden md:inline ml-2">{progress}%</span> Processing...
</> </>
) : ( ) : (
<> <>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
<span className="hidden sm:inline ml-2" aria-label="Add file"> Add
Add
</span>
</> </>
)} )}
</Button> </Button>
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto p-2">
@ -298,15 +276,7 @@ export function MediaPanel() {
<AspectRatio ratio={item.aspectRatio}> <AspectRatio ratio={item.aspectRatio}>
{renderPreview(item)} {renderPreview(item)}
</AspectRatio> </AspectRatio>
<span <span className="text-xs truncate px-1">{item.name}</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>
</Button> </Button>
{/* Show remove button on hover */} {/* Show remove button on hover */}

View File

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

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

View File

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

View File

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

View File

@ -4,128 +4,108 @@ import { useRef, useEffect } from "react";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
interface VideoPlayerProps { interface VideoPlayerProps {
src: string; src: string;
poster?: string; poster?: string;
className?: string; className?: string;
clipStartTime: number; clipStartTime: number;
trimStart: number; trimStart: number;
trimEnd: number; trimEnd: number;
clipDuration: number; clipDuration: number;
} }
export function VideoPlayer({ export function VideoPlayer({
src, src,
poster, poster,
className = "", className = "",
clipStartTime, clipStartTime,
trimStart, trimStart,
trimEnd, trimEnd,
clipDuration, clipDuration
}: VideoPlayerProps) { }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore(); const { isPlaying, currentTime, volume, speed } = usePlaybackStore();
// Calculate if we're within this clip's timeline range // Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
const isInClipRange = const isInClipRange = currentTime >= clipStartTime && currentTime < clipEndTime;
currentTime >= clipStartTime && currentTime < clipEndTime;
// Sync playback events // Sync playback events
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!video || !isInClipRange) return; if (!video || !isInClipRange) return;
const handleSeekEvent = (e: CustomEvent) => { const handleSeekEvent = (e: CustomEvent) => {
// Always update video time, even if outside clip range // Always update video time, even if outside clip range
const timelineTime = e.detail.time; const timelineTime = e.detail.time;
const videoTime = Math.max( const videoTime = Math.max(trimStart, Math.min(
trimStart, clipDuration - trimEnd,
Math.min( timelineTime - clipStartTime + trimStart
clipDuration - trimEnd, ));
timelineTime - clipStartTime + trimStart video.currentTime = videoTime;
) };
);
video.currentTime = videoTime;
};
const handleUpdateEvent = (e: CustomEvent) => { const handleUpdateEvent = (e: CustomEvent) => {
// Always update video time, even if outside clip range // Always update video time, even if outside clip range
const timelineTime = e.detail.time; const timelineTime = e.detail.time;
const targetTime = Math.max( const targetTime = Math.max(trimStart, Math.min(
trimStart, clipDuration - trimEnd,
Math.min( timelineTime - clipStartTime + trimStart
clipDuration - trimEnd, ));
timelineTime - clipStartTime + trimStart
)
);
if (Math.abs(video.currentTime - targetTime) > 0.5) { if (Math.abs(video.currentTime - targetTime) > 0.5) {
video.currentTime = targetTime; video.currentTime = targetTime;
} }
}; };
const handleSpeed = (e: CustomEvent) => { const handleSpeed = (e: CustomEvent) => {
video.playbackRate = e.detail.speed; video.playbackRate = e.detail.speed;
}; };
window.addEventListener("playback-seek", handleSeekEvent as EventListener); window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener( window.addEventListener("playback-update", handleUpdateEvent as EventListener);
"playback-update", window.addEventListener("playback-speed", handleSpeed as EventListener);
handleUpdateEvent 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 { usePlaybackStore } from "@/stores/playback-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { toast } from "sonner";
export const usePlaybackControls = () => { export function usePlaybackControls() {
const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore(); const { toggle } = 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,
]
);
useEffect(() => { useEffect(() => {
document.addEventListener("keydown", handleKeyPress); const handleKeyDown = (e: KeyboardEvent) => {
return () => document.removeEventListener("keydown", handleKeyPress); if (e.code === "Space" && e.target === document.body) {
}, [handleKeyPress]); 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 interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
export async function processMediaFiles( export async function processMediaFiles(
files: FileList | File[], files: FileList | File[]
onProgress?: (progress: number) => void
): Promise<ProcessedMediaItem[]> { ): Promise<ProcessedMediaItem[]> {
const fileArray = Array.from(files); const fileArray = Array.from(files);
const processedItems: ProcessedMediaItem[] = []; const processedItems: ProcessedMediaItem[] = [];
const total = fileArray.length;
let completed = 0;
for (const file of fileArray) { for (const file of fileArray) {
const fileType = getFileType(file); const fileType = getFileType(file);
@ -61,15 +57,6 @@ export async function processMediaFiles(
duration, duration,
aspectRatio, 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) { } catch (error) {
console.error("Error processing file:", file.name, error); console.error("Error processing file:", file.name, error);
toast.error(`Failed to process ${file.name}`); 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 { waitlist } from "@opencut/db/schema";
import { sql } from "drizzle-orm";
export async function getWaitlistCount() { export async function getWaitlistCount() {
try { try {

View File

@ -8,9 +8,9 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
let playbackTimer: number | null = null; let playbackTimer: number | null = null;
const startTimer = (store: () => PlaybackStore) => { const startTimer = (store: any) => {
if (playbackTimer) cancelAnimationFrame(playbackTimer); if (playbackTimer) cancelAnimationFrame(playbackTimer);
// Use requestAnimationFrame for smoother updates // Use requestAnimationFrame for smoother updates
const updateTime = () => { const updateTime = () => {
const state = store(); const state = store();
@ -18,22 +18,14 @@ const startTimer = (store: () => PlaybackStore) => {
const now = performance.now(); const now = performance.now();
const delta = (now - lastUpdate) / 1000; // Convert to seconds const delta = (now - lastUpdate) / 1000; // Convert to seconds
lastUpdate = now; lastUpdate = now;
const newTime = state.currentTime + delta * state.speed; const newTime = state.currentTime + (delta * state.speed);
if (newTime >= state.duration) { if (newTime >= state.duration) {
// When video completes, pause and reset playhead to start
state.pause(); state.pause();
state.setCurrentTime(0);
// Notify video elements to sync with reset
window.dispatchEvent(
new CustomEvent("playback-seek", { detail: { time: 0 } })
);
} else { } else {
state.setCurrentTime(newTime); state.setCurrentTime(newTime);
// Notify video elements to sync // Notify video elements to sync
window.dispatchEvent( window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } }));
new CustomEvent("playback-update", { detail: { time: newTime } })
);
} }
} }
playbackTimer = requestAnimationFrame(updateTime); playbackTimer = requestAnimationFrame(updateTime);
@ -55,8 +47,6 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
currentTime: 0, currentTime: 0,
duration: 0, duration: 0,
volume: 1, volume: 1,
muted: false,
previousVolume: 1,
speed: 1.0, speed: 1.0,
play: () => { play: () => {
@ -82,53 +72,22 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
const { duration } = get(); const { duration } = get();
const clampedTime = Math.max(0, Math.min(duration, time)); const clampedTime = Math.max(0, Math.min(duration, time));
set({ currentTime: clampedTime }); set({ currentTime: clampedTime });
const event = new CustomEvent("playback-seek", { // Notify video elements to seek
detail: { time: clampedTime }, const event = new CustomEvent('playback-seek', { detail: { time: clampedTime } });
});
window.dispatchEvent(event); window.dispatchEvent(event);
}, },
setVolume: (volume: number) => setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }),
set((state) => ({
volume: Math.max(0, Math.min(1, volume)),
muted: volume === 0,
previousVolume: volume > 0 ? volume : state.previousVolume,
})),
setSpeed: (speed: number) => { setSpeed: (speed: number) => {
const newSpeed = Math.max(0.1, Math.min(2.0, speed)); const newSpeed = Math.max(0.1, Math.min(2.0, speed));
set({ speed: newSpeed }); set({ speed: newSpeed });
const event = new CustomEvent("playback-speed", { const event = new CustomEvent('playback-speed', { detail: { speed: newSpeed } });
detail: { speed: newSpeed },
});
window.dispatchEvent(event); window.dispatchEvent(event);
}, },
setDuration: (duration: number) => set({ duration }), setDuration: (duration: number) => set({ duration }),
setCurrentTime: (time: number) => set({ currentTime: time }), 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 // Actions
createNewProject: (name: string) => void; createNewProject: (name: string) => void;
closeProject: () => void; closeProject: () => void;
updateProjectName: (name: string) => void;
} }
export const useProjectStore = create<ProjectStore>((set) => ({ export const useProjectStore = create<ProjectStore>((set) => ({
@ -26,16 +25,4 @@ export const useProjectStore = create<ProjectStore>((set) => ({
closeProject: () => { closeProject: () => {
set({ activeProject: null }); 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 { create } from "zustand";
import type { TrackType } from "@/types/timeline";
export interface TimelineClip {
// Helper function to manage clip naming with suffixes id: string;
const getClipNameWithSuffix = ( mediaId: string;
originalName: string, name: string;
suffix: string duration: number;
): string => { startTime: number;
// Remove existing suffixes to prevent accumulation trimStart: number;
const baseName = originalName trimEnd: number;
.replace(/ \(left\)$/, "") }
.replace(/ \(right\)$/, "")
.replace(/ \(audio\)$/, "") export interface TimelineTrack {
.replace(/ \(split \d+\)$/, ""); id: string;
name: string;
return `${baseName} (${suffix})`; type: "video" | "audio" | "effects";
}; clips: TimelineClip[];
muted?: boolean;
export interface TimelineClip { }
id: string;
mediaId: string; interface TimelineStore {
name: string; tracks: TimelineTrack[];
duration: number; history: TimelineTrack[][];
startTime: number; redoStack: TimelineTrack[][];
trimStart: number;
trimEnd: number; // Multi-selection
} selectedClips: { trackId: string; clipId: string }[];
selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
export interface TimelineTrack { deselectClip: (trackId: string, clipId: string) => void;
id: string; clearSelectedClips: () => void;
name: string; setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
type: TrackType;
clips: TimelineClip[]; // Actions
muted?: boolean; addTrack: (type: "video" | "audio" | "effects") => string;
} removeTrack: (trackId: string) => void;
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
interface TimelineStore { removeClipFromTrack: (trackId: string, clipId: string) => void;
tracks: TimelineTrack[]; moveClipToTrack: (
history: TimelineTrack[][]; fromTrackId: string,
redoStack: TimelineTrack[][]; toTrackId: string,
clipId: string
// Multi-selection ) => void;
selectedClips: { trackId: string; clipId: string }[]; updateClipTrim: (
selectClip: (trackId: string, clipId: string, multi?: boolean) => void; trackId: string,
deselectClip: (trackId: string, clipId: string) => void; clipId: string,
clearSelectedClips: () => void; trimStart: number,
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void; trimEnd: number
) => void;
// Drag state updateClipStartTime: (
dragState: { trackId: string,
isDragging: boolean; clipId: string,
clipId: string | null; startTime: number
trackId: string | null; ) => void;
startMouseX: number; toggleTrackMute: (trackId: string) => void;
startClipTime: number;
clickOffsetTime: number; // Computed values
currentTime: number; getTotalDuration: () => number;
};
setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void; // New actions
startDrag: ( undo: () => void;
clipId: string, redo: () => void;
trackId: string, pushHistory: () => void;
startMouseX: number, }
startClipTime: number,
clickOffsetTime: number export const useTimelineStore = create<TimelineStore>((set, get) => ({
) => void; tracks: [],
updateDragTime: (currentTime: number) => void; history: [],
endDrag: () => void; redoStack: [],
selectedClips: [],
// Actions
addTrack: (type: TrackType) => string; pushHistory: () => {
removeTrack: (trackId: string) => void; const { tracks, history, redoStack } = get();
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void; // Deep copy tracks
removeClipFromTrack: (trackId: string, clipId: string) => void; set({
moveClipToTrack: ( history: [...history, JSON.parse(JSON.stringify(tracks))],
fromTrackId: string, redoStack: [] // Clear redo stack when new action is performed
toTrackId: string, });
clipId: string },
) => void;
updateClipTrim: ( undo: () => {
trackId: string, const { history, redoStack, tracks } = get();
clipId: string, if (history.length === 0) return;
trimStart: number, const prev = history[history.length - 1];
trimEnd: number set({
) => void; tracks: prev,
updateClipStartTime: ( history: history.slice(0, -1),
trackId: string, redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack
clipId: string, });
startTime: number },
) => void;
toggleTrackMute: (trackId: string) => void; selectClip: (trackId, clipId, multi = false) => {
set((state) => {
// Split operations for clips const exists = state.selectedClips.some(
splitClip: ( (c) => c.trackId === trackId && c.clipId === clipId
trackId: string, );
clipId: string, if (multi) {
splitTime: number // Toggle selection
) => string | null; return exists
splitAndKeepLeft: ( ? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) }
trackId: string, : { selectedClips: [...state.selectedClips, { trackId, clipId }] };
clipId: string, } else {
splitTime: number return { selectedClips: [{ trackId, clipId }] };
) => void; }
splitAndKeepRight: ( });
trackId: string, },
clipId: string, deselectClip: (trackId, clipId) => {
splitTime: number set((state) => ({
) => void; selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)),
separateAudio: (trackId: string, clipId: string) => string | null; }));
},
// Computed values clearSelectedClips: () => {
getTotalDuration: () => number; set({ selectedClips: [] });
},
// History actions
undo: () => void; setSelectedClips: (clips) => set({ selectedClips: clips }),
redo: () => void;
pushHistory: () => void; addTrack: (type) => {
} get().pushHistory();
const newTrack: TimelineTrack = {
export const useTimelineStore = create<TimelineStore>((set, get) => ({ id: crypto.randomUUID(),
tracks: [], name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
history: [], type,
redoStack: [], clips: [],
selectedClips: [], muted: false,
};
pushHistory: () => { set((state) => ({
const { tracks, history, redoStack } = get(); tracks: [...state.tracks, newTrack],
set({ }));
history: [...history, JSON.parse(JSON.stringify(tracks))], return newTrack.id;
redoStack: [], },
});
}, removeTrack: (trackId) => {
get().pushHistory();
undo: () => { set((state) => ({
const { history, redoStack, tracks } = get(); tracks: state.tracks.filter((track) => track.id !== trackId),
if (history.length === 0) return; }));
const prev = history[history.length - 1]; },
set({
tracks: prev, addClipToTrack: (trackId, clipData) => {
history: history.slice(0, -1), get().pushHistory();
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], const newClip: TimelineClip = {
}); ...clipData,
}, id: crypto.randomUUID(),
startTime: clipData.startTime || 0,
selectClip: (trackId, clipId, multi = false) => { trimStart: 0,
set((state) => { trimEnd: 0,
const exists = state.selectedClips.some( };
(c) => c.trackId === trackId && c.clipId === clipId
); set((state) => ({
if (multi) { tracks: state.tracks.map((track) =>
return exists track.id === trackId
? { ? { ...track, clips: [...track.clips, newClip] }
selectedClips: state.selectedClips.filter( : track
(c) => !(c.trackId === trackId && c.clipId === clipId) ),
), }));
} },
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
} else { removeClipFromTrack: (trackId, clipId) => {
return { selectedClips: [{ trackId, clipId }] }; get().pushHistory();
} set((state) => ({
}); tracks: state.tracks
}, .map((track) =>
track.id === trackId
deselectClip: (trackId, clipId) => { ? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
set((state) => ({ : track
selectedClips: state.selectedClips.filter( )
(c) => !(c.trackId === trackId && c.clipId === clipId) // Remove track if it becomes empty
), .filter((track) => track.clips.length > 0),
})); }));
}, },
clearSelectedClips: () => { moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
set({ selectedClips: [] }); get().pushHistory();
}, set((state) => {
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
setSelectedClips: (clips) => set({ selectedClips: clips }), const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
addTrack: (type) => { if (!clipToMove) return state;
get().pushHistory();
const newTrack: TimelineTrack = { return {
id: crypto.randomUUID(), tracks: state.tracks
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`, .map((track) => {
type, if (track.id === fromTrackId) {
clips: [], return {
muted: false, ...track,
}; clips: track.clips.filter((clip) => clip.id !== clipId),
set((state) => ({ };
tracks: [...state.tracks, newTrack], } else if (track.id === toTrackId) {
})); return {
return newTrack.id; ...track,
}, clips: [...track.clips, clipToMove],
};
removeTrack: (trackId) => { }
get().pushHistory(); return track;
set((state) => ({ })
tracks: state.tracks.filter((track) => track.id !== trackId), // Remove track if it becomes empty
})); .filter((track) => track.clips.length > 0),
}, };
});
addClipToTrack: (trackId, clipData) => { },
get().pushHistory();
const newClip: TimelineClip = { updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
...clipData, get().pushHistory();
id: crypto.randomUUID(), set((state) => ({
startTime: clipData.startTime || 0, tracks: state.tracks.map((track) =>
trimStart: 0, track.id === trackId
trimEnd: 0, ? {
}; ...track,
clips: track.clips.map((clip) =>
set((state) => ({ clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
tracks: state.tracks.map((track) => ),
track.id === trackId }
? { ...track, clips: [...track.clips, newClip] } : track
: track ),
), }));
})); },
},
updateClipStartTime: (trackId, clipId, startTime) => {
removeClipFromTrack: (trackId, clipId) => { get().pushHistory();
get().pushHistory(); set((state) => ({
set((state) => ({ tracks: state.tracks.map((track) =>
tracks: state.tracks track.id === trackId
.map((track) => ? {
track.id === trackId ...track,
? { clips: track.clips.map((clip) =>
...track, clip.id === clipId ? { ...clip, startTime } : clip
clips: track.clips.filter((clip) => clip.id !== clipId), ),
} }
: track : track
) ),
.filter((track) => track.clips.length > 0), }));
})); },
},
toggleTrackMute: (trackId) => {
moveClipToTrack: (fromTrackId, toTrackId, clipId) => { get().pushHistory();
get().pushHistory(); set((state) => ({
set((state) => { tracks: state.tracks.map((track) =>
const fromTrack = state.tracks.find((track) => track.id === fromTrackId); track.id === trackId ? { ...track, muted: !track.muted } : track
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId); ),
}));
if (!clipToMove) return state; },
return { getTotalDuration: () => {
tracks: state.tracks const { tracks } = get();
.map((track) => { if (tracks.length === 0) return 0;
if (track.id === fromTrackId) {
return { const trackEndTimes = tracks.map((track) =>
...track, track.clips.reduce((maxEnd, clip) => {
clips: track.clips.filter((clip) => clip.id !== clipId), const clipEnd =
}; clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
} else if (track.id === toTrackId) { return Math.max(maxEnd, clipEnd);
return { }, 0)
...track, );
clips: [...track.clips, clipToMove],
}; return Math.max(...trackEndTimes, 0);
} },
return track;
}) redo: () => {
.filter((track) => track.clips.length > 0), const { redoStack } = get();
}; if (redoStack.length === 0) return;
}); const next = redoStack[redoStack.length - 1];
}, set({ tracks: next, redoStack: redoStack.slice(0, -1) });
},
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,
},
});
},
}));

View File

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

View File

@ -4,8 +4,6 @@ export interface PlaybackState {
duration: number; duration: number;
volume: number; volume: number;
speed: number; speed: number;
muted: boolean;
previousVolume?: number;
} }
export interface PlaybackControls { export interface PlaybackControls {
@ -15,7 +13,4 @@ export interface PlaybackControls {
setVolume: (volume: number) => void; setVolume: (volume: number) => void;
setSpeed: (speed: number) => void; setSpeed: (speed: number) => void;
toggle: () => 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: { borderRadius: {
lg: "var(--radius)", lg: "var(--radius)",
md: "calc(var(--radius) - 2px)", md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 6px)", sm: "calc(var(--radius) - 4px)",
}, },
keyframes: { keyframes: {
"accordion-down": { "accordion-down": {

View File

@ -4,7 +4,6 @@
"": { "": {
"dependencies": { "dependencies": {
"next": "^15.3.4", "next": "^15.3.4",
"wavesurfer.js": "^7.9.8",
}, },
"devDependencies": { "devDependencies": {
"turbo": "^2.5.4", "turbo": "^2.5.4",
@ -431,7 +430,7 @@
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], "@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=="], "@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=="], "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=="], "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=="], "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: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U opencut"] test: ["CMD", "pg_isready -U opencut"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
@ -48,7 +48,7 @@ services:
start_period: 10s start_period: 10s
web: web:
build: build:
context: . context: ./apps/web
dockerfile: ./apps/web/Dockerfile dockerfile: ./apps/web/Dockerfile
restart: unless-stopped restart: unless-stopped
ports: ports:

View File

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

View File

@ -16,7 +16,7 @@ export const auth = betterAuth({
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
socialProviders: { socialProviders: {
google: { google: {
clientId: process.env.GOOGLE_CLIENT_ID as string, clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
@ -26,4 +26,4 @@ export const auth = betterAuth({
trustedOrigins: ["http://localhost:3000"], 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 postgres from "postgres";
import * as schema from "./schema"; import * as schema from "./schema";
// Create a lazy database instance that only initializes when accessed if (!process.env.DATABASE_URL) {
let _db: ReturnType<typeof drizzle> | null = null; throw new Error("DATABASE_URL is not set");
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;
} }
// Export a proxy that forwards all calls to the actual db instance // Create the postgres client
export const db = new Proxy({} as ReturnType<typeof drizzle>, { const client = postgres(process.env.DATABASE_URL);
get(target, prop) {
return getDb()[prop as keyof typeof _db]; // Create the drizzle instance
}, export const db = drizzle(client, { schema });
});
// Re-export schema for convenience // Re-export schema for convenience
export * from "./schema"; 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";