added the speed slider with presets.

added the spped control to the properites panel
fixed dialogprops import in command component
now it can update the video player to handle speed changes
This commit is contained in:
StarKnightt 2025-06-23 19:03:20 +05:30
parent d92aed2dac
commit b941ae26c9
14 changed files with 3297 additions and 1836 deletions

File diff suppressed because it is too large Load Diff

View File

@ -61,6 +61,7 @@
"prettier": "^3.4.2",
"tailwindcss": "^3.4.1",
"tsx": "^4.7.1",
"typescript": "^5"
"typescript": "^5",
"worker-loader": "^3.0.8"
}
}

View File

@ -1,59 +1,59 @@
"use client";
import Link from "next/link";
import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
export function EditorHeader() {
const { activeProject } = useProjectStore();
const { getTotalDuration } = useTimelineStore();
const handleExport = () => {
// TODO: Implement export functionality
console.log("Export project");
};
// Format duration from seconds to MM:SS format
const formatDuration = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
};
const leftContent = (
<Link
href="/"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<ChevronLeft className="h-4 w-4" />
<span className="text-sm">{activeProject?.name || "Loading..."}</span>
</Link>
);
const centerContent = (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDuration(getTotalDuration())}</span>
</div>
);
const rightContent = (
<nav className="flex items-center gap-2">
<Button size="sm" onClick={handleExport}>
<Download className="h-4 w-4" />
<span className="text-sm">Export</span>
</Button>
</nav>
);
return (
<HeaderBase
leftContent={leftContent}
centerContent={centerContent}
rightContent={rightContent}
className="bg-background border-b"
/>
);
}
"use client";
import Link from "next/link";
import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
export function EditorHeader() {
const { activeProject } = useProjectStore();
const { getTotalDuration } = useTimelineStore();
const handleExport = () => {
// TODO: Implement export functionality
console.log("Export project");
};
// Format duration from seconds to MM:SS format
const formatDuration = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
};
const leftContent = (
<Link
href="/"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<ChevronLeft className="h-4 w-4" />
<span className="text-sm">{activeProject?.name || "Loading..."}</span>
</Link>
);
const centerContent = (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDuration(getTotalDuration())}</span>
</div>
);
const rightContent = (
<nav className="flex items-center gap-2">
<Button size="sm" onClick={handleExport}>
<Download className="h-4 w-4" />
<span className="text-sm">Export</span>
</Button>
</nav>
);
return (
<HeaderBase
leftContent={leftContent}
centerContent={centerContent}
rightContent={rightContent}
className="bg-background border-b"
/>
);
}

View File

