fix: lock aspect ratio in preview
This commit is contained in:
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user