mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
stuff
This commit is contained in:
+14
-13
@@ -1,6 +1,6 @@
|
||||
import { PlaybackManager } from "./managers/playback-manager";
|
||||
import { TimelineManager } from "./managers/timeline-manager";
|
||||
import { SceneManager } from "./managers/scene-manager";
|
||||
import { ScenesManager } from "./managers/scenes-manager";
|
||||
import { ProjectManager } from "./managers/project-manager";
|
||||
import { MediaManager } from "./managers/media-manager";
|
||||
import { RendererManager } from "./managers/renderer-manager";
|
||||
@@ -8,7 +8,6 @@ import { CommandManager } from "./managers/commands";
|
||||
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;
|
||||
@@ -16,7 +15,7 @@ export class EditorCore {
|
||||
public readonly command: CommandManager;
|
||||
public readonly playback: PlaybackManager;
|
||||
public readonly timeline: TimelineManager;
|
||||
public readonly scene: SceneManager;
|
||||
public readonly scenes: ScenesManager;
|
||||
public readonly project: ProjectManager;
|
||||
public readonly media: MediaManager;
|
||||
public readonly renderer: RendererManager;
|
||||
@@ -25,7 +24,7 @@ export class EditorCore {
|
||||
this.command = new CommandManager();
|
||||
this.playback = new PlaybackManager(this);
|
||||
this.timeline = new TimelineManager(this);
|
||||
this.scene = new SceneManager(this);
|
||||
this.scenes = new ScenesManager(this);
|
||||
this.project = new ProjectManager(this);
|
||||
this.media = new MediaManager(this);
|
||||
this.renderer = new RendererManager(this);
|
||||
@@ -65,29 +64,31 @@ export class EditorCore {
|
||||
try {
|
||||
const sceneGraph = buildScene({
|
||||
tracks: this.timeline.getTracks(),
|
||||
mediaFiles: this.media.getMediaFiles(),
|
||||
mediaAssets: this.media.getAssets(),
|
||||
duration,
|
||||
canvasSize: project.canvasSize,
|
||||
backgroundColor: project.backgroundColor,
|
||||
canvasSize: project.settings.canvasSize,
|
||||
background: project.settings.background,
|
||||
});
|
||||
|
||||
const exporter = new SceneExporter({
|
||||
width: project.canvasSize.width,
|
||||
height: project.canvasSize.height,
|
||||
fps: project.fps ?? DEFAULT_FPS,
|
||||
width: project.settings.canvasSize.width,
|
||||
height: project.settings.canvasSize.height,
|
||||
fps: project.settings.fps,
|
||||
format,
|
||||
quality,
|
||||
includeAudio,
|
||||
});
|
||||
|
||||
let progressHandler: ((progress: number) => void) | undefined;
|
||||
if (onProgress) {
|
||||
exporter.on("progress", onProgress);
|
||||
progressHandler = (progress: number) => onProgress({ progress });
|
||||
exporter.on("progress", progressHandler);
|
||||
}
|
||||
|
||||
const buffer = await exporter.export(sceneGraph);
|
||||
|
||||
if (onProgress) {
|
||||
exporter.off("progress", onProgress);
|
||||
if (progressHandler) {
|
||||
exporter.off("progress", progressHandler);
|
||||
}
|
||||
|
||||
if (!buffer) {
|
||||
|
||||
@@ -1,83 +1,81 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { MediaFile } from "@/types/assets";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { videoCache } from "@/lib/video-cache";
|
||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
||||
|
||||
export class MediaManager {
|
||||
public mediaFiles: MediaFile[] = [];
|
||||
public isLoading = false;
|
||||
private assets: MediaAsset[] = [];
|
||||
private isLoading = false;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
async addMediaFile({
|
||||
async addMediaAsset({
|
||||
projectId,
|
||||
file,
|
||||
asset,
|
||||
}: {
|
||||
projectId: string;
|
||||
file: Omit<MediaFile, "id">;
|
||||
asset: Omit<MediaAsset, "id">;
|
||||
}): Promise<void> {
|
||||
const newItem: MediaFile = {
|
||||
...file,
|
||||
const newAsset: MediaAsset = {
|
||||
...asset,
|
||||
id: generateUUID(),
|
||||
};
|
||||
|
||||
this.mediaFiles = [...this.mediaFiles, newItem];
|
||||
this.assets = [...this.assets, newAsset];
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
await storageService.saveMediaFile({ projectId, mediaItem: newItem });
|
||||
await storageService.saveMediaAsset({ projectId, mediaAsset: newAsset });
|
||||
} catch (error) {
|
||||
console.error("Failed to save media item:", error);
|
||||
this.mediaFiles = this.mediaFiles.filter(
|
||||
(media) => media.id !== newItem.id,
|
||||
);
|
||||
console.error("Failed to save media asset:", error);
|
||||
this.assets = this.assets.filter((asset) => asset.id !== newAsset.id);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
async removeMediaFile({
|
||||
async removeMediaAsset({
|
||||
projectId,
|
||||
id,
|
||||
}: {
|
||||
projectId: string;
|
||||
id: string;
|
||||
}): Promise<void> {
|
||||
const item = this.mediaFiles.find((media) => media.id === id);
|
||||
const asset = this.assets.find((asset) => asset.id === id);
|
||||
|
||||
videoCache.clearVideo(id);
|
||||
|
||||
if (item?.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
if (asset?.url) {
|
||||
URL.revokeObjectURL(asset.url);
|
||||
if (asset.thumbnailUrl) {
|
||||
URL.revokeObjectURL(asset.thumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
this.mediaFiles = this.mediaFiles.filter((media) => media.id !== id);
|
||||
this.assets = this.assets.filter((asset) => asset.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 });
|
||||
for (const element of track.elements) {
|
||||
if (hasMediaId(element) && element.mediaId === id) {
|
||||
elementsToRemove.push({ trackId: track.id, elementId: element.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (elementsToRemove.length > 0) {
|
||||
this.editor.timeline.setSelectedElements({ elements: elementsToRemove });
|
||||
this.editor.timeline.deleteSelected({});
|
||||
this.editor.timeline.deleteElements({ elements: elementsToRemove });
|
||||
}
|
||||
|
||||
try {
|
||||
await storageService.deleteMediaFile({ projectId, id });
|
||||
await storageService.deleteMediaAsset({ projectId, id });
|
||||
} catch (error) {
|
||||
console.error("Failed to delete media item:", error);
|
||||
console.error("Failed to delete media asset:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,11 +84,13 @@ export class MediaManager {
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
const mediaItems = await storageService.loadAllMediaFiles({ projectId });
|
||||
this.mediaFiles = mediaItems;
|
||||
const mediaAssets = await storageService.loadAllMediaAssets({
|
||||
projectId,
|
||||
});
|
||||
this.assets = mediaAssets;
|
||||
this.notify();
|
||||
} catch (error) {
|
||||
console.error("Failed to load media items:", error);
|
||||
console.error("Failed to load media assets:", error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.notify();
|
||||
@@ -98,46 +98,53 @@ export class MediaManager {
|
||||
}
|
||||
|
||||
async clearProjectMedia({ projectId }: { projectId: string }): Promise<void> {
|
||||
this.mediaFiles.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
this.assets.forEach((asset) => {
|
||||
if (asset.url) {
|
||||
URL.revokeObjectURL(asset.url);
|
||||
}
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
if (asset.thumbnailUrl) {
|
||||
URL.revokeObjectURL(asset.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
const mediaIds = this.mediaFiles.map((item) => item.id);
|
||||
this.mediaFiles = [];
|
||||
const mediaIds = this.assets.map((asset) => asset.id);
|
||||
this.assets = [];
|
||||
this.notify();
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id })),
|
||||
mediaIds.map((id) =>
|
||||
storageService.deleteMediaAsset({ projectId, id }),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear media items from storage:", error);
|
||||
console.error("Failed to clear media assets from storage:", error);
|
||||
}
|
||||
}
|
||||
|
||||
clearAllMedia(): void {
|
||||
clearAllAssets(): void {
|
||||
videoCache.clearAll();
|
||||
|
||||
this.mediaFiles.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
this.assets.forEach((asset) => {
|
||||
if (asset.url) {
|
||||
URL.revokeObjectURL(asset.url);
|
||||
}
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
if (asset.thumbnailUrl) {
|
||||
URL.revokeObjectURL(asset.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
this.mediaFiles = [];
|
||||
this.assets = [];
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getMediaFiles(): MediaFile[] {
|
||||
return this.mediaFiles;
|
||||
getAssets(): MediaAsset[] {
|
||||
return this.assets;
|
||||
}
|
||||
|
||||
setAssets({ assets }: { assets: MediaAsset[] }): void {
|
||||
this.assets = assets;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
isLoadingMedia(): boolean {
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import { DEFAULT_FPS } from "@/constants/editor-constants";
|
||||
|
||||
export class PlaybackManager {
|
||||
public isPlaying = false;
|
||||
public currentTime = 0;
|
||||
public volume = 1;
|
||||
public muted = false;
|
||||
public previousVolume = 1;
|
||||
public speed = 1.0;
|
||||
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;
|
||||
@@ -18,7 +17,8 @@ export class PlaybackManager {
|
||||
const duration = this.editor.timeline.getTotalDuration();
|
||||
|
||||
if (duration > 0) {
|
||||
const fps = this.editor.project.getActiveFps() ?? DEFAULT_FPS;
|
||||
const activeProject = this.editor.project.getActive();
|
||||
const fps = activeProject.settings.fps;
|
||||
const frameOffset = 1 / fps;
|
||||
const endThreshold = Math.max(0, duration - frameOffset);
|
||||
|
||||
@@ -158,7 +158,8 @@ export class PlaybackManager {
|
||||
const duration = this.editor.timeline.getTotalDuration();
|
||||
|
||||
if (duration > 0 && newTime >= duration) {
|
||||
const fps = this.editor.project.getActiveFps() ?? DEFAULT_FPS;
|
||||
const activeProject = this.editor.project.getActive();
|
||||
const fps = activeProject.settings.fps;
|
||||
const frameOffset = 1 / fps;
|
||||
const stopTime = Math.max(0, duration - frameOffset);
|
||||
|
||||
|
||||
@@ -1,57 +1,81 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { TProject } from "@/types/project";
|
||||
import type { TCanvasSize } from "@/types/editor";
|
||||
import type {
|
||||
TProject,
|
||||
TProjectMetadata,
|
||||
TProjectSettings,
|
||||
} from "@/types/project";
|
||||
import type { TimelineElement } from "@/types/timeline";
|
||||
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";
|
||||
DEFAULT_COLOR,
|
||||
} from "@/constants/project-constants";
|
||||
import { buildDefaultScene } from "@/lib/scene-utils";
|
||||
import { generateThumbnail } from "@/lib/media-processing-utils";
|
||||
import { CURRENT_VERSION, runMigrations } from "@/lib/migrations";
|
||||
|
||||
export interface MigrationState {
|
||||
isMigrating: boolean;
|
||||
fromVersion: number | null;
|
||||
toVersion: number | null;
|
||||
projectName: string | null;
|
||||
}
|
||||
|
||||
export class ProjectManager {
|
||||
public activeProject: TProject | null = null;
|
||||
public savedProjects: TProject[] = [];
|
||||
public isLoading = true;
|
||||
public isInitialized = false;
|
||||
private active: TProject | null = null;
|
||||
private savedProjects: TProjectMetadata[] = [];
|
||||
private isLoading = true;
|
||||
private isInitialized = false;
|
||||
private invalidProjectIds = new Set<string>();
|
||||
private listeners = new Set<() => void>();
|
||||
private migrationState: MigrationState = {
|
||||
isMigrating: false,
|
||||
fromVersion: null,
|
||||
toVersion: null,
|
||||
projectName: null,
|
||||
};
|
||||
|
||||
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(),
|
||||
metadata: {
|
||||
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,
|
||||
settings: {
|
||||
fps: DEFAULT_FPS,
|
||||
canvasSize: DEFAULT_CANVAS_SIZE,
|
||||
background: {
|
||||
type: "color",
|
||||
color: DEFAULT_COLOR,
|
||||
},
|
||||
},
|
||||
version: CURRENT_VERSION,
|
||||
};
|
||||
|
||||
this.activeProject = newProject;
|
||||
this.active = newProject;
|
||||
this.notify();
|
||||
|
||||
this.editor.media.clearAllMedia();
|
||||
this.editor.timeline.clearTimeline();
|
||||
this.editor.scene.initializeScenes({
|
||||
this.editor.media.clearAllAssets();
|
||||
this.editor.scenes.initializeScenes({
|
||||
scenes: newProject.scenes,
|
||||
currentSceneId: newProject.currentSceneId,
|
||||
});
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: newProject });
|
||||
await this.loadAllProjects();
|
||||
return newProject.id;
|
||||
this.updateMetadata(newProject);
|
||||
|
||||
return newProject.metadata.id;
|
||||
} catch (error) {
|
||||
toast.error("Failed to save new project");
|
||||
throw error;
|
||||
@@ -64,39 +88,55 @@ export class ProjectManager {
|
||||
this.notify();
|
||||
}
|
||||
|
||||
this.editor.media.clearAllMedia();
|
||||
this.editor.timeline.clearTimeline();
|
||||
this.editor.scene.clearScenes();
|
||||
this.editor.media.clearAllAssets();
|
||||
this.editor.scenes.clearScenes();
|
||||
|
||||
try {
|
||||
const project = await storageService.loadProject({ id });
|
||||
if (!project) {
|
||||
const result = await storageService.loadProject({ id });
|
||||
if (!result) {
|
||||
throw new Error(`Project with id ${id} not found`);
|
||||
}
|
||||
|
||||
this.activeProject = project;
|
||||
let project = result.project;
|
||||
const migrationResult = runMigrations({ project });
|
||||
|
||||
if (migrationResult.migrated) {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.setMigrationState({
|
||||
isMigrating: true,
|
||||
fromVersion: migrationResult.fromVersion ?? null,
|
||||
toVersion: migrationResult.toVersion ?? null,
|
||||
projectName: project.metadata.name,
|
||||
});
|
||||
|
||||
project = migrationResult.project;
|
||||
await storageService.saveProject({ project });
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed < 300) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300 - elapsed));
|
||||
}
|
||||
|
||||
this.setMigrationState({
|
||||
isMigrating: false,
|
||||
fromVersion: null,
|
||||
toVersion: null,
|
||||
projectName: null,
|
||||
});
|
||||
}
|
||||
|
||||
this.active = project;
|
||||
this.notify();
|
||||
|
||||
let currentScene = null;
|
||||
if (project.scenes && project.scenes.length > 0) {
|
||||
this.editor.scene.initializeScenes({
|
||||
this.editor.scenes.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,
|
||||
}),
|
||||
]);
|
||||
await this.editor.media.loadProjectMedia({ projectId: id });
|
||||
} catch (error) {
|
||||
console.error("Failed to load project:", error);
|
||||
throw error;
|
||||
@@ -107,19 +147,21 @@ export class ProjectManager {
|
||||
}
|
||||
|
||||
async saveCurrentProject(): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
if (!this.active) return;
|
||||
|
||||
try {
|
||||
const currentScene = this.editor.scene.getCurrentScene();
|
||||
const updatedProject = {
|
||||
...this.active,
|
||||
scenes: this.editor.scenes.getScenes(),
|
||||
metadata: {
|
||||
...this.active.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
storageService.saveProject({ project: this.activeProject }),
|
||||
this.editor.timeline.saveProjectTimeline({
|
||||
projectId: this.activeProject.id,
|
||||
sceneId: currentScene?.id,
|
||||
}),
|
||||
]);
|
||||
await this.loadAllProjects();
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.active = updatedProject;
|
||||
this.updateMetadata(updatedProject);
|
||||
} catch (error) {
|
||||
console.error("Failed to save project:", error);
|
||||
}
|
||||
@@ -132,8 +174,8 @@ export class ProjectManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await storageService.loadAllProjects();
|
||||
this.savedProjects = projects;
|
||||
const metadata = await storageService.loadAllProjectsMetadata();
|
||||
this.savedProjects = metadata;
|
||||
this.notify();
|
||||
} catch (error) {
|
||||
console.error("Failed to load projects:", error);
|
||||
@@ -148,18 +190,18 @@ export class ProjectManager {
|
||||
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.savedProjects = this.savedProjects.filter((p) => p.id !== id);
|
||||
this.notify();
|
||||
|
||||
if (this.active?.metadata.id === id) {
|
||||
this.active = null;
|
||||
this.notify();
|
||||
|
||||
this.editor.media.clearAllMedia();
|
||||
this.editor.timeline.clearTimeline();
|
||||
this.editor.scene.clearScenes();
|
||||
this.editor.media.clearAllAssets();
|
||||
this.editor.scenes.clearScenes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete project:", error);
|
||||
@@ -167,12 +209,11 @@ export class ProjectManager {
|
||||
}
|
||||
|
||||
closeProject(): void {
|
||||
this.activeProject = null;
|
||||
this.active = null;
|
||||
this.notify();
|
||||
|
||||
this.editor.media.clearAllMedia();
|
||||
this.editor.timeline.clearTimeline();
|
||||
this.editor.scene.clearScenes();
|
||||
this.editor.media.clearAllAssets();
|
||||
this.editor.scenes.clearScenes();
|
||||
}
|
||||
|
||||
async renameProject({
|
||||
@@ -182,28 +223,32 @@ export class ProjectManager {
|
||||
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();
|
||||
const result = await storageService.loadProject({ id });
|
||||
if (!result) {
|
||||
toast.error("Project not found", {
|
||||
description: "Please try again",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeProject?.id === id) {
|
||||
this.activeProject = updatedProject;
|
||||
const updatedProject: TProject = {
|
||||
...result.project,
|
||||
metadata: {
|
||||
...result.project.metadata,
|
||||
name,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
|
||||
if (this.active?.metadata.id === id) {
|
||||
this.active = updatedProject;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
this.updateMetadata(updatedProject);
|
||||
} catch (error) {
|
||||
console.error("Failed to rename project:", error);
|
||||
toast.error("Failed to rename project", {
|
||||
@@ -213,22 +258,19 @@ export class ProjectManager {
|
||||
}
|
||||
}
|
||||
|
||||
async duplicateProject({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}): Promise<string> {
|
||||
async duplicateProject({ id }: { id: string }): Promise<string> {
|
||||
try {
|
||||
const project = await storageService.loadProject({ id: projectId });
|
||||
if (!project) {
|
||||
const result = await storageService.loadProject({ id });
|
||||
if (!result) {
|
||||
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 project = result.project;
|
||||
const numberMatch = project.metadata.name.match(/^\((\d+)\)\s+(.+)$/);
|
||||
const baseName = numberMatch ? numberMatch[2] : project.metadata.name;
|
||||
const existingNumbers: number[] = [];
|
||||
|
||||
this.savedProjects.forEach((p) => {
|
||||
@@ -241,17 +283,33 @@ export class ProjectManager {
|
||||
const nextNumber =
|
||||
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
|
||||
|
||||
const newProjectId = generateUUID();
|
||||
const newProject: TProject = {
|
||||
...project,
|
||||
id: generateUUID(),
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {
|
||||
...project.metadata,
|
||||
id: newProjectId,
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: newProject });
|
||||
await this.loadAllProjects();
|
||||
return newProject.id;
|
||||
|
||||
const sourceMediaAssets = await storageService.loadAllMediaAssets({
|
||||
projectId: id,
|
||||
});
|
||||
for (const asset of sourceMediaAssets) {
|
||||
await storageService.saveMediaAsset({
|
||||
projectId: newProjectId,
|
||||
mediaAsset: asset,
|
||||
});
|
||||
}
|
||||
|
||||
this.updateMetadata(newProject);
|
||||
|
||||
return newProjectId;
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate project:", error);
|
||||
toast.error("Failed to duplicate project", {
|
||||
@@ -262,62 +320,86 @@ export class ProjectManager {
|
||||
}
|
||||
}
|
||||
|
||||
async updateProjectThumbnail({
|
||||
thumbnail,
|
||||
async updateSettings({
|
||||
settings,
|
||||
}: {
|
||||
thumbnail: string;
|
||||
settings: Partial<TProjectSettings>;
|
||||
}): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
if (!this.active) return;
|
||||
|
||||
const updatedProject = {
|
||||
...this.activeProject,
|
||||
thumbnail,
|
||||
updatedAt: new Date(),
|
||||
const updatedProject: TProject = {
|
||||
...this.active,
|
||||
settings: { ...this.active.settings, ...settings },
|
||||
metadata: { ...this.active.metadata, updatedAt: new Date() },
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.activeProject = updatedProject;
|
||||
this.active = updatedProject;
|
||||
this.notify();
|
||||
await this.loadAllProjects();
|
||||
} catch (error) {
|
||||
console.error("Failed to update project thumbnail:", error);
|
||||
console.error("Failed to update settings:", error);
|
||||
toast.error("Failed to update settings", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateThumbnail({ thumbnail }: { thumbnail: string }): Promise<void> {
|
||||
if (!this.active) return;
|
||||
|
||||
const updatedProject: TProject = {
|
||||
...this.active,
|
||||
metadata: { ...this.active.metadata, thumbnail, updatedAt: new Date() },
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.active = updatedProject;
|
||||
this.notify();
|
||||
this.updateMetadata(updatedProject);
|
||||
} catch (error) {
|
||||
console.error("Failed to update thumbnail:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async prepareExit(): Promise<void> {
|
||||
if (!this.activeProject) return;
|
||||
if (!this.active) return;
|
||||
|
||||
try {
|
||||
const tracks = this.editor.timeline.getTracks();
|
||||
const mediaFiles = this.editor.media.getMediaFiles();
|
||||
const mediaAssets = this.editor.media.getAssets();
|
||||
|
||||
const firstElement = tracks
|
||||
.flatMap((track) => track.elements)
|
||||
.sort((a, b) => a.startTime - b.startTime)[0];
|
||||
const allElements: TimelineElement[] = tracks.flatMap(
|
||||
(track) => track.elements as TimelineElement[],
|
||||
);
|
||||
const sortedElements = allElements.sort(
|
||||
(a, b) => a.startTime - b.startTime,
|
||||
);
|
||||
const firstElement = sortedElements[0];
|
||||
|
||||
if (
|
||||
firstElement &&
|
||||
(firstElement.type === "video" || firstElement.type === "image")
|
||||
) {
|
||||
const mediaFile = mediaFiles.find(
|
||||
(item) => item.id === firstElement.mediaId,
|
||||
const mediaAsset = mediaAssets.find(
|
||||
(asset) => asset.id === firstElement.mediaId,
|
||||
);
|
||||
|
||||
if (mediaFile) {
|
||||
if (mediaAsset) {
|
||||
let thumbnailDataUrl: string | undefined;
|
||||
|
||||
if (mediaFile.type === "video" && mediaFile.file) {
|
||||
if (mediaAsset.type === "video" && mediaAsset.file) {
|
||||
thumbnailDataUrl = await generateThumbnail({
|
||||
videoFile: mediaFile.file,
|
||||
videoFile: mediaAsset.file,
|
||||
timeInSeconds: 1,
|
||||
});
|
||||
} else if (mediaFile.type === "image" && mediaFile.url) {
|
||||
thumbnailDataUrl = mediaFile.thumbnailUrl || mediaFile.url;
|
||||
} else if (mediaAsset.type === "image" && mediaAsset.url) {
|
||||
thumbnailDataUrl = mediaAsset.thumbnailUrl || mediaAsset.url;
|
||||
}
|
||||
|
||||
if (thumbnailDataUrl && !thumbnailDataUrl.startsWith("blob:")) {
|
||||
await this.updateProjectThumbnail({ thumbnail: thumbnailDataUrl });
|
||||
await this.updateThumbnail({ thumbnail: thumbnailDataUrl });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,117 +408,13 @@ export class ProjectManager {
|
||||
}
|
||||
}
|
||||
|
||||
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[] {
|
||||
}): TProjectMetadata[] {
|
||||
const filteredProjects = this.savedProjects.filter((project) =>
|
||||
project.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
@@ -481,15 +459,18 @@ export class ProjectManager {
|
||||
this.notify();
|
||||
}
|
||||
|
||||
getActive(): TProject | null {
|
||||
return this.activeProject;
|
||||
getActive(): TProject {
|
||||
if (!this.active) {
|
||||
throw new Error("No active project");
|
||||
}
|
||||
return this.active;
|
||||
}
|
||||
|
||||
getActiveFps(): number | undefined {
|
||||
return this.activeProject?.fps;
|
||||
getActiveOrNull(): TProject | null {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
getSavedProjects(): TProject[] {
|
||||
getSavedProjects(): TProjectMetadata[] {
|
||||
return this.savedProjects;
|
||||
}
|
||||
|
||||
@@ -501,8 +482,17 @@ export class ProjectManager {
|
||||
return this.isInitialized;
|
||||
}
|
||||
|
||||
getMigrationState(): MigrationState {
|
||||
return this.migrationState;
|
||||
}
|
||||
|
||||
private setMigrationState(state: Partial<MigrationState>): void {
|
||||
this.migrationState = { ...this.migrationState, ...state };
|
||||
this.notify();
|
||||
}
|
||||
|
||||
setActiveProject({ project }: { project: TProject }): void {
|
||||
this.activeProject = project;
|
||||
this.active = project;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
@@ -511,6 +501,20 @@ export class ProjectManager {
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private updateMetadata(project: TProject): void {
|
||||
const index = this.savedProjects.findIndex(
|
||||
(p) => p.id === project.metadata.id,
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
this.savedProjects[index] = project.metadata;
|
||||
} else {
|
||||
this.savedProjects = [project.metadata, ...this.savedProjects];
|
||||
}
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
@@ -2,16 +2,9 @@ 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/assets";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
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;
|
||||
@@ -23,7 +16,7 @@ interface AudioElement {
|
||||
}
|
||||
|
||||
export class RendererManager {
|
||||
public renderTree: RootNode | null = null;
|
||||
private renderTree: RootNode | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
@@ -47,7 +40,7 @@ export class RendererManager {
|
||||
|
||||
try {
|
||||
const tracks = this.editor.timeline.getTracks();
|
||||
const mediaFiles = this.editor.media.getMediaFiles();
|
||||
const mediaAssets = this.editor.media.getAssets();
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (!activeProject) {
|
||||
@@ -59,30 +52,25 @@ export class RendererManager {
|
||||
return { success: false, error: "Project is empty" };
|
||||
}
|
||||
|
||||
const exportFps = fps || activeProject.fps || DEFAULT_FPS;
|
||||
const canvasSize = activeProject.canvasSize || DEFAULT_CANVAS_SIZE;
|
||||
const exportFps = fps || activeProject.settings.fps;
|
||||
const canvasSize = activeProject.settings.canvasSize;
|
||||
|
||||
let audioBuffer: AudioBuffer | null = null;
|
||||
if (includeAudio) {
|
||||
onProgress?.(0.05);
|
||||
onProgress?.({ progress: 0.05 });
|
||||
audioBuffer = await this.createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
mediaAssets,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
|
||||
const scene = buildScene({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
mediaAssets,
|
||||
duration,
|
||||
canvasSize,
|
||||
backgroundColor:
|
||||
activeProject.backgroundType === "blur"
|
||||
? "transparent"
|
||||
: activeProject.backgroundColor || "#000000",
|
||||
backgroundType: activeProject.backgroundType,
|
||||
blurIntensity: activeProject.blurIntensity,
|
||||
background: activeProject.settings.background,
|
||||
});
|
||||
|
||||
const exporter = new SceneExporter({
|
||||
@@ -99,7 +87,7 @@ export class RendererManager {
|
||||
const adjustedProgress = includeAudio
|
||||
? 0.05 + progress * 0.95
|
||||
: progress;
|
||||
onProgress?.(adjustedProgress);
|
||||
onProgress?.({ progress: adjustedProgress });
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
@@ -142,12 +130,12 @@ export class RendererManager {
|
||||
|
||||
private async createTimelineAudioBuffer({
|
||||
tracks,
|
||||
mediaFiles,
|
||||
mediaAssets,
|
||||
duration,
|
||||
sampleRate = 44100,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaFiles: MediaFile[];
|
||||
mediaAssets: MediaAsset[];
|
||||
duration: number;
|
||||
sampleRate?: number;
|
||||
}): Promise<AudioBuffer | null> {
|
||||
@@ -158,8 +146,8 @@ export class RendererManager {
|
||||
const audioContext = new AudioContextClass();
|
||||
|
||||
const audioElements: AudioElement[] = [];
|
||||
const mediaMap = new Map<string, MediaFile>(
|
||||
mediaFiles.map((m) => [m.id, m]),
|
||||
const mediaMap = new Map<string, MediaAsset>(
|
||||
mediaAssets.map((m) => [m.id, m]),
|
||||
);
|
||||
|
||||
for (const track of tracks) {
|
||||
@@ -170,20 +158,25 @@ export class RendererManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mediaItem = mediaMap.get(element.mediaId);
|
||||
if (!mediaItem || mediaItem.type !== "audio") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const visibleDuration =
|
||||
element.duration - element.trimStart - element.trimEnd;
|
||||
if (visibleDuration <= 0) continue;
|
||||
if (element.duration <= 0) continue;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await mediaItem.file.arrayBuffer();
|
||||
const audioBuffer = await audioContext.decodeAudioData(
|
||||
arrayBuffer.slice(0),
|
||||
);
|
||||
let audioBuffer: AudioBuffer;
|
||||
|
||||
if (element.sourceType === "upload") {
|
||||
const mediaAsset = mediaMap.get(element.mediaId);
|
||||
if (!mediaAsset || mediaAsset.type !== "audio") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const arrayBuffer = await mediaAsset.file.arrayBuffer();
|
||||
audioBuffer = await audioContext.decodeAudioData(
|
||||
arrayBuffer.slice(0),
|
||||
);
|
||||
} else {
|
||||
// library audio - already has decoded buffer
|
||||
audioBuffer = element.buffer;
|
||||
}
|
||||
|
||||
audioElements.push({
|
||||
buffer: audioBuffer,
|
||||
@@ -194,7 +187,7 @@ export class RendererManager {
|
||||
muted: element.muted || track.muted || false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);
|
||||
console.warn("Failed to decode audio:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,14 +211,12 @@ export class RendererManager {
|
||||
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,
|
||||
elementDuration * buffer.sampleRate,
|
||||
);
|
||||
const outputStartSample = Math.floor(startTime * sampleRate);
|
||||
|
||||
|
||||
+123
-105
@@ -1,16 +1,14 @@
|
||||
import type { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/types/project";
|
||||
import type { TScene } from "@/types/timeline";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getActiveScene,
|
||||
updateSceneInArray,
|
||||
getMainScene as getMainSceneUtil,
|
||||
getMainScene,
|
||||
ensureMainScene,
|
||||
buildDefaultScene,
|
||||
canDeleteScene,
|
||||
getFallbackSceneAfterDelete,
|
||||
normalizeScenes,
|
||||
findCurrentScene,
|
||||
} from "@/lib/scene-utils";
|
||||
import {
|
||||
@@ -20,9 +18,9 @@ import {
|
||||
isBookmarkAtTime,
|
||||
} from "@/lib/timeline/bookmark-utils";
|
||||
|
||||
export class SceneManager {
|
||||
public currentScene: TScene | null = null;
|
||||
public scenes: TScene[] = [];
|
||||
export class ScenesManager {
|
||||
private active: TScene | null = null;
|
||||
private list: TScene[] = [];
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
@@ -35,7 +33,7 @@ export class SceneManager {
|
||||
isMain: boolean;
|
||||
}): Promise<string> {
|
||||
const newScene = buildDefaultScene({ name, isMain });
|
||||
const updatedScenes = [...this.scenes, newScene];
|
||||
const updatedScenes = [...this.list, newScene];
|
||||
|
||||
try {
|
||||
await this.updateProjectWithScenes({ updatedScenes });
|
||||
@@ -47,7 +45,7 @@ export class SceneManager {
|
||||
}
|
||||
|
||||
async deleteScene({ sceneId }: { sceneId: string }): Promise<void> {
|
||||
const sceneToDelete = this.scenes.find((s) => s.id === sceneId);
|
||||
const sceneToDelete = this.list.find((s) => s.id === sceneId);
|
||||
|
||||
if (!sceneToDelete) {
|
||||
throw new Error("Scene not found");
|
||||
@@ -58,12 +56,12 @@ export class SceneManager {
|
||||
throw new Error(reason);
|
||||
}
|
||||
|
||||
const updatedScenes = this.scenes.filter((s) => s.id !== sceneId);
|
||||
const updatedScenes = this.list.filter((s) => s.id !== sceneId);
|
||||
|
||||
const newCurrentScene = getFallbackSceneAfterDelete({
|
||||
scenes: updatedScenes,
|
||||
deletedSceneId: sceneId,
|
||||
currentSceneId: this.currentScene?.id || null,
|
||||
currentSceneId: this.active?.id || null,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -71,16 +69,6 @@ export class SceneManager {
|
||||
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;
|
||||
@@ -95,7 +83,7 @@ export class SceneManager {
|
||||
name: string;
|
||||
}): Promise<void> {
|
||||
const updatedScenes = updateSceneInArray({
|
||||
scenes: this.scenes,
|
||||
scenes: this.list,
|
||||
sceneId,
|
||||
updates: { name, updatedAt: new Date() },
|
||||
});
|
||||
@@ -112,7 +100,7 @@ export class SceneManager {
|
||||
}
|
||||
|
||||
async switchToScene({ sceneId }: { sceneId: string }): Promise<void> {
|
||||
const targetScene = this.scenes.find((s) => s.id === sceneId);
|
||||
const targetScene = this.list.find((s) => s.id === sceneId);
|
||||
|
||||
if (!targetScene) {
|
||||
throw new Error("Scene not found");
|
||||
@@ -120,67 +108,51 @@ export class SceneManager {
|
||||
|
||||
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(),
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
}
|
||||
|
||||
this.currentScene = targetScene;
|
||||
this.active = targetScene;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async toggleBookmark({ time }: { time: number }): Promise<void> {
|
||||
const activeScene = this.getActiveScene();
|
||||
if (!activeScene || !this.currentScene) return;
|
||||
if (!activeScene || !this.active) return;
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.fps,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
|
||||
const bookmarks = activeScene.timeline?.bookmarks || [];
|
||||
const updatedBookmarks = toggleBookmarkInArray({
|
||||
bookmarks,
|
||||
bookmarks: activeScene.bookmarks,
|
||||
frameTime,
|
||||
});
|
||||
|
||||
const updatedScenes = updateSceneInArray({
|
||||
scenes: this.scenes,
|
||||
scenes: this.list,
|
||||
sceneId: activeScene.id,
|
||||
updates: {
|
||||
timeline: {
|
||||
...activeScene.timeline,
|
||||
bookmarks: updatedBookmarks,
|
||||
},
|
||||
},
|
||||
updates: { bookmarks: updatedBookmarks },
|
||||
});
|
||||
|
||||
try {
|
||||
await this.updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId: activeScene.id,
|
||||
refreshProjectList: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update scene bookmarks:", error);
|
||||
@@ -194,55 +166,47 @@ export class SceneManager {
|
||||
const activeScene = this.getActiveScene();
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
if (!activeScene || !this.currentScene || !activeProject) return false;
|
||||
if (!activeScene || !this.active || !activeProject) return false;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.fps,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
const bookmarks = activeScene.timeline?.bookmarks || [];
|
||||
|
||||
return isBookmarkAtTime({ bookmarks, frameTime });
|
||||
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
|
||||
}
|
||||
|
||||
async removeBookmark({ time }: { time: number }): Promise<void> {
|
||||
const activeScene = this.getActiveScene();
|
||||
if (!activeScene || !this.currentScene) return;
|
||||
if (!activeScene || !this.active) return;
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const frameTime = getFrameTime({
|
||||
time,
|
||||
fps: activeProject.fps,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
const bookmarks = activeScene.timeline?.bookmarks || [];
|
||||
|
||||
const updatedBookmarks = removeBookmarkFromArray({
|
||||
bookmarks,
|
||||
bookmarks: activeScene.bookmarks,
|
||||
frameTime,
|
||||
});
|
||||
|
||||
if (updatedBookmarks.length === bookmarks.length) {
|
||||
if (updatedBookmarks.length === activeScene.bookmarks.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedScenes = updateSceneInArray({
|
||||
scenes: this.scenes,
|
||||
scenes: this.list,
|
||||
sceneId: activeScene.id,
|
||||
updates: {
|
||||
timeline: {
|
||||
...activeScene.timeline,
|
||||
bookmarks: updatedBookmarks,
|
||||
},
|
||||
},
|
||||
updates: { bookmarks: updatedBookmarks },
|
||||
});
|
||||
|
||||
try {
|
||||
await this.updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId: activeScene.id,
|
||||
refreshProjectList: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update scene bookmarks:", error);
|
||||
@@ -254,22 +218,21 @@ export class SceneManager {
|
||||
|
||||
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 result = await storageService.loadProject({ id: projectId });
|
||||
if (result?.project.scenes) {
|
||||
const currentScene = findCurrentScene({
|
||||
scenes: normalizedScenes,
|
||||
currentSceneId: project.currentSceneId,
|
||||
scenes: result.project.scenes,
|
||||
currentSceneId: result.project.currentSceneId,
|
||||
});
|
||||
|
||||
this.scenes = normalizedScenes;
|
||||
this.currentScene = currentScene;
|
||||
this.list = result.project.scenes;
|
||||
this.active = currentScene;
|
||||
this.notify();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load project scenes:", error);
|
||||
this.scenes = [];
|
||||
this.currentScene = null;
|
||||
this.list = [];
|
||||
this.active = null;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
@@ -286,10 +249,10 @@ export class SceneManager {
|
||||
? ensuredScenes.find((s) => s.id === currentSceneId)
|
||||
: null;
|
||||
|
||||
const fallbackScene = getMainSceneUtil({ scenes: ensuredScenes });
|
||||
const fallbackScene = getMainScene({ scenes: ensuredScenes });
|
||||
|
||||
this.scenes = ensuredScenes;
|
||||
this.currentScene = currentScene || fallbackScene;
|
||||
this.list = ensuredScenes;
|
||||
this.active = currentScene || fallbackScene;
|
||||
this.notify();
|
||||
|
||||
if (ensuredScenes.length > scenes.length) {
|
||||
@@ -299,7 +262,10 @@ export class SceneManager {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: ensuredScenes,
|
||||
updatedAt: new Date(),
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
storageService
|
||||
@@ -318,28 +284,50 @@ export class SceneManager {
|
||||
}
|
||||
|
||||
clearScenes(): void {
|
||||
this.scenes = [];
|
||||
this.currentScene = null;
|
||||
this.list = [];
|
||||
this.active = 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 || "",
|
||||
});
|
||||
getActiveScene(): TScene {
|
||||
if (!this.active) {
|
||||
throw new Error("No active scene.");
|
||||
}
|
||||
return this.active;
|
||||
}
|
||||
|
||||
getScenes(): TScene[] {
|
||||
return this.scenes;
|
||||
return this.list;
|
||||
}
|
||||
|
||||
setScenes({
|
||||
scenes,
|
||||
activeSceneId,
|
||||
}: {
|
||||
scenes: TScene[];
|
||||
activeSceneId?: string;
|
||||
}): void {
|
||||
this.list = scenes;
|
||||
this.active = activeSceneId
|
||||
? (scenes.find((s) => s.id === activeSceneId) ?? null)
|
||||
: this.active;
|
||||
this.notify();
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
storageService.saveProject({ project: updatedProject }).catch((error) => {
|
||||
console.error("Failed to persist scenes:", error);
|
||||
});
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
@@ -351,14 +339,45 @@ export class SceneManager {
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
updateSceneTracks({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: import("@/types/timeline").TimelineTrack[];
|
||||
}): void {
|
||||
if (!this.active) return;
|
||||
|
||||
const updatedScene: TScene = {
|
||||
...this.active,
|
||||
tracks,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.list = this.list.map((s) =>
|
||||
s.id === this.active?.id ? updatedScene : s,
|
||||
);
|
||||
this.active = updatedScene;
|
||||
this.notify();
|
||||
|
||||
const activeProject = this.editor.project.getActive();
|
||||
if (activeProject) {
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: this.list,
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
}
|
||||
}
|
||||
|
||||
private async updateProjectWithScenes({
|
||||
updatedScenes,
|
||||
updatedSceneId,
|
||||
refreshProjectList = false,
|
||||
}: {
|
||||
updatedScenes: TScene[];
|
||||
updatedSceneId?: string;
|
||||
refreshProjectList?: boolean;
|
||||
}): Promise<void> {
|
||||
const activeProject = this.editor.project.getActive();
|
||||
|
||||
@@ -368,22 +387,21 @@ export class SceneManager {
|
||||
|
||||
const updatedScene = updatedSceneId
|
||||
? updatedScenes.find((s) => s.id === updatedSceneId)
|
||||
: this.currentScene;
|
||||
: this.active;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
scenes: updatedScenes,
|
||||
updatedAt: new Date(),
|
||||
metadata: {
|
||||
...activeProject.metadata,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
await storageService.saveProject({ project: updatedProject });
|
||||
this.editor.project.setActiveProject({ project: updatedProject });
|
||||
this.scenes = updatedScenes;
|
||||
this.currentScene = updatedScene || null;
|
||||
this.list = updatedScenes;
|
||||
this.active = updatedScene || null;
|
||||
this.notify();
|
||||
|
||||
if (refreshProjectList) {
|
||||
await this.editor.project.loadAllProjects();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import type {
|
||||
TimelineTrack,
|
||||
TextElement,
|
||||
TimelineElement,
|
||||
ClipboardItem,
|
||||
} from "@/types/timeline";
|
||||
import { calculateTotalDuration } from "@/lib/timeline";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import {
|
||||
AddTrackCommand,
|
||||
RemoveTrackCommand,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
UpdateElementDurationCommand,
|
||||
DeleteElementsCommand,
|
||||
DuplicateElementsCommand,
|
||||
ToggleElementsHiddenCommand,
|
||||
ToggleElementsVisibilityCommand,
|
||||
ToggleElementsMutedCommand,
|
||||
UpdateTextElementCommand,
|
||||
SplitElementsCommand,
|
||||
@@ -27,16 +27,9 @@ import {
|
||||
} from "@/lib/commands/timeline";
|
||||
|
||||
export class TimelineManager {
|
||||
public sortedTracks: TimelineTrack[] = [];
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private editor: EditorCore) {
|
||||
this.initializeTracks();
|
||||
}
|
||||
|
||||
private initializeTracks(): void {
|
||||
this.sortedTracks = [];
|
||||
}
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
addTrack({ type, index }: { type: TrackType; index?: number }): string {
|
||||
const command = new AddTrackCommand(type, index);
|
||||
@@ -153,92 +146,50 @@ export class TimelineManager {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
splitTime: number;
|
||||
retainSide?: "both" | "left" | "right";
|
||||
}): void {
|
||||
}): string[] {
|
||||
const command = new SplitElementsCommand(elements, splitTime, retainSide);
|
||||
this.editor.command.execute({ command });
|
||||
return command.splitElementIds;
|
||||
}
|
||||
|
||||
getTotalDuration(): number {
|
||||
return calculateTotalDuration({ tracks: this.sortedTracks });
|
||||
return calculateTotalDuration({ tracks: this.getTracks() });
|
||||
}
|
||||
|
||||
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
||||
return this.sortedTracks.find((track) => track.id === trackId) ?? null;
|
||||
return this.getTracks().find((track) => track.id === trackId) ?? null;
|
||||
}
|
||||
|
||||
getElementsWithTracks({
|
||||
elements,
|
||||
}: {
|
||||
elements:
|
||||
| { trackId: string; elementId: string }[]
|
||||
| { trackId: string; elementId: string };
|
||||
}):
|
||||
| Array<{ track: TimelineTrack; element: TimelineElement }>
|
||||
| { track: TimelineTrack; element: TimelineElement }
|
||||
| null {
|
||||
const normalized = Array.isArray(elements) ? elements : [elements];
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}): Array<{ track: TimelineTrack; element: TimelineElement }> {
|
||||
const result: Array<{ track: TimelineTrack; element: TimelineElement }> =
|
||||
[];
|
||||
|
||||
for (const { trackId, elementId } of normalized) {
|
||||
for (const { trackId, elementId } of elements) {
|
||||
const track = this.getTrackById({ trackId });
|
||||
const element = track?.elements.find((el) => el.id === elementId);
|
||||
const element = track?.elements.find(
|
||||
(trackElement) => trackElement.id === elementId,
|
||||
);
|
||||
|
||||
if (track && element) {
|
||||
result.push({ track, element });
|
||||
}
|
||||
}
|
||||
|
||||
return Array.isArray(elements) ? result : (result[0] ?? null);
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadProjectTimeline({
|
||||
projectId,
|
||||
sceneId,
|
||||
pasteAtTime({
|
||||
time,
|
||||
clipboardItems,
|
||||
}: {
|
||||
projectId: string;
|
||||
sceneId?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const tracks = await storageService.loadTimeline({
|
||||
projectId,
|
||||
sceneId,
|
||||
});
|
||||
|
||||
if (tracks) {
|
||||
this.updateTracks(tracks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load timeline:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async saveProjectTimeline({
|
||||
projectId,
|
||||
sceneId,
|
||||
}: {
|
||||
projectId: string;
|
||||
sceneId?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await storageService.saveTimeline({
|
||||
projectId,
|
||||
tracks: this.sortedTracks,
|
||||
sceneId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save timeline:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeline(): void {
|
||||
this.updateTracks([]);
|
||||
}
|
||||
|
||||
pasteAtTime({ time }: { time: number }): void {
|
||||
const command = new PasteCommand(time);
|
||||
time: number;
|
||||
clipboardItems: ClipboardItem[];
|
||||
}): void {
|
||||
const command = new PasteCommand(time, clipboardItems);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
@@ -270,6 +221,8 @@ export class TimelineManager {
|
||||
| "fontWeight"
|
||||
| "fontStyle"
|
||||
| "textDecoration"
|
||||
| "transform"
|
||||
| "opacity"
|
||||
>
|
||||
>;
|
||||
}): void {
|
||||
@@ -286,12 +239,12 @@ export class TimelineManager {
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
toggleElementsHidden({
|
||||
toggleElementsVisibility({
|
||||
elements,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}): void {
|
||||
const command = new ToggleElementsHiddenCommand(elements);
|
||||
const command = new ToggleElementsVisibilityCommand(elements);
|
||||
this.editor.command.execute({ command });
|
||||
}
|
||||
|
||||
@@ -319,7 +272,7 @@ export class TimelineManager {
|
||||
}
|
||||
|
||||
getTracks(): TimelineTrack[] {
|
||||
return this.sortedTracks;
|
||||
return this.editor.scenes.getActiveScene()?.tracks ?? [];
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
@@ -332,7 +285,7 @@ export class TimelineManager {
|
||||
}
|
||||
|
||||
updateTracks(newTracks: TimelineTrack[]): void {
|
||||
this.sortedTracks = newTracks;
|
||||
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user