@ -1,123 +1,123 @@
"use client";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
import { Play, Pause } from "lucide-react";
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { isPlaying, toggle, currentTime } = usePlaybackStore();
// Find the active clip at the current playback time
const getActiveClip = () => {
for (const track of tracks) {
for (const clip of track.clips) {
const clipStart = clip.startTime;
const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) {
return clip;
}
}
}
return null;
};
const activeClip = getActiveClip();
const activeMediaItem = activeClip
? mediaItems.find((item) => item.id === activeClip.mediaId)
: null;
const aspectRatio = activeMediaItem?.aspectRatio || 16 / 9;
const renderContent = () => {
if (!activeMediaItem || !activeClip) {
return (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground/50">
{tracks.length === 0 ? "Drop media to start editing" : "No clip at current time"}
</div>
);
}
// Calculate the relative time within the clip (accounting for trim)
const relativeTime = Math.max(0, currentTime - activeClip.startTime + activeClip.trimStart);
if (activeMediaItem.type === "video") {
return (
<VideoPlayer
src={activeMediaItem.url}
poster={activeMediaItem.thumbnailUrl}
className="w-full h-full"
startTime={relativeTime}
key={`${activeClip.id}-${activeClip.trimStart}-${activeClip.trimEnd}`}
/>
);
}
if (activeMediaItem.type === "image") {
return (
<ImageTimelineTreatment
src={activeMediaItem.url}
alt={activeMediaItem.name}
targetAspectRatio={aspectRatio}
className="w-full h-full"
backgroundType="blur"
/>
);
}
if (activeMediaItem.type === "audio") {
return (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-green-500/20 to-emerald-500/20">
<div className="text-center">
<div className="text-6xl mb-4">🎵</div>
<p className="text-muted-foreground">{activeMediaItem.name}</p>
<Button
variant="outline"
className="mt-4"
onClick={toggle}
>
{isPlaying ? <Pause className="h-4 w-4 mr-2" /> : <Play className="h-4 w-4 mr-2" />}
{isPlaying ? "Pause" : "Play"}
</Button>
</div>
</div>
);
}
return null;
};
return (
<div className="h-full flex flex-col items-center justify-center p-4 overflow-hidden">
<div
className="bg-black rounded-lg shadow-lg relative overflow-hidden flex-shrink-0"
style={{
aspectRatio: aspectRatio.toString(),
width: aspectRatio > 1 ? "100%" : "auto",
height: aspectRatio <= 1 ? "100%" : "auto",
maxWidth: "100%",
maxHeight: "100%",
}}
>
{renderContent()}
</div>
{activeMediaItem && (
<div className="mt-4 text-center">
<p className="text-sm text-muted-foreground">
{activeMediaItem.name}
</p>
<p className="text-xs text-muted-foreground/70">
{aspectRatio.toFixed(2)} {aspectRatio > 1 ? "Landscape" : aspectRatio < 1 ? "Portrait" : "Square"}
</p>
</div>
)}
</div>
);
}
"use client";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
import { Play, Pause } from "lucide-react";
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { isPlaying, toggle, currentTime } = usePlaybackStore();
// Find the active clip at the current playback time
const getActiveClip = () => {
for (const track of tracks) {
for (const clip of track.clips) {
const clipStart = clip.startTime;
const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) {
return clip;
}
}
}
return null;
};
const activeClip = getActiveClip();
const activeMediaItem = activeClip
? mediaItems.find((item) => item.id === activeClip.mediaId)
: null;
const aspectRatio = activeMediaItem?.aspectRatio || 16 / 9;
const renderContent = () => {
if (!activeMediaItem || !activeClip) {
return (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground/50">
{tracks.length === 0 ? "Drop media to start editing" : "No clip at current time"}
</div>
);
}
// Calculate the relative time within the clip (accounting for trim)
const relativeTime = Math.max(0, currentTime - activeClip.startTime + activeClip.trimStart);
if (activeMediaItem.type === "video") {
return (
<VideoPlayer
src={activeMediaItem.url}
poster={activeMediaItem.thumbnailUrl}
className="w-full h-full"
startTime={relativeTime}
key={`${activeClip.id}-${activeClip.trimStart}-${activeClip.trimEnd}`}
/>
);
}
if (activeMediaItem.type === "image") {
return (
<ImageTimelineTreatment
src={activeMediaItem.url}
alt={activeMediaItem.name}
targetAspectRatio={aspectRatio}
className="w-full h-full"
backgroundType="blur"
/>
);
}
if (activeMediaItem.type === "audio") {
return (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-green-500/20 to-emerald-500/20">
<div className="text-center">
<div className="text-6xl mb-4">🎵</div>
<p className="text-muted-foreground">{activeMediaItem.name}</p>
<Button
variant="outline"
className="mt-4"
onClick={toggle}
>
{isPlaying ? <Pause className="h-4 w-4 mr-2" /> : <Play className="h-4 w-4 mr-2" />}
{isPlaying ? "Pause" : "Play"}
</Button>
</div>
</div>
);
}
return null;
};
return (
<div className="h-full flex flex-col items-center justify-center p-4 overflow-hidden">
<div
className="bg-black rounded-lg shadow-lg relative overflow-hidden flex-shrink-0"
style={{
aspectRatio: aspectRatio.toString(),
width: aspectRatio > 1 ? "100%" : "auto",
height: aspectRatio <= 1 ? "100%" : "auto",
maxWidth: "100%",
maxHeight: "100%",
}}
>
{renderContent()}
</div>
{activeMediaItem && (
<div className="mt-4 text-center">
<p className="text-sm text-muted-foreground">
{activeMediaItem.name}
</p>
<p className="text-xs text-muted-foreground/70">
{aspectRatio.toFixed(2)} {aspectRatio > 1 ? "Landscape" : aspectRatio < 1 ? "Portrait" : "Square"}
</p>
</div>
)}
</div>
);
}

