From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:20:04 +0100 Subject: codebase overhaul (#697) --- apps/web/src/core/index.ts | 50 ++ apps/web/src/core/managers/audio-manager.ts | 335 ++++++++++++ apps/web/src/core/managers/commands.ts | 44 ++ apps/web/src/core/managers/media-manager.ts | 162 ++++++ apps/web/src/core/managers/playback-manager.ts | 162 ++++++ apps/web/src/core/managers/project-manager.ts | 648 ++++++++++++++++++++++++ apps/web/src/core/managers/renderer-manager.ts | 129 +++++ apps/web/src/core/managers/save-manager.ts | 107 ++++ apps/web/src/core/managers/scenes-manager.ts | 316 ++++++++++++ apps/web/src/core/managers/selection-manager.ts | 35 ++ apps/web/src/core/managers/timeline-manager.ts | 275 ++++++++++ 11 files changed, 2263 insertions(+) create mode 100644 apps/web/src/core/index.ts create mode 100644 apps/web/src/core/managers/audio-manager.ts create mode 100644 apps/web/src/core/managers/commands.ts create mode 100644 apps/web/src/core/managers/media-manager.ts create mode 100644 apps/web/src/core/managers/playback-manager.ts create mode 100644 apps/web/src/core/managers/project-manager.ts create mode 100644 apps/web/src/core/managers/renderer-manager.ts create mode 100644 apps/web/src/core/managers/save-manager.ts create mode 100644 apps/web/src/core/managers/scenes-manager.ts create mode 100644 apps/web/src/core/managers/selection-manager.ts create mode 100644 apps/web/src/core/managers/timeline-manager.ts (limited to 'apps/web/src/core') diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts new file mode 100644 index 00000000..4a8813f8 --- /dev/null +++ b/apps/web/src/core/index.ts @@ -0,0 +1,50 @@ +import { PlaybackManager } from "./managers/playback-manager"; +import { TimelineManager } from "./managers/timeline-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"; +import { CommandManager } from "./managers/commands"; +import { SaveManager } from "./managers/save-manager"; +import { AudioManager } from "./managers/audio-manager"; +import { SelectionManager } from "./managers/selection-manager"; + +export class EditorCore { + private static instance: EditorCore | null = null; + + public readonly command: CommandManager; + public readonly playback: PlaybackManager; + public readonly timeline: TimelineManager; + public readonly scenes: ScenesManager; + public readonly project: ProjectManager; + public readonly media: MediaManager; + public readonly renderer: RendererManager; + public readonly save: SaveManager; + public readonly audio: AudioManager; + public readonly selection: SelectionManager; + + private constructor() { + this.command = new CommandManager(); + this.playback = new PlaybackManager(this); + this.timeline = new TimelineManager(this); + this.scenes = new ScenesManager(this); + this.project = new ProjectManager(this); + this.media = new MediaManager(this); + this.renderer = new RendererManager(this); + this.save = new SaveManager(this); + this.audio = new AudioManager(this); + this.selection = new SelectionManager(this); + this.save.start(); + } + + static getInstance(): EditorCore { + if (!EditorCore.instance) { + EditorCore.instance = new EditorCore(); + } + return EditorCore.instance; + } + + static reset(): void { + EditorCore.instance = null; + } +} diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts new file mode 100644 index 00000000..621cca18 --- /dev/null +++ b/apps/web/src/core/managers/audio-manager.ts @@ -0,0 +1,335 @@ +import type { EditorCore } from "@/core"; +import type { AudioClipSource } from "@/lib/media/audio"; +import { createAudioContext, collectAudioClips } from "@/lib/media/audio"; +import { + ALL_FORMATS, + AudioBufferSink, + BlobSource, + Input, + type WrappedAudioBuffer, +} from "mediabunny"; + +export class AudioManager { + private audioContext: AudioContext | null = null; + private masterGain: GainNode | null = null; + private playbackStartTime = 0; + private playbackStartContextTime = 0; + private scheduleTimer: number | null = null; + private lookaheadSeconds = 2; + private scheduleIntervalMs = 500; + private clips: AudioClipSource[] = []; + private sinks = new Map(); + private inputs = new Map(); + private activeClipIds = new Set(); + private clipIterators = new Map< + string, + AsyncGenerator + >(); + private queuedSources = new Set(); + private playbackSessionId = 0; + private lastIsPlaying = false; + private lastVolume = 1; + private unsubscribers: Array<() => void> = []; + + constructor(private editor: EditorCore) { + this.lastVolume = this.editor.playback.getVolume(); + + this.unsubscribers.push( + this.editor.playback.subscribe(this.handlePlaybackChange), + this.editor.timeline.subscribe(this.handleTimelineChange), + this.editor.media.subscribe(this.handleTimelineChange), + ); + if (typeof window !== "undefined") { + window.addEventListener("playback-seek", this.handleSeek); + } + } + + dispose(): void { + this.stopPlayback(); + for (const unsub of this.unsubscribers) { + unsub(); + } + this.unsubscribers = []; + if (typeof window !== "undefined") { + window.removeEventListener("playback-seek", this.handleSeek); + } + this.disposeSinks(); + if (this.audioContext) { + void this.audioContext.close(); + this.audioContext = null; + this.masterGain = null; + } + } + + private handlePlaybackChange = (): void => { + const isPlaying = this.editor.playback.getIsPlaying(); + const volume = this.editor.playback.getVolume(); + + if (volume !== this.lastVolume) { + this.lastVolume = volume; + this.updateGain(); + } + + if (isPlaying !== this.lastIsPlaying) { + this.lastIsPlaying = isPlaying; + if (isPlaying) { + void this.startPlayback({ + time: this.editor.playback.getCurrentTime(), + }); + } else { + this.stopPlayback(); + } + } + }; + + private handleSeek = (event: Event): void => { + const detail = (event as CustomEvent<{ time: number }>).detail; + if (!detail) return; + + if (this.editor.playback.getIsPlaying()) { + void this.startPlayback({ time: detail.time }); + return; + } + + this.stopPlayback(); + }; + + private handleTimelineChange = (): void => { + this.disposeSinks(); + + if (!this.editor.playback.getIsPlaying()) return; + + void this.startPlayback({ time: this.editor.playback.getCurrentTime() }); + }; + + private ensureAudioContext(): AudioContext | null { + if (this.audioContext) return this.audioContext; + if (typeof window === "undefined") return null; + + this.audioContext = createAudioContext(); + this.masterGain = this.audioContext.createGain(); + this.masterGain.gain.value = this.lastVolume; + this.masterGain.connect(this.audioContext.destination); + return this.audioContext; + } + + private updateGain(): void { + if (!this.masterGain) return; + this.masterGain.gain.value = this.lastVolume; + } + + private getPlaybackTime(): number { + if (!this.audioContext) return this.playbackStartTime; + const elapsed = this.audioContext.currentTime - this.playbackStartContextTime; + return this.playbackStartTime + elapsed; + } + + private async startPlayback({ time }: { time: number }): Promise { + const audioContext = this.ensureAudioContext(); + if (!audioContext) return; + + this.stopPlayback(); + this.playbackSessionId++; + + const tracks = this.editor.timeline.getTracks(); + const mediaAssets = this.editor.media.getAssets(); + const duration = this.editor.timeline.getTotalDuration(); + + if (duration <= 0) return; + + if (audioContext.state === "suspended") { + await audioContext.resume(); + } + + this.clips = await collectAudioClips({ tracks, mediaAssets }); + if (!this.editor.playback.getIsPlaying()) return; + + this.playbackStartTime = time; + this.playbackStartContextTime = audioContext.currentTime; + + this.scheduleUpcomingClips(); + + if (typeof window !== "undefined") { + this.scheduleTimer = window.setInterval(() => { + this.scheduleUpcomingClips(); + }, this.scheduleIntervalMs); + } + } + + private scheduleUpcomingClips(): void { + if (!this.editor.playback.getIsPlaying()) return; + + const currentTime = this.getPlaybackTime(); + const windowEnd = currentTime + this.lookaheadSeconds; + + for (const clip of this.clips) { + if (clip.muted) continue; + if (this.activeClipIds.has(clip.id)) continue; + + const clipEnd = clip.startTime + clip.duration; + if (clipEnd <= currentTime) continue; + if (clip.startTime > windowEnd) continue; + + this.activeClipIds.add(clip.id); + void this.runClipIterator({ clip, startTime: currentTime, sessionId: this.playbackSessionId }); + } + } + + private stopPlayback(): void { + if (this.scheduleTimer && typeof window !== "undefined") { + window.clearInterval(this.scheduleTimer); + } + this.scheduleTimer = null; + + for (const iterator of this.clipIterators.values()) { + void iterator.return(); + } + this.clipIterators.clear(); + this.activeClipIds.clear(); + + for (const source of this.queuedSources) { + try { + source.stop(); + } catch {} + source.disconnect(); + } + this.queuedSources.clear(); + } + + private async runClipIterator({ + clip, + startTime, + sessionId, + }: { + clip: AudioClipSource; + startTime: number; + sessionId: number; + }): Promise { + const audioContext = this.ensureAudioContext(); + if (!audioContext) return; + + const sink = await this.getAudioSink({ clip }); + if (!sink || !this.editor.playback.getIsPlaying()) return; + if (sessionId !== this.playbackSessionId) return; + + const clipStart = clip.startTime; + const clipEnd = clip.startTime + clip.duration; + + const iteratorStartTime = Math.max(startTime, clipStart); + const sourceStartTime = + clip.trimStart + (iteratorStartTime - clip.startTime); + + const iterator = sink.buffers(sourceStartTime); + this.clipIterators.set(clip.id, iterator); + + for await (const { buffer, timestamp } of iterator) { + if (!this.editor.playback.getIsPlaying()) return; + if (sessionId !== this.playbackSessionId) return; + + const timelineTime = clip.startTime + (timestamp - clip.trimStart); + if (timelineTime >= clipEnd) break; + + const node = audioContext.createBufferSource(); + node.buffer = buffer; + node.connect(this.masterGain ?? audioContext.destination); + + const startTimestamp = + this.playbackStartContextTime + + (timelineTime - this.playbackStartTime); + + if (startTimestamp >= audioContext.currentTime) { + node.start(startTimestamp); + } else { + const offset = audioContext.currentTime - startTimestamp; + if (offset < buffer.duration) { + node.start(audioContext.currentTime, offset); + } else { + continue; + } + } + + this.queuedSources.add(node); + node.addEventListener("ended", () => { + node.disconnect(); + this.queuedSources.delete(node); + }); + + const aheadTime = timelineTime - this.getPlaybackTime(); + if (aheadTime >= 1) { + await this.waitUntilCaughtUp({ timelineTime, targetAhead: 1 }); + if (sessionId !== this.playbackSessionId) return; + } + } + + this.clipIterators.delete(clip.id); + // don't remove from activeClipIds - prevents scheduler from restarting this clip + // the set is cleared on stopPlayback anyway + } + + private waitUntilCaughtUp({ + timelineTime, + targetAhead, + }: { + timelineTime: number; + targetAhead: number; + }): Promise { + return new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (!this.editor.playback.getIsPlaying()) { + clearInterval(checkInterval); + resolve(); + return; + } + + const playbackTime = this.getPlaybackTime(); + if (timelineTime - playbackTime < targetAhead) { + clearInterval(checkInterval); + resolve(); + } + }, 100); + }); + } + + private disposeSinks(): void { + for (const iterator of this.clipIterators.values()) { + void iterator.return(); + } + this.clipIterators.clear(); + this.activeClipIds.clear(); + + for (const input of this.inputs.values()) { + input.dispose(); + } + this.inputs.clear(); + this.sinks.clear(); + } + + private async getAudioSink({ + clip, + }: { + clip: AudioClipSource; + }): Promise { + const existingSink = this.sinks.get(clip.sourceKey); + if (existingSink) return existingSink; + + try { + const input = new Input({ + source: new BlobSource(clip.file), + formats: ALL_FORMATS, + }); + const audioTrack = await input.getPrimaryAudioTrack(); + if (!audioTrack) { + input.dispose(); + return null; + } + + const sink = new AudioBufferSink(audioTrack); + this.inputs.set(clip.sourceKey, input); + this.sinks.set(clip.sourceKey, sink); + return sink; + } catch (error) { + console.warn("Failed to initialize audio sink:", error); + return null; + } + } +} diff --git a/apps/web/src/core/managers/commands.ts b/apps/web/src/core/managers/commands.ts new file mode 100644 index 00000000..d996a30e --- /dev/null +++ b/apps/web/src/core/managers/commands.ts @@ -0,0 +1,44 @@ +import type { Command } from "@/lib/commands"; + +export class CommandManager { + private history: Command[] = []; + private redoStack: Command[] = []; + + execute({ command }: { command: Command }): Command { + command.execute(); + this.history.push(command); + this.redoStack = []; + return command; + } + + undo(): void { + if (this.history.length === 0) return; + const command = this.history.pop(); + command?.undo(); + if (command) { + this.redoStack.push(command); + } + } + + redo(): void { + if (this.redoStack.length === 0) return; + const command = this.redoStack.pop(); + command?.redo(); + if (command) { + this.history.push(command); + } + } + + canUndo(): boolean { + return this.history.length > 0; + } + + canRedo(): boolean { + return this.redoStack.length > 0; + } + + clear(): void { + this.history = []; + this.redoStack = []; + } +} diff --git a/apps/web/src/core/managers/media-manager.ts b/apps/web/src/core/managers/media-manager.ts new file mode 100644 index 00000000..7b9db0d5 --- /dev/null +++ b/apps/web/src/core/managers/media-manager.ts @@ -0,0 +1,162 @@ +import type { EditorCore } from "@/core"; +import type { MediaAsset } from "@/types/assets"; +import { storageService } from "@/services/storage/storage-service"; +import { generateUUID } from "@/utils/id"; +import { videoCache } from "@/services/media/video-cache"; +import { hasMediaId } from "@/lib/timeline/element-utils"; + +export class MediaManager { + private assets: MediaAsset[] = []; + private isLoading = false; + private listeners = new Set<() => void>(); + + constructor(private editor: EditorCore) {} + + async addMediaAsset({ + projectId, + asset, + }: { + projectId: string; + asset: Omit; + }): Promise { + const newAsset: MediaAsset = { + ...asset, + id: generateUUID(), + }; + + this.assets = [...this.assets, newAsset]; + this.notify(); + + try { + await storageService.saveMediaAsset({ projectId, mediaAsset: newAsset }); + } catch (error) { + console.error("Failed to save media asset:", error); + this.assets = this.assets.filter((asset) => asset.id !== newAsset.id); + this.notify(); + } + } + + async removeMediaAsset({ + projectId, + id, + }: { + projectId: string; + id: string; + }): Promise { + const asset = this.assets.find((asset) => asset.id === id); + + videoCache.clearVideo({ mediaId: id }); + + if (asset?.url) { + URL.revokeObjectURL(asset.url); + if (asset.thumbnailUrl) { + URL.revokeObjectURL(asset.thumbnailUrl); + } + } + + 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 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.deleteElements({ elements: elementsToRemove }); + } + + try { + await storageService.deleteMediaAsset({ projectId, id }); + } catch (error) { + console.error("Failed to delete media asset:", error); + } + } + + async loadProjectMedia({ projectId }: { projectId: string }): Promise { + this.isLoading = true; + this.notify(); + + try { + const mediaAssets = await storageService.loadAllMediaAssets({ + projectId, + }); + this.assets = mediaAssets; + this.notify(); + } catch (error) { + console.error("Failed to load media assets:", error); + } finally { + this.isLoading = false; + this.notify(); + } + } + + async clearProjectMedia({ projectId }: { projectId: string }): Promise { + this.assets.forEach((asset) => { + if (asset.url) { + URL.revokeObjectURL(asset.url); + } + if (asset.thumbnailUrl) { + URL.revokeObjectURL(asset.thumbnailUrl); + } + }); + + const mediaIds = this.assets.map((asset) => asset.id); + this.assets = []; + this.notify(); + + try { + await Promise.all( + mediaIds.map((id) => + storageService.deleteMediaAsset({ projectId, id }), + ), + ); + } catch (error) { + console.error("Failed to clear media assets from storage:", error); + } + } + + clearAllAssets(): void { + videoCache.clearAll(); + + this.assets.forEach((asset) => { + if (asset.url) { + URL.revokeObjectURL(asset.url); + } + if (asset.thumbnailUrl) { + URL.revokeObjectURL(asset.thumbnailUrl); + } + }); + + this.assets = []; + this.notify(); + } + + getAssets(): MediaAsset[] { + return this.assets; + } + + setAssets({ assets }: { assets: MediaAsset[] }): void { + this.assets = assets; + this.notify(); + } + + isLoadingMedia(): boolean { + return this.isLoading; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + this.listeners.forEach((fn) => fn()); + } +} diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts new file mode 100644 index 00000000..ca6f4e43 --- /dev/null +++ b/apps/web/src/core/managers/playback-manager.ts @@ -0,0 +1,162 @@ +import type { EditorCore } from "@/core"; + +export class PlaybackManager { + private isPlaying = false; + private currentTime = 0; + private volume = 1; + private muted = false; + private previousVolume = 1; + private listeners = new Set<() => void>(); + private playbackTimer: number | null = null; + private lastUpdate = 0; + + constructor(private editor: EditorCore) {} + + play(): void { + const duration = this.editor.timeline.getTotalDuration(); + + if (duration > 0) { + if (this.currentTime >= duration) { + this.seek({ time: 0 }); + } + } + + this.isPlaying = true; + this.startTimer(); + this.notify(); + } + + pause(): void { + this.isPlaying = false; + this.stopTimer(); + this.notify(); + } + + toggle(): void { + if (this.isPlaying) { + this.pause(); + } else { + this.play(); + } + } + + seek({ time }: { time: number }): void { + const duration = this.editor.timeline.getTotalDuration(); + this.currentTime = Math.max(0, Math.min(duration, time)); + this.notify(); + + window.dispatchEvent( + new CustomEvent("playback-seek", { + detail: { time: this.currentTime }, + }), + ); + } + + setVolume({ volume }: { volume: number }): void { + const clampedVolume = Math.max(0, Math.min(1, volume)); + this.volume = clampedVolume; + this.muted = clampedVolume === 0; + if (clampedVolume > 0) { + this.previousVolume = clampedVolume; + } + this.notify(); + } + + mute(): void { + if (this.volume > 0) { + this.previousVolume = this.volume; + } + this.muted = true; + this.volume = 0; + this.notify(); + } + + unmute(): void { + this.muted = false; + this.volume = this.previousVolume; + this.notify(); + } + + toggleMute(): void { + if (this.muted) { + this.unmute(); + } else { + this.mute(); + } + } + + getIsPlaying(): boolean { + return this.isPlaying; + } + + getCurrentTime(): number { + return this.currentTime; + } + + getVolume(): number { + return this.volume; + } + + isMuted(): boolean { + return this.muted; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + this.listeners.forEach((fn) => fn()); + } + + private startTimer(): void { + if (this.playbackTimer) { + cancelAnimationFrame(this.playbackTimer); + } + + this.lastUpdate = performance.now(); + this.updateTime(); + } + + private stopTimer(): void { + if (this.playbackTimer) { + cancelAnimationFrame(this.playbackTimer); + this.playbackTimer = null; + } + } + + private updateTime = (): void => { + if (!this.isPlaying) return; + + const now = performance.now(); + const delta = (now - this.lastUpdate) / 1000; + this.lastUpdate = now; + + const newTime = this.currentTime + delta; + const duration = this.editor.timeline.getTotalDuration(); + + if (duration > 0 && newTime >= duration) { + this.pause(); + this.currentTime = duration; + this.notify(); + + window.dispatchEvent( + new CustomEvent("playback-seek", { + detail: { time: duration }, + }), + ); + } else { + this.currentTime = newTime; + this.notify(); + + window.dispatchEvent( + new CustomEvent("playback-update", { + detail: { time: newTime }, + }), + ); + } + + this.playbackTimer = requestAnimationFrame(this.updateTime); + }; +} diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts new file mode 100644 index 00000000..db9e3fad --- /dev/null +++ b/apps/web/src/core/managers/project-manager.ts @@ -0,0 +1,648 @@ +import type { EditorCore } from "@/core"; +import type { + TProject, + TProjectMetadata, + TProjectSortKey, + TProjectSortOption, + TProjectSettings, + TTimelineViewState, +} from "@/types/project"; +import type { ExportOptions, ExportResult } from "@/types/export"; +import { storageService } from "@/services/storage/storage-service"; +import { toast } from "sonner"; +import { generateUUID } from "@/utils/id"; +import { UpdateProjectSettingsCommand } from "@/lib/commands/project"; +import { + DEFAULT_FPS, + DEFAULT_CANVAS_SIZE, + DEFAULT_COLOR, +} from "@/constants/project-constants"; +import { buildDefaultScene, getProjectDurationFromScenes } from "@/lib/scenes"; +import { buildScene } from "@/services/renderer/scene-builder"; +import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; +import { + CURRENT_STORAGE_VERSION, + migrations, + runStorageMigrations, +} from "@/services/storage/migrations"; +import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants"; + +export interface MigrationState { + isMigrating: boolean; + fromVersion: number | null; + toVersion: number | null; + projectName: string | null; +} + +export class ProjectManager { + private active: TProject | null = null; + private savedProjects: TProjectMetadata[] = []; + private isLoading = true; + private isInitialized = false; + private invalidProjectIds = new Set(); + private storageMigrationPromise: Promise | null = null; + private listeners = new Set<() => void>(); + private migrationState: MigrationState = { + isMigrating: false, + fromVersion: null, + toVersion: null, + projectName: null, + }; + + constructor(private editor: EditorCore) {} + + private async ensureStorageMigrations(): Promise { + if (this.storageMigrationPromise) { + await this.storageMigrationPromise; + return; + } + + this.storageMigrationPromise = (async () => { + let hasShownState = false; + + await runStorageMigrations({ + migrations, + callbacks: { + onMigrationStart: ({ fromVersion, toVersion }) => { + hasShownState = true; + this.setMigrationState({ + isMigrating: true, + fromVersion, + toVersion, + projectName: null, + }); + }, + }, + }); + + if (hasShownState) { + this.setMigrationState({ + isMigrating: false, + fromVersion: null, + toVersion: null, + projectName: null, + }); + } + })(); + + await this.storageMigrationPromise; + } + + async createNewProject({ name }: { name: string }): Promise { + const mainScene = buildDefaultScene({ name: "Main scene", isMain: true }); + const newProject: TProject = { + metadata: { + id: generateUUID(), + name, + duration: getProjectDurationFromScenes({ scenes: [mainScene] }), + createdAt: new Date(), + updatedAt: new Date(), + }, + scenes: [mainScene], + currentSceneId: mainScene.id, + settings: { + fps: DEFAULT_FPS, + canvasSize: DEFAULT_CANVAS_SIZE, + originalCanvasSize: null, + background: { + type: "color", + color: DEFAULT_COLOR, + }, + }, + version: CURRENT_STORAGE_VERSION, + }; + + this.active = newProject; + this.notify(); + + this.editor.media.clearAllAssets(); + this.editor.scenes.initializeScenes({ + scenes: newProject.scenes, + currentSceneId: newProject.currentSceneId, + }); + + try { + await storageService.saveProject({ project: newProject }); + this.updateMetadata(newProject); + + return newProject.metadata.id; + } catch (error) { + toast.error("Failed to save new project"); + throw error; + } + } + + async loadProject({ id }: { id: string }): Promise { + if (!this.isInitialized) { + this.isLoading = true; + this.notify(); + } + + this.editor.save.pause(); + await this.ensureStorageMigrations(); + this.editor.media.clearAllAssets(); + this.editor.scenes.clearScenes(); + + try { + const result = await storageService.loadProject({ id }); + if (!result) { + throw new Error(`Project with id ${id} not found`); + } + + const project = result.project; + + this.active = project; + this.notify(); + + if (project.scenes && project.scenes.length > 0) { + this.editor.scenes.initializeScenes({ + scenes: project.scenes, + currentSceneId: project.currentSceneId, + }); + } + + await this.editor.media.loadProjectMedia({ projectId: id }); + + if (!project.metadata.thumbnail) { + const didUpdateThumbnail = await this.updateThumbnailFromTimeline(); + if (didUpdateThumbnail) { + await this.saveCurrentProject(); + } + } + } catch (error) { + console.error("Failed to load project:", error); + throw error; + } finally { + this.isLoading = false; + this.notify(); + this.editor.save.resume(); + } + } + + async saveCurrentProject(): Promise { + if (!this.active) return; + + try { + const scenes = this.editor.scenes.getScenes(); + const updatedProject = { + ...this.active, + scenes, + metadata: { + ...this.active.metadata, + duration: getProjectDurationFromScenes({ scenes }), + updatedAt: new Date(), + }, + }; + + await storageService.saveProject({ project: updatedProject }); + this.active = updatedProject; + this.updateMetadata(updatedProject); + } catch (error) { + console.error("Failed to save project:", error); + } + } + + async export({ options }: { options: ExportOptions }): Promise { + return this.editor.renderer.exportProject({ options }); + } + + async loadAllProjects(): Promise { + if (!this.isInitialized) { + this.isLoading = true; + this.notify(); + } + + await this.ensureStorageMigrations(); + try { + const metadata = await storageService.loadAllProjectsMetadata(); + this.savedProjects = metadata; + this.notify(); + } catch (error) { + console.error("Failed to load projects:", error); + } finally { + this.isLoading = false; + this.isInitialized = true; + this.notify(); + } + } + + async deleteProjects({ ids }: { ids: string[] }): Promise { + const uniqueIds = Array.from(new Set(ids)); + if (uniqueIds.length === 0) return; + + try { + await Promise.all( + uniqueIds.map((id) => + Promise.all([ + storageService.deleteProjectMedia({ projectId: id }), + storageService.deleteProject({ id }), + ]), + ), + ); + + const idSet = new Set(uniqueIds); + this.savedProjects = this.savedProjects.filter( + (project) => !idSet.has(project.id), + ); + + const shouldClearActive = + this.active && idSet.has(this.active.metadata.id); + + if (shouldClearActive) { + this.active = null; + this.editor.media.clearAllAssets(); + this.editor.scenes.clearScenes(); + } + + this.notify(); + } catch (error) { + console.error("Failed to delete projects:", error); + } + } + + closeProject(): void { + this.active = null; + this.notify(); + + this.editor.media.clearAllAssets(); + this.editor.scenes.clearScenes(); + } + + async renameProject({ + id, + name, + }: { + id: string; + name: string; + }): Promise { + try { + const result = await storageService.loadProject({ id }); + if (!result) { + toast.error("Project not found", { + description: "Please try again", + }); + return; + } + + 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", { + description: + error instanceof Error ? error.message : "Please try again", + }); + } + } + + async duplicateProjects({ ids }: { ids: string[] }): Promise { + const uniqueIds = Array.from(new Set(ids)); + if (uniqueIds.length === 0) return []; + + try { + const getDuplicateBaseName = ({ name }: { name: string }) => { + const match = name.match(/^\((\d+)\)\s+(.+)$/); + const number = match ? Number.parseInt(match[1], 10) : null; + const baseName = match ? match[2] : name; + return { baseName, number }; + }; + + const loadResults = await Promise.all( + uniqueIds.map(async (projectId) => { + const result = await storageService.loadProject({ id: projectId }); + return { projectId, project: result?.project ?? null }; + }), + ); + + const missingProjectIds = loadResults + .filter((result) => !result.project) + .map((result) => result.projectId); + + if (missingProjectIds.length > 0) { + toast.error( + missingProjectIds.length === 1 + ? "Project not found" + : "Projects not found", + { + description: + missingProjectIds.length === 1 + ? "Please try again" + : "Some projects could not be found", + }, + ); + throw new Error(`Projects not found: ${missingProjectIds.join(", ")}`); + } + + const projectsToDuplicate = loadResults.flatMap((result) => + result.project ? [result.project] : [], + ); + + const maxNumberByBaseName = new Map(); + + for (const project of this.savedProjects) { + const { baseName, number } = getDuplicateBaseName({ + name: project.name, + }); + + if (number === null) continue; + + const currentMax = maxNumberByBaseName.get(baseName); + if (currentMax === undefined || number > currentMax) { + maxNumberByBaseName.set(baseName, number); + } + } + + const nextNumberByBaseName = new Map(); + for (const [baseName, maxNumber] of maxNumberByBaseName) { + nextNumberByBaseName.set(baseName, maxNumber + 1); + } + + const duplicationPlans = projectsToDuplicate.map((project) => { + const { baseName } = getDuplicateBaseName({ + name: project.metadata.name, + }); + const nextNumber = nextNumberByBaseName.get(baseName) ?? 1; + nextNumberByBaseName.set(baseName, nextNumber + 1); + + const newProjectId = generateUUID(); + const newProject: TProject = { + ...project, + metadata: { + ...project.metadata, + id: newProjectId, + name: `(${nextNumber}) ${baseName}`, + createdAt: new Date(), + updatedAt: new Date(), + }, + }; + + return { + newProjectId, + newProject, + sourceProjectId: project.metadata.id, + }; + }); + + await Promise.all( + duplicationPlans.map(({ newProject }) => + storageService.saveProject({ project: newProject }), + ), + ); + + await Promise.all( + duplicationPlans.map(async ({ sourceProjectId, newProjectId }) => { + const sourceMediaAssets = await storageService.loadAllMediaAssets({ + projectId: sourceProjectId, + }); + + await Promise.all( + sourceMediaAssets.map((mediaAsset) => + storageService.saveMediaAsset({ + projectId: newProjectId, + mediaAsset, + }), + ), + ); + }), + ); + + for (const { newProject } of duplicationPlans) { + this.updateMetadata(newProject); + } + + return duplicationPlans.map((plan) => plan.newProjectId); + } catch (error) { + console.error("Failed to duplicate projects:", error); + toast.error("Failed to duplicate projects", { + description: + error instanceof Error ? error.message : "Please try again", + }); + throw error; + } + } + + async updateSettings({ + settings, + pushHistory = true, + }: { + settings: Partial; + pushHistory?: boolean; + }): Promise { + if (!this.active) return; + + const command = new UpdateProjectSettingsCommand(settings); + if (pushHistory) { + this.editor.command.execute({ command }); + return; + } + + command.execute(); + } + + async updateThumbnail({ thumbnail }: { thumbnail: string }): Promise { + if (!this.active) return; + + const updatedProject: TProject = { + ...this.active, + metadata: { ...this.active.metadata, thumbnail, updatedAt: new Date() }, + }; + this.active = updatedProject; + this.notify(); + this.updateMetadata(updatedProject); + this.editor.save.markDirty(); + } + + async prepareExit(): Promise { + if (!this.active) return; + + try { + const didUpdateThumbnail = await this.updateThumbnailFromTimeline(); + if (didUpdateThumbnail) { + await this.editor.save.flush(); + } + } catch (error) { + console.error("Failed to generate project thumbnail on exit:", error); + } + } + + getFilteredAndSortedProjects({ + searchQuery, + sortOption, + }: { + searchQuery: string; + sortOption: TProjectSortOption; + }): TProjectMetadata[] { + const filteredProjects = this.savedProjects.filter((project) => + project.name.toLowerCase().includes(searchQuery.toLowerCase()), + ); + + const [key, order] = sortOption.split("-") as [ + TProjectSortKey, + "asc" | "desc", + ]; + + const sortedProjects = [...filteredProjects].sort((a, b) => { + const aValue = a[key]; + const bValue = b[key]; + + if (order === "asc") { + if (aValue < bValue) return -1; + if (aValue > bValue) return 1; + return 0; + } + if (aValue > bValue) return -1; + if (aValue < bValue) return 1; + return 0; + }); + + return sortedProjects; + } + + isInvalidProjectId({ id }: { id: string }): boolean { + return this.invalidProjectIds.has(id); + } + + markProjectIdAsInvalid({ id }: { id: string }): void { + this.invalidProjectIds.add(id); + this.notify(); + } + + clearInvalidProjectIds(): void { + this.invalidProjectIds.clear(); + this.notify(); + } + + getActive(): TProject { + if (!this.active) { + throw new Error("No active project"); + } + return this.active; + } + + /** + * for agents: + * in most cases, the project is guaranteed to be active, in which getActive() should be used instead. + * for very rare cases, this function may be used. + */ + getActiveOrNull(): TProject | null { + return this.active; + } + + getTimelineViewState(): TTimelineViewState { + return this.active?.timelineViewState ?? DEFAULT_TIMELINE_VIEW_STATE; + } + + setTimelineViewState({ viewState }: { viewState: TTimelineViewState }): void { + if (!this.active) return; + this.active = { + ...this.active, + timelineViewState: viewState ?? undefined, + }; + this.editor.save.markDirty(); + } + + getSavedProjects(): TProjectMetadata[] { + return this.savedProjects; + } + + getIsLoading(): boolean { + return this.isLoading; + } + + getIsInitialized(): boolean { + return this.isInitialized; + } + + getMigrationState(): MigrationState { + return this.migrationState; + } + + private setMigrationState(state: Partial): void { + this.migrationState = { ...this.migrationState, ...state }; + this.notify(); + } + + setActiveProject({ project }: { project: TProject }): void { + this.active = project; + this.notify(); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private async updateThumbnailFromTimeline(): Promise { + if (!this.active) return false; + + const tracks = this.editor.timeline.getTracks(); + const mediaAssets = this.editor.media.getAssets(); + const duration = this.editor.timeline.getTotalDuration(); + + if (duration === 0) return false; + + const { canvasSize, background } = this.active.settings; + + const scene = buildScene({ + tracks, + mediaAssets, + duration, + canvasSize, + background, + }); + + const renderer = new CanvasRenderer({ + width: canvasSize.width, + height: canvasSize.height, + fps: this.active.settings.fps, + }); + + const tempCanvas = document.createElement("canvas"); + tempCanvas.width = canvasSize.width; + tempCanvas.height = canvasSize.height; + + await renderer.renderToCanvas({ + node: scene, + time: 0, + targetCanvas: tempCanvas, + }); + + const thumbnailDataUrl = tempCanvas.toDataURL("image/png"); + + await this.updateThumbnail({ thumbnail: thumbnailDataUrl }); + return true; + } + + 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()); + } +} diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts new file mode 100644 index 00000000..22f1ebb8 --- /dev/null +++ b/apps/web/src/core/managers/renderer-manager.ts @@ -0,0 +1,129 @@ +import type { EditorCore } from "@/core"; +import type { RootNode } from "@/services/renderer/nodes/root-node"; +import type { ExportOptions, ExportResult } from "@/types/export"; +import { SceneExporter } from "@/services/renderer/scene-exporter"; +import { buildScene } from "@/services/renderer/scene-builder"; +import { createTimelineAudioBuffer } from "@/lib/media/audio"; + +export class RendererManager { + private renderTree: RootNode | null = null; + private listeners = new Set<() => void>(); + + constructor(private editor: EditorCore) {} + + setRenderTree({ renderTree }: { renderTree: RootNode | null }): void { + this.renderTree = renderTree; + this.notify(); + } + + getRenderTree(): RootNode | null { + return this.renderTree; + } + + async exportProject({ + options, + }: { + options: ExportOptions; + }): Promise { + const { format, quality, fps, includeAudio, onProgress, onCancel } = + options; + + try { + const tracks = this.editor.timeline.getTracks(); + const mediaAssets = this.editor.media.getAssets(); + const activeProject = this.editor.project.getActive(); + + if (!activeProject) { + return { success: false, error: "No active project" }; + } + + const duration = this.editor.timeline.getTotalDuration(); + if (duration === 0) { + return { success: false, error: "Project is empty" }; + } + + const exportFps = fps || activeProject.settings.fps; + const canvasSize = activeProject.settings.canvasSize; + + let audioBuffer: AudioBuffer | null = null; + if (includeAudio) { + onProgress?.({ progress: 0.05 }); + audioBuffer = await createTimelineAudioBuffer({ + tracks, + mediaAssets, + duration, + }); + } + + const scene = buildScene({ + tracks, + mediaAssets, + duration, + canvasSize, + background: activeProject.settings.background, + }); + + const exporter = new SceneExporter({ + width: canvasSize.width, + height: canvasSize.height, + fps: exportFps, + format, + quality, + shouldIncludeAudio: !!includeAudio, + audioBuffer: audioBuffer || undefined, + }); + + exporter.on("progress", (progress) => { + const adjustedProgress = includeAudio + ? 0.05 + progress * 0.95 + : progress; + onProgress?.({ progress: adjustedProgress }); + }); + + let cancelled = false; + const checkCancel = () => { + if (onCancel?.()) { + cancelled = true; + exporter.cancel(); + } + }; + + const cancelInterval = setInterval(checkCancel, 100); + + try { + const buffer = await exporter.export({ rootNode: scene }); + clearInterval(cancelInterval); + + if (cancelled) { + return { success: false, cancelled: true }; + } + + if (!buffer) { + return { success: false, error: "Export failed to produce buffer" }; + } + + return { + success: true, + buffer, + }; + } finally { + clearInterval(cancelInterval); + } + } catch (error) { + console.error("Export failed:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown export error", + }; + } + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + this.listeners.forEach((fn) => fn()); + } +} diff --git a/apps/web/src/core/managers/save-manager.ts b/apps/web/src/core/managers/save-manager.ts new file mode 100644 index 00000000..d5881b6b --- /dev/null +++ b/apps/web/src/core/managers/save-manager.ts @@ -0,0 +1,107 @@ +import type { EditorCore } from "@/core"; + +type SaveManagerOptions = { + debounceMs?: number; +}; + +export class SaveManager { + private debounceMs: number; + private isPaused = false; + private isSaving = false; + private hasPendingSave = false; + private saveTimer: ReturnType | null = null; + private unsubscribeHandlers: Array<() => void> = []; + + constructor( + private editor: EditorCore, + { debounceMs = 800 }: SaveManagerOptions = {}, + ) { + this.debounceMs = debounceMs; + } + + start(): void { + if (this.unsubscribeHandlers.length > 0) return; + + this.unsubscribeHandlers = [ + this.editor.scenes.subscribe(() => { + this.markDirty(); + }), + this.editor.timeline.subscribe(() => { + this.markDirty(); + }), + ]; + } + + stop(): void { + for (const unsubscribe of this.unsubscribeHandlers) { + unsubscribe(); + } + this.unsubscribeHandlers = []; + this.clearTimer(); + } + + pause(): void { + this.isPaused = true; + } + + resume(): void { + this.isPaused = false; + if (this.hasPendingSave) { + this.queueSave(); + } + } + + markDirty({ force = false }: { force?: boolean } = {}): void { + if (this.isPaused && !force) return; + this.hasPendingSave = true; + this.queueSave(); + } + + async flush(): Promise { + this.hasPendingSave = true; + await this.saveNow(); + } + + getIsDirty(): boolean { + return this.hasPendingSave || this.isSaving; + } + + private queueSave(): void { + if (this.isSaving) return; + if (this.saveTimer) { + clearTimeout(this.saveTimer); + } + this.saveTimer = setTimeout(() => { + void this.saveNow(); + }, this.debounceMs); + } + + private async saveNow(): Promise { + if (this.isSaving) return; + if (!this.hasPendingSave) return; + + const activeProject = this.editor.project.getActive(); + if (!activeProject) return; + if (this.editor.project.getIsLoading()) return; + if (this.editor.project.getMigrationState().isMigrating) return; + + this.isSaving = true; + this.hasPendingSave = false; + this.clearTimer(); + + try { + await this.editor.project.saveCurrentProject(); + } finally { + this.isSaving = false; + if (this.hasPendingSave) { + this.queueSave(); + } + } + } + + private clearTimer(): void { + if (!this.saveTimer) return; + clearTimeout(this.saveTimer); + this.saveTimer = null; + } +} diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts new file mode 100644 index 00000000..447ec820 --- /dev/null +++ b/apps/web/src/core/managers/scenes-manager.ts @@ -0,0 +1,316 @@ +import type { EditorCore } from "@/core"; +import type { TimelineTrack, TScene } from "@/types/timeline"; +import { storageService } from "@/services/storage/storage-service"; +import { + getMainScene, + ensureMainScene, + canDeleteScene, + findCurrentScene, +} from "@/lib/scenes"; +import { getFrameTime, isBookmarkAtTime } from "@/lib/timeline/bookmarks"; +import { ensureMainTrack } from "@/lib/timeline/track-utils"; +import { + CreateSceneCommand, + DeleteSceneCommand, + RemoveBookmarkCommand, + RenameSceneCommand, + ToggleBookmarkCommand, +} from "@/lib/commands/scene"; + +export class ScenesManager { + private active: TScene | null = null; + private list: TScene[] = []; + private listeners = new Set<() => void>(); + + constructor(private editor: EditorCore) {} + + async createScene({ + name, + isMain = false, + }: { + name: string; + isMain: boolean; + }): Promise { + if (!this.editor.project.getActive()) { + throw new Error("No active project"); + } + + const command = new CreateSceneCommand(name, isMain); + this.editor.command.execute({ command }); + return command.getSceneId(); + } + + async deleteScene({ sceneId }: { sceneId: string }): Promise { + const sceneToDelete = this.list.find((s) => s.id === sceneId); + + if (!sceneToDelete) { + throw new Error("Scene not found"); + } + + const { canDelete, reason } = canDeleteScene({ scene: sceneToDelete }); + if (!canDelete) { + throw new Error(reason); + } + + if (!this.editor.project.getActive()) { + throw new Error("No active project"); + } + + const command = new DeleteSceneCommand(sceneId); + this.editor.command.execute({ command }); + } + + async renameScene({ + sceneId, + name, + }: { + sceneId: string; + name: string; + }): Promise { + if (!this.editor.project.getActive()) { + throw new Error("No active project"); + } + + const command = new RenameSceneCommand(sceneId, name); + this.editor.command.execute({ command }); + } + + async switchToScene({ sceneId }: { sceneId: string }): Promise { + const targetScene = this.list.find((s) => s.id === sceneId); + + if (!targetScene) { + throw new Error("Scene not found"); + } + + const activeProject = this.editor.project.getActive(); + + if (activeProject) { + const updatedProject = { + ...activeProject, + currentSceneId: sceneId, + metadata: { + ...activeProject.metadata, + updatedAt: new Date(), + }, + }; + + this.editor.project.setActiveProject({ project: updatedProject }); + } + + this.active = targetScene; + this.notify(); + } + + async toggleBookmark({ time }: { time: number }): Promise { + const command = new ToggleBookmarkCommand(time); + this.editor.command.execute({ command }); + } + + isBookmarked({ time }: { time: number }): boolean { + const activeScene = this.getActiveScene(); + const activeProject = this.editor.project.getActive(); + + if (!activeScene || !this.active || !activeProject) return false; + + const frameTime = getFrameTime({ + time, + fps: activeProject.settings.fps, + }); + + return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime }); + } + + async removeBookmark({ time }: { time: number }): Promise { + const command = new RemoveBookmarkCommand(time); + this.editor.command.execute({ command }); + } + + async loadProjectScenes({ projectId }: { projectId: string }): Promise { + try { + const result = await storageService.loadProject({ id: projectId }); + if (result?.project.scenes) { + const { scenes: ensuredScenes, hasAddedMainTrack } = + this.ensureScenesHaveMainTrack({ + scenes: result.project.scenes ?? [], + }); + const currentScene = findCurrentScene({ + scenes: ensuredScenes, + currentSceneId: result.project.currentSceneId, + }); + + this.list = ensuredScenes; + this.active = currentScene; + this.notify(); + + if (hasAddedMainTrack) { + const activeProject = this.editor.project.getActive(); + if (activeProject) { + const updatedProject = { + ...activeProject, + scenes: ensuredScenes, + metadata: { + ...activeProject.metadata, + updatedAt: new Date(), + }, + }; + this.editor.project.setActiveProject({ project: updatedProject }); + this.editor.save.markDirty({ force: true }); + } + } + } + } catch (error) { + console.error("Failed to load project scenes:", error); + this.list = []; + this.active = null; + this.notify(); + } + } + + initializeScenes({ + scenes, + currentSceneId, + }: { + scenes: TScene[]; + currentSceneId?: string; + }): void { + const ensuredScenes = ensureMainScene({ scenes }); + const { scenes: scenesWithMainTracks, hasAddedMainTrack } = + this.ensureScenesHaveMainTrack({ scenes: ensuredScenes }); + const currentScene = currentSceneId + ? scenesWithMainTracks.find((s) => s.id === currentSceneId) + : null; + + const fallbackScene = getMainScene({ scenes: scenesWithMainTracks }); + + this.list = scenesWithMainTracks; + this.active = currentScene || fallbackScene; + this.notify(); + + const hasAddedMainScene = ensuredScenes.length > scenes.length; + if (hasAddedMainScene || hasAddedMainTrack) { + const activeProject = this.editor.project.getActive(); + + if (activeProject) { + const updatedProject = { + ...activeProject, + scenes: scenesWithMainTracks, + metadata: { + ...activeProject.metadata, + updatedAt: new Date(), + }, + }; + + this.editor.project.setActiveProject({ project: updatedProject }); + this.editor.save.markDirty({ force: true }); + } + } + } + + clearScenes(): void { + this.list = []; + this.active = null; + this.notify(); + } + + getActiveScene(): TScene { + if (!this.active) { + throw new Error("No active scene."); + } + return this.active; + } + + getScenes(): TScene[] { + return this.list; + } + + setScenes({ + scenes, + activeSceneId, + }: { + scenes: TScene[]; + activeSceneId?: string; + }): void { + this.list = scenes; + const nextActiveSceneId = activeSceneId ?? this.active?.id ?? null; + this.active = nextActiveSceneId + ? (scenes.find((scene) => scene.id === nextActiveSceneId) ?? null) + : null; + this.notify(); + + const activeProject = this.editor.project.getActive(); + if (activeProject) { + const updatedProject = { + ...activeProject, + scenes, + metadata: { + ...activeProject.metadata, + updatedAt: new Date(), + }, + }; + this.editor.project.setActiveProject({ project: updatedProject }); + } + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + this.listeners.forEach((fn) => fn()); + } + + updateSceneTracks({ tracks }: { tracks: 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 ensureScenesHaveMainTrack({ scenes }: { scenes: TScene[] }): { + scenes: TScene[]; + hasAddedMainTrack: boolean; + } { + let hasAddedMainTrack = false; + const ensuredScenes: TScene[] = []; + + for (const scene of scenes) { + const existingTracks = scene.tracks ?? []; + const updatedTracks = ensureMainTrack({ tracks: existingTracks }); + if (updatedTracks !== existingTracks) { + hasAddedMainTrack = true; + ensuredScenes.push({ + ...scene, + tracks: updatedTracks, + updatedAt: new Date(), + }); + } else { + ensuredScenes.push(scene); + } + } + + return { scenes: ensuredScenes, hasAddedMainTrack }; + } +} diff --git a/apps/web/src/core/managers/selection-manager.ts b/apps/web/src/core/managers/selection-manager.ts new file mode 100644 index 00000000..fa6a02bf --- /dev/null +++ b/apps/web/src/core/managers/selection-manager.ts @@ -0,0 +1,35 @@ +import type { EditorCore } from "@/core"; + +type ElementRef = { trackId: string; elementId: string }; + +export class SelectionManager { + private selectedElements: ElementRef[] = []; + private listeners = new Set<() => void>(); + + constructor(editor: EditorCore) { + void editor; + } + + getSelectedElements(): ElementRef[] { + return this.selectedElements; + } + + setSelectedElements({ elements }: { elements: ElementRef[] }): void { + this.selectedElements = elements; + this.notify(); + } + + clearSelection(): void { + this.selectedElements = []; + this.notify(); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + this.listeners.forEach((fn) => fn()); + } +} diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts new file mode 100644 index 00000000..867df3a8 --- /dev/null +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -0,0 +1,275 @@ +import type { EditorCore } from "@/core"; +import type { + TrackType, + TimelineTrack, + TextElement, + TimelineElement, + ClipboardItem, +} from "@/types/timeline"; +import { calculateTotalDuration } from "@/lib/timeline"; +import { + AddTrackCommand, + RemoveTrackCommand, + ToggleTrackMuteCommand, + ToggleTrackVisibilityCommand, + InsertElementCommand, + UpdateElementTrimCommand, + UpdateElementDurationCommand, + DeleteElementsCommand, + DuplicateElementsCommand, + ToggleElementsVisibilityCommand, + ToggleElementsMutedCommand, + UpdateTextElementCommand, + SplitElementsCommand, + PasteCommand, + UpdateElementStartTimeCommand, + MoveElementCommand, +} from "@/lib/commands/timeline"; +import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element"; + +export class TimelineManager { + private listeners = new Set<() => void>(); + + constructor(private editor: EditorCore) {} + + addTrack({ type, index }: { type: TrackType; index?: number }): string { + const command = new AddTrackCommand(type, index); + this.editor.command.execute({ command }); + return command.getTrackId(); + } + + removeTrack({ trackId }: { trackId: string }): void { + const command = new RemoveTrackCommand(trackId); + this.editor.command.execute({ command }); + } + + insertElement({ element, placement }: InsertElementParams): void { + const command = new InsertElementCommand({ element, placement }); + this.editor.command.execute({ command }); + } + + updateElementTrim({ + elementId, + trimStart, + trimEnd, + pushHistory = true, + }: { + elementId: string; + trimStart: number; + trimEnd: number; + pushHistory?: boolean; + }): void { + const command = new UpdateElementTrimCommand(elementId, trimStart, trimEnd); + if (pushHistory) { + this.editor.command.execute({ command }); + } else { + command.execute(); + } + } + + updateElementDuration({ + trackId, + elementId, + duration, + pushHistory = true, + }: { + trackId: string; + elementId: string; + duration: number; + pushHistory?: boolean; + }): void { + const command = new UpdateElementDurationCommand( + trackId, + elementId, + duration, + ); + if (pushHistory) { + this.editor.command.execute({ command }); + } else { + command.execute(); + } + } + + updateElementStartTime({ + elements, + startTime, + }: { + elements: { trackId: string; elementId: string }[]; + startTime: number; + }): void { + const command = new UpdateElementStartTimeCommand(elements, startTime); + this.editor.command.execute({ command }); + } + + moveElement({ + sourceTrackId, + targetTrackId, + elementId, + newStartTime, + createTrack, + }: { + sourceTrackId: string; + targetTrackId: string; + elementId: string; + newStartTime: number; + createTrack?: { type: TrackType; index: number }; + }): void { + const command = new MoveElementCommand( + sourceTrackId, + targetTrackId, + elementId, + newStartTime, + createTrack, + ); + this.editor.command.execute({ command }); + } + + toggleTrackMute({ trackId }: { trackId: string }): void { + const command = new ToggleTrackMuteCommand(trackId); + this.editor.command.execute({ command }); + } + + toggleTrackVisibility({ trackId }: { trackId: string }): void { + const command = new ToggleTrackVisibilityCommand(trackId); + this.editor.command.execute({ command }); + } + + splitElements({ + elements, + splitTime, + retainSide = "both", + }: { + elements: { trackId: string; elementId: string }[]; + splitTime: number; + retainSide?: "both" | "left" | "right"; + }): { trackId: string; elementId: string }[] { + const command = new SplitElementsCommand(elements, splitTime, retainSide); + this.editor.command.execute({ command }); + return command.getRightSideElements(); + } + + getTotalDuration(): number { + return calculateTotalDuration({ tracks: this.getTracks() }); + } + + getTrackById({ trackId }: { trackId: string }): TimelineTrack | null { + return this.getTracks().find((track) => track.id === trackId) ?? null; + } + + getElementsWithTracks({ + elements, + }: { + elements: { trackId: string; elementId: string }[]; + }): Array<{ track: TimelineTrack; element: TimelineElement }> { + const result: Array<{ track: TimelineTrack; element: TimelineElement }> = + []; + + for (const { trackId, elementId } of elements) { + const track = this.getTrackById({ trackId }); + const element = track?.elements.find( + (trackElement) => trackElement.id === elementId, + ); + + if (track && element) { + result.push({ track, element }); + } + } + + return result; + } + + pasteAtTime({ + time, + clipboardItems, + }: { + time: number; + clipboardItems: ClipboardItem[]; + }): { trackId: string; elementId: string }[] { + const command = new PasteCommand(time, clipboardItems); + this.editor.command.execute({ command }); + return command.getPastedElements(); + } + + deleteElements({ + elements, + }: { + elements: { trackId: string; elementId: string }[]; + }): void { + const command = new DeleteElementsCommand(elements); + this.editor.command.execute({ command }); + } + + updateTextElement({ + trackId, + elementId, + updates, + }: { + trackId: string; + elementId: string; + updates: Partial< + Pick< + TextElement, + | "content" + | "fontSize" + | "fontFamily" + | "color" + | "backgroundColor" + | "textAlign" + | "fontWeight" + | "fontStyle" + | "textDecoration" + | "transform" + | "opacity" + > + >; + }): void { + const command = new UpdateTextElementCommand(trackId, elementId, updates); + this.editor.command.execute({ command }); + } + + duplicateElements({ + elements, + }: { + elements: { trackId: string; elementId: string }[]; + }): { trackId: string; elementId: string }[] { + const command = new DuplicateElementsCommand({ elements }); + this.editor.command.execute({ command }); + return command.getDuplicatedElements(); + } + + toggleElementsVisibility({ + elements, + }: { + elements: { trackId: string; elementId: string }[]; + }): void { + const command = new ToggleElementsVisibilityCommand(elements); + this.editor.command.execute({ command }); + } + + toggleElementsMuted({ + elements, + }: { + elements: { trackId: string; elementId: string }[]; + }): void { + const command = new ToggleElementsMutedCommand(elements); + this.editor.command.execute({ command }); + } + + getTracks(): TimelineTrack[] { + return this.editor.scenes.getActiveScene()?.tracks ?? []; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + this.listeners.forEach((fn) => fn()); + } + + updateTracks(newTracks: TimelineTrack[]): void { + this.editor.scenes.updateSceneTracks({ tracks: newTracks }); + this.notify(); + } +} -- cgit v1.3.1