refactor: structured track layout per scene

This commit is contained in:
Maze Winther
2026-04-07 04:41:27 +02:00
parent 4f7d401a97
commit cbd1b82123
91 changed files with 2086 additions and 1378 deletions
+15 -6
View File
@@ -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);
}
});
+1 -1
View File
@@ -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();
+12 -5
View File
@@ -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,
@@ -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<boolean> {
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;
@@ -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();
+12 -55
View File
@@ -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 };
}
}
+78 -28
View File
@@ -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<string, Partial<TimelineElement>>();
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<TimelineElement>;
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 = <TTrack extends TimelineTrack>(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 });