Merge branch 'main' into split-issue-83

This commit is contained in:
Priyankar Pal
2025-06-26 11:35:55 +05:30
committed by GitHub
61 changed files with 1682 additions and 702 deletions

View File

@ -1,31 +0,0 @@
---
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.

70
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@ -0,0 +1,70 @@
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

@ -1,19 +0,0 @@
---
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

@ -0,0 +1,42 @@
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.2 bun-version: 1.2.17
- 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-${{ hashFiles('apps/web/bun.lock') }} key: ${{ runner.os }}-bun-1.2.17-${{ hashFiles('apps/web/bun.lock') }}
- name: Install dependencies - name: Install dependencies
working-directory: apps/web working-directory: apps/web

6
.gitignore vendored
View File

@ -27,4 +27,8 @@ node_modules
.cursorignore .cursorignore
.turbo .turbo
*.env *.env
# cursor
.cursor/

View File

@ -59,7 +59,16 @@ 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.*
@ -94,13 +103,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

@ -19,7 +19,8 @@ COPY apps/web/ apps/web/
COPY packages/db/ packages/db/ COPY packages/db/ packages/db/
COPY packages/auth/ packages/auth/ COPY packages/auth/ packages/auth/
ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
WORKDIR /app/apps/web WORKDIR /app/apps/web
RUN bun run build RUN bun run build
@ -28,7 +29,8 @@ RUN bun run build
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1 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

View File

@ -0,0 +1,11 @@
<?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>

BIN
apps/web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

10
apps/web/public/frame.svg Normal file
View File

@ -0,0 +1,10 @@
<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>

After

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

10
apps/web/public/logo.svg Normal file
View File

@ -0,0 +1,10 @@
<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>

After

Width:  |  Height:  |  Size: 362 B

View File

@ -0,0 +1,44 @@
{
"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"
}
]
}

View File

