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:
parent
d92aed2dac
commit
b941ae26c9
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
@ -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);
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue