Merge branch 'OpenCut-app:main' into main
This commit is contained in:
@ -1,132 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { Slider } from "../ui/slider";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import { Separator } from "../ui/separator";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
|
||||
import { useState } from "react";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const [backgroundType, setBackgroundType] = useState<
|
||||
"blur" | "mirror" | "color"
|
||||
>("blur");
|
||||
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
||||
|
||||
// Get the first image clip for preview (simplified)
|
||||
const firstImageClip = tracks
|
||||
.flatMap((track) => track.clips)
|
||||
.find((clip) => {
|
||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||
return mediaItem?.type === "image";
|
||||
});
|
||||
|
||||
const firstImageItem = firstImageClip
|
||||
? mediaItems.find((item) => item.id === firstImageClip.mediaId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-6 p-5">
|
||||
|
||||
|
||||
{/* Transform */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Transform</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="x">X Position</Label>
|
||||
<Input id="x" type="number" defaultValue="0" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="y">Y Position</Label>
|
||||
<Input id="y" type="number" defaultValue="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rotation">Rotation</Label>
|
||||
<Slider
|
||||
id="rotation"
|
||||
max={360}
|
||||
step={1}
|
||||
defaultValue={[0]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Effects */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Effects</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="opacity">Opacity</Label>
|
||||
<Slider
|
||||
id="opacity"
|
||||
max={100}
|
||||
step={1}
|
||||
defaultValue={[100]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="blur">Blur</Label>
|
||||
<Slider
|
||||
id="blur"
|
||||
max={20}
|
||||
step={0.5}
|
||||
defaultValue={[0]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Timing */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Timing</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="duration">Duration (seconds)</Label>
|
||||
<Input
|
||||
id="duration"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
defaultValue="5"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="delay">Delay (seconds)</Label>
|
||||
<Input
|
||||
id="delay"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
defaultValue="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { Slider } from "../ui/slider";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import { Separator } from "../ui/separator";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
|
||||
import { useState } from "react";
|
||||
import { SpeedControl } from "./speed-control";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const [backgroundType, setBackgroundType] = useState<
|
||||
"blur" | "mirror" | "color"
|
||||
>("blur");
|
||||
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
||||
|
||||
// Get the first video clip for preview (simplified)
|
||||
const firstVideoClip = tracks
|
||||
.flatMap((track) => track.clips)
|
||||
.find((clip) => {
|
||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||
return mediaItem?.type === "video";
|
||||
});
|
||||
|
||||
const firstVideoItem = firstVideoClip
|
||||
? mediaItems.find((item) => item.id === firstVideoClip.mediaId)
|
||||
: null;
|
||||
|
||||
// Get the first image clip for preview (simplified)
|
||||
const firstImageClip = tracks
|
||||
.flatMap((track) => track.clips)
|
||||
.find((clip) => {
|
||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||
return mediaItem?.type === "image";
|
||||
});
|
||||
|
||||
const firstImageItem = firstImageClip
|
||||
? mediaItems.find((item) => item.id === firstImageClip.mediaId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-6 p-5">
|
||||
{/* Image Treatment - only show if an image is selected */}
|
||||
{firstImageItem && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Image Treatment</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Preview */}
|
||||
<div className="space-y-2">
|
||||
<Label>Preview</Label>
|
||||
<div className="w-full aspect-video max-w-48">
|
||||
<ImageTimelineTreatment
|
||||
src={firstImageItem.url}
|
||||
alt={firstImageItem.name}
|
||||
targetAspectRatio={16 / 9}
|
||||
className="rounded-sm border"
|
||||
backgroundType={backgroundType}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Type */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bg-type">Background Type</Label>
|
||||
<Select
|
||||
value={backgroundType}
|
||||
onValueChange={(value: any) => setBackgroundType(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select background type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="blur">Blur</SelectItem>
|
||||
<SelectItem value="mirror">Mirror</SelectItem>
|
||||
<SelectItem value="color">Solid Color</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Background Color - only show for color type */}
|
||||
{backgroundType === "color" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bg-color">Background Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="bg-color"
|
||||
type="color"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
className="w-16 h-10 p-1"
|
||||
/>
|
||||
<Input
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Video Controls - only show if a video is selected */}
|
||||
{firstVideoItem && (
|
||||
<>
|
||||
<SpeedControl />
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Transform */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Transform</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="x">X Position</Label>
|
||||
<Input id="x" type="number" defaultValue="0" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="y">Y Position</Label>
|
||||
<Input id="y" type="number" defaultValue="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rotation">Rotation</Label>
|
||||
<Slider
|
||||
id="rotation"
|
||||
max={360}
|
||||
step={1}
|
||||
defaultValue={[0]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Effects */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Effects</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="opacity">Opacity</Label>
|
||||
<Slider
|
||||
id="opacity"
|
||||
max={100}
|
||||
step={1}
|
||||
defaultValue={[100]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="blur">Blur</Label>
|
||||
<Slider
|
||||
id="blur"
|
||||
max={20}
|
||||
step={0.5}
|
||||
defaultValue={[0]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Timing */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Timing</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="duration">Duration (seconds)</Label>
|
||||
<Input
|
||||
id="duration"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
defaultValue="5"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="delay">Delay (seconds)</Label>
|
||||
<Input
|
||||
id="delay"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
defaultValue="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
46
apps/web/src/components/editor/speed-control.tsx
Normal file
46
apps/web/src/components/editor/speed-control.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { Slider } from "../ui/slider";
|
||||
import { Label } from "../ui/label";
|
||||
import { Button } from "../ui/button";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
|
||||
const SPEED_PRESETS = [
|
||||
{ label: "0.5x", value: 0.5 },
|
||||
{ label: "1x", value: 1.0 },
|
||||
{ label: "1.5x", value: 1.5 },
|
||||
{ label: "2x", value: 2.0 },
|
||||
];
|
||||
|
||||
export function SpeedControl() {
|
||||
const { speed, setSpeed } = usePlaybackStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Playback Speed</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{SPEED_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.value}
|
||||
variant={speed === preset.value ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => setSpeed(preset.value)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Custom ({speed.toFixed(1)}x)</Label>
|
||||
<Slider
|
||||
value={[speed]}
|
||||
min={0.1}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
onValueChange={(value) => setSpeed(value[0])}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -28,6 +28,13 @@ import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
|
||||
export function Timeline() {
|
||||
// Timeline shows all tracks (video, audio, effects) and their clips.
|
||||
@ -36,7 +43,7 @@ export function Timeline() {
|
||||
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips } =
|
||||
useTimelineStore();
|
||||
const { mediaItems, addMediaItem } = useMediaStore();
|
||||
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle } = usePlaybackStore();
|
||||
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
@ -430,6 +437,29 @@ export function Timeline() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete clip (Delete)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
|
||||
{/* Speed Control */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Select
|
||||
value={speed.toFixed(1)}
|
||||
onValueChange={(value) => setSpeed(parseFloat(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[90px] h-8">
|
||||
<SelectValue placeholder="1.0x" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.5">0.5x</SelectItem>
|
||||
<SelectItem value="1.0">1.0x</SelectItem>
|
||||
<SelectItem value="1.5">1.5x</SelectItem>
|
||||
<SelectItem value="2.0">2.0x</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Playback Speed</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { type DialogProps } from "radix-ui";
|
||||
import { DialogProps } from "@radix-ui/react-dialog";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
|
@ -25,7 +25,7 @@ export function VideoPlayer({
|
||||
clipDuration
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const { isPlaying, currentTime, volume, play, pause, setVolume } = usePlaybackStore();
|
||||
const { isPlaying, currentTime, volume, speed, play, pause, setVolume } = usePlaybackStore();
|
||||
|
||||
// Calculate if we're within this clip's timeline range
|
||||
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
||||
@ -59,18 +59,26 @@ export function VideoPlayer({
|
||||
timelineTime - clipStartTime + trimStart
|
||||
));
|
||||
|
||||
// Only sync if there's a significant difference
|
||||
if (Math.abs(video.currentTime - targetVideoTime) > 0.2) {
|
||||
// Only sync if there's a significant difference to avoid micro-adjustments
|
||||
if (Math.abs(video.currentTime - targetVideoTime) > 0.5) {
|
||||
video.currentTime = targetVideoTime;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpeedEvent = (e: CustomEvent) => {
|
||||
if (!isInClipRange) return;
|
||||
// Set playbackRate directly without any additional checks
|
||||
video.playbackRate = e.detail.speed;
|
||||
};
|
||||
|
||||
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||
window.addEventListener("playback-update", handleUpdateEvent as EventListener);
|
||||
window.addEventListener("playback-speed", handleSpeedEvent as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||
window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
|
||||
window.removeEventListener("playback-speed", handleSpeedEvent as EventListener);
|
||||
};
|
||||
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
||||
|
||||
@ -93,6 +101,13 @@ export function VideoPlayer({
|
||||
video.volume = volume;
|
||||
}, [volume]);
|
||||
|
||||
// Sync speed immediately when it changes
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.playbackRate = speed;
|
||||
}, [speed]);
|
||||
|
||||
return (
|
||||
<div className={`relative group ${className}`}>
|
||||
<video
|
||||
@ -101,7 +116,7 @@ export function VideoPlayer({
|
||||
poster={poster}
|
||||
className="w-full h-full object-cover"
|
||||
playsInline
|
||||
preload="metadata"
|
||||
preload="auto"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||
|
@ -6,15 +6,20 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
|
||||
setCurrentTime: (time: number) => void;
|
||||
}
|
||||
|
||||
let playbackTimer: NodeJS.Timeout | null = null;
|
||||
let playbackTimer: number | null = null;
|
||||
|
||||
const startTimer = (store: any) => {
|
||||
if (playbackTimer) clearInterval(playbackTimer);
|
||||
if (playbackTimer) cancelAnimationFrame(playbackTimer);
|
||||
|
||||
playbackTimer = setInterval(() => {
|
||||
// Use requestAnimationFrame for smoother updates
|
||||
const updateTime = () => {
|
||||
const state = store();
|
||||
if (state.isPlaying && state.currentTime < state.duration) {
|
||||
const newTime = state.currentTime + 0.1;
|
||||
const now = performance.now();
|
||||
const delta = (now - lastUpdate) / 1000; // Convert to seconds
|
||||
lastUpdate = now;
|
||||
|
||||
const newTime = state.currentTime + (delta * state.speed);
|
||||
if (newTime >= state.duration) {
|
||||
state.pause();
|
||||
} else {
|
||||
@ -23,12 +28,16 @@ const startTimer = (store: any) => {
|
||||
window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } }));
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
playbackTimer = requestAnimationFrame(updateTime);
|
||||
};
|
||||
|
||||
let lastUpdate = performance.now();
|
||||
playbackTimer = requestAnimationFrame(updateTime);
|
||||
};
|
||||
|
||||
const stopTimer = () => {
|
||||
if (playbackTimer) {
|
||||
clearInterval(playbackTimer);
|
||||
cancelAnimationFrame(playbackTimer);
|
||||
playbackTimer = null;
|
||||
}
|
||||
};
|
||||
@ -38,6 +47,7 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 1,
|
||||
speed: 1.0,
|
||||
|
||||
play: () => {
|
||||
set({ isPlaying: true });
|
||||
@ -64,10 +74,20 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
|
||||
set({ currentTime: clampedTime });
|
||||
|
||||
// Notify video elements to seek
|
||||
window.dispatchEvent(new CustomEvent('playback-seek', { detail: { time: clampedTime } }));
|
||||
const event = new CustomEvent('playback-seek', { detail: { time: clampedTime } });
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
|
||||
setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }),
|
||||
|
||||
setSpeed: (speed: number) => {
|
||||
const newSpeed = Math.max(0.1, Math.min(2.0, speed));
|
||||
set({ speed: newSpeed });
|
||||
|
||||
const event = new CustomEvent('playback-speed', { detail: { speed: newSpeed } });
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
|
||||
setDuration: (duration: number) => set({ duration }),
|
||||
setCurrentTime: (time: number) => set({ currentTime: time }),
|
||||
}));
|
@ -3,6 +3,7 @@ export interface PlaybackState {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export interface PlaybackControls {
|
||||
@ -10,5 +11,6 @@ export interface PlaybackControls {
|
||||
pause: () => void;
|
||||
seek: (time: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
toggle: () => void;
|
||||
}
|
Reference in New Issue
Block a user