feat: initial state for composition and overlay editing on the video player
This commit is contained in:
@ -7,7 +7,7 @@ import {
|
||||
ResizableHandle,
|
||||
} from "../../components/ui/resizable";
|
||||
import { MediaPanel } from "../../components/editor/media-panel";
|
||||
import { PropertiesPanel } from "../../components/editor/properties-panel";
|
||||
// import { PropertiesPanel } from "../../components/editor/properties-panel";
|
||||
import { Timeline } from "../../components/editor/timeline";
|
||||
import { PreviewPanel } from "../../components/editor/preview-panel";
|
||||
import { EditorHeader } from "@/components/editor-header";
|
||||
@ -74,13 +74,13 @@ export default function Editor() {
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Properties Panel */}
|
||||
<ResizablePanel
|
||||
{/* <ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
onResize={setPropertiesPanel}
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanel> */}
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
|
@ -3,110 +3,397 @@
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
|
||||
import { VideoPlayer } from "@/components/ui/video-player";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import { Play, Pause, Move, RotateCw, Crop, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
|
||||
interface ClipTransform {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
rotation: number;
|
||||
opacity: number;
|
||||
width: number;
|
||||
height: number;
|
||||
blendMode: string;
|
||||
cropTop: number;
|
||||
cropBottom: number;
|
||||
cropLeft: number;
|
||||
cropRight: number;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
isDragging: boolean;
|
||||
dragType: 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se' | 'rotate' | 'scale' | 'crop-n' | 'crop-s' | 'crop-e' | 'crop-w';
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
startTransform: ClipTransform;
|
||||
clipId: string;
|
||||
}
|
||||
|
||||
export function PreviewPanel() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const { isPlaying, toggle, currentTime } = usePlaybackStore();
|
||||
|
||||
// Find the active clip at the current playback time
|
||||
const getActiveClip = () => {
|
||||
for (const track of tracks) {
|
||||
for (const clip of track.clips) {
|
||||
const [clipTransforms, setClipTransforms] = useState<Record<string, ClipTransform>>({});
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 }); // Default 16:9
|
||||
const [dragState, setDragState] = useState<DragState | null>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get all active clips at current time (for overlaying)
|
||||
const getActiveClips = () => {
|
||||
const activeClips: Array<{
|
||||
clip: any;
|
||||
track: any;
|
||||
mediaItem: any;
|
||||
layer: number;
|
||||
}> = [];
|
||||
|
||||
tracks.forEach((track, trackIndex) => {
|
||||
track.clips.forEach((clip) => {
|
||||
const clipStart = clip.startTime;
|
||||
const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime >= clipStart && currentTime < clipEnd) {
|
||||
return clip;
|
||||
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,
|
||||
layer: trackIndex, // Track index determines layer order
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by layer (track order) - higher index = on top
|
||||
return activeClips.sort((a, b) => a.layer - b.layer);
|
||||
};
|
||||
|
||||
const activeClips = getActiveClips();
|
||||
const aspectRatio = canvasSize.width / canvasSize.height;
|
||||
|
||||
// Get or create transform for a clip
|
||||
const getClipTransform = (clipId: string): ClipTransform => {
|
||||
return clipTransforms[clipId] || {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
width: 100, // Percentage of canvas
|
||||
height: 100,
|
||||
blendMode: 'normal',
|
||||
cropTop: 0,
|
||||
cropBottom: 0,
|
||||
cropLeft: 0,
|
||||
cropRight: 0,
|
||||
};
|
||||
};
|
||||
|
||||
// Update clip transform
|
||||
const updateClipTransform = useCallback((clipId: string, updates: Partial<ClipTransform>) => {
|
||||
setClipTransforms(prev => {
|
||||
const currentTransform = prev[clipId] || {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
width: 100,
|
||||
height: 100,
|
||||
blendMode: 'normal',
|
||||
cropTop: 0,
|
||||
cropBottom: 0,
|
||||
cropLeft: 0,
|
||||
cropRight: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[clipId]: { ...currentTransform, ...updates }
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Mouse event handlers
|
||||
const handleMouseDown = (e: React.MouseEvent, clipId: string, dragType: DragState['dragType']) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
dragType,
|
||||
startMouseX: e.clientX,
|
||||
startMouseY: e.clientY,
|
||||
startTransform: getClipTransform(clipId),
|
||||
clipId
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!dragState || !dragState.isDragging) return;
|
||||
|
||||
const deltaX = e.clientX - dragState.startMouseX;
|
||||
const deltaY = e.clientY - dragState.startMouseY;
|
||||
const { startTransform, clipId, dragType } = dragState;
|
||||
|
||||
switch (dragType) {
|
||||
case 'move':
|
||||
updateClipTransform(clipId, {
|
||||
x: Math.max(-100, Math.min(100, startTransform.x + deltaX * 0.3)),
|
||||
y: Math.max(-100, Math.min(100, startTransform.y + deltaY * 0.3))
|
||||
});
|
||||
break;
|
||||
|
||||
case 'resize-nw':
|
||||
updateClipTransform(clipId, {
|
||||
width: Math.max(20, startTransform.width - deltaX * 0.5),
|
||||
height: Math.max(20, startTransform.height - deltaY * 0.5)
|
||||
});
|
||||
break;
|
||||
|
||||
case 'resize-ne':
|
||||
updateClipTransform(clipId, {
|
||||
width: Math.max(20, startTransform.width + deltaX * 0.5),
|
||||
height: Math.max(20, startTransform.height - deltaY * 0.5)
|
||||
});
|
||||
break;
|
||||
|
||||
case 'resize-sw':
|
||||
updateClipTransform(clipId, {
|
||||
width: Math.max(20, startTransform.width - deltaX * 0.5),
|
||||
height: Math.max(20, startTransform.height + deltaY * 0.5)
|
||||
});
|
||||
break;
|
||||
|
||||
case 'resize-se':
|
||||
updateClipTransform(clipId, {
|
||||
width: Math.max(20, startTransform.width + deltaX * 0.5),
|
||||
height: Math.max(20, startTransform.height + deltaY * 0.5)
|
||||
});
|
||||
break;
|
||||
|
||||
case 'rotate':
|
||||
updateClipTransform(clipId, {
|
||||
rotation: (startTransform.rotation + deltaX * 2) % 360
|
||||
});
|
||||
break;
|
||||
|
||||
case 'scale':
|
||||
updateClipTransform(clipId, {
|
||||
scale: Math.max(0.1, Math.min(3, startTransform.scale + deltaX * 0.01))
|
||||
});
|
||||
break;
|
||||
|
||||
case 'crop-n':
|
||||
updateClipTransform(clipId, {
|
||||
cropTop: Math.max(0, Math.min(40, startTransform.cropTop + deltaY * 0.2))
|
||||
});
|
||||
break;
|
||||
|
||||
case 'crop-s':
|
||||
updateClipTransform(clipId, {
|
||||
cropBottom: Math.max(0, Math.min(40, startTransform.cropBottom - deltaY * 0.2))
|
||||
});
|
||||
break;
|
||||
|
||||
case 'crop-e':
|
||||
updateClipTransform(clipId, {
|
||||
cropRight: Math.max(0, Math.min(40, startTransform.cropRight - deltaX * 0.2))
|
||||
});
|
||||
break;
|
||||
|
||||
case 'crop-w':
|
||||
updateClipTransform(clipId, {
|
||||
cropLeft: Math.max(0, Math.min(40, startTransform.cropLeft + deltaX * 0.2))
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, [dragState, updateClipTransform]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setDragState(null);
|
||||
}, []);
|
||||
|
||||
// Add global mouse event listeners
|
||||
useEffect(() => {
|
||||
if (dragState?.isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [dragState, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// Initialize transforms for new clips
|
||||
useEffect(() => {
|
||||
const activeClips = getActiveClips();
|
||||
const newTransforms: Record<string, ClipTransform> = {};
|
||||
let hasNewClips = false;
|
||||
|
||||
activeClips.forEach(({ clip }) => {
|
||||
if (!clipTransforms[clip.id]) {
|
||||
hasNewClips = true;
|
||||
newTransforms[clip.id] = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
width: 100,
|
||||
height: 100,
|
||||
blendMode: 'normal',
|
||||
cropTop: 0,
|
||||
cropBottom: 0,
|
||||
cropLeft: 0,
|
||||
cropRight: 0,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (hasNewClips) {
|
||||
setClipTransforms(prev => ({ ...prev, ...newTransforms }));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}, [tracks, currentTime]); // Re-run when tracks or time changes
|
||||
|
||||
const activeClip = getActiveClip();
|
||||
const activeMediaItem = activeClip
|
||||
? mediaItems.find((item) => item.id === activeClip.mediaId)
|
||||
: null;
|
||||
|
||||
const aspectRatio = activeMediaItem?.aspectRatio || 16 / 9;
|
||||
|
||||
const renderContent = () => {
|
||||
if (!activeClip) {
|
||||
// Render a single clip layer
|
||||
const renderClipLayer = (clipData: any, index: number) => {
|
||||
const { clip, mediaItem } = clipData;
|
||||
const transform = getClipTransform(clip.id);
|
||||
|
||||
const layerStyle = {
|
||||
position: 'absolute' as const,
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: `${transform.width}%`,
|
||||
height: `${transform.height}%`,
|
||||
transform: `translate(-50%, -50%) translate(${transform.x}%, ${transform.y}%) scale(${transform.scale}) rotate(${transform.rotation}deg)`,
|
||||
opacity: transform.opacity,
|
||||
mixBlendMode: transform.blendMode as any,
|
||||
clipPath: `inset(${transform.cropTop}% ${transform.cropRight}% ${transform.cropBottom}% ${transform.cropLeft}%)`,
|
||||
zIndex: index + 10,
|
||||
cursor: dragState?.isDragging && dragState.clipId === clip.id ? 'grabbing' : 'grab',
|
||||
userSelect: 'none' as const,
|
||||
};
|
||||
|
||||
const handleClipMouseDown = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
handleMouseDown(e, clip.id, 'move');
|
||||
};
|
||||
|
||||
// Handle test clips
|
||||
if (!mediaItem || clip.mediaId === "test") {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground/50">
|
||||
{tracks.length === 0 ? "Drop media to start editing" : "No clip at current time"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle test clips without media items
|
||||
if (!activeMediaItem && activeClip.mediaId === "test") {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-500/20 to-purple-500/20">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">🎬</div>
|
||||
<p className="text-muted-foreground">{activeClip.name}</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-2">Test clip for playback</p>
|
||||
<div
|
||||
key={clip.id}
|
||||
style={layerStyle}
|
||||
onMouseDown={handleClipMouseDown}
|
||||
className="bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center group"
|
||||
>
|
||||
<div className="text-center pointer-events-none">
|
||||
<div className="text-2xl">🎬</div>
|
||||
<p className="text-xs text-white">{clip.name}</p>
|
||||
</div>
|
||||
|
||||
{/* Hover resize corners */}
|
||||
<div className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}></div>
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}></div>
|
||||
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}></div>
|
||||
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeMediaItem) {
|
||||
// Render video
|
||||
if (mediaItem.type === "video") {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground/50">
|
||||
Media not found
|
||||
<div key={clip.id} style={layerStyle} onMouseDown={handleClipMouseDown} className="group">
|
||||
<VideoPlayer
|
||||
src={mediaItem.url}
|
||||
poster={mediaItem.thumbnailUrl}
|
||||
className="w-full h-full pointer-events-none"
|
||||
clipStartTime={clip.startTime}
|
||||
trimStart={clip.trimStart}
|
||||
trimEnd={clip.trimEnd}
|
||||
clipDuration={clip.duration}
|
||||
/>
|
||||
|
||||
{/* Hover resize corners */}
|
||||
<div className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}></div>
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}></div>
|
||||
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}></div>
|
||||
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeMediaItem.type === "video") {
|
||||
// Render image
|
||||
if (mediaItem.type === "image") {
|
||||
return (
|
||||
<VideoPlayer
|
||||
src={activeMediaItem.url}
|
||||
poster={activeMediaItem.thumbnailUrl}
|
||||
className="w-full h-full"
|
||||
clipStartTime={activeClip.startTime}
|
||||
trimStart={activeClip.trimStart}
|
||||
trimEnd={activeClip.trimEnd}
|
||||
clipDuration={activeClip.duration}
|
||||
key={`${activeClip.id}-${activeClip.trimStart}-${activeClip.trimEnd}`}
|
||||
/>
|
||||
<div key={clip.id} style={layerStyle} onMouseDown={handleClipMouseDown} className="group">
|
||||
<img
|
||||
src={mediaItem.url}
|
||||
alt={mediaItem.name}
|
||||
className="w-full h-full object-cover rounded pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Hover resize corners */}
|
||||
<div className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}></div>
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}></div>
|
||||
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}></div>
|
||||
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeMediaItem.type === "image") {
|
||||
// Render audio (visual representation)
|
||||
if (mediaItem.type === "audio") {
|
||||
return (
|
||||
<ImageTimelineTreatment
|
||||
src={activeMediaItem.url}
|
||||
alt={activeMediaItem.name}
|
||||
targetAspectRatio={aspectRatio}
|
||||
className="w-full h-full"
|
||||
backgroundType="blur"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeMediaItem.type === "audio") {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-green-500/20 to-emerald-500/20">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">🎵</div>
|
||||
<p className="text-muted-foreground">{activeMediaItem.name}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={toggle}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4 mr-2" /> : <Play className="h-4 w-4 mr-2" />}
|
||||
{isPlaying ? "Pause" : "Play"}
|
||||
</Button>
|
||||
<div
|
||||
key={clip.id}
|
||||
style={layerStyle}
|
||||
onMouseDown={handleClipMouseDown}
|
||||
className="bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center group"
|
||||
>
|
||||
<div className="text-center pointer-events-none">
|
||||
<div className="text-2xl">🎵</div>
|
||||
<p className="text-xs text-white">{mediaItem.name}</p>
|
||||
</div>
|
||||
|
||||
{/* Hover resize corners */}
|
||||
<div className="absolute -top-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-nw-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-nw'); }}></div>
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-ne-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-ne'); }}></div>
|
||||
<div className="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-sw-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-sw'); }}></div>
|
||||
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-500 border border-white rounded-sm cursor-se-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, clip.id, 'resize-se'); }}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -114,31 +401,100 @@ export function PreviewPanel() {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Canvas size 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 flex flex-col items-center justify-center p-4 overflow-hidden">
|
||||
<div
|
||||
className="bg-black rounded-lg shadow-lg relative overflow-hidden flex-shrink-0"
|
||||
style={{
|
||||
aspectRatio: aspectRatio.toString(),
|
||||
width: aspectRatio > 1 ? "100%" : "auto",
|
||||
height: aspectRatio <= 1 ? "100%" : "auto",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Canvas Controls */}
|
||||
<div className="border-b p-2 flex items-center gap-2 text-xs">
|
||||
<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"
|
||||
>
|
||||
{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={toggle}
|
||||
className="ml-auto"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-3 w-3 mr-1" /> : <Play className="h-3 w-3 mr-1" />}
|
||||
{isPlaying ? "Pause" : "Play"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{activeMediaItem && (
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeMediaItem.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{aspectRatio.toFixed(2)} • {aspectRatio > 1 ? "Landscape" : aspectRatio < 1 ? "Portrait" : "Square"}
|
||||
</p>
|
||||
{/* Preview Area - Full Width */}
|
||||
<div className="flex-1 flex items-center justify-center p-4 bg-gray-900">
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
aspectRatio: aspectRatio.toString(),
|
||||
width: aspectRatio > 1 ? "100%" : "auto",
|
||||
height: aspectRatio <= 1 ? "100%" : "auto",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
background: '#000000',
|
||||
border: '1px solid #374151'
|
||||
}}
|
||||
|
||||
>
|
||||
|
||||
|
||||
{/* Render all active clips as layers */}
|
||||
{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) => renderClipLayer(clipData, index))
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Info Panel */}
|
||||
<div className="border-t bg-background">
|
||||
{/* Layer List */}
|
||||
<div className="p-2 border-b">
|
||||
<div className="text-xs font-medium mb-2">Active Layers ({activeClips.length})</div>
|
||||
<div className="space-y-1 max-h-20 overflow-y-auto">
|
||||
{activeClips.map((clipData, index) => (
|
||||
<div
|
||||
key={clipData.clip.id}
|
||||
className="flex items-center gap-2 p-1 rounded text-xs hover:bg-muted/50"
|
||||
>
|
||||
<span className="w-4 h-4 bg-muted rounded text-center text-xs leading-4">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{clipData.clip.name}</span>
|
||||
<span className="text-muted-foreground">{clipData.track.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user