2026-01-31 00:20:04 +01:00
|
|
|
import type { EditorCore } from "@/core";
|
|
|
|
|
import type {
|
|
|
|
|
TrackType,
|
|
|
|
|
TimelineTrack,
|
|
|
|
|
TimelineElement,
|
|
|
|
|
ClipboardItem,
|
|
|
|
|
} from "@/types/timeline";
|
2026-02-27 16:33:57 +01:00
|
|
|
import type {
|
|
|
|
|
AnimationInterpolation,
|
|
|
|
|
AnimationPropertyPath,
|
|
|
|
|
AnimationValue,
|
|
|
|
|
} from "@/types/animation";
|
2026-01-31 00:20:04 +01:00
|
|
|
import { calculateTotalDuration } from "@/lib/timeline";
|
|
|
|
|
import {
|
|
|
|
|
AddTrackCommand,
|
|
|
|
|
RemoveTrackCommand,
|
|
|
|
|
ToggleTrackMuteCommand,
|
|
|
|
|
ToggleTrackVisibilityCommand,
|
|
|
|
|
InsertElementCommand,
|
|
|
|
|
UpdateElementTrimCommand,
|
|
|
|
|
UpdateElementDurationCommand,
|
|
|
|
|
DeleteElementsCommand,
|
|
|
|
|
DuplicateElementsCommand,
|
|
|
|
|
ToggleElementsVisibilityCommand,
|
|
|
|
|
ToggleElementsMutedCommand,
|
2026-02-08 18:01:45 +01:00
|
|
|
UpdateElementCommand,
|
2026-01-31 00:20:04 +01:00
|
|
|
SplitElementsCommand,
|
|
|
|
|
PasteCommand,
|
|
|
|
|
UpdateElementStartTimeCommand,
|
|
|
|
|
MoveElementCommand,
|
2026-02-23 03:24:02 +01:00
|
|
|
TracksSnapshotCommand,
|
2026-02-27 16:33:57 +01:00
|
|
|
UpsertKeyframeCommand,
|
|
|
|
|
RemoveKeyframeCommand,
|
|
|
|
|
RetimeKeyframeCommand,
|
2026-01-31 00:20:04 +01:00
|
|
|
} from "@/lib/commands/timeline";
|
2026-02-23 03:24:02 +01:00
|
|
|
import { BatchCommand, PreviewTracker } from "@/lib/commands";
|
2026-01-31 00:20:04 +01:00
|
|
|
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
|
|
|
|
|
|
|
|
|
|
export class TimelineManager {
|
|
|
|
|
private listeners = new Set<() => void>();
|
2026-02-23 03:24:02 +01:00
|
|
|
private previewTracker = new PreviewTracker<TimelineTrack[]>();
|
2026-01-31 00:20:04 +01:00
|
|
|
|
|
|
|
|
constructor(private editor: EditorCore) {}
|
|
|
|
|
|
|
|
|
|
addTrack({ type, index }: { type: TrackType; index?: number }): string {
|
|
|
|
|
const command = new AddTrackCommand(type, index);
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
return command.getTrackId();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
removeTrack({ trackId }: { trackId: string }): void {
|
|
|
|
|
const command = new RemoveTrackCommand(trackId);
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
insertElement({ element, placement }: InsertElementParams): void {
|
|
|
|
|
const command = new InsertElementCommand({ element, placement });
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateElementTrim({
|
|
|
|
|
elementId,
|
|
|
|
|
trimStart,
|
|
|
|
|
trimEnd,
|
|
|
|
|
pushHistory = true,
|
|
|
|
|
}: {
|
|
|
|
|
elementId: string;
|
|
|
|
|
trimStart: number;
|
|
|
|
|
trimEnd: number;
|
|
|
|
|
pushHistory?: boolean;
|
|
|
|
|
}): void {
|
2026-02-27 16:33:57 +01:00
|
|
|
const command = new UpdateElementTrimCommand({
|
|
|
|
|
elementId,
|
|
|
|
|
trimStart,
|
|
|
|
|
trimEnd,
|
|
|
|
|
});
|
2026-01-31 00:20:04 +01:00
|
|
|
if (pushHistory) {
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
} else {
|
|
|
|
|
command.execute();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateElementDuration({
|
|
|
|
|
trackId,
|
|
|
|
|
elementId,
|
|
|
|
|
duration,
|
|
|
|
|
pushHistory = true,
|
|
|
|
|
}: {
|
|
|
|
|
trackId: string;
|
|
|
|
|
elementId: string;
|
|
|
|
|
duration: number;
|
|
|
|
|
pushHistory?: boolean;
|
|
|
|
|
}): void {
|
2026-02-27 16:33:57 +01:00
|
|
|
const command = new UpdateElementDurationCommand({
|
2026-01-31 00:20:04 +01:00
|
|
|
trackId,
|
|
|
|
|
elementId,
|
|
|
|
|
duration,
|
2026-02-27 16:33:57 +01:00
|
|
|
});
|
2026-01-31 00:20:04 +01:00
|
|
|
if (pushHistory) {
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
} else {
|
|
|
|
|
command.execute();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateElementStartTime({
|
|
|
|
|
elements,
|
|
|
|
|
startTime,
|
|
|
|
|
}: {
|
|
|
|
|
elements: { trackId: string; elementId: string }[];
|
|
|
|
|
startTime: number;
|
|
|
|
|
}): void {
|
2026-02-27 16:33:57 +01:00
|
|
|
const command = new UpdateElementStartTimeCommand({
|
|
|
|
|
elements,
|
|
|
|
|
startTime,
|
|
|
|
|
});
|
2026-01-31 00:20:04 +01:00
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
moveElement({
|
|
|
|
|
sourceTrackId,
|
|
|
|
|
targetTrackId,
|
|
|
|
|
elementId,
|
|
|
|
|
newStartTime,
|
|
|
|
|
createTrack,
|
|
|
|
|
}: {
|
|
|
|
|
sourceTrackId: string;
|
|
|
|
|
targetTrackId: string;
|
|
|
|
|
elementId: string;
|
|
|
|
|
newStartTime: number;
|
|
|
|
|
createTrack?: { type: TrackType; index: number };
|
|
|
|
|
}): void {
|
2026-02-27 16:33:57 +01:00
|
|
|
const command = new MoveElementCommand({
|
2026-01-31 00:20:04 +01:00
|
|
|
sourceTrackId,
|
|
|
|
|
targetTrackId,
|
|
|
|
|
elementId,
|
|
|
|
|
newStartTime,
|
|
|
|
|
createTrack,
|
2026-02-27 16:33:57 +01:00
|
|
|
});
|
2026-01-31 00:20:04 +01:00
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggleTrackMute({ trackId }: { trackId: string }): void {
|
|
|
|
|
const command = new ToggleTrackMuteCommand(trackId);
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggleTrackVisibility({ trackId }: { trackId: string }): void {
|
|
|
|
|
const command = new ToggleTrackVisibilityCommand(trackId);
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
splitElements({
|
|
|
|
|
elements,
|
|
|
|
|
splitTime,
|
|
|
|
|
retainSide = "both",
|
2026-02-23 07:09:26 +01:00
|
|
|
rippleEnabled = false,
|
2026-01-31 00:20:04 +01:00
|
|
|
}: {
|
|
|
|
|
elements: { trackId: string; elementId: string }[];
|
|
|
|
|
splitTime: number;
|
|
|
|
|
retainSide?: "both" | "left" | "right";
|
2026-02-23 07:09:26 +01:00
|
|
|
rippleEnabled?: boolean;
|
2026-01-31 00:20:04 +01:00
|
|
|
}): { trackId: string; elementId: string }[] {
|
2026-02-27 16:33:57 +01:00
|
|
|
const command = new SplitElementsCommand({
|
2026-02-23 07:09:26 +01:00
|
|
|
elements,
|
|
|
|
|
splitTime,
|
|
|
|
|
retainSide,
|
|
|
|
|
rippleEnabled,
|
2026-02-27 16:33:57 +01:00
|
|
|
});
|
2026-01-31 00:20:04 +01:00
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
return command.getRightSideElements();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getTotalDuration(): number {
|
|
|
|
|
return calculateTotalDuration({ tracks: this.getTracks() });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
|
|
|
|
return this.getTracks().find((track) => track.id === trackId) ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getElementsWithTracks({
|
|
|
|
|
elements,
|
|
|
|
|
}: {
|
|
|
|
|
elements: { trackId: string; elementId: string }[];
|
|
|
|
|
}): Array<{ track: TimelineTrack; element: TimelineElement }> {
|
|
|
|
|
const result: Array<{ track: TimelineTrack; element: TimelineElement }> =
|
|
|
|
|
[];
|
|
|
|
|
|
|
|
|
|
for (const { trackId, elementId } of elements) {
|
|
|
|
|
const track = this.getTrackById({ trackId });
|
|
|
|
|
const element = track?.elements.find(
|
|
|
|
|
(trackElement) => trackElement.id === elementId,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (track && element) {
|
|
|
|
|
result.push({ track, element });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pasteAtTime({
|
|
|
|
|
time,
|
|
|
|
|
clipboardItems,
|
|
|
|
|
}: {
|
|
|
|
|
time: number;
|
|
|
|
|
clipboardItems: ClipboardItem[];
|
|
|
|
|
}): { trackId: string; elementId: string }[] {
|
|
|
|
|
const command = new PasteCommand(time, clipboardItems);
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
return command.getPastedElements();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deleteElements({
|
|
|
|
|
elements,
|
2026-02-23 07:09:26 +01:00
|
|
|
rippleEnabled = false,
|
2026-01-31 00:20:04 +01:00
|
|
|
}: {
|
|
|
|
|
elements: { trackId: string; elementId: string }[];
|
2026-02-23 07:09:26 +01:00
|
|
|
rippleEnabled?: boolean;
|
2026-01-31 00:20:04 +01:00
|
|
|
}): void {
|
2026-03-01 01:32:09 +01:00
|
|
|
const command = new DeleteElementsCommand({ elements, rippleEnabled });
|
2026-01-31 00:20:04 +01:00
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 22:21:17 +01:00
|
|
|
updateElements({
|
2026-01-31 00:20:04 +01:00
|
|
|
updates,
|
2026-02-08 22:21:17 +01:00
|
|
|
pushHistory = true,
|
2026-01-31 00:20:04 +01:00
|
|
|
}: {
|
2026-02-08 22:21:17 +01:00
|
|
|
updates: Array<{
|
|
|
|
|
trackId: string;
|
|
|
|
|
elementId: string;
|
2026-02-27 16:33:57 +01:00
|
|
|
updates: Partial<TimelineElement>;
|
2026-02-08 22:21:17 +01:00
|
|
|
}>;
|
|
|
|
|
pushHistory?: boolean;
|
2026-01-31 00:20:04 +01:00
|
|
|
}): void {
|
2026-02-08 22:21:17 +01:00
|
|
|
const commands = updates.map(
|
|
|
|
|
({ trackId, elementId, updates: elementUpdates }) =>
|
2026-02-27 16:33:57 +01:00
|
|
|
new UpdateElementCommand({
|
|
|
|
|
trackId,
|
|
|
|
|
elementId,
|
|
|
|
|
updates: elementUpdates,
|
|
|
|
|
}),
|
2026-02-08 22:21:17 +01:00
|
|
|
);
|
2026-02-23 03:24:02 +01:00
|
|
|
const command =
|
|
|
|
|
commands.length === 1 ? commands[0] : new BatchCommand(commands);
|
2026-02-08 22:21:17 +01:00
|
|
|
if (pushHistory) {
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
} else {
|
|
|
|
|
command.execute();
|
|
|
|
|
}
|
2026-01-31 00:20:04 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-27 16:33:57 +01:00
|
|
|
upsertKeyframes({
|
|
|
|
|
keyframes,
|
|
|
|
|
}: {
|
|
|
|
|
keyframes: Array<{
|
|
|
|
|
trackId: string;
|
|
|
|
|
elementId: string;
|
|
|
|
|
propertyPath: AnimationPropertyPath;
|
|
|
|
|
time: number;
|
|
|
|
|
value: AnimationValue;
|
|
|
|
|
interpolation?: AnimationInterpolation;
|
|
|
|
|
keyframeId?: string;
|
|
|
|
|
}>;
|
|
|
|
|
}): void {
|
|
|
|
|
if (keyframes.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const commands = keyframes.map(
|
|
|
|
|
({
|
|
|
|
|
trackId,
|
|
|
|
|
elementId,
|
|
|
|
|
propertyPath,
|
|
|
|
|
time,
|
|
|
|
|
value,
|
2026-02-27 20:34:33 +01:00
|
|
|
interpolation,
|
2026-02-27 16:33:57 +01:00
|
|
|
keyframeId,
|
|
|
|
|
}) =>
|
|
|
|
|
new UpsertKeyframeCommand({
|
|
|
|
|
trackId,
|
|
|
|
|
elementId,
|
|
|
|
|
propertyPath,
|
|
|
|
|
time,
|
|
|
|
|
value,
|
|
|
|
|
interpolation,
|
|
|
|
|
keyframeId,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const command =
|
|
|
|
|
commands.length === 1 ? commands[0] : new BatchCommand(commands);
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
removeKeyframes({
|
|
|
|
|
keyframes,
|
|
|
|
|
}: {
|
|
|
|
|
keyframes: Array<{
|
|
|
|
|
trackId: string;
|
|
|
|
|
elementId: string;
|
|
|
|
|
propertyPath: AnimationPropertyPath;
|
|
|
|
|
keyframeId: string;
|
|
|
|
|
}>;
|
|
|
|
|
}): void {
|
|
|
|
|
if (keyframes.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const commands = keyframes.map(
|
|
|
|
|
({ trackId, elementId, propertyPath, keyframeId }) =>
|
|
|
|
|
new RemoveKeyframeCommand({
|
|
|
|
|
trackId,
|
|
|
|
|
elementId,
|
|
|
|
|
propertyPath,
|
|
|
|
|
keyframeId,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
const command =
|
|
|
|
|
commands.length === 1 ? commands[0] : new BatchCommand(commands);
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
retimeKeyframe({
|
|
|
|
|
trackId,
|
|
|
|
|
elementId,
|
|
|
|
|
propertyPath,
|
|
|
|
|
keyframeId,
|
|
|
|
|
time,
|
|
|
|
|
}: {
|
|
|
|
|
trackId: string;
|
|
|
|
|
elementId: string;
|
|
|
|
|
propertyPath: AnimationPropertyPath;
|
|
|
|
|
keyframeId: string;
|
|
|
|
|
time: number;
|
|
|
|
|
}): void {
|
|
|
|
|
const command = new RetimeKeyframeCommand({
|
|
|
|
|
trackId,
|
|
|
|
|
elementId,
|
|
|
|
|
propertyPath,
|
|
|
|
|
keyframeId,
|
|
|
|
|
nextTime: time,
|
|
|
|
|
});
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 03:24:02 +01:00
|
|
|
isPreviewActive(): boolean {
|
|
|
|
|
return this.previewTracker.isActive();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
previewElements({
|
|
|
|
|
updates,
|
|
|
|
|
}: {
|
|
|
|
|
updates: Array<{
|
|
|
|
|
trackId: string;
|
|
|
|
|
elementId: string;
|
2026-02-27 16:33:57 +01:00
|
|
|
updates: Partial<TimelineElement>;
|
2026-02-23 03:24:02 +01:00
|
|
|
}>;
|
|
|
|
|
}): void {
|
|
|
|
|
const tracks = this.getTracks();
|
|
|
|
|
this.previewTracker.begin({ state: tracks });
|
|
|
|
|
|
|
|
|
|
let updatedTracks = tracks;
|
|
|
|
|
for (const { trackId, elementId, updates: elementUpdates } of updates) {
|
|
|
|
|
updatedTracks = updatedTracks.map((track) => {
|
|
|
|
|
if (track.id !== trackId) return track;
|
|
|
|
|
const newElements = track.elements.map((element) =>
|
|
|
|
|
element.id === elementId
|
|
|
|
|
? { ...element, ...elementUpdates }
|
|
|
|
|
: element,
|
|
|
|
|
);
|
|
|
|
|
return { ...track, elements: newElements } as TimelineTrack;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
this.updateTracks(updatedTracks);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
commitPreview(): void {
|
|
|
|
|
const snapshot = this.previewTracker.end();
|
|
|
|
|
if (snapshot === null) return;
|
|
|
|
|
const currentTracks = this.getTracks();
|
|
|
|
|
const command = new TracksSnapshotCommand(snapshot, currentTracks);
|
|
|
|
|
this.editor.command.push({ command });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
discardPreview(): void {
|
|
|
|
|
const snapshot = this.previewTracker.end();
|
|
|
|
|
if (snapshot !== null) {
|
|
|
|
|
this.updateTracks(snapshot);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
duplicateElements({
|
|
|
|
|
elements,
|
|
|
|
|
}: {
|
|
|
|
|
elements: { trackId: string; elementId: string }[];
|
|
|
|
|
}): { trackId: string; elementId: string }[] {
|
|
|
|
|
const command = new DuplicateElementsCommand({ elements });
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
return command.getDuplicatedElements();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggleElementsVisibility({
|
|
|
|
|
elements,
|
|
|
|
|
}: {
|
|
|
|
|
elements: { trackId: string; elementId: string }[];
|
|
|
|
|
}): void {
|
|
|
|
|
const command = new ToggleElementsVisibilityCommand(elements);
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggleElementsMuted({
|
|
|
|
|
elements,
|
|
|
|
|
}: {
|
|
|
|
|
elements: { trackId: string; elementId: string }[];
|
|
|
|
|
}): void {
|
|
|
|
|
const command = new ToggleElementsMutedCommand(elements);
|
|
|
|
|
this.editor.command.execute({ command });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getTracks(): TimelineTrack[] {
|
|
|
|
|
return this.editor.scenes.getActiveScene()?.tracks ?? [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
subscribe(listener: () => void): () => void {
|
|
|
|
|
this.listeners.add(listener);
|
|
|
|
|
return () => this.listeners.delete(listener);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private notify(): void {
|
|
|
|
|
this.listeners.forEach((fn) => fn());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateTracks(newTracks: TimelineTrack[]): void {
|
|
|
|
|
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
|
|
|
|
|
this.notify();
|
|
|
|
|
}
|
|
|
|
|
}
|