@ -6,6 +6,7 @@ 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",
@ -49,7 +50,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: any) => contributor.type === "User" (contributor: Contributor) => contributor.type === "User"
); );
return filteredContributors; return filteredContributors;
@ -61,8 +62,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 topContributor = contributors[0]; const topContributors = contributors.slice(0, 2);
const otherContributors = contributors.slice(1); const otherContributors = contributors.slice(2);
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@ -77,10 +78,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">
<div className="inline-flex items-center gap-2 bg-muted/50 text-muted-foreground px-3 py-1 rounded-full text-sm mb-6"> <Badge variant="secondary" className="gap-2 mb-6">
<GithubIcon className="h-3 w-3" /> <GithubIcon className="h-3 w-3" />
Open Source Open Source
</div> </Badge>
<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>
@ -105,54 +106,59 @@ export default async function ContributorsPage() {
</div> </div>
</div> </div>
{topContributor && ( {topContributors.length > 0 && (
<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 Contributor Top Contributors
</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>
<Link <div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto">
href={topContributor.html_url} {topContributors.map((contributor, index) => (
target="_blank" <Link
rel="noopener noreferrer" key={contributor.id}
className="group block" href={contributor.html_url}
> target="_blank"
<div className="relative mx-auto max-w-md"> rel="noopener noreferrer"
<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" /> className="group block flex-1"
<Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl"> >
<CardContent className="p-8 text-center"> <div className="relative mx-auto max-w-md">
<div className="relative mb-6"> <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" />
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl"> <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">
<AvatarImage <CardContent className="p-8 text-center">
src={topContributor.avatar_url} <div className="relative mb-6">
alt={`${topContributor.login}'s avatar`} <Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
/> <AvatarImage
<AvatarFallback className="text-lg font-semibold"> src={contributor.avatar_url}
{topContributor.login.charAt(0).toUpperCase()} alt={`${contributor.login}'s avatar`}
</AvatarFallback> />
</Avatar> <AvatarFallback className="text-lg font-semibold">
<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"> {contributor.login.charAt(0).toUpperCase()}
1 </AvatarFallback>
</div> </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">
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors"> {index + 1}
{topContributor.login} </div>
</h3> </div>
<div className="flex items-center justify-center gap-2 text-muted-foreground"> <h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
<span className="font-medium text-foreground"> {contributor.login}
{topContributor.contributions} </h3>
</span> <div className="flex items-center justify-center gap-2 text-muted-foreground">
<span>contributions</span> <span className="font-medium text-foreground">
</div> {contributor.contributions}
</CardContent> </span>
</Card> <span>contributions</span>
</div> </div>
</Link> </CardContent>
</Card>
</div>
</Link>
))}
</div>
</div> </div>
)} )}
@ -167,7 +173,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-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
{otherContributors.map((contributor, index) => ( {otherContributors.map((contributor, index) => (
<Link <Link
key={contributor.id} key={contributor.id}
@ -179,8 +185,8 @@ export default async function ContributorsPage() {
animationDelay: `${index * 50}ms`, animationDelay: `${index * 50}ms`,
}} }}
> >
<div className="text-center p-4 rounded-xl hover:bg-muted/50 transition-all duration-300 group-hover:scale-105"> <div className="text-center p-2 rounded-xl transition-all duration-300 hover:opacity-50">
<Avatar className="h-16 w-16 mx-auto mb-3 ring-2 ring-transparent group-hover:ring-muted-foreground/20 transition-all duration-300"> <Avatar className="h-16 w-16 mx-auto mb-3">
<AvatarImage <AvatarImage
src={contributor.avatar_url} src={contributor.avatar_url}
alt={`${contributor.login}'s avatar`} alt={`${contributor.login}'s avatar`}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -39,7 +39,7 @@
--sidebar-ring: 0 0% 3.9%; --sidebar-ring: 0 0% 3.9%;
} }
.dark { .dark {
--background: 0 0% 8%; --background: 0 0% 6%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 0 0% 3.9%; --card: 0 0% 3.9%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;

View File

@ -1,4 +1,3 @@
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";
@ -6,46 +5,15 @@ 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: Metadata = { export const metadata = baseMetaData;
title: "OpenCut",
description:
"A simple but powerful video editor that gets the job done. In your browser.",
openGraph: {
title: "OpenCut",
description:
"A simple but powerful video editor that gets the job done. In your browser.",
url: "https://opencut.app",
siteName: "OpenCut",
locale: "en_US",
type: "website",
images: [
{
url: "https://opencut.app/opengraph-image.jpg",
width: 1200,
height: 630,
alt: "OpenCut",
},
],
},
twitter: {
card: "summary_large_image",
title: "OpenCut",
description:
"A simple but powerful video editor that gets the job done. In your browser.",
creator: "@opencutapp",
images: ["/opengraph-image.jpg"],
},
robots: {
index: true,
follow: true,
},
};
export default function RootLayout({ export default function RootLayout({
children, children,
@ -60,6 +28,7 @@ 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

@ -0,0 +1,66 @@
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,6 +1,7 @@
import { Hero } from "@/components/landing/hero"; import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
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 // Force dynamic rendering so waitlist count updates in real-time
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -10,6 +11,13 @@ export default async function Home() {
return ( return (
<div> <div>
<Image
className="fixed top-0 left-0 -z-50 size-full object-cover"
src="/landing-page-bg.png"
height={1903.5}
width={1269}
alt="landing-page.bg"
/>
<Header /> <Header />
<Hero signupCount={signupCount} /> <Hero signupCount={signupCount} />
</div> </div>

View File

@ -0,0 +1,107 @@
"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,13 +3,11 @@
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"; 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 = () => {

View File

@ -3,7 +3,7 @@
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { AspectRatio } from "../ui/aspect-ratio"; import { AspectRatio } from "../ui/aspect-ratio";
import { DragOverlay } from "../ui/drag-overlay"; import { DragOverlay } from "../ui/drag-overlay";
import { useMediaStore } from "@/stores/media-store"; import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { processMediaFiles } from "@/lib/media-processing"; import { processMediaFiles } from "@/lib/media-processing";
import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react"; import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
import { useDragDrop } from "@/hooks/use-drag-drop"; import { useDragDrop } from "@/hooks/use-drag-drop";
@ -17,27 +17,28 @@ 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 no files, do nothing if (!files || files.length === 0) return;
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 items = await processMediaFiles(files); const processedItems = await processMediaFiles(files, (p) =>
setProgress(p)
);
// Add each processed media item to the store // Add each processed media item to the store
items.forEach((item) => { processedItems.forEach((item) => addMediaItem(item));
addMediaItem(item);
});
} catch (error) { } catch (error) {
// Show error if processing fails // Show error toast if processing fails
console.error("File processing failed:", error); console.error("Error processing files:", error);
toast.error("Failed to process files"); toast.error("Failed to process files");
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
setProgress(0);
} }
}; };
@ -67,7 +68,7 @@ export function MediaPanel() {
return `${min}:${sec.toString().padStart(2, "0")}`; return `${min}:${sec.toString().padStart(2, "0")}`;
}; };
const startDrag = (e: React.DragEvent, item: any) => { const startDrag = (e: React.DragEvent, item: MediaItem) => {
// 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,7 +102,7 @@ export function MediaPanel() {
setFilteredMediaItems(filtered); setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]); }, [mediaItems, mediaFilter, searchQuery]);
const renderPreview = (item: any) => { const renderPreview = (item: MediaItem) => {
// 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 = {
@ -241,15 +242,12 @@ export function MediaPanel() {
{isProcessing ? ( {isProcessing ? (
<> <>
<Upload className="h-4 w-4 animate-spin" /> <Upload className="h-4 w-4 animate-spin" />
<span className="hidden md:inline ml-2">Processing...</span> <span className="hidden md:inline ml-2">{progress}%</span>
</> </>
) : ( ) : (
<> <>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
<span <span className="hidden sm:inline ml-2" aria-label="Add file">
className="hidden sm:inline ml-2"
aria-label="Add file"
>
Add Add
</span> </span>
</> </>

View File

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

@ -17,13 +17,12 @@ 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< const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
"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)
@ -78,7 +77,9 @@ 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: any) => setBackgroundType(value)} onValueChange={(value: BackgroundType) =>
setBackgroundType(value)
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select background type" /> <SelectValue placeholder="Select background type" />

View File

@ -0,0 +1,276 @@
"use client";
import { useState } from "react";
import { Button } from "../ui/button";
import { MoreVertical, Scissors, Trash2 } from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useDragClip } from "@/hooks/use-drag-clip";
import AudioWaveform from "./audio-waveform";
import { toast } from "sonner";
import { TimelineClipProps, ResizeState } from "@/types/timeline";
export function TimelineClip({
clip,
track,
zoomLevel,
isSelected,
onContextMenu,
onClipMouseDown,
onClipClick,
}: TimelineClipProps) {
const { mediaItems } = useMediaStore();
const { updateClipTrim, addClipToTrack, removeClipFromTrack, dragState } =
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";
}
};
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);
};
const handleSplitClip = () => {
// Use current playback time as split point
const splitTime = currentTime;
// Only split if splitTime is within the clip's effective range
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) {
toast.error("Playhead must be within clip to split");
return;
}
const firstDuration = splitTime - effectiveStart;
const secondDuration = effectiveEnd - splitTime;
// First part: adjust original clip
updateClipTrim(
track.id,
clip.id,
clip.trimStart,
clip.trimEnd + secondDuration
);
// Second part: add new clip after split
addClipToTrack(track.id, {
mediaId: clip.mediaId,
name: clip.name + " (cut)",
duration: clip.duration,
startTime: splitTime,
trimStart: clip.trimStart + firstDuration,
trimEnd: clip.trimEnd,
});
setClipMenuOpen(false);
toast.success("Clip split successfully");
};
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>
);
}
// Fallback for videos without thumbnails
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
};
return (
<div
className={`timeline-clip absolute h-full border ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""} ${isBeingDragged ? "shadow-lg z-20" : ""}`}
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
onMouseDown={(e) => onClipMouseDown(e, clip)}
onClick={(e) => onClipClick(e, clip)}
onMouseMove={handleResizeMove}
onMouseUp={handleResizeEnd}
onMouseLeave={handleResizeEnd}
tabIndex={0}
onContextMenu={(e) => onContextMenu(e, clip.id)}
>
{/* Left trim handle */}
<div
className={`absolute left-0 top-0 bottom-0 w-2 cursor-w-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`}
onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
/>
{/* Clip content */}
<div className="flex-1 relative">
{renderClipContent()}
{/* Clip options menu */}
<div className="absolute top-1 right-1 z-10">
<Button
variant="text"
size="icon"
className="opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
setClipMenuOpen(!clipMenuOpen);
}}
onMouseDown={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
{clipMenuOpen && (
<div
className="absolute right-0 mt-2 w-32 bg-white border rounded shadow z-50"
onMouseDown={(e) => e.stopPropagation()}
>
<button
className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted/30"
onClick={handleSplitClip}
>
<Scissors className="h-4 w-4 mr-2" /> Split
</button>
<button
className="flex items-center w-full px-3 py-2 text-sm text-red-600 hover:bg-red-50"
onClick={handleDeleteClip}
>
<Trash2 className="h-4 w-4 mr-2" /> Delete
</button>
</div>
)}
</div>
</div>
{/* Right trim handle */}
<div
className={`absolute right-0 top-0 bottom-0 w-2 cursor-e-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`}
onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
/>
</div>
);
}

