Files
OpenCut/apps/web/src/components/editor/preview-panel.tsx
T

394 lines
12 KiB
TypeScript
Raw Normal View History

2025-06-25 22:55:25 +02:00
"use client";
import {
useTimelineStore,
type TimelineTrack,
} from "@/stores/timeline-store";
import { TimelineElement } from "@/types/timeline";
import {
useMediaStore,
type MediaItem,
getMediaAspectRatio,
} from "@/stores/media-store";
2025-06-25 22:55:25 +02:00
import { usePlaybackStore } from "@/stores/playback-store";
import { useEditorStore } from "@/stores/editor-store";
2025-06-25 22:55:25 +02:00
import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
2025-07-01 00:58:21 +02:00
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Play, Pause } from "lucide-react";
2025-06-25 22:55:25 +02:00
import { useState, useRef, useEffect } from "react";
2025-07-01 00:58:21 +02:00
import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
2025-06-25 22:55:25 +02:00
interface ActiveElement {
element: TimelineElement;
2025-06-25 22:55:25 +02:00
track: TimelineTrack;
mediaItem: MediaItem | null;
}
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { currentTime } = usePlaybackStore();
const { canvasSize } = useEditorStore();
2025-06-25 22:55:25 +02:00
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);
2025-06-25 22:55:25 +02:00
const targetRatio = canvasSize.width / canvasSize.height;
const containerRatio = availableWidth / availableHeight;
2025-06-25 22:55:25 +02:00
let width, height;
if (containerRatio > targetRatio) {
// Container is wider - constrain by height
height = availableHeight;
2025-06-25 22:55:25 +02:00
width = height * targetRatio;
} else {
// Container is taller - constrain by width
width = availableWidth;
2025-06-25 22:55:25 +02:00
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 elements at current time
const getActiveElements = (): ActiveElement[] => {
const activeElements: ActiveElement[] = [];
2025-06-25 22:55:25 +02:00
tracks.forEach((track) => {
track.elements.forEach((element) => {
const elementStart = element.startTime;
const elementEnd =
element.startTime + (element.duration - element.trimStart - element.trimEnd);
2025-06-25 22:55:25 +02:00
if (currentTime >= elementStart && currentTime < elementEnd) {
let mediaItem = null;
// Only get media item for media elements
if (element.type === "media") {
mediaItem = element.mediaId === "test"
? null // Test elements don't have a real media item
: mediaItems.find((item) => item.id === element.mediaId) || null;
}
2025-06-25 22:55:25 +02:00
activeElements.push({ element, track, mediaItem });
2025-06-25 22:55:25 +02:00
}
});
});
return activeElements;
2025-06-25 22:55:25 +02:00
};
const activeElements = getActiveElements();
2025-06-25 22:55:25 +02:00
// Check if there are any elements in the timeline at all
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
// Render an element
const renderElement = (elementData: ActiveElement, index: number) => {
const { element, mediaItem } = elementData;
2025-06-25 22:55:25 +02:00
// Text elements
if (element.type === "text") {
2025-06-25 22:55:25 +02:00
return (
<div
key={element.id}
className="absolute flex items-center justify-center"
style={{
left: `${50 + (element.x / canvasSize.width) * 100}%`,
top: `${50 + (element.y / canvasSize.height) * 100}%`,
transform: `translate(-50%, -50%) rotate(${element.rotation}deg)`,
opacity: element.opacity,
zIndex: 100 + index, // Text elements on top
}}
2025-06-25 22:55:25 +02:00
>
<div
style={{
fontSize: `${element.fontSize}px`,
fontFamily: element.fontFamily,
color: element.color,
backgroundColor: element.backgroundColor,
textAlign: element.textAlign,
fontWeight: element.fontWeight,
fontStyle: element.fontStyle,
textDecoration: element.textDecoration,
padding: '4px 8px',
borderRadius: '2px',
whiteSpace: 'pre-wrap',
}}
>
{element.content}
2025-06-25 22:55:25 +02:00
</div>
</div>
);
}
// Media elements
if (element.type === "media") {
// Test elements
if (!mediaItem || element.mediaId === "test") {
return (
<div
key={element.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">{element.name}</p>
</div>
2025-06-25 22:55:25 +02:00
</div>
);
}
// Video elements
if (mediaItem.type === "video") {
return (
<div key={element.id} className="absolute inset-0">
<VideoPlayer
src={mediaItem.url!}
poster={mediaItem.thumbnailUrl}
clipStartTime={element.startTime}
trimStart={element.trimStart}
trimEnd={element.trimEnd}
clipDuration={element.duration}
/>
</div>
);
}
// Image elements
if (mediaItem.type === "image") {
return (
<div key={element.id} className="absolute inset-0">
<img
src={mediaItem.url!}
alt={mediaItem.name}
className="w-full h-full object-cover"
draggable={false}
/>
</div>
);
}
// Audio elements (visual representation)
if (mediaItem.type === "audio") {
return (
<div
key={element.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>
);
}
2025-06-25 22:55:25 +02:00
}
return null;
};
return (
<div className="h-full w-full flex flex-col min-h-0 min-w-0">
<div
ref={containerRef}
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
2025-06-25 22:55:25 +02:00
>
{hasAnyElements ? (
<div
ref={previewRef}
className="relative overflow-hidden rounded-sm bg-black border"
style={{
width: previewDimensions.width,
height: previewDimensions.height,
}}
>
{activeElements.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
No elements at current time
</div>
) : (
activeElements.map((elementData, index) => renderElement(elementData, index))
)}
</div>
2025-07-01 00:58:21 +02:00
) : (
<>
{/* Empty div so toolbar stays at the bottom */}
<div className="w-full h-full"></div>
</>
)}
<PreviewToolbar hasAnyElements={hasAnyElements} />
2025-06-25 22:55:25 +02:00
</div>
</div>
);
}
function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
const { isPlaying, toggle, currentTime } = usePlaybackStore();
2025-07-01 00:58:21 +02:00
const {
canvasSize,
canvasPresets,
setCanvasSize,
setCanvasSizeFromAspectRatio,
} = useEditorStore();
const { mediaItems } = useMediaStore();
const { tracks, getTotalDuration } = useTimelineStore();
2025-07-01 00:58:21 +02:00
// Find the current preset based on canvas size
const currentPreset = canvasPresets.find(
(preset) =>
preset.width === canvasSize.width && preset.height === canvasSize.height
);
const handlePresetSelect = (preset: { width: number; height: number }) => {
setCanvasSize({ width: preset.width, height: preset.height });
};
// Get the first video/image media item to determine original aspect ratio
const getOriginalAspectRatio = () => {
// Find first video or image in timeline
for (const track of tracks) {
for (const element of track.elements) {
if (element.type === "media") {
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
if (
mediaItem &&
(mediaItem.type === "video" || mediaItem.type === "image")
) {
return getMediaAspectRatio(mediaItem);
}
2025-07-01 00:58:21 +02:00
}
}
}
return 16 / 9; // Default aspect ratio
};
const handleOriginalSelect = () => {
const aspectRatio = getOriginalAspectRatio();
setCanvasSizeFromAspectRatio(aspectRatio);
};
// Check if current size is "Original" (not matching any preset)
const isOriginal = !currentPreset;
return (
<div
data-toolbar
2025-07-01 00:58:21 +02:00
className="flex items-end justify-between gap-2 p-1 pt-2 bg-background-500 w-full"
>
2025-07-01 00:58:21 +02:00
<div>
<p
className={cn(
"text-xs text-muted-foreground tabular-nums",
!hasAnyElements && "opacity-50"
2025-07-01 00:58:21 +02:00
)}
>
{formatTimeCode(currentTime, "HH:MM:SS:CS")}/
{formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")}
2025-07-01 00:58:21 +02:00
</p>
</div>
<Button
variant="text"
size="icon"
onClick={toggle}
disabled={!hasAnyElements}
2025-07-01 00:58:21 +02:00
>
{isPlaying ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
2025-07-01 00:58:21 +02:00
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
2025-07-03 17:26:49 +02:00
className="!bg-background text-foreground/85 text-xs h-auto rounded-none border border-muted-foreground px-0.5 py-0 font-light"
disabled={!hasAnyElements}
2025-07-01 00:58:21 +02:00
>
{currentPreset?.name || "Ratio"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={handleOriginalSelect}
className={cn("text-xs", isOriginal && "font-semibold")}
>
Original
</DropdownMenuItem>
<DropdownMenuSeparator />
{canvasPresets.map((preset) => (
<DropdownMenuItem
key={preset.name}
onClick={() => handlePresetSelect(preset)}
className={cn(
"text-xs",
currentPreset?.name === preset.name && "font-semibold"
)}
>
{preset.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}