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

View File

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

View File

@ -38,7 +38,6 @@ export function PropertiesPanel() {
? 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) => {

View File

@ -40,7 +40,7 @@ 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, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips } =
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips, updateClipTrim, undo, redo } =
useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore();
@ -102,6 +102,33 @@ export function Timeline() {
return () => window.removeEventListener("keydown", handleKeyDown);
}, [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
const handleTimelineMouseDown = (e: React.MouseEvent) => {
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) => {
// Only clear selection if the click target is the timeline background (not a child/clip)
if (e.target === e.currentTarget) {
clearSelectedClips();
// Calculate the clicked time position and seek to it
handleSeekToPosition(e);
}
};
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
}
// Otherwise, allow normal scrolling
};
// --- Playhead Scrubbing Handlers ---
@ -350,6 +391,92 @@ export function Timeline() {
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 (
<div
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>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" onClick={handleSplitSelected}>
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
@ -458,7 +585,7 @@ export function Timeline() {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" onClick={handleDuplicateSelected}>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
@ -467,7 +594,7 @@ export function Timeline() {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" onClick={handleFreezeSelected}>
<Snowflake className="h-4 w-4" />
</Button>
</TooltipTrigger>
@ -476,7 +603,7 @@ export function Timeline() {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" onClick={handleDeleteSelected}>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
@ -526,10 +653,14 @@ export function Timeline() {
<div className="flex-1 relative overflow-hidden">
<ScrollArea className="w-full">
<div
className="relative h-12 bg-muted/30"
className="relative h-12 bg-muted/30 cursor-pointer"
style={{
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
}}
onClick={(e) => {
// Calculate the clicked time position and seek to it
handleSeekToPosition(e);
}}
>
{/* Time markers */}
{(() => {
@ -653,15 +784,14 @@ export function Timeline() {
</div>
{/* Timeline Tracks Content */}
<div className="flex-1 relative">
<ScrollArea className="h-full w-full">
<div className="flex-1 relative overflow-hidden">
<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
ref={timelineRef}
className="relative cursor-pointer select-none"
className="relative flex-1"
style={{
height: `${tracks.length * 60}px`,
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
minHeight:
tracks.length > 0 ? `${tracks.length * 60}px` : "200px",
}}
onClick={handleTimelineAreaClick}
onMouseDown={handleTimelineMouseDown}
@ -704,7 +834,6 @@ export function Timeline() {
zoomLevel={zoomLevel}
setContextMenu={setContextMenu}
/>
</div>
))}
@ -722,7 +851,7 @@ export function Timeline() {
</>
)}
</div>
</ScrollArea>
</div>
</div>
</div>
</div>
@ -788,7 +917,7 @@ export function Timeline() {
const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
useTimelineStore.getState().updateClipTrim(
updateClipTrim(
track.id,
clip.id,
clip.trimStart,
@ -1452,9 +1581,18 @@ function TimelineTrackContent({
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
onClick={(e) => {
e.stopPropagation();
const isSelected = selectedClips.some(
(c) => c.trackId === track.id && c.clipId === clip.id
);
if (e.metaKey || e.ctrlKey || e.shiftKey) {
// Multi-selection mode: toggle the clip
selectClip(track.id, clip.id, true);
} else if (isSelected) {
// If clip is already selected, deselect it
deselectClip(track.id, clip.id);
} else {
// If clip is not selected, select it (replacing other selections)
selectClip(track.id, clip.id, false);
}
}}
@ -1527,8 +1665,7 @@ function TimelineTrackContent({
{/* Drop position indicator */}
{isDraggedOver && dropPosition !== null && (
<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={{
left: `${dropPosition * 50 * zoomLevel}px`,
transform: "translateX(-50%)",
@ -1553,4 +1690,4 @@ function TimelineTrackContent({
</div>
</div>
);
}
}

View File

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

View File

@ -5,18 +5,33 @@ import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { ArrowRight } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { getStars } from "@/lib/fetchGhStars";
interface HeroProps {
signupCount: number;
}
export function Hero({ signupCount }: HeroProps) {
const [star, setStar] = useState<string>();
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
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) => {
e.preventDefault();
@ -67,7 +82,7 @@ export function Hero({ signupCount }: HeroProps) {
};
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
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@ -152,7 +167,7 @@ export function Hero({ signupCount }: HeroProps) {
href="https://github.com/OpenCut-app/OpenCut"
className="text-foreground underline"
>
GitHub
GitHub {star}+
</Link>
</motion.div>
</div>