This commit is contained in:
Maze Winther
2026-01-05 01:35:10 +01:00
parent d5ca991501
commit c19f085e48
95 changed files with 4133 additions and 4196 deletions
-295
View File
@@ -1,295 +0,0 @@
import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service";
import { useTimelineStore } from "./timeline-store";
import { generateUUID } from "@/lib/utils";
import { MediaType, MediaFile } from "@/types/media";
import { videoCache } from "@/lib/video-cache";
interface MediaStore {
mediaFiles: MediaFile[];
isLoading: boolean;
// Actions
addMediaFile: (
projectId: string,
file: Omit<MediaFile, "id">
) => Promise<void>;
removeMediaFile: (projectId: string, id: string) => Promise<void>;
loadProjectMedia: (projectId: string) => Promise<void>;
clearProjectMedia: (projectId: string) => Promise<void>;
clearAllMedia: () => void;
}
// Helper function to determine file type
export const getFileType = (file: File): MediaType | null => {
const { type } = file;
if (type.startsWith("image/")) {
return "image";
}
if (type.startsWith("video/")) {
return "video";
}
if (type.startsWith("audio/")) {
return "audio";
}
return null;
};
// Helper function to get image dimensions
export const getImageDimensions = (
file: File
): Promise<{ width: number; height: number }> => {
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("error", () => {
reject(new Error("Could not load image"));
img.remove();
});
img.src = URL.createObjectURL(file);
});
};
export const generateVideoThumbnail = (
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");
if (!ctx) {
reject(new Error("Could not get canvas context"));
return;
}
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);
});
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 });
// Cleanup
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();
});
};
// 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;
element.addEventListener("loadedmetadata", () => {
resolve(element.duration);
element.remove();
});
element.addEventListener("error", () => {
reject(new Error("Could not load media"));
element.remove();
});
element.src = URL.createObjectURL(file);
element.load();
});
};
export const getMediaAspectRatio = (item: MediaFile): number => {
if (item.width && item.height) {
return item.width / item.height;
}
return 16 / 9; // Default aspect ratio
};
export const useMediaStore = create<MediaStore>((set, get) => ({
mediaFiles: [],
isLoading: false,
addMediaFile: async (projectId, file) => {
const newItem: MediaFile = {
...file,
id: generateUUID(),
};
// Add to local state immediately for UI responsiveness
set((state) => ({
mediaFiles: [...state.mediaFiles, newItem],
}));
// Save to persistent storage in background
try {
await storageService.saveMediaFile({ projectId, mediaItem: newItem });
} catch (error) {
console.error("Failed to save media item:", error);
// Remove from local state if save failed
set((state) => ({
mediaFiles: state.mediaFiles.filter((media) => media.id !== newItem.id),
}));
}
},
removeMediaFile: async (projectId: string, id: string) => {
const state = get();
const item = state.mediaFiles.find((media) => media.id === id);
videoCache.clearVideo(id);
// Cleanup object URLs to prevent memory leaks
if (item?.url) {
URL.revokeObjectURL(item.url);
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
}
// 1) Remove from local state immediately
set((state) => ({
mediaFiles: state.mediaFiles.filter((media) => media.id !== id),
}));
// 2) Cascade into the timeline: remove any elements using this media ID
const timeline = useTimelineStore.getState();
const { tracks, deleteSelected, setSelectedElements } = timeline;
// Find all elements that reference this media
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
for (const track of tracks) {
for (const el of track.elements) {
if (el.type === "media" && el.mediaId === id) {
elementsToRemove.push({ trackId: track.id, elementId: el.id });
}
}
}
// If there are elements to remove, use unified delete function
if (elementsToRemove.length > 0) {
setSelectedElements(elementsToRemove);
deleteSelected();
}
// 3) Remove from persistent storage
try {
await storageService.deleteMediaFile({ projectId, id });
} catch (error) {
console.error("Failed to delete media item:", error);
}
},
loadProjectMedia: async (projectId) => {
set({ isLoading: true });
try {
const mediaItems = await storageService.loadAllMediaFiles({ 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;
})
);
set({ mediaFiles: updatedMediaItems });
} catch (error) {
console.error("Failed to load media items:", error);
} finally {
set({ isLoading: false });
}
},
clearProjectMedia: async (projectId) => {
const state = get();
// Cleanup all object URLs
state.mediaFiles.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
});
// Clear local state
set({ mediaFiles: [] });
// Clear persistent storage
try {
const mediaIds = state.mediaFiles.map((item) => item.id);
await Promise.all(
mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id }))
);
} catch (error) {
console.error("Failed to clear media items from storage:", error);
}
},
clearAllMedia: () => {
const state = get();
videoCache.clearAll();
// Cleanup all object URLs
state.mediaFiles.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
});
// Clear local state
set({ mediaFiles: [] });
},
}));
-198
View File
@@ -1,198 +0,0 @@
import { create } from "zustand";
import { useTimelineStore } from "@/stores/timeline-store";
import { useProjectStore } from "./project-store";
import { DEFAULT_FPS } from "@/constants/editor-constants";
interface TPlaybackState {
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
speed: number;
muted: boolean;
previousVolume?: number;
}
interface TPlaybackControls {
play: () => void;
pause: () => void;
seek: (time: number) => void;
setVolume: (volume: number) => void;
setSpeed: (speed: number) => void;
toggle: () => void;
mute: () => void;
unmute: () => void;
toggleMute: () => void;
}
interface TPlaybackStore extends TPlaybackState, TPlaybackControls {
setDuration: (duration: number) => void;
setCurrentTime: (time: number) => void;
}
let playbackTimer: number | null = null;
const startTimer = (store: () => TPlaybackStore) => {
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;
const newTime = state.currentTime + delta * state.speed;
// Get actual content duration from timeline store
const actualContentDuration = useTimelineStore
.getState()
.getTotalDuration();
// Stop at actual content end, not timeline duration
// It was either this or reducing default min timeline to 1 second
const effectiveDuration =
actualContentDuration > 0 ? actualContentDuration : state.duration;
if (newTime >= effectiveDuration) {
// When content completes, pause just before the end so we can see the last frame
const projectFps = useProjectStore.getState().activeProject?.fps;
if (!projectFps)
console.error(
"Project FPS is not set, assuming " + DEFAULT_FPS + "fps",
);
const frameOffset = 1 / (projectFps ?? DEFAULT_FPS); // Stop 1 frame before end based on project FPS
const stopTime = Math.max(0, effectiveDuration - frameOffset);
state.pause();
state.setCurrentTime(stopTime);
// Notify video elements to sync with end position
window.dispatchEvent(
new CustomEvent("playback-seek", {
detail: { time: stopTime },
}),
);
} 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);
};
const stopTimer = () => {
if (playbackTimer) {
cancelAnimationFrame(playbackTimer);
playbackTimer = null;
}
};
export const usePlaybackStore = create<TPlaybackStore>((set, get) => ({
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 1,
muted: false,
previousVolume: 1,
speed: 1.0,
play: () => {
const state = get();
const actualContentDuration = useTimelineStore
.getState()
.getTotalDuration();
const effectiveDuration =
actualContentDuration > 0 ? actualContentDuration : state.duration;
if (effectiveDuration > 0) {
const fps = useProjectStore.getState().activeProject?.fps ?? DEFAULT_FPS;
const frameOffset = 1 / fps;
const endThreshold = Math.max(0, effectiveDuration - frameOffset);
if (state.currentTime >= endThreshold) {
get().seek(0);
}
}
set({ isPlaying: true });
startTimer(get);
},
pause: () => {
set({ isPlaying: false });
stopTimer();
},
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 });
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,
})),
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);
},
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,
});
},
unmute: () => {
const { previousVolume } = get();
set({ muted: false, volume: previousVolume ?? 1 });
},
toggleMute: () => {
const { muted } = get();
if (muted) {
get().unmute();
} else {
get().mute();
}
},
}));
-465
View File
@@ -1,465 +0,0 @@
import { TProject, TScene } 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 { useSceneStore } from "./scene-store";
import { generateUUID } from "@/lib/utils";
import { TCanvasSize } from "@/types/editor";
import { DEFAULT_BLUR_INTENSITY, DEFAULT_CANVAS_SIZE, DEFAULT_FPS } from "@/constants/editor-constants";
export function createMainScene(): TScene {
return {
id: generateUUID(),
name: "Main Scene",
isMain: true,
createdAt: new Date(),
updatedAt: new Date(),
};
}
const createDefaultProject = (name: string): TProject => {
const mainScene: TScene = createMainScene();
return {
id: generateUUID(),
name,
thumbnail: "",
createdAt: new Date(),
updatedAt: new Date(),
scenes: [mainScene],
currentSceneId: mainScene.id,
backgroundColor: "#000000",
backgroundType: "color",
blurIntensity: DEFAULT_BLUR_INTENSITY,
fps: DEFAULT_FPS,
canvasSize: DEFAULT_CANVAS_SIZE,
};
};
interface ProjectStore {
activeProject: TProject | null;
savedProjects: TProject[];
isLoading: boolean;
isInitialized: boolean;
invalidProjectIds?: Set<string>;
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>;
updateCanvasSize: ({ size }: { size: TCanvasSize }) => Promise<void>;
getFilteredAndSortedProjects: (
searchQuery: string,
sortOption: string,
) => TProject[];
isInvalidProjectId: (id: string) => boolean;
markProjectIdAsInvalid: (id: string) => void;
clearInvalidProjectIds: () => void;
}
export const useProjectStore = create<ProjectStore>((set, get) => ({
activeProject: null,
savedProjects: [],
isLoading: true,
isInitialized: false,
invalidProjectIds: new Set<string>(),
createNewProject: async (name: string) => {
const newProject = createDefaultProject(name);
set({ activeProject: newProject });
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
const sceneStore = useSceneStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
sceneStore.initializeScenes({
scenes: newProject.scenes,
currentSceneId: newProject.currentSceneId,
});
try {
await storageService.saveProject({ project: 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 });
}
// Prevent flicker when switching projects - clear all stores
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
const sceneStore = useSceneStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
sceneStore.clearScenes();
try {
const project = await storageService.loadProject({ id });
if (project) {
set({ activeProject: project });
let currentScene = null;
if (project.scenes && project.scenes.length > 0) {
sceneStore.initializeScenes({
scenes: project.scenes,
currentSceneId: project.currentSceneId,
});
// Get current scene directly from project data (don't rely on store state)
currentScene =
project.scenes.find((s) => s.id === project.currentSceneId) ||
project.scenes.find((s) => s.isMain) ||
project.scenes[0];
}
await Promise.all([
mediaStore.loadProjectMedia(id),
timelineStore.loadProjectTimeline({
projectId: id,
sceneId: currentScene?.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 {
const timelineStore = useTimelineStore.getState();
const sceneStore = useSceneStore.getState();
const currentScene = sceneStore.currentScene;
await Promise.all([
storageService.saveProject({ project: activeProject }),
timelineStore.saveProjectTimeline({
projectId: activeProject.id,
sceneId: currentScene?.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 {
await Promise.all([
storageService.deleteProjectMedia({ projectId: id }),
storageService.deleteProjectTimeline({ projectId: id }),
storageService.deleteProject({ id }),
]);
await get().loadAllProjects(); // Refresh the list
// If deleted 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();
const sceneStore = useSceneStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
sceneStore.clearScenes();
}
} catch (error) {
console.error("Failed to delete project:", error);
}
},
closeProject: () => {
set({ activeProject: null });
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
const sceneStore = useSceneStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
sceneStore.clearScenes();
},
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 {
await storageService.saveProject({ project: updatedProject });
await get().loadAllProjects();
// Update activeProject if 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({ id: 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 = {
...project,
id: generateUUID(),
name: `(${nextNumber}) ${baseName}`,
createdAt: new Date(),
updatedAt: new Date(),
};
await storageService.saveProject({ project: 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({ project: updatedProject });
set({ activeProject: updatedProject });
await get().loadAllProjects();
} 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 !== undefined && {
blurIntensity: options.blurIntensity,
}),
updatedAt: new Date(),
};
try {
await storageService.saveProject({ project: updatedProject });
set({ activeProject: updatedProject });
await get().loadAllProjects();
} 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({ project: updatedProject });
set({ activeProject: updatedProject });
await get().loadAllProjects();
} catch (error) {
console.error("Failed to update project FPS:", error);
toast.error("Failed to update project FPS", {
description: "Please try again",
});
}
},
updateCanvasSize: async ({ size }: { size: TCanvasSize }) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject: TProject = {
...activeProject,
canvasSize: size,
updatedAt: new Date(),
};
try {
await storageService.saveProject({ project: updatedProject });
set({ activeProject: updatedProject });
await get().loadAllProjects();
} catch (error) {
console.error("Failed to update canvas size:", error);
toast.error("Failed to update canvas size", {
description: "Please try again",
});
}
},
getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
const { savedProjects } = get();
const filteredProjects = savedProjects.filter((project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
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;
}
if (aValue > bValue) return -1;
if (aValue < bValue) return 1;
return 0;
});
return sortedProjects;
},
// Global invalid project ID tracking
isInvalidProjectId: (id: string) => {
const invalidIds = get().invalidProjectIds || new Set();
return invalidIds.has(id);
},
markProjectIdAsInvalid: (id: string) => {
set((state) => ({
invalidProjectIds: new Set([
...(state.invalidProjectIds || new Set()),
id,
]),
}));
},
clearInvalidProjectIds: () => {
set({ invalidProjectIds: new Set() });
},
}));
-12
View File
@@ -1,12 +0,0 @@
import { create } from "zustand";
import { SceneNode } from "@/services/renderer/nodes/root-node";
interface RendererStore {
renderTree: SceneNode | null;
setRenderTree: (renderTree: SceneNode | null) => void;
}
export const useRendererStore = create<RendererStore>((set) => ({
renderTree: null,
setRenderTree: (renderTree) => set({ renderTree }),
}));
-419
View File
@@ -1,419 +0,0 @@
import { create } from "zustand";
import { TScene } from "@/types/project";
import { useProjectStore } from "./project-store";
import { useTimelineStore } from "./timeline-store";
import { storageService } from "@/lib/storage/storage-service";
import {
getActiveScene,
updateSceneInArray,
getMainScene as getMainSceneUtil,
ensureMainScene,
createScene as createSceneUtil,
canDeleteScene,
getFallbackSceneAfterDelete,
normalizeScenes,
findCurrentScene,
} from "@/lib/scene-utils";
import { toast } from "sonner";
import {
getFrameTime,
toggleBookmarkInArray,
removeBookmarkFromArray,
isBookmarkAtTime,
} from "@/lib/timeline/bookmark-utils";
interface SceneStore {
currentScene: TScene | null;
scenes: TScene[];
activeScene: TScene | null;
createScene: ({
name,
isMain,
}: {
name: string;
isMain: boolean;
}) => Promise<string>;
deleteScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
renameScene: ({
sceneId,
name,
}: {
sceneId: string;
name: string;
}) => Promise<void>;
switchToScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
toggleBookmark: ({ time }: { time: number }) => Promise<void>;
isBookmarked: ({ time }: { time: number }) => boolean;
removeBookmark: ({ time }: { time: number }) => Promise<void>;
loadProjectScenes: ({ projectId }: { projectId: string }) => Promise<void>;
initializeScenes: ({
scenes,
currentSceneId,
}: {
scenes: TScene[];
currentSceneId?: string;
}) => void;
clearScenes: () => void;
}
export const useSceneStore = create<SceneStore>((set, get) => {
const updateProjectWithScenes = async ({
updatedScenes,
updatedSceneId,
refreshProjectList = false,
}: {
updatedScenes: TScene[];
updatedSceneId?: string;
refreshProjectList?: boolean;
}) => {
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
if (!activeProject) {
throw new Error("No active project");
}
const updatedScene = updatedSceneId
? updatedScenes.find((s) => s.id === updatedSceneId)
: get().currentScene;
const updatedProject = {
...activeProject,
scenes: updatedScenes,
updatedAt: new Date(),
};
await storageService.saveProject({ project: updatedProject });
useProjectStore.setState({ activeProject: updatedProject });
set({ scenes: updatedScenes, currentScene: updatedScene || null });
if (refreshProjectList) {
await projectStore.loadAllProjects();
}
};
return {
currentScene: null,
scenes: [],
get activeScene(): TScene | null {
const { scenes, currentScene } = get();
return getActiveScene({
scenes,
currentSceneId: currentScene?.id || "",
});
},
createScene: async ({ name, isMain = false }) => {
const { scenes } = get();
const newScene = createSceneUtil({ name, isMain });
const updatedScenes = [...scenes, newScene];
try {
await updateProjectWithScenes({ updatedScenes });
return newScene.id;
} catch (error) {
console.error("Failed to create scene:", error);
throw error;
}
},
deleteScene: async ({ sceneId }: { sceneId: string }) => {
const { scenes, currentScene } = get();
const sceneToDelete = scenes.find((s) => s.id === sceneId);
if (!sceneToDelete) {
throw new Error("Scene not found");
}
const { canDelete, reason } = canDeleteScene({ scene: sceneToDelete });
if (!canDelete) {
throw new Error(reason);
}
const updatedScenes = scenes.filter((s) => s.id !== sceneId);
const newCurrentScene = getFallbackSceneAfterDelete({
scenes: updatedScenes,
deletedSceneId: sceneId,
currentSceneId: currentScene?.id || null,
});
try {
await updateProjectWithScenes({
updatedScenes,
updatedSceneId: newCurrentScene?.id,
});
if (newCurrentScene && newCurrentScene.id !== currentScene?.id) {
const timelineStore = useTimelineStore.getState();
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
if (activeProject) {
await timelineStore.loadProjectTimeline({
projectId: activeProject.id,
sceneId: newCurrentScene.id,
});
}
}
} catch (error) {
console.error("Failed to delete scene:", error);
throw error;
}
},
renameScene: async ({
sceneId,
name,
}: {
sceneId: string;
name: string;
}) => {
const { scenes } = get();
const updatedScenes = updateSceneInArray({
scenes,
sceneId,
updates: { name, updatedAt: new Date() },
});
try {
await updateProjectWithScenes({
updatedScenes,
updatedSceneId: sceneId,
});
} catch (error) {
console.error("Failed to rename scene:", error);
throw error;
}
},
switchToScene: async ({ sceneId }: { sceneId: string }) => {
const { scenes } = get();
const targetScene = scenes.find((s) => s.id === sceneId);
if (!targetScene) {
throw new Error("Scene not found");
}
const timelineStore = useTimelineStore.getState();
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
const { currentScene } = get();
if (activeProject && currentScene) {
await timelineStore.saveProjectTimeline({
projectId: activeProject.id,
sceneId: currentScene.id,
});
}
if (activeProject) {
await timelineStore.loadProjectTimeline({
projectId: activeProject.id,
sceneId,
});
const updatedProject = {
...activeProject,
currentSceneId: sceneId,
updatedAt: new Date(),
};
await storageService.saveProject({ project: updatedProject });
useProjectStore.setState({ activeProject: updatedProject });
}
set({ currentScene: targetScene });
},
toggleBookmark: async ({ time }: { time: number }) => {
const { activeScene, scenes, currentScene } = get();
if (!activeScene || !currentScene) return;
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
if (!activeProject) return;
const frameTime = getFrameTime({
time,
fps: activeProject.fps,
});
const bookmarks = activeScene.timeline?.bookmarks || [];
const updatedBookmarks = toggleBookmarkInArray({
bookmarks,
frameTime,
});
const updatedScenes = updateSceneInArray({
scenes,
sceneId: activeScene.id,
updates: {
timeline: {
...activeScene.timeline,
bookmarks: updatedBookmarks,
},
},
});
try {
await updateProjectWithScenes({
updatedScenes,
updatedSceneId: activeScene.id,
refreshProjectList: true,
});
} catch (error) {
console.error("Failed to update scene bookmarks:", error);
toast.error("Failed to update bookmarks", {
description: "Please try again",
});
}
},
isBookmarked: ({ time }: { time: number }) => {
const { activeScene, currentScene } = get();
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
if (!activeScene || !currentScene || !activeProject) return false;
const frameTime = getFrameTime({
time,
fps: activeProject.fps,
});
const bookmarks = activeScene.timeline?.bookmarks || [];
return isBookmarkAtTime({ bookmarks, frameTime });
},
removeBookmark: async ({ time }: { time: number }) => {
const { activeScene, scenes, currentScene } = get();
if (!activeScene || !currentScene) return;
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
if (!activeProject) return;
const frameTime = getFrameTime({
time,
fps: activeProject.fps,
});
const bookmarks = activeScene.timeline?.bookmarks || [];
const updatedBookmarks = removeBookmarkFromArray({
bookmarks,
frameTime,
});
if (updatedBookmarks.length === bookmarks.length) {
return;
}
const updatedScenes = updateSceneInArray({
scenes,
sceneId: activeScene.id,
updates: {
timeline: {
...activeScene.timeline,
bookmarks: updatedBookmarks,
},
},
});
try {
await updateProjectWithScenes({
updatedScenes,
updatedSceneId: activeScene.id,
refreshProjectList: true,
});
} catch (error) {
console.error("Failed to update scene bookmarks:", error);
toast.error("Failed to remove bookmark", {
description: "Please try again",
});
}
},
getMainScene: () => {
const { scenes } = get();
return getMainSceneUtil({ scenes });
},
getCurrentScene: () => {
return get().currentScene;
},
loadProjectScenes: async ({ projectId }: { projectId: string }) => {
try {
const project = await storageService.loadProject({ id: projectId });
if (project?.scenes) {
const normalizedScenes = normalizeScenes({ scenes: project.scenes });
const currentScene = findCurrentScene({
scenes: normalizedScenes,
currentSceneId: project.currentSceneId,
});
set({
scenes: normalizedScenes,
currentScene,
});
}
} catch (error) {
console.error("Failed to load project scenes:", error);
set({ scenes: [], currentScene: null });
}
},
initializeScenes: ({
scenes,
currentSceneId,
}: {
scenes: TScene[];
currentSceneId?: string;
}) => {
const ensuredScenes = ensureMainScene({ scenes });
const currentScene = currentSceneId
? ensuredScenes.find((s) => s.id === currentSceneId)
: null;
const fallbackScene = getMainSceneUtil({ scenes: ensuredScenes });
set({
scenes: ensuredScenes,
currentScene: currentScene || fallbackScene,
});
if (ensuredScenes.length > scenes.length) {
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
if (activeProject) {
const updatedProject = {
...activeProject,
scenes: ensuredScenes,
updatedAt: new Date(),
};
storageService
.saveProject({ project: updatedProject })
.then(() => {
useProjectStore.setState({ activeProject: updatedProject });
})
.catch((error) => {
console.error(
"Failed to save project with background scene:",
error,
);
});
}
}
},
clearScenes: () => {
set({
scenes: [],
currentScene: null,
});
},
};
});
+1 -1
View File
@@ -274,7 +274,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
error instanceof Error
? error.message
: "Failed to add sound to timeline",
{ id: `sound-${sound.id}` }
{ id: `sound-${sound.id}` },
);
return false;
}
+3 -3
View File
@@ -14,7 +14,7 @@ import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import type { MediaFile } from "@/types/media";
import type { MediaFile } from "@/types/assets";
export type StickerCategory = "all" | "general" | "brands" | "emoji";
@@ -191,7 +191,7 @@ export const useStickersStore = create<StickersStore>((set, get) => ({
url: URL.createObjectURL(file),
width: 200,
height: 200,
duration: TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
ephemeral: false,
};
@@ -201,7 +201,7 @@ export const useStickersStore = create<StickersStore>((set, get) => ({
const added = useMediaStore
.getState()
.mediaFiles.find(
(m) => m.url === mediaItem.url && m.name === mediaItem.name
(m) => m.url === mediaItem.url && m.name === mediaItem.name,
);
if (!added) {
throw new Error("Sticker not in media store");
File diff suppressed because it is too large Load Diff