refactor: type-safety & separate concerns

This commit is contained in:
Maze Winther
2025-08-26 15:04:25 +02:00
parent 2f7cd11ca1
commit b459b46802
22 changed files with 430 additions and 542 deletions
+27 -51
View File
@@ -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: [] });
},
}));
+3 -3
View File
@@ -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;
+83 -108
View File
@@ -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,
};
}