Merge branch 'main' of https://github.com/mazeincoding/AppCut
This commit is contained in:
@ -7,9 +7,13 @@ DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
BETTER_AUTH_SECRET=your-secret-key-here
|
||||
|
||||
#Google Clients
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Development Environment
|
||||
NODE_ENV=development
|
||||
|
||||
# Redis
|
||||
UPSTASH_REDIS_REST_URL=http://localhost:8079
|
||||
UPSTASH_REDIS_REST_TOKEN=example_token
|
||||
UPSTASH_REDIS_REST_TOKEN=example_token
|
||||
|
899
apps/web/package-lock.json
generated
899
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
162
apps/web/src/app/(auth)/login/page.tsx
Normal file
162
apps/web/src/app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
"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="/auth/signup" className="font-medium text-primary underline-offset-4 hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
181
apps/web/src/app/(auth)/signup/page.tsx
Normal file
181
apps/web/src/app/(auth)/signup/page.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
"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("/auth/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="/auth/login" className="font-medium text-primary underline-offset-4 hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,98 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { authClient } 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 Link from "next/link";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
function LoginForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const redirectUrl = searchParams.get("redirect");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError(null);
|
||||
const { error } = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message || "An unexpected error occurred.");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(redirectUrl || "/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleLogin}>Login</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Card className="w-[350px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email and password to login.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/auth/signup" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { authClient } 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 Link from "next/link";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
function SignUpForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const redirectUrl = searchParams.get("redirect");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSignUp = async () => {
|
||||
setError(null);
|
||||
const { error } = await authClient.signUp.email({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message || "An unexpected error occurred.");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(redirectUrl || "/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</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)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSignUp}>Sign Up</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignUpPage() {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Card className="w-[350px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign Up</CardTitle>
|
||||
<CardDescription>Create an account to get started.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<SignUpForm />
|
||||
</Suspense>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<Link href="/auth/login" className="underline">
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,197 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { Slider } from "../ui/slider";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import { Separator } from "../ui/separator";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
|
||||
import { useState } from "react";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const [backgroundType, setBackgroundType] = useState<
|
||||
"blur" | "mirror" | "color"
|
||||
>("blur");
|
||||
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
||||
|
||||
// Get the first image clip for preview (simplified)
|
||||
const firstImageClip = tracks
|
||||
.flatMap((track) => track.clips)
|
||||
.find((clip) => {
|
||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||
return mediaItem?.type === "image";
|
||||
});
|
||||
|
||||
const firstImageItem = firstImageClip
|
||||
? mediaItems.find((item) => item.id === firstImageClip.mediaId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-6 p-5">
|
||||
{/* Image Treatment - only show if an image is selected */}
|
||||
{firstImageItem && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Image Treatment</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Preview */}
|
||||
<div className="space-y-2">
|
||||
<Label>Preview</Label>
|
||||
<div className="w-full aspect-video max-w-48">
|
||||
<ImageTimelineTreatment
|
||||
src={firstImageItem.url}
|
||||
alt={firstImageItem.name}
|
||||
targetAspectRatio={16 / 9}
|
||||
className="rounded-sm border"
|
||||
backgroundType={backgroundType}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Type */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bg-type">Background Type</Label>
|
||||
<Select
|
||||
value={backgroundType}
|
||||
onValueChange={(value: any) => setBackgroundType(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select background type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="blur">Blur</SelectItem>
|
||||
<SelectItem value="mirror">Mirror</SelectItem>
|
||||
<SelectItem value="color">Solid Color</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Background Color - only show for color type */}
|
||||
{backgroundType === "color" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bg-color">Background Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="bg-color"
|
||||
type="color"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
className="w-16 h-10 p-1"
|
||||
/>
|
||||
<Input
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Transform */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Transform</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="x">X Position</Label>
|
||||
<Input id="x" type="number" defaultValue="0" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="y">Y Position</Label>
|
||||
<Input id="y" type="number" defaultValue="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rotation">Rotation</Label>
|
||||
<Slider
|
||||
id="rotation"
|
||||
max={360}
|
||||
step={1}
|
||||
defaultValue={[0]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Effects */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Effects</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="opacity">Opacity</Label>
|
||||
<Slider
|
||||
id="opacity"
|
||||
max={100}
|
||||
step={1}
|
||||
defaultValue={[100]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="blur">Blur</Label>
|
||||
<Slider
|
||||
id="blur"
|
||||
max={20}
|
||||
step={0.5}
|
||||
defaultValue={[0]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Timing */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Timing</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="duration">Duration (seconds)</Label>
|
||||
<Input
|
||||
id="duration"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
defaultValue="5"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="delay">Delay (seconds)</Label>
|
||||
<Input
|
||||
id="delay"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
defaultValue="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { Slider } from "../ui/slider";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import { Separator } from "../ui/separator";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
|
||||
import { useState } from "react";
|
||||
import { SpeedControl } from "./speed-control";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const [backgroundType, setBackgroundType] = useState<
|
||||
"blur" | "mirror" | "color"
|
||||
>("blur");
|
||||
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
||||
|
||||
// Get the first video clip for preview (simplified)
|
||||
const firstVideoClip = tracks
|
||||
.flatMap((track) => track.clips)
|
||||
.find((clip) => {
|
||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||
return mediaItem?.type === "video";
|
||||
});
|
||||
|
||||
const firstVideoItem = firstVideoClip
|
||||
? mediaItems.find((item) => item.id === firstVideoClip.mediaId)
|
||||
: null;
|
||||
|
||||
// Get the first image clip for preview (simplified)
|
||||
const firstImageClip = tracks
|
||||
.flatMap((track) => track.clips)
|
||||
.find((clip) => {
|
||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||
return mediaItem?.type === "image";
|
||||
});
|
||||
|
||||
const firstImageItem = firstImageClip
|
||||
? mediaItems.find((item) => item.id === firstImageClip.mediaId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-6 p-5">
|
||||
{/* Image Treatment - only show if an image is selected */}
|
||||
{firstImageItem && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Image Treatment</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Preview */}
|
||||
<div className="space-y-2">
|
||||
<Label>Preview</Label>
|
||||
<div className="w-full aspect-video max-w-48">
|
||||
<ImageTimelineTreatment
|
||||
src={firstImageItem.url}
|
||||
alt={firstImageItem.name}
|
||||
targetAspectRatio={16 / 9}
|
||||
className="rounded-sm border"
|
||||
backgroundType={backgroundType}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Type */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bg-type">Background Type</Label>
|
||||
<Select
|
||||
value={backgroundType}
|
||||
onValueChange={(value: any) => setBackgroundType(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select background type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="blur">Blur</SelectItem>
|
||||
<SelectItem value="mirror">Mirror</SelectItem>
|
||||
<SelectItem value="color">Solid Color</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Background Color - only show for color type */}
|
||||
{backgroundType === "color" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bg-color">Background Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="bg-color"
|
||||
type="color"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
className="w-16 h-10 p-1"
|
||||
/>
|
||||
<Input
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Video Controls - only show if a video is selected */}
|
||||
{firstVideoItem && (
|
||||
<>
|
||||
<SpeedControl />
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Transform */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Transform</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="x">X Position</Label>
|
||||
<Input id="x" type="number" defaultValue="0" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="y">Y Position</Label>
|
||||
<Input id="y" type="number" defaultValue="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rotation">Rotation</Label>
|
||||
<Slider
|
||||
id="rotation"
|
||||
max={360}
|
||||
step={1}
|
||||
defaultValue={[0]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Effects */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Effects</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="opacity">Opacity</Label>
|
||||
<Slider
|
||||
id="opacity"
|
||||
max={100}
|
||||
step={1}
|
||||
defaultValue={[100]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="blur">Blur</Label>
|
||||
<Slider
|
||||
id="blur"
|
||||
max={20}
|
||||
step={0.5}
|
||||
defaultValue={[0]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Timing */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Timing</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="duration">Duration (seconds)</Label>
|
||||
<Input
|
||||
id="duration"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
defaultValue="5"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="delay">Delay (seconds)</Label>
|
||||
<Input
|
||||
id="delay"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
defaultValue="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
46
apps/web/src/components/editor/speed-control.tsx
Normal file
46
apps/web/src/components/editor/speed-control.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { Slider } from "../ui/slider";
|
||||
import { Label } from "../ui/label";
|
||||
import { Button } from "../ui/button";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
|
||||
const SPEED_PRESETS = [
|
||||
{ label: "0.5x", value: 0.5 },
|
||||
{ label: "1x", value: 1.0 },
|
||||
{ label: "1.5x", value: 1.5 },
|
||||
{ label: "2x", value: 2.0 },
|
||||
];
|
||||
|
||||
export function SpeedControl() {
|
||||
const { speed, setSpeed } = usePlaybackStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Playback Speed</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{SPEED_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.value}
|
||||
variant={speed === preset.value ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => setSpeed(preset.value)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Custom ({speed.toFixed(1)}x)</Label>
|
||||
<Slider
|
||||
value={[speed]}
|
||||
min={0.1}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
onValueChange={(value) => setSpeed(value[0])}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -28,15 +28,22 @@ import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
|
||||
export function Timeline() {
|
||||
// Timeline shows all tracks (video, audio, effects) and their clips.
|
||||
// You can drag media here to add it to your project.
|
||||
// Clips can be trimmed, deleted, and moved.
|
||||
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration } =
|
||||
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips } =
|
||||
useTimelineStore();
|
||||
const { mediaItems, addMediaItem } = useMediaStore();
|
||||
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle } = usePlaybackStore();
|
||||
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
@ -52,6 +59,16 @@ export function Timeline() {
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
// Marquee selection state
|
||||
const [marquee, setMarquee] = useState<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
active: boolean;
|
||||
additive: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Update timeline duration when tracks change
|
||||
useEffect(() => {
|
||||
const totalDuration = getTotalDuration();
|
||||
@ -67,6 +84,106 @@ export function Timeline() {
|
||||
}
|
||||
}, [contextMenu]);
|
||||
|
||||
// Keyboard event for deleting selected clips
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.key === "Delete" || e.key === "Backspace") && selectedClips.length > 0) {
|
||||
selectedClips.forEach(({ trackId, clipId }) => {
|
||||
removeClipFromTrack(trackId, clipId);
|
||||
});
|
||||
clearSelectedClips();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedClips, removeClipFromTrack, clearSelectedClips]);
|
||||
|
||||
// Mouse down on timeline background to start marquee
|
||||
const handleTimelineMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget && e.button === 0) {
|
||||
setMarquee({
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
endX: e.clientX,
|
||||
endY: e.clientY,
|
||||
active: true,
|
||||
additive: e.metaKey || e.ctrlKey || e.shiftKey,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse move to update marquee
|
||||
useEffect(() => {
|
||||
if (!marquee || !marquee.active) return;
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
setMarquee((prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY });
|
||||
};
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
setMarquee((prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false });
|
||||
};
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [marquee]);
|
||||
|
||||
// On marquee end, select clips in box
|
||||
useEffect(() => {
|
||||
if (!marquee || marquee.active) return;
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) return;
|
||||
const rect = timeline.getBoundingClientRect();
|
||||
const x1 = Math.min(marquee.startX, marquee.endX) - rect.left;
|
||||
const x2 = Math.max(marquee.startX, marquee.endX) - rect.left;
|
||||
const y1 = Math.min(marquee.startY, marquee.endY) - rect.top;
|
||||
const y2 = Math.max(marquee.startY, marquee.endY) - rect.top;
|
||||
// Validation: skip if too small
|
||||
if (Math.abs(x2 - x1) < 5 || Math.abs(y2 - y1) < 5) {
|
||||
setMarquee(null);
|
||||
return;
|
||||
}
|
||||
// Clamp to timeline bounds
|
||||
const clamp = (val: number, min: number, max: number) => Math.max(min, Math.min(max, val));
|
||||
const bx1 = clamp(x1, 0, rect.width);
|
||||
const bx2 = clamp(x2, 0, rect.width);
|
||||
const by1 = clamp(y1, 0, rect.height);
|
||||
const by2 = clamp(y2, 0, rect.height);
|
||||
let newSelection: { trackId: string; clipId: string }[] = [];
|
||||
tracks.forEach((track, trackIdx) => {
|
||||
track.clips.forEach((clip) => {
|
||||
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
|
||||
const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
|
||||
const clipLeft = clip.startTime * 50 * zoomLevel;
|
||||
const clipTop = trackIdx * 60;
|
||||
const clipBottom = clipTop + 60;
|
||||
const clipRight = clipLeft + clipWidth;
|
||||
if (
|
||||
bx1 < clipRight &&
|
||||
bx2 > clipLeft &&
|
||||
by1 < clipBottom &&
|
||||
by2 > clipTop
|
||||
) {
|
||||
newSelection.push({ trackId: track.id, clipId: clip.id });
|
||||
}
|
||||
});
|
||||
});
|
||||
if (newSelection.length > 0) {
|
||||
if (marquee.additive) {
|
||||
const selectedSet = new Set(selectedClips.map((c) => c.trackId + ':' + c.clipId));
|
||||
newSelection = [
|
||||
...selectedClips,
|
||||
...newSelection.filter((c) => !selectedSet.has(c.trackId + ':' + c.clipId)),
|
||||
];
|
||||
}
|
||||
setSelectedClips(newSelection);
|
||||
} else if (!marquee.additive) {
|
||||
clearSelectedClips();
|
||||
}
|
||||
setMarquee(null);
|
||||
}, [marquee, tracks, zoomLevel, selectedClips, setSelectedClips, clearSelectedClips]);
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
// When something is dragged over the timeline, show overlay
|
||||
e.preventDefault();
|
||||
@ -173,17 +290,12 @@ export function Timeline() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimelineClick = (e: React.MouseEvent) => {
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline || duration === 0) return;
|
||||
|
||||
const rect = timeline.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const timelineWidth = rect.width;
|
||||
const visibleDuration = duration / zoomLevel;
|
||||
const clickedTime = (x / timelineWidth) * visibleDuration;
|
||||
|
||||
seek(Math.max(0, Math.min(duration, clickedTime)));
|
||||
// Deselect all clips when clicking empty timeline area
|
||||
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) {
|
||||
clearSelectedClips();
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
@ -331,6 +443,29 @@ export function Timeline() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete clip (Delete)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
|
||||
{/* Speed Control */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Select
|
||||
value={speed.toFixed(1)}
|
||||
onValueChange={(value) => setSpeed(parseFloat(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[90px] h-8">
|
||||
<SelectValue placeholder="1.0x" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.5">0.5x</SelectItem>
|
||||
<SelectItem value="1.0">1.0x</SelectItem>
|
||||
<SelectItem value="1.5">1.5x</SelectItem>
|
||||
<SelectItem value="2.0">2.0x</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Playback Speed</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
@ -488,7 +623,8 @@ export function Timeline() {
|
||||
minHeight:
|
||||
tracks.length > 0 ? `${tracks.length * 60}px` : "200px",
|
||||
}}
|
||||
onClick={handleTimelineClick}
|
||||
onClick={handleTimelineAreaClick}
|
||||
onMouseDown={handleTimelineMouseDown}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{tracks.length === 0 ? (
|
||||
@ -699,6 +835,9 @@ function TimelineTrackContent({
|
||||
addClipToTrack,
|
||||
removeClipFromTrack,
|
||||
toggleTrackMute,
|
||||
selectedClips,
|
||||
selectClip,
|
||||
deselectClip,
|
||||
} = useTimelineStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const [isDropping, setIsDropping] = useState(false);
|
||||
@ -1260,12 +1399,23 @@ function TimelineTrackContent({
|
||||
effectiveDuration * 50 * zoomLevel
|
||||
);
|
||||
const clipLeft = clip.startTime * 50 * zoomLevel;
|
||||
|
||||
const isSelected = selectedClips.some(
|
||||
(c) => c.trackId === track.id && c.clipId === clip.id
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`timeline-clip absolute h-full rounded-sm border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg`}
|
||||
className={`timeline-clip absolute h-full rounded-sm border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""}`}
|
||||
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
selectClip(track.id, clip.id, true);
|
||||
} else {
|
||||
selectClip(track.id, clip.id, false);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@ -1342,16 +1492,13 @@ function TimelineTrackContent({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
|
||||
}`}
|
||||
className={`absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
|
||||
}`}
|
||||
className={`absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white px-1 py-0.5 rounded whitespace-nowrap ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
|
||||
}`}
|
||||
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white px-1 py-0.5 rounded whitespace-nowrap ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
|
||||
>
|
||||
{wouldOverlap ? "⚠️" : ""}
|
||||
{dropPosition.toFixed(1)}s
|
||||
|
@ -5,8 +5,10 @@ import Image from "next/image";
|
||||
import { Button } from "./ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { HeaderBase } from "./header-base";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
|
||||
export function Header() {
|
||||
const { data: session } = useSession();
|
||||
const leftContent = (
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
|
||||
@ -21,7 +23,7 @@ export function Header() {
|
||||
GitHub
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/editor">
|
||||
<Link href={session ? "/editor" : "/auth/login"}>
|
||||
<Button size="sm" className="text-sm ml-4">
|
||||
Start editing
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
|
22
apps/web/src/components/icons.tsx
Normal file
22
apps/web/src/components/icons.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
export function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { type DialogProps } from "radix-ui";
|
||||
import { DialogProps } from "@radix-ui/react-dialog";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
|
@ -25,7 +25,7 @@ export function VideoPlayer({
|
||||
clipDuration
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const { isPlaying, currentTime, volume, play, pause, setVolume } = usePlaybackStore();
|
||||
const { isPlaying, currentTime, volume, speed, play, pause, setVolume } = usePlaybackStore();
|
||||
|
||||
// Calculate if we're within this clip's timeline range
|
||||
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
||||
@ -59,18 +59,26 @@ export function VideoPlayer({
|
||||
timelineTime - clipStartTime + trimStart
|
||||
));
|
||||
|
||||
// Only sync if there's a significant difference
|
||||
if (Math.abs(video.currentTime - targetVideoTime) > 0.2) {
|
||||
// Only sync if there's a significant difference to avoid micro-adjustments
|
||||
if (Math.abs(video.currentTime - targetVideoTime) > 0.5) {
|
||||
video.currentTime = targetVideoTime;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpeedEvent = (e: CustomEvent) => {
|
||||
if (!isInClipRange) return;
|
||||
// Set playbackRate directly without any additional checks
|
||||
video.playbackRate = e.detail.speed;
|
||||
};
|
||||
|
||||
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||
window.addEventListener("playback-update", handleUpdateEvent as EventListener);
|
||||
window.addEventListener("playback-speed", handleSpeedEvent as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||
window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
|
||||
window.removeEventListener("playback-speed", handleSpeedEvent as EventListener);
|
||||
};
|
||||
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
||||
|
||||
@ -93,6 +101,13 @@ export function VideoPlayer({
|
||||
video.volume = volume;
|
||||
}, [volume]);
|
||||
|
||||
// Sync speed immediately when it changes
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.playbackRate = speed;
|
||||
}, [speed]);
|
||||
|
||||
return (
|
||||
<div className={`relative group ${className}`}>
|
||||
<video
|
||||
@ -101,7 +116,7 @@ export function VideoPlayer({
|
||||
poster={poster}
|
||||
className="w-full h-full object-cover"
|
||||
playsInline
|
||||
preload="metadata"
|
||||
preload="auto"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
export const authClient = createAuthClient({
|
||||
export const { signIn, signUp, useSession } = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL!,
|
||||
});
|
||||
|
@ -16,6 +16,12 @@ export const auth = betterAuth({
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID as string,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
|
||||
},
|
||||
},
|
||||
appName: "OpenCut",
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
});
|
||||
|
@ -102,26 +102,60 @@ export const getVideoInfo = async (videoFile: File): Promise<{
|
||||
fps: number;
|
||||
}> => {
|
||||
const ffmpeg = await initFFmpeg();
|
||||
|
||||
|
||||
const inputName = 'input.mp4';
|
||||
|
||||
|
||||
// Write input file
|
||||
await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer()));
|
||||
|
||||
// Get video info
|
||||
await ffmpeg.exec(['-i', inputName, '-f', 'null', '-']);
|
||||
|
||||
// Note: In a real implementation, you'd parse the FFmpeg output
|
||||
// For now, we'll return default values and enhance this later
|
||||
|
||||
|
||||
// Capture FFmpeg stderr output with a one-time listener pattern
|
||||
let ffmpegOutput = '';
|
||||
let listening = true;
|
||||
const listener = (data: string) => {
|
||||
if (listening) ffmpegOutput += data;
|
||||
};
|
||||
ffmpeg.on('log', ({ message }) => listener(message));
|
||||
|
||||
// Run ffmpeg to get info (stderr will contain the info)
|
||||
try {
|
||||
await ffmpeg.exec(['-i', inputName, '-f', 'null', '-']);
|
||||
} catch (error) {
|
||||
listening = false;
|
||||
await ffmpeg.deleteFile(inputName);
|
||||
console.error('FFmpeg execution failed:', error);
|
||||
throw new Error('Failed to extract video info. The file may be corrupted or in an unsupported format.');
|
||||
}
|
||||
|
||||
// Disable listener after exec completes
|
||||
listening = false;
|
||||
|
||||
// Cleanup
|
||||
await ffmpeg.deleteFile(inputName);
|
||||
|
||||
|
||||
// Parse output for duration, resolution, and fps
|
||||
// Example: Duration: 00:00:10.00, start: 0.000000, bitrate: 1234 kb/s
|
||||
// Example: Stream #0:0: Video: h264 (High), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 30 fps, 30 tbr, 90k tbn, 60 tbc
|
||||
|
||||
const durationMatch = ffmpegOutput.match(/Duration: (\d+):(\d+):([\d.]+)/);
|
||||
let duration = 0;
|
||||
if (durationMatch) {
|
||||
const [, h, m, s] = durationMatch;
|
||||
duration = parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
|
||||
}
|
||||
|
||||
const videoStreamMatch = ffmpegOutput.match(/Video:.* (\d+)x(\d+)[^,]*, ([\d.]+) fps/);
|
||||
let width = 0, height = 0, fps = 0;
|
||||
if (videoStreamMatch) {
|
||||
width = parseInt(videoStreamMatch[1]);
|
||||
height = parseInt(videoStreamMatch[2]);
|
||||
fps = parseFloat(videoStreamMatch[3]);
|
||||
}
|
||||
|
||||
return {
|
||||
duration: 10, // Placeholder - would parse from FFmpeg output
|
||||
width: 1920, // Placeholder
|
||||
height: 1080, // Placeholder
|
||||
fps: 30 // Placeholder
|
||||
duration,
|
||||
width,
|
||||
height,
|
||||
fps
|
||||
};
|
||||
};
|
||||
|
||||
@ -194,4 +228,4 @@ export const extractAudio = async (
|
||||
await ffmpeg.deleteFile(outputName);
|
||||
|
||||
return blob;
|
||||
};
|
||||
};
|
@ -6,15 +6,20 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
|
||||
setCurrentTime: (time: number) => void;
|
||||
}
|
||||
|
||||
let playbackTimer: NodeJS.Timeout | null = null;
|
||||
let playbackTimer: number | null = null;
|
||||
|
||||
const startTimer = (store: any) => {
|
||||
if (playbackTimer) clearInterval(playbackTimer);
|
||||
if (playbackTimer) cancelAnimationFrame(playbackTimer);
|
||||
|
||||
playbackTimer = setInterval(() => {
|
||||
// Use requestAnimationFrame for smoother updates
|
||||
const updateTime = () => {
|
||||
const state = store();
|
||||
if (state.isPlaying && state.currentTime < state.duration) {
|
||||
const newTime = state.currentTime + 0.1;
|
||||
const now = performance.now();
|
||||
const delta = (now - lastUpdate) / 1000; // Convert to seconds
|
||||
lastUpdate = now;
|
||||
|
||||
const newTime = state.currentTime + (delta * state.speed);
|
||||
if (newTime >= state.duration) {
|
||||
state.pause();
|
||||
} else {
|
||||
@ -23,12 +28,16 @@ const startTimer = (store: any) => {
|
||||
window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } }));
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
playbackTimer = requestAnimationFrame(updateTime);
|
||||
};
|
||||
|
||||
let lastUpdate = performance.now();
|
||||
playbackTimer = requestAnimationFrame(updateTime);
|
||||
};
|
||||
|
||||
const stopTimer = () => {
|
||||
if (playbackTimer) {
|
||||
clearInterval(playbackTimer);
|
||||
cancelAnimationFrame(playbackTimer);
|
||||
playbackTimer = null;
|
||||
}
|
||||
};
|
||||
@ -38,6 +47,7 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 1,
|
||||
speed: 1.0,
|
||||
|
||||
play: () => {
|
||||
set({ isPlaying: true });
|
||||
@ -64,10 +74,20 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
|
||||
set({ currentTime: clampedTime });
|
||||
|
||||
// Notify video elements to seek
|
||||
window.dispatchEvent(new CustomEvent('playback-seek', { detail: { time: clampedTime } }));
|
||||
const event = new CustomEvent('playback-seek', { detail: { time: clampedTime } });
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
|
||||
setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }),
|
||||
|
||||
setSpeed: (speed: number) => {
|
||||
const newSpeed = Math.max(0.1, Math.min(2.0, speed));
|
||||
set({ speed: newSpeed });
|
||||
|
||||
const event = new CustomEvent('playback-speed', { detail: { speed: newSpeed } });
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
|
||||
setDuration: (duration: number) => set({ duration }),
|
||||
setCurrentTime: (time: number) => set({ currentTime: time }),
|
||||
}));
|
@ -21,6 +21,13 @@ export interface TimelineTrack {
|
||||
interface TimelineStore {
|
||||
tracks: TimelineTrack[];
|
||||
|
||||
// Multi-selection
|
||||
selectedClips: { trackId: string; clipId: string }[];
|
||||
selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
|
||||
deselectClip: (trackId: string, clipId: string) => void;
|
||||
clearSelectedClips: () => void;
|
||||
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
|
||||
|
||||
// Actions
|
||||
addTrack: (type: "video" | "audio" | "effects") => string;
|
||||
removeTrack: (trackId: string) => void;
|
||||
@ -50,6 +57,33 @@ interface TimelineStore {
|
||||
|
||||
export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
||||
tracks: [],
|
||||
selectedClips: [],
|
||||
|
||||
selectClip: (trackId, clipId, multi = false) => {
|
||||
set((state) => {
|
||||
const exists = state.selectedClips.some(
|
||||
(c) => c.trackId === trackId && c.clipId === clipId
|
||||
);
|
||||
if (multi) {
|
||||
// Toggle selection
|
||||
return exists
|
||||
? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) }
|
||||
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
|
||||
} else {
|
||||
return { selectedClips: [{ trackId, clipId }] };
|
||||
}
|
||||
});
|
||||
},
|
||||
deselectClip: (trackId, clipId) => {
|
||||
set((state) => ({
|
||||
selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)),
|
||||
}));
|
||||
},
|
||||
clearSelectedClips: () => {
|
||||
set({ selectedClips: [] });
|
||||
},
|
||||
|
||||
setSelectedClips: (clips) => set({ selectedClips: clips }),
|
||||
|
||||
addTrack: (type) => {
|
||||
const newTrack: TimelineTrack = {
|
||||
|
@ -3,6 +3,7 @@ export interface PlaybackState {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export interface PlaybackControls {
|
||||
@ -10,5 +11,6 @@ export interface PlaybackControls {
|
||||
pause: () => void;
|
||||
seek: (time: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
toggle: () => void;
|
||||
}
|
Reference in New Issue
Block a user