Files
OpenCut/apps/web/src/core/managers/timeline-manager.ts
T

320 lines
7.8 KiB
TypeScript
Raw Normal View History

2026-01-31 00:20:04 +01:00
import type { EditorCore } from "@/core";
import type {
TrackType,
TimelineTrack,
TimelineElement,
ClipboardItem,
} from "@/types/timeline";
import { calculateTotalDuration } from "@/lib/timeline";
import {
AddTrackCommand,
RemoveTrackCommand,
ToggleTrackMuteCommand,
ToggleTrackVisibilityCommand,
InsertElementCommand,
UpdateElementTrimCommand,
UpdateElementDurationCommand,
DeleteElementsCommand,
DuplicateElementsCommand,
ToggleElementsVisibilityCommand,
ToggleElementsMutedCommand,
UpdateElementCommand,
2026-01-31 00:20:04 +01:00
SplitElementsCommand,
PasteCommand,
UpdateElementStartTimeCommand,
MoveElementCommand,
TracksSnapshotCommand,
2026-01-31 00:20:04 +01:00
} from "@/lib/commands/timeline";
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>();
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 {
const command = new UpdateElementTrimCommand(elementId, trimStart, trimEnd);
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 {
const command = new UpdateElementDurationCommand(
trackId,
elementId,
duration,
);
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
updateElementStartTime({
elements,
startTime,
}: {
elements: { trackId: string; elementId: string }[];
startTime: number;
}): void {
const command = new UpdateElementStartTimeCommand(elements, startTime);
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 {
const command = new MoveElementCommand(
sourceTrackId,
targetTrackId,
elementId,
newStartTime,
createTrack,
);
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",
}: {
elements: { trackId: string; elementId: string }[];
splitTime: number;
retainSide?: "both" | "left" | "right";
}): { trackId: string; elementId: string }[] {
const command = new SplitElementsCommand(elements, splitTime, retainSide);
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,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new DeleteElementsCommand(elements);
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;
updates: Partial<Record<string, unknown>>;
}>;
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 }) =>
new UpdateElementCommand(trackId, elementId, elementUpdates),
);
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
}
isPreviewActive(): boolean {
return this.previewTracker.isActive();
}
previewElements({
updates,
}: {
updates: Array<{
trackId: string;
elementId: string;
updates: Partial<Record<string, unknown>>;
}>;
}): 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();
}
}