mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor not done
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { PlaybackManager } from "./managers/playback-manager";
|
||||
import { TimelineManager } from "./managers/timeline-manager";
|
||||
import { SceneManager } from "./managers/scene-manager";
|
||||
import { ProjectManager } from "./managers/project-manager";
|
||||
import { MediaManager } from "./managers/media-manager";
|
||||
import { RendererManager } from "./managers/renderer-manager";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { SceneExporter } from "@/services/renderer/scene-exporter";
|
||||
import type { ExportOptions } from "@/types/export";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
|
||||
export class EditorCore {
|
||||
private static instance: EditorCore | null = null;
|
||||
|
||||
public readonly playback: PlaybackManager;
|
||||
public readonly timeline: TimelineManager;
|
||||
public readonly scene: SceneManager;
|
||||
public readonly project: ProjectManager;
|
||||
public readonly media: MediaManager;
|
||||
public readonly renderer: RendererManager;
|
||||
|
||||
private constructor() {
|
||||
this.playback = new PlaybackManager(this);
|
||||
this.timeline = new TimelineManager(this);
|
||||
this.scene = new SceneManager(this);
|
||||
this.project = new ProjectManager(this);
|
||||
this.media = new MediaManager(this);
|
||||
this.renderer = new RendererManager(this);
|
||||
}
|
||||
|
||||
static getInstance(): EditorCore {
|
||||
if (!EditorCore.instance) {
|
||||
EditorCore.instance = new EditorCore();
|
||||
}
|
||||
return EditorCore.instance;
|
||||
}
|
||||
|
||||
static reset(): void {
|
||||
EditorCore.instance = null;
|
||||
}
|
||||
|
||||
async export({
|
||||
format,
|
||||
quality,
|
||||
includeAudio = true,
|
||||
onProgress,
|
||||
}: ExportOptions): Promise<{
|
||||
success: boolean;
|
||||
buffer?: ArrayBuffer;
|
||||
error?: string;
|
||||
}> {
|
||||
const project = this.project.getActive();
|
||||
if (!project) {
|
||||
return { success: false, error: "No active project" };
|
||||
}
|
||||
|
||||
const duration = this.timeline.getTotalDuration();
|
||||
if (duration === 0) {
|
||||
return { success: false, error: "Timeline is empty" };
|
||||
}
|
||||
|
||||
try {
|
||||
const sceneGraph = buildScene({
|
||||
tracks: this.timeline.getTracks(),
|
||||
mediaFiles: this.media.getMediaFiles(),
|
||||
duration,
|
||||
canvasSize: project.canvasSize,
|
||||
backgroundColor: project.backgroundColor,
|
||||
});
|
||||
|
||||
const exporter = new SceneExporter({
|
||||
width: project.canvasSize.width,
|
||||
height: project.canvasSize.height,
|
||||
fps: project.fps ?? DEFAULT_FPS,
|
||||
format,
|
||||
quality,
|
||||
includeAudio,
|
||||
});
|
||||
|
||||
if (onProgress) {
|
||||
exporter.on("progress", onProgress);
|
||||
}
|
||||
|
||||
const buffer = await exporter.export(sceneGraph);
|
||||
|
||||
if (onProgress) {
|
||||
exporter.off("progress", onProgress);
|
||||
}
|
||||
|
||||
if (!buffer) {
|
||||
return { success: false, error: "Export was cancelled" };
|
||||
}
|
||||
|
||||
return { success: true, buffer };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Export failed";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { MediaFile } from "@/types/media";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { videoCache } from "@/lib/video-cache";
|
||||
import { generateThumbnail } from "@/lib/media-processing-utils";
|
||||
|
||||
export class MediaManager {
|
||||
private mediaFiles: MediaFile[] = [];
|
||||
private isLoading = false;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
async addMediaFile({
|
||||
projectId,
|
||||
file,
|
||||
}: {
|
||||
projectId: string;
|
||||
file: Omit<MediaFile, "id">;
|
||||
}): Promise<void> {
|
||||
const newItem: MediaFile = {
|
||||
...file,
|
||||
id: generateUUID(),
|
||||
};
|
||||
|
||||
this.mediaFiles = [...this.mediaFiles, newItem];
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
await storageService.saveMediaFile({ projectId, mediaItem: newItem });
|
||||
} catch (error) {
|
||||
console.error("Failed to save media item:", error);
|
||||
this.mediaFiles = this.mediaFiles.filter(
|
||||
(media) => media.id !== newItem.id,
|
||||
);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
async removeMediaFile({
|
||||
projectId,
|
||||
id,
|
||||
}: {
|
||||
projectId: string;
|
||||
id: string;
|
||||
}): Promise<void> {
|
||||
const item = this.mediaFiles.find((media) => media.id === id);
|
||||
|
||||
videoCache.clearVideo(id);
|
||||
|
||||
if (item?.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
this.mediaFiles = this.mediaFiles.filter((media) => media.id !== id);
|
||||
this.notify();
|
||||
|
||||
const tracks = this.editor.timeline.getTracks();
|
||||
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 (elementsToRemove.length > 0) {
|
||||
this.editor.timeline.setSelectedElements({ elements: elementsToRemove });
|
||||
this.editor.timeline.deleteSelected({});
|
||||
}
|
||||
|
||||
try {
|
||||
await storageService.deleteMediaFile({ projectId, id });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete media item:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadProjectMedia({ projectId }: { projectId: string }): Promise<void> {
|
||||
this.isLoading = true;
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
const mediaItems = await storageService.loadAllMediaFiles({ projectId });
|
||||
|
||||
const updatedMediaItems = await Promise.all(
|
||||
mediaItems.map(async (item) => {
|
||||
if (item.type === "video" && item.file) {
|
||||
try {
|
||||
const thumbnailUrl = await generateThumbnail({
|
||||
videoFile: item.file,
|
||||
timeInSeconds: 1,
|
||||
});
|
||||
return {
|
||||
...item,
|
||||
thumbnailUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to regenerate thumbnail for video ${item.id}:`,
|
||||
error,
|
||||
);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
|
||||
this.mediaFiles = updatedMediaItems;
|
||||
this.notify();
|
||||
} catch (error) {
|
||||
console.error("Failed to load media items:", error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
async clearProjectMedia({ projectId }: { projectId: string }): Promise<void> {
|
||||
this.mediaFiles.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
const mediaIds = this.mediaFiles.map((item) => item.id);
|
||||
this.mediaFiles = [];
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id })),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear media items from storage:", error);
|
||||
}
|
||||
}
|
||||
|
||||
clearAllMedia(): void {
|
||||
videoCache.clearAll();
|
||||
|
||||
this.mediaFiles.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
this.mediaFiles = [];
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getMediaFiles(): MediaFile[] {
|
||||
return this.mediaFiles;
|
||||
}
|
||||
|
||||
isLoadingMedia(): boolean {
|
||||
return this.isLoading;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
|
||||
export class PlaybackManager {
|
||||
private isPlaying = false;
|
||||
private currentTime = 0;
|
||||
private volume = 1;
|
||||
private muted = false;
|
||||
private previousVolume = 1;
|
||||
private speed = 1.0;
|
||||
private listeners = new Set<() => void>();
|
||||
private playbackTimer: number | null = null;
|
||||
private lastUpdate = 0;
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
play(): void {
|
||||
const duration = this.editor.timeline.getTotalDuration();
|
||||
|
||||
if (duration > 0) {
|
||||
const fps = this.editor.project.getActiveFps() ?? DEFAULT_FPS;
|
||||
const frameOffset = 1 / fps;
|
||||
const endThreshold = Math.max(0, duration - frameOffset);
|
||||
|
||||
if (this.currentTime >= endThreshold) {
|
||||
this.seek({ time: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
this.isPlaying = true;
|
||||
this.startTimer();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.isPlaying = false;
|
||||
this.stopTimer();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
if (this.isPlaying) {
|
||||
this.pause();
|
||||
} else {
|
||||
this.play();
|
||||
}
|
||||
}
|
||||
|
||||
seek({ time }: { time: number }): void {
|
||||
const duration = this.editor.timeline.getTotalDuration();
|
||||
this.currentTime = Math.max(0, Math.min(duration, time));
|
||||
this.notify();
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-seek", {
|
||||
detail: { time: this.currentTime },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setVolume({ volume }: { volume: number }): void {
|
||||
const clampedVolume = Math.max(0, Math.min(1, volume));
|
||||
this.volume = clampedVolume;
|
||||
this.muted = clampedVolume === 0;
|
||||
if (clampedVolume > 0) {
|
||||
this.previousVolume = clampedVolume;
|
||||
}
|
||||
this.notify();
|
||||
}
|
||||
|
||||
setSpeed({ speed }: { speed: number }): void {
|
||||
this.speed = Math.max(0.1, Math.min(2.0, speed));
|
||||
this.notify();
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-speed", {
|
||||
detail: { speed: this.speed },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
mute(): void {
|
||||
if (this.volume > 0) {
|
||||
this.previousVolume = this.volume;
|
||||
}
|
||||
this.muted = true;
|
||||
this.volume = 0;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
unmute(): void {
|
||||
this.muted = false;
|
||||
this.volume = this.previousVolume;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
toggleMute(): void {
|
||||
if (this.muted) {
|
||||
this.unmute();
|
||||
} else {
|
||||
this.mute();
|
||||
}
|
||||
}
|
||||
|
||||
getIsPlaying(): boolean {
|
||||
return this.isPlaying;
|
||||
}
|
||||
|
||||
getCurrentTime(): number {
|
||||
return this.currentTime;
|
||||
}
|
||||
|
||||
getVolume(): number {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
isMuted(): boolean {
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
getSpeed(): number {
|
||||
return this.speed;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
private startTimer(): void {
|
||||
if (this.playbackTimer) {
|
||||
cancelAnimationFrame(this.playbackTimer);
|
||||
}
|
||||
|
||||
this.lastUpdate = performance.now();
|
||||
this.updateTime();
|
||||
}
|
||||
|
||||
private stopTimer(): void {
|
||||
if (this.playbackTimer) {
|
||||
cancelAnimationFrame(this.playbackTimer);
|
||||
this.playbackTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private updateTime = (): void => {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
const now = performance.now();
|
||||
const delta = (now - this.lastUpdate) / 1000;
|
||||
this.lastUpdate = now;
|
||||
|
||||
const newTime = this.currentTime + delta * this.speed;
|
||||
const duration = this.editor.timeline.getTotalDuration();
|
||||
|
||||
if (duration > 0 && newTime >= duration) {
|
||||
const fps = this.editor.project.getActiveFps() ?? DEFAULT_FPS;
|
||||
const frameOffset = 1 / fps;
|
||||
const stopTime = Math.max(0, duration - frameOffset);
|
||||
|
||||
this.pause();
|
||||
this.currentTime = stopTime;
|
||||
this.notify();
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-seek", {
|
||||
detail: { time: stopTime },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.currentTime = newTime;
|
||||
this.notify();
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-update", {
|
||||
detail: { time: newTime },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
this.playbackTimer = requestAnimationFrame(this.updateTime);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { TProject } from "@/types/project";
|
||||
import type { TCanvasSize } from "@/types/editor";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE, DEFAULT_BLUR_INTENSITY } from "@/constants/editor-constants";
|
||||
import { buildDefaultScene } from "@/lib/scene-utils";
|
||||
|
||||
export class ProjectManager {
|
||||
private activeProject: TProject | null = null;
|
||||
private savedProjects: TProject[] = [];
|
||||
private isLoading = true;
|
||||
private isInitialized = false;
|
||||
private invalidProjectIds = new Set<string>();
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
async createNewProject({ name }: { name: string }): Promise<string> {
|
||||
const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
|
||||
const newProject: TProject = {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
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,
|
||||
};
|
||||
|
||||
this.activeProject = newProject;
|
||||
this.notify();
|
||||
|
||||
this.editor.media.clearAllMedia();
|
||||
this.editor.timeline.clearTimeline();
|
||||
this.editor.scene.initializeScenes({
|
||||
scenes: newProject.scenes,
|
||||
currentSceneId: newProject.currentSceneId,
|
||||
});
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: newProject });
|
||||
await this.loadAllProjects();
|
||||
return newProject.id;
|
||||
} catch (error) {
|
||||
toast.error("Failed to save new project");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async loadProject({ id }: { id: string }): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
this.isLoading = true;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
this.editor.media.clearAllMedia();
|
||||
this.editor.timeline.clearTimeline();
|
||||
this.editor.scene.clearScenes();
|
||||
|
||||
try {
|
||||
const project = await storageService.loadProject({ id });
|
||||
if (!project) {
|
||||
throw new Error(`Project with id ${id} not found`);
|
||||
}
|
||||
|
||||
this.activeProject = project;
|
||||
this.notify();
|
||||
|
||||
let currentScene = null;
|
||||
if (project.scenes && project.scenes.length > 0) {
|
||||
this.editor.scene.initializeScenes({
|
||||
scenes: project.scenes,
|
||||
currentSceneId: project.currentSceneId,
|
||||
});
|
||||
|
||||
currentScene =
|
||||
project.scenes.find((s) => s.id === project.currentSceneId) ||
|
||||
project.scenes.find((s) => s.isMain) ||
|
||||
project.scenes[0];
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.editor.media.loadProjectMedia({ projectId: id }),
|
||||
this.editor.timeline.loadProjectTimeline({
|
||||
projectId: id,
|
||||
sceneId: currentScene?.id,
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Failed to load project:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
async saveCurrentProject(): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
|
||||
try {
|
||||
const currentScene = this.editor.scene.getCurrentScene();
|
||||
|
||||
await Promise.all([
|
||||
storageService.saveProject({ project: this.activeProject }),
|
||||
this.editor.timeline.saveProjectTimeline({
|
||||
projectId: this.activeProject.id,
|
||||
sceneId: currentScene?.id,
|
||||
}),
|
||||
]);
|
||||
await this.loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to save project:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadAllProjects(): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
this.isLoading = true;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await storageService.loadAllProjects();
|
||||
this.savedProjects = projects;
|
||||
this.notify();
|
||||
} catch (error) {
|
||||
console.error("Failed to load projects:", error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.isInitialized = true;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteProject({ id }: { id: string }): Promise<void> {
|
||||
try {
|
||||
await Promise.all([
|
||||
storageService.deleteProjectMedia({ projectId: id }),
|
||||
storageService.deleteProjectTimeline({ projectId: id }),
|
||||
storageService.deleteProject({ id }),
|
||||
]);
|
||||
await this.loadAllProjects();
|
||||
|
||||
if (this.activeProject?.id === id) {
|
||||
this.activeProject = null;
|
||||
this.notify();
|
||||
|
||||
this.editor.media.clearAllMedia();
|
||||
this.editor.timeline.clearTimeline();
|
||||
this.editor.scene.clearScenes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete project:", error);
|
||||
}
|
||||
}
|
||||
|
||||
closeProject(): void {
|
||||
this.activeProject = null;
|
||||
this.notify();
|
||||
|
||||
this.editor.media.clearAllMedia();
|
||||
this.editor.timeline.clearTimeline();
|
||||
this.editor.scene.clearScenes();
|
||||
}
|
||||
|
||||
async renameProject({
|
||||
id,
|
||||
name,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
}): Promise<void> {
|
||||
const projectToRename = this.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 this.loadAllProjects();
|
||||
|
||||
if (this.activeProject?.id === id) {
|
||||
this.activeProject = updatedProject;
|
||||
this.notify();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to rename project:", error);
|
||||
toast.error("Failed to rename project", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async duplicateProject({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}): Promise<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 numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
|
||||
const baseName = numberMatch ? numberMatch[2] : project.name;
|
||||
const existingNumbers: number[] = [];
|
||||
|
||||
this.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 this.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;
|
||||
}
|
||||
}
|
||||
|
||||
async updateProjectBackground({
|
||||
backgroundColor,
|
||||
}: {
|
||||
backgroundColor: string;
|
||||
}): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...this.activeProject,
|
||||
backgroundColor,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.activeProject = updatedProject;
|
||||
this.notify();
|
||||
await this.loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update project background:", error);
|
||||
toast.error("Failed to update background", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateBackgroundType({
|
||||
type,
|
||||
options,
|
||||
}: {
|
||||
type: "color" | "blur";
|
||||
options?: { backgroundColor?: string; blurIntensity?: number };
|
||||
}): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...this.activeProject,
|
||||
backgroundType: type,
|
||||
...(options?.backgroundColor && {
|
||||
backgroundColor: options.backgroundColor,
|
||||
}),
|
||||
...(options?.blurIntensity !== undefined && {
|
||||
blurIntensity: options.blurIntensity,
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.activeProject = updatedProject;
|
||||
this.notify();
|
||||
await this.loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update background type:", error);
|
||||
toast.error("Failed to update background", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateProjectFps({ fps }: { fps: number }): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...this.activeProject,
|
||||
fps,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.activeProject = updatedProject;
|
||||
this.notify();
|
||||
await this.loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update project FPS:", error);
|
||||
toast.error("Failed to update project FPS", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateCanvasSize({ size }: { size: TCanvasSize }): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
|
||||
const updatedProject: TProject = {
|
||||
...this.activeProject,
|
||||
canvasSize: size,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.activeProject = updatedProject;
|
||||
this.notify();
|
||||
await this.loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update canvas size:", error);
|
||||
toast.error("Failed to update canvas size", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getFilteredAndSortedProjects({
|
||||
searchQuery,
|
||||
sortOption,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
sortOption: string;
|
||||
}): TProject[] {
|
||||
const filteredProjects = this.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;
|
||||
}
|
||||
|
||||
isInvalidProjectId({ id }: { id: string }): boolean {
|
||||
return this.invalidProjectIds.has(id);
|
||||
}
|
||||
|
||||
markProjectIdAsInvalid({ id }: { id: string }): void {
|
||||
this.invalidProjectIds.add(id);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
clearInvalidProjectIds(): void {
|
||||
this.invalidProjectIds.clear();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getActive(): TProject | null {
|
||||
return this.activeProject;
|
||||
}
|
||||
|
||||
getActiveFps(): number | undefined {
|
||||
return this.activeProject?.fps;
|
||||
}
|
||||
|
||||
getSavedProjects(): TProject[] {
|
||||
return this.savedProjects;
|
||||
}
|
||||
|
||||
getIsLoading(): boolean {
|
||||
return this.isLoading;
|
||||
}
|
||||
|
||||
getIsInitialized(): boolean {
|
||||
return this.isInitialized;
|
||||
}
|
||||
|
||||
setActiveProject({ project }: { project: TProject }): void {
|
||||
this.activeProject = project;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
||||
import type { ExportOptions, ExportResult } from "@/types/export";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { MediaFile } from "@/types/media";
|
||||
import { SceneExporter } from "@/services/renderer/scene-exporter";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { DEFAULT_FPS, DEFAULT_CANVAS_SIZE } from "@/constants/editor-constants";
|
||||
|
||||
export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
|
||||
format: "mp4",
|
||||
quality: "high",
|
||||
includeAudio: true,
|
||||
};
|
||||
|
||||
interface AudioElement {
|
||||
buffer: AudioBuffer;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export class RendererManager {
|
||||
private renderTree: RootNode | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
setRenderTree({ renderTree }: { renderTree: RootNode | null }): void {
|
||||
this.renderTree = renderTree;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getRenderTree(): RootNode | null {
|
||||
return this.renderTree;
|
||||
}
|
||||
|
||||
async exportProject({
|
||||
options,
|
||||
}: {
|
||||
options: ExportOptions;
|
||||
}): Promise<ExportResult> {
|
||||
const { format, quality, fps, includeAudio, onProgress, onCancel } =
|
||||
options;
|
||||
|
||||
try {
|
||||
const tracks = this.editor.timeline.getTracks();
|
||||
const mediaFiles = this.editor.media.getMediaFiles();
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (!activeProject) {
|
||||
return { success: false, error: "No active project" };
|
||||
}
|
||||
|
||||
const duration = this.editor.timeline.getTotalDuration();
|
||||
if (duration === 0) {
|
||||
return { success: false, error: "Project is empty" };
|
||||
}
|
||||
|
||||
const exportFps = fps || activeProject.fps || DEFAULT_FPS;
|
||||
const canvasSize = activeProject.canvasSize || DEFAULT_CANVAS_SIZE;
|
||||
|
||||
let audioBuffer: AudioBuffer | null = null;
|
||||
if (includeAudio) {
|
||||
onProgress?.(0.05);
|
||||
audioBuffer = await this.createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
|
||||
const scene = buildScene({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
duration,
|
||||
canvasSize,
|
||||
backgroundColor:
|
||||
activeProject.backgroundType === "blur"
|
||||
? "transparent"
|
||||
: activeProject.backgroundColor || "#000000",
|
||||
backgroundType: activeProject.backgroundType,
|
||||
blurIntensity: activeProject.blurIntensity,
|
||||
});
|
||||
|
||||
const exporter = new SceneExporter({
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
fps: exportFps,
|
||||
format,
|
||||
quality,
|
||||
includeAudio: !!includeAudio,
|
||||
audioBuffer: audioBuffer || undefined,
|
||||
});
|
||||
|
||||
exporter.on("progress", (progress) => {
|
||||
const adjustedProgress = includeAudio
|
||||
? 0.05 + progress * 0.95
|
||||
: progress;
|
||||
onProgress?.(adjustedProgress);
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
const checkCancel = () => {
|
||||
if (onCancel?.()) {
|
||||
cancelled = true;
|
||||
exporter.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
const cancelInterval = setInterval(checkCancel, 100);
|
||||
|
||||
try {
|
||||
const buffer = await exporter.export(scene);
|
||||
clearInterval(cancelInterval);
|
||||
|
||||
if (cancelled) {
|
||||
return { success: false, cancelled: true };
|
||||
}
|
||||
|
||||
if (!buffer) {
|
||||
return { success: false, error: "Export failed to produce buffer" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
buffer,
|
||||
};
|
||||
} finally {
|
||||
clearInterval(cancelInterval);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Export failed:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown export error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
duration,
|
||||
sampleRate = 44100,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaFiles: MediaFile[];
|
||||
duration: number;
|
||||
sampleRate?: number;
|
||||
}): Promise<AudioBuffer | null> {
|
||||
const AudioContextClass =
|
||||
window.AudioContext ||
|
||||
(window as typeof window & { webkitAudioContext: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
const audioContext = new AudioContextClass();
|
||||
|
||||
const audioElements: AudioElement[] = [];
|
||||
const mediaMap = new Map<string, MediaFile>(
|
||||
mediaFiles.map((m) => [m.id, m]),
|
||||
);
|
||||
|
||||
for (const track of tracks) {
|
||||
if (track.muted) continue;
|
||||
|
||||
for (const element of track.elements) {
|
||||
if (element.type !== "media") continue;
|
||||
|
||||
const mediaElement = element;
|
||||
const mediaItem = mediaMap.get(mediaElement.mediaId);
|
||||
if (!mediaItem || mediaItem.type !== "audio") continue;
|
||||
|
||||
const visibleDuration =
|
||||
mediaElement.duration - mediaElement.trimStart - mediaElement.trimEnd;
|
||||
if (visibleDuration <= 0) continue;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await mediaItem.file.arrayBuffer();
|
||||
const audioBuffer = await audioContext.decodeAudioData(
|
||||
arrayBuffer.slice(0),
|
||||
);
|
||||
|
||||
audioElements.push({
|
||||
buffer: audioBuffer,
|
||||
startTime: mediaElement.startTime,
|
||||
duration: mediaElement.duration,
|
||||
trimStart: mediaElement.trimStart,
|
||||
trimEnd: mediaElement.trimEnd,
|
||||
muted: mediaElement.muted || track.muted || false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (audioElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outputChannels = 2;
|
||||
const outputLength = Math.ceil(duration * sampleRate);
|
||||
const outputBuffer = audioContext.createBuffer(
|
||||
outputChannels,
|
||||
outputLength,
|
||||
sampleRate,
|
||||
);
|
||||
|
||||
for (const element of audioElements) {
|
||||
if (element.muted) continue;
|
||||
|
||||
const {
|
||||
buffer,
|
||||
startTime,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
duration: elementDuration,
|
||||
} = element;
|
||||
|
||||
const sourceStartSample = Math.floor(trimStart * buffer.sampleRate);
|
||||
const sourceDuration = elementDuration - trimStart - trimEnd;
|
||||
const sourceLengthSamples = Math.floor(
|
||||
sourceDuration * buffer.sampleRate,
|
||||
);
|
||||
const outputStartSample = Math.floor(startTime * sampleRate);
|
||||
|
||||
const resampleRatio = sampleRate / buffer.sampleRate;
|
||||
const resampledLength = Math.floor(sourceLengthSamples * resampleRatio);
|
||||
|
||||
for (let channel = 0; channel < outputChannels; channel++) {
|
||||
const outputData = outputBuffer.getChannelData(channel);
|
||||
const sourceChannel = Math.min(channel, buffer.numberOfChannels - 1);
|
||||
const sourceData = buffer.getChannelData(sourceChannel);
|
||||
|
||||
for (let i = 0; i < resampledLength; i++) {
|
||||
const outputIndex = outputStartSample + i;
|
||||
if (outputIndex >= outputLength) break;
|
||||
|
||||
const sourceIndex = sourceStartSample + Math.floor(i / resampleRatio);
|
||||
if (sourceIndex >= sourceData.length) break;
|
||||
|
||||
outputData[outputIndex] += sourceData[sourceIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/types/project";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getActiveScene,
|
||||
updateSceneInArray,
|
||||
getMainScene as getMainSceneUtil,
|
||||
ensureMainScene,
|
||||
buildDefaultScene,
|
||||
canDeleteScene,
|
||||
getFallbackSceneAfterDelete,
|
||||
normalizeScenes,
|
||||
findCurrentScene,
|
||||
} from "@/lib/scene-utils";
|
||||
import {
|
||||
getFrameTime,
|
||||
toggleBookmarkInArray,
|
||||
removeBookmarkFromArray,
|
||||
isBookmarkAtTime,
|
||||
} from "@/lib/timeline/bookmark-utils";
|
||||
|
||||
export class SceneManager {
|
||||
private currentScene: TScene | null = null;
|
||||
private scenes: TScene[] = [];
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
async createScene({
|
||||
name,
|
||||
isMain = false,
|
||||
}: {
|
||||
name: string;
|
||||
isMain: boolean;
|
||||
}): Promise<string> {
|
||||
const newScene = buildDefaultScene({ name, isMain });
|
||||
const updatedScenes = [...this.scenes, newScene];
|
||||
|
||||
try {
|
||||
await this.updateProjectWithScenes({ updatedScenes });
|
||||
return newScene.id;
|
||||
} catch (error) {
|
||||
console.error("Failed to create scene:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteScene({ sceneId }: { sceneId: string }): Promise<void> {
|
||||
const sceneToDelete = this.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 = this.scenes.filter((s) => s.id !== sceneId);
|
||||
|
||||
const newCurrentScene = getFallbackSceneAfterDelete({
|
||||
scenes: updatedScenes,
|
||||
deletedSceneId: sceneId,
|
||||
currentSceneId: this.currentScene?.id || null,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId: newCurrentScene?.id,
|
||||
});
|
||||
|
||||
if (newCurrentScene && newCurrentScene.id !== this.currentScene?.id) {
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (activeProject) {
|
||||
await this.editor.timeline.loadProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId: newCurrentScene.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete scene:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async renameScene({
|
||||
sceneId,
|
||||
name,
|
||||
}: {
|
||||
sceneId: string;
|
||||
name: string;
|
||||
}): Promise<void> {
|
||||
const updatedScenes = updateSceneInArray({
|
||||
scenes: this.scenes,
|
||||
sceneId,
|
||||
updates: { name, updatedAt: new Date() },
|
||||
});
|
||||
|
||||
try {
|
||||
await this.updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId: sceneId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to rename scene:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async switchToScene({ sceneId }: { sceneId: string }): Promise<void> {
|
||||
const targetScene = this.scenes.find((s) => s.id === sceneId);
|
||||
|
||||
if (!targetScene) {
|
||||
throw new Error("Scene not found");
|
||||
}
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (activeProject && this.currentScene) {
|
||||
await this.editor.timeline.saveProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId: this.currentScene.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeProject) {
|
||||
await this.editor.timeline.loadProjectTimeline({
|
||||
projectId: activeProject.id,
|
||||
sceneId,
|
||||
});
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
currentSceneId: sceneId,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
}
|
||||
|
||||
this.currentScene = targetScene;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async toggleBookmark({ time }: { time: number }): Promise<void> {
|
||||
const activeScene = this.getActiveScene();
|
||||
if (!activeScene || !this.currentScene) return;
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.fps,
|
||||
});
|
||||
|
||||
const bookmarks = activeScene.timeline?.bookmarks || [];
|
||||
const updatedBookmarks = toggleBookmarkInArray({
|
||||
bookmarks,
|
||||
frameTime,
|
||||
});
|
||||
|
||||
const updatedScenes = updateSceneInArray({
|
||||
scenes: this.scenes,
|
||||
sceneId: activeScene.id,
|
||||
updates: {
|
||||
timeline: {
|
||||
...activeScene.timeline,
|
||||
bookmarks: updatedBookmarks,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await this.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 }): boolean {
|
||||
const activeScene = this.getActiveScene();
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (!activeScene || !this.currentScene || !activeProject) return false;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.fps,
|
||||
});
|
||||
const bookmarks = activeScene.timeline?.bookmarks || [];
|
||||
|
||||
return isBookmarkAtTime({ bookmarks, frameTime });
|
||||
}
|
||||
|
||||
async removeBookmark({ time }: { time: number }): Promise<void> {
|
||||
const activeScene = this.getActiveScene();
|
||||
if (!activeScene || !this.currentScene) return;
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
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: this.scenes,
|
||||
sceneId: activeScene.id,
|
||||
updates: {
|
||||
timeline: {
|
||||
...activeScene.timeline,
|
||||
bookmarks: updatedBookmarks,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await this.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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async loadProjectScenes({ projectId }: { projectId: string }): Promise<void> {
|
||||
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,
|
||||
});
|
||||
|
||||
this.scenes = normalizedScenes;
|
||||
this.currentScene = currentScene;
|
||||
this.notify();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load project scenes:", error);
|
||||
this.scenes = [];
|
||||
this.currentScene = null;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
initializeScenes({
|
||||
scenes,
|
||||
currentSceneId,
|
||||
}: {
|
||||
scenes: TScene[];
|
||||
currentSceneId?: string;
|
||||
}): void {
|
||||
const ensuredScenes = ensureMainScene({ scenes });
|
||||
const currentScene = currentSceneId
|
||||
? ensuredScenes.find((s) => s.id === currentSceneId)
|
||||
: null;
|
||||
|
||||
const fallbackScene = getMainSceneUtil({ scenes: ensuredScenes });
|
||||
|
||||
this.scenes = ensuredScenes;
|
||||
this.currentScene = currentScene || fallbackScene;
|
||||
this.notify();
|
||||
|
||||
if (ensuredScenes.length > scenes.length) {
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: ensuredScenes,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
storageService
|
||||
.saveProject({ project: updatedProject })
|
||||
.then(() => {
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"Failed to save project with background scene:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearScenes(): void {
|
||||
this.scenes = [];
|
||||
this.currentScene = null;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getMainScene(): TScene | null {
|
||||
return getMainSceneUtil({ scenes: this.scenes });
|
||||
}
|
||||
|
||||
getCurrentScene(): TScene | null {
|
||||
return this.currentScene;
|
||||
}
|
||||
|
||||
getActiveScene(): TScene | null {
|
||||
return getActiveScene({
|
||||
scenes: this.scenes,
|
||||
currentSceneId: this.currentScene?.id || "",
|
||||
});
|
||||
}
|
||||
|
||||
getScenes(): TScene[] {
|
||||
return this.scenes;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
private async updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId,
|
||||
refreshProjectList = false,
|
||||
}: {
|
||||
updatedScenes: TScene[];
|
||||
updatedSceneId?: string;
|
||||
refreshProjectList?: boolean;
|
||||
}): Promise<void> {
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (!activeProject) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
|
||||
const updatedScene = updatedSceneId
|
||||
? updatedScenes.find((s) => s.id === updatedSceneId)
|
||||
: this.currentScene;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: updatedScenes,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
this.scenes = updatedScenes;
|
||||
this.currentScene = updatedScene || null;
|
||||
this.notify();
|
||||
|
||||
if (refreshProjectList) {
|
||||
await this.editor.project.loadAllProjects();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type {
|
||||
TrackType,
|
||||
CreateTimelineElement,
|
||||
TimelineTrack,
|
||||
TextElement,
|
||||
DragData,
|
||||
} from "@/types/timeline";
|
||||
import type { MediaFile } from "@/types/media";
|
||||
import { calculateTotalDuration } from "@/lib/timeline/calculation-utils";
|
||||
|
||||
export class TimelineManager {
|
||||
private _tracks: TimelineTrack[] = [];
|
||||
private history: TimelineTrack[][] = [];
|
||||
private redoStack: TimelineTrack[][] = [];
|
||||
private clipboard: {
|
||||
items: Array<{ trackType: TrackType; element: CreateTimelineElement }>;
|
||||
} | null = null;
|
||||
private tracks: TimelineTrack[] = [];
|
||||
private snappingEnabled = true;
|
||||
private rippleEditingEnabled = false;
|
||||
private selectedElements: { trackId: string; elementId: string }[] = [];
|
||||
private dragState = {
|
||||
isDragging: false,
|
||||
elementId: null as string | null,
|
||||
trackId: null as string | null,
|
||||
startMouseX: 0,
|
||||
startElementTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
};
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {
|
||||
this.initializeTracks();
|
||||
}
|
||||
|
||||
private initializeTracks(): void {
|
||||
this._tracks = [];
|
||||
this.tracks = [];
|
||||
}
|
||||
|
||||
getSortedTracks(): TimelineTrack[] {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
toggleSnapping(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
toggleRippleEditing(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
selectElement({
|
||||
trackId,
|
||||
elementId,
|
||||
multi = false,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
multi?: boolean;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
deselectElement({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
clearSelectedElements(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
setSelectedElements({
|
||||
elements,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
setDragState({
|
||||
dragState,
|
||||
}: {
|
||||
dragState: Partial<{
|
||||
isDragging: boolean;
|
||||
elementId: string | null;
|
||||
trackId: string | null;
|
||||
startMouseX: number;
|
||||
startElementTime: number;
|
||||
clickOffsetTime: number;
|
||||
currentTime: number;
|
||||
}>;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
startDrag({
|
||||
elementId,
|
||||
trackId,
|
||||
startMouseX,
|
||||
startElementTime,
|
||||
clickOffsetTime,
|
||||
}: {
|
||||
elementId: string;
|
||||
trackId: string;
|
||||
startMouseX: number;
|
||||
startElementTime: number;
|
||||
clickOffsetTime: number;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
updateDragTime({ currentTime }: { currentTime: number }): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
endDrag(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
addTrack({ type }: { type: TrackType }): string {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
insertTrackAt({ type, index }: { type: TrackType; index: number }): string {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
removeTrack({ trackId }: { trackId: string }): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
removeTrackWithRipple({ trackId }: { trackId: string }): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
addElementToTrack({
|
||||
trackId,
|
||||
element,
|
||||
}: {
|
||||
trackId: string;
|
||||
element: CreateTimelineElement;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
moveElementToTrack({
|
||||
fromTrackId,
|
||||
toTrackId,
|
||||
elementId,
|
||||
}: {
|
||||
fromTrackId: string;
|
||||
toTrackId: string;
|
||||
elementId: string;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
updateElementTrim({
|
||||
trackId,
|
||||
elementId,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
pushHistory = true,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
updateElementDuration({
|
||||
trackId,
|
||||
elementId,
|
||||
duration,
|
||||
pushHistory = true,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
duration: number;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
updateElementStartTime({
|
||||
trackId,
|
||||
elementId,
|
||||
startTime,
|
||||
pushHistory = true,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
startTime: number;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
toggleTrackMute({ trackId }: { trackId: string }): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
splitAndKeepLeft({
|
||||
trackId,
|
||||
elementId,
|
||||
splitTime,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
splitTime: number;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
splitAndKeepRight({
|
||||
trackId,
|
||||
elementId,
|
||||
splitTime,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
splitTime: number;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
separateAudio({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
}): string | null {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
replaceElementMedia({
|
||||
trackId,
|
||||
elementId,
|
||||
newFile,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
newFile: File;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
updateElementStartTimeWithRipple({
|
||||
trackId,
|
||||
elementId,
|
||||
newStartTime,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
newStartTime: number;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
removeElementFromTrackWithRipple({
|
||||
trackId,
|
||||
elementId,
|
||||
pushHistory = true,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
pushHistory?: boolean;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
getTotalDuration(): number {
|
||||
return calculateTotalDuration({ tracks: this._tracks });
|
||||
}
|
||||
|
||||
getProjectThumbnail({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}): Promise<string | null> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
pushHistory(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async loadProjectTimeline({
|
||||
projectId,
|
||||
sceneId,
|
||||
}: {
|
||||
projectId: string;
|
||||
sceneId?: string;
|
||||
}): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async saveProjectTimeline({
|
||||
projectId,
|
||||
sceneId,
|
||||
}: {
|
||||
projectId: string;
|
||||
sceneId?: string;
|
||||
}): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
clearTimeline(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
copySelected(): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
pasteAtTime({ time }: { time: number }): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
deleteSelected({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId?: string;
|
||||
elementId?: string;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
splitSelected({
|
||||
splitTime,
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
splitTime: number;
|
||||
trackId?: string;
|
||||
elementId?: string;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
toggleSelectedHidden({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId?: string;
|
||||
elementId?: string;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
toggleSelectedMuted({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId?: string;
|
||||
elementId?: string;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
duplicateElement({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
revealElementInMedia({ elementId }: { elementId: string }): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
async replaceElementWithFile({
|
||||
trackId,
|
||||
elementId,
|
||||
file,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
file: File;
|
||||
}): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
getContextMenuState({
|
||||
trackId,
|
||||
elementId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
}): {
|
||||
isMultipleSelected: boolean;
|
||||
isCurrentElementSelected: boolean;
|
||||
hasAudioElements: boolean;
|
||||
canSplitSelected: boolean;
|
||||
currentTime: number;
|
||||
} {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
updateTextElement({
|
||||
trackId,
|
||||
elementId,
|
||||
updates,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
updates: Partial<
|
||||
Pick<
|
||||
TextElement,
|
||||
| "content"
|
||||
| "fontSize"
|
||||
| "fontFamily"
|
||||
| "color"
|
||||
| "backgroundColor"
|
||||
| "textAlign"
|
||||
| "fontWeight"
|
||||
| "fontStyle"
|
||||
| "textDecoration"
|
||||
| "x"
|
||||
| "y"
|
||||
| "rotation"
|
||||
| "opacity"
|
||||
>
|
||||
>;
|
||||
}): void {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
checkElementOverlap({
|
||||
trackId,
|
||||
startTime,
|
||||
duration,
|
||||
excludeElementId,
|
||||
}: {
|
||||
trackId: string;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
excludeElementId?: string;
|
||||
}): boolean {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
findOrCreateTrack({ trackType }: { trackType: TrackType }): string {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
addElementAtTime({
|
||||
item,
|
||||
currentTime = 0,
|
||||
}: {
|
||||
item: MediaFile | TextElement;
|
||||
currentTime?: number;
|
||||
}): boolean {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
addElementToNewTrack({
|
||||
item,
|
||||
}: {
|
||||
item: MediaFile | TextElement | DragData;
|
||||
}): boolean {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
getTracks(): TimelineTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
|
||||
getTracksWithMain(): TimelineTrack[] {
|
||||
return this._tracks;
|
||||
}
|
||||
|
||||
getSelectedElements(): { trackId: string; elementId: string }[] {
|
||||
return this.selectedElements;
|
||||
}
|
||||
|
||||
getDragState(): typeof this.dragState {
|
||||
return this.dragState;
|
||||
}
|
||||
|
||||
getSnappingEnabled(): boolean {
|
||||
return this.snappingEnabled;
|
||||
}
|
||||
|
||||
getRippleEditingEnabled(): boolean {
|
||||
return this.rippleEditingEnabled;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
private updateTracks(newTracks: TimelineTrack[]): void {
|
||||
this._tracks = newTracks;
|
||||
this.tracks = newTracks; // TODO: Implement proper sorting with main track
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user