From cbd1b8212370c67b34a70dccd21bd82de72fead2 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 7 Apr 2026 04:41:27 +0200 Subject: [PATCH] refactor: structured track layout per scene --- .../editor/panels/assets/views/captions.tsx | 2 +- .../editor/panels/preview/index.tsx | 4 +- .../editor/panels/properties/index.tsx | 2 +- .../panels/properties/tabs/masks-tab.tsx | 4 +- .../editor/panels/timeline/drop-target.ts | 16 +- .../panels/timeline/graph-editor/session.ts | 8 +- .../timeline/graph-editor/use-controller.ts | 3 +- .../editor/panels/timeline/index.tsx | 38 ++- 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 +- .../web/src/core/managers/renderer-manager.ts | 2 +- apps/web/src/core/managers/scenes-manager.ts | 67 +---- .../web/src/core/managers/timeline-manager.ts | 106 +++++--- .../src/hooks/actions/use-editor-actions.ts | 16 +- .../element/use-element-interaction.ts | 24 +- .../timeline/element/use-element-resize.ts | 2 +- .../src/hooks/timeline/use-bookmark-drag.ts | 2 +- .../hooks/timeline/use-timeline-drag-drop.ts | 55 ++-- .../hooks/timeline/use-timeline-playhead.ts | 2 +- apps/web/src/hooks/use-element-preview.ts | 13 +- apps/web/src/hooks/use-mask-handles.ts | 4 +- apps/web/src/hooks/use-preview-interaction.ts | 4 +- apps/web/src/hooks/use-transform-handles.ts | 4 +- .../src/lib/commands/media/add-media-asset.ts | 8 +- .../lib/commands/media/remove-media-asset.ts | 202 +++++++-------- apps/web/src/lib/commands/preview-tracker.ts | 46 ++-- .../project/update-project-settings.ts | 92 +++---- .../src/lib/commands/scene/delete-scene.ts | 118 ++++----- apps/web/src/lib/commands/scene/index.ts | 14 +- .../src/lib/commands/scene/move-bookmark.ts | 118 ++++----- .../src/lib/commands/scene/remove-bookmark.ts | 118 ++++----- .../src/lib/commands/scene/rename-scene.ts | 92 +++---- .../src/lib/commands/scene/toggle-bookmark.ts | 104 ++++---- .../src/lib/commands/scene/update-bookmark.ts | 110 ++++---- .../lib/commands/timeline/clipboard/paste.ts | 34 +-- .../timeline/element/delete-elements.ts | 57 +++-- .../timeline/element/duplicate-elements.ts | 14 +- .../timeline/element/effects/add-effect.ts | 10 +- .../timeline/element/effects/index.ts | 10 +- .../timeline/element/effects/remove-effect.ts | 10 +- .../element/effects/reorder-effect.ts | 10 +- .../timeline/element/effects/toggle-effect.ts | 10 +- .../element/effects/update-effect-params.ts | 10 +- .../lib/commands/timeline/element/index.ts | 22 +- .../timeline/element/insert-element.ts | 44 ++-- .../keyframes/remove-effect-param-keyframe.ts | 10 +- .../element/keyframes/remove-keyframe.ts | 10 +- .../element/keyframes/retime-keyframe.ts | 10 +- .../keyframes/update-scalar-keyframe-curve.ts | 10 +- .../keyframes/upsert-effect-param-keyframe.ts | 10 +- .../element/keyframes/upsert-keyframe.ts | 10 +- .../commands/timeline/element/masks/index.ts | 4 +- .../timeline/element/masks/remove-mask.ts | 10 +- .../element/masks/toggle-mask-inverted.ts | 10 +- .../timeline/element/move-elements.ts | 122 ++++++--- .../timeline/element/split-elements.ts | 20 +- .../element/toggle-source-audio-separation.ts | 87 +++---- .../timeline/element/update-elements.ts | 20 +- apps/web/src/lib/commands/timeline/index.ts | 10 +- .../lib/commands/timeline/track/add-track.ts | 86 ++++++- .../commands/timeline/track/remove-track.ts | 65 +++-- .../timeline/track/toggle-track-mute.ts | 80 +++--- .../timeline/track/toggle-track-visibility.ts | 85 ++++--- .../lib/commands/timeline/tracks-snapshot.ts | 6 +- apps/web/src/lib/media/audio.ts | 19 +- apps/web/src/lib/media/mediabunny.ts | 4 +- apps/web/src/lib/preview/element-bounds.ts | 12 +- apps/web/src/lib/ripple/apply.ts | 28 ++- apps/web/src/lib/ripple/diff.ts | 23 +- apps/web/src/lib/scenes.ts | 18 +- apps/web/src/lib/timeline/element-utils.ts | 10 +- apps/web/src/lib/timeline/index.ts | 9 +- .../placement/__tests__/resolve.test.ts | 238 ++++++++++-------- apps/web/src/lib/timeline/placement/apply.ts | 149 +++++++++-- apps/web/src/lib/timeline/placement/index.ts | 8 +- .../lib/timeline/placement/insert-index.ts | 33 +-- .../src/lib/timeline/placement/main-track.ts | 56 +---- .../web/src/lib/timeline/placement/resolve.ts | 43 ++-- .../lib/timeline/placement/track-factory.ts | 66 ++++- apps/web/src/lib/timeline/snap-utils.ts | 9 +- .../src/lib/timeline/track-element-update.ts | 152 +++++++++-- apps/web/src/lib/timeline/types.ts | 13 +- apps/web/src/lib/timeline/update-pipeline.ts | 46 ++-- .../src/services/renderer/scene-builder.ts | 22 +- .../src/services/storage/migrations/index.ts | 4 +- .../migrations/transformers/v23-to-v24.ts | 113 +++++++++ .../services/storage/migrations/v23-to-v24.ts | 16 ++ apps/web/src/services/storage/service.ts | 22 +- apps/web/src/stores/sounds-store.ts | 4 +- 91 files changed, 2086 insertions(+), 1378 deletions(-) create mode 100644 apps/web/src/services/storage/migrations/transformers/v23-to-v24.ts create mode 100644 apps/web/src/services/storage/migrations/v23-to-v24.ts diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx index f22c0211..c89e587b 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -59,7 +59,7 @@ export function Captions() { setProcessingStep("Extracting audio..."); const audioBlob = await extractTimelineAudio({ - tracks: editor.timeline.getTracks(), + tracks: editor.scenes.getActiveScene().tracks, mediaAssets: editor.media.getAssets(), totalDuration: editor.timeline.getTotalDuration(), }); diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index 786d9a46..369d854c 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -73,7 +73,9 @@ export function PreviewPanel() { function RenderTreeController() { const editor = useEditor(); - const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const tracks = useEditor( + (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, + ); const mediaAssets = useEditor((e) => e.media.getAssets()); const activeProject = useEditor((e) => e.project.getActive()); diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 3f3ff1f3..75319ef3 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -17,7 +17,7 @@ import { EmptyView } from "./empty-view"; export function PropertiesPanel() { const editor = useEditor(); - useEditor((e) => e.timeline.getTracks()); + useEditor((e) => e.scenes.getActiveSceneOrNull()); useEditor((e) => e.media.getAssets()); const { selectedElements } = useElementSelection(); const { activeTabPerType, setActiveTab } = usePropertiesStore(); diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx index 9b0ffc88..8148b457 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx @@ -87,7 +87,9 @@ export function MasksTab({ element, trackId }: MasksTabProps) { fallback: element, }); const maskDefs = masksRegistry.getAll(); - const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const tracks = useEditor( + (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, + ); const currentTime = useEditor((e) => e.playback.getCurrentTime()); const mediaAssets = useEditor((e) => e.media.getAssets()); const canvasSize = useEditor( diff --git a/apps/web/src/components/editor/panels/timeline/drop-target.ts b/apps/web/src/components/editor/panels/timeline/drop-target.ts index dbd4efaf..f65170fe 100644 --- a/apps/web/src/components/editor/panels/timeline/drop-target.ts +++ b/apps/web/src/components/editor/panels/timeline/drop-target.ts @@ -105,6 +105,8 @@ export function computeDropTarget({ excludeElementId, targetElementTypes, }: ComputeDropTargetParams): DropTarget { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + const mainTrackIndex = tracks.overlay.length; const xPosition = typeof startTimeOverride === "number" ? startTimeOverride @@ -112,7 +114,7 @@ export function computeDropTarget({ ? playheadTime : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)); - if (tracks.length === 0) { + if (orderedTracks.length === 0) { const placementResult = resolveTrackPlacement({ tracks, elementType, @@ -139,7 +141,11 @@ export function computeDropTarget({ }; } - const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection }); + const trackAtMouse = getTrackAtY({ + mouseY, + tracks: orderedTracks, + verticalDragDirection, + }); if (!trackAtMouse) { const isAboveAllTracks = mouseY < 0; @@ -150,7 +156,7 @@ export function computeDropTarget({ timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], strategy: { type: "preferIndex", - trackIndex: isAboveAllTracks ? 0 : tracks.length - 1, + trackIndex: isAboveAllTracks ? 0 : orderedTracks.length - 1, hoverDirection: isAboveAllTracks ? "above" : "below", createNewTrackOnly: true, }, @@ -171,12 +177,12 @@ export function computeDropTarget({ } const { trackIndex, relativeY } = trackAtMouse; - const track = tracks[trackIndex]; + const track = orderedTracks[trackIndex]; if (targetElementTypes && targetElementTypes.length > 0) { const targetElement = findElementAtPosition({ mouseX, - tracks, + tracks: orderedTracks, trackIndex, targetElementTypes, pixelsPerSecond, diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts index e16690e5..15657bfb 100644 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts @@ -13,7 +13,7 @@ import type { ScalarGraphKeyframeContext, SelectedKeyframeRef, } from "@/lib/animation/types"; -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks, TimelineElement } from "@/lib/timeline"; const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1]; const FLAT_VALUE_EPSILON = 1e-6; @@ -91,10 +91,10 @@ function findElementByKeyframe({ tracks, keyframe, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; keyframe: SelectedKeyframeRef; }): { element: TimelineElement; trackId: string; elementId: string } | null { - for (const track of tracks) { + for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) { if (track.id !== keyframe.trackId) { continue; } @@ -180,7 +180,7 @@ export function resolveGraphEditorSelectionState({ selectedKeyframes, preferredComponentKey, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; selectedKeyframes: SelectedKeyframeRef[]; preferredComponentKey?: string | null; }): GraphEditorSelectionState { diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts index 41df702f..d0303b6f 100644 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts @@ -15,7 +15,8 @@ import { export function useGraphEditorController() { const editor = useEditor(); const renderTracks = useEditor((currentEditor) => - currentEditor.timeline.getRenderTracks(), + currentEditor.timeline.getPreviewTracks() ?? + currentEditor.scenes.getActiveScene().tracks, ); const { selectedKeyframes } = useKeyframeSelection(); const [open, setOpen] = useState(false); diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 11abfd68..883011d1 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -58,7 +58,6 @@ import { } from "./track-layout"; import { SELECTED_TRACK_ROW_CLASS } from "./theme"; import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction"; -import { isMainTrack } from "@/lib/timeline/placement"; import { TimelineToolbar } from "./timeline-toolbar"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; @@ -112,7 +111,15 @@ export function Timeline() { } = useElementSelection(); const editor = useEditor(); const timeline = editor.timeline; - const tracks = useEditor((editor) => editor.timeline.getTracks()); + const scene = useEditor((currentEditor) => currentEditor.scenes.getActiveSceneOrNull()); + const tracks = useMemo( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); + const mainTrackId = scene?.tracks.main.id ?? null; const seek = (time: number) => editor.playback.seek({ time }); const timelineRef = useRef(null); @@ -424,13 +431,13 @@ export function Timeline() { /> @@ -508,6 +515,7 @@ export function Timeline() { {tracks.length > 0 && ( e.timeline.getTracks()); + const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); + const tracks = useMemo( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); const { selectedElements } = useElementSelection(); const tracksWithSelection = useMemo( () => new Set(selectedElements.map((el) => el.trackId)), @@ -650,6 +665,7 @@ function TrackLabelsPanel({ function TimelineTrackRows({ dragElementId, + mainTrackId, zoomLevel, dragState, tracksScrollRef, @@ -665,6 +681,7 @@ function TimelineTrackRows({ dropTarget, }: { dragElementId: string | null; + mainTrackId: string | null; zoomLevel: number; dragState: ElementDragState; tracksScrollRef: React.RefObject; @@ -684,7 +701,14 @@ function TimelineTrackRows({ dropTarget: DropTarget | null; }) { const timeline = useEditor((e) => e.timeline); - const tracks = useEditor((e) => e.timeline.getTracks()); + const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); + const tracks = useMemo( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); const { selectedElements } = useElementSelection(); const tracksWithSelection = useMemo( () => new Set(selectedElements.map((el) => el.trackId)), @@ -779,7 +803,7 @@ function TimelineTrackRows({ ? "Show track" : "Hide track"} - {!isMainTrack(track) && ( + {track.id !== mainTrackId && ( } onClick={(event: React.MouseEvent) => { 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 }); diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index 599b4260..be04a7d3 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -179,11 +179,12 @@ export function useEditorActions() { "split", () => { const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.scenes.getActiveScene().tracks; const elementsToSplit = selectedElements.length > 0 ? selectedElements : getElementsAtTime({ - tracks: editor.timeline.getTracks(), + tracks, time: currentTime, }); @@ -201,11 +202,12 @@ export function useEditorActions() { "split-left", () => { const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.scenes.getActiveScene().tracks; const elementsToSplit = selectedElements.length > 0 ? selectedElements : getElementsAtTime({ - tracks: editor.timeline.getTracks(), + tracks, time: currentTime, }); @@ -233,11 +235,12 @@ export function useEditorActions() { "split-right", () => { const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.scenes.getActiveScene().tracks; const elementsToSplit = selectedElements.length > 0 ? selectedElements : getElementsAtTime({ - tracks: editor.timeline.getTracks(), + tracks, time: currentTime, }); @@ -310,7 +313,12 @@ export function useEditorActions() { useActionHandler( "select-all", () => { - const allElements = editor.timeline.getTracks().flatMap((track) => + const scene = editor.scenes.getActiveScene(); + const allElements = [ + ...scene.tracks.overlay, + scene.tracks.main, + ...scene.tracks.audio, + ].flatMap((track) => track.elements.map((element) => ({ trackId: track.id, elementId: element.id, diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index 6fcd4168..98918bea 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -21,6 +21,7 @@ import { registerCanceller } from "@/lib/cancel-interaction"; import type { DropTarget, ElementDragState, + SceneTracks, TimelineElement, TimelineTrack, } from "@/lib/timeline"; @@ -101,7 +102,7 @@ function getDragDropTarget({ clientY: number; elementId: string; trackId: string; - tracks: TimelineTrack[]; + tracks: SceneTracks; tracksContainerRef: RefObject; tracksScrollRef: RefObject; headerRef?: RefObject; @@ -113,7 +114,11 @@ function getDragDropTarget({ const scrollContainer = tracksScrollRef.current; if (!containerRect || !scrollContainer) return null; - const sourceTrack = tracks.find(({ id }) => id === trackId); + const sourceTrack = [ + ...tracks.overlay, + tracks.main, + ...tracks.audio, + ].find(({ id }) => id === trackId); const movingElement = sourceTrack?.elements.find( ({ id }) => id === elementId, ); @@ -163,7 +168,12 @@ export function useElementInteraction({ }: UseElementInteractionProps) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); - const tracks = editor.timeline.getTracks(); + const sceneTracks = editor.scenes.getActiveScene().tracks; + const tracks = [ + ...sceneTracks.overlay, + sceneTracks.main, + ...sceneTracks.audio, + ]; const { isElementSelected, selectElement, @@ -242,7 +252,7 @@ export function useElementInteraction({ const startSnap = snapElementEdge({ targetTime: frameSnappedTime, elementDuration, - tracks, + tracks: sceneTracks, playheadTime, zoomLevel, excludeElementId: movingElement.id, @@ -252,7 +262,7 @@ export function useElementInteraction({ const endSnap = snapElementEdge({ targetTime: frameSnappedTime, elementDuration, - tracks, + tracks: sceneTracks, playheadTime, zoomLevel, excludeElementId: movingElement.id, @@ -376,7 +386,7 @@ export function useElementInteraction({ clientY, elementId: dragState.elementId, trackId: dragState.trackId, - tracks, + tracks: sceneTracks, tracksContainerRef, tracksScrollRef, headerRef, @@ -436,7 +446,7 @@ export function useElementInteraction({ clientY, elementId: dragState.elementId, trackId: dragState.trackId, - tracks, + tracks: sceneTracks, tracksContainerRef, tracksScrollRef, headerRef, diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts index dec87eef..bb0d880c 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -196,7 +196,7 @@ export function useTimelineElementResize({ const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator); const shouldSnap = snappingEnabled && !isShiftHeldRef.current; if (shouldSnap) { - const tracks = editor.timeline.getTracks(); + const tracks = editor.scenes.getActiveScene().tracks; const playheadTime = editor.playback.getCurrentTime(); const snapPoints = findSnapPoints({ tracks, diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts index 90edbede..3ff1cb21 100644 --- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts +++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts @@ -44,7 +44,7 @@ export function useBookmarkDrag({ }: UseBookmarkDragProps) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); - const tracks = editor.timeline.getTracks(); + const tracks = editor.scenes.getActiveScene().tracks; const activeScene = editor.scenes.getActiveScene(); const bookmarks = activeScene?.bookmarks ?? []; const playheadTime = editor.playback.getCurrentTime(); diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts index 7b9ee2ee..009f0ff6 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -17,7 +17,6 @@ import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline"; import { BatchCommand } from "@/lib/commands"; import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target"; import { getDragData, hasDragData } from "@/lib/drag-data"; -import { isMainTrack } from "@/lib/timeline/placement"; import type { TrackType, DropTarget, ElementType } from "@/lib/timeline"; import type { MediaDragData, @@ -149,13 +148,13 @@ export function useTimelineDragDrop({ ? (dragData as MediaDragData).targetElementTypes : undefined; - const tracks = editor.timeline.getTracks(); + const sceneTracks = editor.scenes.getActiveScene().tracks; const currentTime = editor.playback.getCurrentTime(); const target = computeDropTarget({ elementType, mouseX, mouseY, - tracks, + tracks: sceneTracks, playheadTime: currentTime, isExternalDrop: isExternal, elementDuration: duration, @@ -230,7 +229,11 @@ export function useTimelineDragDrop({ return; } - const tracks = editor.timeline.getTracks(); + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ @@ -267,7 +270,11 @@ export function useTimelineDragDrop({ return; } - const tracks = editor.timeline.getTracks(); + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ @@ -305,7 +312,11 @@ export function useTimelineDragDrop({ return; } - const tracks = editor.timeline.getTracks(); + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ @@ -352,7 +363,11 @@ export function useTimelineDragDrop({ return; } - const tracks = editor.timeline.getTracks(); + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ @@ -380,7 +395,11 @@ export function useTimelineDragDrop({ return; } - const tracks = editor.timeline.getTracks(); + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; const effectTrack = tracks.find((t) => t.type === "effect"); let trackId: string; @@ -447,16 +466,14 @@ export function useTimelineDragDrop({ const duration = createdAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION; - const currentTracks = editor.timeline.getTracks(); + const sceneTracks = editor.scenes.getActiveScene().tracks; const currentTime = editor.playback.getCurrentTime(); - const onlyTrack = currentTracks[0]; const reuseMainTrackId = createdAsset.type !== "audio" && - currentTracks.length === 1 && - !!onlyTrack && - isMainTrack(onlyTrack) && - onlyTrack.elements.length === 0 - ? onlyTrack.id + sceneTracks.overlay.length === 0 && + sceneTracks.audio.length === 0 && + sceneTracks.main.elements.length === 0 + ? sceneTracks.main.id : null; const dropTarget = reuseMainTrackId ? null @@ -464,7 +481,7 @@ export function useTimelineDragDrop({ elementType: createdAsset.type, mouseX, mouseY, - tracks: currentTracks, + tracks: sceneTracks, playheadTime: currentTime, isExternalDrop: true, elementDuration: duration, @@ -488,7 +505,11 @@ export function useTimelineDragDrop({ trackId = addTrackCmd.getTrackId(); editor.command.execute({ command: addTrackCmd }); } else { - trackId = currentTracks[dropTarget.trackIndex]?.id; + trackId = [ + ...sceneTracks.overlay, + sceneTracks.main, + ...sceneTracks.audio, + ][dropTarget.trackIndex]?.id; } } diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts index 70364e90..1886222f 100644 --- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts +++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts @@ -90,7 +90,7 @@ export function useTimelinePlayhead({ const shouldSnap = snappingEnabled && !isShiftHeldRef.current; const time = (() => { if (!shouldSnap) return frameTime; - const tracks = editor.timeline.getTracks(); + const tracks = editor.scenes.getActiveScene().tracks; const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; const snapPoints = findSnapPoints({ tracks, diff --git a/apps/web/src/hooks/use-element-preview.ts b/apps/web/src/hooks/use-element-preview.ts index d717143d..4f28be83 100644 --- a/apps/web/src/hooks/use-element-preview.ts +++ b/apps/web/src/hooks/use-element-preview.ts @@ -1,5 +1,5 @@ import { useEditor } from "@/hooks/use-editor"; -import type { TimelineElement } from "@/lib/timeline"; +import { findTrackInSceneTracks, type TimelineElement } from "@/lib/timeline"; /** * Subscribes to render tracks and returns the live (preview-aware) version of @@ -18,13 +18,14 @@ export function useElementPreview({ fallback: T; }) { const editor = useEditor(); - useEditor((e) => e.timeline.getRenderTracks()); + useEditor((e) => e.timeline.getPreviewTracks()); + const previewTracks = editor.timeline.getPreviewTracks(); const renderElement = - (editor.timeline - .getRenderTracks() - .find((t) => t.id === trackId) - ?.elements.find((el) => el.id === elementId) as T | undefined) ?? + (findTrackInSceneTracks({ + tracks: previewTracks ?? editor.scenes.getActiveScene().tracks, + trackId, + })?.elements.find((element) => element.id === elementId) as T | undefined) ?? fallback; const previewUpdates = (updates: Partial) => diff --git a/apps/web/src/hooks/use-mask-handles.ts b/apps/web/src/hooks/use-mask-handles.ts index 63fdd674..d2400ee0 100644 --- a/apps/web/src/hooks/use-mask-handles.ts +++ b/apps/web/src/hooks/use-mask-handles.ts @@ -45,7 +45,9 @@ export function useMaskHandles({ null, ); - const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const tracks = useEditor( + (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, + ); const currentTime = useEditor((e) => e.playback.getCurrentTime()); const mediaAssets = useEditor((e) => e.media.getAssets()); const canvasSize = useEditor( diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts index 90fa0450..2a3e5c28 100644 --- a/apps/web/src/hooks/use-preview-interaction.ts +++ b/apps/web/src/hooks/use-preview-interaction.ts @@ -180,7 +180,7 @@ export function usePreviewInteraction({ ({ clientX, clientY }: React.MouseEvent) => { if (editingText || isMaskMode) return; - const tracks = editor.timeline.getTracks(); + const tracks = editor.scenes.getActiveScene().tracks; const currentTime = editor.playback.getCurrentTime(); const mediaAssets = editor.media.getAssets(); const canvasSize = editor.project.getActive().settings.canvasSize; @@ -238,7 +238,7 @@ export function usePreviewInteraction({ if (isMaskMode) return; if (button !== 0) return; - const tracks = editor.timeline.getTracks(); + const tracks = editor.scenes.getActiveScene().tracks; const currentTime = editor.playback.getCurrentTime(); const mediaAssets = editor.media.getAssets(); const canvasSize = editor.project.getActive().settings.canvasSize; diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts index 659aa598..7495a387 100644 --- a/apps/web/src/hooks/use-transform-handles.ts +++ b/apps/web/src/hooks/use-transform-handles.ts @@ -132,7 +132,9 @@ export function useTransformHandles({ ); const selectedElements = useEditor((e) => e.selection.getSelectedElements()); - const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const tracks = useEditor( + (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, + ); const currentTime = useEditor((e) => e.playback.getCurrentTime()); const currentTimeRef = useRef(currentTime); currentTimeRef.current = currentTime; diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts index 8b3067a1..12e0906e 100644 --- a/apps/web/src/lib/commands/media/add-media-asset.ts +++ b/apps/web/src/lib/commands/media/add-media-asset.ts @@ -54,11 +54,15 @@ export class AddMediaAssetCommand extends Command { assets: currentAssets.filter((asset) => asset.id !== this.assetId), }); - const currentTracks = editor.timeline.getTracks(); + const currentTracks = editor.scenes.getActiveScene().tracks; const orphanedElements: Array<{ trackId: string; elementId: string }> = []; - for (const track of currentTracks) { + for (const track of [ + ...currentTracks.overlay, + currentTracks.main, + ...currentTracks.audio, + ]) { for (const element of track.elements) { if (hasMediaId(element) && element.mediaId === this.assetId) { orphanedElements.push({ diff --git a/apps/web/src/lib/commands/media/remove-media-asset.ts b/apps/web/src/lib/commands/media/remove-media-asset.ts index 1a938de7..5601a7e3 100644 --- a/apps/web/src/lib/commands/media/remove-media-asset.ts +++ b/apps/web/src/lib/commands/media/remove-media-asset.ts @@ -1,99 +1,103 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { MediaAsset } from "@/lib/media/types"; -import { storageService } from "@/services/storage/service"; -import { videoCache } from "@/services/video-cache/service"; -import { hasMediaId } from "@/lib/timeline/element-utils"; -import type { TimelineTrack } from "@/lib/timeline"; - -export class RemoveMediaAssetCommand extends Command { - private savedAssets: MediaAsset[] | null = null; - private savedTracks: TimelineTrack[] | null = null; - private removedAsset: MediaAsset | null = null; - - constructor( - private projectId: string, - private assetId: string, - ) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const assets = editor.media.getAssets(); - - this.savedAssets = [...assets]; - this.savedTracks = editor.timeline.getTracks(); - - this.removedAsset = - assets.find((media) => media.id === this.assetId) ?? null; - - if (!this.removedAsset) { - console.error("Media asset not found:", this.assetId); - return; - } - - if (this.removedAsset.url) { - URL.revokeObjectURL(this.removedAsset.url); - } - if (this.removedAsset.thumbnailUrl) { - URL.revokeObjectURL(this.removedAsset.thumbnailUrl); - } - - videoCache.clearVideo({ mediaId: this.assetId }); - - editor.media.setAssets({ - assets: assets.filter((media) => media.id !== this.assetId), - }); - - const elementsToRemove: Array<{ trackId: string; elementId: string }> = []; - - for (const track of this.savedTracks) { - for (const element of track.elements) { - if (hasMediaId(element) && element.mediaId === this.assetId) { - elementsToRemove.push({ trackId: track.id, elementId: element.id }); - } - } - } - - if (elementsToRemove.length > 0) { - editor.timeline.deleteElements({ elements: elementsToRemove }); - } - - storageService - .deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) - .catch((error) => { - console.error("Failed to delete media item:", error); - }); - } - - undo(): void { - const editor = EditorCore.getInstance(); - - if (this.savedAssets && this.removedAsset) { - const restoredAsset: MediaAsset = { - ...this.removedAsset, - url: URL.createObjectURL(this.removedAsset.file), - }; - - editor.media.setAssets({ - assets: this.savedAssets.map((a) => - a.id === this.assetId ? restoredAsset : a, - ), - }); - - storageService - .saveMediaAsset({ - projectId: this.projectId, - mediaAsset: restoredAsset, - }) - .catch((error) => { - console.error("Failed to restore media item on undo:", error); - }); - } - - if (this.savedTracks) { - editor.timeline.updateTracks(this.savedTracks); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { MediaAsset } from "@/lib/media/types"; +import { storageService } from "@/services/storage/service"; +import { videoCache } from "@/services/video-cache/service"; +import { hasMediaId } from "@/lib/timeline/element-utils"; +import type { SceneTracks } from "@/lib/timeline"; + +export class RemoveMediaAssetCommand extends Command { + private savedAssets: MediaAsset[] | null = null; + private savedTracks: SceneTracks | null = null; + private removedAsset: MediaAsset | null = null; + + constructor( + private projectId: string, + private assetId: string, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const assets = editor.media.getAssets(); + + this.savedAssets = [...assets]; + this.savedTracks = editor.scenes.getActiveScene().tracks; + + this.removedAsset = + assets.find((media) => media.id === this.assetId) ?? null; + + if (!this.removedAsset) { + console.error("Media asset not found:", this.assetId); + return; + } + + if (this.removedAsset.url) { + URL.revokeObjectURL(this.removedAsset.url); + } + if (this.removedAsset.thumbnailUrl) { + URL.revokeObjectURL(this.removedAsset.thumbnailUrl); + } + + videoCache.clearVideo({ mediaId: this.assetId }); + + editor.media.setAssets({ + assets: assets.filter((media) => media.id !== this.assetId), + }); + + const elementsToRemove: Array<{ trackId: string; elementId: string }> = []; + + for (const track of [ + ...this.savedTracks.overlay, + this.savedTracks.main, + ...this.savedTracks.audio, + ]) { + for (const element of track.elements) { + if (hasMediaId(element) && element.mediaId === this.assetId) { + elementsToRemove.push({ trackId: track.id, elementId: element.id }); + } + } + } + + if (elementsToRemove.length > 0) { + editor.timeline.deleteElements({ elements: elementsToRemove }); + } + + storageService + .deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) + .catch((error) => { + console.error("Failed to delete media item:", error); + }); + } + + undo(): void { + const editor = EditorCore.getInstance(); + + if (this.savedAssets && this.removedAsset) { + const restoredAsset: MediaAsset = { + ...this.removedAsset, + url: URL.createObjectURL(this.removedAsset.file), + }; + + editor.media.setAssets({ + assets: this.savedAssets.map((a) => + a.id === this.assetId ? restoredAsset : a, + ), + }); + + storageService + .saveMediaAsset({ + projectId: this.projectId, + mediaAsset: restoredAsset, + }) + .catch((error) => { + console.error("Failed to restore media item on undo:", error); + }); + } + + if (this.savedTracks) { + editor.timeline.updateTracks(this.savedTracks); + } + } +} diff --git a/apps/web/src/lib/commands/preview-tracker.ts b/apps/web/src/lib/commands/preview-tracker.ts index 38ef4edd..68c8dee3 100644 --- a/apps/web/src/lib/commands/preview-tracker.ts +++ b/apps/web/src/lib/commands/preview-tracker.ts @@ -1,23 +1,23 @@ -export class PreviewTracker { - private snapshot: T | null = null; - - begin({ state }: { state: T }): void { - if (this.snapshot === null) { - this.snapshot = structuredClone(state); - } - } - - isActive(): boolean { - return this.snapshot !== null; - } - - getSnapshot(): T | null { - return this.snapshot; - } - - end(): T | null { - const snapshot = this.snapshot; - this.snapshot = null; - return snapshot; - } -} +export class PreviewTracker { + private snapshot: T | null = null; + + begin({ state }: { state: T }): void { + if (this.snapshot === null) { + this.snapshot = structuredClone(state); + } + } + + isActive(): boolean { + return this.snapshot !== null; + } + + getSnapshot(): T | null { + return this.snapshot; + } + + end(): T | null { + const snapshot = this.snapshot; + this.snapshot = null; + return snapshot; + } +} diff --git a/apps/web/src/lib/commands/project/update-project-settings.ts b/apps/web/src/lib/commands/project/update-project-settings.ts index b4cbb125..32258e12 100644 --- a/apps/web/src/lib/commands/project/update-project-settings.ts +++ b/apps/web/src/lib/commands/project/update-project-settings.ts @@ -1,46 +1,46 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TProject, TProjectSettings } from "@/lib/project/types"; - -export class UpdateProjectSettingsCommand extends Command { - private savedSettings: TProjectSettings | null = null; - private savedUpdatedAt: Date | null = null; - - constructor(private updates: Partial) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - this.savedSettings = activeProject.settings; - this.savedUpdatedAt = activeProject.metadata.updatedAt; - - const updatedProject: TProject = { - ...activeProject, - settings: { ...activeProject.settings, ...this.updates }, - metadata: { ...activeProject.metadata, updatedAt: new Date() }, - }; - - editor.project.setActiveProject({ project: updatedProject }); - editor.save.markDirty(); - } - - undo(): void { - if (!this.savedSettings || !this.savedUpdatedAt) return; - const editor = EditorCore.getInstance(); - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const updatedProject: TProject = { - ...activeProject, - settings: this.savedSettings, - metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt }, - }; - - editor.project.setActiveProject({ project: updatedProject }); - editor.save.markDirty(); - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TProject, TProjectSettings } from "@/lib/project/types"; + +export class UpdateProjectSettingsCommand extends Command { + private savedSettings: TProjectSettings | null = null; + private savedUpdatedAt: Date | null = null; + + constructor(private updates: Partial) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + this.savedSettings = activeProject.settings; + this.savedUpdatedAt = activeProject.metadata.updatedAt; + + const updatedProject: TProject = { + ...activeProject, + settings: { ...activeProject.settings, ...this.updates }, + metadata: { ...activeProject.metadata, updatedAt: new Date() }, + }; + + editor.project.setActiveProject({ project: updatedProject }); + editor.save.markDirty(); + } + + undo(): void { + if (!this.savedSettings || !this.savedUpdatedAt) return; + const editor = EditorCore.getInstance(); + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const updatedProject: TProject = { + ...activeProject, + settings: this.savedSettings, + metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt }, + }; + + editor.project.setActiveProject({ project: updatedProject }); + editor.save.markDirty(); + } +} diff --git a/apps/web/src/lib/commands/scene/delete-scene.ts b/apps/web/src/lib/commands/scene/delete-scene.ts index 1c433687..ab269b12 100644 --- a/apps/web/src/lib/commands/scene/delete-scene.ts +++ b/apps/web/src/lib/commands/scene/delete-scene.ts @@ -1,59 +1,59 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes"; - -export class DeleteSceneCommand extends Command { - private savedScenes: TScene[] | null = null; - private savedActiveSceneId: string | null = null; - private deletedScene: TScene | null = null; - - constructor(private sceneId: string) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const scenes = editor.scenes.getScenes(); - const activeScene = editor.scenes.getActiveScene(); - - this.savedScenes = [...scenes]; - this.savedActiveSceneId = activeScene?.id ?? null; - - this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null; - - if (!this.deletedScene) { - console.error("Scene not found:", this.sceneId); - return; - } - - const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene }); - if (!canDelete) { - console.error("Cannot delete scene:", reason); - return; - } - - const updatedScenes = scenes.filter((s) => s.id !== this.sceneId); - - const newActiveScene = getFallbackSceneAfterDelete({ - scenes: updatedScenes, - deletedSceneId: this.sceneId, - currentSceneId: activeScene?.id ?? null, - }); - - editor.scenes.setScenes({ - scenes: updatedScenes, - activeSceneId: newActiveScene?.id, - }); - } - - undo(): void { - if (this.savedScenes && this.deletedScene) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ - scenes: this.savedScenes, - activeSceneId: this.savedActiveSceneId ?? undefined, - }); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/lib/timeline"; +import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes"; + +export class DeleteSceneCommand extends Command { + private savedScenes: TScene[] | null = null; + private savedActiveSceneId: string | null = null; + private deletedScene: TScene | null = null; + + constructor(private sceneId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const scenes = editor.scenes.getScenes(); + const activeScene = editor.scenes.getActiveScene(); + + this.savedScenes = [...scenes]; + this.savedActiveSceneId = activeScene?.id ?? null; + + this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null; + + if (!this.deletedScene) { + console.error("Scene not found:", this.sceneId); + return; + } + + const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene }); + if (!canDelete) { + console.error("Cannot delete scene:", reason); + return; + } + + const updatedScenes = scenes.filter((s) => s.id !== this.sceneId); + + const newActiveScene = getFallbackSceneAfterDelete({ + scenes: updatedScenes, + deletedSceneId: this.sceneId, + currentSceneId: activeScene?.id ?? null, + }); + + editor.scenes.setScenes({ + scenes: updatedScenes, + activeSceneId: newActiveScene?.id, + }); + } + + undo(): void { + if (this.savedScenes && this.deletedScene) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ + scenes: this.savedScenes, + activeSceneId: this.savedActiveSceneId ?? undefined, + }); + } + } +} diff --git a/apps/web/src/lib/commands/scene/index.ts b/apps/web/src/lib/commands/scene/index.ts index b680108d..2b843c99 100644 --- a/apps/web/src/lib/commands/scene/index.ts +++ b/apps/web/src/lib/commands/scene/index.ts @@ -1,7 +1,7 @@ -export { CreateSceneCommand } from "./create-scene"; -export { DeleteSceneCommand } from "./delete-scene"; -export { RenameSceneCommand } from "./rename-scene"; -export { ToggleBookmarkCommand } from "./toggle-bookmark"; -export { RemoveBookmarkCommand } from "./remove-bookmark"; -export { UpdateBookmarkCommand } from "./update-bookmark"; -export { MoveBookmarkCommand } from "./move-bookmark"; +export { CreateSceneCommand } from "./create-scene"; +export { DeleteSceneCommand } from "./delete-scene"; +export { RenameSceneCommand } from "./rename-scene"; +export { ToggleBookmarkCommand } from "./toggle-bookmark"; +export { RemoveBookmarkCommand } from "./remove-bookmark"; +export { UpdateBookmarkCommand } from "./update-bookmark"; +export { MoveBookmarkCommand } from "./move-bookmark"; diff --git a/apps/web/src/lib/commands/scene/move-bookmark.ts b/apps/web/src/lib/commands/scene/move-bookmark.ts index f9b0664c..103caa74 100644 --- a/apps/web/src/lib/commands/scene/move-bookmark.ts +++ b/apps/web/src/lib/commands/scene/move-bookmark.ts @@ -1,59 +1,59 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks"; - -export class MoveBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - - constructor( - private fromTime: number, - private toTime: number, - ) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - const fromFrameTime = getFrameTime({ - time: this.fromTime, - fps: activeProject.settings.fps, - }); - const toFrameTime = getFrameTime({ - time: this.toTime, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = moveBookmarkInArray({ - bookmarks: activeScene.bookmarks, - fromTime: fromFrameTime, - toTime: toFrameTime, - }); - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/lib/timeline"; +import { updateSceneInArray } from "@/lib/scenes"; +import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks"; + +export class MoveBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + + constructor( + private fromTime: number, + private toTime: number, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + const fromFrameTime = getFrameTime({ + time: this.fromTime, + fps: activeProject.settings.fps, + }); + const toFrameTime = getFrameTime({ + time: this.toTime, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = moveBookmarkInArray({ + bookmarks: activeScene.bookmarks, + fromTime: fromFrameTime, + toTime: toFrameTime, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/lib/commands/scene/remove-bookmark.ts b/apps/web/src/lib/commands/scene/remove-bookmark.ts index cee41d36..5eac920f 100644 --- a/apps/web/src/lib/commands/scene/remove-bookmark.ts +++ b/apps/web/src/lib/commands/scene/remove-bookmark.ts @@ -1,59 +1,59 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { - getFrameTime, - removeBookmarkFromArray, -} from "@/lib/timeline/bookmarks"; - -export class RemoveBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - private frameTime: number = 0; - - constructor(private time: number) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - this.frameTime = getFrameTime({ - time: this.time, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = removeBookmarkFromArray({ - bookmarks: activeScene.bookmarks, - frameTime: this.frameTime, - }); - - if (updatedBookmarks.length === activeScene.bookmarks.length) { - return; - } - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/lib/timeline"; +import { updateSceneInArray } from "@/lib/scenes"; +import { + getFrameTime, + removeBookmarkFromArray, +} from "@/lib/timeline/bookmarks"; + +export class RemoveBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + private frameTime: number = 0; + + constructor(private time: number) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + this.frameTime = getFrameTime({ + time: this.time, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = removeBookmarkFromArray({ + bookmarks: activeScene.bookmarks, + frameTime: this.frameTime, + }); + + if (updatedBookmarks.length === activeScene.bookmarks.length) { + return; + } + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/lib/commands/scene/rename-scene.ts b/apps/web/src/lib/commands/scene/rename-scene.ts index 06d0414f..ccdc85d6 100644 --- a/apps/web/src/lib/commands/scene/rename-scene.ts +++ b/apps/web/src/lib/commands/scene/rename-scene.ts @@ -1,46 +1,46 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; - -export class RenameSceneCommand extends Command { - private savedScenes: TScene[] | null = null; - private previousName: string | null = null; - - constructor( - private sceneId: string, - private newName: string, - ) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const scenes = editor.scenes.getScenes(); - - this.savedScenes = [...scenes]; - - const scene = scenes.find((s) => s.id === this.sceneId); - if (!scene) { - console.error("Scene not found:", this.sceneId); - return; - } - - this.previousName = scene.name; - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: this.sceneId, - updates: { name: this.newName, updatedAt: new Date() }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes && this.previousName !== null) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/lib/timeline"; +import { updateSceneInArray } from "@/lib/scenes"; + +export class RenameSceneCommand extends Command { + private savedScenes: TScene[] | null = null; + private previousName: string | null = null; + + constructor( + private sceneId: string, + private newName: string, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const scenes = editor.scenes.getScenes(); + + this.savedScenes = [...scenes]; + + const scene = scenes.find((s) => s.id === this.sceneId); + if (!scene) { + console.error("Scene not found:", this.sceneId); + return; + } + + this.previousName = scene.name; + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: this.sceneId, + updates: { name: this.newName, updatedAt: new Date() }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes && this.previousName !== null) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/lib/commands/scene/toggle-bookmark.ts b/apps/web/src/lib/commands/scene/toggle-bookmark.ts index 5df599ad..cb4fa655 100644 --- a/apps/web/src/lib/commands/scene/toggle-bookmark.ts +++ b/apps/web/src/lib/commands/scene/toggle-bookmark.ts @@ -1,52 +1,52 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks"; - -export class ToggleBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - private frameTime: number = 0; - - constructor(private time: number) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - this.frameTime = getFrameTime({ - time: this.time, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = toggleBookmarkInArray({ - bookmarks: activeScene.bookmarks, - frameTime: this.frameTime, - }); - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/lib/timeline"; +import { updateSceneInArray } from "@/lib/scenes"; +import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks"; + +export class ToggleBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + private frameTime: number = 0; + + constructor(private time: number) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + this.frameTime = getFrameTime({ + time: this.time, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = toggleBookmarkInArray({ + bookmarks: activeScene.bookmarks, + frameTime: this.frameTime, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/lib/commands/scene/update-bookmark.ts b/apps/web/src/lib/commands/scene/update-bookmark.ts index f383ab5d..1d1c7283 100644 --- a/apps/web/src/lib/commands/scene/update-bookmark.ts +++ b/apps/web/src/lib/commands/scene/update-bookmark.ts @@ -1,55 +1,55 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { Bookmark, TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks"; - -export class UpdateBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - - constructor( - private time: number, - private updates: Partial>, - ) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - const frameTime = getFrameTime({ - time: this.time, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = updateBookmarkInArray({ - bookmarks: activeScene.bookmarks, - frameTime, - updates: this.updates, - }); - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { Bookmark, TScene } from "@/lib/timeline"; +import { updateSceneInArray } from "@/lib/scenes"; +import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks"; + +export class UpdateBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + + constructor( + private time: number, + private updates: Partial>, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + const frameTime = getFrameTime({ + time: this.time, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = updateBookmarkInArray({ + bookmarks: activeScene.bookmarks, + frameTime, + updates: this.updates, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index 9fa4b83b..6ccf96fb 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -1,21 +1,16 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { - TimelineTrack, + SceneTracks, TimelineElement, ClipboardItem, } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; -import { - applyPlacement, - resolveTrackPlacement, - isMainTrack, - enforceMainTrackStart, -} from "@/lib/timeline/placement"; +import { applyPlacement, resolveTrackPlacement, enforceMainTrackStart } from "@/lib/timeline/placement"; import { cloneAnimations } from "@/lib/animation"; export class PasteCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private pastedElements: { trackId: string; elementId: string }[] = []; private readonly time: number; private readonly clipboardItems: ClipboardItem[]; @@ -36,14 +31,14 @@ export class PasteCommand extends Command { if (this.clipboardItems.length === 0) return undefined; const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; this.pastedElements = []; const minStart = Math.min( ...this.clipboardItems.map((item) => item.element.startTime), ); - let updatedTracks = [...this.savedState]; + let updatedTracks = this.savedState; const itemsByTrackId = groupClipboardItemsByTrackId({ clipboardItems: this.clipboardItems, }); @@ -60,9 +55,11 @@ export class PasteCommand extends Command { } const trackType = items[0].trackType; - const sourceTrackIndex = updatedTracks.findIndex( - (track) => track.id === trackId, - ); + const sourceTrackIndex = [ + ...updatedTracks.overlay, + updatedTracks.main, + ...updatedTracks.audio, + ].findIndex((track) => track.id === trackId); const placementResult = resolveTrackPlacement({ tracks: updatedTracks, trackType, @@ -78,8 +75,15 @@ export class PasteCommand extends Command { let elementsForPlacement = elementsToAdd; if (placementResult.kind === "existingTrack") { - const targetTrack = updatedTracks[placementResult.trackIndex]; - if (isMainTrack(targetTrack)) { + const targetTrack = + placementResult.trackIndex < updatedTracks.overlay.length + ? updatedTracks.overlay[placementResult.trackIndex] + : placementResult.trackIndex === updatedTracks.overlay.length + ? updatedTracks.main + : updatedTracks.audio[ + placementResult.trackIndex - updatedTracks.overlay.length - 1 + ]; + if (targetTrack?.id === updatedTracks.main.id) { const earliestElement = elementsToAdd.reduce((earliest, element) => element.startTime < earliest.startTime ? element : earliest, ); diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts index c8cc4494..8d93c6ba 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -1,9 +1,28 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks } from "@/lib/timeline"; import { EditorCore } from "@/core"; +import type { TimelineTrack } from "@/lib/timeline"; + +function removeTrackElements({ + track, + elements, +}: { + track: TTrack; + elements: { trackId: string; elementId: string }[]; +}): TTrack { + const nextElements = track.elements.filter( + (element) => + !elements.some( + (target) => + target.trackId === track.id && target.elementId === element.id, + ), + ); + + return { ...track, elements: nextElements } as TTrack; +} export class DeleteElementsCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly elements: { trackId: string; elementId: string }[]; constructor({ @@ -17,28 +36,20 @@ export class DeleteElementsCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = this.savedState.map((track) => { - const elementsToDeleteOnTrack = this.elements.filter( - (target) => target.trackId === track.id, - ); - - if (elementsToDeleteOnTrack.length === 0) { - return track; - } - - const elements = track.elements.filter( - (element) => - !this.elements.some( - (target) => - target.trackId === track.id && - target.elementId === element.id, - ), - ); - - return { ...track, elements } as typeof track; - }); + const updatedTracks: SceneTracks = { + overlay: this.savedState.overlay.map((track) => + removeTrackElements({ track, elements: this.elements }), + ), + main: removeTrackElements({ + track: this.savedState.main, + elements: this.elements, + }), + audio: this.savedState.audio.map((track) => + removeTrackElements({ track, elements: this.elements }), + ), + }; editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts index 35be3da2..ab7a4a25 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -1,5 +1,5 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks, TimelineElement } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement"; @@ -11,7 +11,7 @@ interface DuplicateElementsParams { export class DuplicateElementsCommand extends Command { private duplicatedElements: { trackId: string; elementId: string }[] = []; - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private elements: DuplicateElementsParams["elements"]; constructor({ elements }: DuplicateElementsParams) { @@ -21,12 +21,16 @@ export class DuplicateElementsCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; this.duplicatedElements = []; - let updatedTracks = [...this.savedState]; + let updatedTracks = this.savedState; - for (const track of this.savedState) { + for (const track of [ + ...this.savedState.overlay, + this.savedState.main, + ...this.savedState.audio, + ]) { const elementsToDuplicate = this.elements.filter( (elementEntry) => elementEntry.trackId === track.id, ); diff --git a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts index 0c117f3b..902ca341 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts @@ -1,7 +1,7 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; +import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; +import type { SceneTracks, VisualElement } from "@/lib/timeline"; import { buildDefaultEffectInstance } from "@/lib/effects"; function addEffectToElement({ @@ -17,7 +17,7 @@ function addEffectToElement({ } export class AddClipEffectCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private effectId: string | null = null; private readonly trackId: string; private readonly elementId: string; @@ -40,9 +40,9 @@ export class AddClipEffectCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = updateElementInTracks({ + const updatedTracks = updateElementInSceneTracks({ tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, diff --git a/apps/web/src/lib/commands/timeline/element/effects/index.ts b/apps/web/src/lib/commands/timeline/element/effects/index.ts index b03c51e6..133ce661 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/index.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/index.ts @@ -1,5 +1,5 @@ -export { AddClipEffectCommand } from "./add-effect"; -export { RemoveClipEffectCommand } from "./remove-effect"; -export { ToggleClipEffectCommand } from "./toggle-effect"; -export { UpdateClipEffectParamsCommand } from "./update-effect-params"; -export { ReorderClipEffectsCommand } from "./reorder-effect"; +export { AddClipEffectCommand } from "./add-effect"; +export { RemoveClipEffectCommand } from "./remove-effect"; +export { ToggleClipEffectCommand } from "./toggle-effect"; +export { UpdateClipEffectParamsCommand } from "./update-effect-params"; +export { ReorderClipEffectsCommand } from "./reorder-effect"; diff --git a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts index f876a1d1..f453f2cc 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts @@ -1,7 +1,7 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; +import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; +import type { SceneTracks, VisualElement } from "@/lib/timeline"; function removeEffectFromElement({ element, @@ -16,7 +16,7 @@ function removeEffectFromElement({ } export class RemoveClipEffectCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly effectId: string; @@ -38,9 +38,9 @@ export class RemoveClipEffectCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = updateElementInTracks({ + const updatedTracks = updateElementInSceneTracks({ tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, diff --git a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts index e63430be..28b7c4e1 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts @@ -1,7 +1,7 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; +import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; +import type { SceneTracks, VisualElement } from "@/lib/timeline"; function reorderEffectsOnElement({ element, @@ -19,7 +19,7 @@ function reorderEffectsOnElement({ } export class ReorderClipEffectsCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly fromIndex: number; @@ -45,9 +45,9 @@ export class ReorderClipEffectsCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = updateElementInTracks({ + const updatedTracks = updateElementInSceneTracks({ tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, diff --git a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts index 0329f1a0..a703cd94 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts @@ -1,7 +1,7 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; +import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; +import type { SceneTracks, VisualElement } from "@/lib/timeline"; export function toggleEffectOnElement({ element, @@ -18,7 +18,7 @@ export function toggleEffectOnElement({ } export class ToggleClipEffectCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly effectId: string; @@ -40,9 +40,9 @@ export class ToggleClipEffectCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = updateElementInTracks({ + const updatedTracks = updateElementInSceneTracks({ tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, diff --git a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts index 058706ec..5e75c1f0 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts @@ -1,8 +1,8 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; import type { ParamValues } from "@/lib/params"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; +import type { SceneTracks, VisualElement } from "@/lib/timeline"; function updateEffectParamsOnElement({ element, @@ -32,7 +32,7 @@ function updateEffectParamsOnElement({ } export class UpdateClipEffectParamsCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly effectId: string; @@ -58,9 +58,9 @@ export class UpdateClipEffectParamsCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = updateElementInTracks({ + const updatedTracks = updateElementInSceneTracks({ tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts index eabdc385..ff365801 100644 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -1,11 +1,11 @@ -export { InsertElementCommand } from "./insert-element"; -export { DeleteElementsCommand } from "./delete-elements"; -export { DuplicateElementsCommand } from "./duplicate-elements"; -export { SplitElementsCommand } from "./split-elements"; -export { UpdateElementsCommand } from "./update-elements"; -export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation"; -export { MoveElementCommand } from "./move-elements"; - -export * from "./keyframes"; -export * from "./effects"; -export * from "./masks"; +export { InsertElementCommand } from "./insert-element"; +export { DeleteElementsCommand } from "./delete-elements"; +export { DuplicateElementsCommand } from "./duplicate-elements"; +export { SplitElementsCommand } from "./split-elements"; +export { UpdateElementsCommand } from "./update-elements"; +export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation"; +export { MoveElementCommand } from "./move-elements"; + +export * from "./keyframes"; +export * from "./effects"; +export * from "./masks"; diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts index b06631d8..0ac11ebd 100644 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -2,6 +2,7 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { CreateTimelineElement, + SceneTracks, TimelineTrack, TimelineElement, TrackType, @@ -30,7 +31,7 @@ export interface InsertElementParams { export class InsertElementCommand extends Command { private elementId: string; - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private targetTrackId: string | null = null; constructor({ element, placement }: InsertElementParams) { @@ -45,21 +46,22 @@ export class InsertElementCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - if (!this.savedState) { - console.error("Tracks not available"); - return; - } + this.savedState = editor.scenes.getActiveScene().tracks; if (!this.validateElementBasics({ element: this.element })) { return; } - const totalElementsInTimeline = this.savedState.reduce( - (total, t) => total + t.elements.length, - 0, - ); + const totalElementsInTimeline = + this.savedState.main.elements.length + + this.savedState.overlay.reduce( + (total, track) => total + track.elements.length, + 0, + ) + + this.savedState.audio.reduce( + (total, track) => total + track.elements.length, + 0, + ); const isFirstElement = totalElementsInTimeline === 0; const newElement = this.buildElement({ element: this.element }); @@ -194,9 +196,9 @@ export class InsertElementCommand extends Command { tracks, element, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; element: TimelineElement; - }): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null { + }): { updatedTracks: SceneTracks; targetTrackId: string } | null { const placement = this.placement; if ( @@ -231,7 +233,11 @@ export class InsertElementCommand extends Command { }); if (!placementResult) { if (placement.mode === "explicit") { - const targetTrack = tracks.find((track) => track.id === placement.trackId); + const targetTrack = + tracks.main.id === placement.trackId + ? tracks.main + : tracks.overlay.find((track) => track.id === placement.trackId) ?? + tracks.audio.find((track) => track.id === placement.trackId); if (!targetTrack) { console.error("Track not found:", placement.trackId); return null; @@ -256,7 +262,7 @@ export class InsertElementCommand extends Command { } : element; - return applyPlacement({ + const appliedPlacement = applyPlacement({ tracks, placementResult, elements: [elementToPlace], @@ -265,5 +271,13 @@ export class InsertElementCommand extends Command { ? placement.insertIndex : undefined, }); + if (!appliedPlacement) { + return null; + } + + return { + updatedTracks: appliedPlacement.updatedTracks, + targetTrackId: appliedPlacement.targetTrackId, + }; } } diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts index 36f8a6b1..b1ef4aef 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts @@ -1,12 +1,12 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/lib/commands/base-command"; import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; -import { updateElementInTracks } from "@/lib/timeline"; +import { updateElementInSceneTracks } from "@/lib/timeline"; import { isVisualElement } from "@/lib/timeline/element-utils"; -import type { TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks } from "@/lib/timeline"; export class RemoveEffectParamKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly effectId: string; @@ -36,9 +36,9 @@ export class RemoveEffectParamKeyframeCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = updateElementInTracks({ + const updatedTracks = updateElementInSceneTracks({ tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts index 35aa0860..c757055d 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts @@ -7,9 +7,9 @@ import { resolveAnimationTarget, } from "@/lib/animation"; import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { updateElementInTracks } from "@/lib/timeline"; +import { updateElementInSceneTracks } from "@/lib/timeline"; import type { AnimationPath, AnimationValue } from "@/lib/animation/types"; -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks, TimelineElement } from "@/lib/timeline"; function sampleValueBeforeRemoval({ element, @@ -86,7 +86,7 @@ function removeKeyframeAndPersist({ } export class RemoveKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly propertyPath: AnimationPath; @@ -112,9 +112,9 @@ export class RemoveKeyframeCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = updateElementInTracks({ + const updatedTracks = updateElementInSceneTracks({ tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts index 89491823..ad7f5651 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts @@ -1,12 +1,12 @@ import { EditorCore } from "@/core"; import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation"; import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { updateElementInTracks } from "@/lib/timeline"; +import { updateElementInSceneTracks } from "@/lib/timeline"; import type { AnimationPath } from "@/lib/animation/types"; -import type { TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks } from "@/lib/timeline"; export class RetimeKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly propertyPath: AnimationPath; @@ -36,9 +36,9 @@ export class RetimeKeyframeCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = updateElementInTracks({ + const updatedTracks = updateElementInSceneTracks({ tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts index f1eaab9d..9a4c2304 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts @@ -4,15 +4,15 @@ import { updateScalarKeyframeCurve, } from "@/lib/animation"; import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { updateElementInTracks } from "@/lib/timeline"; +import { updateElementInSceneTracks } from "@/lib/timeline"; import type { AnimationPath, ScalarCurveKeyframePatch, } from "@/lib/animation/types"; -import type { TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks } from "@/lib/timeline"; export class UpdateScalarKeyframeCurveCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly propertyPath: AnimationPath; @@ -46,9 +46,9 @@ export class UpdateScalarKeyframeCurveCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = updateElementInTracks({ + const updatedTracks = updateElementInSceneTracks({ tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index d0f01104..f436721a 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -5,13 +5,13 @@ import { resolveAnimationTarget, upsertPathKeyframe, } from "@/lib/animation"; -import { updateElementInTracks } from "@/lib/timeline"; +import { updateElementInSceneTracks } from "@/lib/timeline"; import { isVisualElement } from "@/lib/timeline/element-utils"; import type { AnimationInterpolation } from "@/lib/animation/types"; -import type { TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks } from "@/lib/timeline"; export class UpsertEffectParamKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly effectId: string; @@ -53,9 +53,9 @@ export class UpsertEffectParamKeyframeCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = updateElementInTracks({ + const updatedTracks = updateElementInSceneTracks({ tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts index bd56749a..f77e69b8 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -1,8 +1,8 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/lib/commands/base-command"; import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation"; -import { updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack } from "@/lib/timeline"; +import { updateElementInSceneTracks } from "@/lib/timeline"; +import type { SceneTracks } from "@/lib/timeline"; import type { AnimationPath, AnimationInterpolation, @@ -10,7 +10,7 @@ import type { } from "@/lib/animation/types"; export class UpsertKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly propertyPath: AnimationPath; @@ -48,9 +48,9 @@ export class UpsertKeyframeCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = updateElementInTracks({ + const updatedTracks = updateElementInSceneTracks({ tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, diff --git a/apps/web/src/lib/commands/timeline/element/masks/index.ts b/apps/web/src/lib/commands/timeline/element/masks/index.ts index 5ceec931..acc47184 100644 --- a/apps/web/src/lib/commands/timeline/element/masks/index.ts +++ b/apps/web/src/lib/commands/timeline/element/masks/index.ts @@ -1,2 +1,2 @@ -export { RemoveMaskCommand } from "./remove-mask"; -export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; +export { RemoveMaskCommand } from "./remove-mask"; +export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; diff --git a/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts index b0daced7..9f6c5f77 100644 --- a/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts +++ b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts @@ -1,7 +1,7 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { isMaskableElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, MaskableElement } from "@/lib/timeline"; +import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline"; +import type { SceneTracks, MaskableElement } from "@/lib/timeline"; function removeMaskFromElement({ element, @@ -16,7 +16,7 @@ function removeMaskFromElement({ } export class RemoveMaskCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly maskId: string; @@ -38,9 +38,9 @@ export class RemoveMaskCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = updateElementInTracks({ + const updatedTracks = updateElementInSceneTracks({ tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, diff --git a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts index bc812398..2b5c1694 100644 --- a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts +++ b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts @@ -1,8 +1,8 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { isMaskableElement, updateElementInTracks } from "@/lib/timeline"; +import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline"; import type { Mask } from "@/lib/masks/types"; -import type { TimelineTrack, MaskableElement } from "@/lib/timeline"; +import type { SceneTracks, MaskableElement } from "@/lib/timeline"; export function toggleMaskInvertedOnElement({ element, @@ -27,7 +27,7 @@ export function toggleMaskInvertedOnElement({ } export class ToggleMaskInvertedCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly maskId: string; @@ -49,9 +49,9 @@ export class ToggleMaskInvertedCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = updateElementInTracks({ + const updatedTracks = updateElementInSceneTracks({ tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index cf3afa6d..c1fa6897 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -1,18 +1,23 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { - TimelineTrack, + SceneTracks, TimelineElement, TrackType, + TimelineTrack, } from "@/lib/timeline"; import { buildEmptyTrack, validateElementTrackCompatibility, enforceMainTrackStart, } from "@/lib/timeline/placement"; +import { + findTrackInSceneTracks, + updateTrackInSceneTracks, +} from "@/lib/timeline/track-element-update"; export class MoveElementCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly sourceTrackId: string; private readonly targetTrackId: string; private readonly elementId: string; @@ -42,11 +47,12 @@ export class MoveElementCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const sourceTrack = this.savedState.find( - (track) => track.id === this.sourceTrackId, - ); + const sourceTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.sourceTrackId, + }); const element = sourceTrack?.elements.find( (trackElement) => trackElement.id === this.elementId, ); @@ -55,15 +61,21 @@ export class MoveElementCommand extends Command { throw new Error("Source track or element not found"); } - let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId); + let targetTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.targetTrackId, + }); let tracksToUpdate = this.savedState; if (!targetTrack && this.createTrack) { const newTrack = buildEmptyTrack({ id: this.targetTrackId, type: this.createTrack.type, }); - tracksToUpdate = [...this.savedState]; - tracksToUpdate.splice(this.createTrack.index, 0, newTrack); + tracksToUpdate = insertTrackAtDisplayIndex({ + tracks: this.savedState, + track: newTrack, + insertIndex: this.createTrack.index, + }); targetTrack = newTrack; } if (!targetTrack) { @@ -94,32 +106,34 @@ export class MoveElementCommand extends Command { const isSameTrack = this.sourceTrackId === this.targetTrackId; - const updatedTracks = tracksToUpdate.map((track): TimelineTrack => { - if (isSameTrack && track.id === this.sourceTrackId) { - return { - ...track, - elements: track.elements.map((trackElement) => - trackElement.id === this.elementId ? movedElement : trackElement, - ), - } as typeof track; - } - - if (track.id === this.sourceTrackId) { - const remainingElements = track.elements.filter( - (trackElement) => trackElement.id !== this.elementId, - ); - return { ...track, elements: remainingElements } as typeof track; - } - - if (track.id === this.targetTrackId) { - return { - ...track, - elements: [...track.elements, movedElement], - } as typeof track; - } - - return track; - }); + const updatedTracks = isSameTrack + ? updateTrackInSceneTracks({ + tracks: tracksToUpdate, + trackId: this.sourceTrackId, + update: (track) => ({ + ...track, + elements: track.elements.map((trackElement) => + trackElement.id === this.elementId ? movedElement : trackElement, + ), + }), + }) + : updateTrackInSceneTracks({ + tracks: updateTrackInSceneTracks({ + tracks: tracksToUpdate, + trackId: this.sourceTrackId, + update: (track) => ({ + ...track, + elements: track.elements.filter( + (trackElement) => trackElement.id !== this.elementId, + ), + }), + }), + trackId: this.targetTrackId, + update: (track) => ({ + ...track, + elements: [...track.elements, movedElement], + }), + }); editor.timeline.updateTracks(updatedTracks); return undefined; @@ -132,3 +146,41 @@ export class MoveElementCommand extends Command { } } } + +function insertTrackAtDisplayIndex({ + tracks, + track, + insertIndex, +}: { + tracks: SceneTracks; + track: TimelineTrack; + insertIndex: number; +}): SceneTracks { + if (track.type === "audio") { + const audioInsertIndex = Math.max( + 0, + Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length), + ); + return { + ...tracks, + audio: [ + ...tracks.audio.slice(0, audioInsertIndex), + track, + ...tracks.audio.slice(audioInsertIndex), + ], + }; + } + + const overlayInsertIndex = Math.max( + 0, + Math.min(insertIndex, tracks.overlay.length), + ); + return { + ...tracks, + overlay: [ + ...tracks.overlay.slice(0, overlayInsertIndex), + track, + ...tracks.overlay.slice(overlayInsertIndex), + ], + }; +} diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts index 79722b4c..9799f7e8 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -1,5 +1,5 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks, TimelineElement } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; import { isRetimableElement } from "@/lib/timeline"; @@ -7,7 +7,7 @@ import { splitAnimationsAtTime } from "@/lib/animation"; import { getSourceSpanAtClipTime } from "@/lib/retime"; export class SplitElementsCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private rightSideElements: { trackId: string; elementId: string }[] = []; private readonly elements: { trackId: string; elementId: string }[]; private readonly splitTime: number; @@ -34,10 +34,12 @@ export class SplitElementsCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; this.rightSideElements = []; - const updatedTracks = this.savedState.map((track) => { + const splitTrack = ( + track: TTrack, + ): TTrack => { const elementsToSplit = this.elements.filter( (target) => target.trackId === track.id, ); @@ -148,8 +150,14 @@ export class SplitElementsCommand extends Command { ]; }); - return { ...track, elements } as typeof track; - }); + return { ...track, elements } as TTrack; + }; + + const updatedTracks: SceneTracks = { + overlay: this.savedState.overlay.map((track) => splitTrack(track)), + main: splitTrack(this.savedState.main), + audio: this.savedState.audio.map((track) => splitTrack(track)), + }; editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts index fc195cba..36b3b7a7 100644 --- a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts +++ b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts @@ -3,18 +3,20 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; import { buildSeparatedAudioElement, canExtractSourceAudio, - canRecoverSourceAudio, + isSourceAudioSeparated, } from "@/lib/timeline/audio-separation"; -import { - applyPlacement, - resolveTrackPlacement, -} from "@/lib/timeline/placement"; -import { updateElementInTracks } from "@/lib/timeline/track-element-update"; -import type { TimelineTrack, VideoElement } from "@/lib/timeline/types"; +import { buildEmptyTrack } from "@/lib/timeline/placement"; +import { updateElementInSceneTracks } from "@/lib/timeline/track-element-update"; +import type { + AudioTrack, + SceneTracks, + TimelineElement, + VideoElement, +} from "@/lib/timeline/types"; import { generateUUID } from "@/utils/id"; export class ToggleSourceAudioSeparationCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; constructor( private readonly params: { @@ -27,27 +29,27 @@ export class ToggleSourceAudioSeparationCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const trackIndex = this.savedState.findIndex( + const sourceTrack = [ + ...this.savedState.overlay, + this.savedState.main, + ...this.savedState.audio, + ].find( (track) => track.id === this.params.trackId, ); - if (trackIndex < 0) { + if (!sourceTrack) { return; } - - const sourceTrack = this.savedState[trackIndex]; const sourceElement = sourceTrack.elements.find( (element) => element.id === this.params.elementId, - ); - if (!sourceElement) { - return; - } - if (sourceElement.type !== "video") { + ) as TimelineElement | undefined; + if (!sourceElement || sourceElement.type !== "video") { return; } + const videoElement: VideoElement = sourceElement; - if (canRecoverSourceAudio(sourceElement)) { + if (isSourceAudioSeparated({ element: videoElement })) { editor.timeline.updateTracks( updateSourceAudioEnabled({ tracks: this.savedState, @@ -62,47 +64,34 @@ export class ToggleSourceAudioSeparationCommand extends Command { const mediaAsset = editor .media .getAssets() - .find((asset) => asset.id === sourceElement.mediaId); - if (!canExtractSourceAudio(sourceElement, mediaAsset)) { + .find((asset) => asset.id === videoElement.mediaId); + if (!canExtractSourceAudio(videoElement, mediaAsset)) { return; } - if (sourceElement.duration <= 0) { + if (videoElement.duration <= 0) { return; } const separatedAudioElement = { ...buildSeparatedAudioElement({ - sourceElement, + sourceElement: videoElement, }), id: generateUUID(), }; - const placementResult = resolveTrackPlacement({ - tracks: this.savedState, - trackType: "audio", - timeSpans: [ - { - startTime: separatedAudioElement.startTime, - duration: separatedAudioElement.duration, - }, - ], - strategy: { type: "aboveSource", sourceTrackIndex: trackIndex }, - }); - if (!placementResult) { - return; - } - - const appliedPlacement = applyPlacement({ - tracks: this.savedState, - placementResult, + const newAudioTrack = { + ...buildEmptyTrack({ + id: generateUUID(), + type: "audio", + }), elements: [separatedAudioElement], - }); - if (!appliedPlacement) { - return; - } + } as AudioTrack; editor.timeline.updateTracks( updateSourceAudioEnabled({ - tracks: appliedPlacement.updatedTracks, + tracks: { + ...this.savedState, + audio: [...this.savedState.audio, newAudioTrack], + }, trackId: this.params.trackId, elementId: this.params.elementId, isSourceAudioEnabled: false, @@ -126,12 +115,12 @@ function updateSourceAudioEnabled({ elementId, isSourceAudioEnabled, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackId: string; elementId: string; isSourceAudioEnabled: boolean; -}): TimelineTrack[] { - return updateElementInTracks({ +}): SceneTracks { + return updateElementInSceneTracks({ tracks, trackId, elementId, diff --git a/apps/web/src/lib/commands/timeline/element/update-elements.ts b/apps/web/src/lib/commands/timeline/element/update-elements.ts index abeec842..54d1ca88 100644 --- a/apps/web/src/lib/commands/timeline/element/update-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/update-elements.ts @@ -1,11 +1,14 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; -import { updateElementInTracks } from "@/lib/timeline"; +import type { SceneTracks, TimelineElement } from "@/lib/timeline"; +import { + findTrackInSceneTracks, + updateElementInSceneTracks, +} from "@/lib/timeline"; import { applyElementUpdate } from "@/lib/timeline/update-pipeline"; export class UpdateElementsCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly updates: Array<{ trackId: string; elementId: string; @@ -27,13 +30,14 @@ export class UpdateElementsCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; let updatedTracks = this.savedState; for (const updateEntry of this.updates) { - const currentTrack = updatedTracks.find( - (track) => track.id === updateEntry.trackId, - ); + const currentTrack = findTrackInSceneTracks({ + tracks: updatedTracks, + trackId: updateEntry.trackId, + }); const currentElement = currentTrack?.elements.find( (element) => element.id === updateEntry.elementId, ); @@ -50,7 +54,7 @@ export class UpdateElementsCommand extends Command { }, }); - updatedTracks = updateElementInTracks({ + updatedTracks = updateElementInSceneTracks({ tracks: updatedTracks, trackId: updateEntry.trackId, elementId: updateEntry.elementId, diff --git a/apps/web/src/lib/commands/timeline/index.ts b/apps/web/src/lib/commands/timeline/index.ts index d0bfd5e9..cb285d94 100644 --- a/apps/web/src/lib/commands/timeline/index.ts +++ b/apps/web/src/lib/commands/timeline/index.ts @@ -1,5 +1,5 @@ -export * from "./track"; -export * from "./element"; -export * from "./clipboard"; - -export { TracksSnapshotCommand } from "./tracks-snapshot"; +export * from "./track"; +export * from "./element"; +export * from "./clipboard"; + +export { TracksSnapshotCommand } from "./tracks-snapshot"; diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts index 29bdbb0a..66b9c348 100644 --- a/apps/web/src/lib/commands/timeline/track/add-track.ts +++ b/apps/web/src/lib/commands/timeline/track/add-track.ts @@ -1,5 +1,5 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TrackType, TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks, TrackType } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; import { @@ -9,7 +9,7 @@ import { export class AddTrackCommand extends Command { private trackId: string; - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; constructor( private type: TrackType, @@ -21,21 +21,28 @@ export class AddTrackCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const newTrack: TimelineTrack = buildEmptyTrack({ - id: this.trackId, - type: this.type, - }); - - const updatedTracks = [...(this.savedState || [])]; const insertIndex = this.index ?? getDefaultInsertIndexForTrack({ - tracks: updatedTracks, + tracks: this.savedState, trackType: this.type, }); - updatedTracks.splice(insertIndex, 0, newTrack); + + const updatedTracks = + this.type === "audio" + ? buildAudioTrackState({ + tracks: this.savedState, + insertIndex, + trackId: this.trackId, + }) + : buildOverlayTrackState({ + tracks: this.savedState, + insertIndex, + trackId: this.trackId, + trackType: this.type, + }); editor.timeline.updateTracks(updatedTracks); return undefined; @@ -52,3 +59,60 @@ export class AddTrackCommand extends Command { return this.trackId; } } + +function buildAudioTrackState({ + tracks, + insertIndex, + trackId, +}: { + tracks: SceneTracks; + insertIndex: number; + trackId: string; +}): SceneTracks { + const audioInsertIndex = Math.max( + 0, + insertIndex - tracks.overlay.length - 1, + ); + const newTrack = buildEmptyTrack({ + id: trackId, + type: "audio", + }); + return { + ...tracks, + audio: [ + ...tracks.audio.slice(0, audioInsertIndex), + newTrack, + ...tracks.audio.slice(audioInsertIndex), + ], + }; +} + +function buildOverlayTrackState({ + tracks, + insertIndex, + trackId, + trackType, +}: { + tracks: SceneTracks; + insertIndex: number; + trackId: string; + trackType: Exclude; +}): SceneTracks { + const overlayInsertIndex = Math.min(insertIndex, tracks.overlay.length); + const newTrack = + trackType === "video" + ? buildEmptyTrack({ id: trackId, type: "video" }) + : trackType === "text" + ? buildEmptyTrack({ id: trackId, type: "text" }) + : trackType === "graphic" + ? buildEmptyTrack({ id: trackId, type: "graphic" }) + : buildEmptyTrack({ id: trackId, type: "effect" }); + return { + ...tracks, + overlay: [ + ...tracks.overlay.slice(0, overlayInsertIndex), + newTrack, + ...tracks.overlay.slice(overlayInsertIndex), + ], + }; +} diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts index c2ea7ef0..5fcaa898 100644 --- a/apps/web/src/lib/commands/timeline/track/remove-track.ts +++ b/apps/web/src/lib/commands/timeline/track/remove-track.ts @@ -1,35 +1,30 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TimelineTrack } from "@/lib/timeline"; -import { getMainTrack } from "@/lib/timeline/placement"; - -export class RemoveTrackCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private trackId: string) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - const targetTrack = this.savedState.find( - (track) => track.id === this.trackId, - ); - const mainTrack = getMainTrack({ tracks: this.savedState }); - if (mainTrack?.id === targetTrack?.id) { - return; - } - const updatedTracks = this.savedState.filter( - (track) => track.id !== this.trackId, - ); - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { SceneTracks } from "@/lib/timeline"; + +export class RemoveTrackCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor(private trackId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + const updatedTracks: SceneTracks = { + ...this.savedState, + overlay: this.savedState.overlay.filter((track) => track.id !== this.trackId), + audio: this.savedState.audio.filter((track) => track.id !== this.trackId), + }; + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts index 513fffc7..7ffe2fd7 100644 --- a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts +++ b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts @@ -1,39 +1,41 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { canTrackHaveAudio } from "@/lib/timeline"; - -export class ToggleTrackMuteCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private trackId: string) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const targetTrack = this.savedState.find( - (track) => track.id === this.trackId, - ); - if (!targetTrack) { - return; - } - - const updatedTracks = this.savedState.map((track) => - track.id === this.trackId && canTrackHaveAudio(track) - ? { ...track, muted: !track.muted } - : track, - ); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { SceneTracks } from "@/lib/timeline"; +import { EditorCore } from "@/core"; +import { canTrackHaveAudio, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/lib/timeline"; + +export class ToggleTrackMuteCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor(private trackId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const targetTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + }); + if (!targetTrack) { + return; + } + + const updatedTracks = updateTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + update: (track) => + canTrackHaveAudio(track) ? { ...track, muted: !track.muted } : track, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts index 077c21db..2292c83a 100644 --- a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts +++ b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts @@ -1,40 +1,45 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { canTrackBeHidden } from "@/lib/timeline"; - -export class ToggleTrackVisibilityCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private trackId: string) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const targetTrack = this.savedState.find( - (track) => track.id === this.trackId, - ); - if (!targetTrack) { - return; - } - - const updatedTracks = this.savedState.map((track) => { - if (track.id === this.trackId && canTrackBeHidden(track)) { - return { ...track, hidden: !track.hidden }; - } - return track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { SceneTracks } from "@/lib/timeline"; +import { EditorCore } from "@/core"; +import { canTrackBeHidden, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/lib/timeline"; + +export class ToggleTrackVisibilityCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor(private trackId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const targetTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + }); + if (!targetTrack) { + return; + } + + const updatedTracks = updateTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + update: (track) => { + if (canTrackBeHidden(track)) { + return { ...track, hidden: !track.hidden }; + } + return track; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts index 2493f19b..daed6a9e 100644 --- a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts +++ b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts @@ -1,11 +1,11 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks } from "@/lib/timeline"; import { EditorCore } from "@/core"; export class TracksSnapshotCommand extends Command { constructor( - private before: TimelineTrack[], - private after: TimelineTrack[], + private before: SceneTracks, + private after: SceneTracks, ) { super(); } diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts index 2fbea32e..a70a6fa1 100644 --- a/apps/web/src/lib/media/audio.ts +++ b/apps/web/src/lib/media/audio.ts @@ -2,7 +2,7 @@ import type { AudioElement, LibraryAudioElement, RetimeConfig, - TimelineTrack, + SceneTracks, } from "@/lib/timeline"; import { shouldMaintainPitch } from "@/lib/retime/rate"; import type { MediaAsset } from "@/lib/media/types"; @@ -89,16 +89,17 @@ export async function collectAudioElements({ mediaAssets, audioContext, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; mediaAssets: MediaAsset[]; audioContext: AudioContext; }): Promise { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const mediaMap = new Map( mediaAssets.map((media) => [media.id, media]), ); const pendingElements: Array> = []; - for (const track of tracks) { + for (const track of orderedTracks) { if (canTrackHaveAudio(track) && track.muted) continue; for (const element of track.elements) { @@ -445,16 +446,17 @@ export async function collectAudioMixSources({ tracks, mediaAssets, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; mediaAssets: MediaAsset[]; }): Promise { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const audioMixSources: AudioMixSource[] = []; const mediaMap = new Map( mediaAssets.map((asset) => [asset.id, asset]), ); const pendingLibrarySources: Array> = []; - for (const track of tracks) { + for (const track of orderedTracks) { if (canTrackHaveAudio(track) && track.muted) continue; for (const element of track.elements) { @@ -507,16 +509,17 @@ export async function collectAudioClips({ tracks, mediaAssets, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; mediaAssets: MediaAsset[]; }): Promise { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const clips: AudioClipSource[] = []; const mediaMap = new Map( mediaAssets.map((asset) => [asset.id, asset]), ); const pendingLibraryClips: Array> = []; - for (const track of tracks) { + for (const track of orderedTracks) { const isTrackMuted = canTrackHaveAudio(track) && track.muted; for (const element of track.elements) { @@ -587,7 +590,7 @@ export async function createTimelineAudioBuffer({ sampleRate = EXPORT_SAMPLE_RATE, audioContext, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; mediaAssets: MediaAsset[]; duration: number; sampleRate?: number; diff --git a/apps/web/src/lib/media/mediabunny.ts b/apps/web/src/lib/media/mediabunny.ts index dbb6ecad..a313c6f0 100644 --- a/apps/web/src/lib/media/mediabunny.ts +++ b/apps/web/src/lib/media/mediabunny.ts @@ -1,6 +1,6 @@ import { Input, ALL_FORMATS, BlobSource } from "mediabunny"; import { createTimelineAudioBuffer } from "@/lib/media/audio"; -import type { TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks } from "@/lib/timeline"; import type { MediaAsset } from "@/lib/media/types"; export async function getVideoInfo({ @@ -48,7 +48,7 @@ export const extractTimelineAudio = async ({ totalDuration, onProgress, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; mediaAssets: MediaAsset[]; totalDuration: number; onProgress?: (progress: number) => void; diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts index 380a008c..40145d02 100644 --- a/apps/web/src/lib/preview/element-bounds.ts +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -1,6 +1,5 @@ -import type { TimelineTrack, TimelineElement } from "@/lib/timeline"; +import type { SceneTracks, TimelineElement } from "@/lib/timeline"; import type { MediaAsset } from "@/lib/media/types"; -import { isMainTrack } from "@/lib/timeline/placement"; import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants"; import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics"; import { measureTextElement } from "@/lib/text/measure-element"; @@ -266,18 +265,15 @@ export function getVisibleElementsWithBounds({ canvasSize, mediaAssets, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; currentTime: number; canvasSize: { width: number; height: number }; mediaAssets: MediaAsset[]; }): ElementWithBounds[] { const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); - const visibleTracks = tracks.filter( - (track) => !("hidden" in track && track.hidden), - ); const orderedTracks = [ - ...visibleTracks.filter((track) => !isMainTrack(track)), - ...visibleTracks.filter((track) => isMainTrack(track)), + ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)), + ...(!tracks.main.hidden ? [tracks.main] : []), ].reverse(); const result: ElementWithBounds[] = []; diff --git a/apps/web/src/lib/ripple/apply.ts b/apps/web/src/lib/ripple/apply.ts index 098a0247..f96e4600 100644 --- a/apps/web/src/lib/ripple/apply.ts +++ b/apps/web/src/lib/ripple/apply.ts @@ -1,4 +1,4 @@ -import type { TimelineTrack } from "@/lib/timeline/types"; +import type { SceneTracks, TimelineTrack } from "@/lib/timeline/types"; import { rippleShiftElements } from "./shift"; export interface RippleAdjustment { @@ -11,9 +11,9 @@ export function applyRippleAdjustments({ tracks, adjustments, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; adjustments: RippleAdjustment[]; -}): TimelineTrack[] { +}): SceneTracks { if (adjustments.length === 0) { return tracks; } @@ -25,12 +25,24 @@ export function applyRippleAdjustments({ adjustmentsByTrack.set(adjustment.trackId, trackAdjustments); } - return tracks.map((track) => - applyTrackRippleAdjustments({ - track, - adjustments: adjustmentsByTrack.get(track.id) ?? [], + return { + overlay: tracks.overlay.map((track) => + applyTrackRippleAdjustments({ + track, + adjustments: adjustmentsByTrack.get(track.id) ?? [], + }), + ), + main: applyTrackRippleAdjustments({ + track: tracks.main, + adjustments: adjustmentsByTrack.get(tracks.main.id) ?? [], }), - ); + audio: tracks.audio.map((track) => + applyTrackRippleAdjustments({ + track, + adjustments: adjustmentsByTrack.get(track.id) ?? [], + }), + ), + }; } function applyTrackRippleAdjustments< diff --git a/apps/web/src/lib/ripple/diff.ts b/apps/web/src/lib/ripple/diff.ts index 22b8d996..49238738 100644 --- a/apps/web/src/lib/ripple/diff.ts +++ b/apps/web/src/lib/ripple/diff.ts @@ -1,5 +1,4 @@ - -import type { TimelineElement, TimelineTrack } from "@/lib/timeline/types"; +import type { SceneTracks, TimelineElement, TimelineTrack } from "@/lib/timeline/types"; import type { RippleAdjustment } from "./apply"; interface Interval { @@ -15,15 +14,25 @@ export function computeRippleAdjustments({ beforeTracks, afterTracks, }: { - beforeTracks: TimelineTrack[]; - afterTracks: TimelineTrack[]; + beforeTracks: SceneTracks; + afterTracks: SceneTracks; }): RippleAdjustment[] { - const afterTracksById = new Map(afterTracks.map((track) => [track.id, track])); + const beforeTrackList = [ + ...beforeTracks.overlay, + beforeTracks.main, + ...beforeTracks.audio, + ]; + const afterTrackList = [ + ...afterTracks.overlay, + afterTracks.main, + ...afterTracks.audio, + ]; + const afterTracksById = new Map(afterTrackList.map((track) => [track.id, track])); const allAfterElementIds = new Set( - afterTracks.flatMap((track) => track.elements.map((element) => element.id)), + afterTrackList.flatMap((track) => track.elements.map((element) => element.id)), ); - return beforeTracks.flatMap((beforeTrack): RippleAdjustment[] => + return beforeTrackList.flatMap((beforeTrack): RippleAdjustment[] => computeTrackRippleAdjustments({ trackId: beforeTrack.id, beforeElements: beforeTrack.elements, diff --git a/apps/web/src/lib/scenes.ts b/apps/web/src/lib/scenes.ts index 1b5b293c..91285275 100644 --- a/apps/web/src/lib/scenes.ts +++ b/apps/web/src/lib/scenes.ts @@ -1,7 +1,7 @@ import type { TScene } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { calculateTotalDuration } from "@/lib/timeline"; -import { ensureMainTrack } from "@/lib/timeline/placement"; +import { MAIN_TRACK_NAME } from "@/lib/timeline/placement/main-track"; export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null { return scenes.find((scene) => scene.isMain) || null; @@ -23,12 +23,22 @@ export function buildDefaultScene({ name: string; isMain: boolean; }): TScene { - const tracks = ensureMainTrack({ tracks: [] }); return { id: generateUUID(), name, isMain, - tracks, + tracks: { + overlay: [], + main: { + id: generateUUID(), + name: MAIN_TRACK_NAME, + type: "video", + elements: [], + muted: false, + hidden: false, + }, + audio: [], + }, bookmarks: [], createdAt: new Date(), updatedAt: new Date(), @@ -81,7 +91,7 @@ export function getProjectDurationFromScenes({ scenes: TScene[]; }): number { const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null; - if (!mainScene?.tracks || !Array.isArray(mainScene.tracks)) { + if (!mainScene?.tracks) { return 0; } diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index 6f9e9dbc..e56acd3c 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -13,6 +13,7 @@ import { type CreateLibraryAudioElement, type TextBackground, type TextElement, + type SceneTracks, type TimelineElement, type TimelineTrack, type AudioElement, @@ -382,12 +383,13 @@ export function getElementsAtTime({ tracks, time, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; time: number; }): { trackId: string; elementId: string }[] { const result: { trackId: string; elementId: string }[] = []; + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; - for (const track of tracks) { + for (const track of orderedTracks) { for (const element of track.elements) { const elementStart = element.startTime; const elementEnd = element.startTime + element.duration; @@ -404,10 +406,10 @@ export function getElementsAtTime({ export function getElementFontFamilies({ tracks, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; }): string[] { const families = new Set(); - for (const track of tracks) { + for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) { for (const element of track.elements) { if (element.type === "text" && element.fontFamily) { families.add(element.fontFamily); diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index 194cd84b..5ee079b1 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -1,4 +1,4 @@ -import type { TimelineTrack } from "./types"; +import type { SceneTracks } from "./types"; export * from "./types"; export * from "./drag"; @@ -13,11 +13,12 @@ export * from "./pixel-utils"; export function calculateTotalDuration({ tracks, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; }): number { - if (tracks.length === 0) return 0; + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + if (orderedTracks.length === 0) return 0; - const trackEndTimes = tracks.map((track) => + const trackEndTimes = orderedTracks.map((track) => track.elements.reduce((maxEnd, element) => { const elementEnd = element.startTime + element.duration; return Math.max(maxEnd, elementEnd); diff --git a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts index 5ddccd90..4d39f5d8 100644 --- a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts +++ b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts @@ -4,6 +4,8 @@ import type { AudioTrack, GraphicElement, GraphicTrack, + OverlayTrack, + SceneTracks, TextElement, TextTrack, TimelineTrack, @@ -134,53 +136,45 @@ type BuildTrackParams = id: string; type: "audio"; elements?: AudioTrack["elements"]; - isMain?: boolean; } | { id: string; type: "graphic"; elements?: GraphicTrack["elements"]; - isMain?: boolean; } | { id: string; type: "text"; elements?: TextTrack["elements"]; - isMain?: boolean; } | { id: string; type: "video"; elements?: VideoTrack["elements"]; - isMain?: boolean; }; function buildTrack(params: { id: string; type: "audio"; elements?: AudioTrack["elements"]; - isMain?: boolean; }): AudioTrack; function buildTrack(params: { id: string; type: "graphic"; elements?: GraphicTrack["elements"]; - isMain?: boolean; }): GraphicTrack; function buildTrack(params: { id: string; type: "text"; elements?: TextTrack["elements"]; - isMain?: boolean; }): TextTrack; function buildTrack(params: { id: string; type: "video"; elements?: VideoTrack["elements"]; - isMain?: boolean; }): VideoTrack; function buildTrack(params: BuildTrackParams): TimelineTrack { - const { id, type, isMain = false } = params; + const { id, type } = params; switch (type) { case "audio": @@ -213,7 +207,6 @@ function buildTrack(params: BuildTrackParams): TimelineTrack { type: "video", name: id, elements: params.elements ?? [], - isMain, muted: false, hidden: false, }; @@ -234,9 +227,32 @@ function buildTimeSpan({ return { startTime, duration, excludeElementId }; } +function buildSceneTracks({ + overlay = [], + main, + audio = [], +}: { + overlay?: Array; + main?: VideoTrack; + audio?: Array; +}): SceneTracks { + return { + overlay, + main: + main ?? + buildTrack({ + id: "video-main", + type: "video", + }), + audio, + }; +} + describe("resolveTrackPlacement", () => { test("explicit returns the requested compatible track", () => { - const tracks = [buildTrack({ id: "text-1", type: "text" })]; + const tracks = buildSceneTracks({ + overlay: [buildTrack({ id: "text-1", type: "text" })], + }); expect( resolveTrackPlacement({ @@ -254,7 +270,9 @@ describe("resolveTrackPlacement", () => { }); test("explicit rejects missing and incompatible tracks", () => { - const tracks = [buildTrack({ id: "video-1", type: "video", isMain: true })]; + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-1", type: "video" }), + }); expect( resolveTrackPlacement({ @@ -276,16 +294,18 @@ describe("resolveTrackPlacement", () => { }); test("firstAvailable picks the first compatible track without overlap", () => { - const tracks = [ - buildTrack({ - id: "text-1", - type: "text", - elements: [ - buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), - ], - }), - buildTrack({ id: "text-2", type: "text" }), - ]; + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ + id: "text-1", + type: "text", + elements: [ + buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), + ], + }), + buildTrack({ id: "text-2", type: "text" }), + ], + }); expect( resolveTrackPlacement({ @@ -303,21 +323,22 @@ describe("resolveTrackPlacement", () => { }); test("firstAvailable creates a new track when all compatible tracks are full", () => { - const tracks = [ - buildTrack({ - id: "graphic-1", - type: "graphic", - elements: [ - buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }), - ], - }), - buildTrack({ + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ + id: "graphic-1", + type: "graphic", + elements: [ + buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }), + ], + }), + ], + main: buildTrack({ id: "video-main", type: "video", - isMain: true, }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -335,7 +356,9 @@ describe("resolveTrackPlacement", () => { }); test("preferIndex uses the preferred track when it fits", () => { - const tracks = [buildTrack({ id: "audio-1", type: "audio" })]; + const tracks = buildSceneTracks({ + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -349,18 +372,18 @@ describe("resolveTrackPlacement", () => { }, }), ).toEqual({ - kind: "existingTrack", - trackId: "audio-1", - trackIndex: 0, + kind: "newTrack", trackType: "audio", + insertIndex: 1, + insertPosition: "below", }); }); test("preferIndex creates a new overlay track above the main track", () => { - const tracks = [ - buildTrack({ id: "video-main", type: "video", isMain: true }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -382,11 +405,11 @@ describe("resolveTrackPlacement", () => { }); test("preferIndex keeps audio tracks below the main track", () => { - const tracks = [ - buildTrack({ id: "text-1", type: "text" }), - buildTrack({ id: "video-main", type: "video", isMain: true }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + const tracks = buildSceneTracks({ + overlay: [buildTrack({ id: "text-1", type: "text" })], + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -409,17 +432,19 @@ describe("resolveTrackPlacement", () => { }); test("aboveSource tries the track above source, then any compatible track", () => { - const tracks = [ - buildTrack({ id: "text-top", type: "text" }), - buildTrack({ - id: "text-middle", - type: "text", - elements: [ - buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), - ], - }), - buildTrack({ id: "text-source", type: "text" }), - ]; + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ id: "text-top", type: "text" }), + buildTrack({ + id: "text-middle", + type: "text", + elements: [ + buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), + ], + }), + buildTrack({ id: "text-source", type: "text" }), + ], + }); expect( resolveTrackPlacement({ @@ -436,23 +461,25 @@ describe("resolveTrackPlacement", () => { }); }); - test("aboveSource creates a new track near the source when none fit", () => { - const tracks = [ - buildTrack({ - id: "text-top", - type: "text", - elements: [ - buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), - ], - }), - buildTrack({ - id: "text-source", - type: "text", - elements: [ - buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }), - ], - }), - ]; + test("aboveSource creates a new overlay track in the overlay zone when none fit", () => { + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ + id: "text-top", + type: "text", + elements: [ + buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), + ], + }), + buildTrack({ + id: "text-source", + type: "text", + elements: [ + buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }), + ], + }), + ], + }); expect( resolveTrackPlacement({ @@ -464,16 +491,16 @@ describe("resolveTrackPlacement", () => { ).toEqual({ kind: "newTrack", trackType: "text", - insertIndex: 1, + insertIndex: 0, insertPosition: null, }); }); test("alwaysNew honors highest and default insertion rules", () => { - const tracks = [ - buildTrack({ id: "video-main", type: "video", isMain: true }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -505,16 +532,18 @@ describe("resolveTrackPlacement", () => { }); test("batch time spans reject tracks when any span overlaps", () => { - const tracks = [ - buildTrack({ - id: "audio-1", - type: "audio", - elements: [ - buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }), - buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }), - ], - }), - ]; + const tracks = buildSceneTracks({ + audio: [ + buildTrack({ + id: "audio-1", + type: "audio", + elements: [ + buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }), + buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }), + ], + }), + ], + }); expect( resolveTrackPlacement({ @@ -534,10 +563,10 @@ describe("resolveTrackPlacement", () => { }); }); - test("handles empty timelines, single tracks, and track-type derivation", () => { + test("handles main-only timelines, single tracks, and track-type derivation", () => { expect( resolveTrackPlacement({ - tracks: [], + tracks: buildSceneTracks({}), elementType: "video", timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })], strategy: { @@ -551,12 +580,14 @@ describe("resolveTrackPlacement", () => { kind: "newTrack", trackType: "video", insertIndex: 0, - insertPosition: null, + insertPosition: "above", }); expect( resolveTrackPlacement({ - tracks: [buildTrack({ id: "audio-1", type: "audio" })], + tracks: buildSceneTracks({ + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }), elementType: "audio", timeSpans: [], strategy: { type: "alwaysNew", position: "default" }, @@ -564,22 +595,21 @@ describe("resolveTrackPlacement", () => { ).toEqual({ kind: "newTrack", trackType: "audio", - insertIndex: 1, + insertIndex: 2, insertPosition: null, }); }); test("existingTrack on main video includes adjustedStartTime when start snaps", () => { - const tracks = [ - buildTrack({ + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-main", type: "video", - isMain: true, elements: [ buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }), ], }), - ]; + }); expect( resolveTrackPlacement({ @@ -598,11 +628,11 @@ describe("resolveTrackPlacement", () => { }); test("preferIndex uses vertical drag direction when hovered track is incompatible", () => { - const tracks = [ - buildTrack({ id: "text-1", type: "text" }), - buildTrack({ id: "video-main", type: "video", isMain: true }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + const tracks = buildSceneTracks({ + overlay: [buildTrack({ id: "text-1", type: "text" })], + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ diff --git a/apps/web/src/lib/timeline/placement/apply.ts b/apps/web/src/lib/timeline/placement/apply.ts index 4ff5d50d..639b1b42 100644 --- a/apps/web/src/lib/timeline/placement/apply.ts +++ b/apps/web/src/lib/timeline/placement/apply.ts @@ -1,7 +1,18 @@ -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { + AudioTrack, + EffectTrack, + GraphicTrack, + OverlayTrack, + SceneTracks, + TextTrack, + TimelineElement, + TimelineTrack, + VideoTrack, +} from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { buildEmptyTrack } from "./track-factory"; import type { PlacementResult } from "./types"; +import { updateTrackInSceneTracks } from "@/lib/timeline/track-element-update"; export function applyPlacement({ tracks, @@ -9,37 +20,139 @@ export function applyPlacement({ elements, newTrackInsertIndexOverride, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; placementResult: PlacementResult; elements: TimelineElement[]; newTrackInsertIndexOverride?: number; -}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null { +}): { updatedTracks: SceneTracks; targetTrackId: string } | null { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; if (placementResult.kind === "existingTrack") { - const targetTrack = tracks[placementResult.trackIndex]; + const targetTrack = orderedTracks[placementResult.trackIndex]; if (!targetTrack) { return null; } - const updatedTracks = tracks.map((track, trackIndex) => - trackIndex === placementResult.trackIndex - ? { - ...track, - elements: [...track.elements, ...elements], - } - : track, - ) as TimelineTrack[]; + const updatedTracks = updateTrackInSceneTracks({ + tracks, + trackId: targetTrack.id, + update: (track) => ({ + ...track, + elements: [...track.elements, ...elements], + }), + }); return { updatedTracks, targetTrackId: targetTrack.id }; } const newTrackId = generateUUID(); - const newTrack = { - ...buildEmptyTrack({ id: newTrackId, type: placementResult.trackType }), - elements, - } as TimelineTrack; const insertIndex = newTrackInsertIndexOverride ?? placementResult.insertIndex; - const updatedTracks = [...tracks]; - updatedTracks.splice(insertIndex, 0, newTrack); + const updatedTracks = + placementResult.trackType === "audio" + ? { + ...tracks, + audio: insertIntoAudioTracks({ + tracks, + insertIndex, + track: buildPlacedAudioTrack({ + id: newTrackId, + elements, + }), + }), + } + : { + ...tracks, + overlay: insertIntoOverlayTracks({ + tracks, + insertIndex, + track: buildPlacedOverlayTrack({ + id: newTrackId, + type: placementResult.trackType, + elements, + }), + }), + }; return { updatedTracks, targetTrackId: newTrackId }; } + +function insertIntoOverlayTracks({ + tracks, + insertIndex, + track, +}: { + tracks: SceneTracks; + insertIndex: number; + track: OverlayTrack; +}): OverlayTrack[] { + const normalizedInsertIndex = Math.max( + 0, + Math.min(insertIndex, tracks.overlay.length), + ); + const nextTracks = [...tracks.overlay]; + nextTracks.splice(normalizedInsertIndex, 0, track); + return nextTracks; +} + +function insertIntoAudioTracks({ + tracks, + insertIndex, + track, +}: { + tracks: SceneTracks; + insertIndex: number; + track: AudioTrack; +}): AudioTrack[] { + const audioInsertIndex = Math.max( + 0, + Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length), + ); + const nextTracks = [...tracks.audio]; + nextTracks.splice(audioInsertIndex, 0, track); + return nextTracks; +} + +function buildPlacedAudioTrack({ + id, + elements, +}: { + id: string; + elements: TimelineElement[]; +}): AudioTrack { + return { + ...buildEmptyTrack({ id, type: "audio" }), + elements: elements as AudioTrack["elements"], + }; +} + +function buildPlacedOverlayTrack({ + id, + type, + elements, +}: { + id: string; + type: Exclude; + elements: TimelineElement[]; +}): OverlayTrack { + switch (type) { + case "video": + return { + ...buildEmptyTrack({ id, type: "video" }), + elements: elements as VideoTrack["elements"], + }; + case "text": + return { + ...buildEmptyTrack({ id, type: "text" }), + elements: elements as TextTrack["elements"], + }; + case "graphic": + return { + ...buildEmptyTrack({ id, type: "graphic" }), + elements: elements as GraphicTrack["elements"], + }; + case "effect": + return { + ...buildEmptyTrack({ id, type: "effect" }), + elements: elements as EffectTrack["elements"], + }; + } +} diff --git a/apps/web/src/lib/timeline/placement/index.ts b/apps/web/src/lib/timeline/placement/index.ts index 091acc33..530cb69f 100644 --- a/apps/web/src/lib/timeline/placement/index.ts +++ b/apps/web/src/lib/timeline/placement/index.ts @@ -1,13 +1,7 @@ export { applyPlacement } from "./apply"; export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility"; export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index"; -export { - enforceMainTrackStart, - ensureMainTrack, - getEarliestMainTrackElement, - getMainTrack, - isMainTrack, -} from "./main-track"; +export { MAIN_TRACK_NAME, enforceMainTrackStart, getEarliestMainTrackElement } from "./main-track"; export { resolveTrackPlacement } from "./resolve"; export { buildEmptyTrack } from "./track-factory"; export type { diff --git a/apps/web/src/lib/timeline/placement/insert-index.ts b/apps/web/src/lib/timeline/placement/insert-index.ts index fecb893a..04fa5a4a 100644 --- a/apps/web/src/lib/timeline/placement/insert-index.ts +++ b/apps/web/src/lib/timeline/placement/insert-index.ts @@ -1,44 +1,32 @@ -import type { TrackType, TimelineTrack } from "@/lib/timeline"; -import { isMainTrack } from "./main-track"; +import type { SceneTracks, TrackType } from "@/lib/timeline"; export function getDefaultInsertIndexForTrack({ tracks, trackType, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackType: TrackType; }): number { if (trackType === "audio") { - return tracks.length; + return tracks.overlay.length + 1 + tracks.audio.length; } if (trackType === "effect") { return 0; } - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); - if (mainTrackIndex >= 0) { - return mainTrackIndex; - } - - const firstAudioTrackIndex = tracks.findIndex((track) => track.type === "audio"); - if (firstAudioTrackIndex >= 0) { - return firstAudioTrackIndex; - } - - return tracks.length; + return tracks.overlay.length; } export function getHighestInsertIndexForTrack({ tracks, trackType, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackType: TrackType; }): number { - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); if (trackType === "audio") { - return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length; + return tracks.overlay.length + 1; } return 0; @@ -50,12 +38,13 @@ export function resolvePreferredNewTrackPlacement({ preferredIndex, direction, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackType: TrackType; preferredIndex: number; direction: "above" | "below"; }): { insertIndex: number; insertPosition: "above" | "below" | null } { - if (tracks.length === 0) { + const trackCount = tracks.overlay.length + 1 + tracks.audio.length; + if (trackCount === 0) { return { insertIndex: 0, insertPosition: trackType === "audio" ? "below" : null, @@ -64,9 +53,9 @@ export function resolvePreferredNewTrackPlacement({ const safePreferredIndex = Math.min( Math.max(preferredIndex, 0), - tracks.length - 1, + trackCount - 1, ); - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); + const mainTrackIndex = tracks.overlay.length; if (trackType === "audio") { if (safePreferredIndex <= mainTrackIndex) { diff --git a/apps/web/src/lib/timeline/placement/main-track.ts b/apps/web/src/lib/timeline/placement/main-track.ts index 1783352c..7e206517 100644 --- a/apps/web/src/lib/timeline/placement/main-track.ts +++ b/apps/web/src/lib/timeline/placement/main-track.ts @@ -1,55 +1,14 @@ -import type { TimelineElement, TimelineTrack, VideoTrack } from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; +import type { SceneTracks, TimelineElement, VideoTrack } from "@/lib/timeline"; -const MAIN_TRACK_NAME = "Main Track"; - -export function isMainTrack(track: TimelineTrack): track is VideoTrack { - return track.type === "video" && track.isMain === true; -} - -export function getMainTrack({ - tracks, -}: { - tracks: TimelineTrack[]; -}): VideoTrack | null { - return tracks.find((track) => isMainTrack(track)) ?? null; -} - -export function ensureMainTrack({ - tracks, -}: { - tracks: TimelineTrack[]; -}): TimelineTrack[] { - if (tracks.some((track) => isMainTrack(track))) { - return tracks; - } - - return [ - { - id: generateUUID(), - name: MAIN_TRACK_NAME, - type: "video", - elements: [], - muted: false, - isMain: true, - hidden: false, - }, - ...tracks, - ]; -} +export const MAIN_TRACK_NAME = "Main Track"; export function getEarliestMainTrackElement({ - tracks, + mainTrack, excludeElementId, }: { - tracks: TimelineTrack[]; + mainTrack: VideoTrack; excludeElementId?: string; }): TimelineElement | null { - const mainTrack = getMainTrack({ tracks }); - if (!mainTrack) { - return null; - } - const elements = mainTrack.elements.filter((element) => { return !excludeElementId || element.id !== excludeElementId; }); @@ -70,18 +29,17 @@ export function enforceMainTrackStart({ requestedStartTime, excludeElementId, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; targetTrackId: string; requestedStartTime: number; excludeElementId?: string; }): number { - const mainTrack = getMainTrack({ tracks }); - if (!mainTrack || mainTrack.id !== targetTrackId) { + if (tracks.main.id !== targetTrackId) { return requestedStartTime; } const earliestElement = getEarliestMainTrackElement({ - tracks, + mainTrack: tracks.main, excludeElementId, }); if (!earliestElement) { diff --git a/apps/web/src/lib/timeline/placement/resolve.ts b/apps/web/src/lib/timeline/placement/resolve.ts index d0417d0a..67943912 100644 --- a/apps/web/src/lib/timeline/placement/resolve.ts +++ b/apps/web/src/lib/timeline/placement/resolve.ts @@ -1,4 +1,4 @@ -import type { TrackType, TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks, TrackType, TimelineTrack } from "@/lib/timeline"; import { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack, @@ -15,7 +15,7 @@ import type { } from "./types"; type ResolveTrackPlacementParams = PlacementSubject & { - tracks: TimelineTrack[]; + tracks: SceneTracks; timeSpans: PlacementTimeSpan[]; strategy: PlacementStrategy; }; @@ -28,7 +28,7 @@ function buildExistingTrackResult({ }: { track: TimelineTrack; trackIndex: number; - tracks: TimelineTrack[]; + tracks: SceneTracks; timeSpans: PlacementTimeSpan[]; }): PlacementResult { const firstSpan = timeSpans[0]; @@ -90,7 +90,7 @@ function resolveAlwaysNewTrack({ trackType, position, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackType: TrackType; position: "highest" | "default"; }): PlacementResult { @@ -134,6 +134,7 @@ export function resolveTrackPlacement({ tracks, ...placement }: ResolveTrackPlacementParams): PlacementResult | null { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const trackType = "trackType" in placement ? placement.trackType @@ -143,30 +144,35 @@ export function resolveTrackPlacement({ const { timeSpans, strategy } = placement; if (strategy.type === "explicit") { - const trackIndex = tracks.findIndex( + const trackIndex = orderedTracks.findIndex( (track) => track.id === strategy.trackId, ); if (trackIndex < 0) { return null; } - const track = tracks[trackIndex]; + const track = orderedTracks[trackIndex]; if (track.type !== trackType) { return null; } - return buildExistingTrackResult({ track, trackIndex, tracks, timeSpans }); + return buildExistingTrackResult({ + track, + trackIndex, + tracks, + timeSpans, + }); } if (strategy.type === "firstAvailable") { const existingTrackIndex = findFirstAvailableTrackIndex({ - tracks, + tracks: orderedTracks, trackType, timeSpans, }); if (existingTrackIndex >= 0) { return buildExistingTrackResult({ - track: tracks[existingTrackIndex], + track: orderedTracks[existingTrackIndex], trackIndex: existingTrackIndex, tracks, timeSpans, @@ -181,7 +187,7 @@ export function resolveTrackPlacement({ } if (strategy.type === "preferIndex") { - const preferredTrack = tracks[strategy.trackIndex]; + const preferredTrack = orderedTracks[strategy.trackIndex]; const isPreferredTrackCompatible = !!preferredTrack && preferredTrack.type === trackType; const canUseExistingTrack = @@ -220,7 +226,7 @@ export function resolveTrackPlacement({ if (strategy.type === "aboveSource") { const aboveTrackIndex = strategy.sourceTrackIndex - 1; - const aboveTrack = tracks[aboveTrackIndex]; + const aboveTrack = orderedTracks[aboveTrackIndex]; if ( aboveTrack && aboveTrack.type === trackType && @@ -238,26 +244,23 @@ export function resolveTrackPlacement({ } const firstAvailableTrackIndex = findFirstAvailableTrackIndex({ - tracks, + tracks: orderedTracks, trackType, timeSpans, }); if (firstAvailableTrackIndex >= 0) { return buildExistingTrackResult({ - track: tracks[firstAvailableTrackIndex], + track: orderedTracks[firstAvailableTrackIndex], trackIndex: firstAvailableTrackIndex, tracks, timeSpans, }); } - const insertIndex = - strategy.sourceTrackIndex >= 0 - ? strategy.sourceTrackIndex - : getHighestInsertIndexForTrack({ - tracks, - trackType, - }); + const insertIndex = getHighestInsertIndexForTrack({ + tracks, + trackType, + }); return buildNewTrackResult({ trackType, diff --git a/apps/web/src/lib/timeline/placement/track-factory.ts b/apps/web/src/lib/timeline/placement/track-factory.ts index ee056261..b73c5155 100644 --- a/apps/web/src/lib/timeline/placement/track-factory.ts +++ b/apps/web/src/lib/timeline/placement/track-factory.ts @@ -1,6 +1,69 @@ import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks"; -import type { TrackType, TimelineTrack } from "@/lib/timeline"; +import type { + AudioTrack, + EffectTrack, + GraphicTrack, + TextTrack, + TrackType, + TimelineTrack, + VideoTrack, +} from "@/lib/timeline"; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "video"; + name?: string; +}): VideoTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "text"; + name?: string; +}): TextTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "audio"; + name?: string; +}): AudioTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "graphic"; + name?: string; +}): GraphicTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "effect"; + name?: string; +}): EffectTrack; + +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: TrackType; + name?: string; +}): TimelineTrack; export function buildEmptyTrack({ id, type, @@ -21,7 +84,6 @@ export function buildEmptyTrack({ elements: [], hidden: false, muted: false, - isMain: false, }; case "text": return { diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts index 2e7bdcec..c1768deb 100644 --- a/apps/web/src/lib/timeline/snap-utils.ts +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -1,4 +1,4 @@ -import type { Bookmark, TimelineTrack } from "@/lib/timeline"; +import type { Bookmark, SceneTracks } from "@/lib/timeline"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; import { getElementKeyframes } from "@/lib/animation"; import { TICKS_PER_SECOND } from "@/lib/wasm"; @@ -29,7 +29,7 @@ export function findSnapPoints({ enableBookmarkSnapping = true, enableKeyframeSnapping = true, }: { - tracks: Array; + tracks: SceneTracks; playheadTime: number; excludeElementId?: string; bookmarks?: Array; @@ -40,8 +40,9 @@ export function findSnapPoints({ enableKeyframeSnapping?: boolean; }): SnapPoint[] { const snapPoints: SnapPoint[] = []; + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; - for (const track of tracks) { + for (const track of orderedTracks) { for (const element of track.elements) { if (element.id === excludeElementId) continue; @@ -140,7 +141,7 @@ export function snapElementEdge({ }: { targetTime: number; elementDuration: number; - tracks: Array; + tracks: SceneTracks; playheadTime: number; zoomLevel: number; excludeElementId?: string; diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts index 19765923..f1c2028e 100644 --- a/apps/web/src/lib/timeline/track-element-update.ts +++ b/apps/web/src/lib/timeline/track-element-update.ts @@ -1,33 +1,147 @@ -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks, TimelineElement, TimelineTrack } from "@/lib/timeline"; -export function updateElementInTracks({ +export function findTrackInSceneTracks({ + tracks, + trackId, +}: { + tracks: SceneTracks; + trackId: string; +}): TimelineTrack | null { + if (tracks.main.id === trackId) { + return tracks.main; + } + + return ( + tracks.overlay.find((track) => track.id === trackId) ?? + tracks.audio.find((track) => track.id === trackId) ?? + null + ); +} + +export function updateTrackInSceneTracks({ + tracks, + trackId, + update, +}: { + tracks: SceneTracks; + trackId: string; + update: (track: TTrack) => TTrack; +}): SceneTracks { + if (tracks.main.id === trackId) { + return { + ...tracks, + main: update(tracks.main), + }; + } + + const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId); + if (overlayTrackIndex >= 0) { + return { + ...tracks, + overlay: tracks.overlay.map((track, index) => + index === overlayTrackIndex ? update(track) : track, + ), + }; + } + + const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId); + if (audioTrackIndex >= 0) { + return { + ...tracks, + audio: tracks.audio.map((track, index) => + index === audioTrackIndex ? update(track) : track, + ), + }; + } + + return tracks; +} + +function updateElementInTrack({ + track, + elementId, + update, + elementPredicate, +}: { + track: TTrack; + elementId: string; + update: (element: TimelineElement) => TimelineElement; + elementPredicate?: (element: TimelineElement) => boolean; +}): TTrack { + const nextElements = track.elements.map((element) => { + if (element.id !== elementId) { + return element; + } + if (elementPredicate && !elementPredicate(element)) { + return element; + } + return update(element); + }); + + return { + ...track, + elements: nextElements, + } as TTrack; +} + +export function updateElementInSceneTracks({ tracks, trackId, elementId, update, elementPredicate, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackId: string; elementId: string; update: (element: TimelineElement) => TimelineElement; elementPredicate?: (element: TimelineElement) => boolean; -}): TimelineTrack[] { - return tracks.map((track) => { - if (track.id !== trackId) { - return track; - } +}): SceneTracks { + if (tracks.main.id === trackId) { + return { + ...tracks, + main: updateElementInTrack({ + track: tracks.main, + elementId, + update, + elementPredicate, + }), + }; + } - const nextElements = track.elements.map((element) => { - if (element.id !== elementId) { - return element; - } - if (elementPredicate && !elementPredicate(element)) { - return element; - } - return update(element); - }); + const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId); + if (overlayTrackIndex >= 0) { + return { + ...tracks, + overlay: tracks.overlay.map((track, index) => + index === overlayTrackIndex + ? updateElementInTrack({ + track, + elementId, + update, + elementPredicate, + }) + : track, + ), + }; + } - return { ...track, elements: nextElements } as TimelineTrack; - }); + const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId); + if (audioTrackIndex >= 0) { + return { + ...tracks, + audio: tracks.audio.map((track, index) => + index === audioTrackIndex + ? updateElementInTrack({ + track, + elementId, + update, + elementPredicate, + }) + : track, + ), + }; + } + + return tracks; } diff --git a/apps/web/src/lib/timeline/types.ts b/apps/web/src/lib/timeline/types.ts index ebdc4c23..1b2c7c9b 100644 --- a/apps/web/src/lib/timeline/types.ts +++ b/apps/web/src/lib/timeline/types.ts @@ -20,7 +20,7 @@ export interface TScene { id: string; name: string; isMain: boolean; - tracks: TimelineTrack[]; + tracks: SceneTracks; bookmarks: Bookmark[]; createdAt: Date; updatedAt: Date; @@ -36,7 +36,6 @@ interface BaseTrack { export interface VideoTrack extends BaseTrack { type: "video"; elements: (VideoElement | ImageElement)[]; - isMain: boolean; muted: boolean; hidden: boolean; } @@ -72,6 +71,14 @@ export type TimelineTrack = | GraphicTrack | EffectTrack; +export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack; + +export interface SceneTracks { + overlay: OverlayTrack[]; + main: VideoTrack; + audio: AudioTrack[]; +} + export interface RetimeConfig { rate: number; maintainPitch?: boolean; @@ -286,7 +293,7 @@ export interface ComputeDropTargetParams { elementType: ElementType; mouseX: number; mouseY: number; - tracks: TimelineTrack[]; + tracks: SceneTracks; playheadTime: number; isExternalDrop: boolean; elementDuration: number; diff --git a/apps/web/src/lib/timeline/update-pipeline.ts b/apps/web/src/lib/timeline/update-pipeline.ts index 61d09a9d..2be73441 100644 --- a/apps/web/src/lib/timeline/update-pipeline.ts +++ b/apps/web/src/lib/timeline/update-pipeline.ts @@ -4,14 +4,13 @@ import { getSourceSpanAtClipTime, getTimelineDurationForSourceSpan, } from "@/lib/retime"; -import { enforceMainTrackStart } from "@/lib/timeline/placement"; -import type { RetimeConfig, TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { RetimeConfig, SceneTracks, TimelineElement } from "@/lib/timeline"; import { isRetimableElement } from "@/lib/timeline"; type ElementUpdateField = keyof TimelineElement | string; export interface ElementUpdateContext { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackId: string; } @@ -94,17 +93,36 @@ const enforceRules: ElementUpdateRule[] = [ }, { triggers: ["startTime"], - apply: ({ element, context }) => ({ - element: { - ...element, - startTime: enforceMainTrackStart({ - tracks: context.tracks, - targetTrackId: context.trackId, - requestedStartTime: Math.max(0, element.startTime), - excludeElementId: element.id, - }), - }, - }), + apply: ({ element, context }) => { + const requestedStartTime = Math.max(0, element.startTime); + if (context.trackId !== context.tracks.main.id) { + return { + element: { + ...element, + startTime: requestedStartTime, + }, + }; + } + + const earliestElement = context.tracks.main.elements + .filter((candidate) => candidate.id !== element.id) + .reduce((earliest, candidate) => { + if (!earliest || candidate.startTime < earliest.startTime) { + return candidate; + } + return earliest; + }, null); + + return { + element: { + ...element, + startTime: + !earliestElement || requestedStartTime <= earliestElement.startTime + ? 0 + : requestedStartTime, + }, + }; + }, }, ]; diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index 76b97021..22d941be 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -1,4 +1,4 @@ -import type { TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks, TimelineTrack } from "@/lib/timeline"; import type { MediaAsset } from "@/lib/media/types"; import { RootNode } from "./nodes/root-node"; import { VideoNode } from "./nodes/video-node"; @@ -12,7 +12,6 @@ import { EffectLayerNode } from "./nodes/effect-layer-node"; import type { BaseNode } from "./nodes/base-node"; import type { TBackground, TCanvasSize } from "@/lib/project/types"; import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/lib/background/constants"; -import { isMainTrack } from "@/lib/timeline/placement"; const PREVIEW_MAX_IMAGE_SIZE = 2048; @@ -209,7 +208,7 @@ function buildBlurBackgroundNodes({ export type BuildSceneParams = { canvasSize: TCanvasSize; - tracks: TimelineTrack[]; + tracks: SceneTracks; mediaAssets: MediaAsset[]; duration: number; background: TBackground; @@ -227,19 +226,12 @@ export function buildScene({ const rootNode = new RootNode({ duration }); const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); - const visibleTracks = tracks.filter( - (track) => !("hidden" in track && track.hidden), - ); - - const orderedTracksTopToBottom = [ - ...visibleTracks.filter((track) => !isMainTrack(track)), - ...visibleTracks.filter((track) => isMainTrack(track)), + const visibleTracks = [ + ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)), + ...(!tracks.main.hidden ? [tracks.main] : []), ]; - - const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse(); - const mainTrack = orderedTracksBottomToTop.find((track) => - isMainTrack(track), - ); + const orderedTracksBottomToTop = visibleTracks.slice().reverse(); + const mainTrack = tracks.main.hidden ? undefined : tracks.main; const allNodes = buildTrackNodes({ tracks: orderedTracksBottomToTop, diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index 9f0f3e0d..b7ee37f5 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -22,10 +22,11 @@ import { V19toV20Migration } from "./v19-to-v20"; import { V20toV21Migration } from "./v20-to-v21"; import { V21toV22Migration } from "./v21-to-v22"; import { V22toV23Migration } from "./v22-to-v23"; +import { V23toV24Migration } from "./v23-to-v24"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 23; +export const CURRENT_PROJECT_VERSION = 24; export const migrations = [ new V0toV1Migration(), @@ -51,4 +52,5 @@ export const migrations = [ new V20toV21Migration(), new V21toV22Migration(), new V22toV23Migration(), + new V23toV24Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v23-to-v24.ts b/apps/web/src/services/storage/migrations/transformers/v23-to-v24.ts new file mode 100644 index 00000000..710d4dc1 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v23-to-v24.ts @@ -0,0 +1,113 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV23ToV24({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 24) { + return { project, skipped: true, reason: "already v24" }; + } + if (version !== 23) { + return { project, skipped: true, reason: "not v23" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 24, + }, + skipped: false, + }; +} + +function migrateProject({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + if (!Array.isArray(project.scenes)) { + return project; + } + + return { + ...project, + scenes: project.scenes.map((scene) => migrateScene({ scene })), + }; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene) || !Array.isArray(scene.tracks)) { + return scene; + } + + const mainTrack = findMainTrack({ tracks: scene.tracks }); + if (!mainTrack) { + return scene; + } + + return { + ...scene, + tracks: { + overlay: scene.tracks + .filter((track) => track !== mainTrack) + .map((track) => migrateTrack({ track })) + .filter( + (track): track is ProjectRecord => + isRecord(track) && track.type !== "audio", + ), + main: migrateTrack({ track: mainTrack }), + audio: scene.tracks + .map((track) => migrateTrack({ track })) + .filter( + (track): track is ProjectRecord => + isRecord(track) && track.type === "audio", + ), + }, + }; +} + +function findMainTrack({ + tracks, +}: { + tracks: unknown[]; +}): ProjectRecord | null { + for (const track of tracks) { + if (!isRecord(track)) { + continue; + } + if (track.type === "video" && track.isMain === true) { + return track; + } + } + + for (const track of tracks) { + if (!isRecord(track)) { + continue; + } + if (track.type === "video") { + return track; + } + } + + return null; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track)) { + return track; + } + + const nextTrack = { ...track }; + delete nextTrack.isMain; + return nextTrack; +} diff --git a/apps/web/src/services/storage/migrations/v23-to-v24.ts b/apps/web/src/services/storage/migrations/v23-to-v24.ts new file mode 100644 index 00000000..fe952bb1 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v23-to-v24.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV23ToV24 } from "./transformers/v23-to-v24"; + +export class V23toV24Migration extends StorageMigration { + from = 23; + to = 24; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV23ToV24({ project }); + } +} diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts index 3a014894..1aea0297 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -21,7 +21,7 @@ import { migrations, runStorageMigrations, } from "@/services/storage/migrations"; -import type { Bookmark, TimelineTrack, TScene } from "@/lib/timeline"; +import type { Bookmark, SceneTracks, TScene } from "@/lib/timeline"; function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] { if (!Array.isArray(raw)) return []; @@ -116,18 +116,18 @@ class StorageService { private stripAudioBuffers({ tracks, }: { - tracks: TimelineTrack[]; - }): TimelineTrack[] { - return tracks.map((track) => { - if (track.type !== "audio") return track; - return { + tracks: SceneTracks; + }): SceneTracks { + return { + ...tracks, + audio: tracks.audio.map((track) => ({ ...track, elements: track.elements.map((element) => { const { buffer: _buffer, ...rest } = element; return rest; }), - }; - }); + })), + }; } async saveProject({ project }: { project: TProject }): Promise { @@ -178,11 +178,7 @@ class StorageService { id: scene.id, name: scene.name, isMain: scene.isMain, - tracks: (scene.tracks ?? []).map((track) => - track.type === "video" - ? { ...track, isMain: track.isMain ?? false } // legacy: isMain was optional - : track, - ), + tracks: scene.tracks, bookmarks: normalizeBookmarks({ raw: scene.bookmarks }), createdAt: new Date(scene.createdAt), updatedAt: new Date(scene.updatedAt), diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts index b32d77bc..e7622240 100644 --- a/apps/web/src/stores/sounds-store.ts +++ b/apps/web/src/stores/sounds-store.ts @@ -215,7 +215,7 @@ export const useSoundsStore = create((set, get) => ({ try { const editor = EditorCore.getInstance(); const currentTime = editor.playback.getCurrentTime(); - const tracks = editor.timeline.getTracks(); + const tracks = editor.scenes.getActiveScene().tracks; const response = await fetch(audioUrl); if (!response.ok) @@ -225,7 +225,7 @@ export const useSoundsStore = create((set, get) => ({ const audioContext = new AudioContext(); const buffer = await audioContext.decodeAudioData(arrayBuffer); - const audioTrack = tracks.find((t) => t.type === "audio"); + const audioTrack = tracks.audio[0]; let trackId: string; if (audioTrack) {