summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/layout.tsx2
-rw-r--r--apps/web/src/app/metadata.ts4
-rw-r--r--apps/web/src/app/roadmap/page.tsx247
-rw-r--r--apps/web/src/components/development-debug.tsx108
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx58
-rw-r--r--apps/web/src/components/editor/media-panel/views/text.tsx37
-rw-r--r--apps/web/src/components/editor/snap-indicator.tsx54
-rw-r--r--apps/web/src/components/editor/timeline-playhead.tsx14
-rw-r--r--apps/web/src/components/editor/timeline-track.tsx50
-rw-r--r--apps/web/src/components/editor/timeline.tsx383
-rw-r--r--apps/web/src/components/footer.tsx264
-rw-r--r--apps/web/src/components/ui/draggable-item.tsx22
-rw-r--r--apps/web/src/components/ui/tooltip.tsx2
-rw-r--r--apps/web/src/constants/timeline-constants.ts1
-rw-r--r--apps/web/src/env.ts27
-rw-r--r--apps/web/src/hooks/use-timeline-snapping.ts178
-rw-r--r--apps/web/src/lib/rate-limit.ts5
-rw-r--r--apps/web/src/middleware.ts3
-rw-r--r--apps/web/src/stores/timeline-store.ts178
19 files changed, 1168 insertions, 469 deletions
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 71716e74..9ac54262 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -4,7 +4,6 @@ import Script from "next/script";
import "./globals.css";
import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
-import { DevelopmentDebug } from "../components/development-debug";
import { StorageProvider } from "../components/storage-provider";
import { baseMetaData } from "./metadata";
import { defaultFont } from "../lib/font-config";
@@ -24,7 +23,6 @@ export default function RootLayout({
<StorageProvider>{children}</StorageProvider>
<Analytics />
<Toaster />
- <DevelopmentDebug />
<Script
src="https://cdn.databuddy.cc/databuddy.js"
strategy="afterInteractive"
diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts
index 6db26811..47f0020b 100644
--- a/apps/web/src/app/metadata.ts
+++ b/apps/web/src/app/metadata.ts
@@ -3,8 +3,8 @@ import { Metadata } from "next";
const title = "OpenCut";
const description =
"A simple but powerful video editor that gets the job done. In your browser.";
-const openGraphImageUrl = "https://opencut.app/opengraph-image.jpg";
-const twitterImageUrl = "/opengraph-image.jpg";
+const openGraphImageUrl = "https://opencut.app/open-graph/default.jpg";
+const twitterImageUrl = "/open-graph/default.jpg";
export const baseMetaData: Metadata = {
metadataBase: new URL("https://opencut.app"),
diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx
new file mode 100644
index 00000000..c62dce3a
--- /dev/null
+++ b/apps/web/src/app/roadmap/page.tsx
@@ -0,0 +1,247 @@
+import { Metadata } from "next";
+import { Badge } from "@/components/ui/badge";
+import { GithubIcon } from "@/components/icons";
+import Link from "next/link";
+import { Footer } from "@/components/footer";
+import { Header } from "@/components/header";
+import ReactMarkdown from "react-markdown";
+import { cn } from "@/lib/utils";
+
+const roadmapItems: {
+ title: string;
+ description: string;
+ status: {
+ text: string;
+ type: "complete" | "pending" | "default" | "info";
+ };
+}[] = [
+ {
+ title: "Start",
+ description:
+ "This is where it all started. Repository created, initial project structure, and the vision for a free, open-source video editor. [Check out the first tweet](https://x.com/mazeincoding/status/1936706642512388188) to see where it started.",
+ status: {
+ text: "Completed",
+ type: "complete",
+ },
+ },
+ {
+ title: "Core UI",
+ description:
+ "Built the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
+ status: {
+ text: "Completed",
+ type: "complete",
+ },
+ },
+ {
+ title: "Basic Functionality",
+ description:
+ "The heart of any video editor. Timeline zoom in/out, making clips longer/shorter, dragging elements around, selection, playhead scrubbing. **This part has to be fucking perfect** because it's what users interact with 99% of the time.",
+ status: {
+ text: "In Progress",
+ type: "pending",
+ },
+ },
+ {
+ title: "Export/Preview Logic",
+ description:
+ "The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
+ status: {
+ text: "In Progress",
+ type: "pending",
+ },
+ },
+ {
+ title: "Text",
+ description:
+ "After media, text is the next most important thing. Font selection with custom font imports, text stroke, colors. All the text essential text properties.",
+ status: {
+ text: "Not Started",
+ type: "default",
+ },
+ },
+ {
+ title: "Effects",
+ description:
+ "Adding visual effects to both text and media. Blur, brightness, contrast, saturation, filters, and all the creative tools that make videos pop. This is where the magic happens.",
+ status: {
+ text: "Not Started",
+ type: "default",
+ },
+ },
+ {
+ title: "Transitions",
+ description:
+ "Smooth transitions between clips. Fade in/out, slide, zoom, dissolve, and custom transition effects.",
+ status: {
+ text: "Not Started",
+ type: "default",
+ },
+ },
+ {
+ title: "Refine from Here",
+ description:
+ "Once we nail the above, we have a **solid foundation** to build anything. Advanced features, performance optimizations, mobile support, desktop app.",
+ status: {
+ text: "Future",
+ type: "info",
+ },
+ },
+];
+
+export const metadata: Metadata = {
+ title: "Roadmap - OpenCut",
+ description:
+ "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
+ openGraph: {
+ title: "OpenCut Roadmap - What's Coming Next",
+ description:
+ "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
+ type: "website",
+ images: [
+ {
+ url: "/open-graph/roadmap.jpg",
+ width: 1200,
+ height: 630,
+ alt: "OpenCut Roadmap",
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "OpenCut Roadmap - What's Coming Next",
+ description:
+ "See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
+ images: ["/open-graph/roadmap.jpg"],
+ },
+};
+
+export default function RoadmapPage() {
+ return (
+ <div className="min-h-screen bg-background">
+ <Header />
+ <main className="relative">
+ <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-4xl mx-auto">
+ <div className="text-center mb-10">
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut"
+ target="_blank"
+ >
+ <Badge variant="secondary" className="gap-2 mb-6">
+ <GithubIcon className="h-3 w-3" />
+ Open Source
+ </Badge>
+ </Link>
+ <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
+ Roadmap
+ </h1>
+ <p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
+ What's coming next for OpenCut (last updated: July 14, 2025)
+ </p>
+ </div>
+ <div className="space-y-6">
+ {roadmapItems.map((item, index) => (
+ <div key={index} className="relative">
+ <div className="flex items-start gap-2">
+ <span className="text-lg font-medium text-muted-foreground select-none leading-[1.5]">
+ {index + 1}.
+ </span>
+ <div className="flex-1 pt-[2px]">
+ <div className="flex items-center gap-2 mb-2">
+ <h3 className="font-medium text-lg">{item.title}</h3>
+ <Badge
+ className={cn("shadow-none", {
+ "!bg-green-500 text-white":
+ item.status.type === "complete",
+ "!bg-yellow-500 text-white":
+ item.status.type === "pending",
+ "!bg-blue-500 text-white":
+ item.status.type === "info",
+ "!bg-foreground/10 text-accent-foreground":
+ item.status.type === "default",
+ })}
+ >
+ {item.status.text}
+ </Badge>
+ </div>
+ <div className="text-foreground/70 leading-relaxed">
+ <ReactMarkdown
+ components={{
+ a: ({ className, children, ...props }) => (
+ <a
+ className={cn(
+ "text-primary hover:underline",
+ className
+ )}
+ target="_blank"
+ rel="noopener noreferrer"
+ {...props}
+ >
+ {children}
+ </a>
+ ),
+ strong: ({ children }) => (
+ <strong className="font-semibold text-foreground">
+ {children}
+ </strong>
+ ),
+ }}
+ >
+ {item.description}
+ </ReactMarkdown>
+ </div>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+
+ <div className="mt-12 pt-8 border-t border-muted/20">
+ <div className="text-center space-y-4">
+ <h3 className="text-xl font-semibold">Want to Help?</h3>
+ <p className="text-muted-foreground max-w-2xl mx-auto">
+ OpenCut is open source and built by the community. Every
+ contribution, no matter how small, helps us build the best
+ free video editor possible.
+ </p>
+ <div className="flex flex-col sm:flex-row gap-4 justify-center mt-6">
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Badge
+ variant="outline"
+ className="text-sm px-4 py-2 hover:bg-muted/50 transition-colors"
+ >
+ <GithubIcon className="h-4 w-4 mr-2" />
+ Start Contributing
+ </Badge>
+ </Link>
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut/issues"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Badge
+ variant="outline"
+ className="text-sm px-4 py-2 hover:bg-muted/50 transition-colors"
+ >
+ Report Issues
+ </Badge>
+ </Link>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </main>
+ <Footer />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/development-debug.tsx b/apps/web/src/components/development-debug.tsx
deleted file mode 100644
index 3b891e38..00000000
--- a/apps/web/src/components/development-debug.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-"use client";
-
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore, type MediaItem } from "@/stores/media-store";
-import { TimelineTrack } from "@/types/timeline";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { Button } from "@/components/ui/button";
-import { useState } from "react";
-import type { TimelineElement } from "@/types/timeline";
-
-// Only show in development
-const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
-
-interface ActiveElement {
- element: TimelineElement;
- track: TimelineTrack;
- mediaItem: MediaItem | null;
-}
-
-export function DevelopmentDebug() {
- const { tracks } = useTimelineStore();
- const { mediaItems } = useMediaStore();
- const { currentTime } = usePlaybackStore();
- const [showDebug, setShowDebug] = useState(false);
-
- // Don't render anything in production
- if (!SHOW_DEBUG_INFO) return null;
-
- // Get active elements at current time
- const getActiveElements = (): ActiveElement[] => {
- const activeElements: ActiveElement[] = [];
-
- tracks.forEach((track) => {
- track.elements.forEach((element) => {
- const elementStart = element.startTime;
- const elementEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime >= elementStart && currentTime < elementEnd) {
- const mediaItem =
- element.type === "media"
- ? mediaItems.find((item) => item.id === element.mediaId) || null
- : null; // Text elements don't have media items
-
- activeElements.push({ element, track, mediaItem });
- }
- });
- });
-
- return activeElements;
- };
-
- const activeElements = getActiveElements();
-
- return (
- <div className="fixed bottom-4 right-4 z-50">
- <div className="flex flex-col items-end gap-2">
- {/* Toggle Button */}
- <Button
- variant="outline"
- size="sm"
- onClick={() => setShowDebug(!showDebug)}
- className="text-xs backdrop-blur-md bg-background/80 border-border/50"
- >
- Debug {showDebug ? "ON" : "OFF"}
- </Button>
-
- {/* Debug Info Panel */}
- {showDebug && (
- <div className="backdrop-blur-md bg-background/90 border border-border/50 rounded-lg p-3 max-w-sm">
- <div className="text-xs font-medium mb-2 text-foreground">
- Active Elements ({activeElements.length})
- </div>
- <div className="space-y-1 max-h-40 overflow-y-auto">
- {activeElements.map((elementData, index) => (
- <div
- key={elementData.element.id}
- className="flex items-center gap-2 px-2 py-1 bg-muted/60 rounded text-xs"
- >
- <span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4 flex-shrink-0">
- {index + 1}
- </span>
- <div className="min-w-0 flex-1">
- <div className="truncate">{elementData.element.name}</div>
- <div className="text-muted-foreground text-[10px]">
- {elementData.element.type === "media"
- ? elementData.mediaItem?.type || "media"
- : "text"}
- </div>
- </div>
- </div>
- ))}
- {activeElements.length === 0 && (
- <div className="text-muted-foreground text-xs py-2 text-center">
- No active elements
- </div>
- )}
- </div>
- <div className="mt-2 pt-2 border-t border-border/30 text-[10px] text-muted-foreground">
- Time: {currentTime.toFixed(2)}s
- </div>
- </div>
- )}
- </div>
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx
index 48e54961..393d72db 100644
--- a/apps/web/src/components/editor/media-panel/views/media.tsx
+++ b/apps/web/src/components/editor/media-panel/views/media.tsx
@@ -24,6 +24,7 @@ import {
} from "@/components/ui/select";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { useProjectStore } from "@/stores/project-store";
+import { useTimelineStore } from "@/stores/timeline-store";
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
@@ -207,9 +208,8 @@ export function MediaView() {
<DragOverlay isVisible={isDragOver} />
<div className="p-3 pb-2">
- {/* Button to add/upload media */}
+ {/* Search and filter controls */}
<div className="flex gap-2">
- {/* Search and filter controls */}
<Select value={mediaFilter} onValueChange={setMediaFilter}>
<SelectTrigger className="w-[80px] h-full text-xs">
<SelectValue />
@@ -228,29 +228,6 @@ export function MediaView() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
-
- {/* Add media button */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleFileSelect}
- disabled={isProcessing}
- className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
- >
- {isProcessing ? (
- <>
- <Upload className="h-4 w-4 animate-spin" />
- <span className="hidden md:inline ml-2">{progress}%</span>
- </>
- ) : (
- <>
- <Plus className="h-4 w-4" />
- <span className="hidden sm:inline ml-2" aria-label="Add file">
- Add
- </span>
- </>
- )}
- </Button>
</div>
</div>
@@ -265,8 +242,32 @@ export function MediaView() {
No media in project
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
- Drag files here or use the button above
+ Drag files here or use the button below
</p>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleFileSelect}
+ disabled={isProcessing}
+ className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center mt-2"
+ >
+ {isProcessing ? (
+ <>
+ <Upload className="h-4 w-4 animate-spin" />
+ <span className="hidden md:inline ml-2">{progress}%</span>
+ </>
+ ) : (
+ <>
+ <Plus className="h-4 w-4" />
+ <span
+ className="hidden sm:inline ml-2"
+ aria-label="Add file"
+ >
+ Add
+ </span>
+ </>
+ )}
+ </Button>
</div>
) : (
<div
@@ -288,6 +289,11 @@ export function MediaView() {
name: item.name,
}}
showPlusOnDrag={false}
+ onAddToTimeline={(currentTime) =>
+ useTimelineStore
+ .getState()
+ .addMediaAtTime(item, currentTime)
+ }
rounded={false}
/>
</ContextMenuTrigger>
diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx
index bec32c85..6d0d310b 100644
--- a/apps/web/src/components/editor/media-panel/views/text.tsx
+++ b/apps/web/src/components/editor/media-panel/views/text.tsx
@@ -1,4 +1,30 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { type TextElement } from "@/types/timeline";
+
+let textData: TextElement = {
+ id: "default-text",
+ type: "text",
+ name: "Default text",
+ content: "Default text",
+ fontSize: 48,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ backgroundColor: "transparent",
+ textAlign: "center" as const,
+ fontWeight: "normal" as const,
+ fontStyle: "normal" as const,
+ textDecoration: "none" as const,
+ x: 0,
+ y: 0,
+ rotation: 0,
+ opacity: 1,
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+};
export function TextView() {
return (
@@ -11,12 +37,15 @@ export function TextView() {
</div>
}
dragData={{
- id: "default-text",
- type: "text",
- name: "Default text",
- content: "Default text",
+ id: textData.id,
+ type: textData.type,
+ name: textData.name,
+ content: textData.content,
}}
aspectRatio={1}
+ onAddToTimeline={(currentTime) =>
+ useTimelineStore.getState().addTextAtTime(textData, currentTime)
+ }
showLabel={false}
/>
</div>
diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx
new file mode 100644
index 00000000..83a1f678
--- /dev/null
+++ b/apps/web/src/components/editor/snap-indicator.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { SnapPoint } from "@/hooks/use-timeline-snapping";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import type { TimelineTrack } from "@/types/timeline";
+
+interface SnapIndicatorProps {
+ snapPoint: SnapPoint | null;
+ zoomLevel: number;
+ isVisible: boolean;
+ tracks: TimelineTrack[];
+ timelineRef: React.RefObject<HTMLDivElement>;
+ trackLabelsRef?: React.RefObject<HTMLDivElement>;
+}
+
+export function SnapIndicator({
+ snapPoint,
+ zoomLevel,
+ isVisible,
+ tracks,
+ timelineRef,
+ trackLabelsRef,
+}: SnapIndicatorProps) {
+ if (!isVisible || !snapPoint) {
+ return null;
+ }
+
+ 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 +
+ snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+
+ return (
+ <div
+ className="absolute pointer-events-none z-[90]"
+ style={{
+ left: `${leftPosition}px`,
+ top: 0,
+ height: `${totalHeight}px`,
+ width: "2px",
+ }}
+ >
+ <div className={`w-0.5 h-full bg-primary/40 opacity-80`} />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline-playhead.tsx b/apps/web/src/components/editor/timeline-playhead.tsx
index ce4ed1ff..78aed3a8 100644
--- a/apps/web/src/components/editor/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/timeline-playhead.tsx
@@ -20,6 +20,7 @@ interface TimelinePlayheadProps {
trackLabelsRef?: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
+ isSnappingToPlayhead?: boolean;
}
export function TimelinePlayhead({
@@ -34,6 +35,7 @@ export function TimelinePlayhead({
trackLabelsRef,
timelineRef,
playheadRef: externalPlayheadRef,
+ isSnappingToPlayhead = false,
}: TimelinePlayheadProps) {
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
@@ -73,11 +75,15 @@ export function TimelinePlayhead({
}}
onMouseDown={handlePlayheadMouseDown}
>
- {/* The red line spanning full height */}
- <div className="absolute left-0 w-0.5 bg-foreground cursor-col-resize h-full" />
+ {/* The playhead line spanning full height */}
+ <div
+ className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
+ />
- {/* 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" />
+ {/* Playhead 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 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
+ />
</div>
);
}
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx
index 033b79a6..2be143eb 100644
--- a/apps/web/src/components/editor/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline-track.tsx
@@ -22,13 +22,16 @@ import {
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import { useProjectStore } from "@/stores/project-store";
+import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
export function TimelineTrackContent({
track,
zoomLevel,
+ onSnapPointChange,
}: {
track: TimelineTrack;
zoomLevel: number;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}) {
const { mediaItems } = useMediaStore();
const {
@@ -45,8 +48,18 @@ export function TimelineTrackContent({
endDrag: endDragAction,
clearSelectedElements,
insertTrackAt,
+ snappingEnabled,
} = useTimelineStore();
+ const { currentTime } = usePlaybackStore();
+
+ // Initialize snapping hook
+ const { snapElementPosition } = useTimelineSnapping({
+ snapThreshold: 10,
+ enableElementSnapping: snappingEnabled,
+ enablePlayheadSnapping: snappingEnabled,
+ });
+
const timelineRef = useRef<HTMLDivElement>(null);
const [isDropping, setIsDropping] = useState(false);
const [dropPosition, setDropPosition] = useState<number | null>(null);
@@ -85,12 +98,34 @@ export function TimelineTrackContent({
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
);
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
- // 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);
+ // Apply snapping if enabled
+ let finalTime = adjustedTime;
+ let snapPoint = null;
+ if (snappingEnabled) {
+ const snapResult = snapElementPosition(
+ adjustedTime,
+ tracks,
+ currentTime,
+ zoomLevel,
+ dragState.elementId || undefined
+ );
+ finalTime = snapResult.snappedTime;
+ snapPoint = snapResult.snapPoint;
+
+ // Notify parent component about snap point change
+ onSnapPointChange?.(snapPoint);
+ } else {
+ // Use frame snapping if project has FPS, otherwise use decimal snapping
+ const projectStore = useProjectStore.getState();
+ const projectFps = projectStore.activeProject?.fps || 30;
+ finalTime = snapTimeToFrame(adjustedTime, projectFps);
+
+ // Clear snap point when not snapping
+ onSnapPointChange?.(null);
+ }
+
+ updateDragTime(finalTime);
};
const handleMouseUp = (e: MouseEvent) => {
@@ -108,6 +143,8 @@ export function TimelineTrackContent({
dragState.currentTime
);
endDragAction();
+ // Clear snap point when drag ends
+ onSnapPointChange?.(null);
}
return;
}
@@ -204,6 +241,8 @@ export function TimelineTrackContent({
if (isTrackThatStartedDrag) {
endDragAction();
+ // Clear snap point when drag ends
+ onSnapPointChange?.(null);
}
};
@@ -229,6 +268,7 @@ export function TimelineTrackContent({
endDragAction,
selectedElements,
selectElement,
+ onSnapPointChange,
]);
const handleElementMouseDown = (
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index 71e2997b..c1603542 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -15,6 +15,8 @@ import {
Video,
Music,
TypeIcon,
+ Magnet,
+ Lock,
} from "lucide-react";
import {
Tooltip,
@@ -36,13 +38,6 @@ import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
import { processMediaFiles } from "@/lib/media-processing";
import { toast } from "sonner";
import { useState, useRef, useEffect, useCallback } from "react";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "../ui/select";
import { TimelineTrackContent } from "./timeline-track";
import {
TimelinePlayhead,
@@ -50,6 +45,8 @@ import {
} from "./timeline-playhead";
import { SelectionBox } from "./selection-box";
import { useSelectionBox } from "@/hooks/use-selection-box";
+import { SnapIndicator } from "./snap-indicator";
+import { SnapPoint } from "@/hooks/use-timeline-snapping";
import type { DragData, TimelineTrack } from "@/types/timeline";
import {
getTrackHeight,
@@ -78,6 +75,9 @@ export function Timeline() {
separateAudio,
undo,
redo,
+ snappingEnabled,
+ toggleSnapping,
+ dragState,
} = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
const { activeProject } = useProjectStore();
@@ -118,7 +118,7 @@ export function Timeline() {
const lastVerticalSync = useRef(0);
// Timeline playhead ruler handlers
- const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayheadRuler({
+ const { handleRulerMouseDown } = useTimelinePlayheadRuler({
currentTime,
duration,
zoomLevel,
@@ -145,6 +145,18 @@ export function Timeline() {
},
});
+ // Calculate snap indicator state
+ const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
+ null
+ );
+ const showSnapIndicator =
+ dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
+
+ // Callback to handle snap point changes from TimelineTrackContent
+ const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
+ setCurrentSnapPoint(snapPoint);
+ }, []);
+
// Timeline content click to seek handler
const handleTimelineContentClick = useCallback(
(e: React.MouseEvent) => {
@@ -364,29 +376,7 @@ export function Timeline() {
if (dragData.type === "text") {
// Always create new text track to avoid overlaps
- const newTrackId = addTrack("text");
-
- addElementToTrack(newTrackId, {
- type: "text",
- name: dragData.name || "Text",
- content: dragData.content || "Default Text",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- fontSize: 48,
- fontFamily: "Arial",
- color: "#ffffff",
- backgroundColor: "transparent",
- textAlign: "center",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- x: 0,
- y: 0,
- rotation: 0,
- opacity: 1,
- });
+ useTimelineStore.getState().addTextToNewTrack(dragData);
} else {
// Handle media items
const mediaItem = mediaItems.find((item) => item.id === dragData.id);
@@ -395,19 +385,7 @@ export function Timeline() {
return;
}
- const trackType = dragData.type === "audio" ? "audio" : "media";
- let targetTrack = tracks.find((t) => t.type === trackType);
- const newTrackId = targetTrack ? targetTrack.id : addTrack(trackType);
-
- addElementToTrack(newTrackId, {
- type: "media",
- mediaId: mediaItem.id,
- name: mediaItem.name,
- duration: mediaItem.duration || 5,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
+ useTimelineStore.getState().addMediaToNewTrack(mediaItem);
}
} catch (error) {
console.error("Error parsing dropped item data:", error);
@@ -435,18 +413,7 @@ export function Timeline() {
item.name === processedItem.name && item.url === processedItem.url
);
if (addedItem) {
- const trackType =
- processedItem.type === "audio" ? "audio" : "media";
- const newTrackId = addTrack(trackType);
- addElementToTrack(newTrackId, {
- type: "media",
- mediaId: addedItem.id,
- name: addedItem.name,
- duration: addedItem.duration || 5,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
+ useTimelineStore.getState().addMediaToNewTrack(addedItem);
}
}
} catch (error) {
@@ -686,136 +653,168 @@ export function Timeline() {
onMouseLeave={() => setIsInTimeline(false)}
>
{/* Toolbar */}
- <div className="border-b flex items-center px-2 py-1 gap-1">
- <TooltipProvider delayDuration={500}>
- {/* Play/Pause Button */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={toggle}
- className="mr-2"
- >
- {isPlaying ? (
- <Pause className="h-4 w-4" />
- ) : (
- <Play className="h-4 w-4" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- {isPlaying ? "Pause (Space)" : "Play (Space)"}
- </TooltipContent>
- </Tooltip>
- <div className="w-px h-6 bg-border mx-1" />
- {/* Time Display */}
- <div
- className="text-xs text-muted-foreground font-mono px-2"
- style={{ minWidth: "18ch", textAlign: "center" }}
- >
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
- </div>
- {/* Test Clip Button - for debugging */}
- {tracks.length === 0 && (
- <>
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- const trackId = addTrack("media");
- addElementToTrack(trackId, {
- type: "media",
- mediaId: "test",
- name: "Test Clip",
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- }}
- className="text-xs"
- >
- Add Test Clip
- </Button>
- </TooltipTrigger>
- <TooltipContent>Add a test clip to try playback</TooltipContent>
- </Tooltip>
- </>
- )}
- <div className="w-px h-6 bg-border mx-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleSplitSelected}>
- <Scissors className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split element (Ctrl+S)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepLeft}
- >
- <ArrowLeftToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleSplitAndKeepRight}
- >
- <ArrowRightToLine className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleSeparateAudio}>
- <SplitSquareHorizontal className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="text"
- size="icon"
- onClick={handleDuplicateSelected}
- >
- <Copy className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleFreezeSelected}>
- <Snowflake className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Freeze frame (F)</TooltipContent>
- </Tooltip>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button variant="text" size="icon" onClick={handleDeleteSelected}>
- <Trash2 className="h-4 w-4" />
- </Button>
- </TooltipTrigger>
- <TooltipContent>Delete element (Delete)</TooltipContent>
- </Tooltip>
- </TooltipProvider>
+ <div className="border-b flex items-center justify-between px-2 py-1">
+ <div className="flex items-center gap-1 w-full">
+ <TooltipProvider delayDuration={500}>
+ {/* Play/Pause Button */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={toggle}
+ className="mr-2"
+ >
+ {isPlaying ? (
+ <Pause className="h-4 w-4" />
+ ) : (
+ <Play className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {isPlaying ? "Pause (Space)" : "Play (Space)"}
+ </TooltipContent>
+ </Tooltip>
+ <div className="w-px h-6 bg-border mx-1" />
+ {/* Time Display */}
+ <div
+ className="text-xs text-muted-foreground font-mono px-2"
+ style={{ minWidth: "18ch", textAlign: "center" }}
+ >
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
+ </div>
+ {/* Test Clip Button - for debugging */}
+ {tracks.length === 0 && (
+ <>
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const trackId = addTrack("media");
+ addElementToTrack(trackId, {
+ type: "media",
+ mediaId: "test",
+ name: "Test Clip",
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }}
+ className="text-xs"
+ >
+ Add Test Clip
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ Add a test clip to try playback
+ </TooltipContent>
+ </Tooltip>
+ </>
+ )}
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitSelected}
+ >
+ <Scissors className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split element (Ctrl+S)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitAndKeepLeft}
+ >
+ <ArrowLeftToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSplitAndKeepRight}
+ >
+ <ArrowRightToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleSeparateAudio}
+ >
+ <SplitSquareHorizontal className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleDuplicateSelected}
+ >
+ <Copy className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Duplicate element (Ctrl+D)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleFreezeSelected}
+ >
+ <Snowflake className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Freeze frame (F)</TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleDeleteSelected}
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Delete element (Delete)</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ <div className="flex items-center gap-1">
+ <TooltipProvider delayDuration={500}>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={toggleSnapping}>
+ <Lock className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Auto snapping</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
</div>
{/* Timeline Container */}
@@ -835,6 +834,17 @@ export function Timeline() {
trackLabelsRef={trackLabelsRef}
timelineRef={timelineRef}
playheadRef={playheadRef}
+ isSnappingToPlayhead={
+ showSnapIndicator && currentSnapPoint?.type === "playhead"
+ }
+ />
+ <SnapIndicator
+ snapPoint={currentSnapPoint}
+ zoomLevel={zoomLevel}
+ tracks={tracks}
+ timelineRef={timelineRef}
+ trackLabelsRef={trackLabelsRef}
+ isVisible={showSnapIndicator}
/>
{/* Timeline Header with Ruler */}
<div className="flex bg-panel sticky top-0 z-10">
@@ -1016,6 +1026,7 @@ export function Timeline() {
<TimelineTrackContent
track={track}
zoomLevel={zoomLevel}
+ onSnapPointChange={handleSnapPointChange}
/>
</div>
</ContextMenuTrigger>
diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx
index 10f0e119..81505d4a 100644
--- a/apps/web/src/components/footer.tsx
+++ b/apps/web/src/components/footer.tsx
@@ -1,124 +1,140 @@
-"use client";
-
-import { motion } from "motion/react";
-import Link from "next/link";
-import { useEffect, useState } from "react";
-import { RiGithubLine, RiTwitterXLine } from "react-icons/ri";
-import { getStars } from "@/lib/fetch-github-stars";
-import Image from "next/image";
-
-export function Footer() {
- const [star, setStar] = useState<string>();
-
- useEffect(() => {
- const fetchStars = async () => {
- try {
- const data = await getStars();
- setStar(data);
- } catch (err) {
- console.error("Failed to fetch GitHub stars", err);
- }
- };
-
- fetchStars();
- }, []);
-
- return (
- <motion.footer
- className="bg-background border-t"
- initial={{ opacity: 0 }}
- animate={{ opacity: 1 }}
- transition={{ delay: 0.8, duration: 0.8 }}
- >
- <div className="max-w-5xl mx-auto px-8 py-10">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8">
- {/* Brand Section */}
- <div className="md:col-span-1 max-w-sm">
- <div className="flex items-center gap-2 mb-4">
- <Image src="/logo.svg" alt="OpenCut" width={24} height={24} />
- <span className="font-bold text-lg">OpenCut</span>
- </div>
- <p className="text-sm text-muted-foreground mb-5">
- The open source video editor that gets the job done. Simple,
- powerful, and works on any platform.
- </p>
- <div className="flex gap-3">
- <Link
- href="https://github.com/OpenCut-app/OpenCut"
- className="text-muted-foreground hover:text-foreground transition-colors"
- target="_blank"
- rel="noopener noreferrer"
- >
- <RiGithubLine className="h-5 w-5" />
- </Link>
- <Link
- href="https://x.com/OpenCutApp"
- className="text-muted-foreground hover:text-foreground transition-colors"
- target="_blank"
- rel="noopener noreferrer"
- >
- <RiTwitterXLine className="h-5 w-5" />
- </Link>
- </div>
- </div>
-
- <div className="flex gap-12 justify-end items-start py-2">
- <div>
- <h3 className="font-semibold text-foreground mb-4">Resources</h3>
- <ul className="space-y-2 text-sm">
- <li>
- <Link
- href="/privacy"
- className="text-muted-foreground hover:text-foreground transition-colors"
- >
- Privacy policy
- </Link>
- </li>
- <li>
- <Link
- href="/terms"
- className="text-muted-foreground hover:text-foreground transition-colors"
- >
- Terms of use
- </Link>
- </li>
- </ul>
- </div>
-
- {/* Company Links */}
- <div>
- <h3 className="font-semibold text-foreground mb-4">Company</h3>
- <ul className="space-y-2 text-sm">
- <li>
- <Link
- href="/contributors"
- className="text-muted-foreground hover:text-foreground transition-colors"
- >
- Contributors
- </Link>
- </li>
- <li>
- <Link
- href="https://github.com/OpenCut-app/OpenCut/blob/main/README.md"
- className="text-muted-foreground hover:text-foreground transition-colors"
- target="_blank"
- rel="noopener noreferrer"
- >
- About
- </Link>
- </li>
- </ul>
- </div>
- </div>
- </div>
-
- {/* Bottom Section */}
- <div className="pt-2 flex flex-col md:flex-row justify-between items-center gap-4">
- <div className="flex items-center gap-4 text-sm text-muted-foreground">
- <span>© 2025 OpenCut, All Rights Reserved</span>
- </div>
- </div>
- </div>
- </motion.footer>
- );
-}
+"use client";
+
+import { motion } from "motion/react";
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { RiDiscordFill, RiGithubLine, RiTwitterXLine } from "react-icons/ri";
+import { getStars } from "@/lib/fetch-github-stars";
+import Image from "next/image";
+
+export function Footer() {
+ const [star, setStar] = useState<string>();
+
+ useEffect(() => {
+ const fetchStars = async () => {
+ try {
+ const data = await getStars();
+ setStar(data);
+ } catch (err) {
+ console.error("Failed to fetch GitHub stars", err);
+ }
+ };
+
+ fetchStars();
+ }, []);
+
+ return (
+ <motion.footer
+ className="bg-background border-t"
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ transition={{ delay: 0.8, duration: 0.8 }}
+ >
+ <div className="max-w-5xl mx-auto px-8 py-10">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8">
+ {/* Brand Section */}
+ <div className="md:col-span-1 max-w-sm">
+ <div className="flex items-center gap-2 mb-4">
+ <Image src="/logo.svg" alt="OpenCut" width={24} height={24} />
+ <span className="font-bold text-lg">OpenCut</span>
+ </div>
+ <p className="text-sm text-muted-foreground mb-5">
+ The open source video editor that gets the job done. Simple,
+ powerful, and works on any platform.
+ </p>
+ <div className="flex gap-3">
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <RiGithubLine className="h-5 w-5" />
+ </Link>
+ <Link
+ href="https://x.com/OpenCutApp"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <RiTwitterXLine className="h-5 w-5" />
+ </Link>
+ <Link
+ href="https://discord.com/invite/Mu3acKZvCp"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <RiDiscordFill className="h-5 w-5" />
+ </Link>
+ </div>
+ </div>
+
+ <div className="flex gap-12 justify-end items-start py-2">
+ <div>
+ <h3 className="font-semibold text-foreground mb-4">Resources</h3>
+ <ul className="space-y-2 text-sm">
+ <li>
+ <Link
+ href="/roadmap"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ >
+ Roadmap
+ </Link>
+ </li>
+ <li>
+ <Link
+ href="/privacy"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ >
+ Privacy policy
+ </Link>
+ </li>
+ <li>
+ <Link
+ href="/terms"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ >
+ Terms of use
+ </Link>
+ </li>
+ </ul>
+ </div>
+
+ {/* Company Links */}
+ <div>
+ <h3 className="font-semibold text-foreground mb-4">Company</h3>
+ <ul className="space-y-2 text-sm">
+ <li>
+ <Link
+ href="/contributors"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ >
+ Contributors
+ </Link>
+ </li>
+ <li>
+ <Link
+ href="https://github.com/OpenCut-app/OpenCut/blob/main/README.md"
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ About
+ </Link>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+
+ {/* Bottom Section */}
+ <div className="pt-2 flex flex-col md:flex-row justify-between items-center gap-4">
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <span>© 2025 OpenCut, All Rights Reserved</span>
+ </div>
+ </div>
+ </div>
+ </motion.footer>
+ );
+}
diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx
index 16518dde..c1f81e16 100644
--- a/apps/web/src/components/ui/draggable-item.tsx
+++ b/apps/web/src/components/ui/draggable-item.tsx
@@ -6,12 +6,14 @@ import { ReactNode, useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
+import { usePlaybackStore } from "@/stores/playback-store";
export interface DraggableMediaItemProps {
name: string;
preview: ReactNode;
dragData: Record<string, any>;
onDragStart?: (e: React.DragEvent) => void;
+ onAddToTimeline?: (currentTime: number) => void;
aspectRatio?: number;
className?: string;
showPlusOnDrag?: boolean;
@@ -24,6 +26,7 @@ export function DraggableMediaItem({
preview,
dragData,
onDragStart,
+ onAddToTimeline,
aspectRatio = 16 / 9,
className = "",
showPlusOnDrag = true,
@@ -33,6 +36,11 @@ export function DraggableMediaItem({
const [isDragging, setIsDragging] = useState(false);
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const dragRef = useRef<HTMLDivElement>(null);
+ const currentTime = usePlaybackStore((state) => state.currentTime);
+
+ const handleAddToTimeline = () => {
+ onAddToTimeline?.(currentTime);
+ };
const emptyImg = new window.Image();
emptyImg.src =
@@ -92,7 +100,10 @@ export function DraggableMediaItem({
>
{preview}
{!isDragging && (
- <PlusButton className="opacity-0 group-hover:opacity-100" />
+ <PlusButton
+ className="opacity-0 group-hover:opacity-100"
+ onClick={handleAddToTimeline}
+ />
)}
</AspectRatio>
{showLabel && (
@@ -128,7 +139,7 @@ export function DraggableMediaItem({
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
{preview}
</div>
- {showPlusOnDrag && <PlusButton />}
+ {showPlusOnDrag && <PlusButton onClick={handleAddToTimeline} tooltipText="Add to timeline or drag to position" />}
</AspectRatio>
</div>
</div>,
@@ -138,11 +149,16 @@ export function DraggableMediaItem({
);
}
-function PlusButton({ className }: { className?: string }) {
+function PlusButton({ className, onClick }: { className?: string; onClick?: () => void }) {
return (
<Button
size="icon"
className={cn("absolute bottom-2 right-2 size-4", className)}
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onClick?.();
+ }}
>
<Plus className="!size-3" />
</Button>
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx
index a2fb0a01..0da023e6 100644
--- a/apps/web/src/components/ui/tooltip.tsx
+++ b/apps/web/src/components/ui/tooltip.tsx
@@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
- "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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",
+ "z-50 overflow-hidden rounded-md bg-foreground/10 px-3 py-1.5 text-xs text-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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",
className
)}
{...props}
diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts
index 92045c5e..8c76a2e8 100644
--- a/apps/web/src/constants/timeline-constants.ts
+++ b/apps/web/src/constants/timeline-constants.ts
@@ -74,6 +74,7 @@ export const TIMELINE_CONSTANTS = {
PIXELS_PER_SECOND: 50,
TRACK_HEIGHT: 60, // Default fallback
DEFAULT_TEXT_DURATION: 5,
+ DEFAULT_IMAGE_DURATION: 5,
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
} as const;
diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts
new file mode 100644
index 00000000..8071107e
--- /dev/null
+++ b/apps/web/src/env.ts
@@ -0,0 +1,27 @@
+import { vercel } from "@t3-oss/env-core/presets-zod";
+import { createEnv } from "@t3-oss/env-nextjs";
+import { z } from "zod";
+import { keys as auth } from "@opencut/auth/keys";
+import { keys as db } from "@opencut/db/keys";
+
+export const env = createEnv({
+ extends: [vercel(), auth(), db()],
+ server: {
+ ANALYZE: z.string().optional(),
+ // Added by Vercel
+ NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(),
+ NODE_ENV: z
+ .enum(["development", "production", "test"])
+ .default("development"),
+ UPSTASH_REDIS_REST_URL: z.string().url(),
+ UPSTASH_REDIS_REST_TOKEN: z.string(),
+ },
+ client: {},
+ runtimeEnv: {
+ ANALYZE: process.env.ANALYZE,
+ NEXT_RUNTIME: process.env.NEXT_RUNTIME,
+ NODE_ENV: process.env.NODE_ENV,
+ UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
+ UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
+ },
+});
diff --git a/apps/web/src/hooks/use-timeline-snapping.ts b/apps/web/src/hooks/use-timeline-snapping.ts
new file mode 100644
index 00000000..f7f96392
--- /dev/null
+++ b/apps/web/src/hooks/use-timeline-snapping.ts
@@ -0,0 +1,178 @@
+import { useCallback } from "react";
+import { TimelineTrack } from "@/types/timeline";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+export interface SnapPoint {
+ time: number;
+ type: "element-start" | "element-end" | "playhead";
+ elementId?: string;
+ trackId?: string;
+}
+
+export interface SnapResult {
+ snappedTime: number;
+ snapPoint: SnapPoint | null;
+ snapDistance: number;
+}
+
+export interface UseTimelineSnappingOptions {
+ snapThreshold?: number; // Distance in pixels to trigger snapping
+ enableElementSnapping?: boolean;
+ enablePlayheadSnapping?: boolean;
+}
+
+export function useTimelineSnapping({
+ snapThreshold = 10,
+ enableElementSnapping = true,
+ enablePlayheadSnapping = true,
+}: UseTimelineSnappingOptions = {}) {
+ const findSnapPoints = useCallback(
+ (
+ tracks: TimelineTrack[],
+ currentTime: number,
+ playheadTime: number,
+ zoomLevel: number,
+ excludeElementId?: string
+ ): SnapPoint[] => {
+ const snapPoints: SnapPoint[] = [];
+
+ // Add element snap points
+ if (enableElementSnapping) {
+ tracks.forEach((track) => {
+ track.elements.forEach((element) => {
+ // Skip the element being dragged
+ if (element.id === excludeElementId) return;
+
+ const elementStart = element.startTime;
+ const elementEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ snapPoints.push(
+ {
+ time: elementStart,
+ type: "element-start",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ {
+ time: elementEnd,
+ type: "element-end",
+ elementId: element.id,
+ trackId: track.id,
+ }
+ );
+ });
+ });
+ }
+
+ // Add playhead snap point
+ if (enablePlayheadSnapping) {
+ snapPoints.push({
+ time: playheadTime,
+ type: "playhead",
+ });
+ }
+
+ return snapPoints;
+ },
+ [enableElementSnapping, enablePlayheadSnapping]
+ );
+
+ const snapToNearestPoint = useCallback(
+ (
+ targetTime: number,
+ snapPoints: SnapPoint[],
+ zoomLevel: number
+ ): SnapResult => {
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const thresholdInSeconds = snapThreshold / pixelsPerSecond;
+
+ let closestSnapPoint: SnapPoint | null = null;
+ let closestDistance = Infinity;
+
+ snapPoints.forEach((snapPoint) => {
+ const distance = Math.abs(targetTime - snapPoint.time);
+ if (distance < thresholdInSeconds && distance < closestDistance) {
+ closestDistance = distance;
+ closestSnapPoint = snapPoint;
+ }
+ });
+
+ return {
+ snappedTime: closestSnapPoint
+ ? (closestSnapPoint as SnapPoint).time
+ : targetTime,
+ snapPoint: closestSnapPoint,
+ snapDistance: closestDistance,
+ };
+ },
+ [snapThreshold]
+ );
+
+ const snapElementPosition = useCallback(
+ (
+ targetTime: number,
+ tracks: TimelineTrack[],
+ playheadTime: number,
+ zoomLevel: number,
+ excludeElementId?: string
+ ): SnapResult => {
+ const snapPoints = findSnapPoints(
+ tracks,
+ targetTime,
+ playheadTime,
+ zoomLevel,
+ excludeElementId
+ );
+
+ return snapToNearestPoint(targetTime, snapPoints, zoomLevel);
+ },
+ [findSnapPoints, snapToNearestPoint]
+ );
+
+ const snapElementEdge = useCallback(
+ (
+ targetTime: number,
+ elementDuration: number,
+ tracks: TimelineTrack[],
+ playheadTime: number,
+ zoomLevel: number,
+ excludeElementId?: string,
+ snapToStart = true // true for start edge, false for end edge
+ ): SnapResult => {
+ const snapPoints = findSnapPoints(
+ tracks,
+ targetTime,
+ playheadTime,
+ zoomLevel,
+ excludeElementId
+ );
+
+ // For end edge snapping, we need to account for element duration
+ const effectiveTargetTime = snapToStart
+ ? targetTime
+ : targetTime + elementDuration;
+ const snapResult = snapToNearestPoint(
+ effectiveTargetTime,
+ snapPoints,
+ zoomLevel
+ );
+
+ // Adjust the snapped time back for end edge
+ if (!snapToStart && snapResult.snapPoint) {
+ snapResult.snappedTime = snapResult.snappedTime - elementDuration;
+ }
+
+ return snapResult;
+ },
+ [findSnapPoints, snapToNearestPoint]
+ );
+
+ return {
+ snapElementPosition,
+ snapElementEdge,
+ findSnapPoints,
+ snapToNearestPoint,
+ };
+}
diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts
index bf1cce02..92e78e74 100644
--- a/apps/web/src/lib/rate-limit.ts
+++ b/apps/web/src/lib/rate-limit.ts
@@ -1,10 +1,11 @@
// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
+import { env } from "@/env";
const redis = new Redis({
- url: process.env.UPSTASH_REDIS_REST_URL!,
- token: process.env.UPSTASH_REDIS_REST_TOKEN!,
+ url: env.UPSTASH_REDIS_REST_URL,
+ token: env.UPSTASH_REDIS_REST_TOKEN,
});
export const waitlistRateLimit = new Ratelimit({
diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts
index bdce5cc1..57e1e808 100644
--- a/apps/web/src/middleware.ts
+++ b/apps/web/src/middleware.ts
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
+import { env } from "./env";
export async function middleware(request: NextRequest) {
// Handle fuckcapcut.com domain redirect
@@ -9,7 +10,7 @@ export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
- if (path === "/editor" && process.env.NODE_ENV === "production") {
+ if (path === "/editor" && env.NODE_ENV === "production") {
const homeUrl = new URL("/", request.url);
homeUrl.searchParams.set("redirect", request.url);
return NextResponse.redirect(homeUrl);
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 37b45b4a..9a2c7361 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -5,15 +5,22 @@ import {
CreateTimelineElement,
TimelineTrack,
TextElement,
+ DragData,
sortTracksByOrder,
ensureMainTrack,
validateElementTrackCompatibility,
} from "@/types/timeline";
import { useEditorStore } from "./editor-store";
-import { useMediaStore, getMediaAspectRatio } from "./media-store";
+import {
+ useMediaStore,
+ getMediaAspectRatio,
+ type MediaItem,
+} from "./media-store";
import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
import { generateUUID } from "@/lib/utils";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { toast } from "sonner";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
@@ -42,6 +49,12 @@ interface TimelineStore {
// Manual method if you need to force recomputation
getSortedTracks: () => TimelineTrack[];
+ // Snapping settings
+ snappingEnabled: boolean;
+
+ // Snapping actions
+ toggleSnapping: () => void;
+
// Multi-selection
selectedElements: { trackId: string; elementId: string }[];
selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
@@ -160,6 +173,17 @@ interface TimelineStore {
>
>
) => void;
+ checkElementOverlap: (
+ trackId: string,
+ startTime: number,
+ duration: number,
+ excludeElementId?: string
+ ) => boolean;
+ findOrCreateTrack: (trackType: TrackType) => string;
+ addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean;
+ addTextAtTime: (item: TextElement, currentTime?: number) => boolean;
+ addMediaToNewTrack: (item: MediaItem) => boolean;
+ addTextToNewTrack: (item: TextElement | DragData) => boolean;
}
export const useTimelineStore = create<TimelineStore>((set, get) => {
@@ -203,6 +227,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
redoStack: [],
selectedElements: [],
+ // Snapping settings defaults
+ snappingEnabled: true,
+
getSortedTracks: () => {
const { _tracks } = get();
const tracksWithMain = ensureMainTrack(_tracks);
@@ -949,5 +976,154 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
updateTracks(defaultTracks);
set({ history: [], redoStack: [], selectedElements: [] });
},
+
+ // Snapping actions
+ toggleSnapping: () => {
+ set((state) => ({ snappingEnabled: !state.snappingEnabled }));
+ },
+
+ checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
+ const track = get()._tracks.find((t) => t.id === trackId);
+ if (!track) return false;
+
+ const overlap = track.elements.some((element) => {
+ const elementEnd =
+ element.startTime +
+ element.duration -
+ element.trimStart -
+ element.trimEnd;
+
+ if (element.id === excludeElementId) {
+ return false;
+ }
+
+ return (
+ (startTime >= element.startTime && startTime < elementEnd) ||
+ (startTime + duration > element.startTime &&
+ startTime + duration <= elementEnd) ||
+ (startTime < element.startTime && startTime + duration > elementEnd)
+ );
+ });
+ return overlap;
+ },
+
+ findOrCreateTrack: (trackType) => {
+ // Always create new text track to allow multiple text elements
+ if (trackType === "text") {
+ return get().addTrack(trackType);
+ }
+
+ const existingTrack = get()._tracks.find((t) => t.type === trackType);
+ if (existingTrack) {
+ return existingTrack.id;
+ }
+
+ return get().addTrack(trackType);
+ },
+
+ addMediaAtTime: (item, currentTime = 0) => {
+ const trackType = item.type === "audio" ? "audio" : "media";
+ const targetTrackId = get().findOrCreateTrack(trackType);
+
+ const duration =
+ item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
+
+ if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
+ toast.error(
+ "Cannot place element here - it would overlap with existing elements"
+ );
+ return false;
+ }
+
+ get().addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: item.id,
+ name: item.name,
+ duration,
+ startTime: currentTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ return true;
+ },
+
+ addTextAtTime: (item, currentTime = 0) => {
+ const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
+
+ get().addElementToTrack(targetTrackId, {
+ type: "text",
+ name: item.name || "Text",
+ content: item.content || "Default Text",
+ duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: currentTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: item.fontSize || 48,
+ fontFamily: item.fontFamily || "Arial",
+ color: item.color || "#ffffff",
+ backgroundColor: item.backgroundColor || "transparent",
+ textAlign: item.textAlign || "center",
+ fontWeight: item.fontWeight || "normal",
+ fontStyle: item.fontStyle || "normal",
+ textDecoration: item.textDecoration || "none",
+ x: item.x || 0,
+ y: item.y || 0,
+ rotation: item.rotation || 0,
+ opacity: item.opacity !== undefined ? item.opacity : 1,
+ });
+ return true;
+ },
+
+ addMediaToNewTrack: (item) => {
+ const trackType = item.type === "audio" ? "audio" : "media";
+ const targetTrackId = get().findOrCreateTrack(trackType);
+
+ get().addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: item.id,
+ name: item.name,
+ duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ return true;
+ },
+
+ addTextToNewTrack: (item) => {
+ const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
+
+ get().addElementToTrack(targetTrackId, {
+ type: "text",
+ name: item.name || "Text",
+ content:
+ ("content" in item ? item.content : "Default Text") || "Default Text",
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: ("fontSize" in item ? item.fontSize : 48) || 48,
+ fontFamily:
+ ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial",
+ color: ("color" in item ? item.color : "#ffffff") || "#ffffff",
+ backgroundColor:
+ ("backgroundColor" in item ? item.backgroundColor : "transparent") ||
+ "transparent",
+ textAlign:
+ ("textAlign" in item ? item.textAlign : "center") || "center",
+ fontWeight:
+ ("fontWeight" in item ? item.fontWeight : "normal") || "normal",
+ fontStyle:
+ ("fontStyle" in item ? item.fontStyle : "normal") || "normal",
+ textDecoration:
+ ("textDecoration" in item ? item.textDecoration : "none") || "none",
+ x: ("x" in item ? item.x : 0) || 0,
+ y: ("y" in item ? item.y : 0) || 0,
+ rotation: ("rotation" in item ? item.rotation : 0) || 0,
+ opacity:
+ "opacity" in item && item.opacity !== undefined ? item.opacity : 1,
+ });
+ return true;
+ },
};
});