refactor: structured track layout per scene

This commit is contained in:
Maze Winther 2026-04-07 04:41:27 +02:00
parent 4f7d401a97
commit cbd1b82123
91 changed files with 2086 additions and 1378 deletions

View File

@ -59,7 +59,7 @@ export function Captions() {
setProcessingStep("Extracting audio..."); setProcessingStep("Extracting audio...");
const audioBlob = await extractTimelineAudio({ const audioBlob = await extractTimelineAudio({
tracks: editor.timeline.getTracks(), tracks: editor.scenes.getActiveScene().tracks,
mediaAssets: editor.media.getAssets(), mediaAssets: editor.media.getAssets(),
totalDuration: editor.timeline.getTotalDuration(), totalDuration: editor.timeline.getTotalDuration(),
}); });

View File

@ -73,7 +73,9 @@ export function PreviewPanel() {
function RenderTreeController() { function RenderTreeController() {
const editor = useEditor(); 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 mediaAssets = useEditor((e) => e.media.getAssets());
const activeProject = useEditor((e) => e.project.getActive()); const activeProject = useEditor((e) => e.project.getActive());

View File

@ -17,7 +17,7 @@ import { EmptyView } from "./empty-view";
export function PropertiesPanel() { export function PropertiesPanel() {
const editor = useEditor(); const editor = useEditor();
useEditor((e) => e.timeline.getTracks()); useEditor((e) => e.scenes.getActiveSceneOrNull());
useEditor((e) => e.media.getAssets()); useEditor((e) => e.media.getAssets());
const { selectedElements } = useElementSelection(); const { selectedElements } = useElementSelection();
const { activeTabPerType, setActiveTab } = usePropertiesStore(); const { activeTabPerType, setActiveTab } = usePropertiesStore();

View File

@ -87,7 +87,9 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
fallback: element, fallback: element,
}); });
const maskDefs = masksRegistry.getAll(); 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 currentTime = useEditor((e) => e.playback.getCurrentTime());
const mediaAssets = useEditor((e) => e.media.getAssets()); const mediaAssets = useEditor((e) => e.media.getAssets());
const canvasSize = useEditor( const canvasSize = useEditor(

View File

@ -105,6 +105,8 @@ export function computeDropTarget({
excludeElementId, excludeElementId,
targetElementTypes, targetElementTypes,
}: ComputeDropTargetParams): DropTarget { }: ComputeDropTargetParams): DropTarget {
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
const mainTrackIndex = tracks.overlay.length;
const xPosition = const xPosition =
typeof startTimeOverride === "number" typeof startTimeOverride === "number"
? startTimeOverride ? startTimeOverride
@ -112,7 +114,7 @@ export function computeDropTarget({
? playheadTime ? playheadTime
: Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)); : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
if (tracks.length === 0) { if (orderedTracks.length === 0) {
const placementResult = resolveTrackPlacement({ const placementResult = resolveTrackPlacement({
tracks, tracks,
elementType, elementType,
@ -139,7 +141,11 @@ export function computeDropTarget({
}; };
} }
const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection }); const trackAtMouse = getTrackAtY({
mouseY,
tracks: orderedTracks,
verticalDragDirection,
});
if (!trackAtMouse) { if (!trackAtMouse) {
const isAboveAllTracks = mouseY < 0; const isAboveAllTracks = mouseY < 0;
@ -150,7 +156,7 @@ export function computeDropTarget({
timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
strategy: { strategy: {
type: "preferIndex", type: "preferIndex",
trackIndex: isAboveAllTracks ? 0 : tracks.length - 1, trackIndex: isAboveAllTracks ? 0 : orderedTracks.length - 1,
hoverDirection: isAboveAllTracks ? "above" : "below", hoverDirection: isAboveAllTracks ? "above" : "below",
createNewTrackOnly: true, createNewTrackOnly: true,
}, },
@ -171,12 +177,12 @@ export function computeDropTarget({
} }
const { trackIndex, relativeY } = trackAtMouse; const { trackIndex, relativeY } = trackAtMouse;
const track = tracks[trackIndex]; const track = orderedTracks[trackIndex];
if (targetElementTypes && targetElementTypes.length > 0) { if (targetElementTypes && targetElementTypes.length > 0) {
const targetElement = findElementAtPosition({ const targetElement = findElementAtPosition({
mouseX, mouseX,
tracks, tracks: orderedTracks,
trackIndex, trackIndex,
targetElementTypes, targetElementTypes,
pixelsPerSecond, pixelsPerSecond,

View File

@ -13,7 +13,7 @@ import type {
ScalarGraphKeyframeContext, ScalarGraphKeyframeContext,
SelectedKeyframeRef, SelectedKeyframeRef,
} from "@/lib/animation/types"; } 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 GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
const FLAT_VALUE_EPSILON = 1e-6; const FLAT_VALUE_EPSILON = 1e-6;
@ -91,10 +91,10 @@ function findElementByKeyframe({
tracks, tracks,
keyframe, keyframe,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
keyframe: SelectedKeyframeRef; keyframe: SelectedKeyframeRef;
}): { element: TimelineElement; trackId: string; elementId: string } | null { }): { 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) { if (track.id !== keyframe.trackId) {
continue; continue;
} }
@ -180,7 +180,7 @@ export function resolveGraphEditorSelectionState({
selectedKeyframes, selectedKeyframes,
preferredComponentKey, preferredComponentKey,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
selectedKeyframes: SelectedKeyframeRef[]; selectedKeyframes: SelectedKeyframeRef[];
preferredComponentKey?: string | null; preferredComponentKey?: string | null;
}): GraphEditorSelectionState { }): GraphEditorSelectionState {

View File

@ -15,7 +15,8 @@ import {
export function useGraphEditorController() { export function useGraphEditorController() {
const editor = useEditor(); const editor = useEditor();
const renderTracks = useEditor((currentEditor) => const renderTracks = useEditor((currentEditor) =>
currentEditor.timeline.getRenderTracks(), currentEditor.timeline.getPreviewTracks() ??
currentEditor.scenes.getActiveScene().tracks,
); );
const { selectedKeyframes } = useKeyframeSelection(); const { selectedKeyframes } = useKeyframeSelection();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);

View File

@ -58,7 +58,6 @@ import {
} from "./track-layout"; } from "./track-layout";
import { SELECTED_TRACK_ROW_CLASS } from "./theme"; import { SELECTED_TRACK_ROW_CLASS } from "./theme";
import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction"; import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
import { isMainTrack } from "@/lib/timeline/placement";
import { TimelineToolbar } from "./timeline-toolbar"; import { TimelineToolbar } from "./timeline-toolbar";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
@ -112,7 +111,15 @@ export function Timeline() {
} = useElementSelection(); } = useElementSelection();
const editor = useEditor(); const editor = useEditor();
const timeline = editor.timeline; 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 seek = (time: number) => editor.playback.seek({ time });
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
@ -424,13 +431,13 @@ export function Timeline() {
/> />
<DragLine <DragLine
dropTarget={dropTarget} dropTarget={dropTarget}
tracks={timeline.getTracks()} tracks={tracks}
isVisible={isDragOver && !dropTarget?.targetElement} isVisible={isDragOver && !dropTarget?.targetElement}
headerHeight={timelineHeaderHeight} headerHeight={timelineHeaderHeight}
/> />
<DragLine <DragLine
dropTarget={dragDropTarget} dropTarget={dragDropTarget}
tracks={timeline.getTracks()} tracks={tracks}
isVisible={dragState.isDragging} isVisible={dragState.isDragging}
headerHeight={timelineHeaderHeight} headerHeight={timelineHeaderHeight}
/> />
@ -508,6 +515,7 @@ export function Timeline() {
{tracks.length > 0 && ( {tracks.length > 0 && (
<TimelineTrackRows <TimelineTrackRows
dragElementId={dragState.elementId} dragElementId={dragState.elementId}
mainTrackId={mainTrackId}
zoomLevel={zoomLevel} zoomLevel={zoomLevel}
dragState={dragState} dragState={dragState}
tracksScrollRef={tracksScrollRef} tracksScrollRef={tracksScrollRef}
@ -574,7 +582,14 @@ function TrackLabelsPanel({
hasHorizontalScrollbar: boolean; hasHorizontalScrollbar: boolean;
}) { }) {
const editor = useEditor(); 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 { selectedElements } = useElementSelection();
const tracksWithSelection = useMemo( const tracksWithSelection = useMemo(
() => new Set(selectedElements.map((el) => el.trackId)), () => new Set(selectedElements.map((el) => el.trackId)),
@ -650,6 +665,7 @@ function TrackLabelsPanel({
function TimelineTrackRows({ function TimelineTrackRows({
dragElementId, dragElementId,
mainTrackId,
zoomLevel, zoomLevel,
dragState, dragState,
tracksScrollRef, tracksScrollRef,
@ -665,6 +681,7 @@ function TimelineTrackRows({
dropTarget, dropTarget,
}: { }: {
dragElementId: string | null; dragElementId: string | null;
mainTrackId: string | null;
zoomLevel: number; zoomLevel: number;
dragState: ElementDragState; dragState: ElementDragState;
tracksScrollRef: React.RefObject<HTMLDivElement | null>; tracksScrollRef: React.RefObject<HTMLDivElement | null>;
@ -684,7 +701,14 @@ function TimelineTrackRows({
dropTarget: DropTarget | null; dropTarget: DropTarget | null;
}) { }) {
const timeline = useEditor((e) => e.timeline); 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 { selectedElements } = useElementSelection();
const tracksWithSelection = useMemo( const tracksWithSelection = useMemo(
() => new Set(selectedElements.map((el) => el.trackId)), () => new Set(selectedElements.map((el) => el.trackId)),
@ -779,7 +803,7 @@ function TimelineTrackRows({
? "Show track" ? "Show track"
: "Hide track"} : "Hide track"}
</ContextMenuItem> </ContextMenuItem>
{!isMainTrack(track) && ( {track.id !== mainTrackId && (
<ContextMenuItem <ContextMenuItem
icon={<HugeiconsIcon icon={Delete02Icon} />} icon={<HugeiconsIcon icon={Delete02Icon} />}
onClick={(event: React.MouseEvent) => { onClick={(event: React.MouseEvent) => {

View File

@ -10,7 +10,6 @@ import { AudioManager } from "./managers/audio-manager";
import { SelectionManager } from "./managers/selection-manager"; import { SelectionManager } from "./managers/selection-manager";
import { registerDefaultEffects } from "@/lib/effects"; import { registerDefaultEffects } from "@/lib/effects";
import { registerDefaultMasks } from "@/lib/masks"; import { registerDefaultMasks } from "@/lib/masks";
import { isMainTrack } from "@/lib/timeline/placement";
export class EditorCore { export class EditorCore {
private static instance: EditorCore | null = null; private static instance: EditorCore | null = null;
@ -39,11 +38,21 @@ export class EditorCore {
this.audio = new AudioManager(this); this.audio = new AudioManager(this);
this.selection = new SelectionManager(this); this.selection = new SelectionManager(this);
this.command.registerReactor(() => { this.command.registerReactor(() => {
const tracks = this.timeline.getTracks(); const activeScene = this.scenes.getActiveSceneOrNull();
const prunedTracks = tracks.filter( if (!activeScene) {
(track) => track.elements.length > 0 || isMainTrack(track), return;
); }
if (prunedTracks.length !== tracks.length) {
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); this.timeline.updateTracks(prunedTracks);
} }
}); });

View File

@ -163,7 +163,7 @@ export class AudioManager {
this.playbackSessionId++; this.playbackSessionId++;
this.playbackLatencyCompensationSeconds = 0; this.playbackLatencyCompensationSeconds = 0;
const tracks = this.editor.timeline.getTracks(); const tracks = this.editor.scenes.getActiveScene().tracks;
const mediaAssets = this.editor.media.getAssets(); const mediaAssets = this.editor.media.getAssets();
const duration = this.editor.timeline.getTotalDuration(); const duration = this.editor.timeline.getTotalDuration();

View File

@ -1,7 +1,7 @@
import type { EditorCore } from "@/core"; import type { EditorCore } from "@/core";
import type { Command, CommandResult } from "@/lib/commands"; import type { Command, CommandResult } from "@/lib/commands";
import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple"; import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple";
import type { TimelineTrack, ElementRef } from "@/lib/timeline/types"; import type { ElementRef, SceneTracks } from "@/lib/timeline/types";
interface CommandHistoryEntry { interface CommandHistoryEntry {
command: Command; command: Command;
@ -18,7 +18,9 @@ export class CommandManager {
constructor(private editor: EditorCore) {} constructor(private editor: EditorCore) {}
execute({ command }: { command: Command }): Command { 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 previousSelection = this.getSelectionSnapshot();
const result = command.execute(); const result = command.execute();
this.applyRippleIfEnabled({ beforeTracks }); this.applyRippleIfEnabled({ beforeTracks });
@ -71,7 +73,9 @@ export class CommandManager {
return; 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 previousSelection = this.getSelectionSnapshot();
const result = entry.command.redo(); const result = entry.command.redo();
this.applyRippleIfEnabled({ beforeTracks }); this.applyRippleIfEnabled({ beforeTracks });
@ -123,13 +127,16 @@ export class CommandManager {
private applyRippleIfEnabled({ private applyRippleIfEnabled({
beforeTracks, beforeTracks,
}: { }: {
beforeTracks: TimelineTrack[] | null; beforeTracks: SceneTracks | null;
}): void { }): void {
if (!this.isRippleEnabled || !beforeTracks) { if (!this.isRippleEnabled || !beforeTracks) {
return; return;
} }
const afterTracks = this.editor.timeline.getTracks(); const afterTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks;
if (!afterTracks) {
return;
}
const adjustments = computeRippleAdjustments({ const adjustments = computeRippleAdjustments({
beforeTracks, beforeTracks,
afterTracks, afterTracks,

View File

@ -156,9 +156,14 @@ export class ProjectManager {
await this.editor.media.loadProjectMedia({ projectId: id }); await this.editor.media.loadProjectMedia({ projectId: id });
const allTracks = (project.scenes ?? []).flatMap((scene) => scene.tracks);
await loadFonts({ await loadFonts({
families: getElementFontFamilies({ tracks: allTracks }), families: [
...new Set(
(project.scenes ?? []).flatMap((scene) =>
getElementFontFamilies({ tracks: scene.tracks }),
),
),
],
}); });
if (!project.metadata.thumbnail) { if (!project.metadata.thumbnail) {
@ -643,7 +648,7 @@ export class ProjectManager {
private async updateThumbnailFromTimeline(): Promise<boolean> { private async updateThumbnailFromTimeline(): Promise<boolean> {
if (!this.active) return false; 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 mediaAssets = this.editor.media.getAssets();
const duration = this.editor.timeline.getTotalDuration(); const duration = this.editor.timeline.getTotalDuration();
const { canvasSize, background } = this.active.settings; const { canvasSize, background } = this.active.settings;

View File

@ -139,7 +139,7 @@ export class RendererManager {
const { format, quality, fps, includeAudio } = options; const { format, quality, fps, includeAudio } = options;
try { try {
const tracks = this.editor.timeline.getTracks(); const tracks = this.editor.scenes.getActiveScene().tracks;
const mediaAssets = this.editor.media.getAssets(); const mediaAssets = this.editor.media.getAssets();
const activeProject = this.editor.project.getActive(); const activeProject = this.editor.project.getActive();

View File

@ -1,5 +1,5 @@
import type { EditorCore } from "@/core"; 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 { storageService } from "@/services/storage/service";
import { import {
getMainScene, getMainScene,
@ -12,7 +12,6 @@ import {
getFrameTime, getFrameTime,
isBookmarkAtTime, isBookmarkAtTime,
} from "@/lib/timeline/bookmarks"; } from "@/lib/timeline/bookmarks";
import { ensureMainTrack } from "@/lib/timeline/placement";
import { import {
CreateSceneCommand, CreateSceneCommand,
DeleteSceneCommand, DeleteSceneCommand,
@ -174,10 +173,7 @@ export class ScenesManager {
try { try {
const result = await storageService.loadProject({ id: projectId }); const result = await storageService.loadProject({ id: projectId });
if (result?.project.scenes) { if (result?.project.scenes) {
const { scenes: ensuredScenes, hasAddedMainTrack } = const ensuredScenes = result.project.scenes ?? [];
this.ensureScenesHaveMainTrack({
scenes: result.project.scenes ?? [],
});
const currentScene = findCurrentScene({ const currentScene = findCurrentScene({
scenes: ensuredScenes, scenes: ensuredScenes,
currentSceneId: result.project.currentSceneId, currentSceneId: result.project.currentSceneId,
@ -186,22 +182,6 @@ export class ScenesManager {
this.list = ensuredScenes; this.list = ensuredScenes;
this.active = currentScene; this.active = currentScene;
this.notify(); 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) { } catch (error) {
console.error("Failed to load project scenes:", error); console.error("Failed to load project scenes:", error);
@ -219,26 +199,24 @@ export class ScenesManager {
currentSceneId?: string; currentSceneId?: string;
}): void { }): void {
const ensuredScenes = ensureMainScene({ scenes }); const ensuredScenes = ensureMainScene({ scenes });
const { scenes: scenesWithMainTracks, hasAddedMainTrack } =
this.ensureScenesHaveMainTrack({ scenes: ensuredScenes });
const currentScene = currentSceneId const currentScene = currentSceneId
? scenesWithMainTracks.find((s) => s.id === currentSceneId) ? ensuredScenes.find((s) => s.id === currentSceneId)
: null; : null;
const fallbackScene = getMainScene({ scenes: scenesWithMainTracks }); const fallbackScene = getMainScene({ scenes: ensuredScenes });
this.list = scenesWithMainTracks; this.list = ensuredScenes;
this.active = currentScene || fallbackScene; this.active = currentScene || fallbackScene;
this.notify(); this.notify();
const hasAddedMainScene = ensuredScenes.length > scenes.length; const hasAddedMainScene = ensuredScenes.length > scenes.length;
if (hasAddedMainScene || hasAddedMainTrack) { if (hasAddedMainScene) {
const activeProject = this.editor.project.getActive(); const activeProject = this.editor.project.getActive();
if (activeProject) { if (activeProject) {
const updatedProject = { const updatedProject = {
...activeProject, ...activeProject,
scenes: scenesWithMainTracks, scenes: ensuredScenes,
metadata: { metadata: {
...activeProject.metadata, ...activeProject.metadata,
updatedAt: new Date(), updatedAt: new Date(),
@ -264,6 +242,10 @@ export class ScenesManager {
return this.active; return this.active;
} }
getActiveSceneOrNull(): TScene | null {
return this.active;
}
getScenes(): TScene[] { getScenes(): TScene[] {
return this.list; return this.list;
} }
@ -307,7 +289,7 @@ export class ScenesManager {
}); });
} }
updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void { updateSceneTracks({ tracks }: { tracks: SceneTracks }): void {
if (!this.active) return; if (!this.active) return;
const updatedScene: TScene = { const updatedScene: TScene = {
@ -335,29 +317,4 @@ export class ScenesManager {
this.editor.project.setActiveProject({ project: updatedProject }); 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 };
}
} }

View File

@ -1,6 +1,7 @@
import type { EditorCore } from "@/core"; import type { EditorCore } from "@/core";
import type { ParamValues } from "@/lib/params"; import type { ParamValues } from "@/lib/params";
import type { import type {
SceneTracks,
TrackType, TrackType,
TimelineTrack, TimelineTrack,
TimelineElement, TimelineElement,
@ -8,6 +9,10 @@ import type {
RetimeConfig, RetimeConfig,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { calculateTotalDuration } from "@/lib/timeline"; import { calculateTotalDuration } from "@/lib/timeline";
import {
findTrackInSceneTracks,
updateElementInSceneTracks,
} from "@/lib/timeline/track-element-update";
import { import {
canElementBeHidden, canElementBeHidden,
canElementHaveAudio, canElementHaveAudio,
@ -53,7 +58,7 @@ import type { InsertElementParams } from "@/lib/commands/timeline/element/insert
export class TimelineManager { export class TimelineManager {
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private previewOverlay = new Map<string, Partial<TimelineElement>>(); private previewOverlay = new Map<string, Partial<TimelineElement>>();
private previewTracks: TimelineTrack[] | null = null; private previewTracks: SceneTracks | null = null;
constructor(private editor: EditorCore) {} constructor(private editor: EditorCore) {}
@ -193,7 +198,12 @@ export class TimelineManager {
} }
getTotalDuration(): number { getTotalDuration(): number {
return calculateTotalDuration({ tracks: this.getTracks() }); const activeScene = this.editor.scenes.getActiveSceneOrNull();
if (!activeScene) {
return 0;
}
return calculateTotalDuration({ tracks: activeScene.tracks });
} }
getLastFrameTime(): number { getLastFrameTime(): number {
@ -204,7 +214,12 @@ export class TimelineManager {
} }
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null { 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({ getElementsWithTracks({
@ -629,14 +644,20 @@ export class TimelineManager {
} as Partial<TimelineElement>; } as Partial<TimelineElement>;
this.previewOverlay.set(elementId, mergedOverlay); 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.previewTracks = this.applyPreviewOverlay(committedTracks);
this.notify(); this.notify();
} }
commitPreview(): void { commitPreview(): void {
if (this.previewOverlay.size === 0) return; 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 = const afterTracks =
this.previewTracks ?? this.applyPreviewOverlay(committedTracks); this.previewTracks ?? this.applyPreviewOverlay(committedTracks);
const command = new TracksSnapshotCommand(committedTracks, afterTracks); const command = new TracksSnapshotCommand(committedTracks, afterTracks);
@ -653,19 +674,32 @@ export class TimelineManager {
this.notify(); this.notify();
} }
private applyPreviewOverlay(tracks: TimelineTrack[]): TimelineTrack[] { private applyPreviewOverlay(tracks: SceneTracks): SceneTracks {
if (this.previewOverlay.size === 0) return tracks; if (this.previewOverlay.size === 0) return tracks;
return tracks.map((track) => {
const hasOverlay = track.elements.some((el) => const applyTrackOverlay = <TTrack extends TimelineTrack>(track: TTrack): TTrack => {
this.previewOverlay.has(el.id), const hasOverlay = track.elements.some((element) =>
this.previewOverlay.has(element.id),
); );
if (!hasOverlay) return track; if (!hasOverlay) {
const newElements = track.elements.map((el) => { return track;
const overlay = this.previewOverlay.get(el.id); }
return overlay ? ({ ...el, ...overlay } as TimelineElement) : el;
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({ duplicateElements({
@ -734,13 +768,8 @@ export class TimelineManager {
this.updateElements({ updates: nextUpdates }); this.updateElements({ updates: nextUpdates });
} }
getTracks(): TimelineTrack[] { getPreviewTracks(): SceneTracks | null {
return this.editor.scenes.getActiveScene()?.tracks ?? []; return this.previewTracks ?? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null;
}
getRenderTracks(): TimelineTrack[] {
if (this.previewTracks !== null) return this.previewTracks;
return this.getTracks();
} }
subscribe(listener: () => void): () => void { subscribe(listener: () => void): () => void {
@ -771,14 +800,35 @@ export class TimelineManager {
}: { }: {
elementId: string; elementId: string;
}): string | null { }): string | null {
return ( const activeScene = this.editor.scenes.getActiveSceneOrNull();
this.getTracks().find((track) => if (!activeScene) {
track.elements.some((element) => element.id === elementId), return null;
)?.id ?? 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.previewOverlay.clear();
this.previewTracks = null; this.previewTracks = null;
this.editor.scenes.updateSceneTracks({ tracks: newTracks }); this.editor.scenes.updateSceneTracks({ tracks: newTracks });

View File

@ -179,11 +179,12 @@ export function useEditorActions() {
"split", "split",
() => { () => {
const currentTime = editor.playback.getCurrentTime(); const currentTime = editor.playback.getCurrentTime();
const tracks = editor.scenes.getActiveScene().tracks;
const elementsToSplit = const elementsToSplit =
selectedElements.length > 0 selectedElements.length > 0
? selectedElements ? selectedElements
: getElementsAtTime({ : getElementsAtTime({
tracks: editor.timeline.getTracks(), tracks,
time: currentTime, time: currentTime,
}); });
@ -201,11 +202,12 @@ export function useEditorActions() {
"split-left", "split-left",
() => { () => {
const currentTime = editor.playback.getCurrentTime(); const currentTime = editor.playback.getCurrentTime();
const tracks = editor.scenes.getActiveScene().tracks;
const elementsToSplit = const elementsToSplit =
selectedElements.length > 0 selectedElements.length > 0
? selectedElements ? selectedElements
: getElementsAtTime({ : getElementsAtTime({
tracks: editor.timeline.getTracks(), tracks,
time: currentTime, time: currentTime,
}); });
@ -233,11 +235,12 @@ export function useEditorActions() {
"split-right", "split-right",
() => { () => {
const currentTime = editor.playback.getCurrentTime(); const currentTime = editor.playback.getCurrentTime();
const tracks = editor.scenes.getActiveScene().tracks;
const elementsToSplit = const elementsToSplit =
selectedElements.length > 0 selectedElements.length > 0
? selectedElements ? selectedElements
: getElementsAtTime({ : getElementsAtTime({
tracks: editor.timeline.getTracks(), tracks,
time: currentTime, time: currentTime,
}); });
@ -310,7 +313,12 @@ export function useEditorActions() {
useActionHandler( useActionHandler(
"select-all", "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) => ({ track.elements.map((element) => ({
trackId: track.id, trackId: track.id,
elementId: element.id, elementId: element.id,

View File

@ -21,6 +21,7 @@ import { registerCanceller } from "@/lib/cancel-interaction";
import type { import type {
DropTarget, DropTarget,
ElementDragState, ElementDragState,
SceneTracks,
TimelineElement, TimelineElement,
TimelineTrack, TimelineTrack,
} from "@/lib/timeline"; } from "@/lib/timeline";
@ -101,7 +102,7 @@ function getDragDropTarget({
clientY: number; clientY: number;
elementId: string; elementId: string;
trackId: string; trackId: string;
tracks: TimelineTrack[]; tracks: SceneTracks;
tracksContainerRef: RefObject<HTMLDivElement | null>; tracksContainerRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>; tracksScrollRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>; headerRef?: RefObject<HTMLElement | null>;
@ -113,7 +114,11 @@ function getDragDropTarget({
const scrollContainer = tracksScrollRef.current; const scrollContainer = tracksScrollRef.current;
if (!containerRect || !scrollContainer) return null; 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( const movingElement = sourceTrack?.elements.find(
({ id }) => id === elementId, ({ id }) => id === elementId,
); );
@ -163,7 +168,12 @@ export function useElementInteraction({
}: UseElementInteractionProps) { }: UseElementInteractionProps) {
const editor = useEditor(); const editor = useEditor();
const isShiftHeldRef = useShiftKey(); const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks(); const sceneTracks = editor.scenes.getActiveScene().tracks;
const tracks = [
...sceneTracks.overlay,
sceneTracks.main,
...sceneTracks.audio,
];
const { const {
isElementSelected, isElementSelected,
selectElement, selectElement,
@ -242,7 +252,7 @@ export function useElementInteraction({
const startSnap = snapElementEdge({ const startSnap = snapElementEdge({
targetTime: frameSnappedTime, targetTime: frameSnappedTime,
elementDuration, elementDuration,
tracks, tracks: sceneTracks,
playheadTime, playheadTime,
zoomLevel, zoomLevel,
excludeElementId: movingElement.id, excludeElementId: movingElement.id,
@ -252,7 +262,7 @@ export function useElementInteraction({
const endSnap = snapElementEdge({ const endSnap = snapElementEdge({
targetTime: frameSnappedTime, targetTime: frameSnappedTime,
elementDuration, elementDuration,
tracks, tracks: sceneTracks,
playheadTime, playheadTime,
zoomLevel, zoomLevel,
excludeElementId: movingElement.id, excludeElementId: movingElement.id,
@ -376,7 +386,7 @@ export function useElementInteraction({
clientY, clientY,
elementId: dragState.elementId, elementId: dragState.elementId,
trackId: dragState.trackId, trackId: dragState.trackId,
tracks, tracks: sceneTracks,
tracksContainerRef, tracksContainerRef,
tracksScrollRef, tracksScrollRef,
headerRef, headerRef,
@ -436,7 +446,7 @@ export function useElementInteraction({
clientY, clientY,
elementId: dragState.elementId, elementId: dragState.elementId,
trackId: dragState.trackId, trackId: dragState.trackId,
tracks, tracks: sceneTracks,
tracksContainerRef, tracksContainerRef,
tracksScrollRef, tracksScrollRef,
headerRef, headerRef,

View File

@ -196,7 +196,7 @@ export function useTimelineElementResize({
const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator); const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator);
const shouldSnap = snappingEnabled && !isShiftHeldRef.current; const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (shouldSnap) { if (shouldSnap) {
const tracks = editor.timeline.getTracks(); const tracks = editor.scenes.getActiveScene().tracks;
const playheadTime = editor.playback.getCurrentTime(); const playheadTime = editor.playback.getCurrentTime();
const snapPoints = findSnapPoints({ const snapPoints = findSnapPoints({
tracks, tracks,

View File

@ -44,7 +44,7 @@ export function useBookmarkDrag({
}: UseBookmarkDragProps) { }: UseBookmarkDragProps) {
const editor = useEditor(); const editor = useEditor();
const isShiftHeldRef = useShiftKey(); const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks(); const tracks = editor.scenes.getActiveScene().tracks;
const activeScene = editor.scenes.getActiveScene(); const activeScene = editor.scenes.getActiveScene();
const bookmarks = activeScene?.bookmarks ?? []; const bookmarks = activeScene?.bookmarks ?? [];
const playheadTime = editor.playback.getCurrentTime(); const playheadTime = editor.playback.getCurrentTime();

View File

@ -17,7 +17,6 @@ import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
import { BatchCommand } from "@/lib/commands"; import { BatchCommand } from "@/lib/commands";
import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target"; import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target";
import { getDragData, hasDragData } from "@/lib/drag-data"; import { getDragData, hasDragData } from "@/lib/drag-data";
import { isMainTrack } from "@/lib/timeline/placement";
import type { TrackType, DropTarget, ElementType } from "@/lib/timeline"; import type { TrackType, DropTarget, ElementType } from "@/lib/timeline";
import type { import type {
MediaDragData, MediaDragData,
@ -149,13 +148,13 @@ export function useTimelineDragDrop({
? (dragData as MediaDragData).targetElementTypes ? (dragData as MediaDragData).targetElementTypes
: undefined; : undefined;
const tracks = editor.timeline.getTracks(); const sceneTracks = editor.scenes.getActiveScene().tracks;
const currentTime = editor.playback.getCurrentTime(); const currentTime = editor.playback.getCurrentTime();
const target = computeDropTarget({ const target = computeDropTarget({
elementType, elementType,
mouseX, mouseX,
mouseY, mouseY,
tracks, tracks: sceneTracks,
playheadTime: currentTime, playheadTime: currentTime,
isExternalDrop: isExternal, isExternalDrop: isExternal,
elementDuration: duration, elementDuration: duration,
@ -230,7 +229,11 @@ export function useTimelineDragDrop({
return; 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]; const track = tracks[target.trackIndex];
if (!track) return; if (!track) return;
editor.timeline.insertElement({ editor.timeline.insertElement({
@ -267,7 +270,11 @@ export function useTimelineDragDrop({
return; 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]; const track = tracks[target.trackIndex];
if (!track) return; if (!track) return;
editor.timeline.insertElement({ editor.timeline.insertElement({
@ -305,7 +312,11 @@ export function useTimelineDragDrop({
return; 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]; const track = tracks[target.trackIndex];
if (!track) return; if (!track) return;
editor.timeline.insertElement({ editor.timeline.insertElement({
@ -352,7 +363,11 @@ export function useTimelineDragDrop({
return; 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]; const track = tracks[target.trackIndex];
if (!track) return; if (!track) return;
editor.timeline.insertElement({ editor.timeline.insertElement({
@ -380,7 +395,11 @@ export function useTimelineDragDrop({
return; 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"); const effectTrack = tracks.find((t) => t.type === "effect");
let trackId: string; let trackId: string;
@ -447,16 +466,14 @@ export function useTimelineDragDrop({
const duration = const duration =
createdAsset.duration ?? createdAsset.duration ??
DEFAULT_NEW_ELEMENT_DURATION; DEFAULT_NEW_ELEMENT_DURATION;
const currentTracks = editor.timeline.getTracks(); const sceneTracks = editor.scenes.getActiveScene().tracks;
const currentTime = editor.playback.getCurrentTime(); const currentTime = editor.playback.getCurrentTime();
const onlyTrack = currentTracks[0];
const reuseMainTrackId = const reuseMainTrackId =
createdAsset.type !== "audio" && createdAsset.type !== "audio" &&
currentTracks.length === 1 && sceneTracks.overlay.length === 0 &&
!!onlyTrack && sceneTracks.audio.length === 0 &&
isMainTrack(onlyTrack) && sceneTracks.main.elements.length === 0
onlyTrack.elements.length === 0 ? sceneTracks.main.id
? onlyTrack.id
: null; : null;
const dropTarget = reuseMainTrackId const dropTarget = reuseMainTrackId
? null ? null
@ -464,7 +481,7 @@ export function useTimelineDragDrop({
elementType: createdAsset.type, elementType: createdAsset.type,
mouseX, mouseX,
mouseY, mouseY,
tracks: currentTracks, tracks: sceneTracks,
playheadTime: currentTime, playheadTime: currentTime,
isExternalDrop: true, isExternalDrop: true,
elementDuration: duration, elementDuration: duration,
@ -488,7 +505,11 @@ export function useTimelineDragDrop({
trackId = addTrackCmd.getTrackId(); trackId = addTrackCmd.getTrackId();
editor.command.execute({ command: addTrackCmd }); editor.command.execute({ command: addTrackCmd });
} else { } else {
trackId = currentTracks[dropTarget.trackIndex]?.id; trackId = [
...sceneTracks.overlay,
sceneTracks.main,
...sceneTracks.audio,
][dropTarget.trackIndex]?.id;
} }
} }

View File

@ -90,7 +90,7 @@ export function useTimelinePlayhead({
const shouldSnap = snappingEnabled && !isShiftHeldRef.current; const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => { const time = (() => {
if (!shouldSnap) return frameTime; if (!shouldSnap) return frameTime;
const tracks = editor.timeline.getTracks(); const tracks = editor.scenes.getActiveScene().tracks;
const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
const snapPoints = findSnapPoints({ const snapPoints = findSnapPoints({
tracks, tracks,

View File

@ -1,5 +1,5 @@
import { useEditor } from "@/hooks/use-editor"; 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 * Subscribes to render tracks and returns the live (preview-aware) version of
@ -18,13 +18,14 @@ export function useElementPreview<T extends TimelineElement>({
fallback: T; fallback: T;
}) { }) {
const editor = useEditor(); const editor = useEditor();
useEditor((e) => e.timeline.getRenderTracks()); useEditor((e) => e.timeline.getPreviewTracks());
const previewTracks = editor.timeline.getPreviewTracks();
const renderElement = const renderElement =
(editor.timeline (findTrackInSceneTracks({
.getRenderTracks() tracks: previewTracks ?? editor.scenes.getActiveScene().tracks,
.find((t) => t.id === trackId) trackId,
?.elements.find((el) => el.id === elementId) as T | undefined) ?? })?.elements.find((element) => element.id === elementId) as T | undefined) ??
fallback; fallback;
const previewUpdates = (updates: Partial<TimelineElement>) => const previewUpdates = (updates: Partial<TimelineElement>) =>

View File

@ -45,7 +45,9 @@ export function useMaskHandles({
null, 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 currentTime = useEditor((e) => e.playback.getCurrentTime());
const mediaAssets = useEditor((e) => e.media.getAssets()); const mediaAssets = useEditor((e) => e.media.getAssets());
const canvasSize = useEditor( const canvasSize = useEditor(

View File

@ -180,7 +180,7 @@ export function usePreviewInteraction({
({ clientX, clientY }: React.MouseEvent) => { ({ clientX, clientY }: React.MouseEvent) => {
if (editingText || isMaskMode) return; if (editingText || isMaskMode) return;
const tracks = editor.timeline.getTracks(); const tracks = editor.scenes.getActiveScene().tracks;
const currentTime = editor.playback.getCurrentTime(); const currentTime = editor.playback.getCurrentTime();
const mediaAssets = editor.media.getAssets(); const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize; const canvasSize = editor.project.getActive().settings.canvasSize;
@ -238,7 +238,7 @@ export function usePreviewInteraction({
if (isMaskMode) return; if (isMaskMode) return;
if (button !== 0) return; if (button !== 0) return;
const tracks = editor.timeline.getTracks(); const tracks = editor.scenes.getActiveScene().tracks;
const currentTime = editor.playback.getCurrentTime(); const currentTime = editor.playback.getCurrentTime();
const mediaAssets = editor.media.getAssets(); const mediaAssets = editor.media.getAssets();
const canvasSize = editor.project.getActive().settings.canvasSize; const canvasSize = editor.project.getActive().settings.canvasSize;

View File

@ -132,7 +132,9 @@ export function useTransformHandles({
); );
const selectedElements = useEditor((e) => e.selection.getSelectedElements()); 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 currentTime = useEditor((e) => e.playback.getCurrentTime());
const currentTimeRef = useRef(currentTime); const currentTimeRef = useRef(currentTime);
currentTimeRef.current = currentTime; currentTimeRef.current = currentTime;

View File

@ -54,11 +54,15 @@ export class AddMediaAssetCommand extends Command {
assets: currentAssets.filter((asset) => asset.id !== this.assetId), 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 }> = 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) { for (const element of track.elements) {
if (hasMediaId(element) && element.mediaId === this.assetId) { if (hasMediaId(element) && element.mediaId === this.assetId) {
orphanedElements.push({ orphanedElements.push({

View File

@ -1,99 +1,103 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { MediaAsset } from "@/lib/media/types"; import type { MediaAsset } from "@/lib/media/types";
import { storageService } from "@/services/storage/service"; import { storageService } from "@/services/storage/service";
import { videoCache } from "@/services/video-cache/service"; import { videoCache } from "@/services/video-cache/service";
import { hasMediaId } from "@/lib/timeline/element-utils"; import { hasMediaId } from "@/lib/timeline/element-utils";
import type { TimelineTrack } from "@/lib/timeline"; import type { SceneTracks } from "@/lib/timeline";
export class RemoveMediaAssetCommand extends Command { export class RemoveMediaAssetCommand extends Command {
private savedAssets: MediaAsset[] | null = null; private savedAssets: MediaAsset[] | null = null;
private savedTracks: TimelineTrack[] | null = null; private savedTracks: SceneTracks | null = null;
private removedAsset: MediaAsset | null = null; private removedAsset: MediaAsset | null = null;
constructor( constructor(
private projectId: string, private projectId: string,
private assetId: string, private assetId: string,
) { ) {
super(); super();
} }
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const assets = editor.media.getAssets(); const assets = editor.media.getAssets();
this.savedAssets = [...assets]; this.savedAssets = [...assets];
this.savedTracks = editor.timeline.getTracks(); this.savedTracks = editor.scenes.getActiveScene().tracks;
this.removedAsset = this.removedAsset =
assets.find((media) => media.id === this.assetId) ?? null; assets.find((media) => media.id === this.assetId) ?? null;
if (!this.removedAsset) { if (!this.removedAsset) {
console.error("Media asset not found:", this.assetId); console.error("Media asset not found:", this.assetId);
return; return;
} }
if (this.removedAsset.url) { if (this.removedAsset.url) {
URL.revokeObjectURL(this.removedAsset.url); URL.revokeObjectURL(this.removedAsset.url);
} }
if (this.removedAsset.thumbnailUrl) { if (this.removedAsset.thumbnailUrl) {
URL.revokeObjectURL(this.removedAsset.thumbnailUrl); URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
} }
videoCache.clearVideo({ mediaId: this.assetId }); videoCache.clearVideo({ mediaId: this.assetId });
editor.media.setAssets({ editor.media.setAssets({
assets: assets.filter((media) => media.id !== this.assetId), assets: assets.filter((media) => media.id !== this.assetId),
}); });
const elementsToRemove: Array<{ trackId: string; elementId: string }> = []; const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
for (const track of this.savedTracks) { for (const track of [
for (const element of track.elements) { ...this.savedTracks.overlay,
if (hasMediaId(element) && element.mediaId === this.assetId) { this.savedTracks.main,
elementsToRemove.push({ trackId: track.id, elementId: element.id }); ...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 if (elementsToRemove.length > 0) {
.deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) editor.timeline.deleteElements({ elements: elementsToRemove });
.catch((error) => { }
console.error("Failed to delete media item:", error);
}); storageService
} .deleteMediaAsset({ projectId: this.projectId, id: this.assetId })
.catch((error) => {
undo(): void { console.error("Failed to delete media item:", error);
const editor = EditorCore.getInstance(); });
}
if (this.savedAssets && this.removedAsset) {
const restoredAsset: MediaAsset = { undo(): void {
...this.removedAsset, const editor = EditorCore.getInstance();
url: URL.createObjectURL(this.removedAsset.file),
}; if (this.savedAssets && this.removedAsset) {
const restoredAsset: MediaAsset = {
editor.media.setAssets({ ...this.removedAsset,
assets: this.savedAssets.map((a) => url: URL.createObjectURL(this.removedAsset.file),
a.id === this.assetId ? restoredAsset : a, };
),
}); editor.media.setAssets({
assets: this.savedAssets.map((a) =>
storageService a.id === this.assetId ? restoredAsset : a,
.saveMediaAsset({ ),
projectId: this.projectId, });
mediaAsset: restoredAsset,
}) storageService
.catch((error) => { .saveMediaAsset({
console.error("Failed to restore media item on undo:", error); projectId: this.projectId,
}); mediaAsset: restoredAsset,
} })
.catch((error) => {
if (this.savedTracks) { console.error("Failed to restore media item on undo:", error);
editor.timeline.updateTracks(this.savedTracks); });
} }
}
} if (this.savedTracks) {
editor.timeline.updateTracks(this.savedTracks);
}
}
}

View File

@ -1,23 +1,23 @@
export class PreviewTracker<T> { export class PreviewTracker<T> {
private snapshot: T | null = null; private snapshot: T | null = null;
begin({ state }: { state: T }): void { begin({ state }: { state: T }): void {
if (this.snapshot === null) { if (this.snapshot === null) {
this.snapshot = structuredClone(state); this.snapshot = structuredClone(state);
} }
} }
isActive(): boolean { isActive(): boolean {
return this.snapshot !== null; return this.snapshot !== null;
} }
getSnapshot(): T | null { getSnapshot(): T | null {
return this.snapshot; return this.snapshot;
} }
end(): T | null { end(): T | null {
const snapshot = this.snapshot; const snapshot = this.snapshot;
this.snapshot = null; this.snapshot = null;
return snapshot; return snapshot;
} }
} }

View File

@ -1,46 +1,46 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TProject, TProjectSettings } from "@/lib/project/types"; import type { TProject, TProjectSettings } from "@/lib/project/types";
export class UpdateProjectSettingsCommand extends Command { export class UpdateProjectSettingsCommand extends Command {
private savedSettings: TProjectSettings | null = null; private savedSettings: TProjectSettings | null = null;
private savedUpdatedAt: Date | null = null; private savedUpdatedAt: Date | null = null;
constructor(private updates: Partial<TProjectSettings>) { constructor(private updates: Partial<TProjectSettings>) {
super(); super();
} }
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const activeProject = editor.project.getActive(); const activeProject = editor.project.getActive();
if (!activeProject) return; if (!activeProject) return;
this.savedSettings = activeProject.settings; this.savedSettings = activeProject.settings;
this.savedUpdatedAt = activeProject.metadata.updatedAt; this.savedUpdatedAt = activeProject.metadata.updatedAt;
const updatedProject: TProject = { const updatedProject: TProject = {
...activeProject, ...activeProject,
settings: { ...activeProject.settings, ...this.updates }, settings: { ...activeProject.settings, ...this.updates },
metadata: { ...activeProject.metadata, updatedAt: new Date() }, metadata: { ...activeProject.metadata, updatedAt: new Date() },
}; };
editor.project.setActiveProject({ project: updatedProject }); editor.project.setActiveProject({ project: updatedProject });
editor.save.markDirty(); editor.save.markDirty();
} }
undo(): void { undo(): void {
if (!this.savedSettings || !this.savedUpdatedAt) return; if (!this.savedSettings || !this.savedUpdatedAt) return;
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const activeProject = editor.project.getActive(); const activeProject = editor.project.getActive();
if (!activeProject) return; if (!activeProject) return;
const updatedProject: TProject = { const updatedProject: TProject = {
...activeProject, ...activeProject,
settings: this.savedSettings, settings: this.savedSettings,
metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt }, metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt },
}; };
editor.project.setActiveProject({ project: updatedProject }); editor.project.setActiveProject({ project: updatedProject });
editor.save.markDirty(); editor.save.markDirty();
} }
} }

View File

@ -1,59 +1,59 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline"; import type { TScene } from "@/lib/timeline";
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes"; import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
export class DeleteSceneCommand extends Command { export class DeleteSceneCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
private savedActiveSceneId: string | null = null; private savedActiveSceneId: string | null = null;
private deletedScene: TScene | null = null; private deletedScene: TScene | null = null;
constructor(private sceneId: string) { constructor(private sceneId: string) {
super(); super();
} }
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const scenes = editor.scenes.getScenes(); const scenes = editor.scenes.getScenes();
const activeScene = editor.scenes.getActiveScene(); const activeScene = editor.scenes.getActiveScene();
this.savedScenes = [...scenes]; this.savedScenes = [...scenes];
this.savedActiveSceneId = activeScene?.id ?? null; this.savedActiveSceneId = activeScene?.id ?? null;
this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null; this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null;
if (!this.deletedScene) { if (!this.deletedScene) {
console.error("Scene not found:", this.sceneId); console.error("Scene not found:", this.sceneId);
return; return;
} }
const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene }); const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene });
if (!canDelete) { if (!canDelete) {
console.error("Cannot delete scene:", reason); console.error("Cannot delete scene:", reason);
return; return;
} }
const updatedScenes = scenes.filter((s) => s.id !== this.sceneId); const updatedScenes = scenes.filter((s) => s.id !== this.sceneId);
const newActiveScene = getFallbackSceneAfterDelete({ const newActiveScene = getFallbackSceneAfterDelete({
scenes: updatedScenes, scenes: updatedScenes,
deletedSceneId: this.sceneId, deletedSceneId: this.sceneId,
currentSceneId: activeScene?.id ?? null, currentSceneId: activeScene?.id ?? null,
}); });
editor.scenes.setScenes({ editor.scenes.setScenes({
scenes: updatedScenes, scenes: updatedScenes,
activeSceneId: newActiveScene?.id, activeSceneId: newActiveScene?.id,
}); });
} }
undo(): void { undo(): void {
if (this.savedScenes && this.deletedScene) { if (this.savedScenes && this.deletedScene) {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
editor.scenes.setScenes({ editor.scenes.setScenes({
scenes: this.savedScenes, scenes: this.savedScenes,
activeSceneId: this.savedActiveSceneId ?? undefined, activeSceneId: this.savedActiveSceneId ?? undefined,
}); });
} }
} }
} }

View File

@ -1,7 +1,7 @@
export { CreateSceneCommand } from "./create-scene"; export { CreateSceneCommand } from "./create-scene";
export { DeleteSceneCommand } from "./delete-scene"; export { DeleteSceneCommand } from "./delete-scene";
export { RenameSceneCommand } from "./rename-scene"; export { RenameSceneCommand } from "./rename-scene";
export { ToggleBookmarkCommand } from "./toggle-bookmark"; export { ToggleBookmarkCommand } from "./toggle-bookmark";
export { RemoveBookmarkCommand } from "./remove-bookmark"; export { RemoveBookmarkCommand } from "./remove-bookmark";
export { UpdateBookmarkCommand } from "./update-bookmark"; export { UpdateBookmarkCommand } from "./update-bookmark";
export { MoveBookmarkCommand } from "./move-bookmark"; export { MoveBookmarkCommand } from "./move-bookmark";

View File

@ -1,59 +1,59 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline"; import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes"; import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks"; import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
export class MoveBookmarkCommand extends Command { export class MoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
constructor( constructor(
private fromTime: number, private fromTime: number,
private toTime: number, private toTime: number,
) { ) {
super(); super();
} }
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene(); const activeScene = editor.scenes.getActiveScene();
const activeProject = editor.project.getActive(); const activeProject = editor.project.getActive();
if (!activeScene || !activeProject) { if (!activeScene || !activeProject) {
return; return;
} }
const scenes = editor.scenes.getScenes(); const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes]; this.savedScenes = [...scenes];
const fromFrameTime = getFrameTime({ const fromFrameTime = getFrameTime({
time: this.fromTime, time: this.fromTime,
fps: activeProject.settings.fps, fps: activeProject.settings.fps,
}); });
const toFrameTime = getFrameTime({ const toFrameTime = getFrameTime({
time: this.toTime, time: this.toTime,
fps: activeProject.settings.fps, fps: activeProject.settings.fps,
}); });
const updatedBookmarks = moveBookmarkInArray({ const updatedBookmarks = moveBookmarkInArray({
bookmarks: activeScene.bookmarks, bookmarks: activeScene.bookmarks,
fromTime: fromFrameTime, fromTime: fromFrameTime,
toTime: toFrameTime, toTime: toFrameTime,
}); });
const updatedScenes = updateSceneInArray({ const updatedScenes = updateSceneInArray({
scenes, scenes,
sceneId: activeScene.id, sceneId: activeScene.id,
updates: { bookmarks: updatedBookmarks }, updates: { bookmarks: updatedBookmarks },
}); });
editor.scenes.setScenes({ scenes: updatedScenes }); editor.scenes.setScenes({ scenes: updatedScenes });
} }
undo(): void { undo(): void {
if (this.savedScenes) { if (this.savedScenes) {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes }); editor.scenes.setScenes({ scenes: this.savedScenes });
} }
} }
} }

View File

@ -1,59 +1,59 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline"; import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes"; import { updateSceneInArray } from "@/lib/scenes";
import { import {
getFrameTime, getFrameTime,
removeBookmarkFromArray, removeBookmarkFromArray,
} from "@/lib/timeline/bookmarks"; } from "@/lib/timeline/bookmarks";
export class RemoveBookmarkCommand extends Command { export class RemoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
private frameTime: number = 0; private frameTime: number = 0;
constructor(private time: number) { constructor(private time: number) {
super(); super();
} }
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene(); const activeScene = editor.scenes.getActiveScene();
const activeProject = editor.project.getActive(); const activeProject = editor.project.getActive();
if (!activeScene || !activeProject) { if (!activeScene || !activeProject) {
return; return;
} }
const scenes = editor.scenes.getScenes(); const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes]; this.savedScenes = [...scenes];
this.frameTime = getFrameTime({ this.frameTime = getFrameTime({
time: this.time, time: this.time,
fps: activeProject.settings.fps, fps: activeProject.settings.fps,
}); });
const updatedBookmarks = removeBookmarkFromArray({ const updatedBookmarks = removeBookmarkFromArray({
bookmarks: activeScene.bookmarks, bookmarks: activeScene.bookmarks,
frameTime: this.frameTime, frameTime: this.frameTime,
}); });
if (updatedBookmarks.length === activeScene.bookmarks.length) { if (updatedBookmarks.length === activeScene.bookmarks.length) {
return; return;
} }
const updatedScenes = updateSceneInArray({ const updatedScenes = updateSceneInArray({
scenes, scenes,
sceneId: activeScene.id, sceneId: activeScene.id,
updates: { bookmarks: updatedBookmarks }, updates: { bookmarks: updatedBookmarks },
}); });
editor.scenes.setScenes({ scenes: updatedScenes }); editor.scenes.setScenes({ scenes: updatedScenes });
} }
undo(): void { undo(): void {
if (this.savedScenes) { if (this.savedScenes) {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes }); editor.scenes.setScenes({ scenes: this.savedScenes });
} }
} }
} }

View File

@ -1,46 +1,46 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline"; import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes"; import { updateSceneInArray } from "@/lib/scenes";
export class RenameSceneCommand extends Command { export class RenameSceneCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
private previousName: string | null = null; private previousName: string | null = null;
constructor( constructor(
private sceneId: string, private sceneId: string,
private newName: string, private newName: string,
) { ) {
super(); super();
} }
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const scenes = editor.scenes.getScenes(); const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes]; this.savedScenes = [...scenes];
const scene = scenes.find((s) => s.id === this.sceneId); const scene = scenes.find((s) => s.id === this.sceneId);
if (!scene) { if (!scene) {
console.error("Scene not found:", this.sceneId); console.error("Scene not found:", this.sceneId);
return; return;
} }
this.previousName = scene.name; this.previousName = scene.name;
const updatedScenes = updateSceneInArray({ const updatedScenes = updateSceneInArray({
scenes, scenes,
sceneId: this.sceneId, sceneId: this.sceneId,
updates: { name: this.newName, updatedAt: new Date() }, updates: { name: this.newName, updatedAt: new Date() },
}); });
editor.scenes.setScenes({ scenes: updatedScenes }); editor.scenes.setScenes({ scenes: updatedScenes });
} }
undo(): void { undo(): void {
if (this.savedScenes && this.previousName !== null) { if (this.savedScenes && this.previousName !== null) {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes }); editor.scenes.setScenes({ scenes: this.savedScenes });
} }
} }
} }

View File

@ -1,52 +1,52 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TScene } from "@/lib/timeline"; import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes"; import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks"; import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
export class ToggleBookmarkCommand extends Command { export class ToggleBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
private frameTime: number = 0; private frameTime: number = 0;
constructor(private time: number) { constructor(private time: number) {
super(); super();
} }
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene(); const activeScene = editor.scenes.getActiveScene();
const activeProject = editor.project.getActive(); const activeProject = editor.project.getActive();
if (!activeScene || !activeProject) { if (!activeScene || !activeProject) {
return; return;
} }
const scenes = editor.scenes.getScenes(); const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes]; this.savedScenes = [...scenes];
this.frameTime = getFrameTime({ this.frameTime = getFrameTime({
time: this.time, time: this.time,
fps: activeProject.settings.fps, fps: activeProject.settings.fps,
}); });
const updatedBookmarks = toggleBookmarkInArray({ const updatedBookmarks = toggleBookmarkInArray({
bookmarks: activeScene.bookmarks, bookmarks: activeScene.bookmarks,
frameTime: this.frameTime, frameTime: this.frameTime,
}); });
const updatedScenes = updateSceneInArray({ const updatedScenes = updateSceneInArray({
scenes, scenes,
sceneId: activeScene.id, sceneId: activeScene.id,
updates: { bookmarks: updatedBookmarks }, updates: { bookmarks: updatedBookmarks },
}); });
editor.scenes.setScenes({ scenes: updatedScenes }); editor.scenes.setScenes({ scenes: updatedScenes });
} }
undo(): void { undo(): void {
if (this.savedScenes) { if (this.savedScenes) {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes }); editor.scenes.setScenes({ scenes: this.savedScenes });
} }
} }
} }

View File

@ -1,55 +1,55 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { Bookmark, TScene } from "@/lib/timeline"; import type { Bookmark, TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes"; import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks"; import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks";
export class UpdateBookmarkCommand extends Command { export class UpdateBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
constructor( constructor(
private time: number, private time: number,
private updates: Partial<Omit<Bookmark, "time">>, private updates: Partial<Omit<Bookmark, "time">>,
) { ) {
super(); super();
} }
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const activeScene = editor.scenes.getActiveScene(); const activeScene = editor.scenes.getActiveScene();
const activeProject = editor.project.getActive(); const activeProject = editor.project.getActive();
if (!activeScene || !activeProject) { if (!activeScene || !activeProject) {
return; return;
} }
const scenes = editor.scenes.getScenes(); const scenes = editor.scenes.getScenes();
this.savedScenes = [...scenes]; this.savedScenes = [...scenes];
const frameTime = getFrameTime({ const frameTime = getFrameTime({
time: this.time, time: this.time,
fps: activeProject.settings.fps, fps: activeProject.settings.fps,
}); });
const updatedBookmarks = updateBookmarkInArray({ const updatedBookmarks = updateBookmarkInArray({
bookmarks: activeScene.bookmarks, bookmarks: activeScene.bookmarks,
frameTime, frameTime,
updates: this.updates, updates: this.updates,
}); });
const updatedScenes = updateSceneInArray({ const updatedScenes = updateSceneInArray({
scenes, scenes,
sceneId: activeScene.id, sceneId: activeScene.id,
updates: { bookmarks: updatedBookmarks }, updates: { bookmarks: updatedBookmarks },
}); });
editor.scenes.setScenes({ scenes: updatedScenes }); editor.scenes.setScenes({ scenes: updatedScenes });
} }
undo(): void { undo(): void {
if (this.savedScenes) { if (this.savedScenes) {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
editor.scenes.setScenes({ scenes: this.savedScenes }); editor.scenes.setScenes({ scenes: this.savedScenes });
} }
} }
} }

View File

@ -1,21 +1,16 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { import type {
TimelineTrack, SceneTracks,
TimelineElement, TimelineElement,
ClipboardItem, ClipboardItem,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { import { applyPlacement, resolveTrackPlacement, enforceMainTrackStart } from "@/lib/timeline/placement";
applyPlacement,
resolveTrackPlacement,
isMainTrack,
enforceMainTrackStart,
} from "@/lib/timeline/placement";
import { cloneAnimations } from "@/lib/animation"; import { cloneAnimations } from "@/lib/animation";
export class PasteCommand extends Command { export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private pastedElements: { trackId: string; elementId: string }[] = []; private pastedElements: { trackId: string; elementId: string }[] = [];
private readonly time: number; private readonly time: number;
private readonly clipboardItems: ClipboardItem[]; private readonly clipboardItems: ClipboardItem[];
@ -36,14 +31,14 @@ export class PasteCommand extends Command {
if (this.clipboardItems.length === 0) return undefined; if (this.clipboardItems.length === 0) return undefined;
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
this.pastedElements = []; this.pastedElements = [];
const minStart = Math.min( const minStart = Math.min(
...this.clipboardItems.map((item) => item.element.startTime), ...this.clipboardItems.map((item) => item.element.startTime),
); );
let updatedTracks = [...this.savedState]; let updatedTracks = this.savedState;
const itemsByTrackId = groupClipboardItemsByTrackId({ const itemsByTrackId = groupClipboardItemsByTrackId({
clipboardItems: this.clipboardItems, clipboardItems: this.clipboardItems,
}); });
@ -60,9 +55,11 @@ export class PasteCommand extends Command {
} }
const trackType = items[0].trackType; const trackType = items[0].trackType;
const sourceTrackIndex = updatedTracks.findIndex( const sourceTrackIndex = [
(track) => track.id === trackId, ...updatedTracks.overlay,
); updatedTracks.main,
...updatedTracks.audio,
].findIndex((track) => track.id === trackId);
const placementResult = resolveTrackPlacement({ const placementResult = resolveTrackPlacement({
tracks: updatedTracks, tracks: updatedTracks,
trackType, trackType,
@ -78,8 +75,15 @@ export class PasteCommand extends Command {
let elementsForPlacement = elementsToAdd; let elementsForPlacement = elementsToAdd;
if (placementResult.kind === "existingTrack") { if (placementResult.kind === "existingTrack") {
const targetTrack = updatedTracks[placementResult.trackIndex]; const targetTrack =
if (isMainTrack(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) => const earliestElement = elementsToAdd.reduce((earliest, element) =>
element.startTime < earliest.startTime ? element : earliest, element.startTime < earliest.startTime ? element : earliest,
); );

View File

@ -1,9 +1,28 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; 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 { 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 { export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly elements: { trackId: string; elementId: string }[]; private readonly elements: { trackId: string; elementId: string }[];
constructor({ constructor({
@ -17,28 +36,20 @@ export class DeleteElementsCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const updatedTracks = this.savedState.map((track) => { const updatedTracks: SceneTracks = {
const elementsToDeleteOnTrack = this.elements.filter( overlay: this.savedState.overlay.map((track) =>
(target) => target.trackId === track.id, removeTrackElements({ track, elements: this.elements }),
); ),
main: removeTrackElements({
if (elementsToDeleteOnTrack.length === 0) { track: this.savedState.main,
return track; elements: this.elements,
} }),
audio: this.savedState.audio.map((track) =>
const elements = track.elements.filter( removeTrackElements({ track, elements: this.elements }),
(element) => ),
!this.elements.some( };
(target) =>
target.trackId === track.id &&
target.elementId === element.id,
),
);
return { ...track, elements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);

View File

@ -1,5 +1,5 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; 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 { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement"; import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
@ -11,7 +11,7 @@ interface DuplicateElementsParams {
export class DuplicateElementsCommand extends Command { export class DuplicateElementsCommand extends Command {
private duplicatedElements: { trackId: string; elementId: string }[] = []; private duplicatedElements: { trackId: string; elementId: string }[] = [];
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private elements: DuplicateElementsParams["elements"]; private elements: DuplicateElementsParams["elements"];
constructor({ elements }: DuplicateElementsParams) { constructor({ elements }: DuplicateElementsParams) {
@ -21,12 +21,16 @@ export class DuplicateElementsCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
this.duplicatedElements = []; 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( const elementsToDuplicate = this.elements.filter(
(elementEntry) => elementEntry.trackId === track.id, (elementEntry) => elementEntry.trackId === track.id,
); );

View File

@ -1,7 +1,7 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
import type { TimelineTrack, VisualElement } from "@/lib/timeline"; import type { SceneTracks, VisualElement } from "@/lib/timeline";
import { buildDefaultEffectInstance } from "@/lib/effects"; import { buildDefaultEffectInstance } from "@/lib/effects";
function addEffectToElement({ function addEffectToElement({
@ -17,7 +17,7 @@ function addEffectToElement({
} }
export class AddClipEffectCommand extends Command { export class AddClipEffectCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private effectId: string | null = null; private effectId: string | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
@ -40,9 +40,9 @@ export class AddClipEffectCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const updatedTracks = updateElementInTracks({ const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState, tracks: this.savedState,
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,

View File

@ -1,5 +1,5 @@
export { AddClipEffectCommand } from "./add-effect"; export { AddClipEffectCommand } from "./add-effect";
export { RemoveClipEffectCommand } from "./remove-effect"; export { RemoveClipEffectCommand } from "./remove-effect";
export { ToggleClipEffectCommand } from "./toggle-effect"; export { ToggleClipEffectCommand } from "./toggle-effect";
export { UpdateClipEffectParamsCommand } from "./update-effect-params"; export { UpdateClipEffectParamsCommand } from "./update-effect-params";
export { ReorderClipEffectsCommand } from "./reorder-effect"; export { ReorderClipEffectsCommand } from "./reorder-effect";

View File

@ -1,7 +1,7 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
import type { TimelineTrack, VisualElement } from "@/lib/timeline"; import type { SceneTracks, VisualElement } from "@/lib/timeline";
function removeEffectFromElement({ function removeEffectFromElement({
element, element,
@ -16,7 +16,7 @@ function removeEffectFromElement({
} }
export class RemoveClipEffectCommand extends Command { export class RemoveClipEffectCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly effectId: string; private readonly effectId: string;
@ -38,9 +38,9 @@ export class RemoveClipEffectCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const updatedTracks = updateElementInTracks({ const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState, tracks: this.savedState,
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,

View File

@ -1,7 +1,7 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
import type { TimelineTrack, VisualElement } from "@/lib/timeline"; import type { SceneTracks, VisualElement } from "@/lib/timeline";
function reorderEffectsOnElement({ function reorderEffectsOnElement({
element, element,
@ -19,7 +19,7 @@ function reorderEffectsOnElement({
} }
export class ReorderClipEffectsCommand extends Command { export class ReorderClipEffectsCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly fromIndex: number; private readonly fromIndex: number;
@ -45,9 +45,9 @@ export class ReorderClipEffectsCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const updatedTracks = updateElementInTracks({ const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState, tracks: this.savedState,
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,

View File

@ -1,7 +1,7 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
import type { TimelineTrack, VisualElement } from "@/lib/timeline"; import type { SceneTracks, VisualElement } from "@/lib/timeline";
export function toggleEffectOnElement({ export function toggleEffectOnElement({
element, element,
@ -18,7 +18,7 @@ export function toggleEffectOnElement({
} }
export class ToggleClipEffectCommand extends Command { export class ToggleClipEffectCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly effectId: string; private readonly effectId: string;
@ -40,9 +40,9 @@ export class ToggleClipEffectCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const updatedTracks = updateElementInTracks({ const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState, tracks: this.savedState,
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,

View File

@ -1,8 +1,8 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline";
import type { ParamValues } from "@/lib/params"; import type { ParamValues } from "@/lib/params";
import type { TimelineTrack, VisualElement } from "@/lib/timeline"; import type { SceneTracks, VisualElement } from "@/lib/timeline";
function updateEffectParamsOnElement({ function updateEffectParamsOnElement({
element, element,
@ -32,7 +32,7 @@ function updateEffectParamsOnElement({
} }
export class UpdateClipEffectParamsCommand extends Command { export class UpdateClipEffectParamsCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly effectId: string; private readonly effectId: string;
@ -58,9 +58,9 @@ export class UpdateClipEffectParamsCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const updatedTracks = updateElementInTracks({ const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState, tracks: this.savedState,
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,

View File

@ -1,11 +1,11 @@
export { InsertElementCommand } from "./insert-element"; export { InsertElementCommand } from "./insert-element";
export { DeleteElementsCommand } from "./delete-elements"; export { DeleteElementsCommand } from "./delete-elements";
export { DuplicateElementsCommand } from "./duplicate-elements"; export { DuplicateElementsCommand } from "./duplicate-elements";
export { SplitElementsCommand } from "./split-elements"; export { SplitElementsCommand } from "./split-elements";
export { UpdateElementsCommand } from "./update-elements"; export { UpdateElementsCommand } from "./update-elements";
export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation"; export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation";
export { MoveElementCommand } from "./move-elements"; export { MoveElementCommand } from "./move-elements";
export * from "./keyframes"; export * from "./keyframes";
export * from "./effects"; export * from "./effects";
export * from "./masks"; export * from "./masks";

View File

@ -2,6 +2,7 @@ import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { import type {
CreateTimelineElement, CreateTimelineElement,
SceneTracks,
TimelineTrack, TimelineTrack,
TimelineElement, TimelineElement,
TrackType, TrackType,
@ -30,7 +31,7 @@ export interface InsertElementParams {
export class InsertElementCommand extends Command { export class InsertElementCommand extends Command {
private elementId: string; private elementId: string;
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private targetTrackId: string | null = null; private targetTrackId: string | null = null;
constructor({ element, placement }: InsertElementParams) { constructor({ element, placement }: InsertElementParams) {
@ -45,21 +46,22 @@ export class InsertElementCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
if (!this.savedState) {
console.error("Tracks not available");
return;
}
if (!this.validateElementBasics({ element: this.element })) { if (!this.validateElementBasics({ element: this.element })) {
return; return;
} }
const totalElementsInTimeline = this.savedState.reduce( const totalElementsInTimeline =
(total, t) => total + t.elements.length, this.savedState.main.elements.length +
0, 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 isFirstElement = totalElementsInTimeline === 0;
const newElement = this.buildElement({ element: this.element }); const newElement = this.buildElement({ element: this.element });
@ -194,9 +196,9 @@ export class InsertElementCommand extends Command {
tracks, tracks,
element, element,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
element: TimelineElement; element: TimelineElement;
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null { }): { updatedTracks: SceneTracks; targetTrackId: string } | null {
const placement = this.placement; const placement = this.placement;
if ( if (
@ -231,7 +233,11 @@ export class InsertElementCommand extends Command {
}); });
if (!placementResult) { if (!placementResult) {
if (placement.mode === "explicit") { 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) { if (!targetTrack) {
console.error("Track not found:", placement.trackId); console.error("Track not found:", placement.trackId);
return null; return null;
@ -256,7 +262,7 @@ export class InsertElementCommand extends Command {
} }
: element; : element;
return applyPlacement({ const appliedPlacement = applyPlacement({
tracks, tracks,
placementResult, placementResult,
elements: [elementToPlace], elements: [elementToPlace],
@ -265,5 +271,13 @@ export class InsertElementCommand extends Command {
? placement.insertIndex ? placement.insertIndex
: undefined, : undefined,
}); });
if (!appliedPlacement) {
return null;
}
return {
updatedTracks: appliedPlacement.updatedTracks,
targetTrackId: appliedPlacement.targetTrackId,
};
} }
} }

View File

@ -1,12 +1,12 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; 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 { isVisualElement } from "@/lib/timeline/element-utils";
import type { TimelineTrack } from "@/lib/timeline"; import type { SceneTracks } from "@/lib/timeline";
export class RemoveEffectParamKeyframeCommand extends Command { export class RemoveEffectParamKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly effectId: string; private readonly effectId: string;
@ -36,9 +36,9 @@ export class RemoveEffectParamKeyframeCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const updatedTracks = updateElementInTracks({ const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState, tracks: this.savedState,
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,

View File

@ -7,9 +7,9 @@ import {
resolveAnimationTarget, resolveAnimationTarget,
} from "@/lib/animation"; } from "@/lib/animation";
import { Command, type CommandResult } from "@/lib/commands/base-command"; 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 { AnimationPath, AnimationValue } from "@/lib/animation/types";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import type { SceneTracks, TimelineElement } from "@/lib/timeline";
function sampleValueBeforeRemoval({ function sampleValueBeforeRemoval({
element, element,
@ -86,7 +86,7 @@ function removeKeyframeAndPersist({
} }
export class RemoveKeyframeCommand extends Command { export class RemoveKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly propertyPath: AnimationPath; private readonly propertyPath: AnimationPath;
@ -112,9 +112,9 @@ export class RemoveKeyframeCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const updatedTracks = updateElementInTracks({ const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState, tracks: this.savedState,
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,

View File

@ -1,12 +1,12 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation"; import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation";
import { Command, type CommandResult } from "@/lib/commands/base-command"; 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 { AnimationPath } from "@/lib/animation/types";
import type { TimelineTrack } from "@/lib/timeline"; import type { SceneTracks } from "@/lib/timeline";
export class RetimeKeyframeCommand extends Command { export class RetimeKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly propertyPath: AnimationPath; private readonly propertyPath: AnimationPath;
@ -36,9 +36,9 @@ export class RetimeKeyframeCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const updatedTracks = updateElementInTracks({ const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState, tracks: this.savedState,
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,

View File

@ -4,15 +4,15 @@ import {
updateScalarKeyframeCurve, updateScalarKeyframeCurve,
} from "@/lib/animation"; } from "@/lib/animation";
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { updateElementInTracks } from "@/lib/timeline"; import { updateElementInSceneTracks } from "@/lib/timeline";
import type { import type {
AnimationPath, AnimationPath,
ScalarCurveKeyframePatch, ScalarCurveKeyframePatch,
} from "@/lib/animation/types"; } from "@/lib/animation/types";
import type { TimelineTrack } from "@/lib/timeline"; import type { SceneTracks } from "@/lib/timeline";
export class UpdateScalarKeyframeCurveCommand extends Command { export class UpdateScalarKeyframeCurveCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly propertyPath: AnimationPath; private readonly propertyPath: AnimationPath;
@ -46,9 +46,9 @@ export class UpdateScalarKeyframeCurveCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const updatedTracks = updateElementInTracks({ const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState, tracks: this.savedState,
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,

View File

@ -5,13 +5,13 @@ import {
resolveAnimationTarget, resolveAnimationTarget,
upsertPathKeyframe, upsertPathKeyframe,
} from "@/lib/animation"; } from "@/lib/animation";
import { updateElementInTracks } from "@/lib/timeline"; import { updateElementInSceneTracks } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils"; import { isVisualElement } from "@/lib/timeline/element-utils";
import type { AnimationInterpolation } from "@/lib/animation/types"; import type { AnimationInterpolation } from "@/lib/animation/types";
import type { TimelineTrack } from "@/lib/timeline"; import type { SceneTracks } from "@/lib/timeline";
export class UpsertEffectParamKeyframeCommand extends Command { export class UpsertEffectParamKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly effectId: string; private readonly effectId: string;
@ -53,9 +53,9 @@ export class UpsertEffectParamKeyframeCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const updatedTracks = updateElementInTracks({ const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState, tracks: this.savedState,
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,

View File

@ -1,8 +1,8 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation"; import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation";
import { updateElementInTracks } from "@/lib/timeline"; import { updateElementInSceneTracks } from "@/lib/timeline";
import type { TimelineTrack } from "@/lib/timeline"; import type { SceneTracks } from "@/lib/timeline";
import type { import type {
AnimationPath, AnimationPath,
AnimationInterpolation, AnimationInterpolation,
@ -10,7 +10,7 @@ import type {
} from "@/lib/animation/types"; } from "@/lib/animation/types";
export class UpsertKeyframeCommand extends Command { export class UpsertKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly propertyPath: AnimationPath; private readonly propertyPath: AnimationPath;
@ -48,9 +48,9 @@ export class UpsertKeyframeCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const updatedTracks = updateElementInTracks({ const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState, tracks: this.savedState,
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,

View File

@ -1,2 +1,2 @@
export { RemoveMaskCommand } from "./remove-mask"; export { RemoveMaskCommand } from "./remove-mask";
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";

View File

@ -1,7 +1,7 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { isMaskableElement, updateElementInTracks } from "@/lib/timeline"; import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline";
import type { TimelineTrack, MaskableElement } from "@/lib/timeline"; import type { SceneTracks, MaskableElement } from "@/lib/timeline";
function removeMaskFromElement({ function removeMaskFromElement({
element, element,
@ -16,7 +16,7 @@ function removeMaskFromElement({
} }
export class RemoveMaskCommand extends Command { export class RemoveMaskCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly maskId: string; private readonly maskId: string;
@ -38,9 +38,9 @@ export class RemoveMaskCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const updatedTracks = updateElementInTracks({ const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState, tracks: this.savedState,
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,

View File

@ -1,8 +1,8 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/lib/commands/base-command"; 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 { Mask } from "@/lib/masks/types";
import type { TimelineTrack, MaskableElement } from "@/lib/timeline"; import type { SceneTracks, MaskableElement } from "@/lib/timeline";
export function toggleMaskInvertedOnElement({ export function toggleMaskInvertedOnElement({
element, element,
@ -27,7 +27,7 @@ export function toggleMaskInvertedOnElement({
} }
export class ToggleMaskInvertedCommand extends Command { export class ToggleMaskInvertedCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly maskId: string; private readonly maskId: string;
@ -49,9 +49,9 @@ export class ToggleMaskInvertedCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const updatedTracks = updateElementInTracks({ const updatedTracks = updateElementInSceneTracks({
tracks: this.savedState, tracks: this.savedState,
trackId: this.trackId, trackId: this.trackId,
elementId: this.elementId, elementId: this.elementId,

View File

@ -1,18 +1,23 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { import type {
TimelineTrack, SceneTracks,
TimelineElement, TimelineElement,
TrackType, TrackType,
TimelineTrack,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { import {
buildEmptyTrack, buildEmptyTrack,
validateElementTrackCompatibility, validateElementTrackCompatibility,
enforceMainTrackStart, enforceMainTrackStart,
} from "@/lib/timeline/placement"; } from "@/lib/timeline/placement";
import {
findTrackInSceneTracks,
updateTrackInSceneTracks,
} from "@/lib/timeline/track-element-update";
export class MoveElementCommand extends Command { export class MoveElementCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly sourceTrackId: string; private readonly sourceTrackId: string;
private readonly targetTrackId: string; private readonly targetTrackId: string;
private readonly elementId: string; private readonly elementId: string;
@ -42,11 +47,12 @@ export class MoveElementCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const sourceTrack = this.savedState.find( const sourceTrack = findTrackInSceneTracks({
(track) => track.id === this.sourceTrackId, tracks: this.savedState,
); trackId: this.sourceTrackId,
});
const element = sourceTrack?.elements.find( const element = sourceTrack?.elements.find(
(trackElement) => trackElement.id === this.elementId, (trackElement) => trackElement.id === this.elementId,
); );
@ -55,15 +61,21 @@ export class MoveElementCommand extends Command {
throw new Error("Source track or element not found"); 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; let tracksToUpdate = this.savedState;
if (!targetTrack && this.createTrack) { if (!targetTrack && this.createTrack) {
const newTrack = buildEmptyTrack({ const newTrack = buildEmptyTrack({
id: this.targetTrackId, id: this.targetTrackId,
type: this.createTrack.type, type: this.createTrack.type,
}); });
tracksToUpdate = [...this.savedState]; tracksToUpdate = insertTrackAtDisplayIndex({
tracksToUpdate.splice(this.createTrack.index, 0, newTrack); tracks: this.savedState,
track: newTrack,
insertIndex: this.createTrack.index,
});
targetTrack = newTrack; targetTrack = newTrack;
} }
if (!targetTrack) { if (!targetTrack) {
@ -94,32 +106,34 @@ export class MoveElementCommand extends Command {
const isSameTrack = this.sourceTrackId === this.targetTrackId; const isSameTrack = this.sourceTrackId === this.targetTrackId;
const updatedTracks = tracksToUpdate.map((track): TimelineTrack => { const updatedTracks = isSameTrack
if (isSameTrack && track.id === this.sourceTrackId) { ? updateTrackInSceneTracks({
return { tracks: tracksToUpdate,
...track, trackId: this.sourceTrackId,
elements: track.elements.map((trackElement) => update: (track) => ({
trackElement.id === this.elementId ? movedElement : trackElement, ...track,
), elements: track.elements.map((trackElement) =>
} as typeof track; trackElement.id === this.elementId ? movedElement : trackElement,
} ),
}),
if (track.id === this.sourceTrackId) { })
const remainingElements = track.elements.filter( : updateTrackInSceneTracks({
(trackElement) => trackElement.id !== this.elementId, tracks: updateTrackInSceneTracks({
); tracks: tracksToUpdate,
return { ...track, elements: remainingElements } as typeof track; trackId: this.sourceTrackId,
} update: (track) => ({
...track,
if (track.id === this.targetTrackId) { elements: track.elements.filter(
return { (trackElement) => trackElement.id !== this.elementId,
...track, ),
elements: [...track.elements, movedElement], }),
} as typeof track; }),
} trackId: this.targetTrackId,
update: (track) => ({
return track; ...track,
}); elements: [...track.elements, movedElement],
}),
});
editor.timeline.updateTracks(updatedTracks); editor.timeline.updateTracks(updatedTracks);
return undefined; 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),
],
};
}

View File

@ -1,5 +1,5 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; 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 { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { isRetimableElement } from "@/lib/timeline"; import { isRetimableElement } from "@/lib/timeline";
@ -7,7 +7,7 @@ import { splitAnimationsAtTime } from "@/lib/animation";
import { getSourceSpanAtClipTime } from "@/lib/retime"; import { getSourceSpanAtClipTime } from "@/lib/retime";
export class SplitElementsCommand extends Command { export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private rightSideElements: { trackId: string; elementId: string }[] = []; private rightSideElements: { trackId: string; elementId: string }[] = [];
private readonly elements: { trackId: string; elementId: string }[]; private readonly elements: { trackId: string; elementId: string }[];
private readonly splitTime: number; private readonly splitTime: number;
@ -34,10 +34,12 @@ export class SplitElementsCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
this.rightSideElements = []; this.rightSideElements = [];
const updatedTracks = this.savedState.map((track) => { const splitTrack = <TTrack extends { id: string; elements: TimelineElement[] }>(
track: TTrack,
): TTrack => {
const elementsToSplit = this.elements.filter( const elementsToSplit = this.elements.filter(
(target) => target.trackId === track.id, (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); editor.timeline.updateTracks(updatedTracks);

View File

@ -3,18 +3,20 @@ import { Command, type CommandResult } from "@/lib/commands/base-command";
import { import {
buildSeparatedAudioElement, buildSeparatedAudioElement,
canExtractSourceAudio, canExtractSourceAudio,
canRecoverSourceAudio, isSourceAudioSeparated,
} from "@/lib/timeline/audio-separation"; } from "@/lib/timeline/audio-separation";
import { import { buildEmptyTrack } from "@/lib/timeline/placement";
applyPlacement, import { updateElementInSceneTracks } from "@/lib/timeline/track-element-update";
resolveTrackPlacement, import type {
} from "@/lib/timeline/placement"; AudioTrack,
import { updateElementInTracks } from "@/lib/timeline/track-element-update"; SceneTracks,
import type { TimelineTrack, VideoElement } from "@/lib/timeline/types"; TimelineElement,
VideoElement,
} from "@/lib/timeline/types";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
export class ToggleSourceAudioSeparationCommand extends Command { export class ToggleSourceAudioSeparationCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
constructor( constructor(
private readonly params: { private readonly params: {
@ -27,27 +29,27 @@ export class ToggleSourceAudioSeparationCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); 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, (track) => track.id === this.params.trackId,
); );
if (trackIndex < 0) { if (!sourceTrack) {
return; return;
} }
const sourceTrack = this.savedState[trackIndex];
const sourceElement = sourceTrack.elements.find( const sourceElement = sourceTrack.elements.find(
(element) => element.id === this.params.elementId, (element) => element.id === this.params.elementId,
); ) as TimelineElement | undefined;
if (!sourceElement) { if (!sourceElement || sourceElement.type !== "video") {
return;
}
if (sourceElement.type !== "video") {
return; return;
} }
const videoElement: VideoElement = sourceElement;
if (canRecoverSourceAudio(sourceElement)) { if (isSourceAudioSeparated({ element: videoElement })) {
editor.timeline.updateTracks( editor.timeline.updateTracks(
updateSourceAudioEnabled({ updateSourceAudioEnabled({
tracks: this.savedState, tracks: this.savedState,
@ -62,47 +64,34 @@ export class ToggleSourceAudioSeparationCommand extends Command {
const mediaAsset = editor const mediaAsset = editor
.media .media
.getAssets() .getAssets()
.find((asset) => asset.id === sourceElement.mediaId); .find((asset) => asset.id === videoElement.mediaId);
if (!canExtractSourceAudio(sourceElement, mediaAsset)) { if (!canExtractSourceAudio(videoElement, mediaAsset)) {
return; return;
} }
if (sourceElement.duration <= 0) { if (videoElement.duration <= 0) {
return; return;
} }
const separatedAudioElement = { const separatedAudioElement = {
...buildSeparatedAudioElement({ ...buildSeparatedAudioElement({
sourceElement, sourceElement: videoElement,
}), }),
id: generateUUID(), id: generateUUID(),
}; };
const placementResult = resolveTrackPlacement({ const newAudioTrack = {
tracks: this.savedState, ...buildEmptyTrack({
trackType: "audio", id: generateUUID(),
timeSpans: [ type: "audio",
{ }),
startTime: separatedAudioElement.startTime,
duration: separatedAudioElement.duration,
},
],
strategy: { type: "aboveSource", sourceTrackIndex: trackIndex },
});
if (!placementResult) {
return;
}
const appliedPlacement = applyPlacement({
tracks: this.savedState,
placementResult,
elements: [separatedAudioElement], elements: [separatedAudioElement],
}); } as AudioTrack;
if (!appliedPlacement) {
return;
}
editor.timeline.updateTracks( editor.timeline.updateTracks(
updateSourceAudioEnabled({ updateSourceAudioEnabled({
tracks: appliedPlacement.updatedTracks, tracks: {
...this.savedState,
audio: [...this.savedState.audio, newAudioTrack],
},
trackId: this.params.trackId, trackId: this.params.trackId,
elementId: this.params.elementId, elementId: this.params.elementId,
isSourceAudioEnabled: false, isSourceAudioEnabled: false,
@ -126,12 +115,12 @@ function updateSourceAudioEnabled({
elementId, elementId,
isSourceAudioEnabled, isSourceAudioEnabled,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
trackId: string; trackId: string;
elementId: string; elementId: string;
isSourceAudioEnabled: boolean; isSourceAudioEnabled: boolean;
}): TimelineTrack[] { }): SceneTracks {
return updateElementInTracks({ return updateElementInSceneTracks({
tracks, tracks,
trackId, trackId,
elementId, elementId,

View File

@ -1,11 +1,14 @@
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import type { SceneTracks, TimelineElement } from "@/lib/timeline";
import { updateElementInTracks } from "@/lib/timeline"; import {
findTrackInSceneTracks,
updateElementInSceneTracks,
} from "@/lib/timeline";
import { applyElementUpdate } from "@/lib/timeline/update-pipeline"; import { applyElementUpdate } from "@/lib/timeline/update-pipeline";
export class UpdateElementsCommand extends Command { export class UpdateElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
private readonly updates: Array<{ private readonly updates: Array<{
trackId: string; trackId: string;
elementId: string; elementId: string;
@ -27,13 +30,14 @@ export class UpdateElementsCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
let updatedTracks = this.savedState; let updatedTracks = this.savedState;
for (const updateEntry of this.updates) { for (const updateEntry of this.updates) {
const currentTrack = updatedTracks.find( const currentTrack = findTrackInSceneTracks({
(track) => track.id === updateEntry.trackId, tracks: updatedTracks,
); trackId: updateEntry.trackId,
});
const currentElement = currentTrack?.elements.find( const currentElement = currentTrack?.elements.find(
(element) => element.id === updateEntry.elementId, (element) => element.id === updateEntry.elementId,
); );
@ -50,7 +54,7 @@ export class UpdateElementsCommand extends Command {
}, },
}); });
updatedTracks = updateElementInTracks({ updatedTracks = updateElementInSceneTracks({
tracks: updatedTracks, tracks: updatedTracks,
trackId: updateEntry.trackId, trackId: updateEntry.trackId,
elementId: updateEntry.elementId, elementId: updateEntry.elementId,

View File

@ -1,5 +1,5 @@
export * from "./track"; export * from "./track";
export * from "./element"; export * from "./element";
export * from "./clipboard"; export * from "./clipboard";
export { TracksSnapshotCommand } from "./tracks-snapshot"; export { TracksSnapshotCommand } from "./tracks-snapshot";

View File

@ -1,5 +1,5 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; 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 { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { import {
@ -9,7 +9,7 @@ import {
export class AddTrackCommand extends Command { export class AddTrackCommand extends Command {
private trackId: string; private trackId: string;
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
constructor( constructor(
private type: TrackType, private type: TrackType,
@ -21,21 +21,28 @@ export class AddTrackCommand extends Command {
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); 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 = const insertIndex =
this.index ?? this.index ??
getDefaultInsertIndexForTrack({ getDefaultInsertIndexForTrack({
tracks: updatedTracks, tracks: this.savedState,
trackType: this.type, 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); editor.timeline.updateTracks(updatedTracks);
return undefined; return undefined;
@ -52,3 +59,60 @@ export class AddTrackCommand extends Command {
return this.trackId; 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),
],
};
}

View File

@ -1,35 +1,30 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; import { Command, type CommandResult } from "@/lib/commands/base-command";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import type { TimelineTrack } from "@/lib/timeline"; import type { SceneTracks } from "@/lib/timeline";
import { getMainTrack } from "@/lib/timeline/placement";
export class RemoveTrackCommand extends Command {
export class RemoveTrackCommand extends Command { private savedState: SceneTracks | null = null;
private savedState: TimelineTrack[] | null = null;
constructor(private trackId: string) {
constructor(private trackId: string) { super();
super(); }
}
execute(): CommandResult | undefined {
execute(): CommandResult | undefined { const editor = EditorCore.getInstance();
const editor = EditorCore.getInstance(); this.savedState = editor.scenes.getActiveScene().tracks;
this.savedState = editor.timeline.getTracks(); const updatedTracks: SceneTracks = {
const targetTrack = this.savedState.find( ...this.savedState,
(track) => track.id === this.trackId, overlay: this.savedState.overlay.filter((track) => track.id !== this.trackId),
); audio: this.savedState.audio.filter((track) => track.id !== this.trackId),
const mainTrack = getMainTrack({ tracks: this.savedState }); };
if (mainTrack?.id === targetTrack?.id) { editor.timeline.updateTracks(updatedTracks);
return; return undefined;
} }
const updatedTracks = this.savedState.filter(
(track) => track.id !== this.trackId, undo(): void {
); if (this.savedState) {
editor.timeline.updateTracks(updatedTracks); const editor = EditorCore.getInstance();
} editor.timeline.updateTracks(this.savedState);
}
undo(): void { }
if (this.savedState) { }
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -1,39 +1,41 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; 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 { EditorCore } from "@/core";
import { canTrackHaveAudio } from "@/lib/timeline"; import { canTrackHaveAudio, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/lib/timeline";
export class ToggleTrackMuteCommand extends Command { export class ToggleTrackMuteCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
constructor(private trackId: string) { constructor(private trackId: string) {
super(); super();
} }
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const targetTrack = this.savedState.find( const targetTrack = findTrackInSceneTracks({
(track) => track.id === this.trackId, tracks: this.savedState,
); trackId: this.trackId,
if (!targetTrack) { });
return; if (!targetTrack) {
} return;
}
const updatedTracks = this.savedState.map((track) =>
track.id === this.trackId && canTrackHaveAudio(track) const updatedTracks = updateTrackInSceneTracks({
? { ...track, muted: !track.muted } tracks: this.savedState,
: track, trackId: this.trackId,
); update: (track) =>
canTrackHaveAudio(track) ? { ...track, muted: !track.muted } : track,
editor.timeline.updateTracks(updatedTracks); });
}
editor.timeline.updateTracks(updatedTracks);
undo(): void { }
if (this.savedState) {
const editor = EditorCore.getInstance(); undo(): void {
editor.timeline.updateTracks(this.savedState); if (this.savedState) {
} const editor = EditorCore.getInstance();
} editor.timeline.updateTracks(this.savedState);
} }
}
}

View File

@ -1,40 +1,45 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; 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 { EditorCore } from "@/core";
import { canTrackBeHidden } from "@/lib/timeline"; import { canTrackBeHidden, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/lib/timeline";
export class ToggleTrackVisibilityCommand extends Command { export class ToggleTrackVisibilityCommand extends Command {
private savedState: TimelineTrack[] | null = null; private savedState: SceneTracks | null = null;
constructor(private trackId: string) { constructor(private trackId: string) {
super(); super();
} }
execute(): CommandResult | undefined { execute(): CommandResult | undefined {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks(); this.savedState = editor.scenes.getActiveScene().tracks;
const targetTrack = this.savedState.find( const targetTrack = findTrackInSceneTracks({
(track) => track.id === this.trackId, tracks: this.savedState,
); trackId: this.trackId,
if (!targetTrack) { });
return; if (!targetTrack) {
} return;
}
const updatedTracks = this.savedState.map((track) => {
if (track.id === this.trackId && canTrackBeHidden(track)) { const updatedTracks = updateTrackInSceneTracks({
return { ...track, hidden: !track.hidden }; tracks: this.savedState,
} trackId: this.trackId,
return track; update: (track) => {
}); if (canTrackBeHidden(track)) {
return { ...track, hidden: !track.hidden };
editor.timeline.updateTracks(updatedTracks); }
} return track;
},
undo(): void { });
if (this.savedState) {
const editor = EditorCore.getInstance(); editor.timeline.updateTracks(updatedTracks);
editor.timeline.updateTracks(this.savedState); }
}
} undo(): void {
} if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
}
}
}

View File

@ -1,11 +1,11 @@
import { Command, type CommandResult } from "@/lib/commands/base-command"; 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 { EditorCore } from "@/core";
export class TracksSnapshotCommand extends Command { export class TracksSnapshotCommand extends Command {
constructor( constructor(
private before: TimelineTrack[], private before: SceneTracks,
private after: TimelineTrack[], private after: SceneTracks,
) { ) {
super(); super();
} }

View File

@ -2,7 +2,7 @@ import type {
AudioElement, AudioElement,
LibraryAudioElement, LibraryAudioElement,
RetimeConfig, RetimeConfig,
TimelineTrack, SceneTracks,
} from "@/lib/timeline"; } from "@/lib/timeline";
import { shouldMaintainPitch } from "@/lib/retime/rate"; import { shouldMaintainPitch } from "@/lib/retime/rate";
import type { MediaAsset } from "@/lib/media/types"; import type { MediaAsset } from "@/lib/media/types";
@ -89,16 +89,17 @@ export async function collectAudioElements({
mediaAssets, mediaAssets,
audioContext, audioContext,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
audioContext: AudioContext; audioContext: AudioContext;
}): Promise<CollectedAudioElement[]> { }): Promise<CollectedAudioElement[]> {
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
const mediaMap = new Map<string, MediaAsset>( const mediaMap = new Map<string, MediaAsset>(
mediaAssets.map((media) => [media.id, media]), mediaAssets.map((media) => [media.id, media]),
); );
const pendingElements: Array<Promise<CollectedAudioElement | null>> = []; const pendingElements: Array<Promise<CollectedAudioElement | null>> = [];
for (const track of tracks) { for (const track of orderedTracks) {
if (canTrackHaveAudio(track) && track.muted) continue; if (canTrackHaveAudio(track) && track.muted) continue;
for (const element of track.elements) { for (const element of track.elements) {
@ -445,16 +446,17 @@ export async function collectAudioMixSources({
tracks, tracks,
mediaAssets, mediaAssets,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
}): Promise<AudioMixSource[]> { }): Promise<AudioMixSource[]> {
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
const audioMixSources: AudioMixSource[] = []; const audioMixSources: AudioMixSource[] = [];
const mediaMap = new Map<string, MediaAsset>( const mediaMap = new Map<string, MediaAsset>(
mediaAssets.map((asset) => [asset.id, asset]), mediaAssets.map((asset) => [asset.id, asset]),
); );
const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = []; const pendingLibrarySources: Array<Promise<AudioMixSource | null>> = [];
for (const track of tracks) { for (const track of orderedTracks) {
if (canTrackHaveAudio(track) && track.muted) continue; if (canTrackHaveAudio(track) && track.muted) continue;
for (const element of track.elements) { for (const element of track.elements) {
@ -507,16 +509,17 @@ export async function collectAudioClips({
tracks, tracks,
mediaAssets, mediaAssets,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
}): Promise<AudioClipSource[]> { }): Promise<AudioClipSource[]> {
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
const clips: AudioClipSource[] = []; const clips: AudioClipSource[] = [];
const mediaMap = new Map<string, MediaAsset>( const mediaMap = new Map<string, MediaAsset>(
mediaAssets.map((asset) => [asset.id, asset]), mediaAssets.map((asset) => [asset.id, asset]),
); );
const pendingLibraryClips: Array<Promise<AudioClipSource | null>> = []; const pendingLibraryClips: Array<Promise<AudioClipSource | null>> = [];
for (const track of tracks) { for (const track of orderedTracks) {
const isTrackMuted = canTrackHaveAudio(track) && track.muted; const isTrackMuted = canTrackHaveAudio(track) && track.muted;
for (const element of track.elements) { for (const element of track.elements) {
@ -587,7 +590,7 @@ export async function createTimelineAudioBuffer({
sampleRate = EXPORT_SAMPLE_RATE, sampleRate = EXPORT_SAMPLE_RATE,
audioContext, audioContext,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
duration: number; duration: number;
sampleRate?: number; sampleRate?: number;

View File

@ -1,6 +1,6 @@
import { Input, ALL_FORMATS, BlobSource } from "mediabunny"; import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
import { createTimelineAudioBuffer } from "@/lib/media/audio"; 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"; import type { MediaAsset } from "@/lib/media/types";
export async function getVideoInfo({ export async function getVideoInfo({
@ -48,7 +48,7 @@ export const extractTimelineAudio = async ({
totalDuration, totalDuration,
onProgress, onProgress,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
totalDuration: number; totalDuration: number;
onProgress?: (progress: number) => void; onProgress?: (progress: number) => void;

View File

@ -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 type { MediaAsset } from "@/lib/media/types";
import { isMainTrack } from "@/lib/timeline/placement";
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants"; import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics"; import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
import { measureTextElement } from "@/lib/text/measure-element"; import { measureTextElement } from "@/lib/text/measure-element";
@ -266,18 +265,15 @@ export function getVisibleElementsWithBounds({
canvasSize, canvasSize,
mediaAssets, mediaAssets,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
currentTime: number; currentTime: number;
canvasSize: { width: number; height: number }; canvasSize: { width: number; height: number };
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
}): ElementWithBounds[] { }): ElementWithBounds[] {
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
const visibleTracks = tracks.filter(
(track) => !("hidden" in track && track.hidden),
);
const orderedTracks = [ const orderedTracks = [
...visibleTracks.filter((track) => !isMainTrack(track)), ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
...visibleTracks.filter((track) => isMainTrack(track)), ...(!tracks.main.hidden ? [tracks.main] : []),
].reverse(); ].reverse();
const result: ElementWithBounds[] = []; const result: ElementWithBounds[] = [];

View File

@ -1,4 +1,4 @@
import type { TimelineTrack } from "@/lib/timeline/types"; import type { SceneTracks, TimelineTrack } from "@/lib/timeline/types";
import { rippleShiftElements } from "./shift"; import { rippleShiftElements } from "./shift";
export interface RippleAdjustment { export interface RippleAdjustment {
@ -11,9 +11,9 @@ export function applyRippleAdjustments({
tracks, tracks,
adjustments, adjustments,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
adjustments: RippleAdjustment[]; adjustments: RippleAdjustment[];
}): TimelineTrack[] { }): SceneTracks {
if (adjustments.length === 0) { if (adjustments.length === 0) {
return tracks; return tracks;
} }
@ -25,12 +25,24 @@ export function applyRippleAdjustments({
adjustmentsByTrack.set(adjustment.trackId, trackAdjustments); adjustmentsByTrack.set(adjustment.trackId, trackAdjustments);
} }
return tracks.map((track) => return {
applyTrackRippleAdjustments({ overlay: tracks.overlay.map((track) =>
track, applyTrackRippleAdjustments({
adjustments: adjustmentsByTrack.get(track.id) ?? [], 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< function applyTrackRippleAdjustments<

View File

@ -1,5 +1,4 @@
import type { SceneTracks, TimelineElement, TimelineTrack } from "@/lib/timeline/types";
import type { TimelineElement, TimelineTrack } from "@/lib/timeline/types";
import type { RippleAdjustment } from "./apply"; import type { RippleAdjustment } from "./apply";
interface Interval { interface Interval {
@ -15,15 +14,25 @@ export function computeRippleAdjustments({
beforeTracks, beforeTracks,
afterTracks, afterTracks,
}: { }: {
beforeTracks: TimelineTrack[]; beforeTracks: SceneTracks;
afterTracks: TimelineTrack[]; afterTracks: SceneTracks;
}): RippleAdjustment[] { }): 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( 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({ computeTrackRippleAdjustments({
trackId: beforeTrack.id, trackId: beforeTrack.id,
beforeElements: beforeTrack.elements, beforeElements: beforeTrack.elements,

View File

@ -1,7 +1,7 @@
import type { TScene } from "@/lib/timeline"; import type { TScene } from "@/lib/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/lib/timeline"; 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 { export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
return scenes.find((scene) => scene.isMain) || null; return scenes.find((scene) => scene.isMain) || null;
@ -23,12 +23,22 @@ export function buildDefaultScene({
name: string; name: string;
isMain: boolean; isMain: boolean;
}): TScene { }): TScene {
const tracks = ensureMainTrack({ tracks: [] });
return { return {
id: generateUUID(), id: generateUUID(),
name, name,
isMain, isMain,
tracks, tracks: {
overlay: [],
main: {
id: generateUUID(),
name: MAIN_TRACK_NAME,
type: "video",
elements: [],
muted: false,
hidden: false,
},
audio: [],
},
bookmarks: [], bookmarks: [],
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
@ -81,7 +91,7 @@ export function getProjectDurationFromScenes({
scenes: TScene[]; scenes: TScene[];
}): number { }): number {
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null; const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
if (!mainScene?.tracks || !Array.isArray(mainScene.tracks)) { if (!mainScene?.tracks) {
return 0; return 0;
} }

View File

@ -13,6 +13,7 @@ import {
type CreateLibraryAudioElement, type CreateLibraryAudioElement,
type TextBackground, type TextBackground,
type TextElement, type TextElement,
type SceneTracks,
type TimelineElement, type TimelineElement,
type TimelineTrack, type TimelineTrack,
type AudioElement, type AudioElement,
@ -382,12 +383,13 @@ export function getElementsAtTime({
tracks, tracks,
time, time,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
time: number; time: number;
}): { trackId: string; elementId: string }[] { }): { trackId: string; elementId: string }[] {
const result: { 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) { for (const element of track.elements) {
const elementStart = element.startTime; const elementStart = element.startTime;
const elementEnd = element.startTime + element.duration; const elementEnd = element.startTime + element.duration;
@ -404,10 +406,10 @@ export function getElementsAtTime({
export function getElementFontFamilies({ export function getElementFontFamilies({
tracks, tracks,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
}): string[] { }): string[] {
const families = new Set<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) { for (const element of track.elements) {
if (element.type === "text" && element.fontFamily) { if (element.type === "text" && element.fontFamily) {
families.add(element.fontFamily); families.add(element.fontFamily);

View File

@ -1,4 +1,4 @@
import type { TimelineTrack } from "./types"; import type { SceneTracks } from "./types";
export * from "./types"; export * from "./types";
export * from "./drag"; export * from "./drag";
@ -13,11 +13,12 @@ export * from "./pixel-utils";
export function calculateTotalDuration({ export function calculateTotalDuration({
tracks, tracks,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
}): number { }): 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) => { track.elements.reduce((maxEnd, element) => {
const elementEnd = element.startTime + element.duration; const elementEnd = element.startTime + element.duration;
return Math.max(maxEnd, elementEnd); return Math.max(maxEnd, elementEnd);

View File

@ -4,6 +4,8 @@ import type {
AudioTrack, AudioTrack,
GraphicElement, GraphicElement,
GraphicTrack, GraphicTrack,
OverlayTrack,
SceneTracks,
TextElement, TextElement,
TextTrack, TextTrack,
TimelineTrack, TimelineTrack,
@ -134,53 +136,45 @@ type BuildTrackParams =
id: string; id: string;
type: "audio"; type: "audio";
elements?: AudioTrack["elements"]; elements?: AudioTrack["elements"];
isMain?: boolean;
} }
| { | {
id: string; id: string;
type: "graphic"; type: "graphic";
elements?: GraphicTrack["elements"]; elements?: GraphicTrack["elements"];
isMain?: boolean;
} }
| { | {
id: string; id: string;
type: "text"; type: "text";
elements?: TextTrack["elements"]; elements?: TextTrack["elements"];
isMain?: boolean;
} }
| { | {
id: string; id: string;
type: "video"; type: "video";
elements?: VideoTrack["elements"]; elements?: VideoTrack["elements"];
isMain?: boolean;
}; };
function buildTrack(params: { function buildTrack(params: {
id: string; id: string;
type: "audio"; type: "audio";
elements?: AudioTrack["elements"]; elements?: AudioTrack["elements"];
isMain?: boolean;
}): AudioTrack; }): AudioTrack;
function buildTrack(params: { function buildTrack(params: {
id: string; id: string;
type: "graphic"; type: "graphic";
elements?: GraphicTrack["elements"]; elements?: GraphicTrack["elements"];
isMain?: boolean;
}): GraphicTrack; }): GraphicTrack;
function buildTrack(params: { function buildTrack(params: {
id: string; id: string;
type: "text"; type: "text";
elements?: TextTrack["elements"]; elements?: TextTrack["elements"];
isMain?: boolean;
}): TextTrack; }): TextTrack;
function buildTrack(params: { function buildTrack(params: {
id: string; id: string;
type: "video"; type: "video";
elements?: VideoTrack["elements"]; elements?: VideoTrack["elements"];
isMain?: boolean;
}): VideoTrack; }): VideoTrack;
function buildTrack(params: BuildTrackParams): TimelineTrack { function buildTrack(params: BuildTrackParams): TimelineTrack {
const { id, type, isMain = false } = params; const { id, type } = params;
switch (type) { switch (type) {
case "audio": case "audio":
@ -213,7 +207,6 @@ function buildTrack(params: BuildTrackParams): TimelineTrack {
type: "video", type: "video",
name: id, name: id,
elements: params.elements ?? [], elements: params.elements ?? [],
isMain,
muted: false, muted: false,
hidden: false, hidden: false,
}; };
@ -234,9 +227,32 @@ function buildTimeSpan({
return { startTime, duration, excludeElementId }; 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", () => { describe("resolveTrackPlacement", () => {
test("explicit returns the requested compatible track", () => { 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( expect(
resolveTrackPlacement({ resolveTrackPlacement({
@ -254,7 +270,9 @@ describe("resolveTrackPlacement", () => {
}); });
test("explicit rejects missing and incompatible tracks", () => { 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( expect(
resolveTrackPlacement({ resolveTrackPlacement({
@ -276,16 +294,18 @@ describe("resolveTrackPlacement", () => {
}); });
test("firstAvailable picks the first compatible track without overlap", () => { test("firstAvailable picks the first compatible track without overlap", () => {
const tracks = [ const tracks = buildSceneTracks({
buildTrack({ overlay: [
id: "text-1", buildTrack({
type: "text", id: "text-1",
elements: [ type: "text",
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), elements: [
], buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
}), ],
buildTrack({ id: "text-2", type: "text" }), }),
]; buildTrack({ id: "text-2", type: "text" }),
],
});
expect( expect(
resolveTrackPlacement({ resolveTrackPlacement({
@ -303,21 +323,22 @@ describe("resolveTrackPlacement", () => {
}); });
test("firstAvailable creates a new track when all compatible tracks are full", () => { test("firstAvailable creates a new track when all compatible tracks are full", () => {
const tracks = [ const tracks = buildSceneTracks({
buildTrack({ overlay: [
id: "graphic-1", buildTrack({
type: "graphic", id: "graphic-1",
elements: [ type: "graphic",
buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }), elements: [
], buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }),
}), ],
buildTrack({ }),
],
main: buildTrack({
id: "video-main", id: "video-main",
type: "video", type: "video",
isMain: true,
}), }),
buildTrack({ id: "audio-1", type: "audio" }), audio: [buildTrack({ id: "audio-1", type: "audio" })],
]; });
expect( expect(
resolveTrackPlacement({ resolveTrackPlacement({
@ -335,7 +356,9 @@ describe("resolveTrackPlacement", () => {
}); });
test("preferIndex uses the preferred track when it fits", () => { 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( expect(
resolveTrackPlacement({ resolveTrackPlacement({
@ -349,18 +372,18 @@ describe("resolveTrackPlacement", () => {
}, },
}), }),
).toEqual({ ).toEqual({
kind: "existingTrack", kind: "newTrack",
trackId: "audio-1",
trackIndex: 0,
trackType: "audio", trackType: "audio",
insertIndex: 1,
insertPosition: "below",
}); });
}); });
test("preferIndex creates a new overlay track above the main track", () => { test("preferIndex creates a new overlay track above the main track", () => {
const tracks = [ const tracks = buildSceneTracks({
buildTrack({ id: "video-main", type: "video", isMain: true }), main: buildTrack({ id: "video-main", type: "video" }),
buildTrack({ id: "audio-1", type: "audio" }), audio: [buildTrack({ id: "audio-1", type: "audio" })],
]; });
expect( expect(
resolveTrackPlacement({ resolveTrackPlacement({
@ -382,11 +405,11 @@ describe("resolveTrackPlacement", () => {
}); });
test("preferIndex keeps audio tracks below the main track", () => { test("preferIndex keeps audio tracks below the main track", () => {
const tracks = [ const tracks = buildSceneTracks({
buildTrack({ id: "text-1", type: "text" }), overlay: [buildTrack({ id: "text-1", type: "text" })],
buildTrack({ id: "video-main", type: "video", isMain: true }), main: buildTrack({ id: "video-main", type: "video" }),
buildTrack({ id: "audio-1", type: "audio" }), audio: [buildTrack({ id: "audio-1", type: "audio" })],
]; });
expect( expect(
resolveTrackPlacement({ resolveTrackPlacement({
@ -409,17 +432,19 @@ describe("resolveTrackPlacement", () => {
}); });
test("aboveSource tries the track above source, then any compatible track", () => { test("aboveSource tries the track above source, then any compatible track", () => {
const tracks = [ const tracks = buildSceneTracks({
buildTrack({ id: "text-top", type: "text" }), overlay: [
buildTrack({ buildTrack({ id: "text-top", type: "text" }),
id: "text-middle", buildTrack({
type: "text", id: "text-middle",
elements: [ type: "text",
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), elements: [
], buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
}), ],
buildTrack({ id: "text-source", type: "text" }), }),
]; buildTrack({ id: "text-source", type: "text" }),
],
});
expect( expect(
resolveTrackPlacement({ resolveTrackPlacement({
@ -436,23 +461,25 @@ describe("resolveTrackPlacement", () => {
}); });
}); });
test("aboveSource creates a new track near the source when none fit", () => { test("aboveSource creates a new overlay track in the overlay zone when none fit", () => {
const tracks = [ const tracks = buildSceneTracks({
buildTrack({ overlay: [
id: "text-top", buildTrack({
type: "text", id: "text-top",
elements: [ type: "text",
buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), elements: [
], buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
}), ],
buildTrack({ }),
id: "text-source", buildTrack({
type: "text", id: "text-source",
elements: [ type: "text",
buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }), elements: [
], buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }),
}), ],
]; }),
],
});
expect( expect(
resolveTrackPlacement({ resolveTrackPlacement({
@ -464,16 +491,16 @@ describe("resolveTrackPlacement", () => {
).toEqual({ ).toEqual({
kind: "newTrack", kind: "newTrack",
trackType: "text", trackType: "text",
insertIndex: 1, insertIndex: 0,
insertPosition: null, insertPosition: null,
}); });
}); });
test("alwaysNew honors highest and default insertion rules", () => { test("alwaysNew honors highest and default insertion rules", () => {
const tracks = [ const tracks = buildSceneTracks({
buildTrack({ id: "video-main", type: "video", isMain: true }), main: buildTrack({ id: "video-main", type: "video" }),
buildTrack({ id: "audio-1", type: "audio" }), audio: [buildTrack({ id: "audio-1", type: "audio" })],
]; });
expect( expect(
resolveTrackPlacement({ resolveTrackPlacement({
@ -505,16 +532,18 @@ describe("resolveTrackPlacement", () => {
}); });
test("batch time spans reject tracks when any span overlaps", () => { test("batch time spans reject tracks when any span overlaps", () => {
const tracks = [ const tracks = buildSceneTracks({
buildTrack({ audio: [
id: "audio-1", buildTrack({
type: "audio", id: "audio-1",
elements: [ type: "audio",
buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }), elements: [
buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }), buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }),
], buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }),
}), ],
]; }),
],
});
expect( expect(
resolveTrackPlacement({ 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( expect(
resolveTrackPlacement({ resolveTrackPlacement({
tracks: [], tracks: buildSceneTracks({}),
elementType: "video", elementType: "video",
timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })], timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })],
strategy: { strategy: {
@ -551,12 +580,14 @@ describe("resolveTrackPlacement", () => {
kind: "newTrack", kind: "newTrack",
trackType: "video", trackType: "video",
insertIndex: 0, insertIndex: 0,
insertPosition: null, insertPosition: "above",
}); });
expect( expect(
resolveTrackPlacement({ resolveTrackPlacement({
tracks: [buildTrack({ id: "audio-1", type: "audio" })], tracks: buildSceneTracks({
audio: [buildTrack({ id: "audio-1", type: "audio" })],
}),
elementType: "audio", elementType: "audio",
timeSpans: [], timeSpans: [],
strategy: { type: "alwaysNew", position: "default" }, strategy: { type: "alwaysNew", position: "default" },
@ -564,22 +595,21 @@ describe("resolveTrackPlacement", () => {
).toEqual({ ).toEqual({
kind: "newTrack", kind: "newTrack",
trackType: "audio", trackType: "audio",
insertIndex: 1, insertIndex: 2,
insertPosition: null, insertPosition: null,
}); });
}); });
test("existingTrack on main video includes adjustedStartTime when start snaps", () => { test("existingTrack on main video includes adjustedStartTime when start snaps", () => {
const tracks = [ const tracks = buildSceneTracks({
buildTrack({ main: buildTrack({
id: "video-main", id: "video-main",
type: "video", type: "video",
isMain: true,
elements: [ elements: [
buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }), buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }),
], ],
}), }),
]; });
expect( expect(
resolveTrackPlacement({ resolveTrackPlacement({
@ -598,11 +628,11 @@ describe("resolveTrackPlacement", () => {
}); });
test("preferIndex uses vertical drag direction when hovered track is incompatible", () => { test("preferIndex uses vertical drag direction when hovered track is incompatible", () => {
const tracks = [ const tracks = buildSceneTracks({
buildTrack({ id: "text-1", type: "text" }), overlay: [buildTrack({ id: "text-1", type: "text" })],
buildTrack({ id: "video-main", type: "video", isMain: true }), main: buildTrack({ id: "video-main", type: "video" }),
buildTrack({ id: "audio-1", type: "audio" }), audio: [buildTrack({ id: "audio-1", type: "audio" })],
]; });
expect( expect(
resolveTrackPlacement({ resolveTrackPlacement({

View File

@ -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 { generateUUID } from "@/utils/id";
import { buildEmptyTrack } from "./track-factory"; import { buildEmptyTrack } from "./track-factory";
import type { PlacementResult } from "./types"; import type { PlacementResult } from "./types";
import { updateTrackInSceneTracks } from "@/lib/timeline/track-element-update";
export function applyPlacement({ export function applyPlacement({
tracks, tracks,
@ -9,37 +20,139 @@ export function applyPlacement({
elements, elements,
newTrackInsertIndexOverride, newTrackInsertIndexOverride,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
placementResult: PlacementResult; placementResult: PlacementResult;
elements: TimelineElement[]; elements: TimelineElement[];
newTrackInsertIndexOverride?: number; newTrackInsertIndexOverride?: number;
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null { }): { updatedTracks: SceneTracks; targetTrackId: string } | null {
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
if (placementResult.kind === "existingTrack") { if (placementResult.kind === "existingTrack") {
const targetTrack = tracks[placementResult.trackIndex]; const targetTrack = orderedTracks[placementResult.trackIndex];
if (!targetTrack) { if (!targetTrack) {
return null; return null;
} }
const updatedTracks = tracks.map((track, trackIndex) => const updatedTracks = updateTrackInSceneTracks({
trackIndex === placementResult.trackIndex tracks,
? { trackId: targetTrack.id,
...track, update: (track) => ({
elements: [...track.elements, ...elements], ...track,
} elements: [...track.elements, ...elements],
: track, }),
) as TimelineTrack[]; });
return { updatedTracks, targetTrackId: targetTrack.id }; return { updatedTracks, targetTrackId: targetTrack.id };
} }
const newTrackId = generateUUID(); const newTrackId = generateUUID();
const newTrack = {
...buildEmptyTrack({ id: newTrackId, type: placementResult.trackType }),
elements,
} as TimelineTrack;
const insertIndex = const insertIndex =
newTrackInsertIndexOverride ?? placementResult.insertIndex; newTrackInsertIndexOverride ?? placementResult.insertIndex;
const updatedTracks = [...tracks]; const updatedTracks =
updatedTracks.splice(insertIndex, 0, newTrack); 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 }; 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"],
};
}
}

View File

@ -1,13 +1,7 @@
export { applyPlacement } from "./apply"; export { applyPlacement } from "./apply";
export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility"; export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility";
export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index"; export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index";
export { export { MAIN_TRACK_NAME, enforceMainTrackStart, getEarliestMainTrackElement } from "./main-track";
enforceMainTrackStart,
ensureMainTrack,
getEarliestMainTrackElement,
getMainTrack,
isMainTrack,
} from "./main-track";
export { resolveTrackPlacement } from "./resolve"; export { resolveTrackPlacement } from "./resolve";
export { buildEmptyTrack } from "./track-factory"; export { buildEmptyTrack } from "./track-factory";
export type { export type {

View File

@ -1,44 +1,32 @@
import type { TrackType, TimelineTrack } from "@/lib/timeline"; import type { SceneTracks, TrackType } from "@/lib/timeline";
import { isMainTrack } from "./main-track";
export function getDefaultInsertIndexForTrack({ export function getDefaultInsertIndexForTrack({
tracks, tracks,
trackType, trackType,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
trackType: TrackType; trackType: TrackType;
}): number { }): number {
if (trackType === "audio") { if (trackType === "audio") {
return tracks.length; return tracks.overlay.length + 1 + tracks.audio.length;
} }
if (trackType === "effect") { if (trackType === "effect") {
return 0; return 0;
} }
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); return tracks.overlay.length;
if (mainTrackIndex >= 0) {
return mainTrackIndex;
}
const firstAudioTrackIndex = tracks.findIndex((track) => track.type === "audio");
if (firstAudioTrackIndex >= 0) {
return firstAudioTrackIndex;
}
return tracks.length;
} }
export function getHighestInsertIndexForTrack({ export function getHighestInsertIndexForTrack({
tracks, tracks,
trackType, trackType,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
trackType: TrackType; trackType: TrackType;
}): number { }): number {
const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
if (trackType === "audio") { if (trackType === "audio") {
return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length; return tracks.overlay.length + 1;
} }
return 0; return 0;
@ -50,12 +38,13 @@ export function resolvePreferredNewTrackPlacement({
preferredIndex, preferredIndex,
direction, direction,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
trackType: TrackType; trackType: TrackType;
preferredIndex: number; preferredIndex: number;
direction: "above" | "below"; direction: "above" | "below";
}): { insertIndex: number; insertPosition: "above" | "below" | null } { }): { insertIndex: number; insertPosition: "above" | "below" | null } {
if (tracks.length === 0) { const trackCount = tracks.overlay.length + 1 + tracks.audio.length;
if (trackCount === 0) {
return { return {
insertIndex: 0, insertIndex: 0,
insertPosition: trackType === "audio" ? "below" : null, insertPosition: trackType === "audio" ? "below" : null,
@ -64,9 +53,9 @@ export function resolvePreferredNewTrackPlacement({
const safePreferredIndex = Math.min( const safePreferredIndex = Math.min(
Math.max(preferredIndex, 0), 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 (trackType === "audio") {
if (safePreferredIndex <= mainTrackIndex) { if (safePreferredIndex <= mainTrackIndex) {

View File

@ -1,55 +1,14 @@
import type { TimelineElement, TimelineTrack, VideoTrack } from "@/lib/timeline"; import type { SceneTracks, TimelineElement, VideoTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
const MAIN_TRACK_NAME = "Main Track"; export 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 function getEarliestMainTrackElement({ export function getEarliestMainTrackElement({
tracks, mainTrack,
excludeElementId, excludeElementId,
}: { }: {
tracks: TimelineTrack[]; mainTrack: VideoTrack;
excludeElementId?: string; excludeElementId?: string;
}): TimelineElement | null { }): TimelineElement | null {
const mainTrack = getMainTrack({ tracks });
if (!mainTrack) {
return null;
}
const elements = mainTrack.elements.filter((element) => { const elements = mainTrack.elements.filter((element) => {
return !excludeElementId || element.id !== excludeElementId; return !excludeElementId || element.id !== excludeElementId;
}); });
@ -70,18 +29,17 @@ export function enforceMainTrackStart({
requestedStartTime, requestedStartTime,
excludeElementId, excludeElementId,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
targetTrackId: string; targetTrackId: string;
requestedStartTime: number; requestedStartTime: number;
excludeElementId?: string; excludeElementId?: string;
}): number { }): number {
const mainTrack = getMainTrack({ tracks }); if (tracks.main.id !== targetTrackId) {
if (!mainTrack || mainTrack.id !== targetTrackId) {
return requestedStartTime; return requestedStartTime;
} }
const earliestElement = getEarliestMainTrackElement({ const earliestElement = getEarliestMainTrackElement({
tracks, mainTrack: tracks.main,
excludeElementId, excludeElementId,
}); });
if (!earliestElement) { if (!earliestElement) {

View File

@ -1,4 +1,4 @@
import type { TrackType, TimelineTrack } from "@/lib/timeline"; import type { SceneTracks, TrackType, TimelineTrack } from "@/lib/timeline";
import { import {
getDefaultInsertIndexForTrack, getDefaultInsertIndexForTrack,
getHighestInsertIndexForTrack, getHighestInsertIndexForTrack,
@ -15,7 +15,7 @@ import type {
} from "./types"; } from "./types";
type ResolveTrackPlacementParams = PlacementSubject & { type ResolveTrackPlacementParams = PlacementSubject & {
tracks: TimelineTrack[]; tracks: SceneTracks;
timeSpans: PlacementTimeSpan[]; timeSpans: PlacementTimeSpan[];
strategy: PlacementStrategy; strategy: PlacementStrategy;
}; };
@ -28,7 +28,7 @@ function buildExistingTrackResult({
}: { }: {
track: TimelineTrack; track: TimelineTrack;
trackIndex: number; trackIndex: number;
tracks: TimelineTrack[]; tracks: SceneTracks;
timeSpans: PlacementTimeSpan[]; timeSpans: PlacementTimeSpan[];
}): PlacementResult { }): PlacementResult {
const firstSpan = timeSpans[0]; const firstSpan = timeSpans[0];
@ -90,7 +90,7 @@ function resolveAlwaysNewTrack({
trackType, trackType,
position, position,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
trackType: TrackType; trackType: TrackType;
position: "highest" | "default"; position: "highest" | "default";
}): PlacementResult { }): PlacementResult {
@ -134,6 +134,7 @@ export function resolveTrackPlacement({
tracks, tracks,
...placement ...placement
}: ResolveTrackPlacementParams): PlacementResult | null { }: ResolveTrackPlacementParams): PlacementResult | null {
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
const trackType = const trackType =
"trackType" in placement "trackType" in placement
? placement.trackType ? placement.trackType
@ -143,30 +144,35 @@ export function resolveTrackPlacement({
const { timeSpans, strategy } = placement; const { timeSpans, strategy } = placement;
if (strategy.type === "explicit") { if (strategy.type === "explicit") {
const trackIndex = tracks.findIndex( const trackIndex = orderedTracks.findIndex(
(track) => track.id === strategy.trackId, (track) => track.id === strategy.trackId,
); );
if (trackIndex < 0) { if (trackIndex < 0) {
return null; return null;
} }
const track = tracks[trackIndex]; const track = orderedTracks[trackIndex];
if (track.type !== trackType) { if (track.type !== trackType) {
return null; return null;
} }
return buildExistingTrackResult({ track, trackIndex, tracks, timeSpans }); return buildExistingTrackResult({
track,
trackIndex,
tracks,
timeSpans,
});
} }
if (strategy.type === "firstAvailable") { if (strategy.type === "firstAvailable") {
const existingTrackIndex = findFirstAvailableTrackIndex({ const existingTrackIndex = findFirstAvailableTrackIndex({
tracks, tracks: orderedTracks,
trackType, trackType,
timeSpans, timeSpans,
}); });
if (existingTrackIndex >= 0) { if (existingTrackIndex >= 0) {
return buildExistingTrackResult({ return buildExistingTrackResult({
track: tracks[existingTrackIndex], track: orderedTracks[existingTrackIndex],
trackIndex: existingTrackIndex, trackIndex: existingTrackIndex,
tracks, tracks,
timeSpans, timeSpans,
@ -181,7 +187,7 @@ export function resolveTrackPlacement({
} }
if (strategy.type === "preferIndex") { if (strategy.type === "preferIndex") {
const preferredTrack = tracks[strategy.trackIndex]; const preferredTrack = orderedTracks[strategy.trackIndex];
const isPreferredTrackCompatible = const isPreferredTrackCompatible =
!!preferredTrack && preferredTrack.type === trackType; !!preferredTrack && preferredTrack.type === trackType;
const canUseExistingTrack = const canUseExistingTrack =
@ -220,7 +226,7 @@ export function resolveTrackPlacement({
if (strategy.type === "aboveSource") { if (strategy.type === "aboveSource") {
const aboveTrackIndex = strategy.sourceTrackIndex - 1; const aboveTrackIndex = strategy.sourceTrackIndex - 1;
const aboveTrack = tracks[aboveTrackIndex]; const aboveTrack = orderedTracks[aboveTrackIndex];
if ( if (
aboveTrack && aboveTrack &&
aboveTrack.type === trackType && aboveTrack.type === trackType &&
@ -238,26 +244,23 @@ export function resolveTrackPlacement({
} }
const firstAvailableTrackIndex = findFirstAvailableTrackIndex({ const firstAvailableTrackIndex = findFirstAvailableTrackIndex({
tracks, tracks: orderedTracks,
trackType, trackType,
timeSpans, timeSpans,
}); });
if (firstAvailableTrackIndex >= 0) { if (firstAvailableTrackIndex >= 0) {
return buildExistingTrackResult({ return buildExistingTrackResult({
track: tracks[firstAvailableTrackIndex], track: orderedTracks[firstAvailableTrackIndex],
trackIndex: firstAvailableTrackIndex, trackIndex: firstAvailableTrackIndex,
tracks, tracks,
timeSpans, timeSpans,
}); });
} }
const insertIndex = const insertIndex = getHighestInsertIndexForTrack({
strategy.sourceTrackIndex >= 0 tracks,
? strategy.sourceTrackIndex trackType,
: getHighestInsertIndexForTrack({ });
tracks,
trackType,
});
return buildNewTrackResult({ return buildNewTrackResult({
trackType, trackType,

View File

@ -1,6 +1,69 @@
import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks"; import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks";
import type { TrackType, TimelineTrack } from "@/lib/timeline"; import type {
AudioTrack,
EffectTrack,
GraphicTrack,
TextTrack,
TrackType,
TimelineTrack,
VideoTrack,
} from "@/lib/timeline";
export function buildEmptyTrack({
id,
type,
name,
}: {
id: string;
type: "video";
name?: string;
}): VideoTrack;
export function buildEmptyTrack({
id,
type,
name,
}: {
id: string;
type: "text";
name?: string;
}): TextTrack;
export function buildEmptyTrack({
id,
type,
name,
}: {
id: string;
type: "audio";
name?: string;
}): AudioTrack;
export function buildEmptyTrack({
id,
type,
name,
}: {
id: string;
type: "graphic";
name?: string;
}): GraphicTrack;
export function buildEmptyTrack({
id,
type,
name,
}: {
id: string;
type: "effect";
name?: string;
}): EffectTrack;
export function buildEmptyTrack({
id,
type,
name,
}: {
id: string;
type: TrackType;
name?: string;
}): TimelineTrack;
export function buildEmptyTrack({ export function buildEmptyTrack({
id, id,
type, type,
@ -21,7 +84,6 @@ export function buildEmptyTrack({
elements: [], elements: [],
hidden: false, hidden: false,
muted: false, muted: false,
isMain: false,
}; };
case "text": case "text":
return { return {

View File

@ -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 { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { getElementKeyframes } from "@/lib/animation"; import { getElementKeyframes } from "@/lib/animation";
import { TICKS_PER_SECOND } from "@/lib/wasm"; import { TICKS_PER_SECOND } from "@/lib/wasm";
@ -29,7 +29,7 @@ export function findSnapPoints({
enableBookmarkSnapping = true, enableBookmarkSnapping = true,
enableKeyframeSnapping = true, enableKeyframeSnapping = true,
}: { }: {
tracks: Array<TimelineTrack>; tracks: SceneTracks;
playheadTime: number; playheadTime: number;
excludeElementId?: string; excludeElementId?: string;
bookmarks?: Array<Bookmark>; bookmarks?: Array<Bookmark>;
@ -40,8 +40,9 @@ export function findSnapPoints({
enableKeyframeSnapping?: boolean; enableKeyframeSnapping?: boolean;
}): SnapPoint[] { }): SnapPoint[] {
const snapPoints: 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) { for (const element of track.elements) {
if (element.id === excludeElementId) continue; if (element.id === excludeElementId) continue;
@ -140,7 +141,7 @@ export function snapElementEdge({
}: { }: {
targetTime: number; targetTime: number;
elementDuration: number; elementDuration: number;
tracks: Array<TimelineTrack>; tracks: SceneTracks;
playheadTime: number; playheadTime: number;
zoomLevel: number; zoomLevel: number;
excludeElementId?: string; excludeElementId?: string;

View File

@ -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,
}: {
track: TTrack;
elementId: string;
update: (element: TimelineElement) => TimelineElement;
elementPredicate?: (element: TimelineElement) => boolean;
}): TTrack {
const nextElements = track.elements.map((element) => {
if (element.id !== elementId) {
return element;
}
if (elementPredicate && !elementPredicate(element)) {
return element;
}
return update(element);
});
return {
...track,
elements: nextElements,
} as TTrack;
}
export function updateElementInSceneTracks({
tracks, tracks,
trackId, trackId,
elementId, elementId,
update, update,
elementPredicate, elementPredicate,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
trackId: string; trackId: string;
elementId: string; elementId: string;
update: (element: TimelineElement) => TimelineElement; update: (element: TimelineElement) => TimelineElement;
elementPredicate?: (element: TimelineElement) => boolean; elementPredicate?: (element: TimelineElement) => boolean;
}): TimelineTrack[] { }): SceneTracks {
return tracks.map((track) => { if (tracks.main.id === trackId) {
if (track.id !== trackId) { return {
return track; ...tracks,
} main: updateElementInTrack({
track: tracks.main,
elementId,
update,
elementPredicate,
}),
};
}
const nextElements = track.elements.map((element) => { const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId);
if (element.id !== elementId) { if (overlayTrackIndex >= 0) {
return element; return {
} ...tracks,
if (elementPredicate && !elementPredicate(element)) { overlay: tracks.overlay.map((track, index) =>
return element; index === overlayTrackIndex
} ? updateElementInTrack({
return update(element); track,
}); elementId,
update,
elementPredicate,
})
: track,
),
};
}
return { ...track, elements: nextElements } as TimelineTrack; const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId);
}); if (audioTrackIndex >= 0) {
return {
...tracks,
audio: tracks.audio.map((track, index) =>
index === audioTrackIndex
? updateElementInTrack({
track,
elementId,
update,
elementPredicate,
})
: track,
),
};
}
return tracks;
} }

View File

@ -20,7 +20,7 @@ export interface TScene {
id: string; id: string;
name: string; name: string;
isMain: boolean; isMain: boolean;
tracks: TimelineTrack[]; tracks: SceneTracks;
bookmarks: Bookmark[]; bookmarks: Bookmark[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@ -36,7 +36,6 @@ interface BaseTrack {
export interface VideoTrack extends BaseTrack { export interface VideoTrack extends BaseTrack {
type: "video"; type: "video";
elements: (VideoElement | ImageElement)[]; elements: (VideoElement | ImageElement)[];
isMain: boolean;
muted: boolean; muted: boolean;
hidden: boolean; hidden: boolean;
} }
@ -72,6 +71,14 @@ export type TimelineTrack =
| GraphicTrack | GraphicTrack
| EffectTrack; | EffectTrack;
export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
export interface SceneTracks {
overlay: OverlayTrack[];
main: VideoTrack;
audio: AudioTrack[];
}
export interface RetimeConfig { export interface RetimeConfig {
rate: number; rate: number;
maintainPitch?: boolean; maintainPitch?: boolean;
@ -286,7 +293,7 @@ export interface ComputeDropTargetParams {
elementType: ElementType; elementType: ElementType;
mouseX: number; mouseX: number;
mouseY: number; mouseY: number;
tracks: TimelineTrack[]; tracks: SceneTracks;
playheadTime: number; playheadTime: number;
isExternalDrop: boolean; isExternalDrop: boolean;
elementDuration: number; elementDuration: number;

View File

@ -4,14 +4,13 @@ import {
getSourceSpanAtClipTime, getSourceSpanAtClipTime,
getTimelineDurationForSourceSpan, getTimelineDurationForSourceSpan,
} from "@/lib/retime"; } from "@/lib/retime";
import { enforceMainTrackStart } from "@/lib/timeline/placement"; import type { RetimeConfig, SceneTracks, TimelineElement } from "@/lib/timeline";
import type { RetimeConfig, TimelineElement, TimelineTrack } from "@/lib/timeline";
import { isRetimableElement } from "@/lib/timeline"; import { isRetimableElement } from "@/lib/timeline";
type ElementUpdateField = keyof TimelineElement | string; type ElementUpdateField = keyof TimelineElement | string;
export interface ElementUpdateContext { export interface ElementUpdateContext {
tracks: TimelineTrack[]; tracks: SceneTracks;
trackId: string; trackId: string;
} }
@ -94,17 +93,36 @@ const enforceRules: ElementUpdateRule[] = [
}, },
{ {
triggers: ["startTime"], triggers: ["startTime"],
apply: ({ element, context }) => ({ apply: ({ element, context }) => {
element: { const requestedStartTime = Math.max(0, element.startTime);
...element, if (context.trackId !== context.tracks.main.id) {
startTime: enforceMainTrackStart({ return {
tracks: context.tracks, element: {
targetTrackId: context.trackId, ...element,
requestedStartTime: Math.max(0, element.startTime), startTime: requestedStartTime,
excludeElementId: element.id, },
}), };
}, }
}),
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,
},
};
},
}, },
]; ];

View File

@ -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 type { MediaAsset } from "@/lib/media/types";
import { RootNode } from "./nodes/root-node"; import { RootNode } from "./nodes/root-node";
import { VideoNode } from "./nodes/video-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 { BaseNode } from "./nodes/base-node";
import type { TBackground, TCanvasSize } from "@/lib/project/types"; import type { TBackground, TCanvasSize } from "@/lib/project/types";
import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/lib/background/constants"; import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/lib/background/constants";
import { isMainTrack } from "@/lib/timeline/placement";
const PREVIEW_MAX_IMAGE_SIZE = 2048; const PREVIEW_MAX_IMAGE_SIZE = 2048;
@ -209,7 +208,7 @@ function buildBlurBackgroundNodes({
export type BuildSceneParams = { export type BuildSceneParams = {
canvasSize: TCanvasSize; canvasSize: TCanvasSize;
tracks: TimelineTrack[]; tracks: SceneTracks;
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
duration: number; duration: number;
background: TBackground; background: TBackground;
@ -227,19 +226,12 @@ export function buildScene({
const rootNode = new RootNode({ duration }); const rootNode = new RootNode({ duration });
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
const visibleTracks = tracks.filter( const visibleTracks = [
(track) => !("hidden" in track && track.hidden), ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
); ...(!tracks.main.hidden ? [tracks.main] : []),
const orderedTracksTopToBottom = [
...visibleTracks.filter((track) => !isMainTrack(track)),
...visibleTracks.filter((track) => isMainTrack(track)),
]; ];
const orderedTracksBottomToTop = visibleTracks.slice().reverse();
const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse(); const mainTrack = tracks.main.hidden ? undefined : tracks.main;
const mainTrack = orderedTracksBottomToTop.find((track) =>
isMainTrack(track),
);
const allNodes = buildTrackNodes({ const allNodes = buildTrackNodes({
tracks: orderedTracksBottomToTop, tracks: orderedTracksBottomToTop,

View File

@ -22,10 +22,11 @@ import { V19toV20Migration } from "./v19-to-v20";
import { V20toV21Migration } from "./v20-to-v21"; import { V20toV21Migration } from "./v20-to-v21";
import { V21toV22Migration } from "./v21-to-v22"; import { V21toV22Migration } from "./v21-to-v22";
import { V22toV23Migration } from "./v22-to-v23"; import { V22toV23Migration } from "./v22-to-v23";
import { V23toV24Migration } from "./v23-to-v24";
export { runStorageMigrations } from "./runner"; export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner"; export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 23; export const CURRENT_PROJECT_VERSION = 24;
export const migrations = [ export const migrations = [
new V0toV1Migration(), new V0toV1Migration(),
@ -51,4 +52,5 @@ export const migrations = [
new V20toV21Migration(), new V20toV21Migration(),
new V21toV22Migration(), new V21toV22Migration(),
new V22toV23Migration(), new V22toV23Migration(),
new V23toV24Migration(),
]; ];

View File

@ -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;
}

View File

@ -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 });
}
}

View File

@ -21,7 +21,7 @@ import {
migrations, migrations,
runStorageMigrations, runStorageMigrations,
} from "@/services/storage/migrations"; } 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[] { function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
@ -116,18 +116,18 @@ class StorageService {
private stripAudioBuffers({ private stripAudioBuffers({
tracks, tracks,
}: { }: {
tracks: TimelineTrack[]; tracks: SceneTracks;
}): TimelineTrack[] { }): SceneTracks {
return tracks.map((track) => { return {
if (track.type !== "audio") return track; ...tracks,
return { audio: tracks.audio.map((track) => ({
...track, ...track,
elements: track.elements.map((element) => { elements: track.elements.map((element) => {
const { buffer: _buffer, ...rest } = element; const { buffer: _buffer, ...rest } = element;
return rest; return rest;
}), }),
}; })),
}); };
} }
async saveProject({ project }: { project: TProject }): Promise<void> { async saveProject({ project }: { project: TProject }): Promise<void> {
@ -178,11 +178,7 @@ class StorageService {
id: scene.id, id: scene.id,
name: scene.name, name: scene.name,
isMain: scene.isMain, isMain: scene.isMain,
tracks: (scene.tracks ?? []).map((track) => tracks: scene.tracks,
track.type === "video"
? { ...track, isMain: track.isMain ?? false } // legacy: isMain was optional
: track,
),
bookmarks: normalizeBookmarks({ raw: scene.bookmarks }), bookmarks: normalizeBookmarks({ raw: scene.bookmarks }),
createdAt: new Date(scene.createdAt), createdAt: new Date(scene.createdAt),
updatedAt: new Date(scene.updatedAt), updatedAt: new Date(scene.updatedAt),

View File

@ -215,7 +215,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
try { try {
const editor = EditorCore.getInstance(); const editor = EditorCore.getInstance();
const currentTime = editor.playback.getCurrentTime(); const currentTime = editor.playback.getCurrentTime();
const tracks = editor.timeline.getTracks(); const tracks = editor.scenes.getActiveScene().tracks;
const response = await fetch(audioUrl); const response = await fetch(audioUrl);
if (!response.ok) if (!response.ok)
@ -225,7 +225,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
const audioContext = new AudioContext(); const audioContext = new AudioContext();
const buffer = await audioContext.decodeAudioData(arrayBuffer); const buffer = await audioContext.decodeAudioData(arrayBuffer);
const audioTrack = tracks.find((t) => t.type === "audio"); const audioTrack = tracks.audio[0];
let trackId: string; let trackId: string;
if (audioTrack) { if (audioTrack) {