From cbd1b8212370c67b34a70dccd21bd82de72fead2 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 7 Apr 2026 04:41:27 +0200 Subject: refactor: structured track layout per scene --- apps/web/src/core/index.ts | 21 +++-- apps/web/src/core/managers/audio-manager.ts | 2 +- apps/web/src/core/managers/commands.ts | 17 ++-- apps/web/src/core/managers/project-manager.ts | 11 ++- apps/web/src/core/managers/renderer-manager.ts | 2 +- apps/web/src/core/managers/scenes-manager.ts | 67 +++------------- apps/web/src/core/managers/timeline-manager.ts | 106 ++++++++++++++++++------- 7 files changed, 127 insertions(+), 99 deletions(-) (limited to 'apps/web/src/core') diff --git a/apps/web/src/core/index.ts b/apps/web/src/core/index.ts index 1086a6d2..8ee76e28 100644 --- a/apps/web/src/core/index.ts +++ b/apps/web/src/core/index.ts @@ -10,7 +10,6 @@ import { AudioManager } from "./managers/audio-manager"; import { SelectionManager } from "./managers/selection-manager"; import { registerDefaultEffects } from "@/lib/effects"; import { registerDefaultMasks } from "@/lib/masks"; -import { isMainTrack } from "@/lib/timeline/placement"; export class EditorCore { private static instance: EditorCore | null = null; @@ -39,11 +38,21 @@ export class EditorCore { this.audio = new AudioManager(this); this.selection = new SelectionManager(this); this.command.registerReactor(() => { - const tracks = this.timeline.getTracks(); - const prunedTracks = tracks.filter( - (track) => track.elements.length > 0 || isMainTrack(track), - ); - if (prunedTracks.length !== tracks.length) { + const activeScene = this.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return; + } + + const tracks = activeScene.tracks; + const prunedTracks = { + ...tracks, + overlay: tracks.overlay.filter((track) => track.elements.length > 0), + audio: tracks.audio.filter((track) => track.elements.length > 0), + }; + if ( + prunedTracks.overlay.length !== tracks.overlay.length || + prunedTracks.audio.length !== tracks.audio.length + ) { this.timeline.updateTracks(prunedTracks); } }); diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts index aaeb7ade..076b998d 100644 --- a/apps/web/src/core/managers/audio-manager.ts +++ b/apps/web/src/core/managers/audio-manager.ts @@ -163,7 +163,7 @@ export class AudioManager { this.playbackSessionId++; this.playbackLatencyCompensationSeconds = 0; - const tracks = this.editor.timeline.getTracks(); + const tracks = this.editor.scenes.getActiveScene().tracks; const mediaAssets = this.editor.media.getAssets(); const duration = this.editor.timeline.getTotalDuration(); diff --git a/apps/web/src/core/managers/commands.ts b/apps/web/src/core/managers/commands.ts index a3665852..2f652a85 100644 --- a/apps/web/src/core/managers/commands.ts +++ b/apps/web/src/core/managers/commands.ts @@ -1,7 +1,7 @@ import type { EditorCore } from "@/core"; import type { Command, CommandResult } from "@/lib/commands"; import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple"; -import type { TimelineTrack, ElementRef } from "@/lib/timeline/types"; +import type { ElementRef, SceneTracks } from "@/lib/timeline/types"; interface CommandHistoryEntry { command: Command; @@ -18,7 +18,9 @@ export class CommandManager { constructor(private editor: EditorCore) {} execute({ command }: { command: Command }): Command { - const beforeTracks = this.isRippleEnabled ? this.editor.timeline.getTracks() : null; + const beforeTracks = this.isRippleEnabled + ? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null + : null; const previousSelection = this.getSelectionSnapshot(); const result = command.execute(); this.applyRippleIfEnabled({ beforeTracks }); @@ -71,7 +73,9 @@ export class CommandManager { return; } - const beforeTracks = this.isRippleEnabled ? this.editor.timeline.getTracks() : null; + const beforeTracks = this.isRippleEnabled + ? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null + : null; const previousSelection = this.getSelectionSnapshot(); const result = entry.command.redo(); this.applyRippleIfEnabled({ beforeTracks }); @@ -123,13 +127,16 @@ export class CommandManager { private applyRippleIfEnabled({ beforeTracks, }: { - beforeTracks: TimelineTrack[] | null; + beforeTracks: SceneTracks | null; }): void { if (!this.isRippleEnabled || !beforeTracks) { return; } - const afterTracks = this.editor.timeline.getTracks(); + const afterTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks; + if (!afterTracks) { + return; + } const adjustments = computeRippleAdjustments({ beforeTracks, afterTracks, diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts index 513eab94..6324b988 100644 --- a/apps/web/src/core/managers/project-manager.ts +++ b/apps/web/src/core/managers/project-manager.ts @@ -156,9 +156,14 @@ export class ProjectManager { await this.editor.media.loadProjectMedia({ projectId: id }); - const allTracks = (project.scenes ?? []).flatMap((scene) => scene.tracks); await loadFonts({ - families: getElementFontFamilies({ tracks: allTracks }), + families: [ + ...new Set( + (project.scenes ?? []).flatMap((scene) => + getElementFontFamilies({ tracks: scene.tracks }), + ), + ), + ], }); if (!project.metadata.thumbnail) { @@ -643,7 +648,7 @@ export class ProjectManager { private async updateThumbnailFromTimeline(): Promise { if (!this.active) return false; - const tracks = this.editor.timeline.getTracks(); + const tracks = this.editor.scenes.getActiveScene().tracks; const mediaAssets = this.editor.media.getAssets(); const duration = this.editor.timeline.getTotalDuration(); const { canvasSize, background } = this.active.settings; diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts index e0d5b895..eec4bf37 100644 --- a/apps/web/src/core/managers/renderer-manager.ts +++ b/apps/web/src/core/managers/renderer-manager.ts @@ -139,7 +139,7 @@ export class RendererManager { const { format, quality, fps, includeAudio } = options; try { - const tracks = this.editor.timeline.getTracks(); + const tracks = this.editor.scenes.getActiveScene().tracks; const mediaAssets = this.editor.media.getAssets(); const activeProject = this.editor.project.getActive(); diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts index 43938f96..3508a066 100644 --- a/apps/web/src/core/managers/scenes-manager.ts +++ b/apps/web/src/core/managers/scenes-manager.ts @@ -1,5 +1,5 @@ import type { EditorCore } from "@/core"; -import type { TimelineTrack, TScene } from "@/lib/timeline"; +import type { SceneTracks, TScene } from "@/lib/timeline"; import { storageService } from "@/services/storage/service"; import { getMainScene, @@ -12,7 +12,6 @@ import { getFrameTime, isBookmarkAtTime, } from "@/lib/timeline/bookmarks"; -import { ensureMainTrack } from "@/lib/timeline/placement"; import { CreateSceneCommand, DeleteSceneCommand, @@ -174,10 +173,7 @@ export class ScenesManager { try { const result = await storageService.loadProject({ id: projectId }); if (result?.project.scenes) { - const { scenes: ensuredScenes, hasAddedMainTrack } = - this.ensureScenesHaveMainTrack({ - scenes: result.project.scenes ?? [], - }); + const ensuredScenes = result.project.scenes ?? []; const currentScene = findCurrentScene({ scenes: ensuredScenes, currentSceneId: result.project.currentSceneId, @@ -186,22 +182,6 @@ export class ScenesManager { 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); @@ -219,26 +199,24 @@ export class ScenesManager { currentSceneId?: string; }): void { const ensuredScenes = ensureMainScene({ scenes }); - const { scenes: scenesWithMainTracks, hasAddedMainTrack } = - this.ensureScenesHaveMainTrack({ scenes: ensuredScenes }); const currentScene = currentSceneId - ? scenesWithMainTracks.find((s) => s.id === currentSceneId) + ? ensuredScenes.find((s) => s.id === currentSceneId) : null; - const fallbackScene = getMainScene({ scenes: scenesWithMainTracks }); + const fallbackScene = getMainScene({ scenes: ensuredScenes }); - this.list = scenesWithMainTracks; + this.list = ensuredScenes; this.active = currentScene || fallbackScene; this.notify(); const hasAddedMainScene = ensuredScenes.length > scenes.length; - if (hasAddedMainScene || hasAddedMainTrack) { + if (hasAddedMainScene) { const activeProject = this.editor.project.getActive(); if (activeProject) { const updatedProject = { ...activeProject, - scenes: scenesWithMainTracks, + scenes: ensuredScenes, metadata: { ...activeProject.metadata, updatedAt: new Date(), @@ -264,6 +242,10 @@ export class ScenesManager { return this.active; } + getActiveSceneOrNull(): TScene | null { + return this.active; + } + getScenes(): TScene[] { return this.list; } @@ -307,7 +289,7 @@ export class ScenesManager { }); } - updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void { + updateSceneTracks({ tracks }: { tracks: SceneTracks }): void { if (!this.active) return; const updatedScene: TScene = { @@ -335,29 +317,4 @@ export class ScenesManager { 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/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index a5cb428e..74870a95 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -1,6 +1,7 @@ import type { EditorCore } from "@/core"; import type { ParamValues } from "@/lib/params"; import type { + SceneTracks, TrackType, TimelineTrack, TimelineElement, @@ -8,6 +9,10 @@ import type { RetimeConfig, } from "@/lib/timeline"; import { calculateTotalDuration } from "@/lib/timeline"; +import { + findTrackInSceneTracks, + updateElementInSceneTracks, +} from "@/lib/timeline/track-element-update"; import { canElementBeHidden, canElementHaveAudio, @@ -53,7 +58,7 @@ import type { InsertElementParams } from "@/lib/commands/timeline/element/insert export class TimelineManager { private listeners = new Set<() => void>(); private previewOverlay = new Map>(); - private previewTracks: TimelineTrack[] | null = null; + private previewTracks: SceneTracks | null = null; constructor(private editor: EditorCore) {} @@ -193,7 +198,12 @@ export class TimelineManager { } getTotalDuration(): number { - return calculateTotalDuration({ tracks: this.getTracks() }); + const activeScene = this.editor.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return 0; + } + + return calculateTotalDuration({ tracks: activeScene.tracks }); } getLastFrameTime(): number { @@ -204,7 +214,12 @@ export class TimelineManager { } getTrackById({ trackId }: { trackId: string }): TimelineTrack | null { - return this.getTracks().find((track) => track.id === trackId) ?? null; + const activeScene = this.editor.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return null; + } + + return findTrackInSceneTracks({ tracks: activeScene.tracks, trackId }); } getElementsWithTracks({ @@ -629,14 +644,20 @@ export class TimelineManager { } as Partial; this.previewOverlay.set(elementId, mergedOverlay); } - const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? []; + const committedTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks; + if (!committedTracks) { + return; + } this.previewTracks = this.applyPreviewOverlay(committedTracks); this.notify(); } commitPreview(): void { if (this.previewOverlay.size === 0) return; - const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? []; + const committedTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks; + if (!committedTracks) { + return; + } const afterTracks = this.previewTracks ?? this.applyPreviewOverlay(committedTracks); const command = new TracksSnapshotCommand(committedTracks, afterTracks); @@ -653,19 +674,32 @@ export class TimelineManager { this.notify(); } - private applyPreviewOverlay(tracks: TimelineTrack[]): TimelineTrack[] { + private applyPreviewOverlay(tracks: SceneTracks): SceneTracks { if (this.previewOverlay.size === 0) return tracks; - return tracks.map((track) => { - const hasOverlay = track.elements.some((el) => - this.previewOverlay.has(el.id), + + const applyTrackOverlay = (track: TTrack): TTrack => { + const hasOverlay = track.elements.some((element) => + this.previewOverlay.has(element.id), ); - if (!hasOverlay) return track; - const newElements = track.elements.map((el) => { - const overlay = this.previewOverlay.get(el.id); - return overlay ? ({ ...el, ...overlay } as TimelineElement) : el; + if (!hasOverlay) { + return track; + } + + const nextElements = track.elements.map((element) => { + const overlay = this.previewOverlay.get(element.id); + return overlay + ? ({ ...element, ...overlay } as TimelineElement) + : element; }); - return { ...track, elements: newElements } as TimelineTrack; - }); + + return { ...track, elements: nextElements } as TTrack; + }; + + return { + overlay: tracks.overlay.map((track) => applyTrackOverlay(track)), + main: applyTrackOverlay(tracks.main), + audio: tracks.audio.map((track) => applyTrackOverlay(track)), + }; } duplicateElements({ @@ -734,13 +768,8 @@ export class TimelineManager { this.updateElements({ updates: nextUpdates }); } - getTracks(): TimelineTrack[] { - return this.editor.scenes.getActiveScene()?.tracks ?? []; - } - - getRenderTracks(): TimelineTrack[] { - if (this.previewTracks !== null) return this.previewTracks; - return this.getTracks(); + getPreviewTracks(): SceneTracks | null { + return this.previewTracks ?? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null; } subscribe(listener: () => void): () => void { @@ -771,14 +800,35 @@ export class TimelineManager { }: { elementId: string; }): string | null { - return ( - this.getTracks().find((track) => - track.elements.some((element) => element.id === elementId), - )?.id ?? null - ); + const activeScene = this.editor.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return null; + } + + if ( + activeScene.tracks.main.elements.some( + (element) => element.id === elementId, + ) + ) { + return activeScene.tracks.main.id; + } + + for (const track of activeScene.tracks.overlay) { + if (track.elements.some((element) => element.id === elementId)) { + return track.id; + } + } + + for (const track of activeScene.tracks.audio) { + if (track.elements.some((element) => element.id === elementId)) { + return track.id; + } + } + + return null; } - updateTracks(newTracks: TimelineTrack[]): void { + updateTracks(newTracks: SceneTracks): void { this.previewOverlay.clear(); this.previewTracks = null; this.editor.scenes.updateSceneTracks({ tracks: newTracks }); -- cgit v1.3.1