This commit is contained in:
Maze Winther
2026-01-15 11:05:17 +01:00
parent c19f085e48
commit deef784b76
182 changed files with 7787 additions and 18046 deletions
+14 -13
View File
@@ -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) {
+56 -49
View File
@@ -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 {
+10 -9
View File
@@ -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);
+238 -234
View File
@@ -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());
}
+33 -42
View File
@@ -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);
@@ -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();
}
}
}
+27 -74
View File
@@ -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();
}
}