View File

@ -1860,3 +1860,4 @@ function TimelineTrackContent({
</div> </div>
); );
} }

View File

@ -29,7 +29,7 @@ export function HeaderBase({
return ( return (
<header <header
className={cn("px-6 h-16 flex justify-between items-center", className)} className={cn("px-6 h-14 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,14 +1,13 @@
"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();
@ -29,15 +28,15 @@ 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.png" alt="OpenCut Logo" width={24} height={24} /> <Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
<span className="font-medium tracking-tight">OpenCut</span> <span className="text-xl font-medium hidden md:block">OpenCut</span>
</Link> </Link>
); );
const rightContent = ( const rightContent = (
<nav className="flex items-center"> <nav className="flex items-center gap-3">
<Link href="/contributors"> <Link href="/contributors">
<Button variant="text" className="text-sm"> <Button variant="text" className="text-sm p-0">
Contributors Contributors
</Button> </Button>
</Link> </Link>
@ -59,5 +58,13 @@ export function Header() {
</nav> </nav>
); );
return <HeaderBase leftContent={leftContent} rightContent={rightContent} />; return (
<div className="mx-4 md:mx-0">
<HeaderBase
className="bg-[#1D1D1D] border border-white/10 rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
leftContent={leftContent}
rightContent={rightContent}
/>
</div>
);
} }

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="#161614"> <g fill="currentColor">
<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

@ -8,6 +8,7 @@ import Link from "next/link";
import { useEffect, 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 { getStars } from "@/lib/fetchGhStars";
import Image from "next/image";
interface HeroProps { interface HeroProps {
signupCount: number; signupCount: number;
@ -82,7 +83,7 @@ export function Hero({ signupCount }: HeroProps) {
}; };
return ( return (
<div className="min-h-[calc(100vh-4rem)] flex flex-col justify-between items-center text-center px-4"> <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 <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
@ -93,18 +94,21 @@ export function Hero({ signupCount }: HeroProps) {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.8 }} transition={{ delay: 0.2, duration: 0.8 }}
className="inline-block" className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
> >
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter"> <h1>The Open Source</h1>
The open source <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
</h1> <div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2">
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2"> <Image src="/frame.svg" height={79} width={459} alt="frame" />
video editor <span className="absolute inset-0 flex items-center justify-center">
</h1> Video Editor
</span>
</div>
</div>
</motion.div> </motion.div>
<motion.p <motion.p
className="mt-10 text-lg sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto" className="mt-10 text-base 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.4, duration: 0.8 }} transition={{ delay: 0.4, duration: 0.8 }}
@ -119,7 +123,10 @@ export function Hero({ signupCount }: HeroProps) {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.6, duration: 0.8 }} transition={{ delay: 0.6, duration: 0.8 }}
> >
<form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg"> <form
onSubmit={handleSubmit}
className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
>
<Input <Input
type="email" type="email"
placeholder="Enter your email" placeholder="Enter your email"

View File

@ -2,13 +2,14 @@
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?: "blur" | "mirror" | "color"; backgroundType?: BackgroundType;
backgroundColor?: string; backgroundColor?: string;
} }

View File

@ -0,0 +1,226 @@
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

@ -11,11 +11,15 @@ 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);
@ -57,6 +61,15 @@ 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

@ -8,7 +8,7 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
let playbackTimer: number | null = null; let playbackTimer: number | null = null;
const startTimer = (store: any) => { const startTimer = (store: () => PlaybackStore) => {
if (playbackTimer) cancelAnimationFrame(playbackTimer); if (playbackTimer) cancelAnimationFrame(playbackTimer);
// Use requestAnimationFrame for smoother updates // Use requestAnimationFrame for smoother updates

View File

@ -1,264 +1,346 @@
import { create } from "zustand"; import { create } from "zustand";
export interface TimelineClip { export interface TimelineClip {
id: string; id: string;
mediaId: string; mediaId: string;
name: string; name: string;
duration: number; duration: number;
startTime: number; startTime: number;
trimStart: number; trimStart: number;
trimEnd: number; trimEnd: number;
} }
export interface TimelineTrack { export interface TimelineTrack {
id: string; id: string;
name: string; name: string;
type: "video" | "audio" | "effects"; type: "video" | "audio" | "effects";
clips: TimelineClip[]; clips: TimelineClip[];
muted?: boolean; muted?: boolean;
} }
interface TimelineStore { interface TimelineStore {
tracks: TimelineTrack[]; tracks: TimelineTrack[];
history: TimelineTrack[][]; history: TimelineTrack[][];
redoStack: TimelineTrack[][]; redoStack: TimelineTrack[][];
// Multi-selection // Multi-selection
selectedClips: { trackId: string; clipId: string }[]; selectedClips: { trackId: string; clipId: string }[];
selectClip: (trackId: string, clipId: string, multi?: boolean) => void; selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
deselectClip: (trackId: string, clipId: string) => void; deselectClip: (trackId: string, clipId: string) => void;
clearSelectedClips: () => void; clearSelectedClips: () => void;
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void; setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
// Actions // Drag state
addTrack: (type: "video" | "audio" | "effects") => string; dragState: {
removeTrack: (trackId: string) => void; isDragging: boolean;
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void; clipId: string | null;
removeClipFromTrack: (trackId: string, clipId: string) => void; trackId: string | null;
moveClipToTrack: ( startMouseX: number;
fromTrackId: string, startClipTime: number;
toTrackId: string, clickOffsetTime: number;
clipId: string currentTime: number;
) => void; };
updateClipTrim: ( setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
trackId: string, startDrag: (
clipId: string, clipId: string,
trimStart: number, trackId: string,
trimEnd: number startMouseX: number,
) => void; startClipTime: number,
updateClipStartTime: ( clickOffsetTime: number
trackId: string, ) => void;
clipId: string, updateDragTime: (currentTime: number) => void;
startTime: number endDrag: () => void;
) => void;
toggleTrackMute: (trackId: string) => void; // Actions
addTrack: (type: "video" | "audio" | "effects") => string;
// Computed values removeTrack: (trackId: string) => void;
getTotalDuration: () => number; addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
removeClipFromTrack: (trackId: string, clipId: string) => void;
// New actions moveClipToTrack: (
undo: () => void; fromTrackId: string,
redo: () => void; toTrackId: string,
pushHistory: () => void; clipId: string
} ) => void;
updateClipTrim: (
export const useTimelineStore = create<TimelineStore>((set, get) => ({ trackId: string,
tracks: [], clipId: string,
history: [], trimStart: number,
redoStack: [], trimEnd: number
selectedClips: [], ) => void;
updateClipStartTime: (
pushHistory: () => { trackId: string,
const { tracks, history, redoStack } = get(); clipId: string,
// Deep copy tracks startTime: number
set({ ) => void;
history: [...history, JSON.parse(JSON.stringify(tracks))], toggleTrackMute: (trackId: string) => void;
redoStack: [] // Clear redo stack when new action is performed
}); // Computed values
}, getTotalDuration: () => number;
undo: () => { // New actions
const { history, redoStack, tracks } = get(); undo: () => void;
if (history.length === 0) return; redo: () => void;
const prev = history[history.length - 1]; pushHistory: () => void;
set({ }
tracks: prev,
history: history.slice(0, -1), export const useTimelineStore = create<TimelineStore>((set, get) => ({
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack tracks: [],
}); history: [],
}, redoStack: [],
selectedClips: [],
selectClip: (trackId, clipId, multi = false) => {
set((state) => { pushHistory: () => {
const exists = state.selectedClips.some( const { tracks, history, redoStack } = get();
(c) => c.trackId === trackId && c.clipId === clipId // Deep copy tracks
); set({
if (multi) { history: [...history, JSON.parse(JSON.stringify(tracks))],
// Toggle selection redoStack: [], // Clear redo stack when new action is performed
return exists });
? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) } },
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
} else { undo: () => {
return { selectedClips: [{ trackId, clipId }] }; const { history, redoStack, tracks } = get();
} if (history.length === 0) return;
}); const prev = history[history.length - 1];
}, set({
deselectClip: (trackId, clipId) => { tracks: prev,
set((state) => ({ history: history.slice(0, -1),
selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)), redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], // Add current state to redo stack
})); });
}, },
clearSelectedClips: () => {
set({ selectedClips: [] }); selectClip: (trackId, clipId, multi = false) => {
}, set((state) => {
const exists = state.selectedClips.some(
setSelectedClips: (clips) => set({ selectedClips: clips }), (c) => c.trackId === trackId && c.clipId === clipId
);
addTrack: (type) => { if (multi) {
get().pushHistory(); // Toggle selection
const newTrack: TimelineTrack = { return exists
id: crypto.randomUUID(), ? {
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`, selectedClips: state.selectedClips.filter(
type, (c) => !(c.trackId === trackId && c.clipId === clipId)
clips: [], ),
muted: false, }
}; : { selectedClips: [...state.selectedClips, { trackId, clipId }] };
set((state) => ({ } else {
tracks: [...state.tracks, newTrack], return { selectedClips: [{ trackId, clipId }] };
})); }
return newTrack.id; });
}, },
deselectClip: (trackId, clipId) => {
removeTrack: (trackId) => { set((state) => ({
get().pushHistory(); selectedClips: state.selectedClips.filter(
set((state) => ({ (c) => !(c.trackId === trackId && c.clipId === clipId)
tracks: state.tracks.filter((track) => track.id !== trackId), ),
})); }));
}, },
clearSelectedClips: () => {
addClipToTrack: (trackId, clipData) => { set({ selectedClips: [] });
get().pushHistory(); },
const newClip: TimelineClip = {
...clipData, setSelectedClips: (clips) => set({ selectedClips: clips }),
id: crypto.randomUUID(),
startTime: clipData.startTime || 0, addTrack: (type) => {
trimStart: 0, get().pushHistory();
trimEnd: 0, const newTrack: TimelineTrack = {
}; id: crypto.randomUUID(),
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
set((state) => ({ type,
tracks: state.tracks.map((track) => clips: [],
track.id === trackId muted: false,
? { ...track, clips: [...track.clips, newClip] } };
: track set((state) => ({
), tracks: [...state.tracks, newTrack],
})); }));
}, return newTrack.id;
},
removeClipFromTrack: (trackId, clipId) => {
get().pushHistory(); removeTrack: (trackId) => {
set((state) => ({ get().pushHistory();
tracks: state.tracks set((state) => ({
.map((track) => tracks: state.tracks.filter((track) => track.id !== trackId),
track.id === trackId }));
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) } },
: track
) addClipToTrack: (trackId, clipData) => {
// Remove track if it becomes empty get().pushHistory();
.filter((track) => track.clips.length > 0), const newClip: TimelineClip = {
})); ...clipData,
}, id: crypto.randomUUID(),
startTime: clipData.startTime || 0,
moveClipToTrack: (fromTrackId, toTrackId, clipId) => { trimStart: 0,
get().pushHistory(); trimEnd: 0,
set((state) => { };
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId); set((state) => ({
tracks: state.tracks.map((track) =>
if (!clipToMove) return state; track.id === trackId
? { ...track, clips: [...track.clips, newClip] }
return { : track
tracks: state.tracks ),
.map((track) => { }));
if (track.id === fromTrackId) { },
return {
...track, removeClipFromTrack: (trackId, clipId) => {
clips: track.clips.filter((clip) => clip.id !== clipId), get().pushHistory();
}; set((state) => ({
} else if (track.id === toTrackId) { tracks: state.tracks
return { .map((track) =>
...track, track.id === trackId
clips: [...track.clips, clipToMove], ? {
}; ...track,
} clips: track.clips.filter((clip) => clip.id !== clipId),
return track; }
}) : track
// Remove track if it becomes empty )
.filter((track) => track.clips.length > 0), // Remove track if it becomes empty
}; .filter((track) => track.clips.length > 0),
}); }));
}, },
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => { 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 const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
? {
...track, if (!clipToMove) return state;
clips: track.clips.map((clip) =>
clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip return {
), tracks: state.tracks
} .map((track) => {
: track if (track.id === fromTrackId) {
), return {
})); ...track,
}, clips: track.clips.filter((clip) => clip.id !== clipId),
};
updateClipStartTime: (trackId, clipId, startTime) => { } else if (track.id === toTrackId) {
get().pushHistory(); return {
set((state) => ({ ...track,
tracks: state.tracks.map((track) => clips: [...track.clips, clipToMove],
track.id === trackId };
? { }
...track, return track;
clips: track.clips.map((clip) => })
clip.id === clipId ? { ...clip, startTime } : clip // Remove track if it becomes empty
), .filter((track) => track.clips.length > 0),
} };
: track });
), },
}));
}, updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
get().pushHistory();
toggleTrackMute: (trackId) => { set((state) => ({
get().pushHistory(); tracks: state.tracks.map((track) =>
set((state) => ({ track.id === trackId
tracks: state.tracks.map((track) => ? {
track.id === trackId ? { ...track, muted: !track.muted } : track ...track,
), clips: track.clips.map((clip) =>
})); clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
}, ),
}
getTotalDuration: () => { : track
const { tracks } = get(); ),
if (tracks.length === 0) return 0; }));
},
const trackEndTimes = tracks.map((track) =>
track.clips.reduce((maxEnd, clip) => { updateClipStartTime: (trackId, clipId, startTime) => {
const clipEnd = get().pushHistory();
clip.startTime + clip.duration - clip.trimStart - clip.trimEnd; set((state) => ({
return Math.max(maxEnd, clipEnd); tracks: state.tracks.map((track) =>
}, 0) track.id === trackId
); ? {
...track,
return Math.max(...trackEndTimes, 0); clips: track.clips.map((clip) =>
}, clip.id === clipId ? { ...clip, startTime } : clip
),
redo: () => { }
const { redoStack } = get(); : track
if (redoStack.length === 0) return; ),
const next = redoStack[redoStack.length - 1]; }));
set({ tracks: next, redoStack: redoStack.slice(0, -1) }); },
},
})); toggleTrackMute: (trackId) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId ? { ...track, muted: !track.muted } : track
),
}));
},
getTotalDuration: () => {
const { tracks } = get();
if (tracks.length === 0) return 0;
const trackEndTimes = tracks.map((track) =>
track.clips.reduce((maxEnd, clip) => {
const clipEnd =
clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
return Math.max(maxEnd, clipEnd);
}, 0)
);
return Math.max(...trackEndTimes, 0);
},
redo: () => {
const { redoStack } = get();
if (redoStack.length === 0) return;
const next = redoStack[redoStack.length - 1];
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
},
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

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

View File

@ -0,0 +1,27 @@
import { TimelineTrack, TimelineClip } from "@/stores/timeline-store";
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

@ -1,13 +1,10 @@
[build] # Next.js plugin
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