summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/Dockerfile6
-rw-r--r--apps/web/public/browserconfig.xml11
-rw-r--r--apps/web/public/favicon.icobin0 -> 1150 bytes
-rw-r--r--apps/web/public/frame.svg10
-rw-r--r--apps/web/public/icons/android-icon-144x144.pngbin0 -> 1076 bytes
-rw-r--r--apps/web/public/icons/android-icon-192x192.pngbin0 -> 741 bytes
-rw-r--r--apps/web/public/icons/android-icon-36x36.pngbin0 -> 768 bytes
-rw-r--r--apps/web/public/icons/android-icon-48x48.pngbin0 -> 802 bytes
-rw-r--r--apps/web/public/icons/android-icon-72x72.pngbin0 -> 826 bytes
-rw-r--r--apps/web/public/icons/android-icon-96x96.pngbin0 -> 906 bytes
-rw-r--r--apps/web/public/icons/apple-icon-114x114.pngbin0 -> 985 bytes
-rw-r--r--apps/web/public/icons/apple-icon-120x120.pngbin0 -> 998 bytes
-rw-r--r--apps/web/public/icons/apple-icon-144x144.pngbin0 -> 1076 bytes
-rw-r--r--apps/web/public/icons/apple-icon-152x152.pngbin0 -> 1163 bytes
-rw-r--r--apps/web/public/icons/apple-icon-180x180.pngbin0 -> 1360 bytes
-rw-r--r--apps/web/public/icons/apple-icon-57x57.pngbin0 -> 809 bytes
-rw-r--r--apps/web/public/icons/apple-icon-60x60.pngbin0 -> 843 bytes
-rw-r--r--apps/web/public/icons/apple-icon-72x72.pngbin0 -> 826 bytes
-rw-r--r--apps/web/public/icons/apple-icon-76x76.pngbin0 -> 820 bytes
-rw-r--r--apps/web/public/icons/favicon-16x16.pngbin0 -> 670 bytes
-rw-r--r--apps/web/public/icons/favicon-32x32.pngbin0 -> 747 bytes
-rw-r--r--apps/web/public/icons/favicon-96x96.pngbin0 -> 906 bytes
-rw-r--r--apps/web/public/icons/ms-icon-144x144.pngbin0 -> 1076 bytes
-rw-r--r--apps/web/public/icons/ms-icon-150x150.pngbin0 -> 1159 bytes
-rw-r--r--apps/web/public/icons/ms-icon-310x310.pngbin0 -> 2749 bytes
-rw-r--r--apps/web/public/icons/ms-icon-70x70.pngbin0 -> 814 bytes
-rw-r--r--apps/web/public/landing-page-bg.pngbin0 -> 230853 bytes
-rw-r--r--apps/web/public/logo.svg10
-rw-r--r--apps/web/public/manifest.json44
-rw-r--r--apps/web/src/app/contributors/page.tsx100
-rw-r--r--apps/web/src/app/favicon.icobin4286 -> 0 bytes
-rw-r--r--apps/web/src/app/globals.css2
-rw-r--r--apps/web/src/app/layout.tsx39
-rw-r--r--apps/web/src/app/metadata.ts66
-rw-r--r--apps/web/src/app/page.tsx8
-rw-r--r--apps/web/src/components/development-debug.tsx107
-rw-r--r--apps/web/src/components/editor-header.tsx2
-rw-r--r--apps/web/src/components/editor/media-panel.tsx32
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx529
-rw-r--r--apps/web/src/components/editor/properties-panel.tsx9
-rw-r--r--apps/web/src/components/editor/timeline-clip.tsx276
-rw-r--r--apps/web/src/components/editor/timeline.tsx1
-rw-r--r--apps/web/src/components/header-base.tsx2
-rw-r--r--apps/web/src/components/header.tsx21
-rw-r--r--apps/web/src/components/icons.tsx2
-rw-r--r--apps/web/src/components/landing/hero.tsx27
-rw-r--r--apps/web/src/components/ui/image-timeline-treatment.tsx3
-rw-r--r--apps/web/src/hooks/use-drag-clip.ts226
-rw-r--r--apps/web/src/lib/media-processing.ts15
-rw-r--r--apps/web/src/stores/playback-store.ts2
-rw-r--r--apps/web/src/stores/timeline-store.ts610
-rw-r--r--apps/web/src/types/editor.ts1
-rw-r--r--apps/web/src/types/timeline.ts27
53 files changed, 1548 insertions, 640 deletions
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index 8b13ce38..46eeb102 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -19,7 +19,8 @@ COPY apps/web/ apps/web/
COPY packages/db/ packages/db/
COPY packages/auth/ packages/auth/
-ENV NEXT_TELEMETRY_DISABLED=1
+ENV NODE_ENV production
+ENV NEXT_TELEMETRY_DISABLED 1
WORKDIR /app/apps/web
RUN bun run build
@@ -28,7 +29,8 @@ RUN bun run build
FROM base AS runner
WORKDIR /app
-ENV NEXT_TELEMETRY_DISABLED=1
+ENV NODE_ENV production
+ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
diff --git a/apps/web/public/browserconfig.xml b/apps/web/public/browserconfig.xml
new file mode 100644
index 00000000..39060b21
--- /dev/null
+++ b/apps/web/public/browserconfig.xml
@@ -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> \ No newline at end of file
diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico
new file mode 100644
index 00000000..9b2cba8f
--- /dev/null
+++ b/apps/web/public/favicon.ico
Binary files differ
diff --git a/apps/web/public/frame.svg b/apps/web/public/frame.svg
new file mode 100644
index 00000000..4dac68e9
--- /dev/null
+++ b/apps/web/public/frame.svg
@@ -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>
diff --git a/apps/web/public/icons/android-icon-144x144.png b/apps/web/public/icons/android-icon-144x144.png
new file mode 100644
index 00000000..7320ece7
--- /dev/null
+++ b/apps/web/public/icons/android-icon-144x144.png
Binary files differ
diff --git a/apps/web/public/icons/android-icon-192x192.png b/apps/web/public/icons/android-icon-192x192.png
new file mode 100644
index 00000000..f534f429
--- /dev/null
+++ b/apps/web/public/icons/android-icon-192x192.png
Binary files differ
diff --git a/apps/web/public/icons/android-icon-36x36.png b/apps/web/public/icons/android-icon-36x36.png
new file mode 100644
index 00000000..6b8db020
--- /dev/null
+++ b/apps/web/public/icons/android-icon-36x36.png
Binary files differ
diff --git a/apps/web/public/icons/android-icon-48x48.png b/apps/web/public/icons/android-icon-48x48.png
new file mode 100644
index 00000000..30a68fa5
--- /dev/null
+++ b/apps/web/public/icons/android-icon-48x48.png
Binary files differ
diff --git a/apps/web/public/icons/android-icon-72x72.png b/apps/web/public/icons/android-icon-72x72.png
new file mode 100644
index 00000000..e4019c84
--- /dev/null
+++ b/apps/web/public/icons/android-icon-72x72.png
Binary files differ
diff --git a/apps/web/public/icons/android-icon-96x96.png b/apps/web/public/icons/android-icon-96x96.png
new file mode 100644
index 00000000..85591ab4
--- /dev/null
+++ b/apps/web/public/icons/android-icon-96x96.png
Binary files differ
diff --git a/apps/web/public/icons/apple-icon-114x114.png b/apps/web/public/icons/apple-icon-114x114.png
new file mode 100644
index 00000000..c776e3f9
--- /dev/null
+++ b/apps/web/public/icons/apple-icon-114x114.png
Binary files differ
diff --git a/apps/web/public/icons/apple-icon-120x120.png b/apps/web/public/icons/apple-icon-120x120.png
new file mode 100644
index 00000000..76604df8
--- /dev/null
+++ b/apps/web/public/icons/apple-icon-120x120.png
Binary files differ
diff --git a/apps/web/public/icons/apple-icon-144x144.png b/apps/web/public/icons/apple-icon-144x144.png
new file mode 100644
index 00000000..7320ece7
--- /dev/null
+++ b/apps/web/public/icons/apple-icon-144x144.png
Binary files differ
diff --git a/apps/web/public/icons/apple-icon-152x152.png b/apps/web/public/icons/apple-icon-152x152.png
new file mode 100644
index 00000000..eb5ef0df
--- /dev/null
+++ b/apps/web/public/icons/apple-icon-152x152.png
Binary files differ
diff --git a/apps/web/public/icons/apple-icon-180x180.png b/apps/web/public/icons/apple-icon-180x180.png
new file mode 100644
index 00000000..b90e62fb
--- /dev/null
+++ b/apps/web/public/icons/apple-icon-180x180.png
Binary files differ
diff --git a/apps/web/public/icons/apple-icon-57x57.png b/apps/web/public/icons/apple-icon-57x57.png
new file mode 100644
index 00000000..7cfb2e56
--- /dev/null
+++ b/apps/web/public/icons/apple-icon-57x57.png
Binary files differ
diff --git a/apps/web/public/icons/apple-icon-60x60.png b/apps/web/public/icons/apple-icon-60x60.png
new file mode 100644
index 00000000..1d90a370
--- /dev/null
+++ b/apps/web/public/icons/apple-icon-60x60.png
Binary files differ
diff --git a/apps/web/public/icons/apple-icon-72x72.png b/apps/web/public/icons/apple-icon-72x72.png
new file mode 100644
index 00000000..e4019c84
--- /dev/null
+++ b/apps/web/public/icons/apple-icon-72x72.png
Binary files differ
diff --git a/apps/web/public/icons/apple-icon-76x76.png b/apps/web/public/icons/apple-icon-76x76.png
new file mode 100644
index 00000000..34575f42
--- /dev/null
+++ b/apps/web/public/icons/apple-icon-76x76.png
Binary files differ
diff --git a/apps/web/public/icons/favicon-16x16.png b/apps/web/public/icons/favicon-16x16.png
new file mode 100644
index 00000000..96db3a2f
--- /dev/null
+++ b/apps/web/public/icons/favicon-16x16.png
Binary files differ
diff --git a/apps/web/public/icons/favicon-32x32.png b/apps/web/public/icons/favicon-32x32.png
new file mode 100644
index 00000000..f8f589dc
--- /dev/null
+++ b/apps/web/public/icons/favicon-32x32.png
Binary files differ
diff --git a/apps/web/public/icons/favicon-96x96.png b/apps/web/public/icons/favicon-96x96.png
new file mode 100644
index 00000000..85591ab4
--- /dev/null
+++ b/apps/web/public/icons/favicon-96x96.png
Binary files differ
diff --git a/apps/web/public/icons/ms-icon-144x144.png b/apps/web/public/icons/ms-icon-144x144.png
new file mode 100644
index 00000000..7320ece7
--- /dev/null
+++ b/apps/web/public/icons/ms-icon-144x144.png
Binary files differ
diff --git a/apps/web/public/icons/ms-icon-150x150.png b/apps/web/public/icons/ms-icon-150x150.png
new file mode 100644
index 00000000..248ef48a
--- /dev/null
+++ b/apps/web/public/icons/ms-icon-150x150.png
Binary files differ
diff --git a/apps/web/public/icons/ms-icon-310x310.png b/apps/web/public/icons/ms-icon-310x310.png
new file mode 100644
index 00000000..950a3d6b
--- /dev/null
+++ b/apps/web/public/icons/ms-icon-310x310.png
Binary files differ
diff --git a/apps/web/public/icons/ms-icon-70x70.png b/apps/web/public/icons/ms-icon-70x70.png
new file mode 100644
index 00000000..7b9c2694
--- /dev/null
+++ b/apps/web/public/icons/ms-icon-70x70.png
Binary files differ
diff --git a/apps/web/public/landing-page-bg.png b/apps/web/public/landing-page-bg.png
new file mode 100644
index 00000000..4f873945
--- /dev/null
+++ b/apps/web/public/landing-page-bg.png
Binary files differ
diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg
new file mode 100644
index 00000000..f4079a5e
--- /dev/null
+++ b/apps/web/public/logo.svg
@@ -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>
diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json
new file mode 100644
index 00000000..fcbfad59
--- /dev/null
+++ b/apps/web/public/manifest.json
@@ -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"
+ }
+ ]
+} \ No newline at end of file
diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx
index f5fa0755..67957c32 100644
--- a/apps/web/src/app/contributors/page.tsx
+++ b/apps/web/src/app/contributors/page.tsx
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { GithubIcon } from "@/components/icons";
+import { Badge } from "@/components/ui/badge";
export const metadata: Metadata = {
title: "Contributors - OpenCut",
@@ -49,7 +50,7 @@ async function getContributors(): Promise<Contributor[]> {
const contributors = await response.json();
const filteredContributors = contributors.filter(
- (contributor: any) => contributor.type === "User"
+ (contributor: Contributor) => contributor.type === "User"
);
return filteredContributors;
@@ -61,8 +62,8 @@ async function getContributors(): Promise<Contributor[]> {
export default async function ContributorsPage() {
const contributors = await getContributors();
- const topContributor = contributors[0];
- const otherContributors = contributors.slice(1);
+ const topContributors = contributors.slice(0, 2);
+ const otherContributors = contributors.slice(2);
return (
<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="max-w-6xl mx-auto">
<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" />
Open Source
- </div>
+ </Badge>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Contributors
</h1>
@@ -105,54 +106,59 @@ export default async function ContributorsPage() {
</div>
</div>
- {topContributor && (
+ {topContributors.length > 0 && (
<div className="mb-20">
<div className="text-center mb-12">
<h2 className="text-2xl font-semibold mb-2">
- Top Contributor
+ Top Contributors
</h2>
<p className="text-muted-foreground">
Leading the way in contributions
</p>
</div>
- <Link
- href={topContributor.html_url}
- target="_blank"
- rel="noopener noreferrer"
- className="group block"
- >
- <div className="relative mx-auto max-w-md">
- <div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
- <Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
- <CardContent className="p-8 text-center">
- <div className="relative mb-6">
- <Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
- <AvatarImage
- src={topContributor.avatar_url}
- alt={`${topContributor.login}'s avatar`}
- />
- <AvatarFallback className="text-lg font-semibold">
- {topContributor.login.charAt(0).toUpperCase()}
- </AvatarFallback>
- </Avatar>
- <div className="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold">
- 1
- </div>
- </div>
- <h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
- {topContributor.login}
- </h3>
- <div className="flex items-center justify-center gap-2 text-muted-foreground">
- <span className="font-medium text-foreground">
- {topContributor.contributions}
- </span>
- <span>contributions</span>
- </div>
- </CardContent>
- </Card>
- </div>
- </Link>
+ <div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto">
+ {topContributors.map((contributor, index) => (
+ <Link
+ key={contributor.id}
+ href={contributor.html_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="group block flex-1"
+ >
+ <div className="relative mx-auto max-w-md">
+ <div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
+ <Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
+ <CardContent className="p-8 text-center">
+ <div className="relative mb-6">
+ <Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
+ <AvatarImage
+ src={contributor.avatar_url}
+ alt={`${contributor.login}'s avatar`}
+ />
+ <AvatarFallback className="text-lg font-semibold">
+ {contributor.login.charAt(0).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ <div className="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold">
+ {index + 1}
+ </div>
+ </div>
+ <h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
+ {contributor.login}
+ </h3>
+ <div className="flex items-center justify-center gap-2 text-muted-foreground">
+ <span className="font-medium text-foreground">
+ {contributor.contributions}
+ </span>
+ <span>contributions</span>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ </Link>
+ ))}
+ </div>
</div>
)}
@@ -167,7 +173,7 @@ export default async function ContributorsPage() {
</p>
</div>
- <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-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) => (
<Link
key={contributor.id}
@@ -179,8 +185,8 @@ export default async function ContributorsPage() {
animationDelay: `${index * 50}ms`,
}}
>
- <div className="text-center p-4 rounded-xl hover:bg-muted/50 transition-all duration-300 group-hover:scale-105">
- <Avatar className="h-16 w-16 mx-auto mb-3 ring-2 ring-transparent group-hover:ring-muted-foreground/20 transition-all duration-300">
+ <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">
<AvatarImage
src={contributor.avatar_url}
alt={`${contributor.login}'s avatar`}
diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico
deleted file mode 100644
index cb20127b..00000000
--- a/apps/web/src/app/favicon.ico
+++ /dev/null
Binary files differ
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 2169dbbc..1c8a7887 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -39,7 +39,7 @@
--sidebar-ring: 0 0% 3.9%;
}
.dark {
- --background: 0 0% 8%;
+ --background: 0 0% 6%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 5d33100d..8188d31b 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -1,4 +1,3 @@
-import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { Analytics } from "@vercel/analytics/react";
@@ -6,46 +5,15 @@ import Script from "next/script";
import "./globals.css";
import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
+import { DevelopmentDebug } from "../components/development-debug";
+import { baseMetaData } from "./metadata";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
-export const metadata: Metadata = {
- 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 const metadata = baseMetaData;
export default function RootLayout({
children,
@@ -60,6 +28,7 @@ export default function RootLayout({
{children}
<Analytics />
<Toaster />
+ <DevelopmentDebug />
<Script
src="https://app.databuddy.cc/databuddy.js"
strategy="afterInteractive"
diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts
new file mode 100644
index 00000000..4e387014
--- /dev/null
+++ b/apps/web/src/app/metadata.ts
@@ -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"
+ }
+};
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index dcedab32..e9957e1b 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -1,6 +1,7 @@
import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header";
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";
@@ -10,6 +11,13 @@ export default async function Home() {
return (
<div>
+ <Image
+ className="fixed top-0 left-0 -z-50 size-full object-cover"
+ src="/landing-page-bg.png"
+ height={1903.5}
+ width={1269}
+ alt="landing-page.bg"
+ />
<Header />
<Hero signupCount={signupCount} />
</div>
diff --git a/apps/web/src/components/development-debug.tsx b/apps/web/src/components/development-debug.tsx
new file mode 100644
index 00000000..aef69999
--- /dev/null
+++ b/apps/web/src/components/development-debug.tsx
@@ -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>
+ );
+}
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx
index 6f9f0b2f..5131230f 100644
--- a/apps/web/src/components/editor-header.tsx
+++ b/apps/web/src/components/editor-header.tsx
@@ -3,13 +3,11 @@
import Link from "next/link";
import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react";
-import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
import { ProjectNameEditor } from "./editor/project-name-editor";
export function EditorHeader() {
- const { activeProject } = useProjectStore();
const { getTotalDuration } = useTimelineStore();
const handleExport = () => {
diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx
index f2b3f657..dfe08365 100644
--- a/apps/web/src/components/editor/media-panel.tsx
+++ b/apps/web/src/components/editor/media-panel.tsx
@@ -3,7 +3,7 @@
import { Button } from "../ui/button";
import { AspectRatio } from "../ui/aspect-ratio";
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 { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
import { useDragDrop } from "@/hooks/use-drag-drop";
@@ -17,27 +17,28 @@ export function MediaPanel() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => {
- // If no files, do nothing
- if (!files?.length) return;
-
+ if (!files || files.length === 0) return;
setIsProcessing(true);
+ setProgress(0);
try {
// 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
- items.forEach((item) => {
- addMediaItem(item);
- });
+ processedItems.forEach((item) => addMediaItem(item));
} catch (error) {
- // Show error if processing fails
- console.error("File processing failed:", error);
+ // Show error toast if processing fails
+ console.error("Error processing files:", error);
toast.error("Failed to process files");
} finally {
setIsProcessing(false);
+ setProgress(0);
}
};
@@ -67,7 +68,7 @@ export function MediaPanel() {
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
e.dataTransfer.setData(
"application/x-media-item",
@@ -101,7 +102,7 @@ export function MediaPanel() {
setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]);
- const renderPreview = (item: any) => {
+ const renderPreview = (item: MediaItem) => {
// Render a preview for each media type (image, video, audio, unknown)
// Each preview is draggable to the timeline
const baseDragProps = {
@@ -241,15 +242,12 @@ export function MediaPanel() {
{isProcessing ? (
<>
<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" />
- <span
- className="hidden sm:inline ml-2"
- aria-label="Add file"
- >
+ <span className="hidden sm:inline ml-2" aria-label="Add file">
Add
</span>
</>
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 33bfe688..e19b4de7 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -1,246 +1,283 @@
-"use client";
-
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { VideoPlayer } from "@/components/ui/video-player";
-import { Button } from "@/components/ui/button";
-import { Play, Pause, Volume2, VolumeX } from "lucide-react";
-import { useState, useRef } from "react";
-
-// Debug flag - set to false to hide active clips info
-const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
-
-export function PreviewPanel() {
- const { tracks } = useTimelineStore();
- const { mediaItems } = useMediaStore();
- const { isPlaying, toggle, currentTime, muted, toggleMute, volume } =
- usePlaybackStore();
- const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
- const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO);
- const previewRef = useRef<HTMLDivElement>(null);
-
- // Get active clips at current time
- const getActiveClips = () => {
- const activeClips: Array<{
- clip: any;
- track: any;
- mediaItem: any;
- }> = [];
-
- tracks.forEach((track) => {
- track.clips.forEach((clip) => {
- const clipStart = clip.startTime;
- const clipEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
-
- if (currentTime >= clipStart && currentTime < clipEnd) {
- const mediaItem =
- clip.mediaId === "test"
- ? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
- : mediaItems.find((item) => item.id === clip.mediaId);
-
- if (mediaItem || clip.mediaId === "test") {
- activeClips.push({ clip, track, mediaItem });
- }
- }
- });
- });
-
- return activeClips;
- };
-
- const activeClips = getActiveClips();
- const aspectRatio = canvasSize.width / canvasSize.height;
-
- // Render a clip
- const renderClip = (clipData: any, index: number) => {
- const { clip, mediaItem } = clipData;
-
- // Test clips
- if (!mediaItem || clip.mediaId === "test") {
- return (
- <div
- key={clip.id}
- className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
- >
- <div className="text-center">
- <div className="text-2xl mb-2">🎬</div>
- <p className="text-xs text-white">{clip.name}</p>
- </div>
- </div>
- );
- }
-
- // Video clips
- if (mediaItem.type === "video") {
- return (
- <div key={clip.id} className="absolute inset-0">
- <VideoPlayer
- src={mediaItem.url}
- poster={mediaItem.thumbnailUrl}
- clipStartTime={clip.startTime}
- trimStart={clip.trimStart}
- trimEnd={clip.trimEnd}
- clipDuration={clip.duration}
- />
- </div>
- );
- }
-
- // Image clips
- if (mediaItem.type === "image") {
- return (
- <div key={clip.id} className="absolute inset-0">
- <img
- src={mediaItem.url}
- alt={mediaItem.name}
- className="w-full h-full object-cover"
- draggable={false}
- />
- </div>
- );
- }
-
- // Audio clips (visual representation)
- if (mediaItem.type === "audio") {
- return (
- <div
- key={clip.id}
- className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
- >
- <div className="text-center">
- <div className="text-2xl mb-2">🎵</div>
- <p className="text-xs text-white">{mediaItem.name}</p>
- </div>
- </div>
- );
- }
-
- return null;
- };
-
- // Canvas presets
- const canvasPresets = [
- { name: "16:9 HD", width: 1920, height: 1080 },
- { name: "16:9 4K", width: 3840, height: 2160 },
- { name: "9:16 Mobile", width: 1080, height: 1920 },
- { name: "1:1 Square", width: 1080, height: 1080 },
- { name: "4:3 Standard", width: 1440, height: 1080 },
- ];
-
- return (
- <div className="h-full w-full flex flex-col min-h-0 min-w-0">
- {/* Controls */}
- <div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
- <span className="text-muted-foreground">Canvas:</span>
- <select
- value={`${canvasSize.width}x${canvasSize.height}`}
- onChange={(e) => {
- const preset = canvasPresets.find(
- (p) => `${p.width}x${p.height}` === e.target.value
- );
- if (preset)
- setCanvasSize({ width: preset.width, height: preset.height });
- }}
- className="bg-background border rounded px-2 py-1 text-xs"
- >
- {canvasPresets.map((preset) => (
- <option
- key={preset.name}
- value={`${preset.width}x${preset.height}`}
- >
- {preset.name} ({preset.width}×{preset.height})
- </option>
- ))}
- </select>
-
- {/* Debug Toggle - Only show in development */}
- {SHOW_DEBUG_INFO && (
- <Button
- variant="text"
- size="sm"
- onClick={() => setShowDebug(!showDebug)}
- className="text-xs"
- >
- Debug {showDebug ? "ON" : "OFF"}
- </Button>
- )}
-
- <Button
- variant="outline"
- size="sm"
- onClick={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>
-
- <Button variant="outline" size="sm" onClick={toggle}>
- {isPlaying ? (
- <Pause className="h-3 w-3 mr-1" />
- ) : (
- <Play className="h-3 w-3 mr-1" />
- )}
- {isPlaying ? "Pause" : "Play"}
- </Button>
- </div>
-
- {/* Preview Area */}
- <div className="flex-1 flex items-center justify-center p-2 sm:p-4 bg-gray-900 min-h-0 min-w-0">
- <div
- ref={previewRef}
- className="relative overflow-hidden rounded-sm max-w-full max-h-full bg-black border border-gray-600"
- style={{
- aspectRatio: aspectRatio.toString(),
- width: "100%",
- height: "100%",
- }}
- >
- {activeClips.length === 0 ? (
- <div className="absolute inset-0 flex items-center justify-center text-white/50">
- {tracks.length === 0
- ? "Drop media to start editing"
- : "No clips at current time"}
- </div>
- ) : (
- activeClips.map((clipData, index) => renderClip(clipData, index))
- )}
- </div>
- </div>
-
- {/* Debug Info Panel - Conditionally rendered */}
- {showDebug && (
- <div className="border-t bg-background p-2 flex-shrink-0">
- <div className="text-xs font-medium mb-1">
- Debug: Active Clips ({activeClips.length})
- </div>
- <div className="flex gap-2 overflow-x-auto">
- {activeClips.map((clipData, index) => (
- <div
- key={clipData.clip.id}
- className="flex items-center gap-1 px-2 py-1 bg-muted rounded text-xs whitespace-nowrap"
- >
- <span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4">
- {index + 1}
- </span>
- <span>{clipData.clip.name}</span>
- <span className="text-muted-foreground">
- ({clipData.mediaItem?.type || "test"})
- </span>
- </div>
- ))}
- {activeClips.length === 0 && (
- <span className="text-muted-foreground">No active clips</span>
- )}
- </div>
- </div>
- )}
- </div>
- );
-}
+"use client";
+
+import {
+ useTimelineStore,
+ type TimelineClip,
+ type TimelineTrack,
+} from "@/stores/timeline-store";
+import { useMediaStore, type MediaItem } from "@/stores/media-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import { VideoPlayer } from "@/components/ui/video-player";
+import { Button } from "@/components/ui/button";
+import { Play, Pause, Volume2, VolumeX, Plus } from "lucide-react";
+import { useState, useRef, useEffect } from "react";
+
+interface ActiveClip {
+ clip: TimelineClip;
+ track: TimelineTrack;
+ mediaItem: MediaItem | null;
+}
+
+export function PreviewPanel() {
+ const { tracks } = useTimelineStore();
+ const { mediaItems } = useMediaStore();
+ const { currentTime, muted, toggleMute, volume } = usePlaybackStore();
+ const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
+ const previewRef = useRef<HTMLDivElement>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const [previewDimensions, setPreviewDimensions] = useState({
+ width: 0,
+ height: 0,
+ });
+
+ // Calculate optimal preview size that fits in container while maintaining aspect ratio
+ useEffect(() => {
+ const updatePreviewSize = () => {
+ if (!containerRef.current) return;
+
+ const container = containerRef.current.getBoundingClientRect();
+ const computedStyle = getComputedStyle(containerRef.current);
+
+ // Get padding values
+ const paddingTop = parseFloat(computedStyle.paddingTop);
+ const paddingBottom = parseFloat(computedStyle.paddingBottom);
+ const paddingLeft = parseFloat(computedStyle.paddingLeft);
+ const paddingRight = parseFloat(computedStyle.paddingRight);
+
+ // Get gap value (gap-4 = 1rem = 16px)
+ const gap = parseFloat(computedStyle.gap) || 16;
+
+ // Get toolbar height if it exists
+ const toolbar = containerRef.current.querySelector("[data-toolbar]");
+ const toolbarHeight = toolbar
+ ? toolbar.getBoundingClientRect().height
+ : 0;
+
+ // Calculate available space after accounting for padding, gap, and toolbar
+ const availableWidth = container.width - paddingLeft - paddingRight;
+ const availableHeight =
+ container.height -
+ paddingTop -
+ paddingBottom -
+ toolbarHeight -
+ (toolbarHeight > 0 ? gap : 0);
+
+ const targetRatio = canvasSize.width / canvasSize.height;
+ const containerRatio = availableWidth / availableHeight;
+
+ let width, height;
+
+ if (containerRatio > targetRatio) {
+ // Container is wider - constrain by height
+ height = availableHeight;
+ width = height * targetRatio;
+ } else {
+ // Container is taller - constrain by width
+ width = availableWidth;
+ height = width / targetRatio;
+ }
+
+ setPreviewDimensions({ width, height });
+ };
+
+ updatePreviewSize();
+
+ const resizeObserver = new ResizeObserver(updatePreviewSize);
+ if (containerRef.current) {
+ resizeObserver.observe(containerRef.current);
+ }
+
+ return () => resizeObserver.disconnect();
+ }, [canvasSize.width, canvasSize.height]);
+
+ // Get active clips at current time
+ const getActiveClips = (): ActiveClip[] => {
+ const activeClips: ActiveClip[] = [];
+
+ tracks.forEach((track) => {
+ track.clips.forEach((clip) => {
+ const clipStart = clip.startTime;
+ const clipEnd =
+ clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+
+ if (currentTime >= clipStart && currentTime < clipEnd) {
+ const mediaItem =
+ clip.mediaId === "test"
+ ? null // Test clips don't have a real media item
+ : mediaItems.find((item) => item.id === clip.mediaId) || null;
+
+ activeClips.push({ clip, track, mediaItem });
+ }
+ });
+ });
+
+ return activeClips;
+ };
+
+ const activeClips = getActiveClips();
+
+ // Render a clip
+ const renderClip = (clipData: ActiveClip, index: number) => {
+ const { clip, mediaItem } = clipData;
+
+ // Test clips
+ if (!mediaItem || clip.mediaId === "test") {
+ return (
+ <div
+ key={clip.id}
+ className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
+ >
+ <div className="text-center">
+ <div className="text-2xl mb-2">🎬</div>
+ <p className="text-xs text-white">{clip.name}</p>
+ </div>
+ </div>
+ );
+ }
+
+ // Video clips
+ if (mediaItem.type === "video") {
+ return (
+ <div key={clip.id} className="absolute inset-0">
+ <VideoPlayer
+ src={mediaItem.url}
+ poster={mediaItem.thumbnailUrl}
+ clipStartTime={clip.startTime}
+ trimStart={clip.trimStart}
+ trimEnd={clip.trimEnd}
+ clipDuration={clip.duration}
+ />
+ </div>
+ );
+ }
+
+ // Image clips
+ if (mediaItem.type === "image") {
+ return (
+ <div key={clip.id} className="absolute inset-0">
+ <img
+ src={mediaItem.url}
+ alt={mediaItem.name}
+ className="w-full h-full object-cover"
+ draggable={false}
+ />
+ </div>
+ );
+ }
+
+ // Audio clips (visual representation)
+ if (mediaItem.type === "audio") {
+ return (
+ <div
+ key={clip.id}
+ className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
+ >
+ <div className="text-center">
+ <div className="text-2xl mb-2">🎵</div>
+ <p className="text-xs text-white">{mediaItem.name}</p>
+ </div>
+ </div>
+ );
+ }
+
+ return null;
+ };
+
+ // Canvas presets
+ const canvasPresets = [
+ { name: "16:9 HD", width: 1920, height: 1080 },
+ { name: "16:9 4K", width: 3840, height: 2160 },
+ { name: "9:16 Mobile", width: 1080, height: 1920 },
+ { name: "1:1 Square", width: 1080, height: 1080 },
+ { name: "4:3 Standard", width: 1440, height: 1080 },
+ ];
+
+ return (
+ <div className="h-full w-full flex flex-col min-h-0 min-w-0">
+ {/* Controls */}
+ <div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
+ <span className="text-muted-foreground">Canvas:</span>
+ <select
+ value={`${canvasSize.width}x${canvasSize.height}`}
+ onChange={(e) => {
+ const preset = canvasPresets.find(
+ (p) => `${p.width}x${p.height}` === e.target.value
+ );
+ if (preset)
+ setCanvasSize({ width: preset.width, height: preset.height });
+ }}
+ className="bg-background border rounded px-2 py-1 text-xs"
+ >
+ {canvasPresets.map((preset) => (
+ <option
+ key={preset.name}
+ value={`${preset.width}x${preset.height}`}
+ >
+ {preset.name} ({preset.width}×{preset.height})
+ </option>
+ ))}
+ </select>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={toggleMute}
+ className="ml-auto"
+ >
+ {muted || volume === 0 ? (
+ <VolumeX className="h-3 w-3 mr-1" />
+ ) : (
+ <Volume2 className="h-3 w-3 mr-1" />
+ )}
+ {muted || volume === 0 ? "Unmute" : "Mute"}
+ </Button>
+ </div>
+
+ {/* Preview Area */}
+ <div
+ ref={containerRef}
+ className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
+ >
+ <div
+ ref={previewRef}
+ className="relative overflow-hidden rounded-sm bg-black border"
+ style={{
+ width: previewDimensions.width,
+ height: previewDimensions.height,
+ }}
+ >
+ {activeClips.length === 0 ? (
+ <div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
+ {tracks.length === 0
+ ? "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>
+ );
+}
diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx
index c784dab8..96ef66e5 100644
--- a/apps/web/src/components/editor/properties-panel.tsx
+++ b/apps/web/src/components/editor/properties-panel.tsx
@@ -17,13 +17,12 @@ import { useMediaStore } from "@/stores/media-store";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
import { useState } from "react";
import { SpeedControl } from "./speed-control";
+import type { BackgroundType } from "@/types/editor";
export function PropertiesPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
- const [backgroundType, setBackgroundType] = useState<
- "blur" | "mirror" | "color"
- >("blur");
+ const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
const [backgroundColor, setBackgroundColor] = useState("#000000");
// Get the first video clip for preview (simplified)
@@ -78,7 +77,9 @@ export function PropertiesPanel() {
<Label htmlFor="bg-type">Background Type</Label>
<Select
value={backgroundType}
- onValueChange={(value: any) => setBackgroundType(value)}
+ onValueChange={(value: BackgroundType) =>
+ setBackgroundType(value)
+ }
>
<SelectTrigger>
<SelectValue placeholder="Select background type" />
diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-clip.tsx
new file mode 100644
index 00000000..4c0d3b77
--- /dev/null
+++ b/apps/web/src/components/editor/timeline-clip.tsx
@@ -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>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index 591eac89..ae6b98d3 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -1860,3 +1860,4 @@ function TimelineTrackContent({
</div>
);
}
+
diff --git a/apps/web/src/components/header-base.tsx b/apps/web/src/components/header-base.tsx
index 5f7b2b8d..f27eb7fc 100644
--- a/apps/web/src/components/header-base.tsx
+++ b/apps/web/src/components/header-base.tsx
@@ -29,7 +29,7 @@ export function HeaderBase({
return (
<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>}
{centerContent && (
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index 7366dd31..ff15774e 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -1,14 +1,13 @@
"use client";
import Link from "next/link";
-import Image from "next/image";
import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
import { useSession } from "@opencut/auth/client";
import { getStars } from "@/lib/fetchGhStars";
-import { Star } from "lucide-react";
import { useEffect, useState } from "react";
+import Image from "next/image";
export function Header() {
const { data: session } = useSession();
@@ -29,15 +28,15 @@ export function Header() {
const leftContent = (
<Link href="/" className="flex items-center gap-3">
- <Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
- <span className="font-medium tracking-tight">OpenCut</span>
+ <Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
+ <span className="text-xl font-medium hidden md:block">OpenCut</span>
</Link>
);
const rightContent = (
- <nav className="flex items-center">
+ <nav className="flex items-center gap-3">
<Link href="/contributors">
- <Button variant="text" className="text-sm">
+ <Button variant="text" className="text-sm p-0">
Contributors
</Button>
</Link>
@@ -59,5 +58,13 @@ export function Header() {
</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>
+ );
}
diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx
index e63b6c79..563c92a9 100644
--- a/apps/web/src/components/icons.tsx
+++ b/apps/web/src/components/icons.tsx
@@ -29,7 +29,7 @@ export function GithubIcon({ className }: { className?: string }) {
viewBox="0 -3.5 256 256"
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="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" />
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx
index 7b4588c8..d5c5ed92 100644
--- a/apps/web/src/components/landing/hero.tsx
+++ b/apps/web/src/components/landing/hero.tsx
@@ -8,6 +8,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { getStars } from "@/lib/fetchGhStars";
+import Image from "next/image";
interface HeroProps {
signupCount: number;
@@ -82,7 +83,7 @@ export function Hero({ signupCount }: HeroProps) {
};
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
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -93,18 +94,21 @@ export function Hero({ signupCount }: HeroProps) {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
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">
- The open source
- </h1>
- <h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
- video editor
- </h1>
+ <h1>The Open Source</h1>
+ <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
+ <div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2">
+ <Image src="/frame.svg" height={79} width={459} alt="frame" />
+ <span className="absolute inset-0 flex items-center justify-center">
+ Video Editor
+ </span>
+ </div>
+ </div>
</motion.div>
<motion.p
- className="mt-10 text-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 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4, duration: 0.8 }}
@@ -119,7 +123,10 @@ export function Hero({ signupCount }: HeroProps) {
animate={{ opacity: 1 }}
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
type="email"
placeholder="Enter your email"
diff --git a/apps/web/src/components/ui/image-timeline-treatment.tsx b/apps/web/src/components/ui/image-timeline-treatment.tsx
index 600dd243..31847df5 100644
--- a/apps/web/src/components/ui/image-timeline-treatment.tsx
+++ b/apps/web/src/components/ui/image-timeline-treatment.tsx
@@ -2,13 +2,14 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
+import type { BackgroundType } from "@/types/editor";
interface ImageTimelineTreatmentProps {
src: string;
alt: string;
targetAspectRatio?: number; // Default to 16:9 for video
className?: string;
- backgroundType?: "blur" | "mirror" | "color";
+ backgroundType?: BackgroundType;
backgroundColor?: string;
}
diff --git a/apps/web/src/hooks/use-drag-clip.ts b/apps/web/src/hooks/use-drag-clip.ts
new file mode 100644
index 00000000..eb4d7435
--- /dev/null
+++ b/apps/web/src/hooks/use-drag-clip.ts
@@ -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,
+ };
+}
diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts
index d241f411..daf22b34 100644
--- a/apps/web/src/lib/media-processing.ts
+++ b/apps/web/src/lib/media-processing.ts
@@ -11,11 +11,15 @@ import {
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
export async function processMediaFiles(
- files: FileList | File[]
+ files: FileList | File[],
+ onProgress?: (progress: number) => void
): Promise<ProcessedMediaItem[]> {
const fileArray = Array.from(files);
const processedItems: ProcessedMediaItem[] = [];
+ const total = fileArray.length;
+ let completed = 0;
+
for (const file of fileArray) {
const fileType = getFileType(file);
@@ -57,6 +61,15 @@ export async function processMediaFiles(
duration,
aspectRatio,
});
+
+ // Yield back to the event loop to keep the UI responsive
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ completed += 1;
+ if (onProgress) {
+ const percent = Math.round((completed / total) * 100);
+ onProgress(percent);
+ }
} catch (error) {
console.error("Error processing file:", file.name, error);
toast.error(`Failed to process ${file.name}`);
diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts
index 0cc01cc1..543865f9 100644
--- a/apps/web/src/stores/playback-store.ts
+++ b/apps/web/src/stores/playback-store.ts
@@ -8,7 +8,7 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
let playbackTimer: number | null = null;
-const startTimer = (store: any) => {
+const startTimer = (store: () => PlaybackStore) => {
if (playbackTimer) cancelAnimationFrame(playbackTimer);
// Use requestAnimationFrame for smoother updates
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index e1474bae..3b068d7c 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -1,264 +1,346 @@
-import { create } from "zustand";
-
-export interface TimelineClip {
- id: string;
- mediaId: string;
- name: string;
- duration: number;
- startTime: number;
- trimStart: number;
- trimEnd: number;
-}
-
-export interface TimelineTrack {
- id: string;
- name: string;
- type: "video" | "audio" | "effects";
- clips: TimelineClip[];
- muted?: boolean;
-}
-
-interface TimelineStore {
- tracks: TimelineTrack[];
- history: TimelineTrack[][];
- redoStack: TimelineTrack[][];
-
- // Multi-selection
- selectedClips: { trackId: string; clipId: string }[];
- selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
- deselectClip: (trackId: string, clipId: string) => void;
- clearSelectedClips: () => void;
- setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
-
- // Actions
- addTrack: (type: "video" | "audio" | "effects") => string;
- removeTrack: (trackId: string) => void;
- addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
- removeClipFromTrack: (trackId: string, clipId: string) => void;
- moveClipToTrack: (
- fromTrackId: string,
- toTrackId: string,
- clipId: string
- ) => void;
- updateClipTrim: (
- trackId: string,
- clipId: string,
- trimStart: number,
- trimEnd: number
- ) => void;
- updateClipStartTime: (
- trackId: string,
- clipId: string,
- startTime: number
- ) => void;
- toggleTrackMute: (trackId: string) => void;
-
- // Computed values
- getTotalDuration: () => number;
-
- // New actions
- undo: () => void;
- redo: () => void;
- pushHistory: () => void;
-}
-
-export const useTimelineStore = create<TimelineStore>((set, get) => ({
- tracks: [],
- history: [],
- redoStack: [],
- selectedClips: [],
-
- pushHistory: () => {
- const { tracks, history, redoStack } = get();
- // Deep copy tracks
- set({
- history: [...history, JSON.parse(JSON.stringify(tracks))],
- redoStack: [] // Clear redo stack when new action is performed
- });
- },
-
- undo: () => {
- const { history, redoStack, tracks } = get();
- if (history.length === 0) return;
- const prev = history[history.length - 1];
- set({
- tracks: prev,
- history: history.slice(0, -1),
- redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack
- });
- },
-
- selectClip: (trackId, clipId, multi = false) => {
- set((state) => {
- const exists = state.selectedClips.some(
- (c) => c.trackId === trackId && c.clipId === clipId
- );
- if (multi) {
- // Toggle selection
- return exists
- ? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) }
- : { selectedClips: [...state.selectedClips, { trackId, clipId }] };
- } else {
- return { selectedClips: [{ trackId, clipId }] };
- }
- });
- },
- deselectClip: (trackId, clipId) => {
- set((state) => ({
- selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)),
- }));
- },
- clearSelectedClips: () => {
- set({ selectedClips: [] });
- },
-
- setSelectedClips: (clips) => set({ selectedClips: clips }),
-
- addTrack: (type) => {
- get().pushHistory();
- const newTrack: TimelineTrack = {
- id: crypto.randomUUID(),
- name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
- type,
- clips: [],
- muted: false,
- };
- set((state) => ({
- tracks: [...state.tracks, newTrack],
- }));
- return newTrack.id;
- },
-
- removeTrack: (trackId) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks.filter((track) => track.id !== trackId),
- }));
- },
-
- addClipToTrack: (trackId, clipData) => {
- get().pushHistory();
- const newClip: TimelineClip = {
- ...clipData,
- id: crypto.randomUUID(),
- startTime: clipData.startTime || 0,
- trimStart: 0,
- trimEnd: 0,
- };
-
- set((state) => ({
- tracks: state.tracks.map((track) =>
- track.id === trackId
- ? { ...track, clips: [...track.clips, newClip] }
- : track
- ),
- }));
- },
-
- removeClipFromTrack: (trackId, clipId) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks
- .map((track) =>
- track.id === trackId
- ? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
- : track
- )
- // Remove track if it becomes empty
- .filter((track) => track.clips.length > 0),
- }));
- },
-
- moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
- get().pushHistory();
- set((state) => {
- const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
- const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
-
- if (!clipToMove) return state;
-
- return {
- tracks: state.tracks
- .map((track) => {
- if (track.id === fromTrackId) {
- return {
- ...track,
- clips: track.clips.filter((clip) => clip.id !== clipId),
- };
- } else if (track.id === toTrackId) {
- return {
- ...track,
- clips: [...track.clips, clipToMove],
- };
- }
- return track;
- })
- // Remove track if it becomes empty
- .filter((track) => track.clips.length > 0),
- };
- });
- },
-
- updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- clips: track.clips.map((clip) =>
- clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
- ),
- }
- : track
- ),
- }));
- },
-
- updateClipStartTime: (trackId, clipId, startTime) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- clips: track.clips.map((clip) =>
- clip.id === clipId ? { ...clip, startTime } : clip
- ),
- }
- : track
- ),
- }));
- },
-
- toggleTrackMute: (trackId) => {
- get().pushHistory();
- set((state) => ({
- tracks: state.tracks.map((track) =>
- track.id === trackId ? { ...track, muted: !track.muted } : track
- ),
- }));
- },
-
- getTotalDuration: () => {
- const { tracks } = get();
- if (tracks.length === 0) return 0;
-
- const trackEndTimes = tracks.map((track) =>
- track.clips.reduce((maxEnd, clip) => {
- const clipEnd =
- clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
- return Math.max(maxEnd, clipEnd);
- }, 0)
- );
-
- return Math.max(...trackEndTimes, 0);
- },
-
- redo: () => {
- const { redoStack } = get();
- if (redoStack.length === 0) return;
- const next = redoStack[redoStack.length - 1];
- set({ tracks: next, redoStack: redoStack.slice(0, -1) });
- },
-}));
+import { create } from "zustand";
+
+export interface TimelineClip {
+ id: string;
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ trimStart: number;
+ trimEnd: number;
+}
+
+export interface TimelineTrack {
+ id: string;
+ name: string;
+ type: "video" | "audio" | "effects";
+ clips: TimelineClip[];
+ muted?: boolean;
+}
+
+interface TimelineStore {
+ tracks: TimelineTrack[];
+ history: TimelineTrack[][];
+ redoStack: TimelineTrack[][];
+
+ // Multi-selection
+ selectedClips: { trackId: string; clipId: string }[];
+ selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
+ deselectClip: (trackId: string, clipId: string) => void;
+ clearSelectedClips: () => void;
+ setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
+
+ // Drag state
+ dragState: {
+ isDragging: boolean;
+ clipId: string | null;
+ trackId: string | null;
+ startMouseX: number;
+ startClipTime: number;
+ clickOffsetTime: number;
+ currentTime: number;
+ };
+ setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
+ startDrag: (
+ clipId: string,
+ trackId: string,
+ startMouseX: number,
+ startClipTime: number,
+ clickOffsetTime: number
+ ) => void;
+ updateDragTime: (currentTime: number) => void;
+ endDrag: () => void;
+
+ // Actions
+ addTrack: (type: "video" | "audio" | "effects") => string;
+ removeTrack: (trackId: string) => void;
+ addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
+ removeClipFromTrack: (trackId: string, clipId: string) => void;
+ moveClipToTrack: (
+ fromTrackId: string,
+ toTrackId: string,
+ clipId: string
+ ) => void;
+ updateClipTrim: (
+ trackId: string,
+ clipId: string,
+ trimStart: number,
+ trimEnd: number
+ ) => void;
+ updateClipStartTime: (
+ trackId: string,
+ clipId: string,
+ startTime: number
+ ) => void;
+ toggleTrackMute: (trackId: string) => void;
+
+ // Computed values
+ getTotalDuration: () => number;
+
+ // New actions
+ undo: () => void;
+ redo: () => void;
+ pushHistory: () => void;
+}
+
+export const useTimelineStore = create<TimelineStore>((set, get) => ({
+ tracks: [],
+ history: [],
+ redoStack: [],
+ selectedClips: [],
+
+ pushHistory: () => {
+ const { tracks, history, redoStack } = get();
+ // Deep copy tracks
+ set({
+ history: [...history, JSON.parse(JSON.stringify(tracks))],
+ redoStack: [], // Clear redo stack when new action is performed
+ });
+ },
+
+ undo: () => {
+ const { history, redoStack, tracks } = get();
+ if (history.length === 0) return;
+ const prev = history[history.length - 1];
+ set({
+ tracks: prev,
+ history: history.slice(0, -1),
+ redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], // Add current state to redo stack
+ });
+ },
+
+ selectClip: (trackId, clipId, multi = false) => {
+ set((state) => {
+ const exists = state.selectedClips.some(
+ (c) => c.trackId === trackId && c.clipId === clipId
+ );
+ if (multi) {
+ // Toggle selection
+ return exists
+ ? {
+ selectedClips: state.selectedClips.filter(
+ (c) => !(c.trackId === trackId && c.clipId === clipId)
+ ),
+ }
+ : { selectedClips: [...state.selectedClips, { trackId, clipId }] };
+ } else {
+ return { selectedClips: [{ trackId, clipId }] };
+ }
+ });
+ },
+ deselectClip: (trackId, clipId) => {
+ set((state) => ({
+ selectedClips: state.selectedClips.filter(
+ (c) => !(c.trackId === trackId && c.clipId === clipId)
+ ),
+ }));
+ },
+ clearSelectedClips: () => {
+ set({ selectedClips: [] });
+ },
+
+ setSelectedClips: (clips) => set({ selectedClips: clips }),
+
+ addTrack: (type) => {
+ get().pushHistory();
+ const newTrack: TimelineTrack = {
+ id: crypto.randomUUID(),
+ name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
+ type,
+ clips: [],
+ muted: false,
+ };
+ set((state) => ({
+ tracks: [...state.tracks, newTrack],
+ }));
+ return newTrack.id;
+ },
+
+ removeTrack: (trackId) => {
+ get().pushHistory();
+ set((state) => ({
+ tracks: state.tracks.filter((track) => track.id !== trackId),
+ }));
+ },
+
+ addClipToTrack: (trackId, clipData) => {
+ get().pushHistory();
+ const newClip: TimelineClip = {
+ ...clipData,
+ id: crypto.randomUUID(),
+ startTime: clipData.startTime || 0,
+ trimStart: 0,
+ trimEnd: 0,
+ };
+
+ set((state) => ({
+ tracks: state.tracks.map((track) =>
+ track.id === trackId
+ ? { ...track, clips: [...track.clips, newClip] }
+ : track
+ ),
+ }));
+ },
+
+ removeClipFromTrack: (trackId, clipId) => {
+ get().pushHistory();
+ set((state) => ({
+ tracks: state.tracks
+ .map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ clips: track.clips.filter((clip) => clip.id !== clipId),
+ }
+ : track
+ )
+ // Remove track if it becomes empty
+ .filter((track) => track.clips.length > 0),
+ }));
+ },
+
+ moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
+ get().pushHistory();
+ set((state) => {
+ const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
+ const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
+
+ if (!clipToMove) return state;
+
+ return {
+ tracks: state.tracks
+ .map((track) => {
+ if (track.id === fromTrackId) {
+ return {
+ ...track,
+ clips: track.clips.filter((clip) => clip.id !== clipId),
+ };
+ } else if (track.id === toTrackId) {
+ return {
+ ...track,
+ clips: [...track.clips, clipToMove],
+ };
+ }
+ return track;
+ })
+ // Remove track if it becomes empty
+ .filter((track) => track.clips.length > 0),
+ };
+ });
+ },
+
+ updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
+ get().pushHistory();
+ set((state) => ({
+ tracks: state.tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ clips: track.clips.map((clip) =>
+ clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
+ ),
+ }
+ : track
+ ),
+ }));
+ },
+
+ updateClipStartTime: (trackId, clipId, startTime) => {
+ get().pushHistory();
+ set((state) => ({
+ tracks: state.tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ clips: track.clips.map((clip) =>
+ clip.id === clipId ? { ...clip, startTime } : clip
+ ),
+ }
+ : track
+ ),
+ }));
+ },
+
+ toggleTrackMute: (trackId) => {
+ get().pushHistory();
+ set((state) => ({
+ tracks: state.tracks.map((track) =>
+ track.id === trackId ? { ...track, muted: !track.muted } : track
+ ),
+ }));
+ },
+
+ getTotalDuration: () => {
+ const { tracks } = get();
+ if (tracks.length === 0) return 0;
+
+ const trackEndTimes = tracks.map((track) =>
+ track.clips.reduce((maxEnd, clip) => {
+ const clipEnd =
+ clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
+ return Math.max(maxEnd, clipEnd);
+ }, 0)
+ );
+
+ return Math.max(...trackEndTimes, 0);
+ },
+
+ redo: () => {
+ const { redoStack } = get();
+ if (redoStack.length === 0) return;
+ const next = redoStack[redoStack.length - 1];
+ set({ tracks: next, redoStack: redoStack.slice(0, -1) });
+ },
+
+ 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,
+ },
+ });
+ },
+}));
diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts
new file mode 100644
index 00000000..bf1dae2f
--- /dev/null
+++ b/apps/web/src/types/editor.ts
@@ -0,0 +1 @@
+export type BackgroundType = "blur" | "mirror" | "color";
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
new file mode 100644
index 00000000..21be520e
--- /dev/null
+++ b/apps/web/src/types/timeline.ts
@@ -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;
+}