summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/preview-panel.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-07-06 20:45:29 +0200
committerMaze Winther <mazewinther@gmail.com>2025-07-06 20:45:29 +0200
commit40c7fbb4f86130c83fbd9029e83c5e7550e3219d (patch)
tree054d918d282358c084f4fc08462913833de644a5 /apps/web/src/components/editor/preview-panel.tsx
parent0e32c732ddc335d35e6a7f8547f4af68bfd9c369 (diff)
downloadopencut-40c7fbb4f86130c83fbd9029e83c5e7550e3219d.tar.gz
refactor: move to a typed-tracks system and add support for text
Diffstat (limited to 'apps/web/src/components/editor/preview-panel.tsx')
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx222
1 files changed, 133 insertions, 89 deletions
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index eba8ac04..9a02560b 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -2,9 +2,9 @@
import {
useTimelineStore,
- type TimelineClip,
type TimelineTrack,
} from "@/stores/timeline-store";
+import { TimelineElement } from "@/types/timeline";
import {
useMediaStore,
type MediaItem,
@@ -21,13 +21,13 @@ import {
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
-import { Play, Pause, Volume2, VolumeX, Plus, Square } from "lucide-react";
+import { Play, Pause } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
-interface ActiveClip {
- clip: TimelineClip;
+interface ActiveElement {
+ element: TimelineElement;
track: TimelineTrack;
mediaItem: MediaItem | null;
}
@@ -35,8 +35,8 @@ interface ActiveClip {
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
- const { currentTime, muted, toggleMute, volume } = usePlaybackStore();
- const { canvasSize, canvasPresets, setCanvasSize } = useEditorStore();
+ const { currentTime } = usePlaybackStore();
+ const { canvasSize } = useEditorStore();
const previewRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [previewDimensions, setPreviewDimensions] = useState({
@@ -104,97 +104,139 @@ export function PreviewPanel() {
return () => resizeObserver.disconnect();
}, [canvasSize.width, canvasSize.height]);
- // Get active clips at current time
- const getActiveClips = (): ActiveClip[] => {
- const activeClips: ActiveClip[] = [];
+ // Get active elements at current time
+ const getActiveElements = (): ActiveElement[] => {
+ const activeElements: ActiveElement[] = [];
tracks.forEach((track) => {
- track.clips.forEach((clip) => {
- const clipStart = clip.startTime;
- const clipEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ track.elements.forEach((element) => {
+ const elementStart = element.startTime;
+ const elementEnd =
+ element.startTime + (element.duration - element.trimStart - element.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;
+ 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;
+ }
- activeClips.push({ clip, track, mediaItem });
+ activeElements.push({ element, track, mediaItem });
}
});
});
- return activeClips;
+ return activeElements;
};
- const activeClips = getActiveClips();
+ const activeElements = getActiveElements();
- // Check if there are any clips in the timeline at all
- const hasAnyClips = tracks.some((track) => track.clips.length > 0);
+ // Check if there are any elements in the timeline at all
+ const hasAnyElements = tracks.some((track) => track.elements.length > 0);
- // Render a clip
- const renderClip = (clipData: ActiveClip, index: number) => {
- const { clip, mediaItem } = clipData;
+ // Render an element
+ const renderElement = (elementData: ActiveElement, index: number) => {
+ const { element, mediaItem } = elementData;
- // Test clips
- if (!mediaItem || clip.mediaId === "test") {
+ // Text elements
+ if (element.type === "text") {
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"
+ 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
+ }}
>
- <div className="text-center">
- <div className="text-2xl mb-2">🎬</div>
- <p className="text-xs text-white">{clip.name}</p>
+ <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}
</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>
- );
- }
+ // 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>
+ </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>
- );
- }
+ // 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>
+ );
+ }
- // 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>
+ // 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>
- </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>
+ );
+ }
}
return null;
@@ -206,7 +248,7 @@ export function PreviewPanel() {
ref={containerRef}
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
>
- {hasAnyClips ? (
+ {hasAnyElements ? (
<div
ref={previewRef}
className="relative overflow-hidden rounded-sm bg-black border"
@@ -215,12 +257,12 @@ export function PreviewPanel() {
height: previewDimensions.height,
}}
>
- {activeClips.length === 0 ? (
+ {activeElements.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
- No clips at current time
+ No elements at current time
</div>
) : (
- activeClips.map((clipData, index) => renderClip(clipData, index))
+ activeElements.map((elementData, index) => renderElement(elementData, index))
)}
</div>
) : (
@@ -230,13 +272,13 @@ export function PreviewPanel() {
</>
)}
- <PreviewToolbar hasAnyClips={hasAnyClips} />
+ <PreviewToolbar hasAnyElements={hasAnyElements} />
</div>
</div>
);
}
-function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) {
+function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
const { isPlaying, toggle, currentTime } = usePlaybackStore();
const {
canvasSize,
@@ -261,13 +303,15 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) {
const getOriginalAspectRatio = () => {
// Find first video or image in timeline
for (const track of tracks) {
- for (const clip of track.clips) {
- const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
- if (
- mediaItem &&
- (mediaItem.type === "video" || mediaItem.type === "image")
- ) {
- return getMediaAspectRatio(mediaItem);
+ 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);
+ }
}
}
}
@@ -291,7 +335,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) {
<p
className={cn(
"text-xs text-muted-foreground tabular-nums",
- !hasAnyClips && "opacity-50"
+ !hasAnyElements && "opacity-50"
)}
>
{formatTimeCode(currentTime, "HH:MM:SS:CS")}/
@@ -302,7 +346,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) {
variant="text"
size="icon"
onClick={toggle}
- disabled={!hasAnyClips}
+ disabled={!hasAnyElements}
>
{isPlaying ? (
<Pause className="h-3 w-3" />
@@ -316,7 +360,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) {
<Button
size="sm"
className="!bg-background text-foreground/85 text-xs h-auto rounded-none border border-muted-foreground px-0.5 py-0 font-light"
- disabled={!hasAnyClips}
+ disabled={!hasAnyElements}
>
{currentPreset?.name || "Ratio"}
</Button>