refactor: fresh properties panel
This commit is contained in:
@ -2,13 +2,10 @@
|
|||||||
|
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { TimelineElement, TimelineTrack } from "@/types/timeline";
|
import { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||||
import {
|
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||||
useMediaStore,
|
|
||||||
type MediaItem,
|
|
||||||
getMediaAspectRatio,
|
|
||||||
} from "@/stores/media-store";
|
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
|
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
|
||||||
import { VideoPlayer } from "@/components/ui/video-player";
|
import { VideoPlayer } from "@/components/ui/video-player";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -269,54 +266,25 @@ export function PreviewPanel() {
|
|||||||
|
|
||||||
function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
|
function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
|
||||||
const { isPlaying, toggle, currentTime } = usePlaybackStore();
|
const { isPlaying, toggle, currentTime } = usePlaybackStore();
|
||||||
|
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
|
||||||
|
const { getTotalDuration } = useTimelineStore();
|
||||||
const {
|
const {
|
||||||
canvasSize,
|
currentPreset,
|
||||||
|
isOriginal,
|
||||||
|
getOriginalAspectRatio,
|
||||||
|
getDisplayName,
|
||||||
canvasPresets,
|
canvasPresets,
|
||||||
setCanvasSize,
|
} = useAspectRatio();
|
||||||
setCanvasSizeFromAspectRatio,
|
|
||||||
} = useEditorStore();
|
|
||||||
const { mediaItems } = useMediaStore();
|
|
||||||
const { tracks, getTotalDuration } = useTimelineStore();
|
|
||||||
|
|
||||||
// Find the current preset based on canvas size
|
|
||||||
const currentPreset = canvasPresets.find(
|
|
||||||
(preset) =>
|
|
||||||
preset.width === canvasSize.width && preset.height === canvasSize.height
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePresetSelect = (preset: { width: number; height: number }) => {
|
const handlePresetSelect = (preset: { width: number; height: number }) => {
|
||||||
setCanvasSize({ width: preset.width, height: preset.height });
|
setCanvasSize({ width: preset.width, height: preset.height });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get the first video/image media item to determine original aspect ratio
|
|
||||||
const getOriginalAspectRatio = () => {
|
|
||||||
// Find first video or image in timeline
|
|
||||||
for (const track of tracks) {
|
|
||||||
for (const element of track.elements) {
|
|
||||||
if (element.type === "media") {
|
|
||||||
const mediaItem = mediaItems.find(
|
|
||||||
(item) => item.id === element.mediaId
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
mediaItem &&
|
|
||||||
(mediaItem.type === "video" || mediaItem.type === "image")
|
|
||||||
) {
|
|
||||||
return getMediaAspectRatio(mediaItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 16 / 9; // Default aspect ratio
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOriginalSelect = () => {
|
const handleOriginalSelect = () => {
|
||||||
const aspectRatio = getOriginalAspectRatio();
|
const aspectRatio = getOriginalAspectRatio();
|
||||||
setCanvasSizeFromAspectRatio(aspectRatio);
|
setCanvasSizeToOriginal(aspectRatio);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if current size is "Original" (not matching any preset)
|
|
||||||
const isOriginal = !currentPreset;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-toolbar
|
data-toolbar
|
||||||
@ -353,7 +321,7 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
|
|||||||
className="!bg-background text-foreground/85 text-xs h-auto rounded-none border border-muted-foreground px-0.5 py-0 font-light"
|
className="!bg-background text-foreground/85 text-xs h-auto rounded-none border border-muted-foreground px-0.5 py-0 font-light"
|
||||||
disabled={!hasAnyElements}
|
disabled={!hasAnyElements}
|
||||||
>
|
>
|
||||||
{currentPreset?.name || "Ratio"}
|
{getDisplayName()}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
@ -1,220 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Input } from "../ui/input";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
|
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
|
||||||
import { Label } from "../ui/label";
|
import { Label } from "../ui/label";
|
||||||
import { Slider } from "../ui/slider";
|
|
||||||
import { ScrollArea } from "../ui/scroll-area";
|
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";
|
|
||||||
import type { BackgroundType } from "@/types/editor";
|
|
||||||
|
|
||||||
export function PropertiesPanel() {
|
export function PropertiesPanel() {
|
||||||
const { tracks } = useTimelineStore();
|
const { activeProject } = useProjectStore();
|
||||||
const { mediaItems } = useMediaStore();
|
const { getDisplayName, canvasSize } = useAspectRatio();
|
||||||
const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
|
|
||||||
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
|
||||||
|
|
||||||
// Get the first video element for preview (simplified)
|
|
||||||
const firstVideoElement = tracks
|
|
||||||
.flatMap((track) => track.elements)
|
|
||||||
.find((element) => {
|
|
||||||
if (element.type !== "media") return false;
|
|
||||||
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
|
|
||||||
return mediaItem?.type === "video";
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstVideoItem = firstVideoElement && firstVideoElement.type === "media"
|
|
||||||
? mediaItems.find((item) => item.id === firstVideoElement.mediaId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const firstImageElement = tracks
|
|
||||||
.flatMap((track) => track.elements)
|
|
||||||
.find((element) => {
|
|
||||||
if (element.type !== "media") return false;
|
|
||||||
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
|
|
||||||
return mediaItem?.type === "image";
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstImageItem = firstImageElement && firstImageElement.type === "media"
|
|
||||||
? mediaItems.find((item) => item.id === firstImageElement.mediaId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="space-y-6 p-5">
|
<div className="space-y-4 p-5">
|
||||||
{/* Image Treatment - only show if an image is selected */}
|
{/* Media Properties */}
|
||||||
{firstImageItem && (
|
<div className="flex flex-col gap-3">
|
||||||
<>
|
<PropertyItem label="Name:" value={activeProject?.name || ""} />
|
||||||
<div className="space-y-4">
|
<PropertyItem label="Aspect ratio:" value={getDisplayName()} />
|
||||||
<h3 className="text-sm font-medium">Image Treatment</h3>
|
<PropertyItem
|
||||||
<div className="space-y-4">
|
label="Resolution:"
|
||||||
{/* Preview */}
|
value={`${canvasSize.width} × ${canvasSize.height}`}
|
||||||
<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>
|
<PropertyItem label="Frame rate:" value="30.00fps" />
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Background Type */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="bg-type">Background Type</Label>
|
|
||||||
<Select
|
|
||||||
value={backgroundType}
|
|
||||||
onValueChange={(value: BackgroundType) =>
|
|
||||||
setBackgroundType(value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select background type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="blur">Blur</SelectItem>
|
|
||||||
<SelectItem value="mirror">Mirror</SelectItem>
|
|
||||||
<SelectItem value="color">Solid Color</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Background Color - only show for color type */}
|
|
||||||
{backgroundType === "color" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="bg-color">Background Color</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="bg-color"
|
|
||||||
type="color"
|
|
||||||
value={backgroundColor}
|
|
||||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
|
||||||
className="w-16 h-10 p-1"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={backgroundColor}
|
|
||||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
|
||||||
placeholder="#000000"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Video Controls - only show if a video is selected */}
|
|
||||||
{firstVideoItem && (
|
|
||||||
<>
|
|
||||||
<SpeedControl />
|
|
||||||
<Separator />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Transform */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-sm font-medium">Transform</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="x">X Position</Label>
|
|
||||||
<Input id="x" type="number" defaultValue="0" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="y">Y Position</Label>
|
|
||||||
<Input id="y" type="number" defaultValue="0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="rotation">Rotation</Label>
|
|
||||||
<Slider
|
|
||||||
id="rotation"
|
|
||||||
max={360}
|
|
||||||
step={1}
|
|
||||||
defaultValue={[0]}
|
|
||||||
className="mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Effects */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-sm font-medium">Effects</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="opacity">Opacity</Label>
|
|
||||||
<Slider
|
|
||||||
id="opacity"
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
defaultValue={[100]}
|
|
||||||
className="mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="blur">Blur</Label>
|
|
||||||
<Slider
|
|
||||||
id="blur"
|
|
||||||
max={20}
|
|
||||||
step={0.5}
|
|
||||||
defaultValue={[0]}
|
|
||||||
className="mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Timing */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-sm font-medium">Timing</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="duration">Duration (seconds)</Label>
|
|
||||||
<Input
|
|
||||||
id="duration"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.1"
|
|
||||||
defaultValue="5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="delay">Delay (seconds)</Label>
|
|
||||||
<Input
|
|
||||||
id="delay"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.1"
|
|
||||||
defaultValue="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PropertyItem({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||||
|
<span className="text-xs text-right">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Type,
|
Type,
|
||||||
Copy,
|
Copy,
|
||||||
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMediaStore } from "@/stores/media-store";
|
import { useMediaStore } from "@/stores/media-store";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
@ -61,6 +62,7 @@ export function TimelineElement({
|
|||||||
splitAndKeepRight,
|
splitAndKeepRight,
|
||||||
separateAudio,
|
separateAudio,
|
||||||
addElementToTrack,
|
addElementToTrack,
|
||||||
|
replaceElementMedia,
|
||||||
} = useTimelineStore();
|
} = useTimelineStore();
|
||||||
const { currentTime } = usePlaybackStore();
|
const { currentTime } = usePlaybackStore();
|
||||||
|
|
||||||
@ -213,6 +215,37 @@ export function TimelineElement({
|
|||||||
removeElementFromTrack(track.id, element.id);
|
removeElementFromTrack(track.id, element.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReplaceClip = () => {
|
||||||
|
if (element.type !== "media") {
|
||||||
|
toast.error("Replace is only available for media clips");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a file input to select replacement media
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "video/*,audio/*,image/*";
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await replaceElementMedia(track.id, element.id, file);
|
||||||
|
if (success) {
|
||||||
|
toast.success("Clip replaced successfully");
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to replace clip");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to replace clip");
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({ error: "Failed to replace clip", details: error })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
const renderElementContent = () => {
|
const renderElementContent = () => {
|
||||||
if (element.type === "text") {
|
if (element.type === "text") {
|
||||||
return (
|
return (
|
||||||
@ -350,6 +383,12 @@ export function TimelineElement({
|
|||||||
<Copy className="h-4 w-4 mr-2" />
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
Duplicate {element.type === "text" ? "text" : "clip"}
|
Duplicate {element.type === "text" ? "text" : "clip"}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
{element.type === "media" && (
|
||||||
|
<ContextMenuItem onClick={handleReplaceClip}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Replace clip
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={handleElementDeleteContext}
|
onClick={handleElementDeleteContext}
|
||||||
|
92
apps/web/src/hooks/use-aspect-ratio.ts
Normal file
92
apps/web/src/hooks/use-aspect-ratio.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
|
import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store";
|
||||||
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
|
|
||||||
|
export function useAspectRatio() {
|
||||||
|
const { canvasSize, canvasMode, canvasPresets } = useEditorStore();
|
||||||
|
const { mediaItems } = useMediaStore();
|
||||||
|
const { tracks } = useTimelineStore();
|
||||||
|
|
||||||
|
// Find the current preset based on canvas size
|
||||||
|
const currentPreset = canvasPresets.find(
|
||||||
|
(preset) =>
|
||||||
|
preset.width === canvasSize.width && preset.height === canvasSize.height
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the original aspect ratio from the first video/image in timeline
|
||||||
|
const getOriginalAspectRatio = (): number => {
|
||||||
|
// Find first video or image in timeline
|
||||||
|
for (const track of tracks) {
|
||||||
|
for (const element of track.elements) {
|
||||||
|
if (element.type === "media") {
|
||||||
|
const mediaItem = mediaItems.find(
|
||||||
|
(item) => item.id === element.mediaId
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
mediaItem &&
|
||||||
|
(mediaItem.type === "video" || mediaItem.type === "image")
|
||||||
|
) {
|
||||||
|
return getMediaAspectRatio(mediaItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 16 / 9; // Default aspect ratio
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get current aspect ratio
|
||||||
|
const getCurrentAspectRatio = (): number => {
|
||||||
|
return canvasSize.width / canvasSize.height;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format aspect ratio as a readable string
|
||||||
|
const formatAspectRatio = (aspectRatio: number): string => {
|
||||||
|
// Check if it matches a common aspect ratio
|
||||||
|
const ratios = [
|
||||||
|
{ ratio: 16 / 9, label: "16:9" },
|
||||||
|
{ ratio: 9 / 16, label: "9:16" },
|
||||||
|
{ ratio: 1, label: "1:1" },
|
||||||
|
{ ratio: 4 / 3, label: "4:3" },
|
||||||
|
{ ratio: 3 / 4, label: "3:4" },
|
||||||
|
{ ratio: 21 / 9, label: "21:9" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { ratio, label } of ratios) {
|
||||||
|
if (Math.abs(aspectRatio - ratio) < 0.01) {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not a common ratio, format as decimal
|
||||||
|
return aspectRatio.toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if current mode is "Original"
|
||||||
|
const isOriginal = canvasMode === "original";
|
||||||
|
|
||||||
|
// Get display name for current aspect ratio
|
||||||
|
const getDisplayName = (): string => {
|
||||||
|
// If explicitly set to original mode, always show "Original"
|
||||||
|
if (canvasMode === "original") {
|
||||||
|
return "Original";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPreset) {
|
||||||
|
return currentPreset.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatAspectRatio(getCurrentAspectRatio());
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentPreset,
|
||||||
|
canvasMode,
|
||||||
|
isOriginal,
|
||||||
|
getCurrentAspectRatio,
|
||||||
|
getOriginalAspectRatio,
|
||||||
|
formatAspectRatio,
|
||||||
|
getDisplayName,
|
||||||
|
canvasSize,
|
||||||
|
canvasPresets,
|
||||||
|
};
|
||||||
|
}
|
@ -1,6 +1,8 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { CanvasSize, CanvasPreset } from "@/types/editor";
|
import { CanvasSize, CanvasPreset } from "@/types/editor";
|
||||||
|
|
||||||
|
type CanvasMode = "preset" | "original" | "custom";
|
||||||
|
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
// Loading states
|
// Loading states
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
@ -8,6 +10,7 @@ interface EditorState {
|
|||||||
|
|
||||||
// Canvas/Project settings
|
// Canvas/Project settings
|
||||||
canvasSize: CanvasSize;
|
canvasSize: CanvasSize;
|
||||||
|
canvasMode: CanvasMode;
|
||||||
canvasPresets: CanvasPreset[];
|
canvasPresets: CanvasPreset[];
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@ -15,6 +18,7 @@ interface EditorState {
|
|||||||
setPanelsReady: (ready: boolean) => void;
|
setPanelsReady: (ready: boolean) => void;
|
||||||
initializeApp: () => Promise<void>;
|
initializeApp: () => Promise<void>;
|
||||||
setCanvasSize: (size: CanvasSize) => void;
|
setCanvasSize: (size: CanvasSize) => void;
|
||||||
|
setCanvasSizeToOriginal: (aspectRatio: number) => void;
|
||||||
setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
|
setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,6 +69,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
isInitializing: true,
|
isInitializing: true,
|
||||||
isPanelsReady: false,
|
isPanelsReady: false,
|
||||||
canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
|
canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
|
||||||
|
canvasMode: "preset" as CanvasMode,
|
||||||
canvasPresets: DEFAULT_CANVAS_PRESETS,
|
canvasPresets: DEFAULT_CANVAS_PRESETS,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@ -85,15 +90,16 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setCanvasSize: (size) => {
|
setCanvasSize: (size) => {
|
||||||
set({ canvasSize: size });
|
set({ canvasSize: size, canvasMode: "preset" });
|
||||||
|
},
|
||||||
|
|
||||||
|
setCanvasSizeToOriginal: (aspectRatio) => {
|
||||||
|
const newCanvasSize = findBestCanvasPreset(aspectRatio);
|
||||||
|
set({ canvasSize: newCanvasSize, canvasMode: "original" });
|
||||||
},
|
},
|
||||||
|
|
||||||
setCanvasSizeFromAspectRatio: (aspectRatio) => {
|
setCanvasSizeFromAspectRatio: (aspectRatio) => {
|
||||||
const newCanvasSize = findBestCanvasPreset(aspectRatio);
|
const newCanvasSize = findBestCanvasPreset(aspectRatio);
|
||||||
console.log(
|
set({ canvasSize: newCanvasSize, canvasMode: "custom" });
|
||||||
`Setting canvas size based on aspect ratio ${aspectRatio}:`,
|
|
||||||
newCanvasSize
|
|
||||||
);
|
|
||||||
set({ canvasSize: newCanvasSize });
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
@ -117,6 +117,13 @@ interface TimelineStore {
|
|||||||
) => void;
|
) => void;
|
||||||
separateAudio: (trackId: string, elementId: string) => string | null;
|
separateAudio: (trackId: string, elementId: string) => string | null;
|
||||||
|
|
||||||
|
// Replace media for an element
|
||||||
|
replaceElementMedia: (
|
||||||
|
trackId: string,
|
||||||
|
elementId: string,
|
||||||
|
newFile: File
|
||||||
|
) => Promise<boolean>;
|
||||||
|
|
||||||
// Computed values
|
// Computed values
|
||||||
getTotalDuration: () => number;
|
getTotalDuration: () => number;
|
||||||
|
|
||||||
@ -677,6 +684,102 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
|||||||
return audioElementId;
|
return audioElementId;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Replace media for an element
|
||||||
|
replaceElementMedia: async (trackId, elementId, newFile) => {
|
||||||
|
const { _tracks } = get();
|
||||||
|
const track = _tracks.find((t) => t.id === trackId);
|
||||||
|
const element = track?.elements.find((c) => c.id === elementId);
|
||||||
|
|
||||||
|
if (!element || element.type !== "media") return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mediaStore = useMediaStore.getState();
|
||||||
|
const projectStore = useProjectStore.getState();
|
||||||
|
|
||||||
|
if (!projectStore.activeProject) return false;
|
||||||
|
|
||||||
|
// Import required media processing functions
|
||||||
|
const {
|
||||||
|
getFileType,
|
||||||
|
getImageDimensions,
|
||||||
|
generateVideoThumbnail,
|
||||||
|
getMediaDuration,
|
||||||
|
} = await import("./media-store");
|
||||||
|
|
||||||
|
const fileType = getFileType(newFile);
|
||||||
|
if (!fileType) return false;
|
||||||
|
|
||||||
|
// Process the new media file
|
||||||
|
let mediaData: any = {
|
||||||
|
name: newFile.name,
|
||||||
|
type: fileType,
|
||||||
|
file: newFile,
|
||||||
|
url: URL.createObjectURL(newFile),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get media-specific metadata
|
||||||
|
if (fileType === "image") {
|
||||||
|
const { width, height } = await getImageDimensions(newFile);
|
||||||
|
mediaData.width = width;
|
||||||
|
mediaData.height = height;
|
||||||
|
} else if (fileType === "video") {
|
||||||
|
const [duration, { thumbnailUrl, width, height }] = await Promise.all(
|
||||||
|
[getMediaDuration(newFile), generateVideoThumbnail(newFile)]
|
||||||
|
);
|
||||||
|
mediaData.duration = duration;
|
||||||
|
mediaData.thumbnailUrl = thumbnailUrl;
|
||||||
|
mediaData.width = width;
|
||||||
|
mediaData.height = height;
|
||||||
|
} else if (fileType === "audio") {
|
||||||
|
mediaData.duration = await getMediaDuration(newFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new media item to store
|
||||||
|
await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData);
|
||||||
|
|
||||||
|
// Find the newly created media item
|
||||||
|
const newMediaItem = mediaStore.mediaItems.find(
|
||||||
|
(item) => item.file === newFile
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!newMediaItem) return false;
|
||||||
|
|
||||||
|
get().pushHistory();
|
||||||
|
|
||||||
|
// Update the timeline element to reference the new media
|
||||||
|
updateTracksAndSave(
|
||||||
|
_tracks.map((track) =>
|
||||||
|
track.id === trackId
|
||||||
|
? {
|
||||||
|
...track,
|
||||||
|
elements: track.elements.map((c) =>
|
||||||
|
c.id === elementId
|
||||||
|
? {
|
||||||
|
...c,
|
||||||
|
mediaId: newMediaItem.id,
|
||||||
|
name: newMediaItem.name,
|
||||||
|
// Update duration if the new media has a different duration
|
||||||
|
duration: newMediaItem.duration || c.duration,
|
||||||
|
}
|
||||||
|
: c
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: track
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Failed to replace element media",
|
||||||
|
details: error,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
getTotalDuration: () => {
|
getTotalDuration: () => {
|
||||||
const { _tracks } = get();
|
const { _tracks } = get();
|
||||||
if (_tracks.length === 0) return 0;
|
if (_tracks.length === 0) return 0;
|
||||||
|
Reference in New Issue
Block a user