summaryrefslogtreecommitdiff
path: root/apps/web/src/core
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/core
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/core')
-rw-r--r--apps/web/src/core/index.ts50
-rw-r--r--apps/web/src/core/managers/audio-manager.ts335
-rw-r--r--apps/web/src/core/managers/commands.ts44
-rw-r--r--apps/web/src/core/managers/media-manager.ts162
-rw-r--r--apps/web/src/core/managers/playback-manager.ts162
-rw-r--r--apps/web/src/core/managers/project-manager.ts648
-rw-r--r--apps/web/src/core/managers/renderer-manager.ts129
-rw-r--r--apps/web/src/core/managers/save-manager.ts107
-rw-r--r--apps/web/src/core/managers/scenes-manager.ts316
-rw-r--r--apps/web/src/core/managers/selection-manager.ts35
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts275
11 files changed, 2263 insertions, 0 deletions
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<string, AudioBufferSink>();
+ private inputs = new Map<string, Input>();
+ private activeClipIds = new Set<string>();
+ private clipIterators = new Map<
+ string,
+ AsyncGenerator<WrappedAudioBuffer, void, unknown>
+ >();
+ private queuedSources = new Set<AudioBufferSourceNode>();
+ 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<void> {
+ 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<void> {
+ 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<void> {
+ 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<AudioBufferSink | null> {
+ 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<MediaAsset, "id">;
+ }): Promise<void> {
+ 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<void> {
+ 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<void> {
+ 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<void> {
+ 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<string>();
+ private storageMigrationPromise: Promise<void> | 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<void> {
+ 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<string> {
+ 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<void> {
+ 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<void> {
+ 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<ExportResult> {
+ return this.editor.renderer.exportProject({ options });
+ }
+
+ async loadAllProjects(): Promise<void> {
+ 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<void> {
+ 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<void> {
+ 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<string[]> {
+ 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<string, number>();
+
+ 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<string, number>();
+ 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<TProjectSettings>;
+ pushHistory?: boolean;
+ }): Promise<void> {
+ 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<void> {
+ 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<void> {
+ 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<MigrationState>): 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<boolean> {
+ 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<ExportResult> {
+ 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<typeof setTimeout> | 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<void> {
+ 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<void> {
+ 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<string> {
+ 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<void> {
+ 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<void> {
+ 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<void> {
+ 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<void> {
+ 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<void> {
+ const command = new RemoveBookmarkCommand(time);
+ this.editor.command.execute({ command });
+ }
+
+ async loadProjectScenes({ projectId }: { projectId: string }): Promise<void> {
+ 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();
+ }
+}