Merge branch 'main' into split-issue-83

This commit is contained in:
Priyankar Pal
2025-06-26 11:35:55 +05:30
committed by GitHub
61 changed files with 1682 additions and 702 deletions

View File

@ -0,0 +1,107 @@
"use client";
import {
useTimelineStore,
type TimelineClip,
type TimelineTrack,
} from "@/stores/timeline-store";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { Button } from "@/components/ui/button";
import { useState } from "react";
// Only show in development
const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
interface ActiveClip {
clip: TimelineClip;
track: TimelineTrack;
mediaItem: MediaItem | null;
}
export function DevelopmentDebug() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { currentTime } = usePlaybackStore();
const [showDebug, setShowDebug] = useState(false);
// Don't render anything in production
if (!SHOW_DEBUG_INFO) return null;
// Get active clips at current time
const getActiveClips = (): ActiveClip[] => {
const activeClips: ActiveClip[] = [];
tracks.forEach((track) => {
track.clips.forEach((clip) => {
const clipStart = clip.startTime;
const clipEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) {
const mediaItem =
clip.mediaId === "test"
? null // Test clips don't have a real media item
: mediaItems.find((item) => item.id === clip.mediaId) || null;
activeClips.push({ clip, track, mediaItem });
}
});
});
return activeClips;
};
const activeClips = getActiveClips();
return (
<div className="fixed bottom-4 right-4 z-50">
<div className="flex flex-col items-end gap-2">
{/* Toggle Button */}
<Button
variant="outline"
size="sm"
onClick={() => setShowDebug(!showDebug)}
className="text-xs backdrop-blur-md bg-background/80 border-border/50"
>
Debug {showDebug ? "ON" : "OFF"}
</Button>
{/* Debug Info Panel */}
{showDebug && (
<div className="backdrop-blur-md bg-background/90 border border-border/50 rounded-lg p-3 max-w-sm">
<div className="text-xs font-medium mb-2 text-foreground">
Active Clips ({activeClips.length})
</div>
<div className="space-y-1 max-h-40 overflow-y-auto">
{activeClips.map((clipData, index) => (
<div
key={clipData.clip.id}
className="flex items-center gap-2 px-2 py-1 bg-muted/60 rounded text-xs"
>
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4 flex-shrink-0">
{index + 1}
</span>
<div className="min-w-0 flex-1">
<div className="truncate">{clipData.clip.name}</div>
<div className="text-muted-foreground text-[10px]">
{clipData.mediaItem?.type || "test"}
</div>
</div>
</div>
))}
{activeClips.length === 0 && (
<div className="text-muted-foreground text-xs py-2 text-center">
No active clips
</div>
)}
</div>
<div className="mt-2 pt-2 border-t border-border/30 text-[10px] text-muted-foreground">
Time: {currentTime.toFixed(2)}s
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -3,13 +3,11 @@
import Link from "next/link";
import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
import { ProjectNameEditor } from "./editor/project-name-editor";
export function EditorHeader() {
const { activeProject } = useProjectStore();
const { getTotalDuration } = useTimelineStore();
const handleExport = () => {

View File

@ -3,7 +3,7 @@
import { Button } from "../ui/button";
import { AspectRatio } from "../ui/aspect-ratio";
import { DragOverlay } from "../ui/drag-overlay";
import { useMediaStore } from "@/stores/media-store";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { processMediaFiles } from "@/lib/media-processing";
import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
import { useDragDrop } from "@/hooks/use-drag-drop";
@ -17,27 +17,28 @@ export function MediaPanel() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => {
// If no files, do nothing
if (!files?.length) return;
if (!files || files.length === 0) return;
setIsProcessing(true);
setProgress(0);
try {
// Process files (extract metadata, generate thumbnails, etc.)
const items = await processMediaFiles(files);
const processedItems = await processMediaFiles(files, (p) =>
setProgress(p)
);
// Add each processed media item to the store
items.forEach((item) => {
addMediaItem(item);
});
processedItems.forEach((item) => addMediaItem(item));
} catch (error) {
// Show error if processing fails
console.error("File processing failed:", error);
// Show error toast if processing fails
console.error("Error processing files:", error);
toast.error("Failed to process files");
} finally {
setIsProcessing(false);
setProgress(0);
}
};
@ -67,7 +68,7 @@ export function MediaPanel() {
return `${min}:${sec.toString().padStart(2, "0")}`;
};
const startDrag = (e: React.DragEvent, item: any) => {
const startDrag = (e: React.DragEvent, item: MediaItem) => {
// When dragging a media item, set drag data for timeline to read
e.dataTransfer.setData(
"application/x-media-item",
@ -101,7 +102,7 @@ export function MediaPanel() {
setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]);
const renderPreview = (item: any) => {
const renderPreview = (item: MediaItem) => {
// Render a preview for each media type (image, video, audio, unknown)
// Each preview is draggable to the timeline
const baseDragProps = {
@ -241,15 +242,12 @@ export function MediaPanel() {
{isProcessing ? (
<>
<Upload className="h-4 w-4 animate-spin" />
<span className="hidden md:inline ml-2">Processing...</span>
<span className="hidden md:inline ml-2">{progress}%</span>
</>
) : (
<>
<Plus className="h-4 w-4" />
<span
className="hidden sm:inline ml-2"
aria-label="Add file"
>
<span className="hidden sm:inline ml-2" aria-label="Add file">
Add
</span>
</>

View File

@ -1,246 +1,283 @@
"use client";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
import { Play, Pause, Volume2, VolumeX } from "lucide-react";
import { useState, useRef } from "react";
// Debug flag - set to false to hide active clips info
const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { isPlaying, toggle, currentTime, muted, toggleMute, volume } =
usePlaybackStore();
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO);
const previewRef = useRef<HTMLDivElement>(null);
// Get active clips at current time
const getActiveClips = () => {
const activeClips: Array<{
clip: any;
track: any;
mediaItem: any;
}> = [];
tracks.forEach((track) => {
track.clips.forEach((clip) => {
const clipStart = clip.startTime;
const clipEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) {
const mediaItem =
clip.mediaId === "test"
? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
: mediaItems.find((item) => item.id === clip.mediaId);
if (mediaItem || clip.mediaId === "test") {
activeClips.push({ clip, track, mediaItem });
}
}
});
});
return activeClips;
};
const activeClips = getActiveClips();
const aspectRatio = canvasSize.width / canvasSize.height;
// Render a clip
const renderClip = (clipData: any, index: number) => {
const { clip, mediaItem } = clipData;
// Test clips
if (!mediaItem || clip.mediaId === "test") {
return (
<div
key={clip.id}
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
>
<div className="text-center">
<div className="text-2xl mb-2">🎬</div>
<p className="text-xs text-white">{clip.name}</p>
</div>
</div>
);
}
// Video clips
if (mediaItem.type === "video") {
return (
<div key={clip.id} className="absolute inset-0">
<VideoPlayer
src={mediaItem.url}
poster={mediaItem.thumbnailUrl}
clipStartTime={clip.startTime}
trimStart={clip.trimStart}
trimEnd={clip.trimEnd}
clipDuration={clip.duration}
/>
</div>
);
}
// Image clips
if (mediaItem.type === "image") {
return (
<div key={clip.id} className="absolute inset-0">
<img
src={mediaItem.url}
alt={mediaItem.name}
className="w-full h-full object-cover"
draggable={false}
/>
</div>
);
}
// Audio clips (visual representation)
if (mediaItem.type === "audio") {
return (
<div
key={clip.id}
className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
>
<div className="text-center">
<div className="text-2xl mb-2">🎵</div>
<p className="text-xs text-white">{mediaItem.name}</p>
</div>
</div>
);
}
return null;
};
// Canvas presets
const canvasPresets = [
{ name: "16:9 HD", width: 1920, height: 1080 },
{ name: "16:9 4K", width: 3840, height: 2160 },
{ name: "9:16 Mobile", width: 1080, height: 1920 },
{ name: "1:1 Square", width: 1080, height: 1080 },
{ name: "4:3 Standard", width: 1440, height: 1080 },
];
return (
<div className="h-full w-full flex flex-col min-h-0 min-w-0">
{/* Controls */}
<div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
<span className="text-muted-foreground">Canvas:</span>
<select
value={`${canvasSize.width}x${canvasSize.height}`}
onChange={(e) => {
const preset = canvasPresets.find(
(p) => `${p.width}x${p.height}` === e.target.value
);
if (preset)
setCanvasSize({ width: preset.width, height: preset.height });
}}
className="bg-background border rounded px-2 py-1 text-xs"
>
{canvasPresets.map((preset) => (
<option
key={preset.name}
value={`${preset.width}x${preset.height}`}
>
{preset.name} ({preset.width}×{preset.height})
</option>
))}
</select>
{/* Debug Toggle - Only show in development */}
{SHOW_DEBUG_INFO && (
<Button
variant="text"
size="sm"
onClick={() => setShowDebug(!showDebug)}
className="text-xs"
>
Debug {showDebug ? "ON" : "OFF"}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={toggleMute}
className="ml-auto"
>
{muted || volume === 0 ? (
<VolumeX className="h-3 w-3 mr-1" />
) : (
<Volume2 className="h-3 w-3 mr-1" />
)}
{muted || volume === 0 ? "Unmute" : "Mute"}
</Button>
<Button variant="outline" size="sm" onClick={toggle}>
{isPlaying ? (
<Pause className="h-3 w-3 mr-1" />
) : (
<Play className="h-3 w-3 mr-1" />
)}
{isPlaying ? "Pause" : "Play"}
</Button>
</div>
{/* Preview Area */}
<div className="flex-1 flex items-center justify-center p-2 sm:p-4 bg-gray-900 min-h-0 min-w-0">
<div
ref={previewRef}
className="relative overflow-hidden rounded-sm max-w-full max-h-full bg-black border border-gray-600"
style={{
aspectRatio: aspectRatio.toString(),
width: "100%",
height: "100%",
}}
>
{activeClips.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-white/50">
{tracks.length === 0
? "Drop media to start editing"
: "No clips at current time"}
</div>
) : (
activeClips.map((clipData, index) => renderClip(clipData, index))
)}
</div>
</div>
{/* Debug Info Panel - Conditionally rendered */}
{showDebug && (
<div className="border-t bg-background p-2 flex-shrink-0">
<div className="text-xs font-medium mb-1">
Debug: Active Clips ({activeClips.length})
</div>
<div className="flex gap-2 overflow-x-auto">
{activeClips.map((clipData, index) => (
<div
key={clipData.clip.id}
className="flex items-center gap-1 px-2 py-1 bg-muted rounded text-xs whitespace-nowrap"
>
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4">
{index + 1}
</span>
<span>{clipData.clip.name}</span>
<span className="text-muted-foreground">
({clipData.mediaItem?.type || "test"})
</span>
</div>
))}
{activeClips.length === 0 && (
<span className="text-muted-foreground">No active clips</span>
)}
</div>
</div>
)}
</div>
);
}
"use client";
import {
useTimelineStore,
type TimelineClip,
type TimelineTrack,
} from "@/stores/timeline-store";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
import { Play, Pause, Volume2, VolumeX, Plus } from "lucide-react";
import { useState, useRef, useEffect } from "react";
interface ActiveClip {
clip: TimelineClip;
track: TimelineTrack;
mediaItem: MediaItem | null;
}
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { currentTime, muted, toggleMute, volume } = usePlaybackStore();
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
const previewRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [previewDimensions, setPreviewDimensions] = useState({
width: 0,
height: 0,
});
// Calculate optimal preview size that fits in container while maintaining aspect ratio
useEffect(() => {
const updatePreviewSize = () => {
if (!containerRef.current) return;
const container = containerRef.current.getBoundingClientRect();
const computedStyle = getComputedStyle(containerRef.current);
// Get padding values
const paddingTop = parseFloat(computedStyle.paddingTop);
const paddingBottom = parseFloat(computedStyle.paddingBottom);
const paddingLeft = parseFloat(computedStyle.paddingLeft);
const paddingRight = parseFloat(computedStyle.paddingRight);
// Get gap value (gap-4 = 1rem = 16px)
const gap = parseFloat(computedStyle.gap) || 16;
// Get toolbar height if it exists
const toolbar = containerRef.current.querySelector("[data-toolbar]");
const toolbarHeight = toolbar
? toolbar.getBoundingClientRect().height
: 0;
// Calculate available space after accounting for padding, gap, and toolbar
const availableWidth = container.width - paddingLeft - paddingRight;
const availableHeight =
container.height -
paddingTop -
paddingBottom -
toolbarHeight -
(toolbarHeight > 0 ? gap : 0);
const targetRatio = canvasSize.width / canvasSize.height;
const containerRatio = availableWidth / availableHeight;
let width, height;
if (containerRatio > targetRatio) {
// Container is wider - constrain by height
height = availableHeight;
width = height * targetRatio;
} else {
// Container is taller - constrain by width
width = availableWidth;
height = width / targetRatio;
}
setPreviewDimensions({ width, height });
};
updatePreviewSize();
const resizeObserver = new ResizeObserver(updatePreviewSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => resizeObserver.disconnect();
}, [canvasSize.width, canvasSize.height]);
// Get active clips at current time
const getActiveClips = (): ActiveClip[] => {
const activeClips: ActiveClip[] = [];
tracks.forEach((track) => {
track.clips.forEach((clip) => {
const clipStart = clip.startTime;
const clipEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) {
const mediaItem =
clip.mediaId === "test"
? null // Test clips don't have a real media item
: mediaItems.find((item) => item.id === clip.mediaId) || null;
activeClips.push({ clip, track, mediaItem });
}
});
});
return activeClips;
};
const activeClips = getActiveClips();
// Render a clip
const renderClip = (clipData: ActiveClip, index: number) => {
const { clip, mediaItem } = clipData;
// Test clips
if (!mediaItem || clip.mediaId === "test") {
return (
<div
key={clip.id}
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
>
<div className="text-center">
<div className="text-2xl mb-2">🎬</div>
<p className="text-xs text-white">{clip.name}</p>
</div>
</div>
);
}
// Video clips
if (mediaItem.type === "video") {
return (
<div key={clip.id} className="absolute inset-0">
<VideoPlayer
src={mediaItem.url}
poster={mediaItem.thumbnailUrl}
clipStartTime={clip.startTime}
trimStart={clip.trimStart}
trimEnd={clip.trimEnd}
clipDuration={clip.duration}
/>
</div>
);
}
// Image clips
if (mediaItem.type === "image") {
return (
<div key={clip.id} className="absolute inset-0">
<img
src={mediaItem.url}
alt={mediaItem.name}
className="w-full h-full object-cover"
draggable={false}
/>
</div>
);
}
// Audio clips (visual representation)
if (mediaItem.type === "audio") {
return (
<div
key={clip.id}
className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
>
<div className="text-center">
<div className="text-2xl mb-2">🎵</div>
<p className="text-xs text-white">{mediaItem.name}</p>
</div>
</div>
);
}
return null;
};
// Canvas presets
const canvasPresets = [
{ name: "16:9 HD", width: 1920, height: 1080 },
{ name: "16:9 4K", width: 3840, height: 2160 },
{ name: "9:16 Mobile", width: 1080, height: 1920 },
{ name: "1:1 Square", width: 1080, height: 1080 },
{ name: "4:3 Standard", width: 1440, height: 1080 },
];
return (
<div className="h-full w-full flex flex-col min-h-0 min-w-0">
{/* Controls */}
<div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
<span className="text-muted-foreground">Canvas:</span>
<select
value={`${canvasSize.width}x${canvasSize.height}`}
onChange={(e) => {
const preset = canvasPresets.find(
(p) => `${p.width}x${p.height}` === e.target.value
);
if (preset)
setCanvasSize({ width: preset.width, height: preset.height });
}}
className="bg-background border rounded px-2 py-1 text-xs"
>
{canvasPresets.map((preset) => (
<option
key={preset.name}
value={`${preset.width}x${preset.height}`}
>
{preset.name} ({preset.width}×{preset.height})
</option>
))}
</select>
<Button
variant="outline"
size="sm"
onClick={toggleMute}
className="ml-auto"
>
{muted || volume === 0 ? (
<VolumeX className="h-3 w-3 mr-1" />
) : (
<Volume2 className="h-3 w-3 mr-1" />
)}
{muted || volume === 0 ? "Unmute" : "Mute"}
</Button>
</div>
{/* Preview Area */}
<div
ref={containerRef}
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
>
<div
ref={previewRef}
className="relative overflow-hidden rounded-sm bg-black border"
style={{
width: previewDimensions.width,
height: previewDimensions.height,
}}
>
{activeClips.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
{tracks.length === 0
? "Drop media to start editing"
: "No clips at current time"}
</div>
) : (
activeClips.map((clipData, index) => renderClip(clipData, index))
)}
</div>
<PreviewToolbar />
</div>
</div>
);
}
function PreviewToolbar() {
const { isPlaying, toggle } = usePlaybackStore();
return (
<div
data-toolbar
className="flex items-center justify-center gap-2 px-4 pt-2 bg-background-500 w-full"
>
<Button variant="text" size="icon" onClick={toggle}>
{isPlaying ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
</div>
);
}

View File

@ -17,13 +17,12 @@ import { useMediaStore } from "@/stores/media-store";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
import { useState } from "react";
import { SpeedControl } from "./speed-control";
import type { BackgroundType } from "@/types/editor";
export function PropertiesPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const [backgroundType, setBackgroundType] = useState<
"blur" | "mirror" | "color"
>("blur");
const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
const [backgroundColor, setBackgroundColor] = useState("#000000");
// Get the first video clip for preview (simplified)
@ -78,7 +77,9 @@ export function PropertiesPanel() {
<Label htmlFor="bg-type">Background Type</Label>
<Select
value={backgroundType}
onValueChange={(value: any) => setBackgroundType(value)}
onValueChange={(value: BackgroundType) =>
setBackgroundType(value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select background type" />

View File

@ -0,0 +1,276 @@
"use client";
import { useState } from "react";
import { Button } from "../ui/button";
import { MoreVertical, Scissors, Trash2 } from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useDragClip } from "@/hooks/use-drag-clip";
import AudioWaveform from "./audio-waveform";
import { toast } from "sonner";
import { TimelineClipProps, ResizeState } from "@/types/timeline";
export function TimelineClip({
clip,
track,
zoomLevel,
isSelected,
onContextMenu,
onClipMouseDown,
onClipClick,
}: TimelineClipProps) {
const { mediaItems } = useMediaStore();
const { updateClipTrim, addClipToTrack, removeClipFromTrack, dragState } =
useTimelineStore();
const { currentTime } = usePlaybackStore();
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [clipMenuOpen, setClipMenuOpen] = useState(false);
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
// Use real-time position during drag, otherwise use stored position
const isBeingDragged = dragState.clipId === clip.id;
const clipStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
: clip.startTime;
const clipLeft = clipStartTime * 50 * zoomLevel;
const getTrackColor = (type: string) => {
switch (type) {
case "video":
return "bg-blue-500/20 border-blue-500/30";
case "audio":
return "bg-green-500/20 border-green-500/30";
case "effects":
return "bg-purple-500/20 border-purple-500/30";
default:
return "bg-gray-500/20 border-gray-500/30";
}
};
const handleResizeStart = (
e: React.MouseEvent,
clipId: string,
side: "left" | "right"
) => {
e.stopPropagation();
e.preventDefault();
setResizing({
clipId,
side,
startX: e.clientX,
initialTrimStart: clip.trimStart,
initialTrimEnd: clip.trimEnd,
});
};
const updateTrimFromMouseMove = (e: { clientX: number }) => {
if (!resizing) return;
const deltaX = e.clientX - resizing.startX;
const deltaTime = deltaX / (50 * zoomLevel);
if (resizing.side === "left") {
const newTrimStart = Math.max(
0,
Math.min(
clip.duration - clip.trimEnd - 0.1,
resizing.initialTrimStart + deltaTime
)
);
updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd);
} else {
const newTrimEnd = Math.max(
0,
Math.min(
clip.duration - clip.trimStart - 0.1,
resizing.initialTrimEnd - deltaTime
)
);
updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd);
}
};
const handleResizeMove = (e: React.MouseEvent) => {
updateTrimFromMouseMove(e);
};
const handleResizeEnd = () => {
setResizing(null);
};
const handleDeleteClip = () => {
removeClipFromTrack(track.id, clip.id);
setClipMenuOpen(false);
};
const handleSplitClip = () => {
// Use current playback time as split point
const splitTime = currentTime;
// Only split if splitTime is within the clip's effective range
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) {
toast.error("Playhead must be within clip to split");
return;
}
const firstDuration = splitTime - effectiveStart;
const secondDuration = effectiveEnd - splitTime;
// First part: adjust original clip
updateClipTrim(
track.id,
clip.id,
clip.trimStart,
clip.trimEnd + secondDuration
);
// Second part: add new clip after split
addClipToTrack(track.id, {
mediaId: clip.mediaId,
name: clip.name + " (cut)",
duration: clip.duration,
startTime: splitTime,
trimStart: clip.trimStart + firstDuration,
trimEnd: clip.trimEnd,
});
setClipMenuOpen(false);
toast.success("Clip split successfully");
};
const renderClipContent = () => {
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
if (!mediaItem) {
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
}
if (mediaItem.type === "image") {
return (
<div className="w-full h-full flex items-center justify-center">
<img
src={mediaItem.url}
alt={mediaItem.name}
className="w-full h-full object-cover"
draggable={false}
/>
</div>
);
}
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
return (
<div className="w-full h-full flex items-center gap-2">
<div className="w-8 h-8 flex-shrink-0">
<img
src={mediaItem.thumbnailUrl}
alt={mediaItem.name}
className="w-full h-full object-cover rounded-sm"
draggable={false}
/>
</div>
<span className="text-xs text-foreground/80 truncate flex-1">
{clip.name}
</span>
</div>
);
}
if (mediaItem.type === "audio") {
return (
<div className="w-full h-full flex items-center gap-2">
<div className="flex-1 min-w-0">
<AudioWaveform
audioUrl={mediaItem.url}
height={24}
className="w-full"
/>
</div>
</div>
);
}
// Fallback for videos without thumbnails
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
};
return (
<div
className={`timeline-clip absolute h-full border ${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" : ""} ${isBeingDragged ? "shadow-lg z-20" : ""}`}
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
onMouseDown={(e) => onClipMouseDown(e, clip)}
onClick={(e) => onClipClick(e, clip)}
onMouseMove={handleResizeMove}
onMouseUp={handleResizeEnd}
onMouseLeave={handleResizeEnd}
tabIndex={0}
onContextMenu={(e) => onContextMenu(e, clip.id)}
>
{/* Left trim handle */}
<div
className={`absolute left-0 top-0 bottom-0 w-2 cursor-w-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`}
onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
/>
{/* Clip content */}
<div className="flex-1 relative">
{renderClipContent()}
{/* Clip options menu */}
<div className="absolute top-1 right-1 z-10">
<Button
variant="text"
size="icon"
className="opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
setClipMenuOpen(!clipMenuOpen);
}}
onMouseDown={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</Button>
{clipMenuOpen && (
<div
className="absolute right-0 mt-2 w-32 bg-white border rounded shadow z-50"
onMouseDown={(e) => e.stopPropagation()}
>
<button
className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted/30"
onClick={handleSplitClip}
>
<Scissors className="h-4 w-4 mr-2" /> Split
</button>
<button
className="flex items-center w-full px-3 py-2 text-sm text-red-600 hover:bg-red-50"
onClick={handleDeleteClip}
>
<Trash2 className="h-4 w-4 mr-2" /> Delete
</button>
</div>
)}
</div>
</div>
{/* Right trim handle */}
<div
className={`absolute right-0 top-0 bottom-0 w-2 cursor-e-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`}
onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
/>
</div>
);
}

View File

@ -1860,3 +1860,4 @@ function TimelineTrackContent({
</div>
);
}

View File

@ -29,7 +29,7 @@ export function HeaderBase({
return (
<header
className={cn("px-6 h-16 flex justify-between items-center", className)}
className={cn("px-6 h-14 flex justify-between items-center", className)}
>
{leftContent && <div className="flex items-center">{leftContent}</div>}
{centerContent && (

View File

@ -1,14 +1,13 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
import { useSession } from "@opencut/auth/client";
import { getStars } from "@/lib/fetchGhStars";
import { Star } from "lucide-react";
import { useEffect, useState } from "react";
import Image from "next/image";
export function Header() {
const { data: session } = useSession();
@ -29,15 +28,15 @@ export function Header() {
const leftContent = (
<Link href="/" className="flex items-center gap-3">
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
<span className="font-medium tracking-tight">OpenCut</span>
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
<span className="text-xl font-medium hidden md:block">OpenCut</span>
</Link>
);
const rightContent = (
<nav className="flex items-center">
<nav className="flex items-center gap-3">
<Link href="/contributors">
<Button variant="text" className="text-sm">
<Button variant="text" className="text-sm p-0">
Contributors
</Button>
</Link>
@ -59,5 +58,13 @@ export function Header() {
</nav>
);
return <HeaderBase leftContent={leftContent} rightContent={rightContent} />;
return (
<div className="mx-4 md:mx-0">
<HeaderBase
className="bg-[#1D1D1D] border border-white/10 rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
leftContent={leftContent}
rightContent={rightContent}
/>
</div>
);
}

View File

@ -29,7 +29,7 @@ export function GithubIcon({ className }: { className?: string }) {
viewBox="0 -3.5 256 256"
preserveAspectRatio="xMinYMin meet"
>
<g fill="#161614">
<g fill="currentColor">
<path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" />
<path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" />

View File

@ -8,6 +8,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { getStars } from "@/lib/fetchGhStars";
import Image from "next/image";
interface HeroProps {
signupCount: number;
@ -82,7 +83,7 @@ export function Hero({ signupCount }: HeroProps) {
};
return (
<div className="min-h-[calc(100vh-4rem)] flex flex-col justify-between items-center text-center px-4">
<div className="min-h-[calc(100vh-6rem)] supports-[height:100dvh]:min-h-[calc(100dvh-6rem)] flex flex-col justify-between items-center text-center px-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@ -93,18 +94,21 @@ export function Hero({ signupCount }: HeroProps) {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.8 }}
className="inline-block"
className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
>
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter">
The open source
</h1>
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
video editor
</h1>
<h1>The Open Source</h1>
<div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
<div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2">
<Image src="/frame.svg" height={79} width={459} alt="frame" />
<span className="absolute inset-0 flex items-center justify-center">
Video Editor
</span>
</div>
</div>
</motion.div>
<motion.p
className="mt-10 text-lg sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4, duration: 0.8 }}
@ -119,7 +123,10 @@ export function Hero({ signupCount }: HeroProps) {
animate={{ opacity: 1 }}
transition={{ delay: 0.6, duration: 0.8 }}
>
<form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg">
<form
onSubmit={handleSubmit}
className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
>
<Input
type="email"
placeholder="Enter your email"

View File

@ -2,13 +2,14 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import type { BackgroundType } from "@/types/editor";
interface ImageTimelineTreatmentProps {
src: string;
alt: string;
targetAspectRatio?: number; // Default to 16:9 for video
className?: string;
backgroundType?: "blur" | "mirror" | "color";
backgroundType?: BackgroundType;
backgroundColor?: string;
}