fix: lock aspect ratio in preview

This commit is contained in:
Maze Winther
2025-06-25 22:55:25 +02:00
parent e7dabd1444
commit 3ea6b00254

View File

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