summaryrefslogtreecommitdiff
path: root/apps/web/src/stores
diff options
context:
space:
mode:
authorHyteq <DerpsDer.es@gmail.com>2025-07-20 10:50:17 +0300
committerHyteq <DerpsDer.es@gmail.com>2025-07-20 10:50:17 +0300
commitfe020be6cea00691d6ce66e21843915defa5e944 (patch)
tree65718c8aee56a61b6c42032f8ebd96915cd13dc6 /apps/web/src/stores
parenteab8ec3ba81b86fdd4ade920d999c28fb34785b1 (diff)
downloadopencut-fe020be6cea00691d6ce66e21843915defa5e944.tar.gz
Revert "format, cleanup, deps"
This reverts commit eab8ec3ba81b86fdd4ade920d999c28fb34785b1.
Diffstat (limited to 'apps/web/src/stores')
-rw-r--r--apps/web/src/stores/editor-store.ts210
-rw-r--r--apps/web/src/stores/media-store.ts438
-rw-r--r--apps/web/src/stores/panel-store.ts90
-rw-r--r--apps/web/src/stores/playback-store.ts206
-rw-r--r--apps/web/src/stores/project-store.ts722
-rw-r--r--apps/web/src/stores/timeline-store.ts1954
6 files changed, 1807 insertions, 1813 deletions
diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts
index 2b7e6181..c24929a2 100644
--- a/apps/web/src/stores/editor-store.ts
+++ b/apps/web/src/stores/editor-store.ts
@@ -1,105 +1,105 @@
-import { create } from "zustand";
-import { CanvasSize, CanvasPreset } from "@/types/editor";
-
-type CanvasMode = "preset" | "original" | "custom";
-
-interface EditorState {
- // Loading states
- isInitializing: boolean;
- isPanelsReady: boolean;
-
- // Canvas/Project settings
- canvasSize: CanvasSize;
- canvasMode: CanvasMode;
- canvasPresets: CanvasPreset[];
-
- // Actions
- setInitializing: (loading: boolean) => void;
- setPanelsReady: (ready: boolean) => void;
- initializeApp: () => Promise<void>;
- setCanvasSize: (size: CanvasSize) => void;
- setCanvasSizeToOriginal: (aspectRatio: number) => void;
- setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
-}
-
-const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
- { name: "16:9", width: 1920, height: 1080 },
- { name: "9:16", width: 1080, height: 1920 },
- { name: "1:1", width: 1080, height: 1080 },
- { name: "4:3", width: 1440, height: 1080 },
-];
-
-// Helper function to find the best matching canvas preset for an aspect ratio
-const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
- // Calculate aspect ratio for each preset and find the closest match
- let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
- let smallestDifference = Math.abs(
- aspectRatio - bestMatch.width / bestMatch.height,
- );
-
- for (const preset of DEFAULT_CANVAS_PRESETS) {
- const presetAspectRatio = preset.width / preset.height;
- const difference = Math.abs(aspectRatio - presetAspectRatio);
-
- if (difference < smallestDifference) {
- smallestDifference = difference;
- bestMatch = preset;
- }
- }
-
- // If the difference is still significant (> 0.1), create a custom size
- // based on the media aspect ratio with a reasonable resolution
- const bestAspectRatio = bestMatch.width / bestMatch.height;
- if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
- // Create custom dimensions based on the aspect ratio
- if (aspectRatio > 1) {
- // Landscape - use 1920 width
- return { width: 1920, height: Math.round(1920 / aspectRatio) };
- } else {
- // Portrait or square - use 1080 height
- return { width: Math.round(1080 * aspectRatio), height: 1080 };
- }
- }
-
- return { width: bestMatch.width, height: bestMatch.height };
-};
-
-export const useEditorStore = create<EditorState>((set, get) => ({
- // Initial states
- isInitializing: true,
- isPanelsReady: false,
- canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
- canvasMode: "preset" as CanvasMode,
- canvasPresets: DEFAULT_CANVAS_PRESETS,
-
- // Actions
- setInitializing: (loading) => {
- set({ isInitializing: loading });
- },
-
- setPanelsReady: (ready) => {
- set({ isPanelsReady: ready });
- },
-
- initializeApp: async () => {
- console.log("Initializing video editor...");
- set({ isInitializing: true, isPanelsReady: false });
-
- set({ isPanelsReady: true, isInitializing: false });
- console.log("Video editor ready");
- },
-
- setCanvasSize: (size) => {
- set({ canvasSize: size, canvasMode: "preset" });
- },
-
- setCanvasSizeToOriginal: (aspectRatio) => {
- const newCanvasSize = findBestCanvasPreset(aspectRatio);
- set({ canvasSize: newCanvasSize, canvasMode: "original" });
- },
-
- setCanvasSizeFromAspectRatio: (aspectRatio) => {
- const newCanvasSize = findBestCanvasPreset(aspectRatio);
- set({ canvasSize: newCanvasSize, canvasMode: "custom" });
- },
-}));
+import { create } from "zustand";
+import { CanvasSize, CanvasPreset } from "@/types/editor";
+
+type CanvasMode = "preset" | "original" | "custom";
+
+interface EditorState {
+ // Loading states
+ isInitializing: boolean;
+ isPanelsReady: boolean;
+
+ // Canvas/Project settings
+ canvasSize: CanvasSize;
+ canvasMode: CanvasMode;
+ canvasPresets: CanvasPreset[];
+
+ // Actions
+ setInitializing: (loading: boolean) => void;
+ setPanelsReady: (ready: boolean) => void;
+ initializeApp: () => Promise<void>;
+ setCanvasSize: (size: CanvasSize) => void;
+ setCanvasSizeToOriginal: (aspectRatio: number) => void;
+ setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
+}
+
+const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
+ { name: "16:9", width: 1920, height: 1080 },
+ { name: "9:16", width: 1080, height: 1920 },
+ { name: "1:1", width: 1080, height: 1080 },
+ { name: "4:3", width: 1440, height: 1080 },
+];
+
+// Helper function to find the best matching canvas preset for an aspect ratio
+const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
+ // Calculate aspect ratio for each preset and find the closest match
+ let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
+ let smallestDifference = Math.abs(
+ aspectRatio - bestMatch.width / bestMatch.height
+ );
+
+ for (const preset of DEFAULT_CANVAS_PRESETS) {
+ const presetAspectRatio = preset.width / preset.height;
+ const difference = Math.abs(aspectRatio - presetAspectRatio);
+
+ if (difference < smallestDifference) {
+ smallestDifference = difference;
+ bestMatch = preset;
+ }
+ }
+
+ // If the difference is still significant (> 0.1), create a custom size
+ // based on the media aspect ratio with a reasonable resolution
+ const bestAspectRatio = bestMatch.width / bestMatch.height;
+ if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
+ // Create custom dimensions based on the aspect ratio
+ if (aspectRatio > 1) {
+ // Landscape - use 1920 width
+ return { width: 1920, height: Math.round(1920 / aspectRatio) };
+ } else {
+ // Portrait or square - use 1080 height
+ return { width: Math.round(1080 * aspectRatio), height: 1080 };
+ }
+ }
+
+ return { width: bestMatch.width, height: bestMatch.height };
+};
+
+export const useEditorStore = create<EditorState>((set, get) => ({
+ // Initial states
+ isInitializing: true,
+ isPanelsReady: false,
+ canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
+ canvasMode: "preset" as CanvasMode,
+ canvasPresets: DEFAULT_CANVAS_PRESETS,
+
+ // Actions
+ setInitializing: (loading) => {
+ set({ isInitializing: loading });
+ },
+
+ setPanelsReady: (ready) => {
+ set({ isPanelsReady: ready });
+ },
+
+ initializeApp: async () => {
+ console.log("Initializing video editor...");
+ set({ isInitializing: true, isPanelsReady: false });
+
+ set({ isPanelsReady: true, isInitializing: false });
+ console.log("Video editor ready");
+ },
+
+ setCanvasSize: (size) => {
+ set({ canvasSize: size, canvasMode: "preset" });
+ },
+
+ setCanvasSizeToOriginal: (aspectRatio) => {
+ const newCanvasSize = findBestCanvasPreset(aspectRatio);
+ set({ canvasSize: newCanvasSize, canvasMode: "original" });
+ },
+
+ setCanvasSizeFromAspectRatio: (aspectRatio) => {
+ const newCanvasSize = findBestCanvasPreset(aspectRatio);
+ set({ canvasSize: newCanvasSize, canvasMode: "custom" });
+ },
+}));
diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts
index 305316c8..c4ee5f3e 100644
--- a/apps/web/src/stores/media-store.ts
+++ b/apps/web/src/stores/media-store.ts
@@ -6,288 +6,284 @@ 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
- // 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
+ 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
+ // 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
}
interface MediaStore {
- mediaItems: MediaItem[];
- isLoading: boolean;
+ mediaItems: MediaItem[];
+ isLoading: boolean;
- // Actions - now require projectId
- addMediaItem: (
- projectId: string,
- item: Omit<MediaItem, "id">,
- ) => Promise<void>;
- removeMediaItem: (projectId: string, id: string) => Promise<void>;
- loadProjectMedia: (projectId: string) => Promise<void>;
- clearProjectMedia: (projectId: string) => Promise<void>;
- clearAllMedia: () => void; // Clear local state only
+ // Actions - now require projectId
+ addMediaItem: (
+ projectId: string,
+ item: Omit<MediaItem, "id">
+ ) => Promise<void>;
+ removeMediaItem: (projectId: string, id: string) => Promise<void>;
+ loadProjectMedia: (projectId: string) => Promise<void>;
+ clearProjectMedia: (projectId: string) => Promise<void>;
+ clearAllMedia: () => void; // Clear local state only
}
// Helper function to determine file type
export const getFileType = (file: File): MediaType | null => {
- const { type } = file;
+ const { type } = file;
- if (type.startsWith("image/")) {
- return "image";
- }
- if (type.startsWith("video/")) {
- return "video";
- }
- if (type.startsWith("audio/")) {
- return "audio";
- }
+ if (type.startsWith("image/")) {
+ return "image";
+ }
+ if (type.startsWith("video/")) {
+ return "video";
+ }
+ if (type.startsWith("audio/")) {
+ return "audio";
+ }
- return null;
+ return null;
};
// Helper function to get image dimensions
export const getImageDimensions = (
- file: File,
+ file: File
): Promise<{ width: number; height: number }> => {
- return new Promise((resolve, reject) => {
- const img = new window.Image();
+ return new Promise((resolve, reject) => {
+ const img = new window.Image();
- img.addEventListener("load", () => {
- const width = img.naturalWidth;
- const height = img.naturalHeight;
- resolve({ width, height });
- img.remove();
- });
+ img.addEventListener("load", () => {
+ const width = img.naturalWidth;
+ const height = img.naturalHeight;
+ resolve({ width, height });
+ img.remove();
+ });
- img.addEventListener("error", () => {
- reject(new Error("Could not load image"));
- img.remove();
- });
+ img.addEventListener("error", () => {
+ reject(new Error("Could not load image"));
+ img.remove();
+ });
- img.src = URL.createObjectURL(file);
- });
+ img.src = URL.createObjectURL(file);
+ });
};
// Helper function to generate video thumbnail and get dimensions
export const generateVideoThumbnail = (
- file: File,
+ file: File
): Promise<{ thumbnailUrl: string; width: number; height: number }> => {
- return new Promise((resolve, reject) => {
- const video = document.createElement("video") as HTMLVideoElement;
- const canvas = document.createElement("canvas") as HTMLCanvasElement;
- const ctx = canvas.getContext("2d");
+ return new Promise((resolve, reject) => {
+ const video = document.createElement("video") as HTMLVideoElement;
+ const canvas = document.createElement("canvas") as HTMLCanvasElement;
+ const ctx = canvas.getContext("2d");
- if (!ctx) {
- reject(new Error("Could not get canvas context"));
- return;
- }
+ if (!ctx) {
+ reject(new Error("Could not get canvas context"));
+ return;
+ }
- video.addEventListener("loadedmetadata", () => {
- canvas.width = video.videoWidth;
- canvas.height = video.videoHeight;
+ video.addEventListener("loadedmetadata", () => {
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
- // Seek to 1 second or 10% of duration, whichever is smaller
- video.currentTime = Math.min(1, video.duration * 0.1);
- });
+ // Seek to 1 second or 10% of duration, whichever is smaller
+ video.currentTime = Math.min(1, video.duration * 0.1);
+ });
- video.addEventListener("seeked", () => {
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
- const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
- const width = video.videoWidth;
- const height = video.videoHeight;
+ video.addEventListener("seeked", () => {
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+ const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
+ const width = video.videoWidth;
+ const height = video.videoHeight;
- resolve({ thumbnailUrl, width, height });
+ resolve({ thumbnailUrl, width, height });
- // Cleanup
- video.remove();
- canvas.remove();
- });
+ // Cleanup
+ video.remove();
+ canvas.remove();
+ });
- video.addEventListener("error", () => {
- reject(new Error("Could not load video"));
- video.remove();
- canvas.remove();
- });
+ video.addEventListener("error", () => {
+ reject(new Error("Could not load video"));
+ video.remove();
+ canvas.remove();
+ });
- video.src = URL.createObjectURL(file);
- video.load();
- });
+ video.src = URL.createObjectURL(file);
+ video.load();
+ });
};
// Helper function to get media duration
export const getMediaDuration = (file: File): Promise<number> => {
- return new Promise((resolve, reject) => {
- const element = document.createElement(
- file.type.startsWith("video/") ? "video" : "audio",
- ) as HTMLVideoElement;
+ return new Promise((resolve, reject) => {
+ const element = document.createElement(
+ file.type.startsWith("video/") ? "video" : "audio"
+ ) as HTMLVideoElement;
- element.addEventListener("loadedmetadata", () => {
- resolve(element.duration);
- element.remove();
- });
+ element.addEventListener("loadedmetadata", () => {
+ resolve(element.duration);
+ element.remove();
+ });
- element.addEventListener("error", () => {
- reject(new Error("Could not load media"));
- element.remove();
- });
+ element.addEventListener("error", () => {
+ reject(new Error("Could not load media"));
+ element.remove();
+ });
- element.src = URL.createObjectURL(file);
- element.load();
- });
+ element.src = URL.createObjectURL(file);
+ element.load();
+ });
};
// Helper to get aspect ratio from MediaItem
export const getMediaAspectRatio = (item: MediaItem): number => {
- if (item.width && item.height) {
- return item.width / item.height;
- }
- return 16 / 9; // Default aspect ratio
+ if (item.width && item.height) {
+ return item.width / item.height;
+ }
+ return 16 / 9; // Default aspect ratio
};
export const useMediaStore = create<MediaStore>((set, get) => ({
- mediaItems: [],
- isLoading: false,
+ mediaItems: [],
+ isLoading: false,
- addMediaItem: async (projectId, item) => {
- const newItem: MediaItem = {
- ...item,
- id: generateUUID(),
- };
+ addMediaItem: async (projectId, item) => {
+ const newItem: MediaItem = {
+ ...item,
+ id: generateUUID(),
+ };
- // Add to local state immediately for UI responsiveness
- set((state) => ({
- mediaItems: [...state.mediaItems, newItem],
- }));
+ // Add to local state immediately for UI responsiveness
+ set((state) => ({
+ mediaItems: [...state.mediaItems, newItem],
+ }));
- // Save to persistent storage in background
- try {
- await storageService.saveMediaItem(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),
- }));
- }
- },
+ // Save to persistent storage in background
+ try {
+ await storageService.saveMediaItem(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),
+ }));
+ }
+ },
- removeMediaItem: async (projectId, id: string) => {
- const state = get();
- const item = state.mediaItems.find((media) => media.id === id);
+ removeMediaItem: async (projectId, id: string) => {
+ const state = get();
+ const item = state.mediaItems.find((media) => media.id === id);
- // Cleanup object URLs to prevent memory leaks
- if (item && item.url) {
- URL.revokeObjectURL(item.url);
- if (item.thumbnailUrl) {
- URL.revokeObjectURL(item.thumbnailUrl);
- }
- }
+ // Cleanup object URLs to prevent memory leaks
+ if (item && item.url) {
+ URL.revokeObjectURL(item.url);
+ if (item.thumbnailUrl) {
+ URL.revokeObjectURL(item.thumbnailUrl);
+ }
+ }
- // Remove from local state immediately
- set((state) => ({
- mediaItems: state.mediaItems.filter((media) => media.id !== id),
- }));
+ // Remove from local state immediately
+ set((state) => ({
+ mediaItems: state.mediaItems.filter((media) => media.id !== id),
+ }));
- // Remove from persistent storage
- try {
- await storageService.deleteMediaItem(projectId, id);
- } catch (error) {
- console.error("Failed to delete media item:", error);
- }
- },
+ // Remove from persistent storage
+ try {
+ await storageService.deleteMediaItem(projectId, id);
+ } catch (error) {
+ console.error("Failed to delete media item:", error);
+ }
+ },
- loadProjectMedia: async (projectId) => {
- set({ isLoading: true });
+ loadProjectMedia: async (projectId) => {
+ set({ isLoading: true });
- try {
- const mediaItems = await storageService.loadAllMediaItems(projectId);
+ try {
+ const mediaItems = await storageService.loadAllMediaItems(projectId);
- // Regenerate thumbnails for video items
- const updatedMediaItems = await Promise.all(
- mediaItems.map(async (item) => {
- if (item.type === "video" && item.file) {
- try {
- const { thumbnailUrl, width, height } =
- await generateVideoThumbnail(item.file);
- return {
- ...item,
- thumbnailUrl,
- width: width || item.width,
- height: height || item.height,
- };
- } catch (error) {
- console.error(
- `Failed to regenerate thumbnail for video ${item.id}:`,
- error,
- );
- return item;
- }
- }
- return item;
- }),
- );
+ // Regenerate thumbnails for video items
+ const updatedMediaItems = await Promise.all(
+ mediaItems.map(async (item) => {
+ if (item.type === "video" && item.file) {
+ try {
+ const { thumbnailUrl, width, height } = await generateVideoThumbnail(item.file);
+ return {
+ ...item,
+ thumbnailUrl,
+ width: width || item.width,
+ height: height || item.height
+ };
+ } catch (error) {
+ console.error(`Failed to regenerate thumbnail for video ${item.id}:`, error);
+ return item;
+ }
+ }
+ return item;
+ })
+ );
- set({ mediaItems: updatedMediaItems });
- } catch (error) {
- console.error("Failed to load media items:", error);
- } finally {
- set({ isLoading: false });
- }
- },
+ set({ mediaItems: updatedMediaItems });
+ } catch (error) {
+ console.error("Failed to load media items:", error);
+ } finally {
+ set({ isLoading: false });
+ }
+ },
- clearProjectMedia: async (projectId) => {
- const state = get();
+ clearProjectMedia: async (projectId) => {
+ const state = get();
- // Cleanup all object URLs
- state.mediaItems.forEach((item) => {
- if (item.url) {
- URL.revokeObjectURL(item.url);
- }
- if (item.thumbnailUrl) {
- URL.revokeObjectURL(item.thumbnailUrl);
- }
- });
+ // Cleanup all object URLs
+ state.mediaItems.forEach((item) => {
+ if (item.url) {
+ URL.revokeObjectURL(item.url);
+ }
+ if (item.thumbnailUrl) {
+ URL.revokeObjectURL(item.thumbnailUrl);
+ }
+ });
- // Clear local state
- set({ mediaItems: [] });
+ // Clear local state
+ set({ mediaItems: [] });
- // Clear persistent storage
- try {
- const mediaIds = state.mediaItems.map((item) => item.id);
- await Promise.all(
- mediaIds.map((id) => storageService.deleteMediaItem(projectId, id)),
- );
- } catch (error) {
- console.error("Failed to clear media items from storage:", error);
- }
- },
+ // Clear persistent storage
+ try {
+ const mediaIds = state.mediaItems.map((item) => item.id);
+ await Promise.all(
+ mediaIds.map((id) => storageService.deleteMediaItem(projectId, id))
+ );
+ } catch (error) {
+ console.error("Failed to clear media items from storage:", error);
+ }
+ },
- clearAllMedia: () => {
- const state = get();
+ clearAllMedia: () => {
+ const state = get();
- // Cleanup all object URLs
- state.mediaItems.forEach((item) => {
- if (item.url) {
- URL.revokeObjectURL(item.url);
- }
- if (item.thumbnailUrl) {
- URL.revokeObjectURL(item.thumbnailUrl);
- }
- });
+ // Cleanup all object URLs
+ state.mediaItems.forEach((item) => {
+ if (item.url) {
+ URL.revokeObjectURL(item.url);
+ }
+ if (item.thumbnailUrl) {
+ URL.revokeObjectURL(item.thumbnailUrl);
+ }
+ });
- // Clear local state
- set({ mediaItems: [] });
- },
+ // Clear local state
+ set({ mediaItems: [] });
+ },
}));
diff --git a/apps/web/src/stores/panel-store.ts b/apps/web/src/stores/panel-store.ts
index 8360ad57..450b6a6e 100644
--- a/apps/web/src/stores/panel-store.ts
+++ b/apps/web/src/stores/panel-store.ts
@@ -1,45 +1,45 @@
-import { create } from "zustand";
-import { persist } from "zustand/middleware";
-
-const DEFAULT_PANEL_SIZES = {
- toolsPanel: 45,
- previewPanel: 75,
- propertiesPanel: 20,
- mainContent: 70,
- timeline: 30,
-} as const;
-
-interface PanelState {
- // Panel sizes as percentages
- toolsPanel: number;
- previewPanel: number;
- propertiesPanel: number;
- mainContent: number;
- timeline: number;
-
- // Actions
- setToolsPanel: (size: number) => void;
- setPreviewPanel: (size: number) => void;
- setPropertiesPanel: (size: number) => void;
- setMainContent: (size: number) => void;
- setTimeline: (size: number) => void;
-}
-
-export const usePanelStore = create<PanelState>()(
- persist(
- (set) => ({
- // Default sizes - optimized for responsiveness
- ...DEFAULT_PANEL_SIZES,
-
- // Actions
- setToolsPanel: (size) => set({ toolsPanel: size }),
- setPreviewPanel: (size) => set({ previewPanel: size }),
- setPropertiesPanel: (size) => set({ propertiesPanel: size }),
- setMainContent: (size) => set({ mainContent: size }),
- setTimeline: (size) => set({ timeline: size }),
- }),
- {
- name: "panel-sizes",
- },
- ),
-);
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+const DEFAULT_PANEL_SIZES = {
+ toolsPanel: 45,
+ previewPanel: 75,
+ propertiesPanel: 20,
+ mainContent: 70,
+ timeline: 30,
+} as const;
+
+interface PanelState {
+ // Panel sizes as percentages
+ toolsPanel: number;
+ previewPanel: number;
+ propertiesPanel: number;
+ mainContent: number;
+ timeline: number;
+
+ // Actions
+ setToolsPanel: (size: number) => void;
+ setPreviewPanel: (size: number) => void;
+ setPropertiesPanel: (size: number) => void;
+ setMainContent: (size: number) => void;
+ setTimeline: (size: number) => void;
+}
+
+export const usePanelStore = create<PanelState>()(
+ persist(
+ (set) => ({
+ // Default sizes - optimized for responsiveness
+ ...DEFAULT_PANEL_SIZES,
+
+ // Actions
+ setToolsPanel: (size) => set({ toolsPanel: size }),
+ setPreviewPanel: (size) => set({ previewPanel: size }),
+ setPropertiesPanel: (size) => set({ propertiesPanel: size }),
+ setMainContent: (size) => set({ mainContent: size }),
+ setTimeline: (size) => set({ timeline: size }),
+ }),
+ {
+ name: "panel-sizes",
+ }
+ )
+);
diff --git a/apps/web/src/stores/playback-store.ts b/apps/web/src/stores/playback-store.ts
index 3ef1d95c..543865f9 100644
--- a/apps/web/src/stores/playback-store.ts
+++ b/apps/web/src/stores/playback-store.ts
@@ -2,133 +2,133 @@ import { create } from "zustand";
import type { PlaybackState, PlaybackControls } from "@/types/playback";
interface PlaybackStore extends PlaybackState, PlaybackControls {
- setDuration: (duration: number) => void;
- setCurrentTime: (time: number) => void;
+ setDuration: (duration: number) => void;
+ setCurrentTime: (time: number) => void;
}
let playbackTimer: number | null = null;
const startTimer = (store: () => PlaybackStore) => {
- if (playbackTimer) cancelAnimationFrame(playbackTimer);
+ if (playbackTimer) cancelAnimationFrame(playbackTimer);
- // Use requestAnimationFrame for smoother updates
- const updateTime = () => {
- const state = store();
- if (state.isPlaying && state.currentTime < state.duration) {
- const now = performance.now();
- const delta = (now - lastUpdate) / 1000; // Convert to seconds
- lastUpdate = now;
+ // Use requestAnimationFrame for smoother updates
+ const updateTime = () => {
+ const state = store();
+ if (state.isPlaying && state.currentTime < state.duration) {
+ const now = performance.now();
+ const delta = (now - lastUpdate) / 1000; // Convert to seconds
+ lastUpdate = now;
- const newTime = state.currentTime + delta * state.speed;
- if (newTime >= state.duration) {
- // When video completes, pause and reset playhead to start
- state.pause();
- state.setCurrentTime(0);
- // Notify video elements to sync with reset
- window.dispatchEvent(
- new CustomEvent("playback-seek", { detail: { time: 0 } }),
- );
- } else {
- state.setCurrentTime(newTime);
- // Notify video elements to sync
- window.dispatchEvent(
- new CustomEvent("playback-update", { detail: { time: newTime } }),
- );
- }
- }
- playbackTimer = requestAnimationFrame(updateTime);
- };
+ const newTime = state.currentTime + delta * state.speed;
+ if (newTime >= state.duration) {
+ // When video completes, pause and reset playhead to start
+ state.pause();
+ state.setCurrentTime(0);
+ // Notify video elements to sync with reset
+ window.dispatchEvent(
+ new CustomEvent("playback-seek", { detail: { time: 0 } })
+ );
+ } else {
+ state.setCurrentTime(newTime);
+ // Notify video elements to sync
+ window.dispatchEvent(
+ new CustomEvent("playback-update", { detail: { time: newTime } })
+ );
+ }
+ }
+ playbackTimer = requestAnimationFrame(updateTime);
+ };
- let lastUpdate = performance.now();
- playbackTimer = requestAnimationFrame(updateTime);
+ let lastUpdate = performance.now();
+ playbackTimer = requestAnimationFrame(updateTime);
};
const stopTimer = () => {
- if (playbackTimer) {
- cancelAnimationFrame(playbackTimer);
- playbackTimer = null;
- }
+ if (playbackTimer) {
+ cancelAnimationFrame(playbackTimer);
+ playbackTimer = null;
+ }
};
export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
- isPlaying: false,
- currentTime: 0,
- duration: 0,
- volume: 1,
- muted: false,
- previousVolume: 1,
- speed: 1.0,
+ isPlaying: false,
+ currentTime: 0,
+ duration: 0,
+ volume: 1,
+ muted: false,
+ previousVolume: 1,
+ speed: 1.0,
- play: () => {
- set({ isPlaying: true });
- startTimer(get);
- },
+ play: () => {
+ set({ isPlaying: true });
+ startTimer(get);
+ },
- pause: () => {
- set({ isPlaying: false });
- stopTimer();
- },
+ pause: () => {
+ set({ isPlaying: false });
+ stopTimer();
+ },
- toggle: () => {
- const { isPlaying } = get();
- if (isPlaying) {
- get().pause();
- } else {
- get().play();
- }
- },
+ toggle: () => {
+ const { isPlaying } = get();
+ if (isPlaying) {
+ get().pause();
+ } else {
+ get().play();
+ }
+ },
- seek: (time: number) => {
- const { duration } = get();
- const clampedTime = Math.max(0, Math.min(duration, time));
- set({ currentTime: clampedTime });
+ seek: (time: number) => {
+ const { duration } = get();
+ const clampedTime = Math.max(0, Math.min(duration, time));
+ set({ currentTime: clampedTime });
- const event = new CustomEvent("playback-seek", {
- detail: { time: clampedTime },
- });
- window.dispatchEvent(event);
- },
+ const event = new CustomEvent("playback-seek", {
+ detail: { time: clampedTime },
+ });
+ window.dispatchEvent(event);
+ },
- setVolume: (volume: number) =>
- set((state) => ({
- volume: Math.max(0, Math.min(1, volume)),
- muted: volume === 0,
- previousVolume: volume > 0 ? volume : state.previousVolume,
- })),
+ setVolume: (volume: number) =>
+ set((state) => ({
+ volume: Math.max(0, Math.min(1, volume)),
+ muted: volume === 0,
+ previousVolume: volume > 0 ? volume : state.previousVolume,
+ })),
- setSpeed: (speed: number) => {
- const newSpeed = Math.max(0.1, Math.min(2.0, speed));
- set({ speed: newSpeed });
+ setSpeed: (speed: number) => {
+ const newSpeed = Math.max(0.1, Math.min(2.0, speed));
+ set({ speed: newSpeed });
- const event = new CustomEvent("playback-speed", {
- detail: { speed: newSpeed },
- });
- window.dispatchEvent(event);
- },
+ const event = new CustomEvent("playback-speed", {
+ detail: { speed: newSpeed },
+ });
+ window.dispatchEvent(event);
+ },
- setDuration: (duration: number) => set({ duration }),
- setCurrentTime: (time: number) => set({ currentTime: time }),
+ setDuration: (duration: number) => set({ duration }),
+ setCurrentTime: (time: number) => set({ currentTime: time }),
- mute: () => {
- const { volume, previousVolume } = get();
- set({
- muted: true,
- previousVolume: volume > 0 ? volume : previousVolume,
- volume: 0,
- });
- },
+ mute: () => {
+ const { volume, previousVolume } = get();
+ set({
+ muted: true,
+ previousVolume: volume > 0 ? volume : previousVolume,
+ volume: 0,
+ });
+ },
- unmute: () => {
- const { previousVolume } = get();
- set({ muted: false, volume: previousVolume ?? 1 });
- },
+ unmute: () => {
+ const { previousVolume } = get();
+ set({ muted: false, volume: previousVolume ?? 1 });
+ },
- toggleMute: () => {
- const { muted } = get();
- if (muted) {
- get().unmute();
- } else {
- get().mute();
- }
- },
+ toggleMute: () => {
+ const { muted } = get();
+ if (muted) {
+ get().unmute();
+ } else {
+ get().mute();
+ }
+ },
}));
diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts
index 36ab3503..a8833982 100644
--- a/apps/web/src/stores/project-store.ts
+++ b/apps/web/src/stores/project-store.ts
@@ -1,361 +1,361 @@
-import { TProject } from "@/types/project";
-import { create } from "zustand";
-import { storageService } from "@/lib/storage/storage-service";
-import { toast } from "sonner";
-import { useMediaStore } from "./media-store";
-import { useTimelineStore } from "./timeline-store";
-import { generateUUID } from "@/lib/utils";
-
-interface ProjectStore {
- activeProject: TProject | null;
- savedProjects: TProject[];
- isLoading: boolean;
- isInitialized: boolean;
-
- // Actions
- createNewProject: (name: string) => Promise<string>;
- loadProject: (id: string) => Promise<void>;
- saveCurrentProject: () => Promise<void>;
- loadAllProjects: () => Promise<void>;
- deleteProject: (id: string) => Promise<void>;
- closeProject: () => void;
- renameProject: (projectId: string, name: string) => Promise<void>;
- duplicateProject: (projectId: string) => Promise<string>;
- updateProjectBackground: (backgroundColor: string) => Promise<void>;
- updateBackgroundType: (
- type: "color" | "blur",
- options?: { backgroundColor?: string; blurIntensity?: number },
- ) => Promise<void>;
- updateProjectFps: (fps: number) => Promise<void>;
-
- getFilteredAndSortedProjects: (
- searchQuery: string,
- sortOption: string,
- ) => TProject[];
-}
-
-export const useProjectStore = create<ProjectStore>((set, get) => ({
- activeProject: null,
- savedProjects: [],
- isLoading: true,
- isInitialized: false,
-
- createNewProject: async (name: string) => {
- const newProject: TProject = {
- id: generateUUID(),
- name,
- thumbnail: "",
- createdAt: new Date(),
- updatedAt: new Date(),
- backgroundColor: "#000000",
- backgroundType: "color",
- blurIntensity: 8,
- };
-
- set({ activeProject: newProject });
-
- try {
- await storageService.saveProject(newProject);
- // Reload all projects to update the list
- await get().loadAllProjects();
- return newProject.id;
- } catch (error) {
- toast.error("Failed to save new project");
- throw error;
- }
- },
-
- loadProject: async (id: string) => {
- if (!get().isInitialized) {
- set({ isLoading: true });
- }
-
- // Clear media and timeline immediately to prevent flickering when switching projects
- const mediaStore = useMediaStore.getState();
- const timelineStore = useTimelineStore.getState();
- mediaStore.clearAllMedia();
- timelineStore.clearTimeline();
-
- try {
- const project = await storageService.loadProject(id);
- if (project) {
- set({ activeProject: project });
-
- // Load project-specific data in parallel
- await Promise.all([
- mediaStore.loadProjectMedia(id),
- timelineStore.loadProjectTimeline(id),
- ]);
- } else {
- throw new Error(`Project with id ${id} not found`);
- }
- } catch (error) {
- console.error("Failed to load project:", error);
- throw error; // Re-throw so the editor page can handle it
- } finally {
- set({ isLoading: false });
- }
- },
-
- saveCurrentProject: async () => {
- const { activeProject } = get();
- if (!activeProject) return;
-
- try {
- // Save project metadata and timeline data in parallel
- const timelineStore = useTimelineStore.getState();
- await Promise.all([
- storageService.saveProject(activeProject),
- timelineStore.saveProjectTimeline(activeProject.id),
- ]);
- await get().loadAllProjects(); // Refresh the list
- } catch (error) {
- console.error("Failed to save project:", error);
- }
- },
-
- loadAllProjects: async () => {
- if (!get().isInitialized) {
- set({ isLoading: true });
- }
-
- try {
- const projects = await storageService.loadAllProjects();
- set({ savedProjects: projects });
- } catch (error) {
- console.error("Failed to load projects:", error);
- } finally {
- set({ isLoading: false, isInitialized: true });
- }
- },
-
- deleteProject: async (id: string) => {
- try {
- // Delete project data in parallel
- await Promise.all([
- storageService.deleteProjectMedia(id),
- storageService.deleteProjectTimeline(id),
- storageService.deleteProject(id),
- ]);
- await get().loadAllProjects(); // Refresh the list
-
- // If we deleted the active project, close it and clear data
- const { activeProject } = get();
- if (activeProject?.id === id) {
- set({ activeProject: null });
- const mediaStore = useMediaStore.getState();
- const timelineStore = useTimelineStore.getState();
- mediaStore.clearAllMedia();
- timelineStore.clearTimeline();
- }
- } catch (error) {
- console.error("Failed to delete project:", error);
- }
- },
-
- closeProject: () => {
- set({ activeProject: null });
-
- // Clear data from stores when closing project
- const mediaStore = useMediaStore.getState();
- const timelineStore = useTimelineStore.getState();
- mediaStore.clearAllMedia();
- timelineStore.clearTimeline();
- },
-
- renameProject: async (id: string, name: string) => {
- const { savedProjects } = get();
-
- // Find the project to rename
- const projectToRename = savedProjects.find((p) => p.id === id);
- if (!projectToRename) {
- toast.error("Project not found", {
- description: "Please try again",
- });
- return;
- }
-
- const updatedProject = {
- ...projectToRename,
- name,
- updatedAt: new Date(),
- };
-
- try {
- // Save to storage
- await storageService.saveProject(updatedProject);
-
- await get().loadAllProjects();
-
- // Update activeProject if it's the same project
- const { activeProject } = get();
- if (activeProject?.id === id) {
- set({ activeProject: updatedProject });
- }
- } catch (error) {
- console.error("Failed to rename project:", error);
- toast.error("Failed to rename project", {
- description:
- error instanceof Error ? error.message : "Please try again",
- });
- }
- },
-
- duplicateProject: async (projectId: string) => {
- try {
- const project = await storageService.loadProject(projectId);
- if (!project) {
- toast.error("Project not found", {
- description: "Please try again",
- });
- throw new Error("Project not found");
- }
-
- const { savedProjects } = get();
-
- // Extract the base name (remove any existing numbering)
- const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
- const baseName = numberMatch ? numberMatch[2] : project.name;
- const existingNumbers: number[] = [];
-
- // Check for pattern "(number) baseName" in existing projects
- savedProjects.forEach((p) => {
- const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
- if (match && match[2] === baseName) {
- existingNumbers.push(parseInt(match[1], 10));
- }
- });
-
- const nextNumber =
- existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
-
- const newProject: TProject = {
- id: generateUUID(),
- name: `(${nextNumber}) ${baseName}`,
- thumbnail: project.thumbnail,
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- await storageService.saveProject(newProject);
- await get().loadAllProjects();
- return newProject.id;
- } catch (error) {
- console.error("Failed to duplicate project:", error);
- toast.error("Failed to duplicate project", {
- description:
- error instanceof Error ? error.message : "Please try again",
- });
- throw error;
- }
- },
-
- updateProjectBackground: async (backgroundColor: string) => {
- const { activeProject } = get();
- if (!activeProject) return;
-
- const updatedProject = {
- ...activeProject,
- backgroundColor,
- updatedAt: new Date(),
- };
-
- try {
- await storageService.saveProject(updatedProject);
- set({ activeProject: updatedProject });
- await get().loadAllProjects(); // Refresh the list
- } catch (error) {
- console.error("Failed to update project background:", error);
- toast.error("Failed to update background", {
- description: "Please try again",
- });
- }
- },
-
- updateBackgroundType: async (
- type: "color" | "blur",
- options?: { backgroundColor?: string; blurIntensity?: number },
- ) => {
- const { activeProject } = get();
- if (!activeProject) return;
-
- const updatedProject = {
- ...activeProject,
- backgroundType: type,
- ...(options?.backgroundColor && {
- backgroundColor: options.backgroundColor,
- }),
- ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }),
- updatedAt: new Date(),
- };
-
- try {
- await storageService.saveProject(updatedProject);
- set({ activeProject: updatedProject });
- await get().loadAllProjects(); // Refresh the list
- } catch (error) {
- console.error("Failed to update background type:", error);
- toast.error("Failed to update background", {
- description: "Please try again",
- });
- }
- },
-
- updateProjectFps: async (fps: number) => {
- const { activeProject } = get();
- if (!activeProject) return;
-
- const updatedProject = {
- ...activeProject,
- fps,
- updatedAt: new Date(),
- };
-
- try {
- await storageService.saveProject(updatedProject);
- set({ activeProject: updatedProject });
- await get().loadAllProjects(); // Refresh the list
- } catch (error) {
- console.error("Failed to update project FPS:", error);
- toast.error("Failed to update project FPS", {
- description: "Please try again",
- });
- }
- },
-
- getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
- const { savedProjects } = get();
-
- // Filter projects by search query
- const filteredProjects = savedProjects.filter((project) =>
- project.name.toLowerCase().includes(searchQuery.toLowerCase()),
- );
-
- // Sort filtered projects
- const sortedProjects = [...filteredProjects].sort((a, b) => {
- const [key, order] = sortOption.split("-");
-
- if (key !== "createdAt" && key !== "name") {
- console.warn(`Invalid sort key: ${key}`);
- return 0;
- }
-
- const aValue = a[key];
- const bValue = b[key];
-
- if (aValue === undefined || bValue === undefined) return 0;
-
- if (order === "asc") {
- if (aValue < bValue) return -1;
- if (aValue > bValue) return 1;
- return 0;
- } else {
- if (aValue > bValue) return -1;
- if (aValue < bValue) return 1;
- return 0;
- }
- });
-
- return sortedProjects;
- },
-}));
+import { TProject } from "@/types/project";
+import { create } from "zustand";
+import { storageService } from "@/lib/storage/storage-service";
+import { toast } from "sonner";
+import { useMediaStore } from "./media-store";
+import { useTimelineStore } from "./timeline-store";
+import { generateUUID } from "@/lib/utils";
+
+interface ProjectStore {
+ activeProject: TProject | null;
+ savedProjects: TProject[];
+ isLoading: boolean;
+ isInitialized: boolean;
+
+ // Actions
+ createNewProject: (name: string) => Promise<string>;
+ loadProject: (id: string) => Promise<void>;
+ saveCurrentProject: () => Promise<void>;
+ loadAllProjects: () => Promise<void>;
+ deleteProject: (id: string) => Promise<void>;
+ closeProject: () => void;
+ renameProject: (projectId: string, name: string) => Promise<void>;
+ duplicateProject: (projectId: string) => Promise<string>;
+ updateProjectBackground: (backgroundColor: string) => Promise<void>;
+ updateBackgroundType: (
+ type: "color" | "blur",
+ options?: { backgroundColor?: string; blurIntensity?: number }
+ ) => Promise<void>;
+ updateProjectFps: (fps: number) => Promise<void>;
+
+ getFilteredAndSortedProjects: (
+ searchQuery: string,
+ sortOption: string
+ ) => TProject[];
+}
+
+export const useProjectStore = create<ProjectStore>((set, get) => ({
+ activeProject: null,
+ savedProjects: [],
+ isLoading: true,
+ isInitialized: false,
+
+ createNewProject: async (name: string) => {
+ const newProject: TProject = {
+ id: generateUUID(),
+ name,
+ thumbnail: "",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ backgroundColor: "#000000",
+ backgroundType: "color",
+ blurIntensity: 8,
+ };
+
+ set({ activeProject: newProject });
+
+ try {
+ await storageService.saveProject(newProject);
+ // Reload all projects to update the list
+ await get().loadAllProjects();
+ return newProject.id;
+ } catch (error) {
+ toast.error("Failed to save new project");
+ throw error;
+ }
+ },
+
+ loadProject: async (id: string) => {
+ if (!get().isInitialized) {
+ set({ isLoading: true });
+ }
+
+ // Clear media and timeline immediately to prevent flickering when switching projects
+ const mediaStore = useMediaStore.getState();
+ const timelineStore = useTimelineStore.getState();
+ mediaStore.clearAllMedia();
+ timelineStore.clearTimeline();
+
+ try {
+ const project = await storageService.loadProject(id);
+ if (project) {
+ set({ activeProject: project });
+
+ // Load project-specific data in parallel
+ await Promise.all([
+ mediaStore.loadProjectMedia(id),
+ timelineStore.loadProjectTimeline(id),
+ ]);
+ } else {
+ throw new Error(`Project with id ${id} not found`);
+ }
+ } catch (error) {
+ console.error("Failed to load project:", error);
+ throw error; // Re-throw so the editor page can handle it
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ saveCurrentProject: async () => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ try {
+ // Save project metadata and timeline data in parallel
+ const timelineStore = useTimelineStore.getState();
+ await Promise.all([
+ storageService.saveProject(activeProject),
+ timelineStore.saveProjectTimeline(activeProject.id),
+ ]);
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to save project:", error);
+ }
+ },
+
+ loadAllProjects: async () => {
+ if (!get().isInitialized) {
+ set({ isLoading: true });
+ }
+
+ try {
+ const projects = await storageService.loadAllProjects();
+ set({ savedProjects: projects });
+ } catch (error) {
+ console.error("Failed to load projects:", error);
+ } finally {
+ set({ isLoading: false, isInitialized: true });
+ }
+ },
+
+ deleteProject: async (id: string) => {
+ try {
+ // Delete project data in parallel
+ await Promise.all([
+ storageService.deleteProjectMedia(id),
+ storageService.deleteProjectTimeline(id),
+ storageService.deleteProject(id),
+ ]);
+ await get().loadAllProjects(); // Refresh the list
+
+ // If we deleted the active project, close it and clear data
+ const { activeProject } = get();
+ if (activeProject?.id === id) {
+ set({ activeProject: null });
+ const mediaStore = useMediaStore.getState();
+ const timelineStore = useTimelineStore.getState();
+ mediaStore.clearAllMedia();
+ timelineStore.clearTimeline();
+ }
+ } catch (error) {
+ console.error("Failed to delete project:", error);
+ }
+ },
+
+ closeProject: () => {
+ set({ activeProject: null });
+
+ // Clear data from stores when closing project
+ const mediaStore = useMediaStore.getState();
+ const timelineStore = useTimelineStore.getState();
+ mediaStore.clearAllMedia();
+ timelineStore.clearTimeline();
+ },
+
+ renameProject: async (id: string, name: string) => {
+ const { savedProjects } = get();
+
+ // Find the project to rename
+ const projectToRename = savedProjects.find((p) => p.id === id);
+ if (!projectToRename) {
+ toast.error("Project not found", {
+ description: "Please try again",
+ });
+ return;
+ }
+
+ const updatedProject = {
+ ...projectToRename,
+ name,
+ updatedAt: new Date(),
+ };
+
+ try {
+ // Save to storage
+ await storageService.saveProject(updatedProject);
+
+ await get().loadAllProjects();
+
+ // Update activeProject if it's the same project
+ const { activeProject } = get();
+ if (activeProject?.id === id) {
+ set({ activeProject: updatedProject });
+ }
+ } catch (error) {
+ console.error("Failed to rename project:", error);
+ toast.error("Failed to rename project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ }
+ },
+
+ duplicateProject: async (projectId: string) => {
+ try {
+ const project = await storageService.loadProject(projectId);
+ if (!project) {
+ toast.error("Project not found", {
+ description: "Please try again",
+ });
+ throw new Error("Project not found");
+ }
+
+ const { savedProjects } = get();
+
+ // Extract the base name (remove any existing numbering)
+ const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
+ const baseName = numberMatch ? numberMatch[2] : project.name;
+ const existingNumbers: number[] = [];
+
+ // Check for pattern "(number) baseName" in existing projects
+ savedProjects.forEach((p) => {
+ const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
+ if (match && match[2] === baseName) {
+ existingNumbers.push(parseInt(match[1], 10));
+ }
+ });
+
+ const nextNumber =
+ existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
+
+ const newProject: TProject = {
+ id: generateUUID(),
+ name: `(${nextNumber}) ${baseName}`,
+ thumbnail: project.thumbnail,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+
+ await storageService.saveProject(newProject);
+ await get().loadAllProjects();
+ return newProject.id;
+ } catch (error) {
+ console.error("Failed to duplicate project:", error);
+ toast.error("Failed to duplicate project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ throw error;
+ }
+ },
+
+ updateProjectBackground: async (backgroundColor: string) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ const updatedProject = {
+ ...activeProject,
+ backgroundColor,
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject(updatedProject);
+ set({ activeProject: updatedProject });
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to update project background:", error);
+ toast.error("Failed to update background", {
+ description: "Please try again",
+ });
+ }
+ },
+
+ updateBackgroundType: async (
+ type: "color" | "blur",
+ options?: { backgroundColor?: string; blurIntensity?: number }
+ ) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ const updatedProject = {
+ ...activeProject,
+ backgroundType: type,
+ ...(options?.backgroundColor && {
+ backgroundColor: options.backgroundColor,
+ }),
+ ...(options?.blurIntensity && { blurIntensity: options.blurIntensity }),
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject(updatedProject);
+ set({ activeProject: updatedProject });
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to update background type:", error);
+ toast.error("Failed to update background", {
+ description: "Please try again",
+ });
+ }
+ },
+
+ updateProjectFps: async (fps: number) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ const updatedProject = {
+ ...activeProject,
+ fps,
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject(updatedProject);
+ set({ activeProject: updatedProject });
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to update project FPS:", error);
+ toast.error("Failed to update project FPS", {
+ description: "Please try again",
+ });
+ }
+ },
+
+ getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
+ const { savedProjects } = get();
+
+ // Filter projects by search query
+ const filteredProjects = savedProjects.filter((project) =>
+ project.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ // Sort filtered projects
+ const sortedProjects = [...filteredProjects].sort((a, b) => {
+ const [key, order] = sortOption.split("-");
+
+ if (key !== "createdAt" && key !== "name") {
+ console.warn(`Invalid sort key: ${key}`);
+ return 0;
+ }
+
+ const aValue = a[key];
+ const bValue = b[key];
+
+ if (aValue === undefined || bValue === undefined) return 0;
+
+ if (order === "asc") {
+ if (aValue < bValue) return -1;
+ if (aValue > bValue) return 1;
+ return 0;
+ } else {
+ if (aValue > bValue) return -1;
+ if (aValue < bValue) return 1;
+ return 0;
+ }
+ });
+
+ return sortedProjects;
+ },
+}));
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index ec724539..9a2c7361 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -1,20 +1,20 @@
import { create } from "zustand";
import {
- TrackType,
- TimelineElement,
- CreateTimelineElement,
- TimelineTrack,
- TextElement,
- DragData,
- sortTracksByOrder,
- ensureMainTrack,
- validateElementTrackCompatibility,
+ TrackType,
+ TimelineElement,
+ CreateTimelineElement,
+ TimelineTrack,
+ TextElement,
+ DragData,
+ sortTracksByOrder,
+ ensureMainTrack,
+ validateElementTrackCompatibility,
} from "@/types/timeline";
import { useEditorStore } from "./editor-store";
import {
- useMediaStore,
- getMediaAspectRatio,
- type MediaItem,
+ useMediaStore,
+ getMediaAspectRatio,
+ type MediaItem,
} from "./media-store";
import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store";
@@ -24,1108 +24,1106 @@ import { toast } from "sonner";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
- originalName: string,
- suffix: string,
+ originalName: string,
+ suffix: string
): string => {
- // Remove existing suffixes to prevent accumulation
- const baseName = originalName
- .replace(/ \(left\)$/, "")
- .replace(/ \(right\)$/, "")
- .replace(/ \(audio\)$/, "")
- .replace(/ \(split \d+\)$/, "");
+ // Remove existing suffixes to prevent accumulation
+ const baseName = originalName
+ .replace(/ \(left\)$/, "")
+ .replace(/ \(right\)$/, "")
+ .replace(/ \(audio\)$/, "")
+ .replace(/ \(split \d+\)$/, "");
- return `${baseName} (${suffix})`;
+ return `${baseName} (${suffix})`;
};
interface TimelineStore {
- // Private track storage
- _tracks: TimelineTrack[];
- history: TimelineTrack[][];
- redoStack: TimelineTrack[][];
+ // Private track storage
+ _tracks: TimelineTrack[];
+ history: TimelineTrack[][];
+ redoStack: TimelineTrack[][];
- // Always returns properly ordered tracks with main track ensured
- tracks: TimelineTrack[];
+ // Always returns properly ordered tracks with main track ensured
+ tracks: TimelineTrack[];
- // Manual method if you need to force recomputation
- getSortedTracks: () => TimelineTrack[];
+ // Manual method if you need to force recomputation
+ getSortedTracks: () => TimelineTrack[];
- // Snapping settings
- snappingEnabled: boolean;
+ // Snapping settings
+ snappingEnabled: boolean;
- // Snapping actions
- toggleSnapping: () => void;
+ // Snapping actions
+ toggleSnapping: () => void;
- // Multi-selection
- selectedElements: { trackId: string; elementId: string }[];
- selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
- deselectElement: (trackId: string, elementId: string) => void;
- clearSelectedElements: () => void;
- setSelectedElements: (
- elements: { trackId: string; elementId: string }[],
- ) => void;
+ // Multi-selection
+ selectedElements: { trackId: string; elementId: string }[];
+ selectElement: (trackId: string, elementId: string, multi?: boolean) => void;
+ deselectElement: (trackId: string, elementId: string) => void;
+ clearSelectedElements: () => void;
+ setSelectedElements: (
+ elements: { trackId: string; elementId: string }[]
+ ) => void;
- // Drag state
- dragState: {
- isDragging: boolean;
- elementId: string | null;
- trackId: string | null;
- startMouseX: number;
- startElementTime: number;
- clickOffsetTime: number;
- currentTime: number;
- };
- setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
- startDrag: (
- elementId: string,
- trackId: string,
- startMouseX: number,
- startElementTime: number,
- clickOffsetTime: number,
- ) => void;
- updateDragTime: (currentTime: number) => void;
- endDrag: () => void;
+ // Drag state
+ dragState: {
+ isDragging: boolean;
+ elementId: string | null;
+ trackId: string | null;
+ startMouseX: number;
+ startElementTime: number;
+ clickOffsetTime: number;
+ currentTime: number;
+ };
+ setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
+ startDrag: (
+ elementId: string,
+ trackId: string,
+ startMouseX: number,
+ startElementTime: number,
+ clickOffsetTime: number
+ ) => void;
+ updateDragTime: (currentTime: number) => void;
+ endDrag: () => void;
- // Actions
- addTrack: (type: TrackType) => string;
- insertTrackAt: (type: TrackType, index: number) => string;
- removeTrack: (trackId: string) => void;
- addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
- removeElementFromTrack: (trackId: string, elementId: string) => void;
- moveElementToTrack: (
- fromTrackId: string,
- toTrackId: string,
- elementId: string,
- ) => void;
- updateElementTrim: (
- trackId: string,
- elementId: string,
- trimStart: number,
- trimEnd: number,
- ) => void;
- updateElementDuration: (
- trackId: string,
- elementId: string,
- duration: number,
- ) => void;
- updateElementStartTime: (
- trackId: string,
- elementId: string,
- startTime: number,
- ) => void;
- toggleTrackMute: (trackId: string) => void;
+ // Actions
+ addTrack: (type: TrackType) => string;
+ insertTrackAt: (type: TrackType, index: number) => string;
+ removeTrack: (trackId: string) => void;
+ addElementToTrack: (trackId: string, element: CreateTimelineElement) => void;
+ removeElementFromTrack: (trackId: string, elementId: string) => void;
+ moveElementToTrack: (
+ fromTrackId: string,
+ toTrackId: string,
+ elementId: string
+ ) => void;
+ updateElementTrim: (
+ trackId: string,
+ elementId: string,
+ trimStart: number,
+ trimEnd: number
+ ) => void;
+ updateElementDuration: (
+ trackId: string,
+ elementId: string,
+ duration: number
+ ) => void;
+ updateElementStartTime: (
+ trackId: string,
+ elementId: string,
+ startTime: number
+ ) => void;
+ toggleTrackMute: (trackId: string) => void;
- // Split operations for elements
- splitElement: (
- trackId: string,
- elementId: string,
- splitTime: number,
- ) => string | null;
- splitAndKeepLeft: (
- trackId: string,
- elementId: string,
- splitTime: number,
- ) => void;
- splitAndKeepRight: (
- trackId: string,
- elementId: string,
- splitTime: number,
- ) => void;
- separateAudio: (trackId: string, elementId: string) => string | null;
+ // Split operations for elements
+ splitElement: (
+ trackId: string,
+ elementId: string,
+ splitTime: number
+ ) => string | null;
+ splitAndKeepLeft: (
+ trackId: string,
+ elementId: string,
+ splitTime: number
+ ) => void;
+ splitAndKeepRight: (
+ trackId: string,
+ elementId: string,
+ splitTime: number
+ ) => void;
+ separateAudio: (trackId: string, elementId: string) => string | null;
- // Replace media for an element
- replaceElementMedia: (
- trackId: string,
- elementId: string,
- newFile: File,
- ) => Promise<boolean>;
+ // Replace media for an element
+ replaceElementMedia: (
+ trackId: string,
+ elementId: string,
+ newFile: File
+ ) => Promise<boolean>;
- // Computed values
- getTotalDuration: () => number;
+ // Computed values
+ getTotalDuration: () => number;
- // History actions
- undo: () => void;
- redo: () => void;
- pushHistory: () => void;
+ // History actions
+ undo: () => void;
+ redo: () => void;
+ pushHistory: () => void;
- // Persistence actions
- loadProjectTimeline: (projectId: string) => Promise<void>;
- saveProjectTimeline: (projectId: string) => Promise<void>;
- clearTimeline: () => void;
- updateTextElement: (
- trackId: string,
- elementId: string,
- updates: Partial<
- Pick<
- TextElement,
- | "content"
- | "fontSize"
- | "fontFamily"
- | "color"
- | "backgroundColor"
- | "textAlign"
- | "fontWeight"
- | "fontStyle"
- | "textDecoration"
- | "x"
- | "y"
- | "rotation"
- | "opacity"
- >
- >,
- ) => void;
- checkElementOverlap: (
- trackId: string,
- startTime: number,
- duration: number,
- 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;
+ // Persistence actions
+ loadProjectTimeline: (projectId: string) => Promise<void>;
+ saveProjectTimeline: (projectId: string) => Promise<void>;
+ clearTimeline: () => void;
+ updateTextElement: (
+ trackId: string,
+ elementId: string,
+ updates: Partial<
+ Pick<
+ TextElement,
+ | "content"
+ | "fontSize"
+ | "fontFamily"
+ | "color"
+ | "backgroundColor"
+ | "textAlign"
+ | "fontWeight"
+ | "fontStyle"
+ | "textDecoration"
+ | "x"
+ | "y"
+ | "rotation"
+ | "opacity"
+ >
+ >
+ ) => void;
+ checkElementOverlap: (
+ trackId: string,
+ startTime: number,
+ duration: number,
+ 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;
}
export const useTimelineStore = create<TimelineStore>((set, get) => {
- // Helper to update tracks and maintain ordering
- const updateTracks = (newTracks: TimelineTrack[]) => {
- const tracksWithMain = ensureMainTrack(newTracks);
- const sortedTracks = sortTracksByOrder(tracksWithMain);
- set({
- _tracks: tracksWithMain,
- tracks: sortedTracks,
- });
- };
+ // Helper to update tracks and maintain ordering
+ const updateTracks = (newTracks: TimelineTrack[]) => {
+ const tracksWithMain = ensureMainTrack(newTracks);
+ const sortedTracks = sortTracksByOrder(tracksWithMain);
+ set({
+ _tracks: tracksWithMain,
+ tracks: sortedTracks,
+ });
+ };
- // Helper to auto-save timeline changes
- const autoSaveTimeline = async () => {
- const activeProject = useProjectStore.getState().activeProject;
- if (activeProject) {
- try {
- await storageService.saveTimeline(activeProject.id, get()._tracks);
- } catch (error) {
- console.error("Failed to auto-save timeline:", error);
- }
- }
- };
+ // Helper to auto-save timeline changes
+ const autoSaveTimeline = async () => {
+ const activeProject = useProjectStore.getState().activeProject;
+ if (activeProject) {
+ try {
+ await storageService.saveTimeline(activeProject.id, get()._tracks);
+ } catch (error) {
+ console.error("Failed to auto-save timeline:", error);
+ }
+ }
+ };
- // Helper to update tracks and auto-save
- const updateTracksAndSave = (newTracks: TimelineTrack[]) => {
- updateTracks(newTracks);
- // Auto-save in background
- setTimeout(autoSaveTimeline, 100);
- };
+ // Helper to update tracks and auto-save
+ const updateTracksAndSave = (newTracks: TimelineTrack[]) => {
+ updateTracks(newTracks);
+ // Auto-save in background
+ setTimeout(autoSaveTimeline, 100);
+ };
- // Initialize with proper track ordering
- const initialTracks = ensureMainTrack([]);
- const sortedInitialTracks = sortTracksByOrder(initialTracks);
+ // Initialize with proper track ordering
+ const initialTracks = ensureMainTrack([]);
+ const sortedInitialTracks = sortTracksByOrder(initialTracks);
- return {
- _tracks: initialTracks,
- tracks: sortedInitialTracks,
- history: [],
- redoStack: [],
- selectedElements: [],
+ return {
+ _tracks: initialTracks,
+ tracks: sortedInitialTracks,
+ history: [],
+ redoStack: [],
+ selectedElements: [],
- // Snapping settings defaults
- snappingEnabled: true,
+ // Snapping settings defaults
+ snappingEnabled: true,
- getSortedTracks: () => {
- const { _tracks } = get();
- const tracksWithMain = ensureMainTrack(_tracks);
- return sortTracksByOrder(tracksWithMain);
- },
+ getSortedTracks: () => {
+ const { _tracks } = get();
+ const tracksWithMain = ensureMainTrack(_tracks);
+ return sortTracksByOrder(tracksWithMain);
+ },
- pushHistory: () => {
- const { _tracks, history } = get();
- set({
- history: [...history, JSON.parse(JSON.stringify(_tracks))],
- redoStack: [],
- });
- },
+ pushHistory: () => {
+ const { _tracks, history } = get();
+ set({
+ history: [...history, JSON.parse(JSON.stringify(_tracks))],
+ redoStack: [],
+ });
+ },
- undo: () => {
- const { history, redoStack, _tracks } = get();
- if (history.length === 0) return;
- const prev = history[history.length - 1];
- updateTracksAndSave(prev);
- set({
- history: history.slice(0, -1),
- redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))],
- });
- },
+ undo: () => {
+ const { history, redoStack, _tracks } = get();
+ if (history.length === 0) return;
+ const prev = history[history.length - 1];
+ updateTracksAndSave(prev);
+ set({
+ history: history.slice(0, -1),
+ redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))],
+ });
+ },
- selectElement: (trackId, elementId, multi = false) => {
- set((state) => {
- const exists = state.selectedElements.some(
- (c) => c.trackId === trackId && c.elementId === elementId,
- );
- if (multi) {
- return exists
- ? {
- selectedElements: state.selectedElements.filter(
- (c) => !(c.trackId === trackId && c.elementId === elementId),
- ),
- }
- : {
- selectedElements: [
- ...state.selectedElements,
- { trackId, elementId },
- ],
- };
- } else {
- return { selectedElements: [{ trackId, elementId }] };
- }
- });
- },
+ selectElement: (trackId, elementId, multi = false) => {
+ set((state) => {
+ const exists = state.selectedElements.some(
+ (c) => c.trackId === trackId && c.elementId === elementId
+ );
+ if (multi) {
+ return exists
+ ? {
+ selectedElements: state.selectedElements.filter(
+ (c) => !(c.trackId === trackId && c.elementId === elementId)
+ ),
+ }
+ : {
+ selectedElements: [
+ ...state.selectedElements,
+ { trackId, elementId },
+ ],
+ };
+ } else {
+ return { selectedElements: [{ trackId, elementId }] };
+ }
+ });
+ },
- deselectElement: (trackId, elementId) => {
- set((state) => ({
- selectedElements: state.selectedElements.filter(
- (c) => !(c.trackId === trackId && c.elementId === elementId),
- ),
- }));
- },
+ deselectElement: (trackId, elementId) => {
+ set((state) => ({
+ selectedElements: state.selectedElements.filter(
+ (c) => !(c.trackId === trackId && c.elementId === elementId)
+ ),
+ }));
+ },
- clearSelectedElements: () => {
- set({ selectedElements: [] });
- },
+ clearSelectedElements: () => {
+ set({ selectedElements: [] });
+ },
- setSelectedElements: (elements) => set({ selectedElements: elements }),
+ setSelectedElements: (elements) => set({ selectedElements: elements }),
- addTrack: (type) => {
- get().pushHistory();
+ addTrack: (type) => {
+ get().pushHistory();
- // Generate proper track name based on type
- const trackName =
- type === "media"
- ? "Media Track"
- : type === "text"
- ? "Text Track"
- : type === "audio"
- ? "Audio Track"
- : "Track";
+ // Generate proper track name based on type
+ const trackName =
+ type === "media"
+ ? "Media Track"
+ : type === "text"
+ ? "Text Track"
+ : type === "audio"
+ ? "Audio Track"
+ : "Track";
- const newTrack: TimelineTrack = {
- id: generateUUID(),
- name: trackName,
- type,
- elements: [],
- muted: false,
- };
+ const newTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: trackName,
+ type,
+ elements: [],
+ muted: false,
+ };
- updateTracksAndSave([...get()._tracks, newTrack]);
- return newTrack.id;
- },
+ updateTracksAndSave([...get()._tracks, newTrack]);
+ return newTrack.id;
+ },
- insertTrackAt: (type, index) => {
- get().pushHistory();
+ insertTrackAt: (type, index) => {
+ get().pushHistory();
- // Generate proper track name based on type
- const trackName =
- type === "media"
- ? "Media Track"
- : type === "text"
- ? "Text Track"
- : type === "audio"
- ? "Audio Track"
- : "Track";
+ // Generate proper track name based on type
+ const trackName =
+ type === "media"
+ ? "Media Track"
+ : type === "text"
+ ? "Text Track"
+ : type === "audio"
+ ? "Audio Track"
+ : "Track";
- const newTrack: TimelineTrack = {
- id: generateUUID(),
- name: trackName,
- type,
- elements: [],
- muted: false,
- };
+ const newTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: trackName,
+ type,
+ elements: [],
+ muted: false,
+ };
- const newTracks = [...get()._tracks];
- newTracks.splice(index, 0, newTrack);
- updateTracksAndSave(newTracks);
- return newTrack.id;
- },
+ const newTracks = [...get()._tracks];
+ newTracks.splice(index, 0, newTrack);
+ updateTracksAndSave(newTracks);
+ return newTrack.id;
+ },
- removeTrack: (trackId) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.filter((track) => track.id !== trackId),
- );
- },
+ removeTrack: (trackId) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.filter((track) => track.id !== trackId)
+ );
+ },
- addElementToTrack: (trackId, elementData) => {
- get().pushHistory();
+ addElementToTrack: (trackId, elementData) => {
+ get().pushHistory();
- // Validate element type matches track type
- const track = get()._tracks.find((t) => t.id === trackId);
- if (!track) {
- console.error("Track not found:", trackId);
- return;
- }
+ // Validate element type matches track type
+ const track = get()._tracks.find((t) => t.id === trackId);
+ if (!track) {
+ console.error("Track not found:", trackId);
+ return;
+ }
- // Use utility function for validation
- const validation = validateElementTrackCompatibility(elementData, track);
- if (!validation.isValid) {
- console.error(validation.errorMessage);
- return;
- }
+ // Use utility function for validation
+ const validation = validateElementTrackCompatibility(elementData, track);
+ if (!validation.isValid) {
+ console.error(validation.errorMessage);
+ return;
+ }
- // For media elements, validate mediaId exists
- if (elementData.type === "media" && !elementData.mediaId) {
- console.error("Media element must have mediaId");
- return;
- }
+ // For media elements, validate mediaId exists
+ if (elementData.type === "media" && !elementData.mediaId) {
+ console.error("Media element must have mediaId");
+ return;
+ }
- // For text elements, validate required text properties
- if (elementData.type === "text" && !elementData.content) {
- console.error("Text element must have content");
- return;
- }
+ // For text elements, validate required text properties
+ if (elementData.type === "text" && !elementData.content) {
+ console.error("Text element must have content");
+ return;
+ }
- // Check if this is the first element being added to the timeline
- const currentState = get();
- const totalElementsInTimeline = currentState._tracks.reduce(
- (total, track) => total + track.elements.length,
- 0,
- );
- const isFirstElement = totalElementsInTimeline === 0;
+ // Check if this is the first element being added to the timeline
+ const currentState = get();
+ const totalElementsInTimeline = currentState._tracks.reduce(
+ (total, track) => total + track.elements.length,
+ 0
+ );
+ const isFirstElement = totalElementsInTimeline === 0;
- const newElement: TimelineElement = {
- ...elementData,
- id: generateUUID(),
- startTime: elementData.startTime || 0,
- trimStart: 0,
- trimEnd: 0,
- } as TimelineElement; // Type assertion since we trust the caller passes valid data
+ const newElement: TimelineElement = {
+ ...elementData,
+ id: generateUUID(),
+ startTime: elementData.startTime || 0,
+ trimStart: 0,
+ trimEnd: 0,
+ } as TimelineElement; // Type assertion since we trust the caller passes valid data
- // If this is the first element and it's a media element, automatically set the project canvas size
- // to match the media's aspect ratio and FPS (for videos)
- if (isFirstElement && newElement.type === "media") {
- const mediaStore = useMediaStore.getState();
- const mediaItem = mediaStore.mediaItems.find(
- (item) => item.id === newElement.mediaId,
- );
+ // If this is the first element and it's a media element, automatically set the project canvas size
+ // to match the media's aspect ratio and FPS (for videos)
+ if (isFirstElement && newElement.type === "media") {
+ const mediaStore = useMediaStore.getState();
+ const mediaItem = mediaStore.mediaItems.find(
+ (item) => item.id === newElement.mediaId
+ );
- if (
- mediaItem &&
- (mediaItem.type === "image" || mediaItem.type === "video")
- ) {
- const editorStore = useEditorStore.getState();
- editorStore.setCanvasSizeFromAspectRatio(
- getMediaAspectRatio(mediaItem),
- );
- }
+ if (
+ mediaItem &&
+ (mediaItem.type === "image" || mediaItem.type === "video")
+ ) {
+ const editorStore = useEditorStore.getState();
+ editorStore.setCanvasSizeFromAspectRatio(
+ getMediaAspectRatio(mediaItem)
+ );
+ }
- // Set project FPS from the first video element
- if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
- const projectStore = useProjectStore.getState();
- if (projectStore.activeProject) {
- projectStore.updateProjectFps(mediaItem.fps);
- }
- }
- }
+ // Set project FPS from the first video element
+ if (mediaItem && mediaItem.type === "video" && mediaItem.fps) {
+ const projectStore = useProjectStore.getState();
+ if (projectStore.activeProject) {
+ projectStore.updateProjectFps(mediaItem.fps);
+ }
+ }
+ }
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? { ...track, elements: [...track.elements, newElement] }
- : track,
- ),
- );
- },
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? { ...track, elements: [...track.elements, newElement] }
+ : track
+ )
+ );
+ },
- removeElementFromTrack: (trackId, elementId) => {
- get().pushHistory();
- updateTracksAndSave(
- get()
- ._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.filter(
- (element) => element.id !== elementId,
- ),
- }
- : track,
- )
- .filter((track) => track.elements.length > 0),
- );
- },
+ removeElementFromTrack: (trackId, elementId) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()
+ ._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.filter(
+ (element) => element.id !== elementId
+ ),
+ }
+ : track
+ )
+ .filter((track) => track.elements.length > 0)
+ );
+ },
- moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
- get().pushHistory();
+ moveElementToTrack: (fromTrackId, toTrackId, elementId) => {
+ get().pushHistory();
- const fromTrack = get()._tracks.find((track) => track.id === fromTrackId);
- const toTrack = get()._tracks.find((track) => track.id === toTrackId);
- const elementToMove = fromTrack?.elements.find(
- (element) => element.id === elementId,
- );
+ const fromTrack = get()._tracks.find((track) => track.id === fromTrackId);
+ const toTrack = get()._tracks.find((track) => track.id === toTrackId);
+ const elementToMove = fromTrack?.elements.find(
+ (element) => element.id === elementId
+ );
- if (!elementToMove || !toTrack) return;
+ if (!elementToMove || !toTrack) return;
- // Validate element type compatibility with target track
- const validation = validateElementTrackCompatibility(
- elementToMove,
- toTrack,
- );
- if (!validation.isValid) {
- console.error(validation.errorMessage);
- return;
- }
+ // Validate element type compatibility with target track
+ const validation = validateElementTrackCompatibility(
+ elementToMove,
+ toTrack
+ );
+ if (!validation.isValid) {
+ console.error(validation.errorMessage);
+ return;
+ }
- const newTracks = get()
- ._tracks.map((track) => {
- if (track.id === fromTrackId) {
- return {
- ...track,
- elements: track.elements.filter(
- (element) => element.id !== elementId,
- ),
- };
- } else if (track.id === toTrackId) {
- return {
- ...track,
- elements: [...track.elements, elementToMove],
- };
- }
- return track;
- })
- .filter((track) => track.elements.length > 0);
+ const newTracks = get()
+ ._tracks.map((track) => {
+ if (track.id === fromTrackId) {
+ return {
+ ...track,
+ elements: track.elements.filter(
+ (element) => element.id !== elementId
+ ),
+ };
+ } else if (track.id === toTrackId) {
+ return {
+ ...track,
+ elements: [...track.elements, elementToMove],
+ };
+ }
+ return track;
+ })
+ .filter((track) => track.elements.length > 0);
- updateTracksAndSave(newTracks);
- },
+ updateTracksAndSave(newTracks);
+ },
- updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId
- ? { ...element, trimStart, trimEnd }
- : element,
- ),
- }
- : track,
- ),
- );
- },
+ updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId
+ ? { ...element, trimStart, trimEnd }
+ : element
+ ),
+ }
+ : track
+ )
+ );
+ },
- updateElementDuration: (trackId, elementId, duration) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId ? { ...element, duration } : element,
- ),
- }
- : track,
- ),
- );
- },
+ updateElementDuration: (trackId, elementId, duration) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId ? { ...element, duration } : element
+ ),
+ }
+ : track
+ )
+ );
+ },
- updateElementStartTime: (trackId, elementId, startTime) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId
- ? { ...element, startTime }
- : element,
- ),
- }
- : track,
- ),
- );
- },
+ updateElementStartTime: (trackId, elementId, startTime) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId ? { ...element, startTime } : element
+ ),
+ }
+ : track
+ )
+ );
+ },
- toggleTrackMute: (trackId) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId ? { ...track, muted: !track.muted } : track,
- ),
- );
- },
+ toggleTrackMute: (trackId) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId ? { ...track, muted: !track.muted } : track
+ )
+ );
+ },
- updateTextElement: (trackId, elementId, updates) => {
- get().pushHistory();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((element) =>
- element.id === elementId && element.type === "text"
- ? { ...element, ...updates }
- : element,
- ),
- }
- : track,
- ),
- );
- },
+ updateTextElement: (trackId, elementId, updates) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId && element.type === "text"
+ ? { ...element, ...updates }
+ : element
+ ),
+ }
+ : track
+ )
+ );
+ },
- splitElement: (trackId, elementId, splitTime) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ splitElement: (trackId, elementId, splitTime) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return null;
+ if (!element) return null;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
- get().pushHistory();
+ get().pushHistory();
- const relativeTime = splitTime - element.startTime;
- const firstDuration = relativeTime;
- const secondDuration =
- element.duration - element.trimStart - element.trimEnd - relativeTime;
+ const relativeTime = splitTime - element.startTime;
+ const firstDuration = relativeTime;
+ const secondDuration =
+ element.duration - element.trimStart - element.trimEnd - relativeTime;
- const secondElementId = generateUUID();
+ const secondElementId = generateUUID();
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.flatMap((c) =>
- c.id === elementId
- ? [
- {
- ...c,
- trimEnd: c.trimEnd + secondDuration,
- name: getElementNameWithSuffix(c.name, "left"),
- },
- {
- ...c,
- id: secondElementId,
- startTime: splitTime,
- trimStart: c.trimStart + firstDuration,
- name: getElementNameWithSuffix(c.name, "right"),
- },
- ]
- : [c],
- ),
- }
- : track,
- ),
- );
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.flatMap((c) =>
+ c.id === elementId
+ ? [
+ {
+ ...c,
+ trimEnd: c.trimEnd + secondDuration,
+ name: getElementNameWithSuffix(c.name, "left"),
+ },
+ {
+ ...c,
+ id: secondElementId,
+ startTime: splitTime,
+ trimStart: c.trimStart + firstDuration,
+ name: getElementNameWithSuffix(c.name, "right"),
+ },
+ ]
+ : [c]
+ ),
+ }
+ : track
+ )
+ );
- return secondElementId;
- },
+ return secondElementId;
+ },
- // Split element and keep only the left portion
- splitAndKeepLeft: (trackId, elementId, splitTime) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ // Split element and keep only the left portion
+ splitAndKeepLeft: (trackId, elementId, splitTime) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
+ if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
- get().pushHistory();
+ get().pushHistory();
- const relativeTime = splitTime - element.startTime;
- const durationToRemove =
- element.duration - element.trimStart - element.trimEnd - relativeTime;
+ const relativeTime = splitTime - element.startTime;
+ const durationToRemove =
+ element.duration - element.trimStart - element.trimEnd - relativeTime;
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((c) =>
- c.id === elementId
- ? {
- ...c,
- trimEnd: c.trimEnd + durationToRemove,
- name: getElementNameWithSuffix(c.name, "left"),
- }
- : c,
- ),
- }
- : track,
- ),
- );
- },
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((c) =>
+ c.id === elementId
+ ? {
+ ...c,
+ trimEnd: c.trimEnd + durationToRemove,
+ name: getElementNameWithSuffix(c.name, "left"),
+ }
+ : c
+ ),
+ }
+ : track
+ )
+ );
+ },
- // Split element and keep only the right portion
- splitAndKeepRight: (trackId, elementId, splitTime) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ // Split element and keep only the right portion
+ splitAndKeepRight: (trackId, elementId, splitTime) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
+ if (!element) return;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
+ const effectiveStart = element.startTime;
+ const effectiveEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
+ if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
- get().pushHistory();
+ get().pushHistory();
- const relativeTime = splitTime - element.startTime;
+ const relativeTime = splitTime - element.startTime;
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === trackId
- ? {
- ...track,
- elements: track.elements.map((c) =>
- c.id === elementId
- ? {
- ...c,
- startTime: splitTime,
- trimStart: c.trimStart + relativeTime,
- name: getElementNameWithSuffix(c.name, "right"),
- }
- : c,
- ),
- }
- : track,
- ),
- );
- },
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((c) =>
+ c.id === elementId
+ ? {
+ ...c,
+ startTime: splitTime,
+ trimStart: c.trimStart + relativeTime,
+ name: getElementNameWithSuffix(c.name, "right"),
+ }
+ : c
+ ),
+ }
+ : track
+ )
+ );
+ },
- // Extract audio from video element to an audio track
- separateAudio: (trackId, elementId) => {
- const { _tracks } = get();
- const track = _tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
+ // Extract audio from video element to an audio track
+ separateAudio: (trackId, elementId) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
- if (!element || track?.type !== "media") return null;
+ if (!element || track?.type !== "media") return null;
- get().pushHistory();
+ get().pushHistory();
- // Find existing audio track or prepare to create one
- const existingAudioTrack = _tracks.find((t) => t.type === "audio");
- const audioElementId = generateUUID();
+ // Find existing audio track or prepare to create one
+ const existingAudioTrack = _tracks.find((t) => t.type === "audio");
+ const audioElementId = generateUUID();
- if (existingAudioTrack) {
- // Add audio element to existing audio track
- updateTracksAndSave(
- get()._tracks.map((track) =>
- track.id === existingAudioTrack.id
- ? {
- ...track,
- elements: [
- ...track.elements,
- {
- ...element,
- id: audioElementId,
- name: getElementNameWithSuffix(element.name, "audio"),
- },
- ],
- }
- : track,
- ),
- );
- } else {
- // Create new audio track with the audio element in a single atomic update
- const newAudioTrack: TimelineTrack = {
- id: generateUUID(),
- name: "Audio Track",
- type: "audio",
- elements: [
- {
- ...element,
- id: audioElementId,
- name: getElementNameWithSuffix(element.name, "audio"),
- },
- ],
- muted: false,
- };
+ if (existingAudioTrack) {
+ // Add audio element to existing audio track
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === existingAudioTrack.id
+ ? {
+ ...track,
+ elements: [
+ ...track.elements,
+ {
+ ...element,
+ id: audioElementId,
+ name: getElementNameWithSuffix(element.name, "audio"),
+ },
+ ],
+ }
+ : track
+ )
+ );
+ } else {
+ // Create new audio track with the audio element in a single atomic update
+ const newAudioTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: "Audio Track",
+ type: "audio",
+ elements: [
+ {
+ ...element,
+ id: audioElementId,
+ name: getElementNameWithSuffix(element.name, "audio"),
+ },
+ ],
+ muted: false,
+ };
- updateTracksAndSave([...get()._tracks, newAudioTrack]);
- }
+ updateTracksAndSave([...get()._tracks, newAudioTrack]);
+ }
- 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);
+ // 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;
+ if (!element || element.type !== "media") return false;
- try {
- const mediaStore = useMediaStore.getState();
- const projectStore = useProjectStore.getState();
+ try {
+ const mediaStore = useMediaStore.getState();
+ const projectStore = useProjectStore.getState();
- if (!projectStore.activeProject) return false;
+ if (!projectStore.activeProject) return false;
- // Import required media processing functions
- const {
- getFileType,
- getImageDimensions,
- generateVideoThumbnail,
- getMediaDuration,
- } = await import("./media-store");
+ // Import required media processing functions
+ const {
+ getFileType,
+ getImageDimensions,
+ generateVideoThumbnail,
+ getMediaDuration,
+ } = await import("./media-store");
- const fileType = getFileType(newFile);
- if (!fileType) return false;
+ 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),
- };
+ // 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);
- }
+ // 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);
+ // 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,
- );
+ // Find the newly created media item
+ const newMediaItem = mediaStore.mediaItems.find(
+ (item) => item.file === newFile
+ );
- if (!newMediaItem) return false;
+ if (!newMediaItem) return false;
- get().pushHistory();
+ 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,
- ),
- );
+ // 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;
- }
- },
+ return true;
+ } catch (error) {
+ console.log(
+ JSON.stringify({
+ error: "Failed to replace element media",
+ details: error,
+ })
+ );
+ return false;
+ }
+ },
- getTotalDuration: () => {
- const { _tracks } = get();
- if (_tracks.length === 0) return 0;
+ getTotalDuration: () => {
+ const { _tracks } = get();
+ if (_tracks.length === 0) return 0;
- const trackEndTimes = _tracks.map((track) =>
- track.elements.reduce((maxEnd, element) => {
- const elementEnd =
- element.startTime +
- element.duration -
- element.trimStart -
- element.trimEnd;
- return Math.max(maxEnd, elementEnd);
- }, 0),
- );
+ const trackEndTimes = _tracks.map((track) =>
+ track.elements.reduce((maxEnd, element) => {
+ const elementEnd =
+ element.startTime +
+ element.duration -
+ element.trimStart -
+ element.trimEnd;
+ return Math.max(maxEnd, elementEnd);
+ }, 0)
+ );
- return Math.max(...trackEndTimes, 0);
- },
+ return Math.max(...trackEndTimes, 0);
+ },
- redo: () => {
- const { redoStack } = get();
- if (redoStack.length === 0) return;
- const next = redoStack[redoStack.length - 1];
- updateTracksAndSave(next);
- set({ redoStack: redoStack.slice(0, -1) });
- },
+ redo: () => {
+ const { redoStack } = get();
+ if (redoStack.length === 0) return;
+ const next = redoStack[redoStack.length - 1];
+ updateTracksAndSave(next);
+ set({ redoStack: redoStack.slice(0, -1) });
+ },
- dragState: {
- isDragging: false,
- elementId: null,
- trackId: null,
- startMouseX: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- },
+ dragState: {
+ isDragging: false,
+ elementId: null,
+ trackId: null,
+ startMouseX: 0,
+ startElementTime: 0,
+ clickOffsetTime: 0,
+ currentTime: 0,
+ },
- setDragState: (dragState) =>
- set((state) => ({
- dragState: { ...state.dragState, ...dragState },
- })),
+ setDragState: (dragState) =>
+ set((state) => ({
+ dragState: { ...state.dragState, ...dragState },
+ })),
- startDrag: (
- elementId,
- trackId,
- startMouseX,
- startElementTime,
- clickOffsetTime,
- ) => {
- set({
- dragState: {
- isDragging: true,
- elementId,
- trackId,
- startMouseX,
- startElementTime,
- clickOffsetTime,
- currentTime: startElementTime,
- },
- });
- },
+ startDrag: (
+ elementId,
+ trackId,
+ startMouseX,
+ startElementTime,
+ clickOffsetTime
+ ) => {
+ set({
+ dragState: {
+ isDragging: true,
+ elementId,
+ trackId,
+ startMouseX,
+ startElementTime,
+ clickOffsetTime,
+ currentTime: startElementTime,
+ },
+ });
+ },
- updateDragTime: (currentTime) => {
- set((state) => ({
- dragState: {
- ...state.dragState,
- currentTime,
- },
- }));
- },
+ updateDragTime: (currentTime) => {
+ set((state) => ({
+ dragState: {
+ ...state.dragState,
+ currentTime,
+ },
+ }));
+ },
- endDrag: () => {
- set({
- dragState: {
- isDragging: false,
- elementId: null,
- trackId: null,
- startMouseX: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- },
- });
- },
+ endDrag: () => {
+ set({
+ dragState: {
+ isDragging: false,
+ elementId: null,
+ trackId: null,
+ startMouseX: 0,
+ startElementTime: 0,
+ clickOffsetTime: 0,
+ currentTime: 0,
+ },
+ });
+ },
- // Persistence methods
- loadProjectTimeline: async (projectId) => {
- try {
- const tracks = await storageService.loadTimeline(projectId);
- if (tracks) {
- updateTracks(tracks);
- } else {
- // No timeline saved yet, initialize with default
- const defaultTracks = ensureMainTrack([]);
- updateTracks(defaultTracks);
- }
- // Clear history when loading a project
- set({ history: [], redoStack: [] });
- } catch (error) {
- console.error("Failed to load timeline:", error);
- // Initialize with default on error
- const defaultTracks = ensureMainTrack([]);
- updateTracks(defaultTracks);
- set({ history: [], redoStack: [] });
- }
- },
+ // Persistence methods
+ loadProjectTimeline: async (projectId) => {
+ try {
+ const tracks = await storageService.loadTimeline(projectId);
+ if (tracks) {
+ updateTracks(tracks);
+ } else {
+ // No timeline saved yet, initialize with default
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ }
+ // Clear history when loading a project
+ set({ history: [], redoStack: [] });
+ } catch (error) {
+ console.error("Failed to load timeline:", error);
+ // Initialize with default on error
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ set({ history: [], redoStack: [] });
+ }
+ },
- saveProjectTimeline: async (projectId) => {
- try {
- await storageService.saveTimeline(projectId, get()._tracks);
- } catch (error) {
- console.error("Failed to save timeline:", error);
- }
- },
+ saveProjectTimeline: async (projectId) => {
+ try {
+ await storageService.saveTimeline(projectId, get()._tracks);
+ } catch (error) {
+ console.error("Failed to save timeline:", error);
+ }
+ },
- clearTimeline: () => {
- const defaultTracks = ensureMainTrack([]);
- updateTracks(defaultTracks);
- set({ history: [], redoStack: [], selectedElements: [] });
- },
+ clearTimeline: () => {
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ set({ history: [], redoStack: [], selectedElements: [] });
+ },
- // Snapping actions
- toggleSnapping: () => {
- set((state) => ({ snappingEnabled: !state.snappingEnabled }));
- },
+ // Snapping actions
+ toggleSnapping: () => {
+ set((state) => ({ snappingEnabled: !state.snappingEnabled }));
+ },
- checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
- const track = get()._tracks.find((t) => t.id === trackId);
- if (!track) return false;
+ checkElementOverlap: (trackId, startTime, duration, excludeElementId) => {
+ const track = get()._tracks.find((t) => t.id === trackId);
+ if (!track) return false;
- const overlap = track.elements.some((element) => {
- const elementEnd =
- element.startTime +
- element.duration -
- element.trimStart -
- element.trimEnd;
+ const overlap = track.elements.some((element) => {
+ const elementEnd =
+ element.startTime +
+ element.duration -
+ element.trimStart -
+ element.trimEnd;
- if (element.id === excludeElementId) {
- return false;
- }
+ if (element.id === excludeElementId) {
+ return false;
+ }
- return (
- (startTime >= element.startTime && startTime < elementEnd) ||
- (startTime + duration > element.startTime &&
- startTime + duration <= elementEnd) ||
- (startTime < element.startTime && startTime + duration > elementEnd)
- );
- });
- return overlap;
- },
+ return (
+ (startTime >= element.startTime && startTime < elementEnd) ||
+ (startTime + duration > element.startTime &&
+ startTime + duration <= elementEnd) ||
+ (startTime < element.startTime && startTime + duration > elementEnd)
+ );
+ });
+ return overlap;
+ },
- findOrCreateTrack: (trackType) => {
- // Always create new text track to allow multiple text elements
- if (trackType === "text") {
- return get().addTrack(trackType);
- }
+ findOrCreateTrack: (trackType) => {
+ // Always create new text track to allow multiple text elements
+ if (trackType === "text") {
+ return get().addTrack(trackType);
+ }
- const existingTrack = get()._tracks.find((t) => t.type === trackType);
- if (existingTrack) {
- return existingTrack.id;
- }
+ const existingTrack = get()._tracks.find((t) => t.type === trackType);
+ if (existingTrack) {
+ return existingTrack.id;
+ }
- return get().addTrack(trackType);
- },
+ return get().addTrack(trackType);
+ },
- addMediaAtTime: (item, currentTime = 0) => {
- const trackType = item.type === "audio" ? "audio" : "media";
- const targetTrackId = get().findOrCreateTrack(trackType);
+ addMediaAtTime: (item, currentTime = 0) => {
+ const trackType = item.type === "audio" ? "audio" : "media";
+ const targetTrackId = get().findOrCreateTrack(trackType);
- const duration =
- item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
+ const duration =
+ item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
- if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements",
- );
- return false;
- }
+ if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
+ toast.error(
+ "Cannot place element here - it would overlap with existing elements"
+ );
+ return false;
+ }
- get().addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: item.id,
- name: item.name,
- duration,
- startTime: currentTime,
- trimStart: 0,
- trimEnd: 0,
- });
- return true;
- },
+ get().addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: item.id,
+ name: item.name,
+ duration,
+ startTime: currentTime,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ return true;
+ },
- addTextAtTime: (item, currentTime = 0) => {
- const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
+ addTextAtTime: (item, currentTime = 0) => {
+ const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
- 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;
- },
+ 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);
+ addMediaToNewTrack: (item) => {
+ const trackType = item.type === "audio" ? "audio" : "media";
+ const targetTrackId = get().findOrCreateTrack(trackType);
- get().addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: item.id,
- name: item.name,
- duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- });
- return true;
- },
+ get().addElementToTrack(targetTrackId, {
+ type: "media",
+ mediaId: item.id,
+ name: item.name,
+ duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ return true;
+ },
- addTextToNewTrack: (item) => {
- const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
+ addTextToNewTrack: (item) => {
+ const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements
- 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;
- },
- };
+ 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;
+ },
+ };
});