Merge branch 'main' into main

This commit is contained in:
Pulkit Garg
2025-06-24 10:49:15 +05:30
committed by GitHub
20 changed files with 834 additions and 91 deletions

3
.gitignore vendored
View File

@ -19,3 +19,6 @@
# typescript # typescript
/apps/web/next-env.d.ts /apps/web/next-env.d.ts
/apps/web/yarn.lock /apps/web/yarn.lock
# asdf version management
.tool-versions

View File

@ -26,29 +26,74 @@ A free, open-source video editor for web, desktop, and mobile.
## Getting Started ## Getting Started
1. **Clone the repository:** ### Prerequisites
```bash
git clone <repo-url> Before you begin, ensure you have the following installed on your system:
cd OpenCut
``` - [Bun](https://bun.sh/docs/installation)
2. **Install dependencies:** - [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
```bash - [Node.js](https://nodejs.org/en/) (for `npm` alternative)
cd apps/web
npm install ### Setup
# or, with Bun
bun install 1. **Clone the repository**
``` ```bash
3. **Run the development server:** git clone <repo-url>
```bash cd OpenCut
npm run dev ```
# or, with Bun
bun run dev 2. **Start backend services**
``` From the project root, start the PostgreSQL and Redis services:
4. **Open in browser:** ```bash
Visit [http://localhost:3000](http://localhost:3000) docker-compose up -d
```
3. **Set up environment variables**
Navigate into the web app's directory and create a `.env` file from the example:
```bash
cd apps/web
cp .env.example .env
```
*The default values in the `.env` file should work for local development.*
4. **Install dependencies**
Install the project dependencies using `bun` (recommended) or `npm`.
```bash
# With bun
bun install
# Or with npm
npm install
```
5. **Run database migrations**
Apply the database schema to your local database:
```bash
# With bun
bun run db:push:local
# Or with npm
npm run db:push:local
```
6. **Start the development server**
```bash
# With bun
bun run dev
# Or with npm
npm run dev
```
The application will be available at [http://localhost:3000](http://localhost:3000).
=======
## Contributing ## Contributing
Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
=======
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines. We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
Quick start for contributors: Quick start for contributors:
@ -59,4 +104,4 @@ Quick start for contributors:
## License ## License
MIT [Details](LICENSE) [MIT LICENSE](LICENSE)

36
apps/web/Dockerfile Normal file
View File

@ -0,0 +1,36 @@
FROM oven/bun:latest AS base
# Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Build the application
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build
# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["bun", "server.js"]

BIN
apps/web/bun.lockb Executable file

Binary file not shown.

View File

@ -0,0 +1,176 @@
"use client";
import { useRouter } from "next/navigation";
import { signIn } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Suspense, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ArrowLeft, Loader2 } from "lucide-react";
import { GoogleIcon } from "@/components/icons";
function LoginForm() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const handleLogin = async () => {
setError(null);
setIsEmailLoading(true);
const { error } = await signIn.email({
email,
password,
});
if (error) {
setError(error.message || "An unexpected error occurred.");
setIsEmailLoading(false);
return;
}
router.push("/editor");
};
const handleGoogleLogin = async () => {
setError(null);
setIsGoogleLoading(true);
try {
await signIn.social({
provider: "google",
});
router.push("/editor");
} catch (error) {
setError("Failed to sign in with Google. Please try again.");
setIsGoogleLoading(false);
}
};
const isAnyLoading = isEmailLoading || isGoogleLoading;
return (
<div className="flex flex-col space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
onClick={handleGoogleLogin}
variant="outline"
size="lg"
disabled={isAnyLoading}
>
{isGoogleLoading ? (
<Loader2 className="animate-spin" />
) : (
<GoogleIcon />
)}{" "}
Continue with Google
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<Button
onClick={handleLogin}
disabled={isAnyLoading || !email || !password}
className="w-full h-11"
size="lg"
>
{isEmailLoading ? <Loader2 className="animate-spin" /> : "Sign in"}
</Button>
</div>
</div>
);
}
export default function LoginPage() {
const router = useRouter();
return (
<div className="flex h-screen items-center justify-center relative">
<Button
variant="ghost"
onClick={() => router.back()}
className="absolute top-6 left-6"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] shadow-lg border-0">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-semibold">Welcome back</CardTitle>
<CardDescription className="text-base">
Sign in to your account to continue
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Suspense
fallback={
<div className="text-center">
<Loader2 className="animate-spin" />
</div>
}
>
<LoginForm />
</Suspense>
<div className="mt-6 text-center text-sm">
Don't have an account?{" "}
<Link
href="/signup"
className="font-medium text-primary underline-offset-4 hover:underline"
>
Sign up
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,201 @@
"use client";
import { useRouter } from "next/navigation";
import { signUp, signIn } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Suspense, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Loader2, ArrowLeft } from "lucide-react";
import { GoogleIcon } from "@/components/icons";
function SignUpForm() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const handleSignUp = async () => {
setError(null);
setIsEmailLoading(true);
const { error } = await signUp.email({
name,
email,
password,
});
if (error) {
setError(error.message || "An unexpected error occurred.");
setIsEmailLoading(false);
return;
}
router.push("/login");
};
const handleGoogleSignUp = async () => {
setError(null);
setIsGoogleLoading(true);
try {
await signIn.social({
provider: "google",
});
router.push("/editor");
} catch (error) {
setError("Failed to sign up with Google. Please try again.");
setIsGoogleLoading(false);
}
};
const isAnyLoading = isEmailLoading || isGoogleLoading;
return (
<div className="flex flex-col space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
onClick={handleGoogleSignUp}
variant="outline"
size="lg"
disabled={isAnyLoading}
>
{isGoogleLoading ? (
<Loader2 className="animate-spin" />
) : (
<GoogleIcon />
)}{" "}
Continue with Google
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
type="text"
placeholder="John Doe"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Create a strong password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isAnyLoading}
className="h-11"
/>
</div>
<Button
onClick={handleSignUp}
disabled={isAnyLoading || !name || !email || !password}
className="w-full h-11"
size="lg"
>
{isEmailLoading ? (
<Loader2 className="animate-spin" />
) : (
"Create account"
)}
</Button>
</div>
</div>
);
}
export default function SignUpPage() {
const router = useRouter();
return (
<div className="flex h-screen items-center justify-center relative">
<Button
variant="ghost"
onClick={() => router.back()}
className="absolute top-6 left-6"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] shadow-lg border-0">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-semibold">
Create your account
</CardTitle>
<CardDescription className="text-base">
Get started with your free account today
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Suspense
fallback={
<div className="text-center">
<Loader2 className="animate-spin" />
</div>
}
>
<SignUpForm />
</Suspense>
<div className="mt-6 text-center text-sm">
Already have an account?{" "}
<Link
href="/login"
className="font-medium text-primary underline-offset-4 hover:underline"
>
Sign in
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,5 +0,0 @@
import { AuthForm } from "@/components/auth-form";
export default function LoginPage() {
return <AuthForm mode="login" />;
}

View File

@ -1,5 +0,0 @@
import { AuthForm } from "@/components/auth-form";
export default function SignUpPage() {
return <AuthForm mode="signup" />;
}

View File

@ -0,0 +1,4 @@
/* Prevent scroll jumping on Mac devices when using the editor */
body {
overflow: hidden;
}

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import "./editor.css";
import { import {
ResizablePanelGroup, ResizablePanelGroup,
ResizablePanel, ResizablePanel,

View File

@ -54,7 +54,7 @@ const authConfig = {
description: "Sign in to your account to continue", description: "Sign in to your account to continue",
buttonText: "Sign in", buttonText: "Sign in",
linkText: "Don't have an account?", linkText: "Don't have an account?",
linkHref: "/auth/signup", linkHref: "/signup",
linkLabel: "Sign up", linkLabel: "Sign up",
successRedirect: "/editor", successRedirect: "/editor",
}, },
@ -63,9 +63,9 @@ const authConfig = {
description: "Get started with your free account today", description: "Get started with your free account today",
buttonText: "Create account", buttonText: "Create account",
linkText: "Already have an account?", linkText: "Already have an account?",
linkHref: "/auth/login", linkHref: "/login",
linkLabel: "Sign in", linkLabel: "Sign in",
successRedirect: "/auth/login", successRedirect: "/login",
}, },
} as const; } as const;

