Merge branch 'OpenCut-app:main' into main

This commit is contained in:
Aashish Paruvada
2025-06-23 21:06:33 +05:30
committed by GitHub
7 changed files with 344 additions and 145 deletions

View File

@ -1,132 +1,218 @@
"use client"; "use client";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import { Slider } from "../ui/slider"; import { Slider } from "../ui/slider";
import { ScrollArea } from "../ui/scroll-area"; import { ScrollArea } from "../ui/scroll-area";
import { Separator } from "../ui/separator"; import { Separator } from "../ui/separator";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "../ui/select"; } from "../ui/select";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store"; import { useMediaStore } from "@/stores/media-store";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
import { useState } from "react"; import { useState } from "react";
import { SpeedControl } from "./speed-control";
export function PropertiesPanel() {
const { tracks } = useTimelineStore(); export function PropertiesPanel() {
const { mediaItems } = useMediaStore(); const { tracks } = useTimelineStore();
const [backgroundType, setBackgroundType] = useState< const { mediaItems } = useMediaStore();
"blur" | "mirror" | "color" const [backgroundType, setBackgroundType] = useState<
>("blur"); "blur" | "mirror" | "color"
const [backgroundColor, setBackgroundColor] = useState("#000000"); >("blur");
const [backgroundColor, setBackgroundColor] = useState("#000000");
// Get the first image clip for preview (simplified)
const firstImageClip = tracks // Get the first video clip for preview (simplified)
.flatMap((track) => track.clips) const firstVideoClip = tracks
.find((clip) => { .flatMap((track) => track.clips)
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); .find((clip) => {
return mediaItem?.type === "image"; const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
}); return mediaItem?.type === "video";
});
const firstImageItem = firstImageClip
? mediaItems.find((item) => item.id === firstImageClip.mediaId) const firstVideoItem = firstVideoClip
: null; ? mediaItems.find((item) => item.id === firstVideoClip.mediaId)
: null;
return (
<ScrollArea className="h-full"> // Get the first image clip for preview (simplified)
<div className="space-y-6 p-5"> const firstImageClip = tracks
.flatMap((track) => track.clips)
.find((clip) => {
{/* Transform */} const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
<div className="space-y-4"> return mediaItem?.type === "image";
<h3 className="text-sm font-medium">Transform</h3> });
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2"> const firstImageItem = firstImageClip
<div className="space-y-1"> ? mediaItems.find((item) => item.id === firstImageClip.mediaId)
<Label htmlFor="x">X Position</Label> : null;
<Input id="x" type="number" defaultValue="0" />
</div> return (
<div className="space-y-1"> <ScrollArea className="h-full">
<Label htmlFor="y">Y Position</Label> <div className="space-y-6 p-5">
<Input id="y" type="number" defaultValue="0" /> {/* Image Treatment - only show if an image is selected */}
</div> {firstImageItem && (
</div> <>
<div className="space-y-1"> <div className="space-y-4">
<Label htmlFor="rotation">Rotation</Label> <h3 className="text-sm font-medium">Image Treatment</h3>
<Slider <div className="space-y-4">
id="rotation" {/* Preview */}
max={360} <div className="space-y-2">
step={1} <Label>Preview</Label>
defaultValue={[0]} <div className="w-full aspect-video max-w-48">
className="mt-2" <ImageTimelineTreatment
/> src={firstImageItem.url}
</div> alt={firstImageItem.name}
</div> targetAspectRatio={16 / 9}
</div> className="rounded-sm border"
backgroundType={backgroundType}
<Separator /> backgroundColor={backgroundColor}
/>
{/* Effects */} </div>
<div className="space-y-4"> </div>
<h3 className="text-sm font-medium">Effects</h3>
<div className="space-y-4"> {/* Background Type */}
<div className="space-y-1"> <div className="space-y-2">
<Label htmlFor="opacity">Opacity</Label> <Label htmlFor="bg-type">Background Type</Label>
<Slider <Select
id="opacity" value={backgroundType}
max={100} onValueChange={(value: any) => setBackgroundType(value)}
step={1} >
defaultValue={[100]} <SelectTrigger>
className="mt-2" <SelectValue placeholder="Select background type" />
/> </SelectTrigger>
</div> <SelectContent>
<div className="space-y-1"> <SelectItem value="blur">Blur</SelectItem>
<Label htmlFor="blur">Blur</Label> <SelectItem value="mirror">Mirror</SelectItem>
<Slider <SelectItem value="color">Solid Color</SelectItem>
id="blur" </SelectContent>
max={20} </Select>
step={0.5} </div>
defaultValue={[0]}
className="mt-2" {/* Background Color - only show for color type */}
/> {backgroundType === "color" && (
</div> <div className="space-y-2">
</div> <Label htmlFor="bg-color">Background Color</Label>
</div> <div className="flex gap-2">
<Input
<Separator /> id="bg-color"
type="color"
{/* Timing */} value={backgroundColor}
<div className="space-y-4"> onChange={(e) => setBackgroundColor(e.target.value)}
<h3 className="text-sm font-medium">Timing</h3> className="w-16 h-10 p-1"
<div className="space-y-2"> />
<div className="space-y-1"> <Input
<Label htmlFor="duration">Duration (seconds)</Label> value={backgroundColor}
<Input onChange={(e) => setBackgroundColor(e.target.value)}
id="duration" placeholder="#000000"
type="number" className="flex-1"
min="0" />
step="0.1" </div>
defaultValue="5" </div>
/> )}
</div> </div>
<div className="space-y-1"> </div>
<Label htmlFor="delay">Delay (seconds)</Label>
<Input <Separator />
id="delay" </>
type="number" )}
min="0"
step="0.1" {/* Video Controls - only show if a video is selected */}
defaultValue="0" {firstVideoItem && (
/> <>
</div> <SpeedControl />
</div> <Separator />
</div> </>
</div> )}
</ScrollArea>
); {/* 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>
);
}

View 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>
);
}

View File

@ -28,6 +28,13 @@ import { usePlaybackStore } from "@/stores/playback-store";
import { processMediaFiles } from "@/lib/media-processing"; import { processMediaFiles } from "@/lib/media-processing";
import { toast } from "sonner"; import { toast } from "sonner";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
export function Timeline() { export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips. // 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 } = const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips } =
useTimelineStore(); useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore(); 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 [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [zoomLevel, setZoomLevel] = useState(1); const [zoomLevel, setZoomLevel] = useState(1);
@ -430,6 +437,29 @@ export function Timeline() {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Delete clip (Delete)</TooltipContent> <TooltipContent>Delete clip (Delete)</TooltipContent>
</Tooltip> </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> </TooltipProvider>
</div> </div>

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import * as React from "react"; 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 { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react"; import { Search } from "lucide-react";

View File

@ -25,7 +25,7 @@ export function VideoPlayer({
clipDuration clipDuration
}: VideoPlayerProps) { }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null); 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 // Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
@ -59,18 +59,26 @@ export function VideoPlayer({
timelineTime - clipStartTime + trimStart timelineTime - clipStartTime + trimStart
)); ));
// Only sync if there's a significant difference // Only sync if there's a significant difference to avoid micro-adjustments
if (Math.abs(video.currentTime - targetVideoTime) > 0.2) { if (Math.abs(video.currentTime - targetVideoTime) > 0.5) {
video.currentTime = targetVideoTime; 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-seek", handleSeekEvent as EventListener);
window.addEventListener("playback-update", handleUpdateEvent as EventListener); window.addEventListener("playback-update", handleUpdateEvent as EventListener);
window.addEventListener("playback-speed", handleSpeedEvent as EventListener);
return () => { return () => {
window.removeEventListener("playback-seek", handleSeekEvent as EventListener); window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
window.removeEventListener("playback-update", handleUpdateEvent as EventListener); window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
window.removeEventListener("playback-speed", handleSpeedEvent as EventListener);
}; };
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]); }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
@ -93,6 +101,13 @@ export function VideoPlayer({
video.volume = volume; video.volume = volume;
}, [volume]); }, [volume]);
// Sync speed immediately when it changes
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.playbackRate = speed;
}, [speed]);
return ( return (
<div className={`relative group ${className}`}> <div className={`relative group ${className}`}>
<video <video
@ -101,7 +116,7 @@ export function VideoPlayer({
poster={poster} poster={poster}
className="w-full h-full object-cover" className="w-full h-full object-cover"
playsInline playsInline
preload="metadata" preload="auto"
/> />
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" /> <div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />

View File

@ -6,15 +6,20 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
setCurrentTime: (time: number) => void; setCurrentTime: (time: number) => void;
} }
let playbackTimer: NodeJS.Timeout | null = null; let playbackTimer: number | null = null;
const startTimer = (store: any) => { const startTimer = (store: any) => {
if (playbackTimer) clearInterval(playbackTimer); if (playbackTimer) cancelAnimationFrame(playbackTimer);
playbackTimer = setInterval(() => { // Use requestAnimationFrame for smoother updates
const updateTime = () => {
const state = store(); const state = store();
if (state.isPlaying && state.currentTime < state.duration) { 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) { if (newTime >= state.duration) {
state.pause(); state.pause();
} else { } else {
@ -23,12 +28,16 @@ const startTimer = (store: any) => {
window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } })); window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } }));
} }
} }
}, 100); playbackTimer = requestAnimationFrame(updateTime);
};
let lastUpdate = performance.now();
playbackTimer = requestAnimationFrame(updateTime);
}; };
const stopTimer = () => { const stopTimer = () => {
if (playbackTimer) { if (playbackTimer) {
clearInterval(playbackTimer); cancelAnimationFrame(playbackTimer);
playbackTimer = null; playbackTimer = null;
} }
}; };
@ -38,6 +47,7 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
currentTime: 0, currentTime: 0,
duration: 0, duration: 0,
volume: 1, volume: 1,
speed: 1.0,
play: () => { play: () => {
set({ isPlaying: true }); set({ isPlaying: true });
@ -64,10 +74,20 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
set({ currentTime: clampedTime }); set({ currentTime: clampedTime });
// Notify video elements to seek // 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)) }), 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 }), setDuration: (duration: number) => set({ duration }),
setCurrentTime: (time: number) => set({ currentTime: time }), setCurrentTime: (time: number) => set({ currentTime: time }),
})); }));

View File

@ -3,6 +3,7 @@ export interface PlaybackState {
currentTime: number; currentTime: number;
duration: number; duration: number;
volume: number; volume: number;
speed: number;
} }
export interface PlaybackControls { export interface PlaybackControls {
@ -10,5 +11,6 @@ export interface PlaybackControls {
pause: () => void; pause: () => void;
seek: (time: number) => void; seek: (time: number) => void;
setVolume: (volume: number) => void; setVolume: (volume: number) => void;
setSpeed: (speed: number) => void;
toggle: () => void; toggle: () => void;
} }