summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx2
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx4
-rw-r--r--apps/web/src/components/editor/panels/properties/index.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx4
-rw-r--r--apps/web/src/components/editor/panels/timeline/drop-target.ts16
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/session.ts8
-rw-r--r--apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts3
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx38
-rw-r--r--apps/web/src/core/index.ts21
-rw-r--r--apps/web/src/core/managers/audio-manager.ts2
-rw-r--r--apps/web/src/core/managers/commands.ts17
-rw-r--r--apps/web/src/core/managers/project-manager.ts11
-rw-r--r--apps/web/src/core/managers/renderer-manager.ts2
-rw-r--r--apps/web/src/core/managers/scenes-manager.ts67
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts106
-rw-r--r--apps/web/src/hooks/actions/use-editor-actions.ts16
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts24
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts2
-rw-r--r--apps/web/src/hooks/timeline/use-bookmark-drag.ts2
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts55
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-playhead.ts2
-rw-r--r--apps/web/src/hooks/use-element-preview.ts13
-rw-r--r--apps/web/src/hooks/use-mask-handles.ts4
-rw-r--r--apps/web/src/hooks/use-preview-interaction.ts4
-rw-r--r--apps/web/src/hooks/use-transform-handles.ts4
-rw-r--r--apps/web/src/lib/commands/media/add-media-asset.ts8
-rw-r--r--apps/web/src/lib/commands/media/remove-media-asset.ts202
-rw-r--r--apps/web/src/lib/commands/preview-tracker.ts46
-rw-r--r--apps/web/src/lib/commands/project/update-project-settings.ts92
-rw-r--r--apps/web/src/lib/commands/scene/delete-scene.ts118
-rw-r--r--apps/web/src/lib/commands/scene/index.ts14
-rw-r--r--apps/web/src/lib/commands/scene/move-bookmark.ts118
-rw-r--r--apps/web/src/lib/commands/scene/remove-bookmark.ts118
-rw-r--r--apps/web/src/lib/commands/scene/rename-scene.ts92
-rw-r--r--apps/web/src/lib/commands/scene/toggle-bookmark.ts104
-rw-r--r--apps/web/src/lib/commands/scene/update-bookmark.ts110
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/paste.ts34
-rw-r--r--apps/web/src/lib/commands/timeline/element/delete-elements.ts57
-rw-r--r--apps/web/src/lib/commands/timeline/element/duplicate-elements.ts14
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/add-effect.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/index.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/element/index.ts22
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts44
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/index.ts4
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts122
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts20
-rw-r--r--apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts87
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-elements.ts20
-rw-r--r--apps/web/src/lib/commands/timeline/index.ts10
-rw-r--r--apps/web/src/lib/commands/timeline/track/add-track.ts86
-rw-r--r--apps/web/src/lib/commands/timeline/track/remove-track.ts65
-rw-r--r--apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts80
-rw-r--r--apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts85
-rw-r--r--apps/web/src/lib/commands/timeline/tracks-snapshot.ts6
-rw-r--r--apps/web/src/lib/media/audio.ts19
-rw-r--r--apps/web/src/lib/media/mediabunny.ts4
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts12
-rw-r--r--apps/web/src/lib/ripple/apply.ts28
-rw-r--r--apps/web/src/lib/ripple/diff.ts23
-rw-r--r--apps/web/src/lib/scenes.ts18
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts10
-rw-r--r--apps/web/src/lib/timeline/index.ts9
-rw-r--r--apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts238
-rw-r--r--apps/web/src/lib/timeline/placement/apply.ts149
-rw-r--r--apps/web/src/lib/timeline/placement/index.ts8
-rw-r--r--apps/web/src/lib/timeline/placement/insert-index.ts33
-rw-r--r--apps/web/src/lib/timeline/placement/main-track.ts56
-rw-r--r--apps/web/src/lib/timeline/placement/resolve.ts43
-rw-r--r--apps/web/src/lib/timeline/placement/track-factory.ts66
-rw-r--r--apps/web/src/lib/timeline/snap-utils.ts9
-rw-r--r--apps/web/src/lib/timeline/track-element-update.ts152
-rw-r--r--apps/web/src/lib/timeline/types.ts13
-rw-r--r--apps/web/src/lib/timeline/update-pipeline.ts46
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts22
-rw-r--r--apps/web/src/services/storage/migrations/index.ts4
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v23-to-v24.ts113
-rw-r--r--apps/web/src/services/storage/migrations/v23-to-v24.ts16
-rw-r--r--apps/web/src/services/storage/service.ts22
-rw-r--r--apps/web/src/stores/sounds-store.ts4
91 files changed, 2086 insertions, 1378 deletions
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<TimelineTrack[]>(
+ () =>
+ 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<HTMLDivElement>(null);
@@ -424,13 +431,13 @@ export function Timeline() {
/>
<DragLine
dropTarget={dropTarget}
- tracks={timeline.getTracks()}
+ tracks={tracks}
isVisible={isDragOver && !dropTarget?.targetElement}
headerHeight={timelineHeaderHeight}
/>
<DragLine
dropTarget={dragDropTarget}
- tracks={timeline.getTracks()}
+ tracks={tracks}
isVisible={dragState.isDragging}
headerHeight={timelineHeaderHeight}
/>
@@ -508,6 +515,7 @@ export function Timeline() {
{tracks.length > 0 && (
<TimelineTrackRows
dragElementId={dragState.elementId}
+ mainTrackId={mainTrackId}
zoomLevel={zoomLevel}
dragState={dragState}
tracksScrollRef={tracksScrollRef}
@@ -574,7 +582,14 @@ function TrackLabelsPanel({
hasHorizontalScrollbar: boolean;
}) {
const editor = useEditor();
- const tracks = useEditor((e) => e.timeline.getTracks());
+ const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
+ const tracks = useMemo<TimelineTrack[]>(
+ () =>
+ 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<HTMLDivElement | null>;
@@ -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<TimelineTrack[]>(
+ () =>
+ 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"}
</ContextMenuItem>
- {!isMainTrack(track) && (
+ {track.id !== mainTrackId && (
<ContextMenuItem
icon={<HugeiconsIcon icon={Delete02Icon} />}
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<boolean> {
if (!this.active) return false;
- const tracks = this.editor.timeline.getTracks();
+ const tracks = this.editor.scenes.getActiveScene().tracks;
const mediaAssets = this.editor.media.getAssets();
const duration = this.editor.timeline.getTotalDuration();
const { canvasSize, background } = this.active.settings;
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,
@@ -9,6 +10,10 @@ import type {
} from "@/lib/timeline";
import { calculateTotalDuration } from "@/lib/timeline";
import {
+ findTrackInSceneTracks,
+ updateElementInSceneTracks,
+} from "@/lib/timeline/track-element-update";
+import {
canElementBeHidden,
canElementHaveAudio,
} from "@/lib/timeline/element-utils";
@@ -53,7 +58,7 @@ import type { InsertElementParams } from "@/lib/commands/timeline/element/insert
export class TimelineManager {
private listeners = new Set<() => void>();
private previewOverlay = new Map<string, Partial<TimelineElement>>();
- private previewTracks: TimelineTrack[] | null = null;
+ private previewTracks: SceneTracks | null = null;
constructor(private editor: EditorCore) {}
@@ -193,7 +198,12 @@ export class TimelineManager {
}
getTotalDuration(): number {
- return calculateTotalDuration({ tracks: this.getTracks() });
+ const activeScene = this.editor.scenes.getActiveSceneOrNull();
+ if (!activeScene) {
+ return 0;
+ }
+
+ return calculateTotalDuration({ tracks: activeScene.tracks });
}
getLastFrameTime(): number {
@@ -204,7 +214,12 @@ export class TimelineManager {
}
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
- return this.getTracks().find((track) => track.id === trackId) ?? null;
+ const activeScene = this.editor.scenes.getActiveSceneOrNull();
+ if (!activeScene) {
+ return null;
+ }
+
+ return findTrackInSceneTracks({ tracks: activeScene.tracks, trackId });
}
getElementsWithTracks({
@@ -629,14 +644,20 @@ export class TimelineManager {
} as Partial<TimelineElement>;
this.previewOverlay.set(elementId, mergedOverlay);
}
- const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? [];
+ const committedTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks;
+ if (!committedTracks) {
+ return;
+ }
this.previewTracks = this.applyPreviewOverlay(committedTracks);
this.notify();
}
commitPreview(): void {
if (this.previewOverlay.size === 0) return;
- const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? [];
+ const committedTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks;
+ if (!committedTracks) {
+ return;
+ }
const afterTracks =
this.previewTracks ?? this.applyPreviewOverlay(committedTracks);
const command = new TracksSnapshotCommand(committedTracks, afterTracks);
@@ -653,19 +674,32 @@ export class TimelineManager {
this.notify();
}
- private applyPreviewOverlay(tracks: TimelineTrack[]): TimelineTrack[] {
+ private applyPreviewOverlay(tracks: SceneTracks): SceneTracks {
if (this.previewOverlay.size === 0) return tracks;
- return tracks.map((track) => {
- const hasOverlay = track.elements.some((el) =>
- this.previewOverlay.has(el.id),
+
+ const applyTrackOverlay = <TTrack extends TimelineTrack>(track: TTrack): TTrack => {
+ const hasOverlay = track.elements.some((element) =>
+ this.previewOverlay.has(element.id),
);
- if (!hasOverlay) return track;
- const newElements = track.elements.map((el) => {
- const overlay = this.previewOverlay.get(el.id);
- return overlay ? ({ ...el, ...overlay } as TimelineElement) : el;
+ if (!hasOverlay) {
+ return track;
+ }
+
+ const nextElements = track.elements.map((element) => {
+ const overlay = this.previewOverlay.get(element.id);
+ return overlay
+ ? ({ ...element, ...overlay } as TimelineElement)
+ : element;
});
- return { ...track, elements: newElements } as TimelineTrack;
- });
+
+ return { ...track, elements: nextElements } as TTrack;
+ };
+
+ return {
+ overlay: tracks.overlay.map((track) => applyTrackOverlay(track)),
+ main: applyTrackOverlay(tracks.main),
+ audio: tracks.audio.map((track) => applyTrackOverlay(track)),
+ };
}
duplicateElements({
@@ -734,13 +768,8 @@ export class TimelineManager {
this.updateElements({ updates: nextUpdates });
}
- getTracks(): TimelineTrack[] {
- return this.editor.scenes.getActiveScene()?.tracks ?? [];
- }
-
- getRenderTracks(): TimelineTrack[] {
- if (this.previewTracks !== null) return this.previewTracks;
- return this.getTracks();
+ getPreviewTracks(): SceneTracks | null {
+ return this.previewTracks ?? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null;
}
subscribe(listener: () => void): () => void {
@@ -771,14 +800,35 @@ export class TimelineManager {
}: {
elementId: string;
}): string | null {
- return (
- this.getTracks().find((track) =>
- track.elements.some((element) => element.id === elementId),
- )?.id ?? null
- );
+ const activeScene = this.editor.scenes.getActiveSceneOrNull();
+ if (!activeScene) {
+ return null;
+ }
+
+ if (
+ activeScene.tracks.main.elements.some(
+ (element) => element.id === elementId,
+ )
+ ) {
+ return activeScene.tracks.main.id;
+ }
+
+ for (const track of activeScene.tracks.overlay) {
+ if (track.elements.some((element) => element.id === elementId)) {
+ return track.id;
+ }
+ }
+
+ for (const track of activeScene.tracks.audio) {
+ if (track.elements.some((element) => element.id === elementId)) {
+ return track.id;
+ }
+ }
+
+ return null;
}
- updateTracks(newTracks: TimelineTrack[]): void {
+ updateTracks(newTracks: SceneTracks): void {
this.previewOverlay.clear();
this.previewTracks = null;
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
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<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>;
@@ -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<T extends TimelineElement>({
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<TimelineElement>) =>
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<T> {
- 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<T> {
+ 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<TProjectSettings>) {
- 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<TProjectSettings>) {
+ 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<Omit<Bookmark, "time">>,
- ) {
- 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<Omit<Bookmark, "time">>,
+ ) {
+ 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<TTrack extends TimelineTrack>({
+ 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 = <TTrack extends { id: string; elements: TimelineElement[] }>(
+ 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<TrackType, "audio">;
+}): 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<CollectedAudioElement[]> {
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
const mediaMap = new Map<string, MediaAsset>(
mediaAssets.map((media) => [media.id, media]),
);
const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
- 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<AudioMixSource[]> {
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
const audioMixSources: AudioMixSource[] = [];
const mediaMap = new Map<string, MediaAsset>(
mediaAssets.map((asset) => [asset.id, asset]),
);
const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = [];
- 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<AudioClipSource[]> {
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
const clips: AudioClipSource[] = [];
const mediaMap = new Map<string, MediaAsset>(
mediaAssets.map((asset) => [asset.id, asset]),
);
const pendingLibraryClips: Array<Promise<AudioClipSource | null>> = [];
- 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<string>();
- 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<OverlayTrack>;
+ main?: VideoTrack;
+ audio?: Array<AudioTrack>;
+}): 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<OverlayTrack["type"], "audio">;
+ 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,5 +1,13 @@
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,
@@ -7,6 +15,61 @@ export function buildEmptyTrack({
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,
+ name,
+}: {
+ id: string;
type: TrackType;
name?: string;
}): TimelineTrack {
@@ -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<TimelineTrack>;
+ tracks: SceneTracks;
playheadTime: number;
excludeElementId?: string;
bookmarks?: Array<Bookmark>;
@@ -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<TimelineTrack>;
+ 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: <TTrack extends TimelineTrack>(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<TTrack extends TimelineTrack>({
+ track,
elementId,
update,
elementPredicate,
}: {
- tracks: TimelineTrack[];
- trackId: string;
+ track: TTrack;
elementId: string;
update: (element: TimelineElement) => TimelineElement;
elementPredicate?: (element: TimelineElement) => boolean;
-}): TimelineTrack[] {
- return tracks.map((track) => {
- if (track.id !== trackId) {
- return track;
+}): TTrack {
+ const nextElements = track.elements.map((element) => {
+ if (element.id !== elementId) {
+ return element;
+ }
+ if (elementPredicate && !elementPredicate(element)) {
+ return element;
}
+ return update(element);
+ });
- 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;
+}
- return { ...track, elements: nextElements } as TimelineTrack;
- });
+export function updateElementInSceneTracks({
+ tracks,
+ trackId,
+ elementId,
+ update,
+ elementPredicate,
+}: {
+ tracks: SceneTracks;
+ trackId: string;
+ elementId: string;
+ update: (element: TimelineElement) => TimelineElement;
+ elementPredicate?: (element: TimelineElement) => boolean;
+}): SceneTracks {
+ if (tracks.main.id === trackId) {
+ return {
+ ...tracks,
+ main: updateElementInTrack({
+ track: tracks.main,
+ elementId,
+ update,
+ elementPredicate,
+ }),
+ };
+ }
+
+ 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,
+ ),
+ };
+ }
+
+ 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<TimelineElement | null>((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<ProjectRecord> {
+ 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<void> {
@@ -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<SoundsStore>((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<SoundsStore>((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) {