Merge branch 'OpenCut-app:main' into patch-1

This commit is contained in:
YaoSiQian
2025-06-25 20:40:50 +08:00
committed by GitHub
5 changed files with 261 additions and 152 deletions

View File

@ -84,11 +84,14 @@ export function MediaPanel() {
useEffect(() => { useEffect(() => {
const filtered = mediaItems.filter((item) => { const filtered = mediaItems.filter((item) => {
if (mediaFilter && mediaFilter !== 'all' && item.type !== mediaFilter) { if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
return false; return false;
} }
if (searchQuery && !item.name.toLowerCase().includes(searchQuery.toLowerCase())) { if (
searchQuery &&
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false; return false;
} }
@ -209,23 +212,23 @@ export function MediaPanel() {
{/* Button to add/upload media */} {/* Button to add/upload media */}
<div className="flex gap-2"> <div className="flex gap-2">
{/* Search and filter controls */} {/* Search and filter controls */}
<select <select
value={mediaFilter} value={mediaFilter}
onChange={(e) => setMediaFilter(e.target.value)} onChange={(e) => setMediaFilter(e.target.value)}
className="px-2 py-1 text-xs border rounded bg-background" className="px-2 py-1 text-xs border rounded bg-background"
> >
<option value="all">All</option> <option value="all">All</option>
<option value="video">Video</option> <option value="video">Video</option>
<option value="audio">Audio</option> <option value="audio">Audio</option>
<option value="image">Image</option> <option value="image">Image</option>
</select> </select>
<input <input
type="text" type="text"
placeholder="Search media..." placeholder="Search media..."
className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background" className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
{/* Add media button */} {/* Add media button */}
<Button <Button
@ -233,21 +236,26 @@ export function MediaPanel() {
size="sm" size="sm"
onClick={handleFileSelect} onClick={handleFileSelect}
disabled={isProcessing} disabled={isProcessing}
className="flex-none min-w-[80px] whitespace-nowrap" className="flex-none min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
> >
{isProcessing ? ( {isProcessing ? (
<> <>
<Upload className="h-4 w-4 mr-2 animate-spin" /> <Upload className="h-4 w-4 animate-spin" />
Processing... <span className="hidden md:inline ml-2">Processing...</span>
</> </>
) : ( ) : (
<> <>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add <span
className="hidden sm:inline ml-2"
aria-label="Add file"
>
Add
</span>
</> </>
)} )}
</Button> </Button>
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto p-2">
@ -276,7 +284,15 @@ export function MediaPanel() {
<AspectRatio ratio={item.aspectRatio}> <AspectRatio ratio={item.aspectRatio}>
{renderPreview(item)} {renderPreview(item)}
</AspectRatio> </AspectRatio>
<span className="text-xs truncate px-1">{item.name}</span> <span
className="text-xs truncate px-1 max-w-full"
aria-label={item.name}
title={item.name}
>
{item.name.length > 8
? `${item.name.slice(0, 4)}...${item.name.slice(-3)}`
: item.name}
</span>
</Button> </Button>
{/* Show remove button on hover */} {/* Show remove button on hover */}

View File

@ -5,16 +5,17 @@ import { useMediaStore } 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 } from "lucide-react"; import { Play, Pause, Volume2, VolumeX } from "lucide-react";
import { useState, useRef } from "react"; import { useState, useRef } 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";
export function PreviewPanel() { export function PreviewPanel() {
const { tracks } = useTimelineStore(); const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore(); const { mediaItems } = useMediaStore();
const { isPlaying, toggle, currentTime } = usePlaybackStore(); const { isPlaying, toggle, currentTime, muted, toggleMute, volume } =
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);
@ -30,12 +31,14 @@ export function PreviewPanel() {
tracks.forEach((track) => { tracks.forEach((track) => {
track.clips.forEach((clip) => { track.clips.forEach((clip) => {
const clipStart = clip.startTime; const clipStart = clip.startTime;
const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); const clipEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) { if (currentTime >= clipStart && currentTime < clipEnd) {
const mediaItem = clip.mediaId === "test" const mediaItem =
? { type: "test", name: clip.name, url: "", thumbnailUrl: "" } clip.mediaId === "test"
: mediaItems.find((item) => item.id === clip.mediaId); ? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
: mediaItems.find((item) => item.id === clip.mediaId);
if (mediaItem || clip.mediaId === "test") { if (mediaItem || clip.mediaId === "test") {
activeClips.push({ clip, track, mediaItem }); activeClips.push({ clip, track, mediaItem });
@ -134,13 +137,19 @@ export function PreviewPanel() {
<select <select
value={`${canvasSize.width}x${canvasSize.height}`} value={`${canvasSize.width}x${canvasSize.height}`}
onChange={(e) => { onChange={(e) => {
const preset = canvasPresets.find(p => `${p.width}x${p.height}` === e.target.value); const preset = canvasPresets.find(
if (preset) setCanvasSize({ width: preset.width, height: preset.height }); (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 text-xs" className="bg-background border rounded px-2 py-1 text-xs"
> >
{canvasPresets.map(preset => ( {canvasPresets.map((preset) => (
<option key={preset.name} value={`${preset.width}x${preset.height}`}> <option
key={preset.name}
value={`${preset.width}x${preset.height}`}
>
{preset.name} ({preset.width}×{preset.height}) {preset.name} ({preset.width}×{preset.height})
</option> </option>
))} ))}
@ -154,12 +163,30 @@ export function PreviewPanel() {
onClick={() => setShowDebug(!showDebug)} onClick={() => setShowDebug(!showDebug)}
className="text-xs" className="text-xs"
> >
Debug {showDebug ? 'ON' : 'OFF'} Debug {showDebug ? "ON" : "OFF"}
</Button> </Button>
)} )}
<Button variant="outline" size="sm" onClick={toggle} className="ml-auto"> <Button
{isPlaying ? <Pause className="h-3 w-3 mr-1" /> : <Play className="h-3 w-3 mr-1" />} variant="outline"
size="sm"
onClick={toggleMute}
className="ml-auto"
>
{muted || volume === 0 ? (
<VolumeX className="h-3 w-3 mr-1" />
) : (
<Volume2 className="h-3 w-3 mr-1" />
)}
{muted || volume === 0 ? "Unmute" : "Mute"}
</Button>
<Button variant="outline" size="sm" onClick={toggle}>
{isPlaying ? (
<Pause className="h-3 w-3 mr-1" />
) : (
<Play className="h-3 w-3 mr-1" />
)}
{isPlaying ? "Pause" : "Play"} {isPlaying ? "Pause" : "Play"}
</Button> </Button>
</div> </div>
@ -177,7 +204,9 @@ export function PreviewPanel() {
> >
{activeClips.length === 0 ? ( {activeClips.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-white/50"> <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"} {tracks.length === 0
? "Drop media to start editing"
: "No clips at current time"}
</div> </div>
) : ( ) : (
activeClips.map((clipData, index) => renderClip(clipData, index)) activeClips.map((clipData, index) => renderClip(clipData, index))
@ -188,7 +217,9 @@ export function PreviewPanel() {
{/* Debug Info Panel - Conditionally rendered */} {/* Debug Info Panel - Conditionally rendered */}
{showDebug && ( {showDebug && (
<div className="border-t bg-background p-2 flex-shrink-0"> <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="text-xs font-medium mb-1">
Debug: Active Clips ({activeClips.length})
</div>
<div className="flex gap-2 overflow-x-auto"> <div className="flex gap-2 overflow-x-auto">
{activeClips.map((clipData, index) => ( {activeClips.map((clipData, index) => (
<div <div
@ -199,7 +230,9 @@ export function PreviewPanel() {
{index + 1} {index + 1}
</span> </span>
<span>{clipData.clip.name}</span> <span>{clipData.clip.name}</span>
<span className="text-muted-foreground">({clipData.mediaItem?.type || 'test'})</span> <span className="text-muted-foreground">
({clipData.mediaItem?.type || "test"})
</span>
</div> </div>
))} ))}
{activeClips.length === 0 && ( {activeClips.length === 0 && (

View File

@ -4,108 +4,128 @@ import { useRef, useEffect } from "react";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
interface VideoPlayerProps { interface VideoPlayerProps {
src: string; src: string;
poster?: string; poster?: string;
className?: string; className?: string;
clipStartTime: number; clipStartTime: number;
trimStart: number; trimStart: number;
trimEnd: number; trimEnd: number;
clipDuration: number; clipDuration: number;
} }
export function VideoPlayer({ export function VideoPlayer({
src, src,
poster, poster,
className = "", className = "",
clipStartTime, clipStartTime,
trimStart, trimStart,
trimEnd, trimEnd,
clipDuration clipDuration,
}: VideoPlayerProps) { }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const { isPlaying, currentTime, volume, speed } = usePlaybackStore(); const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
// Calculate if we're within this clip's timeline range // Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
const isInClipRange = currentTime >= clipStartTime && currentTime < clipEndTime; const isInClipRange =
currentTime >= clipStartTime && currentTime < clipEndTime;
// Sync playback events // Sync playback events
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!video || !isInClipRange) return; if (!video || !isInClipRange) return;
const handleSeekEvent = (e: CustomEvent) => { const handleSeekEvent = (e: CustomEvent) => {
// Always update video time, even if outside clip range // Always update video time, even if outside clip range
const timelineTime = e.detail.time; const timelineTime = e.detail.time;
const videoTime = Math.max(trimStart, Math.min( const videoTime = Math.max(
clipDuration - trimEnd, trimStart,
timelineTime - clipStartTime + trimStart Math.min(
)); clipDuration - trimEnd,
video.currentTime = videoTime; timelineTime - clipStartTime + trimStart
}; )
);
video.currentTime = videoTime;
};
const handleUpdateEvent = (e: CustomEvent) => { const handleUpdateEvent = (e: CustomEvent) => {
// Always update video time, even if outside clip range // Always update video time, even if outside clip range
const timelineTime = e.detail.time; const timelineTime = e.detail.time;
const targetTime = Math.max(trimStart, Math.min( const targetTime = Math.max(
clipDuration - trimEnd, trimStart,
timelineTime - clipStartTime + trimStart Math.min(
)); clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
if (Math.abs(video.currentTime - targetTime) > 0.5) { if (Math.abs(video.currentTime - targetTime) > 0.5) {
video.currentTime = targetTime; video.currentTime = targetTime;
} }
}; };
const handleSpeed = (e: CustomEvent) => { const handleSpeed = (e: CustomEvent) => {
video.playbackRate = e.detail.speed; video.playbackRate = e.detail.speed;
}; };
window.addEventListener("playback-seek", handleSeekEvent as EventListener); window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener("playback-update", handleUpdateEvent as EventListener); window.addEventListener(
window.addEventListener("playback-speed", handleSpeed as EventListener); "playback-update",
handleUpdateEvent as EventListener
return () => {
window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
window.removeEventListener("playback-speed", handleSpeed as EventListener);
};
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
// Sync playback state
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (isPlaying && isInClipRange) {
video.play().catch(() => { });
} else {
video.pause();
}
}, [isPlaying, isInClipRange]);
// Sync volume and speed
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = volume;
video.playbackRate = speed;
}, [volume, speed]);
return (
<video
ref={videoRef}
src={src}
poster={poster}
className={`w-full h-full object-cover ${className}`}
playsInline
preload="auto"
controls={false}
disablePictureInPicture
disableRemotePlayback
style={{ pointerEvents: 'none' }}
onContextMenu={(e) => e.preventDefault()}
/>
); );
window.addEventListener("playback-speed", handleSpeed as EventListener);
return () => {
window.removeEventListener(
"playback-seek",
handleSeekEvent as EventListener
);
window.removeEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
window.removeEventListener(
"playback-speed",
handleSpeed as EventListener
);
};
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
// Sync playback state
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (isPlaying && isInClipRange) {
video.play().catch(() => {});
} else {
video.pause();
}
}, [isPlaying, isInClipRange]);
// Sync volume and speed
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = volume;
video.muted = muted;
video.playbackRate = speed;
}, [volume, speed, muted]);
return (
<video
ref={videoRef}
src={src}
poster={poster}
className={`w-full h-full object-cover ${className}`}
playsInline
preload="auto"
controls={false}
disablePictureInPicture
disableRemotePlayback
style={{ pointerEvents: "none" }}
onContextMenu={(e) => e.preventDefault()}
/>
);
} }

View File

@ -19,13 +19,15 @@ const startTimer = (store: any) => {
const delta = (now - lastUpdate) / 1000; // Convert to seconds const delta = (now - lastUpdate) / 1000; // Convert to seconds
lastUpdate = now; lastUpdate = now;
const newTime = state.currentTime + (delta * state.speed); const newTime = state.currentTime + delta * state.speed;
if (newTime >= state.duration) { if (newTime >= state.duration) {
state.pause(); state.pause();
} else { } else {
state.setCurrentTime(newTime); state.setCurrentTime(newTime);
// Notify video elements to sync // Notify video elements to sync
window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } })); window.dispatchEvent(
new CustomEvent("playback-update", { detail: { time: newTime } })
);
} }
} }
playbackTimer = requestAnimationFrame(updateTime); playbackTimer = requestAnimationFrame(updateTime);
@ -47,6 +49,8 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
currentTime: 0, currentTime: 0,
duration: 0, duration: 0,
volume: 1, volume: 1,
muted: false,
previousVolume: 1,
speed: 1.0, speed: 1.0,
play: () => { play: () => {
@ -73,21 +77,52 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
const clampedTime = Math.max(0, Math.min(duration, time)); const clampedTime = Math.max(0, Math.min(duration, time));
set({ currentTime: clampedTime }); set({ currentTime: clampedTime });
// Notify video elements to seek const event = new CustomEvent("playback-seek", {
const event = new CustomEvent('playback-seek', { detail: { time: clampedTime } }); detail: { time: clampedTime },
});
window.dispatchEvent(event); window.dispatchEvent(event);
}, },
setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }), setVolume: (volume: number) =>
set((state) => ({
volume: Math.max(0, Math.min(1, volume)),
muted: volume === 0,
previousVolume: volume > 0 ? volume : state.previousVolume,
})),
setSpeed: (speed: number) => { setSpeed: (speed: number) => {
const newSpeed = Math.max(0.1, Math.min(2.0, speed)); const newSpeed = Math.max(0.1, Math.min(2.0, speed));
set({ speed: newSpeed }); set({ speed: newSpeed });
const event = new CustomEvent('playback-speed', { detail: { speed: newSpeed } }); const event = new CustomEvent("playback-speed", {
detail: { speed: newSpeed },
});
window.dispatchEvent(event); window.dispatchEvent(event);
}, },
setDuration: (duration: number) => set({ duration }), setDuration: (duration: number) => set({ duration }),
setCurrentTime: (time: number) => set({ currentTime: time }), setCurrentTime: (time: number) => set({ currentTime: time }),
mute: () => {
const { volume, previousVolume } = get();
set({
muted: true,
previousVolume: volume > 0 ? volume : previousVolume,
volume: 0,
});
},
unmute: () => {
const { previousVolume } = get();
set({ muted: false, volume: previousVolume ?? 1 });
},
toggleMute: () => {
const { muted } = get();
if (muted) {
get().unmute();
} else {
get().mute();
}
},
})); }));

View File

@ -4,6 +4,8 @@ export interface PlaybackState {
duration: number; duration: number;
volume: number; volume: number;
speed: number; speed: number;
muted: boolean;
previousVolume?: number;
} }
export interface PlaybackControls { export interface PlaybackControls {
@ -13,4 +15,7 @@ export interface PlaybackControls {
setVolume: (volume: number) => void; setVolume: (volume: number) => void;
setSpeed: (speed: number) => void; setSpeed: (speed: number) => void;
toggle: () => void; toggle: () => void;
mute: () => void;
unmute: () => void;
toggleMute: () => void;
} }