View File

@ -1,197 +1,200 @@
"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">
{/* 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 />
</>
)}
{/* 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 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 />
</>
)}
{/* 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>
<SpeedControl />
</div>
</ScrollArea>
);
}

View File

@ -0,0 +1,49 @@
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/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();
const handleSpeedChange = (value: number[]) => {
setSpeed(value[0]);
};
return (
<div className="flex flex-col gap-4">
<div>
<Label className="mb-2 block">Playback Speed ({speed.toFixed(1)}x)</Label>
<div className="flex gap-2 mb-3">
{SPEED_PRESETS.map((preset) => (
<Button
key={preset.value}
variant={Math.abs(speed - preset.value) < 0.05 ? "default" : "outline"}
size="sm"
onClick={() => setSpeed(preset.value)}
className="flex-1"
>
{preset.label}
</Button>
))}
</div>
<Slider
defaultValue={[1.0]}
value={[speed]}
min={0.1}
max={2.0}
step={0.1}
onValueChange={handleSpeedChange}
className="w-full"
/>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { type DialogProps } from "radix-ui";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";

View File

@ -14,7 +14,7 @@ interface VideoPlayerProps {
export function VideoPlayer({ src, poster, className = "", startTime = 0 }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const { isPlaying, currentTime, volume, play, pause, setVolume, setDuration, setCurrentTime } = usePlaybackStore();
const { isPlaying, currentTime, volume, speed, play, pause, setVolume, setDuration, setCurrentTime } = usePlaybackStore();
useEffect(() => {
const video = videoRef.current;
@ -35,14 +35,20 @@ export function VideoPlayer({ src, poster, className = "", startTime = 0 }: Vide
video.currentTime = e.detail.time;
};
const handleSpeedEvent = (e: CustomEvent) => {
video.playbackRate = e.detail.speed;
};
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("loadedmetadata", handleLoadedMetadata);
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener("playback-speed", handleSpeedEvent as EventListener);
return () => {
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
window.removeEventListener("playback-speed", handleSpeedEvent as EventListener);
};
}, [setCurrentTime, setDuration]);
@ -63,6 +69,12 @@ export function VideoPlayer({ src, poster, className = "", startTime = 0 }: Vide
video.volume = volume;
}, [volume]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.playbackRate = speed;
}, [speed]);
const handleSeek = (e: React.MouseEvent<HTMLVideoElement>) => {
const video = videoRef.current;
if (!video) return;

View File

@ -1,68 +1,68 @@
import { toast } from "sonner";
import {
getFileType,
generateVideoThumbnail,
getMediaDuration,
getImageAspectRatio,
type MediaItem,
} from "@/stores/media-store";
// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
export async function processMediaFiles(
files: FileList | File[]
): Promise<ProcessedMediaItem[]> {
const fileArray = Array.from(files);
const processedItems: ProcessedMediaItem[] = [];
for (const file of fileArray) {
const fileType = getFileType(file);
if (!fileType) {
toast.error(`Unsupported file type: ${file.name}`);
continue;
}
const url = URL.createObjectURL(file);
let thumbnailUrl: string | undefined;
let duration: number | undefined;
let aspectRatio: number = 16 / 9; // Default fallback
try {
if (fileType === "image") {
// Get image aspect ratio
aspectRatio = await getImageAspectRatio(file);
} else if (fileType === "video") {
// Use basic thumbnail generation for now
const videoResult = await generateVideoThumbnail(file);
thumbnailUrl = videoResult.thumbnailUrl;
aspectRatio = videoResult.aspectRatio;
} else if (fileType === "audio") {
// For audio, use a square aspect ratio
aspectRatio = 1;
}
// Get duration for videos and audio (if not already set by FFmpeg)
if ((fileType === "video" || fileType === "audio") && !duration) {
duration = await getMediaDuration(file);
}
processedItems.push({
name: file.name,
type: fileType,
file,
url,
thumbnailUrl,
duration,
aspectRatio,
});
} catch (error) {
console.error("Error processing file:", file.name, error);
toast.error(`Failed to process ${file.name}`);
URL.revokeObjectURL(url); // Clean up on error
}
}
return processedItems;
}
import { toast } from "sonner";
import {
getFileType,
generateVideoThumbnail,
getMediaDuration,
getImageAspectRatio,
type MediaItem,
} from "@/stores/media-store";
// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
export async function processMediaFiles(
files: FileList | File[]
): Promise<ProcessedMediaItem[]> {
const fileArray = Array.from(files);
const processedItems: ProcessedMediaItem[] = [];
for (const file of fileArray) {
const fileType = getFileType(file);
if (!fileType) {
toast.error(`Unsupported file type: ${file.name}`);
continue;
}
const url = URL.createObjectURL(file);
let thumbnailUrl: string | undefined;
let duration: number | undefined;
let aspectRatio: number = 16 / 9; // Default fallback
try {
if (fileType === "image") {
// Get image aspect ratio
aspectRatio = await getImageAspectRatio(file);
} else if (fileType === "video") {
// Use basic thumbnail generation for now
const videoResult = await generateVideoThumbnail(file);
thumbnailUrl = videoResult.thumbnailUrl;
aspectRatio = videoResult.aspectRatio;
} else if (fileType === "audio") {
// For audio, use a square aspect ratio
aspectRatio = 1;
}
// Get duration for videos and audio (if not already set by FFmpeg)
if ((fileType === "video" || fileType === "audio") && !duration) {
duration = await getMediaDuration(file);
}
processedItems.push({
name: file.name,
type: fileType,
file,
url,
thumbnailUrl,
duration,
aspectRatio,
});
} catch (error) {
console.error("Error processing file:", file.name, error);
toast.error(`Failed to process ${file.name}`);
URL.revokeObjectURL(url); // Clean up on error
}
}
return processedItems;
}

View File

@ -11,6 +11,7 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
currentTime: 0,
duration: 0,
volume: 1,
speed: 1.0,
play: () => set({ isPlaying: true }),
pause: () => set({ isPlaying: false }),
@ -25,6 +26,14 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
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 });
// Notify video element to update playback rate
const event = new CustomEvent('playback-speed', { detail: { speed: newSpeed } });
window.dispatchEvent(event);
},
setDuration: (duration: number) => set({ duration }),
setCurrentTime: (time: number) => set({ currentTime: time }),
}));

View File

@ -1,177 +1,177 @@
import { create } from "zustand";
export interface TimelineClip {
id: string;
mediaId: string;
name: string;
duration: number;
startTime: number;
trimStart: number;
trimEnd: number;
}
export interface TimelineTrack {
id: string;
name: string;
type: "video" | "audio" | "effects";
clips: TimelineClip[];
}
interface TimelineStore {
tracks: TimelineTrack[];
// Actions
addTrack: (type: "video" | "audio" | "effects") => string;
removeTrack: (trackId: string) => void;
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
removeClipFromTrack: (trackId: string, clipId: string) => void;
moveClipToTrack: (
fromTrackId: string,
toTrackId: string,
clipId: string
) => void;
updateClipTrim: (
trackId: string,
clipId: string,
trimStart: number,
trimEnd: number
) => void;
updateClipStartTime: (
trackId: string,
clipId: string,
startTime: number
) => void;
// Computed values
getTotalDuration: () => number;
}
export const useTimelineStore = create<TimelineStore>((set, get) => ({
tracks: [],
addTrack: (type) => {
const newTrack: TimelineTrack = {
id: crypto.randomUUID(),
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
type,
clips: [],
};
set((state) => ({
tracks: [...state.tracks, newTrack],
}));
return newTrack.id;
},
removeTrack: (trackId) => {
set((state) => ({
tracks: state.tracks.filter((track) => track.id !== trackId),
}));
},
addClipToTrack: (trackId, clipData) => {
const newClip: TimelineClip = {
...clipData,
id: crypto.randomUUID(),
startTime: clipData.startTime || 0,
trimStart: 0,
trimEnd: 0,
};
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? { ...track, clips: [...track.clips, newClip] }
: track
),
}));
},
removeClipFromTrack: (trackId, clipId) => {
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.filter((clip) => clip.id !== clipId),
}
: track
),
}));
},
moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
set((state) => {
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
if (!clipToMove) return state;
return {
tracks: state.tracks.map((track) => {
if (track.id === fromTrackId) {
return {
...track,
clips: track.clips.filter((clip) => clip.id !== clipId),
};
} else if (track.id === toTrackId) {
return {
...track,
clips: [...track.clips, clipToMove],
};
}
return track;
}),
};
});
},
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((clip) =>
clip.id === clipId
? { ...clip, trimStart, trimEnd }
: clip
),
}
: track
),
}));
},
updateClipStartTime: (trackId, clipId, startTime) => {
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((clip) =>
clip.id === clipId
? { ...clip, startTime }
: clip
),
}
: track
),
}));
},
getTotalDuration: () => {
const { tracks } = get();
if (tracks.length === 0) return 0;
const trackEndTimes = tracks.map((track) =>
track.clips.reduce((maxEnd, clip) => {
const clipEnd = clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
return Math.max(maxEnd, clipEnd);
}, 0)
);
return Math.max(...trackEndTimes, 0);
},
}));
import { create } from "zustand";
export interface TimelineClip {
id: string;
mediaId: string;
name: string;
duration: number;
startTime: number;
trimStart: number;
trimEnd: number;
}
export interface TimelineTrack {
id: string;
name: string;
type: "video" | "audio" | "effects";
clips: TimelineClip[];
}
interface TimelineStore {
tracks: TimelineTrack[];
// Actions
addTrack: (type: "video" | "audio" | "effects") => string;
removeTrack: (trackId: string) => void;
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
removeClipFromTrack: (trackId: string, clipId: string) => void;
moveClipToTrack: (
fromTrackId: string,
toTrackId: string,
clipId: string
) => void;
updateClipTrim: (
trackId: string,
clipId: string,
trimStart: number,
trimEnd: number
) => void;
updateClipStartTime: (
trackId: string,
clipId: string,
startTime: number
) => void;
// Computed values
getTotalDuration: () => number;
}
export const useTimelineStore = create<TimelineStore>((set, get) => ({
tracks: [],
addTrack: (type) => {
const newTrack: TimelineTrack = {
id: crypto.randomUUID(),
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
type,
clips: [],
};
set((state) => ({
tracks: [...state.tracks, newTrack],
}));
return newTrack.id;
},
removeTrack: (trackId) => {
set((state) => ({
tracks: state.tracks.filter((track) => track.id !== trackId),
}));
},
addClipToTrack: (trackId, clipData) => {
const newClip: TimelineClip = {
...clipData,
id: crypto.randomUUID(),
startTime: clipData.startTime || 0,
trimStart: 0,
trimEnd: 0,
};
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? { ...track, clips: [...track.clips, newClip] }
: track
),
}));
},
removeClipFromTrack: (trackId, clipId) => {
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.filter((clip) => clip.id !== clipId),
}
: track
),
}));
},
moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
set((state) => {
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
if (!clipToMove) return state;
return {
tracks: state.tracks.map((track) => {
if (track.id === fromTrackId) {
return {
...track,
clips: track.clips.filter((clip) => clip.id !== clipId),
};
} else if (track.id === toTrackId) {
return {
...track,
clips: [...track.clips, clipToMove],
};
}
return track;
}),
};
});
},
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((clip) =>
clip.id === clipId
? { ...clip, trimStart, trimEnd }
: clip
),
}
: track
),
}));
},
updateClipStartTime: (trackId, clipId, startTime) => {
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((clip) =>
clip.id === clipId
? { ...clip, startTime }
: clip
),
}
: track
),
}));
},
getTotalDuration: () => {
const { tracks } = get();
if (tracks.length === 0) return 0;
const trackEndTimes = tracks.map((track) =>
track.clips.reduce((maxEnd, clip) => {
const clipEnd = clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
return Math.max(maxEnd, clipEnd);
}, 0)
);
return Math.max(...trackEndTimes, 0);
},
}));

View File

@ -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;
}

View File

@ -1,6 +1,6 @@
export interface TProject {
id: string;
name: string;
createdAt: Date;
updatedAt: Date;
}
export interface TProject {
id: string;
name: string;
createdAt: Date;
updatedAt: Date;
}