View File

@ -38,7 +38,6 @@ export function PropertiesPanel() {
? mediaItems.find((item) => item.id === firstVideoClip.mediaId) ? mediaItems.find((item) => item.id === firstVideoClip.mediaId)
: null; : null;
// Get the first image clip for preview (simplified)
const firstImageClip = tracks const firstImageClip = tracks
.flatMap((track) => track.clips) .flatMap((track) => track.clips)
.find((clip) => { .find((clip) => {

View File

@ -40,7 +40,7 @@ export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips. // Timeline shows all tracks (video, audio, effects) and their clips.
// You can drag media here to add it to your project. // You can drag media here to add it to your project.
// Clips can be trimmed, deleted, and moved. // Clips can be trimmed, deleted, and moved.
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips } = const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips, updateClipTrim, undo, redo } =
useTimelineStore(); useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore(); const { mediaItems, addMediaItem } = useMediaStore();
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore(); const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore();
@ -102,6 +102,33 @@ export function Timeline() {
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedClips, removeClipFromTrack, clearSelectedClips]); }, [selectedClips, removeClipFromTrack, clearSelectedClips]);
// Keyboard event for undo (Cmd+Z)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
e.preventDefault();
undo();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [undo]);
// Keyboard event for redo (Cmd+Shift+Z or Cmd+Y)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) {
e.preventDefault();
redo();
} else if ((e.metaKey || e.ctrlKey) && e.key === "y") {
e.preventDefault();
redo();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [redo]);
// Mouse down on timeline background to start marquee // Mouse down on timeline background to start marquee
const handleTimelineMouseDown = (e: React.MouseEvent) => { const handleTimelineMouseDown = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && e.button === 0) { if (e.target === e.currentTarget && e.button === 0) {
@ -294,18 +321,32 @@ export function Timeline() {
} }
}; };
// Deselect all clips when clicking empty timeline area const handleSeekToPosition = (e: React.MouseEvent) => {
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickedTime = clickX / (50 * zoomLevel);
const clampedTime = Math.max(0, Math.min(duration, clickedTime));
seek(clampedTime);
};
const handleTimelineAreaClick = (e: React.MouseEvent) => { const handleTimelineAreaClick = (e: React.MouseEvent) => {
// Only clear selection if the click target is the timeline background (not a child/clip)
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
clearSelectedClips(); clearSelectedClips();
// Calculate the clicked time position and seek to it
handleSeekToPosition(e);
} }
}; };
const handleWheel = (e: React.WheelEvent) => { const handleWheel = (e: React.WheelEvent) => {
e.preventDefault(); // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
const delta = e.deltaY > 0 ? -0.05 : 0.05; if (e.ctrlKey || e.metaKey) {
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); 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 --- // --- Playhead Scrubbing Handlers ---
@ -350,6 +391,92 @@ export function Timeline() {
onDrop: handleDrop, onDrop: handleDrop,
}; };
// Action handlers for toolbar
const handleSplitSelected = () => {
if (selectedClips.length === 0) {
toast.error("No clips selected");
return;
}
selectedClips.forEach(({ trackId, clipId }) => {
const track = tracks.find(t => t.id === trackId);
const clip = track?.clips.find(c => c.id === clipId);
if (clip && track) {
const splitTime = currentTime;
const effectiveStart = clip.startTime;
const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
updateClipTrim(track.id, clip.id, clip.trimStart, clip.trimEnd + (effectiveEnd - splitTime));
addClipToTrack(track.id, {
mediaId: clip.mediaId,
name: clip.name + " (split)",
duration: clip.duration,
startTime: splitTime,
trimStart: clip.trimStart + (splitTime - effectiveStart),
trimEnd: clip.trimEnd,
});
}
}
});
toast.success("Split selected clip(s)");
};
const handleDuplicateSelected = () => {
if (selectedClips.length === 0) {
toast.error("No clips selected");
return;
}
selectedClips.forEach(({ trackId, clipId }) => {
const track = tracks.find(t => t.id === trackId);
const clip = track?.clips.find(c => c.id === clipId);
if (clip && track) {
addClipToTrack(track.id, {
mediaId: clip.mediaId,
name: clip.name + " (copy)",
duration: clip.duration,
startTime: clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd) + 0.1,
trimStart: clip.trimStart,
trimEnd: clip.trimEnd,
});
}
});
toast.success("Duplicated selected clip(s)");
};
const handleFreezeSelected = () => {
if (selectedClips.length === 0) {
toast.error("No clips selected");
return;
}
selectedClips.forEach(({ trackId, clipId }) => {
const track = tracks.find(t => t.id === trackId);
const clip = track?.clips.find(c => c.id === clipId);
if (clip && track) {
// Add a new freeze frame clip at the playhead
addClipToTrack(track.id, {
mediaId: clip.mediaId,
name: clip.name + " (freeze)",
duration: 1, // 1 second freeze frame
startTime: currentTime,
trimStart: 0,
trimEnd: clip.duration - 1,
});
}
});
toast.success("Freeze frame added for selected clip(s)");
};
const handleDeleteSelected = () => {
if (selectedClips.length === 0) {
toast.error("No clips selected");
return;
}
selectedClips.forEach(({ trackId, clipId }) => {
removeClipFromTrack(trackId, clipId);
});
clearSelectedClips();
toast.success("Deleted selected clip(s)");
};
return ( return (
<div <div
className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`} className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`}
@ -422,7 +549,7 @@ export function Timeline() {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon" onClick={handleSplitSelected}>
<Scissors className="h-4 w-4" /> <Scissors className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@ -458,7 +585,7 @@ export function Timeline() {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon" onClick={handleDuplicateSelected}>
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@ -467,7 +594,7 @@ export function Timeline() {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon" onClick={handleFreezeSelected}>
<Snowflake className="h-4 w-4" /> <Snowflake className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@ -476,7 +603,7 @@ export function Timeline() {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon" onClick={handleDeleteSelected}>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@ -526,10 +653,14 @@ export function Timeline() {
<div className="flex-1 relative overflow-hidden"> <div className="flex-1 relative overflow-hidden">
<ScrollArea className="w-full"> <ScrollArea className="w-full">
<div <div
className="relative h-12 bg-muted/30" className="relative h-12 bg-muted/30 cursor-pointer"
style={{ style={{
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`, width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
}} }}
onClick={(e) => {
// Calculate the clicked time position and seek to it
handleSeekToPosition(e);
}}
> >
{/* Time markers */} {/* Time markers */}
{(() => { {(() => {
@ -653,15 +784,14 @@ export function Timeline() {
</div> </div>
{/* Timeline Tracks Content */} {/* Timeline Tracks Content */}
<div className="flex-1 relative"> <div className="flex-1 relative overflow-hidden">
<ScrollArea className="h-full w-full"> <div className="w-full h-[600px] overflow-hidden flex" ref={timelineRef} style={{ position: 'relative' }}>
{/* Timeline grid and clips area (with left margin for sidebar) */}
<div <div
ref={timelineRef} className="relative flex-1"
className="relative cursor-pointer select-none"
style={{ style={{
height: `${tracks.length * 60}px`,
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`, width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
minHeight:
tracks.length > 0 ? `${tracks.length * 60}px` : "200px",
}} }}
onClick={handleTimelineAreaClick} onClick={handleTimelineAreaClick}
onMouseDown={handleTimelineMouseDown} onMouseDown={handleTimelineMouseDown}
@ -704,7 +834,6 @@ export function Timeline() {
zoomLevel={zoomLevel} zoomLevel={zoomLevel}
setContextMenu={setContextMenu} setContextMenu={setContextMenu}
/> />
</div> </div>
))} ))}
@ -722,7 +851,7 @@ export function Timeline() {
</> </>
)} )}
</div> </div>
</ScrollArea> </div>
</div> </div>
</div> </div>
</div> </div>
@ -788,7 +917,7 @@ export function Timeline() {
const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) { if (splitTime > effectiveStart && splitTime < effectiveEnd) {
useTimelineStore.getState().updateClipTrim( updateClipTrim(
track.id, track.id,
clip.id, clip.id,
clip.trimStart, clip.trimStart,
@ -1452,9 +1581,18 @@ function TimelineTrackContent({
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }} style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
const isSelected = selectedClips.some(
(c) => c.trackId === track.id && c.clipId === clip.id
);
if (e.metaKey || e.ctrlKey || e.shiftKey) { if (e.metaKey || e.ctrlKey || e.shiftKey) {
// Multi-selection mode: toggle the clip
selectClip(track.id, clip.id, true); selectClip(track.id, clip.id, true);
} else if (isSelected) {
// If clip is already selected, deselect it
deselectClip(track.id, clip.id);
} else { } else {
// If clip is not selected, select it (replacing other selections)
selectClip(track.id, clip.id, false); selectClip(track.id, clip.id, false);
} }
}} }}
@ -1527,8 +1665,7 @@ function TimelineTrackContent({
{/* Drop position indicator */} {/* Drop position indicator */}
{isDraggedOver && dropPosition !== null && ( {isDraggedOver && dropPosition !== null && (
<div <div
className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${wouldOverlap ? "bg-red-500" : "bg-blue-500" className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
}`}
style={{ style={{
left: `${dropPosition * 50 * zoomLevel}px`, left: `${dropPosition * 50 * zoomLevel}px`,
transform: "translateX(-50%)", transform: "translateX(-50%)",

View File

@ -6,9 +6,27 @@ import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base"; import { HeaderBase } from "./header-base";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { getStars } from "@/lib/fetchGhStars";
import { Star } from "lucide-react";
import { useEffect, useState } from "react";
export function Header() { export function Header() {
const { data: session } = useSession(); const { data: session } = useSession();
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();
}, []);
const leftContent = ( const leftContent = (
<Link href="/" className="flex items-center gap-3"> <Link href="/" className="flex items-center gap-3">
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} /> <Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
@ -19,11 +37,18 @@ export function Header() {
const rightContent = ( const rightContent = (
<nav className="flex items-center"> <nav className="flex items-center">
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank"> <Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
<Button variant="ghost" className="text-sm"> <Button
GitHub variant="ghost"
className="flex items-center text-sm text-muted-foreground hover:text-foreground"
>
<span className="hidden sm:inline">GitHub</span>
<span className="text-foreground flex items-center">
{star}+
<Star className="w-4 h-4 ml-1" />
</span>
</Button> </Button>
</Link> </Link>
<Link href={session ? "/editor" : "/auth/login"}> <Link href={session ? "/editor" : "/login"}>
<Button size="sm" className="text-sm ml-4"> <Button size="sm" className="text-sm ml-4">
Start editing Start editing
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />

View File

@ -5,18 +5,33 @@ import { Button } from "../ui/button";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { getStars } from "@/lib/fetchGhStars";
interface HeroProps { interface HeroProps {
signupCount: number; signupCount: number;
} }
export function Hero({ signupCount }: HeroProps) { export function Hero({ signupCount }: HeroProps) {
const [star, setStar] = useState<string>();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
useEffect(() => {
const fetchStars = async () => {
try {
const data = await getStars();
setStar(data);
} catch (err) {
console.error("Failed to fetch GitHub stars", err);
}
};
fetchStars();
}, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -67,7 +82,7 @@ export function Hero({ signupCount }: HeroProps) {
}; };
return ( return (
<div className="relative min-h-screen flex flex-col items-center justify-center text-center px-4"> <div className="relative min-h-[calc(100vh-4rem)] flex flex-col items-center justify-center text-center px-4">
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
@ -152,7 +167,7 @@ export function Hero({ signupCount }: HeroProps) {
href="https://github.com/OpenCut-app/OpenCut" href="https://github.com/OpenCut-app/OpenCut"
className="text-foreground underline" className="text-foreground underline"
> >
GitHub GitHub {star}+
</Link> </Link>
</motion.div> </motion.div>
</div> </div>

View File

@ -0,0 +1,29 @@
export async function getStars(): Promise<string> {
try {
const res = await fetch(
"https://api.github.com/repos/OpenCut-app/OpenCut",
{
next: { revalidate: 3600 },
}
);
if (!res.ok) {
throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
}
const data = await res.json();
const count = data.stargazers_count;
if (typeof count !== "number") {
throw new Error("Invalid stargazers_count from GitHub API");
}
if (count >= 1_000_000)
return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
if (count >= 1_000)
return (count / 1_000).toFixed(1).replace(/\.0$/, "") + "k";
return count.toString();
} catch (error) {
console.error("Failed to fetch GitHub stars:", error);
return "1.5k";
}
}

View File

@ -7,7 +7,7 @@ export async function middleware(request: NextRequest) {
const session = getSessionCookie(request); const session = getSessionCookie(request);
if (path === "/editor" && !session && process.env.NODE_ENV === "production") { if (path === "/editor" && !session && process.env.NODE_ENV === "production") {
const loginUrl = new URL("/auth/login", request.url); const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", request.url); loginUrl.searchParams.set("redirect", request.url);
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }

View File

@ -20,6 +20,8 @@ export interface TimelineTrack {
interface TimelineStore { interface TimelineStore {
tracks: TimelineTrack[]; tracks: TimelineTrack[];
history: TimelineTrack[][];
redoStack: TimelineTrack[][];
// Multi-selection // Multi-selection
selectedClips: { trackId: string; clipId: string }[]; selectedClips: { trackId: string; clipId: string }[];
@ -53,12 +55,39 @@ interface TimelineStore {
// Computed values // Computed values
getTotalDuration: () => number; getTotalDuration: () => number;
// New actions
undo: () => void;
redo: () => void;
pushHistory: () => void;
} }
export const useTimelineStore = create<TimelineStore>((set, get) => ({ export const useTimelineStore = create<TimelineStore>((set, get) => ({
tracks: [], tracks: [],
history: [],
redoStack: [],
selectedClips: [], selectedClips: [],
pushHistory: () => {
const { tracks, history, redoStack } = get();
// Deep copy tracks
set({
history: [...history, JSON.parse(JSON.stringify(tracks))],
redoStack: [] // Clear redo stack when new action is performed
});
},
undo: () => {
const { history, redoStack, tracks } = get();
if (history.length === 0) return;
const prev = history[history.length - 1];
set({
tracks: prev,
history: history.slice(0, -1),
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack
});
},
selectClip: (trackId, clipId, multi = false) => { selectClip: (trackId, clipId, multi = false) => {
set((state) => { set((state) => {
const exists = state.selectedClips.some( const exists = state.selectedClips.some(
@ -86,6 +115,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
setSelectedClips: (clips) => set({ selectedClips: clips }), setSelectedClips: (clips) => set({ selectedClips: clips }),
addTrack: (type) => { addTrack: (type) => {
get().pushHistory();
const newTrack: TimelineTrack = { const newTrack: TimelineTrack = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`, name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
@ -100,12 +130,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}, },
removeTrack: (trackId) => { removeTrack: (trackId) => {
get().pushHistory();
set((state) => ({ set((state) => ({
tracks: state.tracks.filter((track) => track.id !== trackId), tracks: state.tracks.filter((track) => track.id !== trackId),
})); }));
}, },
addClipToTrack: (trackId, clipData) => { addClipToTrack: (trackId, clipData) => {
get().pushHistory();
const newClip: TimelineClip = { const newClip: TimelineClip = {
...clipData, ...clipData,
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -124,19 +156,21 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}, },
removeClipFromTrack: (trackId, clipId) => { removeClipFromTrack: (trackId, clipId) => {
get().pushHistory();
set((state) => ({ set((state) => ({
tracks: state.tracks.map((track) => tracks: state.tracks
track.id === trackId .map((track) =>
? { track.id === trackId
...track, ? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
clips: track.clips.filter((clip) => clip.id !== clipId), : track
} )
: track // Remove track if it becomes empty
), .filter((track) => track.clips.length > 0),
})); }));
}, },
moveClipToTrack: (fromTrackId, toTrackId, clipId) => { moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
get().pushHistory();
set((state) => { set((state) => {
const fromTrack = state.tracks.find((track) => track.id === fromTrackId); const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId); const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
@ -144,25 +178,29 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
if (!clipToMove) return state; if (!clipToMove) return state;
return { return {
tracks: state.tracks.map((track) => { tracks: state.tracks
if (track.id === fromTrackId) { .map((track) => {
return { if (track.id === fromTrackId) {
...track, return {
clips: track.clips.filter((clip) => clip.id !== clipId), ...track,
}; clips: track.clips.filter((clip) => clip.id !== clipId),
} else if (track.id === toTrackId) { };
return { } else if (track.id === toTrackId) {
...track, return {
clips: [...track.clips, clipToMove], ...track,
}; clips: [...track.clips, clipToMove],
} };
return track; }
}), return track;
})
// Remove track if it becomes empty
.filter((track) => track.clips.length > 0),
}; };
}); });
}, },
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => { updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
get().pushHistory();
set((state) => ({ set((state) => ({
tracks: state.tracks.map((track) => tracks: state.tracks.map((track) =>
track.id === trackId track.id === trackId
@ -178,6 +216,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}, },
updateClipStartTime: (trackId, clipId, startTime) => { updateClipStartTime: (trackId, clipId, startTime) => {
get().pushHistory();
set((state) => ({ set((state) => ({
tracks: state.tracks.map((track) => tracks: state.tracks.map((track) =>
track.id === trackId track.id === trackId
@ -193,6 +232,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}, },
toggleTrackMute: (trackId) => { toggleTrackMute: (trackId) => {
get().pushHistory();
set((state) => ({ set((state) => ({
tracks: state.tracks.map((track) => tracks: state.tracks.map((track) =>
track.id === trackId ? { ...track, muted: !track.muted } : track track.id === trackId ? { ...track, muted: !track.muted } : track
@ -214,4 +254,11 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
return Math.max(...trackEndTimes, 0); return Math.max(...trackEndTimes, 0);
}, },
redo: () => {
const { redoStack } = get();
if (redoStack.length === 0) return;
const next = redoStack[redoStack.length - 1];
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
},
})); }));

View File

@ -18,7 +18,8 @@ services:
start_period: 10s start_period: 10s
redis: redis:
image: redis image: redis:7-alpine
restart: unless-stopped
ports: ports:
- "6379:6379" - "6379:6379"
healthcheck: healthcheck:
@ -36,12 +37,46 @@ services:
SRH_MODE: env SRH_MODE: env
SRH_TOKEN: example_token SRH_TOKEN: example_token
SRH_CONNECTION_STRING: "redis://redis:6379" SRH_CONNECTION_STRING: "redis://redis:6379"
depends_on:
redis:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget --spider -q http://127.0.0.1:80 || exit 1"] test: ["CMD-SHELL", "wget --spider -q http://127.0.0.1:80 || exit 1"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 5
start_period: 10s start_period: 10s
web:
build:
context: ./apps/web
dockerfile: ./apps/web/Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://opencut:opencutthegoat@db:5432/opencut
- BETTER_AUTH_URL=http://localhost:3000
- BETTER_AUTH_SECRET=your-production-secret-key-here
- UPSTASH_REDIS_REST_URL=http://serverless-redis-http:80
- UPSTASH_REDIS_REST_TOKEN=example_token
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
depends_on:
db:
condition: service_healthy
serverless-redis-http:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
volumes: volumes:
postgres_data: postgres_data:
networks:
default:
name: opencut-network

2
package-lock.json generated
View File

@ -1,5 +1,5 @@
{ {
"name": "Opencut", "name": "OpenCut",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": {} "packages": {}