diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 995dbf19..77d7a6b6 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -15,6 +15,27 @@ Thank you for your interest in contributing to OpenCut! This document provides g > 1. Upgrade to a recent npm version (v9 or later), which has full workspace protocol support. > 2. Use an alternative package manager such as **bun** or **pnpm**. +## What to Focus On + +**🎯 Good Areas to Contribute:** +- Timeline functionality and UI improvements +- Project management features +- Performance optimizations +- Bug fixes in existing functionality +- UI/UX improvements +- Documentation and testing + +**⚠️ Areas to Avoid:** +- Preview panel enhancements (text fonts, stickers, effects) +- Export functionality improvements +- Preview rendering optimizations + +**Why?** We're currently planning a major refactor of the preview system. The current preview renders DOM elements (HTML), but we're moving to a binary rendering approach similar to CapCut. This new system will ensure consistency between preview and export, and provide much better performance and quality. + +The current HTML-based preview is essentially a prototype - the binary approach will be the "real deal." To avoid wasted effort, please focus on other areas of the application until this refactor is complete. + +If you're unsure whether your idea falls into the preview category, feel free to ask us [directly in discord](https://discord.gg/zmR9N35cjK) or create a GitHub issue! + ## Development Setup ### Prerequisites diff --git a/.github/workflows/bun-ci.yml b/.github/workflows/bun-ci.yml index dbcf6f9c..f01b9878 100644 --- a/.github/workflows/bun-ci.yml +++ b/.github/workflows/bun-ci.yml @@ -23,6 +23,10 @@ jobs: env: DATABASE_URL: "postgresql://opencut:opencutthegoat@localhost:5432/opencut" + BETTER_AUTH_SECRET: "supersecret" + NEXT_PUBLIC_BETTER_AUTH_URL: "http://localhost:3000" + UPSTASH_REDIS_REST_URL: "https://your-upstash-redis-url" + UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token" steps: - name: Checkout repository diff --git a/README.md b/README.md index 9ce027f9..ad9a6d38 100644 --- a/README.md +++ b/README.md @@ -129,11 +129,13 @@ The application will be available at [http://localhost:3000](http://localhost:30 ## Contributing -**Note**: We're currently moving at an extremely fast pace with rapid development and breaking changes. While we appreciate the interest, it's recommended to wait until the project stabilizes before contributing to avoid conflicts and wasted effort. +We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively. -## Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md) +**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel. -We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines. +**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach. + +See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance. **Quick start for contributors:** diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index d9d8a08c..4be4d9cc 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { ResizablePanelGroup, @@ -17,6 +17,7 @@ import { useProjectStore } from "@/stores/project-store"; import { EditorProvider } from "@/components/editor-provider"; import { usePlaybackControls } from "@/hooks/use-playback-controls"; import { useDisableBrowserZoom } from "@/hooks/use-disable-browser-zoom"; +import { Onboarding } from "@/components/onboarding"; export default function Editor() { const { @@ -37,13 +38,13 @@ export default function Editor() { const router = useRouter(); const projectId = params.project_id as string; const handledProjectIds = useRef>(new Set()); + const [isOnboardingOpen, setIsOnboardingOpen] = useState(true); useDisableBrowserZoom(); usePlaybackControls(); useEffect(() => { const initProject = async () => { - if (!projectId) return; if (activeProject?.id === projectId) { diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 894b6257..2ecee391 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -347,7 +347,7 @@ export function PreviewPanel() { {hasAnyElements ? (
{ + if (!snappingEnabled) { + // Use frame snapping if project has FPS, otherwise use decimal snapping + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + return snapTimeToFrame(dropTime, projectFps); + } + + // Try snapping both start and end edges for drops + const startSnapResult = snapElementEdge( + dropTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + excludeElementId, + true // snap to start edge + ); + + const endSnapResult = snapElementEdge( + dropTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + excludeElementId, + false // snap to end edge + ); + + // Choose the snap result with the smaller distance (closer snap) + let bestSnapResult = startSnapResult; + if ( + endSnapResult.snapPoint && + (!startSnapResult.snapPoint || + endSnapResult.snapDistance < startSnapResult.snapDistance) + ) { + bestSnapResult = endSnapResult; + } + + return bestSnapResult.snappedTime; + }; + const timelineRef = useRef(null); const [isDropping, setIsDropping] = useState(false); const [dropPosition, setDropPosition] = useState(null); @@ -103,15 +150,52 @@ export function TimelineTrackContent({ let finalTime = adjustedTime; let snapPoint = null; if (snappingEnabled) { - const snapResult = snapElementPosition( + // Find the element being dragged to get its duration + let elementDuration = 5; // fallback duration + if (dragState.elementId && dragState.trackId) { + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const element = sourceTrack?.elements.find( + (e) => e.id === dragState.elementId + ); + if (element) { + elementDuration = + element.duration - element.trimStart - element.trimEnd; + } + } + + // Try snapping both start and end edges + const startSnapResult = snapElementEdge( adjustedTime, + elementDuration, tracks, currentTime, zoomLevel, - dragState.elementId || undefined + dragState.elementId || undefined, + true // snap to start edge ); - finalTime = snapResult.snappedTime; - snapPoint = snapResult.snapPoint; + + const endSnapResult = snapElementEdge( + adjustedTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + dragState.elementId || undefined, + false // snap to end edge + ); + + // Choose the snap result with the smaller distance (closer snap) + let bestSnapResult = startSnapResult; + if ( + endSnapResult.snapPoint && + (!startSnapResult.snapPoint || + endSnapResult.snapDistance < startSnapResult.snapDistance) + ) { + bestSnapResult = endSnapResult; + } + + finalTime = bestSnapResult.snappedTime; + snapPoint = bestSnapResult.snapPoint; // Notify parent component about snap point change onSnapPointChange?.(snapPoint); @@ -390,9 +474,10 @@ export function TimelineTrackContent({ if (dragData.type === "text") { // Text elements have default duration of 5 seconds const newElementDuration = 5; - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - const snappedTime = snapTimeToFrame(dropTime, projectFps); + const snappedTime = getDropSnappedTime( + dropTime, + newElementDuration + ); const newElementEnd = snappedTime + newElementDuration; wouldOverlap = track.elements.some((existingElement) => { @@ -411,9 +496,10 @@ export function TimelineTrackContent({ ); if (mediaItem) { const newElementDuration = mediaItem.duration || 5; - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - const snappedTime = snapTimeToFrame(dropTime, projectFps); + const snappedTime = getDropSnappedTime( + dropTime, + newElementDuration + ); const newElementEnd = snappedTime + newElementDuration; wouldOverlap = track.elements.some((existingElement) => { @@ -453,9 +539,11 @@ export function TimelineTrackContent({ movingElement.duration - movingElement.trimStart - movingElement.trimEnd; - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - const snappedTime = snapTimeToFrame(dropTime, projectFps); + const snappedTime = getDropSnappedTime( + dropTime, + movingElementDuration, + elementId + ); const movingElementEnd = snappedTime + movingElementDuration; wouldOverlap = track.elements.some((existingElement) => { @@ -482,17 +570,15 @@ export function TimelineTrackContent({ if (wouldOverlap) { e.dataTransfer.dropEffect = "none"; setWouldOverlap(true); - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - setDropPosition(snapTimeToFrame(dropTime, projectFps)); + // Use default duration for position indicator + setDropPosition(getDropSnappedTime(dropTime, 5)); return; } e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy"; setWouldOverlap(false); - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - setDropPosition(snapTimeToFrame(dropTime, projectFps)); + // Use default duration for position indicator + setDropPosition(getDropSnappedTime(dropTime, 5)); }; const handleTrackDragEnter = (e: React.DragEvent) => { @@ -614,18 +700,20 @@ export function TimelineTrackContent({ return; } - // Adjust position based on where user clicked on the element - const adjustedStartTime = snappedTime - clickOffsetTime; - const finalStartTime = Math.max( - 0, - snapTimeToFrame(adjustedStartTime, projectFps) - ); - // Check for overlaps with existing elements (excluding the moving element itself) const movingElementDuration = movingElement.duration - movingElement.trimStart - movingElement.trimEnd; + + // Adjust position based on where user clicked on the element + const adjustedStartTime = newStartTime - clickOffsetTime; + const snappedStartTime = getDropSnappedTime( + adjustedStartTime, + movingElementDuration, + elementId + ); + const finalStartTime = Math.max(0, snappedStartTime); const movingElementEnd = finalStartTime + movingElementDuration; const hasOverlap = track.elements.some((existingElement) => { @@ -711,7 +799,11 @@ export function TimelineTrackContent({ // Check for overlaps with existing elements in target track const newElementDuration = 5; // Default text duration - const newElementEnd = snappedTime + newElementDuration; + const textSnappedTime = getDropSnappedTime( + newStartTime, + newElementDuration + ); + const newElementEnd = textSnappedTime + newElementDuration; const hasOverlap = targetTrack.elements.some((existingElement) => { const existingStart = existingElement.startTime; @@ -722,7 +814,9 @@ export function TimelineTrackContent({ existingElement.trimEnd); // Check if elements overlap - return snappedTime < existingEnd && newElementEnd > existingStart; + return ( + textSnappedTime < existingEnd && newElementEnd > existingStart + ); }); if (hasOverlap) { @@ -737,7 +831,7 @@ export function TimelineTrackContent({ name: dragData.name || "Text", content: dragData.content || "Default Text", duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: snappedTime, + startTime: textSnappedTime, trimStart: 0, trimEnd: 0, fontSize: 48, @@ -857,7 +951,11 @@ export function TimelineTrackContent({ // Check for overlaps with existing elements in target track const newElementDuration = mediaItem.duration || 5; - const newElementEnd = snappedTime + newElementDuration; + const mediaSnappedTime = getDropSnappedTime( + newStartTime, + newElementDuration + ); + const newElementEnd = mediaSnappedTime + newElementDuration; const hasOverlap = targetTrack.elements.some((existingElement) => { const existingStart = existingElement.startTime; @@ -868,7 +966,9 @@ export function TimelineTrackContent({ existingElement.trimEnd); // Check if elements overlap - return snappedTime < existingEnd && newElementEnd > existingStart; + return ( + mediaSnappedTime < existingEnd && newElementEnd > existingStart + ); }); if (hasOverlap) { @@ -883,7 +983,7 @@ export function TimelineTrackContent({ mediaId: mediaItem.id, name: mediaItem.name, duration: mediaItem.duration || 5, - startTime: snappedTime, + startTime: mediaSnappedTime, trimStart: 0, trimEnd: 0, }); diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index 81505d4a..5b7f2303 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -34,15 +34,15 @@ export function Footer() {
{/* Brand Section */}
-
+
OpenCut OpenCut
-

+

The open source video editor that gets the job done. Simple, powerful, and works on any platform.

-
+
-
+

Resources

    @@ -129,7 +129,7 @@ export function Footer() {
{/* Bottom Section */} -
+
© 2025 OpenCut, All Rights Reserved
diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx index 4f3986e3..68e8f0ff 100644 --- a/apps/web/src/components/icons.tsx +++ b/apps/web/src/components/icons.tsx @@ -38,6 +38,14 @@ export function GithubIcon({ className }: { className?: string }) { ); } +export function VercelIcon({ className }: { className?: string }) { + return ( + + + + ); +} + export function BackgroundIcon({ className }: { className?: string }) { return ( { - // Replace common key names with symbols + // Replace common key names with symbols or friendly names const displayKey = keyName .replace("Cmd", "⌘") - .replace("Shift", "⇧") + .replace("Shift", "Shift") + .replace("ArrowLeft", "Arrow Left") + .replace("ArrowRight", "Arrow Right") + .replace("ArrowUp", "Arrow Up") + .replace("ArrowDown", "Arrow Down") .replace("←", "◀") .replace("→", "▶") - .replace("Space", "⎵"); + .replace("Space", "Space"); return ( - + {displayKey} ); }; -const ShortcutItem = ({ shortcut }: { shortcut: any }) => ( -
-
- {shortcut.icon && ( -
{shortcut.icon}
- )} - {shortcut.description} +const ShortcutItem = ({ shortcut }: { shortcut: any }) => { + // Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J" + const displayKeys = shortcut.keys.filter((key: string) => { + const lowerKey = key.toLowerCase(); + const upperKey = key.toUpperCase(); + + // If this is a lowercase letter and the uppercase version exists, skip it + if ( + key === lowerKey && + key !== upperKey && + shortcut.keys.includes(upperKey) + ) { + return false; + } + + return true; + }); + + return ( +
+
+ {shortcut.icon && ( +
{shortcut.icon}
+ )} + {shortcut.description} +
+
+ {displayKeys.map((key: string, index: number) => ( +
+
+ {key.split("+").map((keyPart: string, partIndex: number) => ( +
+ + {partIndex < key.split("+").length - 1 && ( + + + )} +
+ ))} +
+ {index < displayKeys.length - 1 && ( + or + )} +
+ ))} +
-
- {shortcut.keys.map((key: string, index: number) => ( -
- - {index < shortcut.keys.length - 1 && ( - + - )} -
- ))} -
-
-); + ); +}; export const KeyboardShortcutsHelp = () => { const [open, setOpen] = useState(false); @@ -67,7 +99,7 @@ export const KeyboardShortcutsHelp = () => { Shortcuts - + @@ -81,11 +113,11 @@ export const KeyboardShortcutsHelp = () => {
{categories.map((category) => ( -
-

+
+

{category}

-
+
{shortcuts .filter((shortcut) => shortcut.category === category) .map((shortcut, index) => ( @@ -95,18 +127,6 @@ export const KeyboardShortcutsHelp = () => {
))}
- -
-

Tips:

-
    -
  • - • Shortcuts work when the editor is focused (not typing in inputs) -
  • -
  • • J/K/L are industry-standard video editing shortcuts
  • -
  • • Use arrow keys for frame-perfect positioning
  • -
  • • Hold Shift with arrow keys for larger jumps
  • -
-
); diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 6e6a45cb..d5fe38d1 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -3,6 +3,8 @@ import { motion } from "motion/react"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { SponsorButton } from "../ui/sponsor-button"; +import { VercelIcon } from "../icons"; import { ArrowRight } from "lucide-react"; import { useState, useEffect } from "react"; import { toast } from "sonner"; @@ -113,6 +115,18 @@ export function Hero() { transition={{ duration: 1 }} className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center" > + + + void; +} + +export function Onboarding({ isOpen, onClose }: OnboardingProps) { + return ( + + + + Welcome to Clipture + + Let's get you started with the basics. + + + + + ); +} diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 0a487d0a..ebfd8907 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef< { + e.stopPropagation(); + e.preventDefault(); + }} + onOpenAutoFocus={(e) => { + e.stopPropagation(); + e.preventDefault(); + }} {...props} > diff --git a/apps/web/src/components/ui/sponsor-button.tsx b/apps/web/src/components/ui/sponsor-button.tsx new file mode 100644 index 00000000..79c708a2 --- /dev/null +++ b/apps/web/src/components/ui/sponsor-button.tsx @@ -0,0 +1,40 @@ +import { motion } from "motion/react"; +import { ComponentType } from "react"; + +interface SponsorButtonProps { + href: string; + logo: ComponentType<{ className?: string }>; + companyName: string; + className?: string; +} + +export function SponsorButton({ + href, + logo: Logo, + companyName, + className = "", +}: SponsorButtonProps) { + + return ( + + + Sponsored by + + +
+
+ +
+ + + {companyName} + +
+
+ ); +} diff --git a/apps/web/src/hooks/use-keyboard-shortcuts.ts b/apps/web/src/hooks/use-keyboard-shortcuts.ts index 86190c94..84cb78f8 100644 --- a/apps/web/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/web/src/hooks/use-keyboard-shortcuts.ts @@ -66,7 +66,6 @@ export const useKeyboardShortcuts = ( category: "Playback", action: () => { toggle(); - toast.info(isPlaying ? "Paused" : "Playing", { duration: 1000 }); }, }, { @@ -76,7 +75,6 @@ export const useKeyboardShortcuts = ( category: "Playback", action: () => { seek(Math.max(0, currentTime - 1)); - toast.info("Rewind 1s", { duration: 1000 }); }, }, { @@ -86,7 +84,6 @@ export const useKeyboardShortcuts = ( category: "Playback", action: () => { toggle(); - toast.info(isPlaying ? "Paused" : "Playing", { duration: 1000 }); }, }, { @@ -96,7 +93,6 @@ export const useKeyboardShortcuts = ( category: "Playback", action: () => { seek(Math.min(duration, currentTime + 1)); - toast.info("Forward 1s", { duration: 1000 }); }, }, @@ -146,7 +142,6 @@ export const useKeyboardShortcuts = ( category: "Navigation", action: () => { seek(0); - toast.info("Start of timeline", { duration: 1000 }); }, }, { @@ -156,7 +151,6 @@ export const useKeyboardShortcuts = ( category: "Navigation", action: () => { seek(duration); - toast.info("End of timeline", { duration: 1000 }); }, }, @@ -185,7 +179,6 @@ export const useKeyboardShortcuts = ( if (currentTime > effectiveStart && currentTime < effectiveEnd) { splitElement(trackId, elementId, currentTime); - toast.success("Element split at playhead"); } else { toast.error("Playhead must be within selected element"); } @@ -218,9 +211,6 @@ export const useKeyboardShortcuts = ( category: "Editing", action: () => { toggleSnapping(); - toast.info(`Snapping ${snappingEnabled ? "disabled" : "enabled"}`, { - duration: 1000, - }); }, }, @@ -238,9 +228,6 @@ export const useKeyboardShortcuts = ( })) ); setSelectedElements(allElements); - toast.info(`Selected ${allElements.length} elements`, { - duration: 1000, - }); }, }, { @@ -270,8 +257,6 @@ export const useKeyboardShortcuts = ( ...elementWithoutId, startTime: newStartTime, }); - - toast.success("Element duplicated"); } }, }, diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 1f229e6d..aabc302a 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -3,6 +3,8 @@ import type { NextRequest } from "next/server"; import { env } from "./env"; export async function middleware(request: NextRequest) { + const protectedPaths = ["/editor", "/projects"]; + // Handle fuckcapcut.com domain redirect if (request.headers.get("host") === "fuckcapcut.com") { return NextResponse.redirect("https://opencut.app/why-not-capcut", 301); @@ -10,7 +12,7 @@ export async function middleware(request: NextRequest) { const path = request.nextUrl.pathname; - if (path.startsWith("/editor") && env.NODE_ENV === "production") { + if (protectedPaths.includes(path) && env.NODE_ENV === "production") { const homeUrl = new URL("/", request.url); homeUrl.searchParams.set("redirect", request.url); return NextResponse.redirect(homeUrl);