summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx10
-rw-r--r--apps/web/src/app/globals.css6
-rw-r--r--apps/web/src/app/layout.tsx11
-rw-r--r--apps/web/src/app/page.tsx1
-rw-r--r--apps/web/src/app/why-not-capcut/page.tsx191
-rw-r--r--apps/web/src/components/background-settings.tsx184
-rw-r--r--apps/web/src/components/editor-header.tsx19
-rw-r--r--apps/web/src/components/editor/media-panel/index.tsx2
-rw-r--r--apps/web/src/components/editor/media-panel/tabbar.tsx4
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx25
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx239
-rw-r--r--apps/web/src/components/editor/properties-panel.tsx220
-rw-r--r--apps/web/src/components/editor/properties-panel/audio-properties.tsx5
-rw-r--r--apps/web/src/components/editor/properties-panel/index.tsx109
-rw-r--r--apps/web/src/components/editor/properties-panel/media-properties.tsx5
-rw-r--r--apps/web/src/components/editor/properties-panel/property-item.tsx47
-rw-r--r--apps/web/src/components/editor/properties-panel/text-properties.tsx76
-rw-r--r--apps/web/src/components/editor/selection-box.tsx58
-rw-r--r--apps/web/src/components/editor/timeline-element.tsx188
-rw-r--r--apps/web/src/components/editor/timeline-playhead.tsx110
-rw-r--r--apps/web/src/components/editor/timeline-track.tsx220
-rw-r--r--apps/web/src/components/editor/timeline.tsx676
-rw-r--r--apps/web/src/components/header.tsx4
-rw-r--r--apps/web/src/components/icons.tsx61
-rw-r--r--apps/web/src/components/landing/handlebars.tsx164
-rw-r--r--apps/web/src/components/landing/hero.tsx30
-rw-r--r--apps/web/src/components/ui/audio-player.tsx127
-rw-r--r--apps/web/src/components/ui/button.tsx2
-rw-r--r--apps/web/src/components/ui/dropdown-menu.tsx4
-rw-r--r--apps/web/src/components/ui/font-picker.tsx40
-rw-r--r--apps/web/src/components/ui/input.tsx11
-rw-r--r--apps/web/src/components/ui/resizable.tsx10
-rw-r--r--apps/web/src/components/ui/select.tsx4
-rw-r--r--apps/web/src/components/ui/textarea.tsx2
-rw-r--r--apps/web/src/components/ui/video-player.tsx2
-rw-r--r--apps/web/src/constants/font-constants.ts79
-rw-r--r--apps/web/src/constants/timeline-constants.ts (renamed from apps/web/src/lib/timeline-constants.ts)49
-rw-r--r--apps/web/src/data/colors.ts244
-rw-r--r--apps/web/src/hooks/auth/useLogin.ts4
-rw-r--r--apps/web/src/hooks/use-aspect-ratio.ts92
-rw-r--r--apps/web/src/hooks/use-selection-box.ts199
-rw-r--r--apps/web/src/hooks/use-timeline-playhead.ts157
-rw-r--r--apps/web/src/hooks/use-timeline-zoom.ts54
-rw-r--r--apps/web/src/lib/font-config.ts39
-rw-r--r--apps/web/src/lib/media-processing.ts38
-rw-r--r--apps/web/src/lib/storage/storage-service.ts6
-rw-r--r--apps/web/src/lib/time.ts8
-rw-r--r--apps/web/src/lib/utils.ts30
-rw-r--r--apps/web/src/middleware.ts5
-rw-r--r--apps/web/src/stores/editor-store.ts18
-rw-r--r--apps/web/src/stores/media-store.ts6
-rw-r--r--apps/web/src/stores/project-store.ts87
-rw-r--r--apps/web/src/stores/timeline-store.ts167
-rw-r--r--apps/web/src/types/project.ts4
-rw-r--r--apps/web/src/types/timeline.ts3
55 files changed, 3243 insertions, 913 deletions
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index 7de30ded..db17dd4a 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -58,7 +58,10 @@ export default function Editor() {
<div className="h-screen w-screen flex flex-col bg-background overflow-hidden">
<EditorHeader />
<div className="flex-1 min-h-0 min-w-0">
- <ResizablePanelGroup direction="vertical" className="h-full w-full">
+ <ResizablePanelGroup
+ direction="vertical"
+ className="h-full w-full gap-1"
+ >
<ResizablePanel
defaultSize={mainContent}
minSize={30}
@@ -69,7 +72,7 @@ export default function Editor() {
{/* Main content area */}
<ResizablePanelGroup
direction="horizontal"
- className="h-full w-full"
+ className="h-full w-full gap-1 px-2"
>
{/* Tools Panel */}
<ResizablePanel
@@ -96,7 +99,6 @@ export default function Editor() {
<ResizableHandle withHandle />
-
<ResizablePanel
defaultSize={propertiesPanel}
minSize={15}
@@ -117,7 +119,7 @@ export default function Editor() {
minSize={15}
maxSize={70}
onResize={setTimeline}
- className="min-h-0"
+ className="min-h-0 px-2 pb-2"
>
<Timeline />
</ResizablePanel>
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 1d41696c..2f7158cc 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% 4%;
--foreground: 0 0% 89%;
--card: 0 0% 14.9%;
--card-foreground: 0 0% 98%;
@@ -71,6 +71,8 @@
--sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 14.9%;
--sidebar-ring: 0 0% 83.1%;
+ --panel-background: 0 0% 11%;
+ --panel-accent: 0 0% 15%;
}
}
@@ -80,5 +82,7 @@
}
body {
@apply bg-background text-foreground;
+ /* Prevent back/forward swipe */
+ overscroll-behavior-x: contain;
}
}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 6dd74b89..cb026566 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -1,4 +1,3 @@
-import { Inter } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { Analytics } from "@vercel/analytics/react";
import Script from "next/script";
@@ -8,11 +7,7 @@ import { TooltipProvider } from "../components/ui/tooltip";
import { DevelopmentDebug } from "../components/development-debug";
import { StorageProvider } from "../components/storage-provider";
import { baseMetaData } from "./metadata";
-
-const inter = Inter({
- subsets: ["latin"],
- variable: "--font-inter",
-});
+import { defaultFont } from "../lib/font-config";
export const metadata = baseMetaData;
@@ -23,8 +18,8 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
- <body className={`${inter.variable} font-sans antialiased`}>
- <ThemeProvider attribute="class" forcedTheme="dark" enableSystem>
+ <body className={`${defaultFont.className} font-sans antialiased`}>
+ <ThemeProvider attribute="class" forcedTheme="dark">
<TooltipProvider>
<StorageProvider>{children}</StorageProvider>
<Analytics />
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index f78e9d52..74308470 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -2,7 +2,6 @@ import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import { getWaitlistCount } from "@/lib/waitlist";
-import Image from "next/image";
// Force dynamic rendering so waitlist count updates in real-time
export const dynamic = "force-dynamic";
diff --git a/apps/web/src/app/why-not-capcut/page.tsx b/apps/web/src/app/why-not-capcut/page.tsx
new file mode 100644
index 00000000..56b3d27a
--- /dev/null
+++ b/apps/web/src/app/why-not-capcut/page.tsx
@@ -0,0 +1,191 @@
+import { Header } from "@/components/header";
+
+export default function WhyNotCapcut() {
+ return (
+ <div className="min-h-screen bg-background px-5">
+ <Header />
+
+ <main className="relative mt-12">
+ <div className="absolute inset-0 overflow-hidden pointer-events-none">
+ <div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
+ <div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
+ </div>
+
+ <div className="relative container mx-auto px-4 py-16">
+ <div className="max-w-6xl mx-auto">
+ <div className="text-center mb-20">
+ <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
+ Fuck CapCut
+ </h1>
+ <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
+ Roasting time, so get ready motherfucker.
+ </p>
+ </div>
+
+ <div className="max-w-4xl mx-auto space-y-12">
+ <div>
+ <h2 className="text-3xl font-bold mb-6">
+ Seriously, what the fuck else do you want?
+ </h2>
+ <p className="text-lg mb-6">
+ You probably use CapCut and think your video editing is
+ special. You think your fucking TikTok with 47 transitions and
+ 12 different fonts is going to get you some viral fame. You
+ think loading up every goddamn effect in their library makes
+ your content better. Wrong, motherfucker. Let me describe what
+ CapCut actually gives you:
+ </p>
+ <ul className="text-lg space-y-2 mb-6 list-disc list-inside">
+ <li>A paywall every time you breathe</li>
+ <li>Terms of service that steal your shit</li>
+ <li>
+ More "Get Pro" dialogs than a Windows 95 error message
+ </li>
+ <li>
+ Features that disappear behind paywalls while you're fucking
+ using them
+ </li>
+ <li>Bugs disguised as "premium features"</li>
+ </ul>
+ <p className="text-lg mb-6">
+ <strong>Well guess what, motherfucker:</strong>
+ </p>
+ <p className="text-lg mb-6">
+ You. Are. Getting. Scammed. Look at this shit. It's a fucking
+ video editor. Why the fuck do you need to pay $20/month just
+ to remove a goddamn watermark? You spent hours editing your
+ video and they slap their logo on it like they fucking made
+ it.
+ </p>
+ </div>
+
+ <div>
+ <h2 className="text-3xl font-bold mb-6">
+ The "Get Pro" dialog is everywhere
+ </h2>
+ <p className="text-lg mb-6">
+ This motherfucking dialog pops up more than ads on a pirated
+ movie site. Want to add a transition? Get Pro. Want to export
+ without their watermark? Get Pro. Want to use more than 2
+ fonts? Get fucking Pro, peasant.
+ </p>
+ <p className="text-lg mb-6">
+ Did you seriously think you could edit a video without seeing
+ this dialog 47 times? You click one button and BAM - there it
+ is again, asking for your credit card like a desperate ex
+ asking for money.
+ </p>
+ </div>
+
+ <div>
+ <h2 className="text-3xl font-bold mb-6">
+ Everything costs money now
+ </h2>
+ <p className="text-lg mb-6">
+ You dumbass. You thought CapCut was free, but no. Free means
+ they let you open the app. Everything else costs money. Basic
+ shake effect? That'll be $20/month. A decent transition that isn't
+ "fade"? Pay up, motherfucker.
+ </p>
+ <p className="text-lg mb-6">
+ Here's my favorite piece of bullshit: You import an MP3 file -
+ you know, AUDIO - and try to export. "Sorry, can't export
+ because you're using our premium extract audio feature!"
+ </p>
+ <p className="text-lg mb-6">
+ <strong>
+ My MP3 was already fucking audio, you absolute morons.
+ </strong>
+ </p>
+ <p className="text-lg mb-6">
+ But wait, there's more! If you drag that same MP3 to their
+ media panel first, then to the timeline, it magically works.
+ This isn't a bug, it's a fucking scam disguised as software
+ engineering.
+ </p>
+ </div>
+
+ <div>
+ <h2 className="text-3xl font-bold mb-6">
+ Their Terms of Service are insane
+ </h2>
+ <p className="text-lg mb-6">
+ Look at this shit. You upload your content and they basically
+ say "thanks for the free content, we own it now, but if Disney
+ sues anyone, that's your problem."
+ </p>
+ <p className="text-lg mb-6">
+ <strong>CapCut's Terms of Service:</strong> We get full rights
+ to use, modify, distribute, and monetize everything you upload
+ - permanently and without paying you shit. But you're still
+ responsible if anything goes wrong.
+ </p>
+ <p className="text-lg mb-6">
+ Translation: "We'll make money off your viral video, you
+ handle the lawsuits." Brilliant legal strategy, you fucks.
+ </p>
+ </div>
+
+ <div>
+ <h2 className="text-3xl font-bold mb-6">
+ The editor is actually good
+ </h2>
+ <p className="text-lg mb-6">
+ Here's the thing that makes me want to punch my monitor: the
+ actual video editor is fucking good. It's intuitive, powerful,
+ and anyone can figure it out. When it's not begging for money
+ every 30 seconds, it actually works well.
+ </p>
+ <p className="text-lg mb-6">
+ Which makes everything else so much worse. They built
+ something people want to use, then turned it into a digital
+ slot machine. Every click might trigger a payment request.
+ </p>
+ </div>
+
+ <div>
+ <h2 className="text-3xl font-bold mb-6">
+ This is a video editor. Look at it. You've never seen one
+ before.
+ </h2>
+ <p className="text-lg mb-6">
+ Like the person who's never used software that doesn't
+ constantly beg for money, you have no fucking idea what a
+ video editor should be. All you've ever seen are predatory
+ apps disguised as creative tools.
+ </p>
+ <p className="text-lg mb-6">
+ A real video editor lets you edit videos. It doesn't steal
+ your content. It doesn't pop up payment dialogs every 5
+ seconds. It doesn't charge you separately for basic features
+ that should be free.
+ </p>
+ </div>
+
+ <div>
+ <h2 className="text-3xl font-bold mb-6">
+ Yes, this is fucking satire, you fuck
+ </h2>
+ <p className="text-lg mb-6">
+ I'm not actually saying all video editors should be basic as
+ shit. What I'm saying is that all the problems we have with
+ video editing apps are{" "}
+ <strong>ones they create themselves</strong>. Video editors
+ aren't broken by default - they edit videos, export them, and
+ let you use basic features without constantly begging for
+ money. CapCut breaks them. They turn them into payment
+ processors with video editing as a side feature.
+ </p>
+ <p className="text-lg">
+ <em>"Good software gets out of your way."</em>
+ <br />- Some smart motherfucker who definitely wasn't working
+ at CapCut
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/background-settings.tsx b/apps/web/src/components/background-settings.tsx
new file mode 100644
index 00000000..721564ea
--- /dev/null
+++ b/apps/web/src/components/background-settings.tsx
@@ -0,0 +1,184 @@
+import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
+import { Button } from "./ui/button";
+import { BackgroundIcon } from "./icons";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import { colors } from "@/data/colors";
+import { useProjectStore } from "@/stores/project-store";
+import { PipetteIcon } from "lucide-react";
+
+type BackgroundTab = "color" | "blur";
+
+export function BackgroundSettings() {
+ const { activeProject, updateBackgroundType } = useProjectStore();
+
+ // ✅ Good: derive activeTab from activeProject during rendering
+ const activeTab = activeProject?.backgroundType || "color";
+
+ const handleColorSelect = (color: string) => {
+ updateBackgroundType("color", { backgroundColor: color });
+ };
+
+ const handleBlurSelect = (blurIntensity: number) => {
+ updateBackgroundType("blur", { blurIntensity });
+ };
+
+ const tabs = [
+ {
+ label: "Color",
+ value: "color",
+ },
+ {
+ label: "Blur",
+ value: "blur",
+ },
+ ];
+
+ return (
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ className="!size-4 border border-muted-foreground"
+ >
+ <BackgroundIcon className="!size-3" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
+ <div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
+ <h2 className="text-sm">Background</h2>
+ <div className="flex items-center gap-2 text-sm">
+ {tabs.map((tab) => (
+ <span
+ key={tab.value}
+ onClick={() => {
+ // Switch to the background type when clicking tabs
+ if (tab.value === "color") {
+ updateBackgroundType("color", {
+ backgroundColor:
+ activeProject?.backgroundColor || "#000000",
+ });
+ } else {
+ updateBackgroundType("blur", {
+ blurIntensity: activeProject?.blurIntensity || 8,
+ });
+ }
+ }}
+ className={cn(
+ "text-muted-foreground cursor-pointer",
+ activeTab === tab.value && "text-foreground"
+ )}
+ >
+ {tab.label}
+ </span>
+ ))}
+ </div>
+ </div>
+ {activeTab === "color" ? (
+ <ColorView
+ selectedColor={activeProject?.backgroundColor || "#000000"}
+ onColorSelect={handleColorSelect}
+ />
+ ) : (
+ <BlurView
+ selectedBlur={activeProject?.blurIntensity || 8}
+ onBlurSelect={handleBlurSelect}
+ />
+ )}
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function ColorView({
+ selectedColor,
+ onColorSelect,
+}: {
+ selectedColor: string;
+ onColorSelect: (color: string) => void;
+}) {
+ return (
+ <div className="w-full h-full">
+ <div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none"></div>
+ <div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
+ <div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
+ <PipetteIcon className="size-4" />
+ </div>
+ {colors.map((color) => (
+ <ColorItem
+ key={color}
+ color={color}
+ isSelected={color === selectedColor}
+ onClick={() => onColorSelect(color)}
+ />
+ ))}
+ </div>
+ </div>
+ );
+}
+
+function ColorItem({
+ color,
+ isSelected,
+ onClick,
+}: {
+ color: string;
+ isSelected: boolean;
+ onClick: () => void;
+}) {
+ return (
+ <div
+ className={cn(
+ "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
+ isSelected && "border-2 border-primary"
+ )}
+ style={{ backgroundColor: color }}
+ onClick={onClick}
+ />
+ );
+}
+
+function BlurView({
+ selectedBlur,
+ onBlurSelect,
+}: {
+ selectedBlur: number;
+ onBlurSelect: (blurIntensity: number) => void;
+}) {
+ const blurLevels = [
+ { label: "Light", value: 4 },
+ { label: "Medium", value: 8 },
+ { label: "Heavy", value: 18 },
+ ];
+ const blurImage =
+ "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
+
+ return (
+ <div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
+ {blurLevels.map((blur) => (
+ <div
+ key={blur.value}
+ className={cn(
+ "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
+ selectedBlur === blur.value && "border-2 border-primary"
+ )}
+ onClick={() => onBlurSelect(blur.value)}
+ >
+ <Image
+ src={blurImage}
+ alt={`Blur preview ${blur.label}`}
+ fill
+ className="object-cover"
+ style={{ filter: `blur(${blur.value}px)` }}
+ />
+ <div className="absolute bottom-1 left-1 right-1 text-center">
+ <span className="text-xs text-white bg-black/50 px-1 rounded">
+ {blur.label}
+ </span>
+ </div>
+ </div>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx
index daeb14cb..c4081c75 100644
--- a/apps/web/src/components/editor-header.tsx
+++ b/apps/web/src/components/editor-header.tsx
@@ -30,14 +30,25 @@ export function EditorHeader() {
);
const centerContent = (
- <div className="flex items-center gap-2 text-xs text-muted-foreground">
- <span>{formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")}</span>
+ <div className="flex items-center gap-2 text-xs">
+ <span>
+ {formatTimeCode(
+ getTotalDuration(),
+ "HH:MM:SS:FF",
+ activeProject?.fps || 30
+ )}
+ </span>
</div>
);
const rightContent = (
<nav className="flex items-center gap-2">
- <Button size="sm" variant="primary" onClick={handleExport}>
+ <Button
+ size="sm"
+ variant="primary"
+ className="h-7 text-xs"
+ onClick={handleExport}
+ >
<Download className="h-4 w-4" />
<span className="text-sm">Export</span>
</Button>
@@ -49,7 +60,7 @@ export function EditorHeader() {
leftContent={leftContent}
centerContent={centerContent}
rightContent={rightContent}
- className="bg-background border-b"
+ className="bg-background h-[3.2rem] px-4"
/>
);
}
diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx
index 51e9a264..9f7322c7 100644
--- a/apps/web/src/components/editor/media-panel/index.tsx
+++ b/apps/web/src/components/editor/media-panel/index.tsx
@@ -47,7 +47,7 @@ export function MediaPanel() {
};
return (
- <div className="h-full flex flex-col">
+ <div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
<TabBar />
<div className="flex-1">{viewMap[activeTab]}</div>
</div>
diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx
index 580394b9..f4e520b2 100644
--- a/apps/web/src/components/editor/media-panel/tabbar.tsx
+++ b/apps/web/src/components/editor/media-panel/tabbar.tsx
@@ -69,7 +69,7 @@ export function TabBar() {
/>
<div
ref={scrollContainerRef}
- className="h-12 bg-accent/50 px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative"
+ className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative"
>
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
const tab = tabs[tabKey];
@@ -111,7 +111,7 @@ function ScrollButton({
const Icon = direction === "left" ? ChevronLeft : ChevronRight;
return (
- <div className="bg-accent/50 w-12 h-full flex items-center justify-center">
+ <div className="bg-panel-accent w-12 h-full flex items-center justify-center">
<Button
size="icon"
className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx
index f7da684d..48e54961 100644
--- a/apps/web/src/components/editor/media-panel/views/media.tsx
+++ b/apps/web/src/components/editor/media-panel/views/media.tsx
@@ -3,7 +3,6 @@
import { useDragDrop } from "@/hooks/use-drag-drop";
import { processMediaFiles } from "@/lib/media-processing";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
-import { useTimelineStore } from "@/stores/timeline-store";
import { Image, Music, Plus, Upload, Video } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
@@ -85,25 +84,7 @@ export function MediaView() {
return;
}
- // Remove elements automatically when delete media
- const { tracks, removeTrack } = useTimelineStore.getState();
- tracks.forEach((track) => {
- const elementsToRemove = track.elements.filter(
- (element) => element.type === "media" && element.mediaId === id
- );
- elementsToRemove.forEach((element) => {
- useTimelineStore
- .getState()
- .removeElementFromTrack(track.id, element.id);
- });
- // Only remove track if it becomes empty and has no other elements
- const updatedTrack = useTimelineStore
- .getState()
- .tracks.find((t) => t.id === track.id);
- if (updatedTrack && updatedTrack.elements.length === 0) {
- removeTrack(track.id);
- }
- });
+ // Media store now handles cascade deletion automatically
await removeMediaItem(activeProject.id, id);
};
@@ -254,7 +235,7 @@ export function MediaView() {
size="sm"
onClick={handleFileSelect}
disabled={isProcessing}
- className="flex-none min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
+ className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
>
{isProcessing ? (
<>
@@ -297,7 +278,7 @@ export function MediaView() {
{/* Render each media item as a draggable button */}
{filteredMediaItems.map((item) => (
<ContextMenu key={item.id}>
- <ContextMenuTrigger asChild>
+ <ContextMenuTrigger>
<DraggableMediaItem
name={item.name}
preview={renderPreview(item)}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 2809a8a5..894b6257 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -2,14 +2,12 @@
import { useTimelineStore } from "@/stores/timeline-store";
import { TimelineElement, TimelineTrack } from "@/types/timeline";
-import {
- useMediaStore,
- type MediaItem,
- getMediaAspectRatio,
-} from "@/stores/media-store";
+import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useEditorStore } from "@/stores/editor-store";
+import { useAspectRatio } from "@/hooks/use-aspect-ratio";
import { VideoPlayer } from "@/components/ui/video-player";
+import { AudioPlayer } from "@/components/ui/audio-player";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -18,10 +16,13 @@ import {
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
-import { Play, Pause } from "lucide-react";
+import { Play, Pause, Expand } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
+import { FONT_CLASS_MAP } from "@/lib/font-config";
+import { BackgroundSettings } from "../background-settings";
+import { useProjectStore } from "@/stores/project-store";
interface ActiveElement {
element: TimelineElement;
@@ -40,6 +41,7 @@ export function PreviewPanel() {
width: 0,
height: 0,
});
+ const { activeProject } = useProjectStore();
// Calculate optimal preview size that fits in container while maintaining aspect ratio
useEffect(() => {
@@ -137,12 +139,96 @@ export function PreviewPanel() {
// Check if there are any elements in the timeline at all
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
+ // Get media elements for blur background (video/image only)
+ const getBlurBackgroundElements = (): ActiveElement[] => {
+ return activeElements.filter(
+ ({ element, mediaItem }) =>
+ element.type === "media" &&
+ mediaItem &&
+ (mediaItem.type === "video" || mediaItem.type === "image") &&
+ element.mediaId !== "test" // Exclude test elements
+ );
+ };
+
+ const blurBackgroundElements = getBlurBackgroundElements();
+
+ // Render blur background layer
+ const renderBlurBackground = () => {
+ if (
+ !activeProject?.backgroundType ||
+ activeProject.backgroundType !== "blur" ||
+ blurBackgroundElements.length === 0
+ ) {
+ return null;
+ }
+
+ // Use the first media element for background (could be enhanced to use primary/focused element)
+ const backgroundElement = blurBackgroundElements[0];
+ const { element, mediaItem } = backgroundElement;
+
+ if (!mediaItem) return null;
+
+ const blurIntensity = activeProject.blurIntensity || 8;
+
+ if (mediaItem.type === "video") {
+ return (
+ <div
+ key={`blur-${element.id}`}
+ className="absolute inset-0 overflow-hidden"
+ style={{
+ filter: `blur(${blurIntensity}px)`,
+ transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts
+ transformOrigin: "center",
+ }}
+ >
+ <VideoPlayer
+ src={mediaItem.url!}
+ poster={mediaItem.thumbnailUrl}
+ clipStartTime={element.startTime}
+ trimStart={element.trimStart}
+ trimEnd={element.trimEnd}
+ clipDuration={element.duration}
+ className="w-full h-full object-cover"
+ />
+ </div>
+ );
+ }
+
+ if (mediaItem.type === "image") {
+ return (
+ <div
+ key={`blur-${element.id}`}
+ className="absolute inset-0 overflow-hidden"
+ style={{
+ filter: `blur(${blurIntensity}px)`,
+ transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts
+ transformOrigin: "center",
+ }}
+ >
+ <img
+ src={mediaItem.url!}
+ alt={mediaItem.name}
+ className="w-full h-full object-cover"
+ draggable={false}
+ />
+ </div>
+ );
+ }
+
+ return null;
+ };
+
// Render an element
const renderElement = (elementData: ActiveElement, index: number) => {
const { element, mediaItem } = elementData;
// Text elements
if (element.type === "text") {
+ const fontClassName =
+ FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || "";
+
+ const scaleRatio = previewDimensions.width / canvasSize.width;
+
return (
<div
key={element.id}
@@ -150,15 +236,15 @@ export function PreviewPanel() {
style={{
left: `${50 + (element.x / canvasSize.width) * 100}%`,
top: `${50 + (element.y / canvasSize.height) * 100}%`,
- transform: `translate(-50%, -50%) rotate(${element.rotation}deg)`,
+ transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`,
opacity: element.opacity,
zIndex: 100 + index, // Text elements on top
}}
>
<div
+ className={fontClassName}
style={{
fontSize: `${element.fontSize}px`,
- fontFamily: element.fontFamily,
color: element.color,
backgroundColor: element.backgroundColor,
textAlign: element.textAlign,
@@ -167,7 +253,9 @@ export function PreviewPanel() {
textDecoration: element.textDecoration,
padding: "4px 8px",
borderRadius: "2px",
- whiteSpace: "pre-wrap",
+ whiteSpace: "nowrap",
+ // Fallback for system fonts that don't have classes
+ ...(fontClassName === "" && { fontFamily: element.fontFamily }),
}}
>
{element.content}
@@ -196,7 +284,10 @@ export function PreviewPanel() {
// Video elements
if (mediaItem.type === "video") {
return (
- <div key={element.id} className="absolute inset-0">
+ <div
+ key={element.id}
+ className="absolute inset-0 flex items-center justify-center"
+ >
<VideoPlayer
src={mediaItem.url!}
poster={mediaItem.thumbnailUrl}
@@ -212,28 +303,32 @@ export function PreviewPanel() {
// Image elements
if (mediaItem.type === "image") {
return (
- <div key={element.id} className="absolute inset-0">
+ <div
+ key={element.id}
+ className="absolute inset-0 flex items-center justify-center"
+ >
<img
src={mediaItem.url!}
alt={mediaItem.name}
- className="w-full h-full object-cover"
+ className="max-w-full max-h-full object-contain"
draggable={false}
/>
</div>
);
}
- // Audio elements (visual representation)
+ // Audio elements (no visual representation)
if (mediaItem.type === "audio") {
return (
- <div
- key={element.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 key={element.id} className="absolute inset-0">
+ <AudioPlayer
+ src={mediaItem.url!}
+ clipStartTime={element.startTime}
+ trimStart={element.trimStart}
+ trimEnd={element.trimEnd}
+ clipDuration={element.duration}
+ trackMuted={elementData.track.muted}
+ />
</div>
);
}
@@ -243,20 +338,26 @@ export function PreviewPanel() {
};
return (
- <div className="h-full w-full flex flex-col min-h-0 min-w-0">
+ <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm">
<div
ref={containerRef}
- className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
+ className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0"
>
+ <div className="flex-1"></div>
{hasAnyElements ? (
<div
ref={previewRef}
- className="relative overflow-hidden rounded-sm bg-black border"
+ className="relative overflow-hidden rounded-sm border"
style={{
width: previewDimensions.width,
height: previewDimensions.height,
+ backgroundColor:
+ activeProject?.backgroundType === "blur"
+ ? "transparent"
+ : activeProject?.backgroundColor || "#000000",
}}
>
+ {renderBlurBackground()}
{activeElements.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
No elements at current time
@@ -266,6 +367,14 @@ export function PreviewPanel() {
renderElement(elementData, index)
)
)}
+ {/* Show message when blur is selected but no media available */}
+ {activeProject?.backgroundType === "blur" &&
+ blurBackgroundElements.length === 0 &&
+ activeElements.length > 0 && (
+ <div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
+ Add a video or image to use blur background
+ </div>
+ )}
</div>
) : null}
@@ -279,68 +388,53 @@ export function PreviewPanel() {
function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
const { isPlaying, toggle, currentTime } = usePlaybackStore();
+ const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
+ const { getTotalDuration } = useTimelineStore();
+ const { activeProject } = useProjectStore();
const {
- canvasSize,
+ currentPreset,
+ isOriginal,
+ getOriginalAspectRatio,
+ getDisplayName,
canvasPresets,
- setCanvasSize,
- setCanvasSizeFromAspectRatio,
- } = useEditorStore();
- const { mediaItems } = useMediaStore();
- const { tracks, getTotalDuration } = useTimelineStore();
-
- // Find the current preset based on canvas size
- const currentPreset = canvasPresets.find(
- (preset) =>
- preset.width === canvasSize.width && preset.height === canvasSize.height
- );
+ } = useAspectRatio();
const handlePresetSelect = (preset: { width: number; height: number }) => {
setCanvasSize({ width: preset.width, height: preset.height });
};
- // Get the first video/image media item to determine original aspect ratio
- const getOriginalAspectRatio = () => {
- // Find first video or image in timeline
- for (const track of tracks) {
- for (const element of track.elements) {
- if (element.type === "media") {
- const mediaItem = mediaItems.find(
- (item) => item.id === element.mediaId
- );
- if (
- mediaItem &&
- (mediaItem.type === "video" || mediaItem.type === "image")
- ) {
- return getMediaAspectRatio(mediaItem);
- }
- }
- }
- }
- return 16 / 9; // Default aspect ratio
- };
-
const handleOriginalSelect = () => {
const aspectRatio = getOriginalAspectRatio();
- setCanvasSizeFromAspectRatio(aspectRatio);
+ setCanvasSizeToOriginal(aspectRatio);
};
- // Check if current size is "Original" (not matching any preset)
- const isOriginal = !currentPreset;
-
return (
<div
data-toolbar
- className="flex items-end justify-between gap-2 p-1 pt-2 bg-background-500 w-full"
+ className="flex items-end justify-between gap-2 p-1 pt-2 w-full"
>
<div>
<p
className={cn(
- "text-xs text-muted-foreground tabular-nums",
+ "text-[0.75rem] text-muted-foreground flex items-center gap-1",
!hasAnyElements && "opacity-50"
)}
>
- {formatTimeCode(currentTime, "HH:MM:SS:CS")}/
- {formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")}
+ <span className="text-primary tabular-nums">
+ {formatTimeCode(
+ currentTime,
+ "HH:MM:SS:FF",
+ activeProject?.fps || 30
+ )}
+ </span>
+ <span className="opacity-50">/</span>
+ <span className="tabular-nums">
+ {formatTimeCode(
+ getTotalDuration(),
+ "HH:MM:SS:FF",
+ activeProject?.fps || 30
+ )}
+ </span>
</p>
</div>
<Button
@@ -348,6 +442,7 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
size="icon"
onClick={toggle}
disabled={!hasAnyElements}
+ className="h-auto p-0"
>
{isPlaying ? (
<Pause className="h-3 w-3" />
@@ -355,15 +450,16 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
<Play className="h-3 w-3" />
)}
</Button>
- <div>
+ <div className="flex items-center gap-3">
+ <BackgroundSettings />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
- className="!bg-background text-foreground/85 text-xs h-auto rounded-none border border-muted-foreground px-0.5 py-0 font-light"
+ className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light"
disabled={!hasAnyElements}
>
- {currentPreset?.name || "Ratio"}
+ {getDisplayName()}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -388,6 +484,13 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
))}
</DropdownMenuContent>
</DropdownMenu>
+ <Button
+ variant="text"
+ size="icon"
+ className="!size-4 text-muted-foreground"
+ >
+ <Expand className="!size-4" />
+ </Button>
</div>
</div>
);
diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx
deleted file mode 100644
index 0de78988..00000000
--- a/apps/web/src/components/editor/properties-panel.tsx
+++ /dev/null
@@ -1,220 +0,0 @@
-"use client";
-
-import { Input } from "../ui/input";
-import { Label } from "../ui/label";
-import { Slider } from "../ui/slider";
-import { ScrollArea } from "../ui/scroll-area";
-import { Separator } from "../ui/separator";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "../ui/select";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
-import { useState } from "react";
-import { SpeedControl } from "./speed-control";
-import type { BackgroundType } from "@/types/editor";
-
-export function PropertiesPanel() {
- const { tracks } = useTimelineStore();
- const { mediaItems } = useMediaStore();
- const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
- const [backgroundColor, setBackgroundColor] = useState("#000000");
-
- // Get the first video element for preview (simplified)
- const firstVideoElement = tracks
- .flatMap((track) => track.elements)
- .find((element) => {
- if (element.type !== "media") return false;
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- return mediaItem?.type === "video";
- });
-
- const firstVideoItem = firstVideoElement && firstVideoElement.type === "media"
- ? mediaItems.find((item) => item.id === firstVideoElement.mediaId)
- : null;
-
- const firstImageElement = tracks
- .flatMap((track) => track.elements)
- .find((element) => {
- if (element.type !== "media") return false;
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- return mediaItem?.type === "image";
- });
-
- const firstImageItem = firstImageElement && firstImageElement.type === "media"
- ? mediaItems.find((item) => item.id === firstImageElement.mediaId)
- : null;
-
- return (
- <ScrollArea className="h-full">
- <div className="space-y-6 p-5">
- {/* Image Treatment - only show if an image is selected */}
- {firstImageItem && (
- <>
- <div className="space-y-4">
- <h3 className="text-sm font-medium">Image Treatment</h3>
- <div className="space-y-4">
- {/* Preview */}
- <div className="space-y-2">
- <Label>Preview</Label>
- <div className="w-full aspect-video max-w-48">
- <ImageTimelineTreatment
- src={firstImageItem.url!}
- alt={firstImageItem.name}
- targetAspectRatio={16 / 9}
- className="rounded-sm border"
- backgroundType={backgroundType}
- backgroundColor={backgroundColor}
- />
- </div>
- </div>
-
- {/* Background Type */}
- <div className="space-y-2">
- <Label htmlFor="bg-type">Background Type</Label>
- <Select
- value={backgroundType}
- onValueChange={(value: BackgroundType) =>
- setBackgroundType(value)
- }
- >
- <SelectTrigger>
- <SelectValue placeholder="Select background type" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="blur">Blur</SelectItem>
- <SelectItem value="mirror">Mirror</SelectItem>
- <SelectItem value="color">Solid Color</SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- {/* Background Color - only show for color type */}
- {backgroundType === "color" && (
- <div className="space-y-2">
- <Label htmlFor="bg-color">Background Color</Label>
- <div className="flex gap-2">
- <Input
- id="bg-color"
- type="color"
- value={backgroundColor}
- onChange={(e) => setBackgroundColor(e.target.value)}
- className="w-16 h-10 p-1"
- />
- <Input
- value={backgroundColor}
- onChange={(e) => setBackgroundColor(e.target.value)}
- placeholder="#000000"
- className="flex-1"
- />
- </div>
- </div>
- )}
- </div>
- </div>
-
- <Separator />
- </>
- )}
-
- {/* Video Controls - only show if a video is selected */}
- {firstVideoItem && (
- <>
- <SpeedControl />
- <Separator />
- </>
- )}
-
- {/* Transform */}
- <div className="space-y-4">
- <h3 className="text-sm font-medium">Transform</h3>
- <div className="space-y-2">
- <div className="grid grid-cols-2 gap-2">
- <div className="space-y-1">
- <Label htmlFor="x">X Position</Label>
- <Input id="x" type="number" defaultValue="0" />
- </div>
- <div className="space-y-1">
- <Label htmlFor="y">Y Position</Label>
- <Input id="y" type="number" defaultValue="0" />
- </div>
- </div>
- <div className="space-y-1">
- <Label htmlFor="rotation">Rotation</Label>
- <Slider
- id="rotation"
- max={360}
- step={1}
- defaultValue={[0]}
- className="mt-2"
- />
- </div>
- </div>
- </div>
-
- <Separator />
-
- {/* Effects */}
- <div className="space-y-4">
- <h3 className="text-sm font-medium">Effects</h3>
- <div className="space-y-4">
- <div className="space-y-1">
- <Label htmlFor="opacity">Opacity</Label>
- <Slider
- id="opacity"
- max={100}
- step={1}
- defaultValue={[100]}
- className="mt-2"
- />
- </div>
- <div className="space-y-1">
- <Label htmlFor="blur">Blur</Label>
- <Slider
- id="blur"
- max={20}
- step={0.5}
- defaultValue={[0]}
- className="mt-2"
- />
- </div>
- </div>
- </div>
-
- <Separator />
-
- {/* Timing */}
- <div className="space-y-4">
- <h3 className="text-sm font-medium">Timing</h3>
- <div className="space-y-2">
- <div className="space-y-1">
- <Label htmlFor="duration">Duration (seconds)</Label>
- <Input
- id="duration"
- type="number"
- min="0"
- step="0.1"
- defaultValue="5"
- />
- </div>
- <div className="space-y-1">
- <Label htmlFor="delay">Delay (seconds)</Label>
- <Input
- id="delay"
- type="number"
- min="0"
- step="0.1"
- defaultValue="0"
- />
- </div>
- </div>
- </div>
- </div>
- </ScrollArea>
- );
-}
diff --git a/apps/web/src/components/editor/properties-panel/audio-properties.tsx b/apps/web/src/components/editor/properties-panel/audio-properties.tsx
new file mode 100644
index 00000000..384f0c5e
--- /dev/null
+++ b/apps/web/src/components/editor/properties-panel/audio-properties.tsx
@@ -0,0 +1,5 @@
+import { MediaElement } from "@/types/timeline";
+
+export function AudioProperties({ element }: { element: MediaElement }) {
+ return <div className="space-y-4 p-5">Audio properties</div>;
+} \ No newline at end of file
diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx
new file mode 100644
index 00000000..9e28850f
--- /dev/null
+++ b/apps/web/src/components/editor/properties-panel/index.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { useProjectStore } from "@/stores/project-store";
+import { useAspectRatio } from "@/hooks/use-aspect-ratio";
+import { Label } from "../../ui/label";
+import { ScrollArea } from "../../ui/scroll-area";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { AudioProperties } from "./audio-properties";
+import { MediaProperties } from "./media-properties";
+import { TextProperties } from "./text-properties";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../../ui/select";
+import { FPS_PRESETS } from "@/constants/timeline-constants";
+
+export function PropertiesPanel() {
+ const { activeProject, updateProjectFps } = useProjectStore();
+ const { getDisplayName, canvasSize } = useAspectRatio();
+ const { selectedElements, tracks } = useTimelineStore();
+ const { mediaItems } = useMediaStore();
+
+ const handleFpsChange = (value: string) => {
+ const fps = parseFloat(value);
+ if (!isNaN(fps) && fps > 0) {
+ updateProjectFps(fps);
+ }
+ };
+
+ const emptyView = (
+ <div className="space-y-4 p-5">
+ {/* Media Properties */}
+ <div className="flex flex-col gap-3">
+ <PropertyItem label="Name:" value={activeProject?.name || ""} />
+ <PropertyItem label="Aspect ratio:" value={getDisplayName()} />
+ <PropertyItem
+ label="Resolution:"
+ value={`${canvasSize.width} × ${canvasSize.height}`}
+ />
+ <div className="flex justify-between items-center">
+ <Label className="text-xs text-muted-foreground">Frame rate:</Label>
+ <Select
+ value={(activeProject?.fps || 30).toString()}
+ onValueChange={handleFpsChange}
+ >
+ <SelectTrigger className="w-32 h-6 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {FPS_PRESETS.map(({ value, label }) => (
+ <SelectItem key={value} value={value} className="text-xs">
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </div>
+ );
+
+ return (
+ <ScrollArea className="h-full bg-panel rounded-sm">
+ {selectedElements.length > 0
+ ? selectedElements.map(({ trackId, elementId }) => {
+ const track = tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((e) => e.id === elementId);
+
+ if (element?.type === "text") {
+ return (
+ <div key={elementId}>
+ <TextProperties element={element} trackId={trackId} />
+ </div>
+ );
+ }
+ if (element?.type === "media") {
+ const mediaItem = mediaItems.find(
+ (item) => item.id === element.mediaId
+ );
+
+ if (mediaItem?.type === "audio") {
+ return <AudioProperties element={element} />;
+ }
+
+ return (
+ <div key={elementId}>
+ <MediaProperties element={element} />
+ </div>
+ );
+ }
+ return null;
+ })
+ : emptyView}
+ </ScrollArea>
+ );
+}
+
+function PropertyItem({ label, value }: { label: string; value: string }) {
+ return (
+ <div className="flex justify-between">
+ <Label className="text-xs text-muted-foreground">{label}</Label>
+ <span className="text-xs text-right">{value}</span>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/properties-panel/media-properties.tsx b/apps/web/src/components/editor/properties-panel/media-properties.tsx
new file mode 100644
index 00000000..c2c514cf
--- /dev/null
+++ b/apps/web/src/components/editor/properties-panel/media-properties.tsx
@@ -0,0 +1,5 @@
+import { MediaElement } from "@/types/timeline";
+
+export function MediaProperties({ element }: { element: MediaElement }) {
+ return <div className="space-y-4 p-5">Media properties</div>;
+}
diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx
new file mode 100644
index 00000000..beb8c799
--- /dev/null
+++ b/apps/web/src/components/editor/properties-panel/property-item.tsx
@@ -0,0 +1,47 @@
+import { cn } from "@/lib/utils";
+
+interface PropertyItemProps {
+ direction?: "row" | "column";
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function PropertyItem({
+ direction = "row",
+ children,
+ className,
+}: PropertyItemProps) {
+ return (
+ <div
+ className={cn(
+ "flex gap-2",
+ direction === "row"
+ ? "items-center justify-between gap-6"
+ : "flex-col gap-1",
+ className
+ )}
+ >
+ {children}
+ </div>
+ );
+}
+
+export function PropertyItemLabel({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <label className={cn("text-xs", className)}>{children}</label>;
+}
+
+export function PropertyItemValue({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return <div className={cn("flex-1", className)}>{children}</div>;
+}
diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx
new file mode 100644
index 00000000..e13dabc8
--- /dev/null
+++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx
@@ -0,0 +1,76 @@
+import { Textarea } from "@/components/ui/textarea";
+import { FontPicker } from "@/components/ui/font-picker";
+import { FontFamily } from "@/constants/font-constants";
+import { TextElement } from "@/types/timeline";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { Slider } from "@/components/ui/slider";
+import { Input } from "@/components/ui/input";
+import {
+ PropertyItem,
+ PropertyItemLabel,
+ PropertyItemValue,
+} from "./property-item";
+
+export function TextProperties({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const { updateTextElement } = useTimelineStore();
+
+ return (
+ <div className="space-y-6 p-5">
+ <Textarea
+ placeholder="Name"
+ defaultValue={element.content}
+ className="min-h-[4.5rem] resize-none bg-background/50"
+ onChange={(e) =>
+ updateTextElement(trackId, element.id, { content: e.target.value })
+ }
+ />
+ <PropertyItem direction="row">
+ <PropertyItemLabel>Font</PropertyItemLabel>
+ <PropertyItemValue>
+ <FontPicker
+ defaultValue={element.fontFamily}
+ onValueChange={(value: FontFamily) =>
+ updateTextElement(trackId, element.id, { fontFamily: value })
+ }
+ />
+ </PropertyItemValue>
+ </PropertyItem>
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Font size</PropertyItemLabel>
+ <PropertyItemValue>
+ <div className="flex items-center gap-2">
+ <Slider
+ defaultValue={[element.fontSize]}
+ min={8}
+ max={300}
+ step={1}
+ onValueChange={([value]) =>
+ updateTextElement(trackId, element.id, { fontSize: value })
+ }
+ className="w-full"
+ />
+ <Input
+ type="number"
+ value={element.fontSize}
+ onChange={(e) =>
+ updateTextElement(trackId, element.id, {
+ fontSize: parseInt(e.target.value),
+ })
+ }
+ className="w-12 !text-xs h-7 rounded-sm text-center
+ [appearance:textfield]
+ [&::-webkit-outer-spin-button]:appearance-none
+ [&::-webkit-inner-spin-button]:appearance-none"
+ />
+ </div>
+ </PropertyItemValue>
+ </PropertyItem>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/selection-box.tsx b/apps/web/src/components/editor/selection-box.tsx
new file mode 100644
index 00000000..5c452f7c
--- /dev/null
+++ b/apps/web/src/components/editor/selection-box.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+
+interface SelectionBoxProps {
+ startPos: { x: number; y: number } | null;
+ currentPos: { x: number; y: number } | null;
+ containerRef: React.RefObject<HTMLElement>;
+ isActive: boolean;
+}
+
+export function SelectionBox({
+ startPos,
+ currentPos,
+ containerRef,
+ isActive,
+}: SelectionBoxProps) {
+ const selectionBoxRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ if (!isActive || !startPos || !currentPos || !containerRef.current) return;
+
+ const container = containerRef.current;
+ const containerRect = container.getBoundingClientRect();
+
+ // Calculate relative positions within the container
+ const startX = startPos.x - containerRect.left;
+ const startY = startPos.y - containerRect.top;
+ const currentX = currentPos.x - containerRect.left;
+ const currentY = currentPos.y - containerRect.top;
+
+ // Calculate the selection rectangle bounds
+ const left = Math.min(startX, currentX);
+ const top = Math.min(startY, currentY);
+ const width = Math.abs(currentX - startX);
+ const height = Math.abs(currentY - startY);
+
+ // Update the selection box position and size
+ if (selectionBoxRef.current) {
+ selectionBoxRef.current.style.left = `${left}px`;
+ selectionBoxRef.current.style.top = `${top}px`;
+ selectionBoxRef.current.style.width = `${width}px`;
+ selectionBoxRef.current.style.height = `${height}px`;
+ }
+ }, [startPos, currentPos, isActive, containerRef]);
+
+ if (!isActive || !startPos || !currentPos) return null;
+
+ return (
+ <div
+ ref={selectionBoxRef}
+ className="absolute pointer-events-none z-50"
+ style={{
+ backgroundColor: "hsl(var(--foreground) / 0.1)",
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx
index 4a909ce4..ee9445f3 100644
--- a/apps/web/src/components/editor/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline-element.tsx
@@ -11,6 +11,8 @@ import {
ChevronRight,
ChevronLeft,
Type,
+ Copy,
+ RefreshCw,
} from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
@@ -22,7 +24,7 @@ import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
import {
getTrackElementClasses,
TIMELINE_CONSTANTS,
-} from "@/lib/timeline-constants";
+} from "@/constants/timeline-constants";
import {
DropdownMenu,
DropdownMenuContent,
@@ -33,6 +35,13 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "../ui/dropdown-menu";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "../ui/context-menu";
export function TimelineElement({
element,
@@ -52,6 +61,8 @@ export function TimelineElement({
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
+ addElementToTrack,
+ replaceElementMedia,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
@@ -172,10 +183,73 @@ export function TimelineElement({
return mediaItem?.type === "video" && track.type === "media";
};
+ const handleElementSplitContext = () => {
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ if (currentTime > effectiveStart && currentTime < effectiveEnd) {
+ const secondElementId = splitElement(track.id, element.id, currentTime);
+ if (!secondElementId) {
+ toast.error("Failed to split element");
+ }
+ } else {
+ toast.error("Playhead must be within element to split");
+ }
+ };
+
+ const handleElementDuplicateContext = () => {
+ const { id, ...elementWithoutId } = element;
+ addElementToTrack(track.id, {
+ ...elementWithoutId,
+ name: element.name + " (copy)",
+ startTime:
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd) +
+ 0.1,
+ });
+ };
+
+ const handleElementDeleteContext = () => {
+ removeElementFromTrack(track.id, element.id);
+ };
+
+ const handleReplaceClip = () => {
+ if (element.type !== "media") {
+ toast.error("Replace is only available for media clips");
+ return;
+ }
+
+ // Create a file input to select replacement media
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = "video/*,audio/*,image/*";
+ input.onchange = async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (!file) return;
+
+ try {
+ const success = await replaceElementMedia(track.id, element.id, file);
+ if (success) {
+ toast.success("Clip replaced successfully");
+ } else {
+ toast.error("Failed to replace clip");
+ }
+ } catch (error) {
+ toast.error("Failed to replace clip");
+ console.log(
+ JSON.stringify({ error: "Failed to replace clip", details: error })
+ );
+ }
+ };
+ input.click();
+ };
+
const renderElementContent = () => {
if (element.type === "text") {
return (
- <div className="w-full h-full flex items-center justify-center">
+ <div className="w-full h-full flex items-center justify-start pl-2">
<span className="text-xs text-foreground/80 truncate">
{element.content}
</span>
@@ -255,47 +329,77 @@ export function TimelineElement({
};
return (
- <div
- className={`absolute top-0 h-full select-none timeline-element ${
- isBeingDragged ? "z-50" : "z-10"
- }`}
- style={{
- left: `${elementLeft}px`,
- width: `${elementWidth}px`,
- }}
- onMouseMove={resizing ? handleResizeMove : undefined}
- onMouseUp={resizing ? handleResizeEnd : undefined}
- onMouseLeave={resizing ? handleResizeEnd : undefined}
- >
- <div
- className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
- track.type
- )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
- isBeingDragged ? "z-50" : "z-10"
- }`}
- onClick={(e) => onElementClick && onElementClick(e, element)}
- onMouseDown={handleElementMouseDown}
- onContextMenu={(e) =>
- onElementMouseDown && onElementMouseDown(e, element)
- }
- >
- <div className="absolute inset-0 flex items-center h-full">
- {renderElementContent()}
- </div>
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div
+ className={`absolute top-0 h-full select-none timeline-element ${
+ isBeingDragged ? "z-50" : "z-10"
+ }`}
+ style={{
+ left: `${elementLeft}px`,
+ width: `${elementWidth}px`,
+ }}
+ data-element-id={element.id}
+ data-track-id={track.id}
+ onMouseMove={resizing ? handleResizeMove : undefined}
+ onMouseUp={resizing ? handleResizeEnd : undefined}
+ onMouseLeave={resizing ? handleResizeEnd : undefined}
+ >
+ <div
+ className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
+ track.type
+ )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
+ isBeingDragged ? "z-50" : "z-10"
+ }`}
+ onClick={(e) => onElementClick && onElementClick(e, element)}
+ onMouseDown={handleElementMouseDown}
+ onContextMenu={(e) =>
+ onElementMouseDown && onElementMouseDown(e, element)
+ }
+ >
+ <div className="absolute inset-0 flex items-center h-full">
+ {renderElementContent()}
+ </div>
- {isSelected && (
- <>
- <div
- className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
- onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
- />
- <div
- className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
- onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
- />
- </>
+ {isSelected && (
+ <>
+ <div
+ className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
+ onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
+ />
+ <div
+ className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
+ onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
+ />
+ </>
+ )}
+ </div>
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem onClick={handleElementSplitContext}>
+ <Scissors className="h-4 w-4 mr-2" />
+ Split at playhead
+ </ContextMenuItem>
+ <ContextMenuItem onClick={handleElementDuplicateContext}>
+ <Copy className="h-4 w-4 mr-2" />
+ Duplicate {element.type === "text" ? "text" : "clip"}
+ </ContextMenuItem>
+ {element.type === "media" && (
+ <ContextMenuItem onClick={handleReplaceClip}>
+ <RefreshCw className="h-4 w-4 mr-2" />
+ Replace clip
+ </ContextMenuItem>
)}
- </div>
- </div>
+ <ContextMenuSeparator />
+ <ContextMenuItem
+ onClick={handleElementDeleteContext}
+ className="text-destructive focus:text-destructive"
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ Delete {element.type === "text" ? "text" : "clip"}
+ </ContextMenuItem>
+ </ContextMenuContent>
+ </ContextMenu>
);
}
diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx
new file mode 100644
index 00000000..ce4ed1ff
--- /dev/null
+++ b/apps/web/src/components/editor/timeline-playhead.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { useRef } from "react";
+import { TimelineTrack } from "@/types/timeline";
+import {
+ TIMELINE_CONSTANTS,
+ getTotalTracksHeight,
+} from "@/constants/timeline-constants";
+import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
+
+interface TimelinePlayheadProps {
+ currentTime: number;
+ duration: number;
+ zoomLevel: number;
+ tracks: TimelineTrack[];
+ seek: (time: number) => void;
+ rulerRef: React.RefObject<HTMLDivElement>;
+ rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ trackLabelsRef?: React.RefObject<HTMLDivElement>;
+ timelineRef: React.RefObject<HTMLDivElement>;
+ playheadRef?: React.RefObject<HTMLDivElement>;
+}
+
+export function TimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ tracks,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ trackLabelsRef,
+ timelineRef,
+ playheadRef: externalPlayheadRef,
+}: TimelinePlayheadProps) {
+ const internalPlayheadRef = useRef<HTMLDivElement>(null);
+ const playheadRef = externalPlayheadRef || internalPlayheadRef;
+ const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ // Use timeline container height minus a few pixels for breathing room
+ const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
+ const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
+
+ // Get dynamic track labels width, fallback to 0 if no tracks or no ref
+ const trackLabelsWidth =
+ tracks.length > 0 && trackLabelsRef?.current
+ ? trackLabelsRef.current.offsetWidth
+ : 0;
+ const leftPosition =
+ trackLabelsWidth +
+ playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+
+ return (
+ <div
+ ref={playheadRef}
+ className="absolute pointer-events-auto z-[100]"
+ style={{
+ left: `${leftPosition}px`,
+ top: 0,
+ height: `${totalHeight}px`,
+ width: "2px", // Slightly wider for better click target
+ }}
+ onMouseDown={handlePlayheadMouseDown}
+ >
+ {/* The red line spanning full height */}
+ <div className="absolute left-0 w-0.5 bg-foreground cursor-col-resize h-full" />
+
+ {/* Red dot indicator at the top (in ruler area) */}
+ <div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-foreground rounded-full border-2 border-foreground shadow-sm" />
+ </div>
+ );
+}
+
+// Also export a hook for getting ruler handlers
+export function useTimelinePlayheadRuler({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
+ const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ return { handleRulerMouseDown, isDraggingRuler };
+}
+
+export { TimelinePlayhead as default };
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx
index f1766593..c3a16b9a 100644
--- a/apps/web/src/components/editor/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline-track.tsx
@@ -4,16 +4,8 @@ import { useRef, useState, useEffect } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { toast } from "sonner";
-import { Copy, Scissors, Trash2 } from "lucide-react";
import { TimelineElement } from "./timeline-element";
import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
-} from "../ui/context-menu";
-import {
TimelineTrack,
sortTracksByOrder,
ensureMainTrack,
@@ -25,7 +17,11 @@ import type {
TimelineElement as TimelineElementType,
DragData,
} from "@/types/timeline";
-import { TIMELINE_CONSTANTS } from "@/lib/timeline-constants";
+import {
+ snapTimeToFrame,
+ TIMELINE_CONSTANTS,
+} from "@/constants/timeline-constants";
+import { useProjectStore } from "@/stores/project-store";
export function TimelineTrackContent({
track,
@@ -88,7 +84,10 @@ export function TimelineTrackContent({
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
);
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
- const snappedTime = Math.round(adjustedTime * 10) / 10;
+ // Use frame snapping if project has FPS, otherwise use decimal snapping
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ const snappedTime = snapTimeToFrame(adjustedTime, projectFps);
updateDragTime(snappedTime);
};
@@ -96,65 +95,115 @@ export function TimelineTrackContent({
const handleMouseUp = (e: MouseEvent) => {
if (!dragState.elementId || !dragState.trackId) return;
- // Check if the mouse is actually over this track
+ // If this track initiated the drag, we should handle the mouse up regardless of where it occurs
+ const isTrackThatStartedDrag = dragState.trackId === track.id;
+
const timelineRect = timelineRef.current?.getBoundingClientRect();
- if (!timelineRect) return;
+ if (!timelineRect) {
+ if (isTrackThatStartedDrag) {
+ updateElementStartTime(
+ track.id,
+ dragState.elementId,
+ dragState.currentTime
+ );
+ endDragAction();
+ }
+ return;
+ }
const isMouseOverThisTrack =
e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
- // Only handle if mouse is over this track
- if (!isMouseOverThisTrack) return;
+ if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
const finalTime = dragState.currentTime;
- // Check for overlaps and update position
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingElement = sourceTrack?.elements.find(
- (c) => c.id === dragState.elementId
- );
+ if (isMouseOverThisTrack) {
+ const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
+ const movingElement = sourceTrack?.elements.find(
+ (c) => c.id === dragState.elementId
+ );
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const movingElementEnd = finalTime + movingElementDuration;
+ if (movingElement) {
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const movingElementEnd = finalTime + movingElementDuration;
- const targetTrack = tracks.find((t) => t.id === track.id);
- const hasOverlap = targetTrack?.elements.some((existingElement) => {
- if (
- dragState.trackId === track.id &&
- existingElement.id === dragState.elementId
- ) {
- return false;
+ const targetTrack = tracks.find((t) => t.id === track.id);
+ const hasOverlap = targetTrack?.elements.some((existingElement) => {
+ if (
+ dragState.trackId === track.id &&
+ existingElement.id === dragState.elementId
+ ) {
+ return false;
+ }
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return finalTime < existingEnd && movingElementEnd > existingStart;
+ });
+
+ if (!hasOverlap) {
+ if (dragState.trackId === track.id) {
+ updateElementStartTime(track.id, dragState.elementId, finalTime);
+ } else {
+ moveElementToTrack(
+ dragState.trackId,
+ track.id,
+ dragState.elementId
+ );
+ requestAnimationFrame(() => {
+ updateElementStartTime(
+ track.id,
+ dragState.elementId!,
+ finalTime
+ );
+ });
+ }
}
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return finalTime < existingEnd && movingElementEnd > existingStart;
- });
+ }
+ } else if (isTrackThatStartedDrag) {
+ // Mouse is not over this track, but this track started the drag
+ // This means user released over ruler/outside - update position within same track
+ const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
+ const movingElement = sourceTrack?.elements.find(
+ (c) => c.id === dragState.elementId
+ );
+
+ if (movingElement) {
+ const movingElementDuration =
+ movingElement.duration -
+ movingElement.trimStart -
+ movingElement.trimEnd;
+ const movingElementEnd = finalTime + movingElementDuration;
- if (!hasOverlap) {
- if (dragState.trackId === track.id) {
+ const hasOverlap = track.elements.some((existingElement) => {
+ if (existingElement.id === dragState.elementId) {
+ return false;
+ }
+ const existingStart = existingElement.startTime;
+ const existingEnd =
+ existingElement.startTime +
+ (existingElement.duration -
+ existingElement.trimStart -
+ existingElement.trimEnd);
+ return finalTime < existingEnd && movingElementEnd > existingStart;
+ });
+
+ if (!hasOverlap) {
updateElementStartTime(track.id, dragState.elementId, finalTime);
- } else {
- moveElementToTrack(
- dragState.trackId,
- track.id,
- dragState.elementId
- );
- requestAnimationFrame(() => {
- updateElementStartTime(track.id, dragState.elementId!, finalTime);
- });
}
}
}
- endDragAction();
+ if (isTrackThatStartedDrag) {
+ endDragAction();
+ }
};
document.addEventListener("mousemove", handleMouseMove);
@@ -300,7 +349,9 @@ export function TimelineTrackContent({
if (dragData.type === "text") {
// Text elements have default duration of 5 seconds
const newElementDuration = 5;
- const snappedTime = Math.round(dropTime * 10) / 10;
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ const snappedTime = snapTimeToFrame(dropTime, projectFps);
const newElementEnd = snappedTime + newElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
@@ -319,7 +370,9 @@ export function TimelineTrackContent({
);
if (mediaItem) {
const newElementDuration = mediaItem.duration || 5;
- const snappedTime = Math.round(dropTime * 10) / 10;
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ const snappedTime = snapTimeToFrame(dropTime, projectFps);
const newElementEnd = snappedTime + newElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
@@ -359,7 +412,9 @@ export function TimelineTrackContent({
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
- const snappedTime = Math.round(dropTime * 10) / 10;
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ const snappedTime = snapTimeToFrame(dropTime, projectFps);
const movingElementEnd = snappedTime + movingElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
@@ -386,13 +441,17 @@ export function TimelineTrackContent({
if (wouldOverlap) {
e.dataTransfer.dropEffect = "none";
setWouldOverlap(true);
- setDropPosition(Math.round(dropTime * 10) / 10);
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ setDropPosition(snapTimeToFrame(dropTime, projectFps));
return;
}
e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
setWouldOverlap(false);
- setDropPosition(Math.round(dropTime * 10) / 10);
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ setDropPosition(snapTimeToFrame(dropTime, projectFps));
};
const handleTrackDragEnter = (e: React.DragEvent) => {
@@ -460,7 +519,9 @@ export function TimelineTrackContent({
const mouseY = e.clientY - rect.top; // Get Y position relative to this track
const newStartTime =
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
- const snappedTime = Math.round(newStartTime * 10) / 10;
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ const snappedTime = snapTimeToFrame(newStartTime, projectFps);
// Calculate drop position relative to tracks
const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
@@ -506,7 +567,7 @@ export function TimelineTrackContent({
const adjustedStartTime = snappedTime - clickOffsetTime;
const finalStartTime = Math.max(
0,
- Math.round(adjustedStartTime * 10) / 10
+ snapTimeToFrame(adjustedStartTime, projectFps)
);
// Check for overlaps with existing elements (excluding the moving element itself)
@@ -882,38 +943,15 @@ export function TimelineTrackContent({
};
return (
- <ContextMenu key={element.id}>
- <ContextMenuTrigger asChild>
- <div>
- <TimelineElement
- element={element}
- track={track}
- zoomLevel={zoomLevel}
- isSelected={isSelected}
- onElementMouseDown={handleElementMouseDown}
- onElementClick={handleElementClick}
- />
- </div>
- </ContextMenuTrigger>
- <ContextMenuContent>
- <ContextMenuItem onClick={handleElementSplit}>
- <Scissors className="h-4 w-4 mr-2" />
- Split at playhead
- </ContextMenuItem>
- <ContextMenuItem onClick={handleElementDuplicate}>
- <Copy className="h-4 w-4 mr-2" />
- Duplicate {element.type === "text" ? "text" : "clip"}
- </ContextMenuItem>
- <ContextMenuSeparator />
- <ContextMenuItem
- onClick={handleElementDelete}
- className="text-destructive focus:text-destructive"
- >
- <Trash2 className="h-4 w-4 mr-2" />
- Delete {element.type === "text" ? "text" : "clip"}
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
+ <TimelineElement
+ key={element.id}
+ element={element}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onElementMouseDown={handleElementMouseDown}
+ onElementClick={handleElementClick}
+ />
);
})}
</>
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index f7208224..71e2997b 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -10,10 +10,11 @@ import {
Snowflake,
Copy,
SplitSquareHorizontal,
- Volume2,
- VolumeX,
Pause,
Play,
+ Video,
+ Music,
+ TypeIcon,
} from "lucide-react";
import {
Tooltip,
@@ -25,13 +26,13 @@ import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
- ContextMenuSeparator,
ContextMenuTrigger,
} from "../ui/context-menu";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useProjectStore } from "@/stores/project-store";
+import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
import { processMediaFiles } from "@/lib/media-processing";
import { toast } from "sonner";
import { useState, useRef, useEffect, useCallback } from "react";
@@ -43,14 +44,19 @@ import {
SelectValue,
} from "../ui/select";
import { TimelineTrackContent } from "./timeline-track";
-import type { DragData } from "@/types/timeline";
import {
- getTrackLabelColor,
+ TimelinePlayhead,
+ useTimelinePlayheadRuler,
+} from "./timeline-playhead";
+import { SelectionBox } from "./selection-box";
+import { useSelectionBox } from "@/hooks/use-selection-box";
+import type { DragData, TimelineTrack } from "@/types/timeline";
+import {
getTrackHeight,
getCumulativeHeightBefore,
getTotalTracksHeight,
TIMELINE_CONSTANTS,
-} from "@/lib/timeline-constants";
+} from "@/constants/timeline-constants";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their elements.
@@ -68,47 +74,30 @@ export function Timeline() {
splitElement,
splitAndKeepLeft,
splitAndKeepRight,
+ toggleTrackMute,
separateAudio,
undo,
redo,
} = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
const { activeProject } = useProjectStore();
- const {
- currentTime,
- duration,
- seek,
- setDuration,
- isPlaying,
- toggle,
- setSpeed,
- speed,
- } = usePlaybackStore();
+ const { currentTime, duration, seek, setDuration, isPlaying, toggle } =
+ usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
- const [zoomLevel, setZoomLevel] = useState(1);
const dragCounterRef = useRef(0);
const timelineRef = useRef<HTMLDivElement>(null);
+ const rulerRef = useRef<HTMLDivElement>(null);
const [isInTimeline, setIsInTimeline] = useState(false);
- // Marquee selection state
- const [marquee, setMarquee] = useState<{
- startX: number;
- startY: number;
- endX: number;
- endY: number;
- active: boolean;
- additive: boolean;
- } | null>(null);
-
- // Playhead scrubbing state
- const [isScrubbing, setIsScrubbing] = useState(false);
- const [scrubTime, setScrubTime] = useState<number | null>(null);
+ // Timeline zoom functionality
+ const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
+ containerRef: timelineRef,
+ isInTimeline,
+ });
- // Add new state for ruler drag detection
- const [isDraggingRuler, setIsDraggingRuler] = useState(false);
- const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
+ // Old marquee selection removed - using new SelectionBox component instead
// Dynamic timeline width calculation based on playhead position and duration
const dynamicTimelineWidth = Math.max(
@@ -120,9 +109,135 @@ export function Timeline() {
// Scroll synchronization and auto-scroll to playhead
const rulerScrollRef = useRef<HTMLDivElement>(null);
const tracksScrollRef = useRef<HTMLDivElement>(null);
+ const trackLabelsRef = useRef<HTMLDivElement>(null);
+ const playheadRef = useRef<HTMLDivElement>(null);
+ const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
const isUpdatingRef = useRef(false);
const lastRulerSync = useRef(0);
const lastTracksSync = useRef(0);
+ const lastVerticalSync = useRef(0);
+
+ // Timeline playhead ruler handlers
+ const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayheadRuler({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ // Selection box functionality
+ const tracksContainerRef = useRef<HTMLDivElement>(null);
+ const {
+ selectionBox,
+ handleMouseDown: handleSelectionMouseDown,
+ isSelecting,
+ justFinishedSelecting,
+ } = useSelectionBox({
+ containerRef: tracksContainerRef,
+ playheadRef,
+ onSelectionComplete: (elements) => {
+ console.log(JSON.stringify({ onSelectionComplete: elements.length }));
+ setSelectedElements(elements);
+ },
+ });
+
+ // Timeline content click to seek handler
+ const handleTimelineContentClick = useCallback(
+ (e: React.MouseEvent) => {
+ console.log(
+ JSON.stringify({
+ timelineClick: {
+ isSelecting,
+ justFinishedSelecting,
+ willReturn: isSelecting || justFinishedSelecting,
+ },
+ })
+ );
+
+ // Don't seek if this was a selection box operation
+ if (isSelecting || justFinishedSelecting) {
+ return;
+ }
+
+ // Don't seek if clicking on timeline elements, but still deselect
+ if ((e.target as HTMLElement).closest(".timeline-element")) {
+ return;
+ }
+
+ // Don't seek if clicking on playhead
+ if (playheadRef.current?.contains(e.target as Node)) {
+ return;
+ }
+
+ // Don't seek if clicking on track labels
+ if ((e.target as HTMLElement).closest("[data-track-labels]")) {
+ clearSelectedElements();
+ return;
+ }
+
+ // Clear selected elements when clicking empty timeline area
+ console.log(JSON.stringify({ clearingSelectedElements: true }));
+ clearSelectedElements();
+
+ // Determine if we're clicking in ruler or tracks area
+ const isRulerClick = (e.target as HTMLElement).closest(
+ "[data-ruler-area]"
+ );
+
+ let mouseX: number;
+ let scrollLeft = 0;
+
+ if (isRulerClick) {
+ // Calculate based on ruler position
+ const rulerContent = rulerScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ if (!rulerContent) return;
+ const rect = rulerContent.getBoundingClientRect();
+ mouseX = e.clientX - rect.left;
+ scrollLeft = rulerContent.scrollLeft;
+ } else {
+ // Calculate based on tracks content position
+ const tracksContent = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ if (!tracksContent) return;
+ const rect = tracksContent.getBoundingClientRect();
+ mouseX = e.clientX - rect.left;
+ scrollLeft = tracksContent.scrollLeft;
+ }
+
+ const rawTime = Math.max(
+ 0,
+ Math.min(
+ duration,
+ (mouseX + scrollLeft) /
+ (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
+ )
+ );
+
+ // Use frame snapping for timeline clicking
+ const projectFps = activeProject?.fps || 30;
+ const { snapTimeToFrame } = require("@/constants/timeline-constants");
+ const time = snapTimeToFrame(rawTime, projectFps);
+
+ seek(time);
+ },
+ [
+ duration,
+ zoomLevel,
+ seek,
+ rulerScrollRef,
+ tracksScrollRef,
+ clearSelectedElements,
+ isSelecting,
+ justFinishedSelecting,
+ ]
+ );
// Update timeline duration when tracks change
useEffect(() => {
@@ -133,6 +248,22 @@ export function Timeline() {
// Keyboard event for deleting selected elements
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
+ // Don't trigger when typing in input fields or textareas
+ if (
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLTextAreaElement
+ ) {
+ return;
+ }
+
+ // Only trigger when timeline is focused or mouse is over timeline
+ if (
+ !isInTimeline &&
+ !timelineRef.current?.contains(document.activeElement)
+ ) {
+ return;
+ }
+
if (
(e.key === "Delete" || e.key === "Backspace") &&
selectedElements.length > 0
@@ -145,7 +276,12 @@ export function Timeline() {
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
- }, [selectedElements, removeElementFromTrack, clearSelectedElements]);
+ }, [
+ selectedElements,
+ removeElementFromTrack,
+ clearSelectedElements,
+ isInTimeline,
+ ]);
// Keyboard event for undo (Cmd+Z)
useEffect(() => {
@@ -174,115 +310,7 @@ export function Timeline() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [redo]);
- // Mouse down on timeline background to start marquee
- const handleTimelineMouseDown = (e: React.MouseEvent) => {
- if (e.target === e.currentTarget && e.button === 0) {
- setMarquee({
- startX: e.clientX,
- startY: e.clientY,
- endX: e.clientX,
- endY: e.clientY,
- active: true,
- additive: e.metaKey || e.ctrlKey || e.shiftKey,
- });
- }
- };
-
- // Add new click handler for deselection
- const handleTimelineClick = (e: React.MouseEvent) => {
- // If clicking empty area (not on an element) and not starting marquee, deselect all elements
- if (!(e.target as HTMLElement).closest(".timeline-element")) {
- clearSelectedElements();
- }
- };
-
- // Mouse move to update marquee
- useEffect(() => {
- if (!marquee || !marquee.active) return;
- const handleMouseMove = (e: MouseEvent) => {
- setMarquee(
- (prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY }
- );
- };
- const handleMouseUp = (e: MouseEvent) => {
- setMarquee(
- (prev) =>
- prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false }
- );
- };
- window.addEventListener("mousemove", handleMouseMove);
- window.addEventListener("mouseup", handleMouseUp);
- return () => {
- window.removeEventListener("mousemove", handleMouseMove);
- window.removeEventListener("mouseup", handleMouseUp);
- };
- }, [marquee]);
-
- // On marquee end, select elements in box
- useEffect(() => {
- if (!marquee || marquee.active) return;
- const timeline = timelineRef.current;
- if (!timeline) return;
- const rect = timeline.getBoundingClientRect();
- const x1 = Math.min(marquee.startX, marquee.endX) - rect.left;
- const x2 = Math.max(marquee.startX, marquee.endX) - rect.left;
- const y1 = Math.min(marquee.startY, marquee.endY) - rect.top;
- const y2 = Math.max(marquee.startY, marquee.endY) - rect.top;
- // Validation: skip if too small
- if (Math.abs(x2 - x1) < 5 || Math.abs(y2 - y1) < 5) {
- setMarquee(null);
- return;
- }
- // Clamp to timeline bounds
- const clamp = (val: number, min: number, max: number) =>
- Math.max(min, Math.min(max, val));
- const bx1 = clamp(x1, 0, rect.width);
- const bx2 = clamp(x2, 0, rect.width);
- const by1 = clamp(y1, 0, rect.height);
- const by2 = clamp(y2, 0, rect.height);
- let newSelection: { trackId: string; elementId: string }[] = [];
- tracks.forEach((track, trackIdx) => {
- track.elements.forEach((element) => {
- const clipLeft =
- element.startTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const clipTop = getCumulativeHeightBefore(tracks, trackIdx);
- const clipBottom = clipTop + getTrackHeight(track.type);
- const clipRight = clipLeft + getTrackHeight(track.type);
- if (
- bx1 < clipRight &&
- bx2 > clipLeft &&
- by1 < clipBottom &&
- by2 > clipTop
- ) {
- newSelection.push({ trackId: track.id, elementId: element.id });
- }
- });
- });
- if (newSelection.length > 0) {
- if (marquee.additive) {
- const selectedSet = new Set(
- selectedElements.map((c) => c.trackId + ":" + c.elementId)
- );
- newSelection = [
- ...selectedElements,
- ...newSelection.filter(
- (c) => !selectedSet.has(c.trackId + ":" + c.elementId)
- ),
- ];
- }
- setSelectedElements(newSelection);
- } else if (!marquee.additive) {
- clearSelectedElements();
- }
- setMarquee(null);
- }, [
- marquee,
- tracks,
- zoomLevel,
- selectedElements,
- setSelectedElements,
- clearSelectedElements,
- ]);
+ // Old marquee system removed - using new SelectionBox component instead
const handleDragEnter = (e: React.DragEvent) => {
// When something is dragged over the timeline, show overlay
@@ -432,102 +460,6 @@ export function Timeline() {
}
};
- const handleWheel = (e: React.WheelEvent) => {
- // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
- if (e.ctrlKey || e.metaKey) {
- e.preventDefault();
- const delta = e.deltaY > 0 ? -0.05 : 0.05;
- setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
- }
- // Otherwise, allow normal scrolling
- };
-
- // --- Playhead Scrubbing Handlers ---
- const handlePlayheadMouseDown = useCallback(
- (e: React.MouseEvent) => {
- e.preventDefault();
- e.stopPropagation(); // Prevent ruler drag from triggering
- setIsScrubbing(true);
- handleScrub(e);
- },
- [duration, zoomLevel]
- );
-
- // Add new ruler mouse down handler
- const handleRulerMouseDown = useCallback(
- (e: React.MouseEvent) => {
- // Only handle left mouse button
- if (e.button !== 0) return;
-
- // Don't interfere if clicking on the playhead itself
- if ((e.target as HTMLElement).closest(".playhead")) return;
-
- e.preventDefault();
- setIsDraggingRuler(true);
- setHasDraggedRuler(false);
-
- // Start scrubbing immediately
- setIsScrubbing(true);
- handleScrub(e);
- },
- [duration, zoomLevel]
- );
-
- const handleScrub = useCallback(
- (e: MouseEvent | React.MouseEvent) => {
- const timeline = timelineRef.current;
- if (!timeline) return;
- const rect = timeline.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
- setScrubTime(time);
- seek(time); // update video preview in real time
- },
- [duration, zoomLevel, seek]
- );
-
- useEffect(() => {
- if (!isScrubbing) return;
- const onMouseMove = (e: MouseEvent) => {
- handleScrub(e);
- // Mark that we've dragged if ruler drag is active
- if (isDraggingRuler) {
- setHasDraggedRuler(true);
- }
- };
- const onMouseUp = (e: MouseEvent) => {
- setIsScrubbing(false);
- if (scrubTime !== null) seek(scrubTime); // finalize seek
- setScrubTime(null);
-
- // Handle ruler click vs drag
- if (isDraggingRuler) {
- setIsDraggingRuler(false);
- // If we didn't drag, treat it as a click-to-seek
- if (!hasDraggedRuler) {
- handleScrub(e);
- }
- setHasDraggedRuler(false);
- }
- };
- window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("mouseup", onMouseUp);
- return () => {
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
- };
- }, [
- isScrubbing,
- scrubTime,
- seek,
- handleScrub,
- isDraggingRuler,
- hasDraggedRuler,
- ]);
-
- const playheadPosition =
- isScrubbing && scrubTime !== null ? scrubTime : currentTime;
-
const dragProps = {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
@@ -667,26 +599,6 @@ export function Timeline() {
clearSelectedElements();
};
- // Prevent explorer zooming in/out when in timeline
- useEffect(() => {
- const preventZoom = (e: WheelEvent) => {
- // if (isInTimeline && (e.ctrlKey || e.metaKey)) {
- if (
- isInTimeline &&
- (e.ctrlKey || e.metaKey) &&
- timelineRef.current?.contains(e.target as Node)
- ) {
- e.preventDefault();
- }
- };
-
- document.addEventListener("wheel", preventZoom, { passive: false });
-
- return () => {
- document.removeEventListener("wheel", preventZoom);
- };
- }, [isInTimeline]);
-
// --- Scroll synchronization effect ---
useEffect(() => {
const rulerViewport = rulerScrollRef.current?.querySelector(
@@ -695,7 +607,13 @@ export function Timeline() {
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
+ const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+
if (!rulerViewport || !tracksViewport) return;
+
+ // Horizontal scroll synchronization between ruler and tracks
const handleRulerScroll = () => {
const now = Date.now();
if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
@@ -712,48 +630,60 @@ export function Timeline() {
rulerViewport.scrollLeft = tracksViewport.scrollLeft;
isUpdatingRef.current = false;
};
+
rulerViewport.addEventListener("scroll", handleRulerScroll);
tracksViewport.addEventListener("scroll", handleTracksScroll);
+
+ // Vertical scroll synchronization between track labels and tracks content
+ if (trackLabelsViewport) {
+ const handleTrackLabelsScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
+ return;
+ lastVerticalSync.current = now;
+ isUpdatingRef.current = true;
+ tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
+ isUpdatingRef.current = false;
+ };
+ const handleTracksVerticalScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
+ return;
+ lastVerticalSync.current = now;
+ isUpdatingRef.current = true;
+ trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
+ isUpdatingRef.current = false;
+ };
+
+ trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
+ tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
+
+ return () => {
+ rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
+ trackLabelsViewport.removeEventListener(
+ "scroll",
+ handleTrackLabelsScroll
+ );
+ tracksViewport.removeEventListener(
+ "scroll",
+ handleTracksVerticalScroll
+ );
+ };
+ }
+
return () => {
rulerViewport.removeEventListener("scroll", handleRulerScroll);
tracksViewport.removeEventListener("scroll", handleTracksScroll);
};
}, []);
- // --- Playhead auto-scroll effect ---
- useEffect(() => {
- const rulerViewport = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- const tracksViewport = tracksScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- if (!rulerViewport || !tracksViewport) return;
- const playheadPx =
- playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const viewportWidth = rulerViewport.clientWidth;
- const scrollMin = 0;
- const scrollMax = rulerViewport.scrollWidth - viewportWidth;
- // Center the playhead if it's not visible (100px buffer)
- const desiredScroll = Math.max(
- scrollMin,
- Math.min(scrollMax, playheadPx - viewportWidth / 2)
- );
- if (
- playheadPx < rulerViewport.scrollLeft + 100 ||
- playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
- ) {
- rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
- }
- }, [playheadPosition, duration, zoomLevel]);
-
return (
<div
- className={`h-full flex flex-col transition-colors duration-200 relative`}
+ className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`}
{...dragProps}
onMouseEnter={() => setIsInTimeline(true)}
onMouseLeave={() => setIsInTimeline(false)}
- onWheel={handleWheel}
>
{/* Toolbar */}
<div className="border-b flex items-center px-2 py-1 gap-1">
@@ -885,48 +815,49 @@ export function Timeline() {
</TooltipTrigger>
<TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
- <div className="w-px h-6 bg-border mx-1" />c{/* Speed Control */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Select
- value={speed.toFixed(1)}
- onValueChange={(value) => setSpeed(parseFloat(value))}
- >
- <SelectTrigger className="w-[90px] h-8">
- <SelectValue placeholder="1.0x" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="0.5">0.5x</SelectItem>
- <SelectItem value="1.0">1.0x</SelectItem>
- <SelectItem value="1.5">1.5x</SelectItem>
- <SelectItem value="2.0">2.0x</SelectItem>
- </SelectContent>
- </Select>
- </TooltipTrigger>
- <TooltipContent>Playback Speed</TooltipContent>
- </Tooltip>
</TooltipProvider>
</div>
{/* Timeline Container */}
- <div className="flex-1 flex flex-col overflow-hidden">
+ <div
+ className="flex-1 flex flex-col overflow-hidden relative"
+ ref={timelineRef}
+ >
+ <TimelinePlayhead
+ currentTime={currentTime}
+ duration={duration}
+ zoomLevel={zoomLevel}
+ tracks={tracks}
+ seek={seek}
+ rulerRef={rulerRef}
+ rulerScrollRef={rulerScrollRef}
+ tracksScrollRef={tracksScrollRef}
+ trackLabelsRef={trackLabelsRef}
+ timelineRef={timelineRef}
+ playheadRef={playheadRef}
+ />
{/* Timeline Header with Ruler */}
- <div className="flex border-b bg-background sticky top-0 z-10">
+ <div className="flex bg-panel sticky top-0 z-10">
{/* Track Labels Header */}
<div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2">
- <span className="text-sm font-medium text-muted-foreground">
- Tracks
+ {/* Empty space */}
+ <span className="text-sm font-medium text-muted-foreground opacity-0">
+ .
</span>
</div>
{/* Timeline Ruler */}
- <div className="flex-1 relative overflow-hidden">
+ <div
+ className="flex-1 relative overflow-hidden h-4"
+ onWheel={handleWheel}
+ onMouseDown={handleSelectionMouseDown}
+ onClick={handleTimelineContentClick}
+ data-ruler-area
+ >
<ScrollArea className="w-full" ref={rulerScrollRef}>
<div
- ref={timelineRef}
- className={`relative h-12 bg-muted/30 select-none ${
- isDraggingRuler ? "cursor-grabbing" : "cursor-grab"
- }`}
+ ref={rulerRef}
+ className="relative h-4 select-none cursor-default"
style={{
width: `${dynamicTimelineWidth}px`,
}}
@@ -970,7 +901,7 @@ export function Timeline() {
}}
>
<span
- className={`absolute top-1 left-1 text-xs ${
+ className={`absolute top-1 left-1 text-[0.6rem] ${
isMainMarker
? "text-muted-foreground font-medium"
: "text-muted-foreground/70"
@@ -999,17 +930,6 @@ export function Timeline() {
);
}).filter(Boolean);
})()}
-
- {/* Playhead in ruler (scrubbable) */}
- <div
- className="playhead absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col-resize"
- style={{
- left: `${playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
- }}
- onMouseDown={handlePlayheadMouseDown}
- >
- <div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" />
- </div>
</div>
</ScrollArea>
</div>
@@ -1019,35 +939,48 @@ export function Timeline() {
<div className="flex-1 flex overflow-hidden">
{/* Track Labels */}
{tracks.length > 0 && (
- <div className="w-48 flex-shrink-0 border-r bg-background overflow-y-auto">
- <div className="flex flex-col">
- {tracks.map((track) => (
- <div
- key={track.id}
- className="flex items-center px-3 border-b border-muted/30 bg-background group"
- style={{ height: `${getTrackHeight(track.type)}px` }}
- >
- <div className="flex items-center flex-1 min-w-0">
- <div
- className={`w-3 h-3 rounded-full flex-shrink-0 ${getTrackLabelColor(track.type)}`}
- />
- <span className="ml-2 text-sm font-medium truncate">
- {track.name}
- </span>
+ <div
+ ref={trackLabelsRef}
+ className="w-48 flex-shrink-0 border-r bg-panel-accent overflow-y-auto"
+ data-track-labels
+ >
+ <ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}>
+ <div className="flex flex-col gap-1">
+ {tracks.map((track) => (
+ <div
+ key={track.id}
+ className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5"
+ style={{ height: `${getTrackHeight(track.type)}px` }}
+ >
+ <div className="flex items-center flex-1 min-w-0">
+ <TrackIcon track={track} />
+ </div>
+ {track.muted && (
+ <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
+ Muted
+ </span>
+ )}
</div>
- {track.muted && (
- <span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
- Muted
- </span>
- )}
- </div>
- ))}
- </div>
+ ))}
+ </div>
+ </ScrollArea>
</div>
)}
{/* Timeline Tracks Content */}
- <div className="flex-1 relative overflow-hidden">
+ <div
+ className="flex-1 relative overflow-hidden"
+ onWheel={handleWheel}
+ onMouseDown={handleSelectionMouseDown}
+ onClick={handleTimelineContentClick}
+ ref={tracksContainerRef}
+ >
+ <SelectionBox
+ startPos={selectionBox?.startPos || null}
+ currentPos={selectionBox?.currentPos || null}
+ containerRef={tracksContainerRef}
+ isActive={selectionBox?.isActive || false}
+ />
<ScrollArea className="w-full h-full" ref={tracksScrollRef}>
<div
className="relative flex-1"
@@ -1055,8 +988,6 @@ export function Timeline() {
height: `${Math.max(200, Math.min(800, getTotalTracksHeight(tracks)))}px`,
width: `${dynamicTimelineWidth}px`,
}}
- onClick={handleTimelineClick}
- onMouseDown={handleTimelineMouseDown}
>
{tracks.length === 0 ? (
<div></div>
@@ -1066,7 +997,7 @@ export function Timeline() {
<ContextMenu key={track.id}>
<ContextMenuTrigger asChild>
<div
- className="absolute left-0 right-0 border-b border-muted/30"
+ className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]"
style={{
top: `${getCumulativeHeightBefore(tracks, index)}px`,
height: `${getTrackHeight(track.type)}px`,
@@ -1089,24 +1020,17 @@ export function Timeline() {
</div>
</ContextMenuTrigger>
<ContextMenuContent>
+ <ContextMenuItem
+ onClick={() => toggleTrackMute(track.id)}
+ >
+ {track.muted ? "Unmute Track" : "Mute Track"}
+ </ContextMenuItem>
<ContextMenuItem>
Track settings (soon)
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
-
- {/* Playhead for tracks area (scrubbable) */}
- {tracks.length > 0 && (
- <div
- className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-50 cursor-col"
- style={{
- left: `${playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
- height: `${getTotalTracksHeight(tracks)}px`,
- }}
- onMouseDown={handlePlayheadMouseDown}
- />
- )}
</>
)}
</div>
@@ -1117,3 +1041,19 @@ export function Timeline() {
</div>
);
}
+
+function TrackIcon({ track }: { track: TimelineTrack }) {
+ return (
+ <>
+ {track.type === "media" && (
+ <Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
+ )}
+ {track.type === "text" && (
+ <TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
+ )}
+ {track.type === "audio" && (
+ <Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
+ )}
+ </>
+ );
+}
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index 468edf16..bfe314d6 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -41,9 +41,9 @@ export function Header() {
</Button>
</Link>
{process.env.NODE_ENV === "development" ? (
- <Link href="/editor/1">
+ <Link href="/projects">
<Button size="sm" className="text-sm ml-4">
- Editor
+ Projects
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx
index 563c92a9..4f3986e3 100644
--- a/apps/web/src/components/icons.tsx
+++ b/apps/web/src/components/icons.tsx
@@ -37,3 +37,64 @@ export function GithubIcon({ className }: { className?: string }) {
</svg>
);
}
+
+export function BackgroundIcon({ className }: { className?: string }) {
+ return (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="353"
+ height="353"
+ viewBox="0 0 353 353"
+ fill="none"
+ className={className}
+ >
+ <g clipPath="url(#clip0_1_3)">
+ <rect
+ x="-241.816"
+ y="233.387"
+ width="592.187"
+ height="17.765"
+ transform="rotate(-37 -241.816 233.387)"
+ fill="white"
+ />
+ <rect
+ x="-189.907"
+ y="306.804"
+ width="592.187"
+ height="17.765"
+ transform="rotate(-37 -189.907 306.804)"
+ fill="white"
+ />
+ <rect
+ x="-146.928"
+ y="389.501"
+ width="592.187"
+ height="17.765"
+ transform="rotate(-37 -146.928 389.501)"
+ fill="white"
+ />
+ <rect
+ x="-103.144"
+ y="477.904"
+ width="592.187"
+ height="17.765"
+ transform="rotate(-37 -103.144 477.904)"
+ fill="white"
+ />
+ <rect
+ x="-57.169"
+ y="570.714"
+ width="592.187"
+ height="17.765"
+ transform="rotate(-37 -57.169 570.714)"
+ fill="white"
+ />
+ </g>
+ <defs>
+ <clipPath id="clip0_1_3">
+ <rect width="353" height="353" fill="white" />
+ </clipPath>
+ </defs>
+ </svg>
+ );
+} \ No newline at end of file
diff --git a/apps/web/src/components/landing/handlebars.tsx b/apps/web/src/components/landing/handlebars.tsx
new file mode 100644
index 00000000..36705e90
--- /dev/null
+++ b/apps/web/src/components/landing/handlebars.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import React, { useState, useRef, useEffect } from "react";
+import { motion, useMotionValue, useTransform, PanInfo } from "motion/react";
+
+interface HandlebarsProps {
+ children: React.ReactNode;
+ minWidth?: number;
+ maxWidth?: number;
+ onRangeChange?: (left: number, right: number) => void;
+}
+
+export function Handlebars({
+ children,
+ minWidth = 50,
+ maxWidth = 400,
+ onRangeChange,
+}: HandlebarsProps) {
+ const [leftHandle, setLeftHandle] = useState(0);
+ const [rightHandle, setRightHandle] = useState(maxWidth);
+ const [contentWidth, setContentWidth] = useState(maxWidth);
+
+ const leftHandleX = useMotionValue(0);
+ const rightHandleX = useMotionValue(maxWidth);
+
+ const visibleWidth = useTransform(
+ [leftHandleX, rightHandleX],
+ (values: number[]) => values[1] - values[0]
+ );
+
+ const contentLeft = useTransform(leftHandleX, (left: number) => -left);
+
+ const containerRef = useRef<HTMLDivElement>(null);
+ const measureRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ if (!measureRef.current) return;
+
+ const measureContent = () => {
+ if (measureRef.current) {
+ const width = measureRef.current.scrollWidth;
+ const paddedWidth = width + 32;
+ setContentWidth(paddedWidth);
+ setRightHandle(paddedWidth);
+ rightHandleX.set(paddedWidth);
+ }
+ };
+
+ measureContent();
+ const timer = setTimeout(measureContent, 50);
+
+ return () => clearTimeout(timer);
+ }, [children, rightHandleX]);
+
+ useEffect(() => {
+ leftHandleX.set(leftHandle);
+ }, [leftHandle, leftHandleX]);
+
+ useEffect(() => {
+ rightHandleX.set(rightHandle);
+ }, [rightHandle, rightHandleX]);
+
+ useEffect(() => {
+ onRangeChange?.(leftHandle, rightHandle);
+ }, [leftHandle, rightHandle, onRangeChange]);
+
+ const handleLeftDrag = (event: any, info: PanInfo) => {
+ const newLeft = Math.max(
+ 0,
+ Math.min(leftHandle + info.offset.x, rightHandle - minWidth)
+ );
+ setLeftHandle(newLeft);
+ };
+
+ const handleRightDrag = (event: any, info: PanInfo) => {
+ const newRight = Math.max(
+ leftHandle + minWidth,
+ Math.min(contentWidth, rightHandle + info.offset.x)
+ );
+ setRightHandle(newRight);
+ };
+
+ return (
+ <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
+ <div
+ ref={measureRef}
+ className="absolute -left-[9999px] top-0 invisible px-4 whitespace-nowrap font-[inherit]"
+ >
+ {children}
+ </div>
+
+ <div
+ ref={containerRef}
+ className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2"
+ style={{ width: contentWidth }}
+ >
+ <div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between">
+ <motion.div
+ className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
+ style={{
+ position: "absolute",
+ x: leftHandleX,
+ left: 0,
+ zIndex: 10,
+ }}
+ drag="x"
+ dragConstraints={{ left: 0, right: rightHandle - minWidth }}
+ dragElastic={0}
+ dragMomentum={false}
+ onDrag={handleLeftDrag}
+ whileHover={{ scale: 1.05 }}
+ whileDrag={{ scale: 1.1 }}
+ transition={{ type: "spring", stiffness: 400, damping: 30 }}
+ >
+ <div className="w-2 h-8 rounded-full bg-yellow-500"></div>
+ </motion.div>
+
+ <motion.div
+ className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
+ style={{
+ position: "absolute",
+ x: rightHandleX,
+ left: -30,
+ zIndex: 10,
+ }}
+ drag="x"
+ dragConstraints={{
+ left: leftHandle + minWidth,
+ right: contentWidth,
+ }}
+ dragElastic={0}
+ dragMomentum={false}
+ onDrag={handleRightDrag}
+ whileHover={{ scale: 1.05 }}
+ whileDrag={{ scale: 1.1 }}
+ transition={{ type: "spring", stiffness: 400, damping: 30 }}
+ >
+ <div className="w-2 h-8 rounded-full bg-yellow-500"></div>
+ </motion.div>
+ </div>
+
+ <motion.div
+ className="relative overflow-hidden rounded-2xl"
+ style={{
+ width: visibleWidth,
+ x: leftHandleX,
+ height: "100%",
+ }}
+ >
+ <motion.div
+ className="w-full h-full flex items-center justify-center px-4"
+ style={{
+ x: contentLeft,
+ width: contentWidth,
+ whiteSpace: "nowrap",
+ }}
+ >
+ {children}
+ </motion.div>
+ </motion.div>
+ </div>
+ </div>
+ );
+};
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx
index d45f33ed..776cc654 100644
--- a/apps/web/src/components/landing/hero.tsx
+++ b/apps/web/src/components/landing/hero.tsx
@@ -8,6 +8,7 @@ import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import Image from "next/image";
+import { Handlebars } from "./handlebars";
interface HeroProps {
signupCount: number;
@@ -91,14 +92,7 @@ export function Hero({ signupCount }: HeroProps) {
className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
>
<h1>The Open Source</h1>
- <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
- <div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2">
- <Image src="/frame.svg" height={79} width={459} alt="frame" />
- <span className="absolute inset-0 flex items-center justify-center">
- Video Editor
- </span>
- </div>
- </div>
+ <Handlebars>Video Editor</Handlebars>
</motion.div>
<motion.p
@@ -121,15 +115,17 @@ export function Hero({ signupCount }: HeroProps) {
onSubmit={handleSubmit}
className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
>
- <Input
- type="email"
- placeholder="Enter your email"
- className="h-11 text-base flex-1"
- value={email}
- onChange={(e) => setEmail(e.target.value)}
- disabled={isSubmitting}
- required
- />
+ <div className="relative w-full">
+ <Input
+ type="email"
+ placeholder="Enter your email"
+ className="h-11 text-base flex-1"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ disabled={isSubmitting}
+ required
+ />
+ </div>
<Button
type="submit"
size="lg"
diff --git a/apps/web/src/components/ui/audio-player.tsx b/apps/web/src/components/ui/audio-player.tsx
new file mode 100644
index 00000000..774dd95c
--- /dev/null
+++ b/apps/web/src/components/ui/audio-player.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useRef, useEffect } from "react";
+import { usePlaybackStore } from "@/stores/playback-store";
+
+interface AudioPlayerProps {
+ src: string;
+ className?: string;
+ clipStartTime: number;
+ trimStart: number;
+ trimEnd: number;
+ clipDuration: number;
+ trackMuted?: boolean;
+}
+
+export function AudioPlayer({
+ src,
+ className = "",
+ clipStartTime,
+ trimStart,
+ trimEnd,
+ clipDuration,
+ trackMuted = false,
+}: AudioPlayerProps) {
+ const audioRef = useRef<HTMLAudioElement>(null);
+ const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
+
+ // Calculate if we're within this clip's timeline range
+ const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
+ const isInClipRange =
+ currentTime >= clipStartTime && currentTime < clipEndTime;
+
+ // Sync playback events
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio || !isInClipRange) return;
+
+ const handleSeekEvent = (e: CustomEvent) => {
+ // Always update audio time, even if outside clip range
+ const timelineTime = e.detail.time;
+ const audioTime = Math.max(
+ trimStart,
+ Math.min(
+ clipDuration - trimEnd,
+ timelineTime - clipStartTime + trimStart
+ )
+ );
+ audio.currentTime = audioTime;
+ };
+
+ const handleUpdateEvent = (e: CustomEvent) => {
+ // Always update audio time, even if outside clip range
+ const timelineTime = e.detail.time;
+ const targetTime = Math.max(
+ trimStart,
+ Math.min(
+ clipDuration - trimEnd,
+ timelineTime - clipStartTime + trimStart
+ )
+ );
+
+ if (Math.abs(audio.currentTime - targetTime) > 0.5) {
+ audio.currentTime = targetTime;
+ }
+ };
+
+ const handleSpeed = (e: CustomEvent) => {
+ audio.playbackRate = e.detail.speed;
+ };
+
+ window.addEventListener("playback-seek", handleSeekEvent as EventListener);
+ window.addEventListener(
+ "playback-update",
+ handleUpdateEvent as EventListener
+ );
+ window.addEventListener("playback-speed", handleSpeed as EventListener);
+
+ return () => {
+ window.removeEventListener(
+ "playback-seek",
+ handleSeekEvent as EventListener
+ );
+ window.removeEventListener(
+ "playback-update",
+ handleUpdateEvent as EventListener
+ );
+ window.removeEventListener(
+ "playback-speed",
+ handleSpeed as EventListener
+ );
+ };
+ }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
+
+ // Sync playback state
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+
+ if (isPlaying && isInClipRange && !trackMuted) {
+ audio.play().catch(() => {});
+ } else {
+ audio.pause();
+ }
+ }, [isPlaying, isInClipRange, trackMuted]);
+
+ // Sync volume and speed
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+
+ audio.volume = volume;
+ audio.muted = muted || trackMuted;
+ audio.playbackRate = speed;
+ }, [volume, speed, muted, trackMuted]);
+
+ return (
+ <audio
+ ref={audioRef}
+ src={src}
+ className={className}
+ preload="auto"
+ controls={false}
+ style={{ display: "none" }} // Audio elements don't need visual representation
+ onContextMenu={(e) => e.preventDefault()}
+ />
+ );
+}
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index 234230f6..b8b9ab2f 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -24,7 +24,7 @@ const buttonVariants = cva(
},
size: {
default: "h-9 px-4 py-2",
- sm: "h-8 rounded-md px-3 text-xs",
+ sm: "h-8 rounded-sm px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-7 w-7",
},
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx
index 9db8a268..d9962dd2 100644
--- a/apps/web/src/components/ui/dropdown-menu.tsx
+++ b/apps/web/src/components/ui/dropdown-menu.tsx
@@ -82,6 +82,10 @@ const DropdownMenuContent = React.forwardRef<
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
+ onCloseAutoFocus={(e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ }}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx
new file mode 100644
index 00000000..9a152fd4
--- /dev/null
+++ b/apps/web/src/components/ui/font-picker.tsx
@@ -0,0 +1,40 @@
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants";
+
+interface FontPickerProps {
+ defaultValue?: FontFamily;
+ onValueChange?: (value: FontFamily) => void;
+ className?: string;
+}
+
+export function FontPicker({
+ defaultValue,
+ onValueChange,
+ className,
+}: FontPickerProps) {
+ return (
+ <Select defaultValue={defaultValue} onValueChange={onValueChange}>
+ <SelectTrigger className={`w-full text-xs ${className || ""}`}>
+ <SelectValue placeholder="Select a font" />
+ </SelectTrigger>
+ <SelectContent>
+ {FONT_OPTIONS.map((font) => (
+ <SelectItem
+ key={font.value}
+ value={font.value}
+ className="text-xs"
+ style={{ fontFamily: font.value }}
+ >
+ {font.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+}
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx
index 72314045..8fc31a58 100644
--- a/apps/web/src/components/ui/input.tsx
+++ b/apps/web/src/components/ui/input.tsx
@@ -11,14 +11,7 @@ interface InputProps extends React.ComponentProps<"input"> {
const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
- {
- className,
- type,
- showPassword,
- onShowPasswordChange,
- value,
- ...props
- },
+ { className, type, showPassword, onShowPasswordChange, value, ...props },
ref
) => {
const isPassword = type === "password";
@@ -26,7 +19,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
const inputType = isPassword && showPassword ? "text" : type;
return (
- <div className="relative w-full">
+ <div className={showPassword ? "relative w-full" : ""}>
<input
type={inputType}
className={cn(
diff --git a/apps/web/src/components/ui/resizable.tsx b/apps/web/src/components/ui/resizable.tsx
index 0b9a955c..8f3cb2e8 100644
--- a/apps/web/src/components/ui/resizable.tsx
+++ b/apps/web/src/components/ui/resizable.tsx
@@ -29,17 +29,11 @@ const ResizableHandle = ({
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
- "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
+ "relative w-0 bg-transparent cursor-col-resize data-[panel-group-direction=vertical]:h-0 data-[panel-group-direction=vertical]:cursor-row-resize",
className
)}
{...props}
- >
- {withHandle && (
- <div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
- <GripVertical className="h-2.5 w-2.5" />
- </div>
- )}
- </ResizablePrimitive.PanelResizeHandle>
+ />
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index 4332dc6b..911b3e5d 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -97,6 +97,10 @@ const SelectContent = React.forwardRef<
className
)}
position={position}
+ onCloseAutoFocus={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
{...props}
>
<SelectScrollUpButton />
diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx
index 717abc35..64f81ce6 100644
--- a/apps/web/src/components/ui/textarea.tsx
+++ b/apps/web/src/components/ui/textarea.tsx
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
- "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+ "flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs",
className
)}
ref={ref}
diff --git a/apps/web/src/components/ui/video-player.tsx b/apps/web/src/components/ui/video-player.tsx
index d8a72e40..29d82cdf 100644
--- a/apps/web/src/components/ui/video-player.tsx
+++ b/apps/web/src/components/ui/video-player.tsx
@@ -118,7 +118,7 @@ export function VideoPlayer({
ref={videoRef}
src={src}
poster={poster}
- className={`w-full h-full object-cover ${className}`}
+ className={`max-w-full max-h-full object-contain ${className}`}
playsInline
preload="auto"
controls={false}
diff --git a/apps/web/src/constants/font-constants.ts b/apps/web/src/constants/font-constants.ts
new file mode 100644
index 00000000..bfddc182
--- /dev/null
+++ b/apps/web/src/constants/font-constants.ts
@@ -0,0 +1,79 @@
+export interface FontOption {
+ value: string;
+ label: string;
+ category: "system" | "google" | "custom";
+ weights?: number[];
+ hasClassName?: boolean;
+}
+
+export const FONT_OPTIONS: FontOption[] = [
+ // System fonts (always available)
+ { value: "Arial", label: "Arial", category: "system", hasClassName: false },
+ {
+ value: "Helvetica",
+ label: "Helvetica",
+ category: "system",
+ hasClassName: false,
+ },
+ {
+ value: "Times New Roman",
+ label: "Times New Roman",
+ category: "system",
+ hasClassName: false,
+ },
+ {
+ value: "Georgia",
+ label: "Georgia",
+ category: "system",
+ hasClassName: false,
+ },
+
+ // Google Fonts (loaded in layout.tsx)
+ {
+ value: "Inter",
+ label: "Inter",
+ category: "google",
+ weights: [400, 700],
+ hasClassName: true,
+ },
+ {
+ value: "Roboto",
+ label: "Roboto",
+ category: "google",
+ weights: [400, 700],
+ hasClassName: true,
+ },
+ {
+ value: "Open Sans",
+ label: "Open Sans",
+ category: "google",
+ hasClassName: true,
+ },
+ {
+ value: "Playfair Display",
+ label: "Playfair Display",
+ category: "google",
+ hasClassName: true,
+ },
+ {
+ value: "Comic Neue",
+ label: "Comic Neue",
+ category: "google",
+ hasClassName: false,
+ },
+] as const;
+
+export const DEFAULT_FONT = "Arial";
+
+// Type-safe font family union
+export type FontFamily = (typeof FONT_OPTIONS)[number]["value"];
+
+// Helper functions
+export const getFontByValue = (value: string): FontOption | undefined =>
+ FONT_OPTIONS.find((font) => font.value === value);
+
+export const getGoogleFonts = (): FontOption[] =>
+ FONT_OPTIONS.filter((font) => font.category === "google");
+
+export const getSystemFonts = (): FontOption[] =>
+ FONT_OPTIONS.filter((font) => font.category === "system");
diff --git a/apps/web/src/lib/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts
index dd93cfdf..92045c5e 100644
--- a/apps/web/src/lib/timeline-constants.ts
+++ b/apps/web/src/constants/timeline-constants.ts
@@ -8,17 +8,17 @@ export const TRACK_COLORS: Record<
media: {
solid: "bg-blue-500",
background: "bg-blue-500/20",
- border: "border-blue-500/30",
+ border: "border-white/80",
},
text: {
solid: "bg-[#9C4937]",
background: "bg-[#9C4937]",
- border: "border-[#9C4937]/40",
+ border: "border-white/80",
},
audio: {
solid: "bg-green-500",
background: "bg-green-500/20",
- border: "border-green-500/30",
+ border: "border-white/80",
},
} as const;
@@ -32,10 +32,6 @@ export function getTrackElementClasses(type: TrackType) {
return `${colors.background} ${colors.border}`;
}
-export function getTrackLabelColor(type: TrackType) {
- return getTrackColors(type).solid;
-}
-
// Track height definitions
export const TRACK_HEIGHTS: Record<TrackType, number> = {
media: 65,
@@ -53,16 +49,23 @@ export function getCumulativeHeightBefore(
tracks: Array<{ type: TrackType }>,
trackIndex: number
): number {
+ const GAP = 4; // 4px gap between tracks (equivalent to Tailwind's gap-1)
return tracks
.slice(0, trackIndex)
- .reduce((sum, track) => sum + getTrackHeight(track.type), 0);
+ .reduce((sum, track) => sum + getTrackHeight(track.type) + GAP, 0);
}
// Calculate total height of all tracks
export function getTotalTracksHeight(
tracks: Array<{ type: TrackType }>
): number {
- return tracks.reduce((sum, track) => sum + getTrackHeight(track.type), 0);
+ const GAP = 4; // 4px gap between tracks (equivalent to Tailwind's gap-1)
+ const tracksHeight = tracks.reduce(
+ (sum, track) => sum + getTrackHeight(track.type),
+ 0
+ );
+ const gapsHeight = Math.max(0, tracks.length - 1) * GAP; // n-1 gaps for n tracks
+ return tracksHeight + gapsHeight;
}
// Other timeline constants
@@ -73,3 +76,31 @@ export const TIMELINE_CONSTANTS = {
DEFAULT_TEXT_DURATION: 5,
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
} as const;
+
+// FPS presets for project settings
+export const FPS_PRESETS = [
+ { value: "24", label: "24 fps (Film)" },
+ { value: "25", label: "25 fps (PAL)" },
+ { value: "30", label: "30 fps (NTSC)" },
+ { value: "60", label: "60 fps (High)" },
+ { value: "120", label: "120 fps (Slow-mo)" },
+] as const;
+
+// Frame snapping utilities
+export function timeToFrame(time: number, fps: number): number {
+ return Math.round(time * fps);
+}
+
+export function frameToTime(frame: number, fps: number): number {
+ return frame / fps;
+}
+
+export function snapTimeToFrame(time: number, fps: number): number {
+ if (fps <= 0) return time; // Fallback for invalid FPS
+ const frame = timeToFrame(time, fps);
+ return frameToTime(frame, fps);
+}
+
+export function getFrameDuration(fps: number): number {
+ return 1 / fps;
+}
diff --git a/apps/web/src/data/colors.ts b/apps/web/src/data/colors.ts
new file mode 100644
index 00000000..3ef094d6
--- /dev/null
+++ b/apps/web/src/data/colors.ts
@@ -0,0 +1,244 @@
+export const colors = [
+ "#ffffff",
+ "#000000",
+ "#ffe2e2",
+ "#ffc9c9",
+ "#ffa2a2",
+ "#ff6467",
+ "#fb2c36",
+ "#e7000b",
+ "#c10007",
+ "#9f0712",
+ "#82181a",
+ "#460809",
+ "#fff7ed",
+ "#ffedd4",
+ "#ffd6a7",
+ "#ffb86a",
+ "#ff8904",
+ "#ff6900",
+ "#f54900",
+ "#ca3500",
+ "#9f2d00",
+ "#7e2a0c",
+ "#441306",
+ "#fffbeb",
+ "#fef3c6",
+ "#fee685",
+ "#ffd230",
+ "#ffb900",
+ "#fe9a00",
+ "#e17100",
+ "#bb4d00",
+ "#973c00",
+ "#7b3306",
+ "#461901",
+ "#fefce8",
+ "#fef9c2",
+ "#fff085",
+ "#ffdf20",
+ "#fdc700",
+ "#f0b100",
+ "#d08700",
+ "#a65f00",
+ "#894b00",
+ "#733e0a",
+ "#432004",
+ "#f7fee7",
+ "#ecfcca",
+ "#d8f999",
+ "#bbf451",
+ "#9ae600",
+ "#7ccf00",
+ "#5ea500",
+ "#497d00",
+ "#3c6300",
+ "#35530e",
+ "#192e03",
+ "#f0fdf4",
+ "#dcfce7",
+ "#b9f8cf",
+ "#7bf1a8",
+ "#05df72",
+ "#00c950",
+ "#00a63e",
+ "#008236",
+ "#016630",
+ "#0d542b",
+ "#032e15",
+ "#ecfdf5",
+ "#d0fae5",
+ "#a4f4cf",
+ "#5ee9b5",
+ "#00d492",
+ "#00bc7d",
+ "#009966",
+ "#007a55",
+ "#006045",
+ "#004f3b",
+ "#002c22",
+ "#f0fdfa",
+ "#cbfbf1",
+ "#96f7e4",
+ "#46ecd5",
+ "#00d5be",
+ "#00bba7",
+ "#009689",
+ "#00786f",
+ "#005f5a",
+ "#0b4f4a",
+ "#022f2e",
+ "#ecfeff",
+ "#cefafe",
+ "#a2f4fd",
+ "#53eafd",
+ "#00d3f2",
+ "#00b8db",
+ "#0092b8",
+ "#007595",
+ "#005f78",
+ "#104e64",
+ "#053345",
+ "#f0f9ff",
+ "#dff2fe",
+ "#b8e6fe",
+ "#74d4ff",
+ "#00bcff",
+ "#00a6f4",
+ "#0084d1",
+ "#0069a8",
+ "#00598a",
+ "#024a70",
+ "#052f4a",
+ "#eff6ff",
+ "#dbeafe",
+ "#bedbff",
+ "#8ec5ff",
+ "#51a2ff",
+ "#2b7fff",
+ "#155dfc",
+ "#1447e6",
+ "#193cb8",
+ "#1c398e",
+ "#162456",
+ "#eef2ff",
+ "#e0e7ff",
+ "#c6d2ff",
+ "#a3b3ff",
+ "#7c86ff",
+ "#615fff",
+ "#4f39f6",
+ "#432dd7",
+ "#372aac",
+ "#312c85",
+ "#1e1a4d",
+ "#f5f3ff",
+ "#ede9fe",
+ "#ddd6ff",
+ "#c4b4ff",
+ "#a684ff",
+ "#8e51ff",
+ "#7f22fe",
+ "#7008e7",
+ "#5d0ec0",
+ "#4d179a",
+ "#2f0d68",
+ "#faf5ff",
+ "#f3e8ff",
+ "#e9d4ff",
+ "#dab2ff",
+ "#c27aff",
+ "#ad46ff",
+ "#9810fa",
+ "#8200db",
+ "#6e11b0",
+ "#59168b",
+ "#3c0366",
+ "#fdf4ff",
+ "#fae8ff",
+ "#f6cfff",
+ "#f4a8ff",
+ "#ed6aff",
+ "#e12afb",
+ "#c800de",
+ "#a800b7",
+ "#8a0194",
+ "#721378",
+ "#4b004f",
+ "#fdf2f8",
+ "#fce7f3",
+ "#fccee8",
+ "#fda5d5",
+ "#fb64b6",
+ "#f6339a",
+ "#e60076",
+ "#c6005c",
+ "#a3004c",
+ "#861043",
+ "#510424",
+ "#fff1f2",
+ "#ffe4e6",
+ "#ffccd3",
+ "#ffa1ad",
+ "#ff637e",
+ "#ff2056",
+ "#ec003f",
+ "#c70036",
+ "#a50036",
+ "#8b0836",
+ "#4d0218",
+ "#f8fafc",
+ "#f1f5f9",
+ "#e2e8f0",
+ "#cad5e2",
+ "#90a1b9",
+ "#62748e",
+ "#45556c",
+ "#314158",
+ "#1d293d",
+ "#0f172b",
+ "#020618",
+ "#f9fafb",
+ "#f3f4f6",
+ "#e5e7eb",
+ "#d1d5dc",
+ "#99a1af",
+ "#6a7282",
+ "#4a5565",
+ "#364153",
+ "#1e2939",
+ "#101828",
+ "#030712",
+ "#fafafa",
+ "#f4f4f5",
+ "#e4e4e7",
+ "#d4d4d8",
+ "#9f9fa9",
+ "#71717b",
+ "#52525c",
+ "#3f3f46",
+ "#27272a",
+ "#18181b",
+ "#09090b",
+ "#f5f5f5",
+ "#e5e5e5",
+ "#d4d4d4",
+ "#a1a1a1",
+ "#737373",
+ "#525252",
+ "#404040",
+ "#262626",
+ "#171717",
+ "#0a0a0a",
+ "#fafaf9",
+ "#f5f5f4",
+ "#e7e5e4",
+ "#d6d3d1",
+ "#a6a09b",
+ "#79716b",
+ "#57534d",
+ "#44403b",
+ "#292524",
+ "#1c1917",
+ "#0c0a09",
+];
diff --git a/apps/web/src/hooks/auth/useLogin.ts b/apps/web/src/hooks/auth/useLogin.ts
index 3737deb3..12eddcbf 100644
--- a/apps/web/src/hooks/auth/useLogin.ts
+++ b/apps/web/src/hooks/auth/useLogin.ts
@@ -25,7 +25,7 @@ export function useLogin() {
return;
}
- router.push("/editor");
+ router.push("/projects");
}, [router, email, password]);
const handleGoogleLogin = async () => {
@@ -35,7 +35,7 @@ export function useLogin() {
try {
await signIn.social({
provider: "google",
- callbackURL: "/editor",
+ callbackURL: "/projects",
});
} catch (error) {
setError("Failed to sign in with Google. Please try again.");
diff --git a/apps/web/src/hooks/use-aspect-ratio.ts b/apps/web/src/hooks/use-aspect-ratio.ts
new file mode 100644
index 00000000..40de8454
--- /dev/null
+++ b/apps/web/src/hooks/use-aspect-ratio.ts
@@ -0,0 +1,92 @@
+import { useEditorStore } from "@/stores/editor-store";
+import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+
+export function useAspectRatio() {
+ const { canvasSize, canvasMode, canvasPresets } = useEditorStore();
+ const { mediaItems } = useMediaStore();
+ const { tracks } = useTimelineStore();
+
+ // Find the current preset based on canvas size
+ const currentPreset = canvasPresets.find(
+ (preset) =>
+ preset.width === canvasSize.width && preset.height === canvasSize.height
+ );
+
+ // Get the original aspect ratio from the first video/image in timeline
+ const getOriginalAspectRatio = (): number => {
+ // Find first video or image in timeline
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ if (element.type === "media") {
+ const mediaItem = mediaItems.find(
+ (item) => item.id === element.mediaId
+ );
+ if (
+ mediaItem &&
+ (mediaItem.type === "video" || mediaItem.type === "image")
+ ) {
+ return getMediaAspectRatio(mediaItem);
+ }
+ }
+ }
+ }
+ return 16 / 9; // Default aspect ratio
+ };
+
+ // Get current aspect ratio
+ const getCurrentAspectRatio = (): number => {
+ return canvasSize.width / canvasSize.height;
+ };
+
+ // Format aspect ratio as a readable string
+ const formatAspectRatio = (aspectRatio: number): string => {
+ // Check if it matches a common aspect ratio
+ const ratios = [
+ { ratio: 16 / 9, label: "16:9" },
+ { ratio: 9 / 16, label: "9:16" },
+ { ratio: 1, label: "1:1" },
+ { ratio: 4 / 3, label: "4:3" },
+ { ratio: 3 / 4, label: "3:4" },
+ { ratio: 21 / 9, label: "21:9" },
+ ];
+
+ for (const { ratio, label } of ratios) {
+ if (Math.abs(aspectRatio - ratio) < 0.01) {
+ return label;
+ }
+ }
+
+ // If not a common ratio, format as decimal
+ return aspectRatio.toFixed(2);
+ };
+
+ // Check if current mode is "Original"
+ const isOriginal = canvasMode === "original";
+
+ // Get display name for current aspect ratio
+ const getDisplayName = (): string => {
+ // If explicitly set to original mode, always show "Original"
+ if (canvasMode === "original") {
+ return "Original";
+ }
+
+ if (currentPreset) {
+ return currentPreset.name;
+ }
+
+ return formatAspectRatio(getCurrentAspectRatio());
+ };
+
+ return {
+ currentPreset,
+ canvasMode,
+ isOriginal,
+ getCurrentAspectRatio,
+ getOriginalAspectRatio,
+ formatAspectRatio,
+ getDisplayName,
+ canvasSize,
+ canvasPresets,
+ };
+}
diff --git a/apps/web/src/hooks/use-selection-box.ts b/apps/web/src/hooks/use-selection-box.ts
new file mode 100644
index 00000000..c40617c1
--- /dev/null
+++ b/apps/web/src/hooks/use-selection-box.ts
@@ -0,0 +1,199 @@
+import { useState, useEffect, useCallback } from "react";
+
+interface UseSelectionBoxProps {
+ containerRef: React.RefObject<HTMLElement>;
+ playheadRef?: React.RefObject<HTMLElement>;
+ onSelectionComplete: (
+ elements: { trackId: string; elementId: string }[]
+ ) => void;
+ isEnabled?: boolean;
+}
+
+interface SelectionBoxState {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+ isActive: boolean;
+}
+
+export function useSelectionBox({
+ containerRef,
+ playheadRef,
+ onSelectionComplete,
+ isEnabled = true,
+}: UseSelectionBoxProps) {
+ const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
+ null
+ );
+ const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
+
+ // Mouse down handler to start selection
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ if (!isEnabled) return;
+
+ // Only start selection on empty space clicks
+ if ((e.target as HTMLElement).closest(".timeline-element")) {
+ return;
+ }
+ if (playheadRef?.current?.contains(e.target as Node)) {
+ return;
+ }
+ if ((e.target as HTMLElement).closest("[data-track-labels]")) {
+ return;
+ }
+ // Don't start selection when clicking in the ruler area - this interferes with playhead dragging
+ if ((e.target as HTMLElement).closest("[data-ruler-area]")) {
+ return;
+ }
+
+ setSelectionBox({
+ startPos: { x: e.clientX, y: e.clientY },
+ currentPos: { x: e.clientX, y: e.clientY },
+ isActive: false, // Will become active when mouse moves
+ });
+ },
+ [isEnabled, playheadRef]
+ );
+
+ // Function to select elements within the selection box
+ const selectElementsInBox = useCallback(
+ (startPos: { x: number; y: number }, endPos: { x: number; y: number }) => {
+ if (!containerRef.current) return;
+
+ const container = containerRef.current;
+ const containerRect = container.getBoundingClientRect();
+
+ // Calculate selection rectangle in container coordinates
+ const startX = startPos.x - containerRect.left;
+ const startY = startPos.y - containerRect.top;
+ const endX = endPos.x - containerRect.left;
+ const endY = endPos.y - containerRect.top;
+
+ const selectionRect = {
+ left: Math.min(startX, endX),
+ top: Math.min(startY, endY),
+ right: Math.max(startX, endX),
+ bottom: Math.max(startY, endY),
+ };
+
+ // Find all timeline elements within the selection rectangle
+ const timelineElements = container.querySelectorAll(".timeline-element");
+
+ const selectedElements: { trackId: string; elementId: string }[] = [];
+
+ timelineElements.forEach((element) => {
+ const elementRect = element.getBoundingClientRect();
+ // Use absolute coordinates for more accurate intersection detection
+ const elementAbsolute = {
+ left: elementRect.left,
+ top: elementRect.top,
+ right: elementRect.right,
+ bottom: elementRect.bottom,
+ };
+
+ const selectionAbsolute = {
+ left: startPos.x,
+ top: startPos.y,
+ right: endPos.x,
+ bottom: endPos.y,
+ };
+
+ // Normalize selection rectangle (handle dragging in any direction)
+ const normalizedSelection = {
+ left: Math.min(selectionAbsolute.left, selectionAbsolute.right),
+ top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom),
+ right: Math.max(selectionAbsolute.left, selectionAbsolute.right),
+ bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom),
+ };
+
+ const elementId = element.getAttribute("data-element-id");
+ const trackId = element.getAttribute("data-track-id");
+
+ // Check if element intersects with selection rectangle (any overlap)
+ // Using absolute coordinates for more precise detection
+ const intersects = !(
+ elementAbsolute.right < normalizedSelection.left ||
+ elementAbsolute.left > normalizedSelection.right ||
+ elementAbsolute.bottom < normalizedSelection.top ||
+ elementAbsolute.top > normalizedSelection.bottom
+ );
+
+ if (intersects && elementId && trackId) {
+ selectedElements.push({ trackId, elementId });
+ }
+ });
+
+ // Always call the callback - with elements or empty array to clear selection
+ console.log(
+ JSON.stringify({ selectElementsInBox: selectedElements.length })
+ );
+ onSelectionComplete(selectedElements);
+ },
+ [containerRef, onSelectionComplete]
+ );
+
+ // Effect to track selection box movement
+ useEffect(() => {
+ if (!selectionBox) return;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ const deltaX = Math.abs(e.clientX - selectionBox.startPos.x);
+ const deltaY = Math.abs(e.clientY - selectionBox.startPos.y);
+
+ // Start selection if mouse moved more than 5px
+ const shouldActivate = deltaX > 5 || deltaY > 5;
+
+ const newSelectionBox = {
+ ...selectionBox,
+ currentPos: { x: e.clientX, y: e.clientY },
+ isActive: shouldActivate || selectionBox.isActive,
+ };
+
+ setSelectionBox(newSelectionBox);
+
+ // Real-time visual feedback: update selection as we drag
+ if (newSelectionBox.isActive) {
+ selectElementsInBox(
+ newSelectionBox.startPos,
+ newSelectionBox.currentPos
+ );
+ }
+ };
+
+ const handleMouseUp = () => {
+ console.log(
+ JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } })
+ );
+
+ // If we had an active selection, mark that we just finished selecting
+ if (selectionBox?.isActive) {
+ console.log(JSON.stringify({ settingJustFinishedSelecting: true }));
+ setJustFinishedSelecting(true);
+ // Clear the flag after a short delay to allow click events to check it
+ setTimeout(() => {
+ console.log(JSON.stringify({ clearingJustFinishedSelecting: true }));
+ setJustFinishedSelecting(false);
+ }, 50);
+ }
+
+ // Don't call selectElementsInBox again - real-time selection already handled it
+ // Just clean up the selection box visual
+ setSelectionBox(null);
+ };
+
+ window.addEventListener("mousemove", handleMouseMove);
+ window.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ window.removeEventListener("mousemove", handleMouseMove);
+ window.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [selectionBox, selectElementsInBox]);
+
+ return {
+ selectionBox,
+ handleMouseDown,
+ isSelecting: selectionBox?.isActive || false,
+ justFinishedSelecting,
+ };
+}
diff --git a/apps/web/src/hooks/use-timeline-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts
new file mode 100644
index 00000000..9a82c23f
--- /dev/null
+++ b/apps/web/src/hooks/use-timeline-playhead.ts
@@ -0,0 +1,157 @@
+import { snapTimeToFrame } from "@/constants/timeline-constants";
+import { useProjectStore } from "@/stores/project-store";
+import { useState, useEffect, useCallback } from "react";
+
+interface UseTimelinePlayheadProps {
+ currentTime: number;
+ duration: number;
+ zoomLevel: number;
+ seek: (time: number) => void;
+ rulerRef: React.RefObject<HTMLDivElement>;
+ rulerScrollRef: React.RefObject<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLDivElement>;
+ playheadRef?: React.RefObject<HTMLDivElement>;
+}
+
+export function useTimelinePlayhead({
+ currentTime,
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+}: UseTimelinePlayheadProps) {
+ // Playhead scrubbing state
+ const [isScrubbing, setIsScrubbing] = useState(false);
+ const [scrubTime, setScrubTime] = useState<number | null>(null);
+
+ // Ruler drag detection state
+ const [isDraggingRuler, setIsDraggingRuler] = useState(false);
+ const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
+
+ const playheadPosition =
+ isScrubbing && scrubTime !== null ? scrubTime : currentTime;
+
+ // --- Playhead Scrubbing Handlers ---
+ const handlePlayheadMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation(); // Prevent ruler drag from triggering
+ setIsScrubbing(true);
+ handleScrub(e);
+ },
+ [duration, zoomLevel]
+ );
+
+ // Ruler mouse down handler
+ const handleRulerMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ // Only handle left mouse button
+ if (e.button !== 0) return;
+
+ // Don't interfere if clicking on the playhead itself
+ if (playheadRef?.current?.contains(e.target as Node)) return;
+
+ e.preventDefault();
+ setIsDraggingRuler(true);
+ setHasDraggedRuler(false);
+
+ // Start scrubbing immediately
+ setIsScrubbing(true);
+ handleScrub(e);
+ },
+ [duration, zoomLevel]
+ );
+
+ const handleScrub = useCallback(
+ (e: MouseEvent | React.MouseEvent) => {
+ const ruler = rulerRef.current;
+ if (!ruler) return;
+ const rect = ruler.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
+ // Use frame snapping for playhead scrubbing
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ const time = snapTimeToFrame(rawTime, projectFps);
+ setScrubTime(time);
+ seek(time); // update video preview in real time
+ },
+ [duration, zoomLevel, seek, rulerRef]
+ );
+
+ // Mouse move/up event handlers
+ useEffect(() => {
+ if (!isScrubbing) return;
+ const onMouseMove = (e: MouseEvent) => {
+ handleScrub(e);
+ // Mark that we've dragged if ruler drag is active
+ if (isDraggingRuler) {
+ setHasDraggedRuler(true);
+ }
+ };
+ const onMouseUp = (e: MouseEvent) => {
+ setIsScrubbing(false);
+ if (scrubTime !== null) seek(scrubTime); // finalize seek
+ setScrubTime(null);
+
+ // Handle ruler click vs drag
+ if (isDraggingRuler) {
+ setIsDraggingRuler(false);
+ // If we didn't drag, treat it as a click-to-seek
+ if (!hasDraggedRuler) {
+ handleScrub(e);
+ }
+ setHasDraggedRuler(false);
+ }
+ };
+ window.addEventListener("mousemove", onMouseMove);
+ window.addEventListener("mouseup", onMouseUp);
+ return () => {
+ window.removeEventListener("mousemove", onMouseMove);
+ window.removeEventListener("mouseup", onMouseUp);
+ };
+ }, [
+ isScrubbing,
+ scrubTime,
+ seek,
+ handleScrub,
+ isDraggingRuler,
+ hasDraggedRuler,
+ ]);
+
+ // --- Playhead auto-scroll effect ---
+ useEffect(() => {
+ const rulerViewport = rulerScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ const tracksViewport = tracksScrollRef.current?.querySelector(
+ "[data-radix-scroll-area-viewport]"
+ ) as HTMLElement;
+ if (!rulerViewport || !tracksViewport) return;
+ const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
+ const viewportWidth = rulerViewport.clientWidth;
+ const scrollMin = 0;
+ const scrollMax = rulerViewport.scrollWidth - viewportWidth;
+ // Center the playhead if it's not visible (100px buffer)
+ const desiredScroll = Math.max(
+ scrollMin,
+ Math.min(scrollMax, playheadPx - viewportWidth / 2)
+ );
+ if (
+ playheadPx < rulerViewport.scrollLeft + 100 ||
+ playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
+ ) {
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
+ }
+ }, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]);
+
+ return {
+ playheadPosition,
+ handlePlayheadMouseDown,
+ handleRulerMouseDown,
+ isDraggingRuler,
+ };
+}
diff --git a/apps/web/src/hooks/use-timeline-zoom.ts b/apps/web/src/hooks/use-timeline-zoom.ts
new file mode 100644
index 00000000..ce32c9f0
--- /dev/null
+++ b/apps/web/src/hooks/use-timeline-zoom.ts
@@ -0,0 +1,54 @@
+import { useState, useCallback, useEffect, RefObject } from "react";
+
+interface UseTimelineZoomProps {
+ containerRef: RefObject<HTMLDivElement>;
+ isInTimeline?: boolean;
+}
+
+interface UseTimelineZoomReturn {
+ zoomLevel: number;
+ setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
+ handleWheel: (e: React.WheelEvent) => void;
+}
+
+export function useTimelineZoom({
+ containerRef,
+ isInTimeline = false,
+}: UseTimelineZoomProps): UseTimelineZoomReturn {
+ const [zoomLevel, setZoomLevel] = useState(1);
+
+ const handleWheel = useCallback((e: React.WheelEvent) => {
+ // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
+ if (e.ctrlKey || e.metaKey) {
+ e.preventDefault();
+ const delta = e.deltaY > 0 ? -0.15 : 0.15;
+ setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
+ }
+ // Otherwise, allow normal scrolling
+ }, []);
+
+ // Prevent browser zooming in/out when in timeline
+ useEffect(() => {
+ const preventZoom = (e: WheelEvent) => {
+ if (
+ isInTimeline &&
+ (e.ctrlKey || e.metaKey) &&
+ containerRef.current?.contains(e.target as Node)
+ ) {
+ e.preventDefault();
+ }
+ };
+
+ document.addEventListener("wheel", preventZoom, { passive: false });
+
+ return () => {
+ document.removeEventListener("wheel", preventZoom);
+ };
+ }, [isInTimeline, containerRef]);
+
+ return {
+ zoomLevel,
+ setZoomLevel,
+ handleWheel,
+ };
+}
diff --git a/apps/web/src/lib/font-config.ts b/apps/web/src/lib/font-config.ts
new file mode 100644
index 00000000..0389054a
--- /dev/null
+++ b/apps/web/src/lib/font-config.ts
@@ -0,0 +1,39 @@
+import {
+ Inter,
+ Roboto,
+ Open_Sans,
+ Playfair_Display,
+ Comic_Neue,
+} from "next/font/google";
+
+// Configure all fonts
+const inter = Inter({ subsets: ["latin"] });
+const roboto = Roboto({ subsets: ["latin"], weight: ["400", "700"] });
+const openSans = Open_Sans({ subsets: ["latin"] });
+const playfairDisplay = Playfair_Display({ subsets: ["latin"] });
+const comicNeue = Comic_Neue({ subsets: ["latin"], weight: ["400", "700"] });
+
+// Export font class mapping for use in components
+export const FONT_CLASS_MAP = {
+ Inter: inter.className,
+ Roboto: roboto.className,
+ "Open Sans": openSans.className,
+ "Playfair Display": playfairDisplay.className,
+ "Comic Neue": comicNeue.className,
+ Arial: "",
+ Helvetica: "",
+ "Times New Roman": "",
+ Georgia: "",
+} as const;
+
+// Export individual fonts for use in layout
+export const fonts = {
+ inter,
+ roboto,
+ openSans,
+ playfairDisplay,
+ comicNeue,
+};
+
+// Default font for the body
+export const defaultFont = inter;
diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts
index 4c1294ae..353c3113 100644
--- a/apps/web/src/lib/media-processing.ts
+++ b/apps/web/src/lib/media-processing.ts
@@ -6,7 +6,7 @@ import {
getImageDimensions,
type MediaItem,
} from "@/stores/media-store";
-// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled
+import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils";
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
@@ -33,6 +33,7 @@ export async function processMediaFiles(
let duration: number | undefined;
let width: number | undefined;
let height: number | undefined;
+ let fps: number | undefined;
try {
if (fileType === "image") {
@@ -41,17 +42,31 @@ export async function processMediaFiles(
width = dimensions.width;
height = dimensions.height;
} else if (fileType === "video") {
- // Use basic thumbnail generation for now
- const videoResult = await generateVideoThumbnail(file);
- thumbnailUrl = videoResult.thumbnailUrl;
- width = videoResult.width;
- height = videoResult.height;
- } else if (fileType === "audio") {
- // For audio, we don't set width/height (they'll be undefined)
- }
+ try {
+ // Use FFmpeg for comprehensive video info extraction
+ const videoInfo = await getVideoInfo(file);
+ duration = videoInfo.duration;
+ width = videoInfo.width;
+ height = videoInfo.height;
+ fps = videoInfo.fps;
- // Get duration for videos and audio (if not already set by FFmpeg)
- if ((fileType === "video" || fileType === "audio") && !duration) {
+ // Generate thumbnail using FFmpeg
+ thumbnailUrl = await generateThumbnail(file, 1);
+ } catch (error) {
+ console.warn(
+ "FFmpeg processing failed, falling back to basic processing:",
+ error
+ );
+ // Fallback to basic processing
+ const videoResult = await generateVideoThumbnail(file);
+ thumbnailUrl = videoResult.thumbnailUrl;
+ width = videoResult.width;
+ height = videoResult.height;
+ duration = await getMediaDuration(file);
+ // FPS will remain undefined for fallback
+ }
+ } else if (fileType === "audio") {
+ // For audio, we don't set width/height/fps (they'll be undefined)
duration = await getMediaDuration(file);
}
@@ -64,6 +79,7 @@ export async function processMediaFiles(
duration,
width,
height,
+ fps,
});
// Yield back to the event loop to keep the UI responsive
diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts
index 79e932bd..0037dbdb 100644
--- a/apps/web/src/lib/storage/storage-service.ts
+++ b/apps/web/src/lib/storage/storage-service.ts
@@ -60,6 +60,9 @@ class StorageService {
thumbnail: project.thumbnail,
createdAt: project.createdAt.toISOString(),
updatedAt: project.updatedAt.toISOString(),
+ backgroundColor: project.backgroundColor,
+ backgroundType: project.backgroundType,
+ blurIntensity: project.blurIntensity,
};
await this.projectsAdapter.set(project.id, serializedProject);
@@ -77,6 +80,9 @@ class StorageService {
thumbnail: serializedProject.thumbnail,
createdAt: new Date(serializedProject.createdAt),
updatedAt: new Date(serializedProject.updatedAt),
+ backgroundColor: serializedProject.backgroundColor,
+ backgroundType: serializedProject.backgroundType,
+ blurIntensity: serializedProject.blurIntensity,
};
}
diff --git a/apps/web/src/lib/time.ts b/apps/web/src/lib/time.ts
index e2db28c2..b3147946 100644
--- a/apps/web/src/lib/time.ts
+++ b/apps/web/src/lib/time.ts
@@ -1,14 +1,16 @@
// Time-related utility functions
-// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS)
+// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS, HH:MM:SS:FF)
export const formatTimeCode = (
timeInSeconds: number,
- format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" = "HH:MM:SS:CS"
+ format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS",
+ fps: number = 30
): string => {
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const centiseconds = Math.floor((timeInSeconds % 1) * 100);
+ const frames = Math.floor((timeInSeconds % 1) * fps);
switch (format) {
case "MM:SS":
@@ -17,5 +19,7 @@ export const formatTimeCode = (
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:CS":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
+ case "HH:MM:SS:FF":
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
}
};
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
index ae939698..dd73059f 100644
--- a/apps/web/src/lib/utils.ts
+++ b/apps/web/src/lib/utils.ts
@@ -5,4 +5,34 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
+}
+
+/**
+ * Generates a UUID v4 string
+ * Uses crypto.randomUUID() if available, otherwise falls back to a custom implementation
+ */
+export function generateUUID(): string {
+ // Use the native crypto.randomUUID if available
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID();
+ }
+
+ // Secure fallback using crypto.getRandomValues
+ const bytes = new Uint8Array(16);
+ crypto.getRandomValues(bytes);
+
+ // Set version 4 (UUIDv4)
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ // Set variant 10xxxxxx
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+
+ const hex = [...bytes].map(b => b.toString(16).padStart(2, '0'));
+
+ return (
+ hex.slice(0, 4).join('') + '-' +
+ hex.slice(4, 6).join('') + '-' +
+ hex.slice(6, 8).join('') + '-' +
+ hex.slice(8, 10).join('') + '-' +
+ hex.slice(10, 16).join('')
+ );
} \ No newline at end of file
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts
index d20c4b1d..bdce5cc1 100644
--- a/apps/web/src/middleware.ts
+++ b/apps/web/src/middleware.ts
@@ -2,6 +2,11 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
+ // Handle fuckcapcut.com domain redirect
+ if (request.headers.get("host") === "fuckcapcut.com") {
+ return NextResponse.redirect("https://opencut.app/why-not-capcut", 301);
+ }
+
const path = request.nextUrl.pathname;
if (path === "/editor" && process.env.NODE_ENV === "production") {
diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts
index 5382c3e2..c24929a2 100644
--- a/apps/web/src/stores/editor-store.ts
+++ b/apps/web/src/stores/editor-store.ts
@@ -1,6 +1,8 @@
import { create } from "zustand";
import { CanvasSize, CanvasPreset } from "@/types/editor";
+type CanvasMode = "preset" | "original" | "custom";
+
interface EditorState {
// Loading states
isInitializing: boolean;
@@ -8,6 +10,7 @@ interface EditorState {
// Canvas/Project settings
canvasSize: CanvasSize;
+ canvasMode: CanvasMode;
canvasPresets: CanvasPreset[];
// Actions
@@ -15,6 +18,7 @@ interface EditorState {
setPanelsReady: (ready: boolean) => void;
initializeApp: () => Promise<void>;
setCanvasSize: (size: CanvasSize) => void;
+ setCanvasSizeToOriginal: (aspectRatio: number) => void;
setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
}
@@ -65,6 +69,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
isInitializing: true,
isPanelsReady: false,
canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
+ canvasMode: "preset" as CanvasMode,
canvasPresets: DEFAULT_CANVAS_PRESETS,
// Actions
@@ -85,15 +90,16 @@ export const useEditorStore = create<EditorState>((set, get) => ({
},
setCanvasSize: (size) => {
- set({ canvasSize: size });
+ set({ canvasSize: size, canvasMode: "preset" });
+ },
+
+ setCanvasSizeToOriginal: (aspectRatio) => {
+ const newCanvasSize = findBestCanvasPreset(aspectRatio);
+ set({ canvasSize: newCanvasSize, canvasMode: "original" });
},
setCanvasSizeFromAspectRatio: (aspectRatio) => {
const newCanvasSize = findBestCanvasPreset(aspectRatio);
- console.log(
- `Setting canvas size based on aspect ratio ${aspectRatio}:`,
- newCanvasSize
- );
- set({ canvasSize: newCanvasSize });
+ set({ canvasSize: newCanvasSize, canvasMode: "custom" });
},
}));
diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts
index cb69a7e9..7d4851c2 100644
--- a/apps/web/src/stores/media-store.ts
+++ b/apps/web/src/stores/media-store.ts
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service";
-import { useProjectStore } from "./project-store";
+import { useTimelineStore } from "./timeline-store";
+import { generateUUID } from "@/lib/utils";
export type MediaType = "image" | "video" | "audio";
@@ -14,6 +15,7 @@ export interface MediaItem {
duration?: number; // For video/audio duration
width?: number; // For video/image width
height?: number; // For video/image height
+ fps?: number; // For video frame rate
// Text-specific properties
content?: string; // Text content
fontSize?: number; // Font size
@@ -161,7 +163,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
addMediaItem: async (projectId, item) => {
const newItem: MediaItem = {
...item,
- id: crypto.randomUUID(),
+ id: generateUUID(),
};
// Add to local state immediately for UI responsiveness
diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts
index 9570f4a0..efb9cc71 100644
--- a/apps/web/src/stores/project-store.ts
+++ b/apps/web/src/stores/project-store.ts
@@ -4,6 +4,7 @@ import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
import { useMediaStore } from "./media-store";
import { useTimelineStore } from "./timeline-store";
+import { generateUUID } from "@/lib/utils";
interface ProjectStore {
activeProject: TProject | null;
@@ -20,6 +21,12 @@ interface ProjectStore {
closeProject: () => void;
renameProject: (projectId: string, name: string) => Promise<void>;
duplicateProject: (projectId: string) => Promise<string>;
+ updateProjectBackground: (backgroundColor: string) => Promise<void>;
+ updateBackgroundType: (
+ type: "color" | "blur",
+ options?: { backgroundColor?: string; blurIntensity?: number }
+ ) => Promise<void>;
+ updateProjectFps: (fps: number) => Promise<void>;
}
export const useProjectStore = create<ProjectStore>((set, get) => ({
@@ -30,11 +37,14 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
createNewProject: async (name: string) => {
const newProject: TProject = {
- id: crypto.randomUUID(),
+ id: generateUUID(),
name,
thumbnail: "",
createdAt: new Date(),
updatedAt: new Date(),
+ backgroundColor: "#000000",
+ backgroundType: "color",
+ blurIntensity: 8,
};
set({ activeProject: newProject });
@@ -215,7 +225,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
const newProject: TProject = {
- id: crypto.randomUUID(),
+ id: generateUUID(),
name: `(${nextNumber}) ${baseName}`,
thumbnail: project.thumbnail,
createdAt: new Date(),
@@ -234,4 +244,77 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
throw error;
}
},
+
+ updateProjectBackground: async (backgroundColor: string) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ const updatedProject = {
+ ...activeProject,
+ backgroundColor,
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject(updatedProject);
+ set({ activeProject: updatedProject });
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to update project background:", error);
+ toast.error("Failed to update background", {
+ description: "Please try again",
+ });
+ }
+ },
+
+ updateBackgroundType: async (
+ type: "color" | "blur",
+ options?: { backgroundColor?: string; blurIntensity?: number }
+ ) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ const updatedProject = {
+ ...activeProject,
+ backgroundType: type,
+ ...(options?.backgroundColor && {
+ backgroundColor: options.backgroundColor,
+ }),
+ ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }),
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject(updatedProject);
+ set({ activeProject: updatedProject });
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to update background type:", error);
+ toast.error("Failed to update background", {
+ description: "Please try again",
+ });
+ }
+ },
+
+ updateProjectFps: async (fps: number) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ const updatedProject = {
+ ...activeProject,
+ fps,
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject(updatedProject);
+ set({ activeProject: updatedProject });
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to update project FPS:", error);
+ toast.error("Failed to update project FPS", {
+ description: "Please try again",
+ });
+ }
+ },
}));
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 6de24c84..37b45b4a 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -4,6 +4,7 @@ import {
TimelineElement,
CreateTimelineElement,
TimelineTrack,
+ TextElement,
sortTracksByOrder,
ensureMainTrack,
validateElementTrackCompatibility,
@@ -12,6 +13,7 @@ import { useEditorStore } from "./editor-store";
import { useMediaStore, getMediaAspectRatio } from "./media-store";
import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
+import { generateUUID } from "@/lib/utils";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
@@ -117,6 +119,13 @@ interface TimelineStore {
) => void;
separateAudio: (trackId: string, elementId: string) => string | null;
+ // Replace media for an element
+ replaceElementMedia: (
+ trackId: string,
+ elementId: string,
+ newFile: File
+ ) => Promise<boolean>;
+
// Computed values
getTotalDuration: () => number;
@@ -129,6 +138,28 @@ interface TimelineStore {
loadProjectTimeline: (projectId: string) => Promise<void>;
saveProjectTimeline: (projectId: string) => Promise<void>;
clearTimeline: () => void;
+ updateTextElement: (
+ trackId: string,
+ elementId: string,
+ updates: Partial<
+ Pick<
+ TextElement,
+ | "content"
+ | "fontSize"
+ | "fontFamily"
+ | "color"
+ | "backgroundColor"
+ | "textAlign"
+ | "fontWeight"
+ | "fontStyle"
+ | "textDecoration"
+ | "x"
+ | "y"
+ | "rotation"
+ | "opacity"
+ >
+ >
+ ) => void;
}
export const useTimelineStore = create<TimelineStore>((set, get) => {
@@ -249,7 +280,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
: "Track";
const newTrack: TimelineTrack = {
- id: crypto.randomUUID(),
+ id: generateUUID(),
name: trackName,
type,
elements: [],
@@ -274,7 +305,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
: "Track";
const newTrack: TimelineTrack = {
- id: crypto.randomUUID(),
+ id: generateUUID(),
name: trackName,
type,
elements: [],
@@ -333,14 +364,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const newElement: TimelineElement = {
...elementData,
- id: crypto.randomUUID(),
+ id: generateUUID(),
startTime: elementData.startTime || 0,
trimStart: 0,
trimEnd: 0,
} as TimelineElement; // Type assertion since we trust the caller passes valid data
// If this is the first element and it's a media element, automatically set the project canvas size
- // to match the media's aspect ratio
+ // to match the media's aspect ratio and FPS (for videos)
if (isFirstElement && newElement.type === "media") {
const mediaStore = useMediaStore.getState();
const mediaItem = mediaStore.mediaItems.find(
@@ -356,6 +387,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
getMediaAspectRatio(mediaItem)
);
}
+
+ // Set project FPS from the first video element
+ if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
+ const projectStore = useProjectStore.getState();
+ if (projectStore.activeProject) {
+ projectStore.updateProjectFps(mediaItem.fps);
+ }
+ }
}
updateTracksAndSave(
@@ -487,6 +526,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
},
+ updateTextElement: (trackId, elementId, updates) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId && element.type === "text"
+ ? { ...element, ...updates }
+ : element
+ ),
+ }
+ : track
+ )
+ );
+ },
+
splitElement: (trackId, elementId, splitTime) => {
const { _tracks } = get();
const track = _tracks.find((t) => t.id === trackId);
@@ -508,7 +565,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const secondDuration =
element.duration - element.trimStart - element.trimEnd - relativeTime;
- const secondElementId = crypto.randomUUID();
+ const secondElementId = generateUUID();
updateTracksAndSave(
get()._tracks.map((track) =>
@@ -634,7 +691,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
// Find existing audio track or prepare to create one
const existingAudioTrack = _tracks.find((t) => t.type === "audio");
- const audioElementId = crypto.randomUUID();
+ const audioElementId = generateUUID();
if (existingAudioTrack) {
// Add audio element to existing audio track
@@ -658,7 +715,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
} else {
// Create new audio track with the audio element in a single atomic update
const newAudioTrack: TimelineTrack = {
- id: crypto.randomUUID(),
+ id: generateUUID(),
name: "Audio Track",
type: "audio",
elements: [
@@ -677,6 +734,102 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return audioElementId;
},
+ // Replace media for an element
+ replaceElementMedia: async (trackId, elementId, newFile) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
+
+ if (!element || element.type !== "media") return false;
+
+ try {
+ const mediaStore = useMediaStore.getState();
+ const projectStore = useProjectStore.getState();
+
+ if (!projectStore.activeProject) return false;
+
+ // Import required media processing functions
+ const {
+ getFileType,
+ getImageDimensions,
+ generateVideoThumbnail,
+ getMediaDuration,
+ } = await import("./media-store");
+
+ const fileType = getFileType(newFile);
+ if (!fileType) return false;
+
+ // Process the new media file
+ let mediaData: any = {
+ name: newFile.name,
+ type: fileType,
+ file: newFile,
+ url: URL.createObjectURL(newFile),
+ };
+
+ // Get media-specific metadata
+ if (fileType === "image") {
+ const { width, height } = await getImageDimensions(newFile);
+ mediaData.width = width;
+ mediaData.height = height;
+ } else if (fileType === "video") {
+ const [duration, { thumbnailUrl, width, height }] = await Promise.all(
+ [getMediaDuration(newFile), generateVideoThumbnail(newFile)]
+ );
+ mediaData.duration = duration;
+ mediaData.thumbnailUrl = thumbnailUrl;
+ mediaData.width = width;
+ mediaData.height = height;
+ } else if (fileType === "audio") {
+ mediaData.duration = await getMediaDuration(newFile);
+ }
+
+ // Add new media item to store
+ await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData);
+
+ // Find the newly created media item
+ const newMediaItem = mediaStore.mediaItems.find(
+ (item) => item.file === newFile
+ );
+
+ if (!newMediaItem) return false;
+
+ get().pushHistory();
+
+ // Update the timeline element to reference the new media
+ updateTracksAndSave(
+ _tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((c) =>
+ c.id === elementId
+ ? {
+ ...c,
+ mediaId: newMediaItem.id,
+ name: newMediaItem.name,
+ // Update duration if the new media has a different duration
+ duration: newMediaItem.duration || c.duration,
+ }
+ : c
+ ),
+ }
+ : track
+ )
+ );
+
+ return true;
+ } catch (error) {
+ console.log(
+ JSON.stringify({
+ error: "Failed to replace element media",
+ details: error,
+ })
+ );
+ return false;
+ }
+ },
+
getTotalDuration: () => {
const { _tracks } = get();
if (_tracks.length === 0) return 0;
diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts
index e208be3a..58d37b03 100644
--- a/apps/web/src/types/project.ts
+++ b/apps/web/src/types/project.ts
@@ -5,4 +5,8 @@ export interface TProject {
createdAt: Date;
updatedAt: Date;
mediaItems?: string[];
+ backgroundColor?: string;
+ backgroundType?: "color" | "blur";
+ blurIntensity?: number; // in pixels (4, 8, 18)
+ fps?: number;
}
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index a759de21..17dbb5d1 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -1,4 +1,5 @@
import { MediaType } from "@/stores/media-store";
+import { generateUUID } from "@/lib/utils";
export type TrackType = "media" | "text" | "audio";
@@ -111,7 +112,7 @@ export function ensureMainTrack(tracks: TimelineTrack[]): TimelineTrack[] {
if (!hasMainTrack) {
// Create main track if it doesn't exist
const mainTrack: TimelineTrack = {
- id: crypto.randomUUID(),
+ id: generateUUID(),
name: "Main Track",
type: "media",
elements: [],