mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: type-safety & separate concerns
This commit is contained in:
@@ -2,44 +2,21 @@ import { create } from "zustand";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export type MediaType = "image" | "video" | "audio";
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: MediaType;
|
||||
file: File;
|
||||
url?: string; // Object URL for preview
|
||||
thumbnailUrl?: string; // For video thumbnails
|
||||
duration?: number; // For video/audio duration
|
||||
width?: number; // For video/image width
|
||||
height?: number; // For video/image height
|
||||
fps?: number; // For video frame rate
|
||||
// Ephemeral items are used by timeline directly and should not appear in the media library or be persisted
|
||||
ephemeral?: boolean;
|
||||
// Text-specific properties
|
||||
content?: string; // Text content
|
||||
fontSize?: number; // Font size
|
||||
fontFamily?: string; // Font family
|
||||
color?: string; // Text color
|
||||
backgroundColor?: string; // Background color
|
||||
textAlign?: "left" | "center" | "right"; // Text alignment
|
||||
}
|
||||
import { MediaType, MediaFile } from "@/types/media";
|
||||
|
||||
interface MediaStore {
|
||||
mediaItems: MediaItem[];
|
||||
mediaFiles: MediaFile[];
|
||||
isLoading: boolean;
|
||||
|
||||
// Actions - now require projectId
|
||||
addMediaItem: (
|
||||
// Actions
|
||||
addMediaFile: (
|
||||
projectId: string,
|
||||
item: Omit<MediaItem, "id">
|
||||
file: Omit<MediaFile, "id">
|
||||
) => Promise<void>;
|
||||
removeMediaItem: (projectId: string, id: string) => Promise<void>;
|
||||
removeMediaFile: (projectId: string, id: string) => Promise<void>;
|
||||
loadProjectMedia: (projectId: string) => Promise<void>;
|
||||
clearProjectMedia: (projectId: string) => Promise<void>;
|
||||
clearAllMedia: () => void; // Clear local state only
|
||||
clearAllMedia: () => void;
|
||||
}
|
||||
|
||||
// Helper function to determine file type
|
||||
@@ -150,8 +127,7 @@ export const getMediaDuration = (file: File): Promise<number> => {
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to get aspect ratio from MediaItem
|
||||
export const getMediaAspectRatio = (item: MediaItem): number => {
|
||||
export const getMediaAspectRatio = (item: MediaFile): number => {
|
||||
if (item.width && item.height) {
|
||||
return item.width / item.height;
|
||||
}
|
||||
@@ -159,35 +135,35 @@ export const getMediaAspectRatio = (item: MediaItem): number => {
|
||||
};
|
||||
|
||||
export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
mediaItems: [],
|
||||
mediaFiles: [],
|
||||
isLoading: false,
|
||||
|
||||
addMediaItem: async (projectId, item) => {
|
||||
const newItem: MediaItem = {
|
||||
...item,
|
||||
addMediaFile: async (projectId, file) => {
|
||||
const newItem: MediaFile = {
|
||||
...file,
|
||||
id: generateUUID(),
|
||||
};
|
||||
|
||||
// Add to local state immediately for UI responsiveness
|
||||
set((state) => ({
|
||||
mediaItems: [...state.mediaItems, newItem],
|
||||
mediaFiles: [...state.mediaFiles, newItem],
|
||||
}));
|
||||
|
||||
// Save to persistent storage in background
|
||||
try {
|
||||
await storageService.saveMediaItem(projectId, newItem);
|
||||
await storageService.saveMediaFile(projectId, newItem);
|
||||
} catch (error) {
|
||||
console.error("Failed to save media item:", error);
|
||||
// Remove from local state if save failed
|
||||
set((state) => ({
|
||||
mediaItems: state.mediaItems.filter((media) => media.id !== newItem.id),
|
||||
mediaFiles: state.mediaFiles.filter((media) => media.id !== newItem.id),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
removeMediaItem: async (projectId: string, id: string) => {
|
||||
removeMediaFile: async (projectId: string, id: string) => {
|
||||
const state = get();
|
||||
const item = state.mediaItems.find((media) => media.id === id);
|
||||
const item = state.mediaFiles.find((media) => media.id === id);
|
||||
|
||||
// Cleanup object URLs to prevent memory leaks
|
||||
if (item?.url) {
|
||||
@@ -199,7 +175,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
|
||||
// 1) Remove from local state immediately
|
||||
set((state) => ({
|
||||
mediaItems: state.mediaItems.filter((media) => media.id !== id),
|
||||
mediaFiles: state.mediaFiles.filter((media) => media.id !== id),
|
||||
}));
|
||||
|
||||
// 2) Cascade into the timeline: remove any elements using this media ID
|
||||
@@ -238,7 +214,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
|
||||
// 3) Remove from persistent storage
|
||||
try {
|
||||
await storageService.deleteMediaItem(projectId, id);
|
||||
await storageService.deleteMediaFile(projectId, id);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete media item:", error);
|
||||
}
|
||||
@@ -248,7 +224,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
set({ isLoading: true });
|
||||
|
||||
try {
|
||||
const mediaItems = await storageService.loadAllMediaItems(projectId);
|
||||
const mediaItems = await storageService.loadAllMediaFiles(projectId);
|
||||
|
||||
// Regenerate thumbnails for video items
|
||||
const updatedMediaItems = await Promise.all(
|
||||
@@ -275,7 +251,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
})
|
||||
);
|
||||
|
||||
set({ mediaItems: updatedMediaItems });
|
||||
set({ mediaFiles: updatedMediaItems });
|
||||
} catch (error) {
|
||||
console.error("Failed to load media items:", error);
|
||||
} finally {
|
||||
@@ -287,7 +263,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
const state = get();
|
||||
|
||||
// Cleanup all object URLs
|
||||
state.mediaItems.forEach((item) => {
|
||||
state.mediaFiles.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
@@ -297,13 +273,13 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
});
|
||||
|
||||
// Clear local state
|
||||
set({ mediaItems: [] });
|
||||
set({ mediaFiles: [] });
|
||||
|
||||
// Clear persistent storage
|
||||
try {
|
||||
const mediaIds = state.mediaItems.map((item) => item.id);
|
||||
const mediaIds = state.mediaFiles.map((item) => item.id);
|
||||
await Promise.all(
|
||||
mediaIds.map((id) => storageService.deleteMediaItem(projectId, id))
|
||||
mediaIds.map((id) => storageService.deleteMediaFile(projectId, id))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear media items from storage:", error);
|
||||
@@ -314,7 +290,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
const state = get();
|
||||
|
||||
// Cleanup all object URLs
|
||||
state.mediaItems.forEach((item) => {
|
||||
state.mediaFiles.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
@@ -324,6 +300,6 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
});
|
||||
|
||||
// Clear local state
|
||||
set({ mediaItems: [] });
|
||||
set({ mediaFiles: [] });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -247,7 +247,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
|
||||
type: "audio/mpeg",
|
||||
});
|
||||
|
||||
await useMediaStore.getState().addMediaItem(activeProject.id, {
|
||||
await useMediaStore.getState().addMediaFile(activeProject.id, {
|
||||
name: sound.name,
|
||||
type: "audio",
|
||||
file,
|
||||
@@ -257,12 +257,12 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
|
||||
|
||||
const mediaItem = useMediaStore
|
||||
.getState()
|
||||
.mediaItems.find((item) => item.file === file);
|
||||
.mediaFiles.find((item) => item.file === file);
|
||||
if (!mediaItem) throw new Error("Failed to create media item");
|
||||
|
||||
const success = useTimelineStore
|
||||
.getState()
|
||||
.addMediaAtTime(mediaItem, usePlaybackStore.getState().currentTime);
|
||||
.addElementAtTime(mediaItem, usePlaybackStore.getState().currentTime);
|
||||
|
||||
if (success) {
|
||||
return true;
|
||||
|
||||
@@ -10,17 +10,15 @@ import {
|
||||
ensureMainTrack,
|
||||
validateElementTrackCompatibility,
|
||||
} from "@/types/timeline";
|
||||
import {
|
||||
useMediaStore,
|
||||
getMediaAspectRatio,
|
||||
type MediaItem,
|
||||
} from "./media-store";
|
||||
import { useMediaStore, getMediaAspectRatio } from "./media-store";
|
||||
import { MediaFile } from "@/types/media";
|
||||
import { findBestCanvasPreset } from "@/lib/editor-utils";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { useProjectStore } from "./project-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
|
||||
// Helper function to manage element naming with suffixes
|
||||
const getElementNameWithSuffix = (
|
||||
@@ -207,10 +205,11 @@ interface TimelineStore {
|
||||
excludeElementId?: string
|
||||
) => boolean;
|
||||
findOrCreateTrack: (trackType: TrackType) => string;
|
||||
addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean;
|
||||
addTextAtTime: (item: TextElement, currentTime?: number) => boolean;
|
||||
addMediaToNewTrack: (item: MediaItem) => boolean;
|
||||
addTextToNewTrack: (item: TextElement | DragData) => boolean;
|
||||
addElementAtTime: (
|
||||
item: MediaFile | TextElement,
|
||||
currentTime?: number
|
||||
) => boolean;
|
||||
addElementToNewTrack: (item: MediaFile | TextElement | DragData) => boolean;
|
||||
}
|
||||
|
||||
export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
@@ -502,7 +501,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
|
||||
if (isFirstElement && newElement.type === "media") {
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const mediaItem = mediaStore.mediaItems.find(
|
||||
const mediaItem = mediaStore.mediaFiles.find(
|
||||
(item) => item.id === newElement.mediaId
|
||||
);
|
||||
|
||||
@@ -1144,7 +1143,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await mediaStore.addMediaItem(
|
||||
await mediaStore.addMediaFile(
|
||||
projectStore.activeProject.id,
|
||||
mediaData
|
||||
);
|
||||
@@ -1155,7 +1154,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
};
|
||||
}
|
||||
|
||||
const newMediaItem = mediaStore.mediaItems.find(
|
||||
const newMediaItem = mediaStore.mediaFiles.find(
|
||||
(item) => item.file === newFile
|
||||
);
|
||||
|
||||
@@ -1219,7 +1218,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
getProjectThumbnail: async (projectId) => {
|
||||
try {
|
||||
const tracks = await storageService.loadTimeline(projectId);
|
||||
const mediaItems = await storageService.loadAllMediaItems(projectId);
|
||||
const mediaItems = await storageService.loadAllMediaFiles(projectId);
|
||||
|
||||
if (!tracks || !mediaItems.length) return null;
|
||||
|
||||
@@ -1230,20 +1229,20 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
|
||||
if (!firstMediaElement) return null;
|
||||
|
||||
const mediaItem = mediaItems.find(
|
||||
const mediaFile = mediaItems.find(
|
||||
(item) => item.id === firstMediaElement.mediaId
|
||||
);
|
||||
if (!mediaItem) return null;
|
||||
if (!mediaFile) return null;
|
||||
|
||||
if (mediaItem.type === "video" && mediaItem.file) {
|
||||
if (mediaFile.type === "video" && mediaFile.file) {
|
||||
const { generateVideoThumbnail } = await import(
|
||||
"@/stores/media-store"
|
||||
);
|
||||
const { thumbnailUrl } = await generateVideoThumbnail(mediaItem.file);
|
||||
const { thumbnailUrl } = await generateVideoThumbnail(mediaFile.file);
|
||||
return thumbnailUrl;
|
||||
}
|
||||
if (mediaItem.type === "image" && mediaItem.url) {
|
||||
return mediaItem.url;
|
||||
if (mediaFile.type === "image" && mediaFile.url) {
|
||||
return mediaFile.url;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -1401,30 +1400,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
return get().addTrack(trackType);
|
||||
},
|
||||
|
||||
addMediaAtTime: (item, currentTime = 0) => {
|
||||
const trackType = item.type === "audio" ? "audio" : "media";
|
||||
const duration =
|
||||
item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
|
||||
|
||||
const tracks = get()._tracks.filter((t) => t.type === trackType);
|
||||
|
||||
let targetTrackId = null;
|
||||
for (const track of tracks) {
|
||||
if (!get().checkElementOverlap(track.id, currentTime, duration)) {
|
||||
targetTrackId = track.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetTrackId) {
|
||||
targetTrackId = get().addTrack(trackType);
|
||||
addElementAtTime: (item: MediaFile | TextElement, currentTime = 0) => {
|
||||
if (item.type === "text") {
|
||||
const targetTrackId = get().insertTrackAt("text", 0);
|
||||
get().addElementToTrack(
|
||||
targetTrackId,
|
||||
buildTextElement(item, currentTime)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const media = item as MediaFile;
|
||||
const trackType = media.type === "audio" ? "audio" : "media";
|
||||
const targetTrackId = get().insertTrackAt(trackType, 0);
|
||||
get().addElementToTrack(targetTrackId, {
|
||||
type: "media",
|
||||
mediaId: item.id,
|
||||
name: item.name,
|
||||
duration,
|
||||
mediaId: media.id,
|
||||
name: media.name,
|
||||
duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
@@ -1433,42 +1426,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
return true;
|
||||
},
|
||||
|
||||
addTextAtTime: (item, currentTime = 0) => {
|
||||
const targetTrackId = get().insertTrackAt("text", 0);
|
||||
|
||||
get().addElementToTrack(targetTrackId, {
|
||||
type: "text",
|
||||
name: item.name || "Text",
|
||||
content: item.content || "Default Text",
|
||||
duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
startTime: currentTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
fontSize: item.fontSize || 48,
|
||||
fontFamily: item.fontFamily || "Arial",
|
||||
color: item.color || "#ffffff",
|
||||
backgroundColor: item.backgroundColor || "transparent",
|
||||
textAlign: item.textAlign || "center",
|
||||
fontWeight: item.fontWeight || "normal",
|
||||
fontStyle: item.fontStyle || "normal",
|
||||
textDecoration: item.textDecoration || "none",
|
||||
x: item.x || 0,
|
||||
y: item.y || 0,
|
||||
rotation: item.rotation || 0,
|
||||
opacity: item.opacity !== undefined ? item.opacity : 1,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
addMediaToNewTrack: (item) => {
|
||||
const trackType = item.type === "audio" ? "audio" : "media";
|
||||
const targetTrackId = get().findOrCreateTrack(trackType);
|
||||
addElementToNewTrack: (item) => {
|
||||
if (item.type === "text") {
|
||||
const targetTrackId = get().insertTrackAt("text", 0);
|
||||
get().addElementToTrack(
|
||||
targetTrackId,
|
||||
buildTextElement(item as TextElement | DragData, 0)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const media = item as MediaFile;
|
||||
const trackType = media.type === "audio" ? "audio" : "media";
|
||||
const targetTrackId = get().insertTrackAt(trackType, 0);
|
||||
get().addElementToTrack(targetTrackId, {
|
||||
type: "media",
|
||||
mediaId: item.id,
|
||||
name: item.name,
|
||||
duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
|
||||
mediaId: media.id,
|
||||
name: media.name,
|
||||
duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
@@ -1476,41 +1451,41 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
addTextToNewTrack: (item) => {
|
||||
const targetTrackId = get().insertTrackAt("text", 0);
|
||||
|
||||
get().addElementToTrack(targetTrackId, {
|
||||
type: "text",
|
||||
name: item.name || "Text",
|
||||
content:
|
||||
("content" in item ? item.content : "Default Text") || "Default Text",
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
fontSize: ("fontSize" in item ? item.fontSize : 48) || 48,
|
||||
fontFamily:
|
||||
("fontFamily" in item ? item.fontFamily : "Arial") || "Arial",
|
||||
color: ("color" in item ? item.color : "#ffffff") || "#ffffff",
|
||||
backgroundColor:
|
||||
("backgroundColor" in item ? item.backgroundColor : "transparent") ||
|
||||
"transparent",
|
||||
textAlign:
|
||||
("textAlign" in item ? item.textAlign : "center") || "center",
|
||||
fontWeight:
|
||||
("fontWeight" in item ? item.fontWeight : "normal") || "normal",
|
||||
fontStyle:
|
||||
("fontStyle" in item ? item.fontStyle : "normal") || "normal",
|
||||
textDecoration:
|
||||
("textDecoration" in item ? item.textDecoration : "none") || "none",
|
||||
x: ("x" in item ? item.x : 0) || 0,
|
||||
y: ("y" in item ? item.y : 0) || 0,
|
||||
rotation: ("rotation" in item ? item.rotation : 0) || 0,
|
||||
opacity:
|
||||
"opacity" in item && item.opacity !== undefined ? item.opacity : 1,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function buildTextElement(
|
||||
raw: TextElement | DragData,
|
||||
startTime: number
|
||||
): CreateTimelineElement {
|
||||
const t = raw as Partial<TextElement>;
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
name: t.name ?? DEFAULT_TEXT_ELEMENT.name,
|
||||
content: t.content ?? DEFAULT_TEXT_ELEMENT.content,
|
||||
duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
startTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
fontSize:
|
||||
typeof t.fontSize === "number"
|
||||
? t.fontSize
|
||||
: DEFAULT_TEXT_ELEMENT.fontSize,
|
||||
fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
|
||||
color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
|
||||
backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor,
|
||||
textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
|
||||
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
|
||||
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
|
||||
textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration,
|
||||
x: typeof t.x === "number" ? t.x : DEFAULT_TEXT_ELEMENT.x,
|
||||
y: typeof t.y === "number" ? t.y : DEFAULT_TEXT_ELEMENT.y,
|
||||
rotation:
|
||||
typeof t.rotation === "number"
|
||||
? t.rotation
|
||||
: DEFAULT_TEXT_ELEMENT.rotation,
|
||||
opacity:
|
||||
typeof t.opacity === "number" ? t.opacity : DEFAULT_TEXT_ELEMENT.opacity,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user