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

611 lines
13 KiB
TypeScript
Raw Normal View History

2026-01-31 00:20:04 +01:00
import type { EditorCore } from "@/core";
import type { EffectParamValues } from "@/types/effects";
2026-01-31 00:20:04 +01:00
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,
UpdateElementCommand,
2026-01-31 00:20:04 +01:00
SplitElementsCommand,
PasteCommand,
UpdateElementStartTimeCommand,
MoveElementCommand,
TracksSnapshotCommand,
2026-02-27 16:33:57 +01:00
UpsertKeyframeCommand,
RemoveKeyframeCommand,
RetimeKeyframeCommand,
AddClipEffectCommand,
RemoveClipEffectCommand,
UpdateClipEffectParamsCommand,
ToggleClipEffectCommand,
ReorderClipEffectsCommand,
UpsertEffectParamKeyframeCommand,
RemoveEffectParamKeyframeCommand,
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,
startTime,
duration,
2026-01-31 00:20:04 +01:00
pushHistory = true,
}: {
elementId: string;
trimStart: number;
trimEnd: number;
startTime?: number;
duration?: number;
2026-01-31 00:20:04 +01:00
pushHistory?: boolean;
}): void {
2026-02-27 16:33:57 +01:00
const command = new UpdateElementTrimCommand({
elementId,
trimStart,
trimEnd,
startTime,
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();
}
}
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,
rippleEnabled = false,
2026-01-31 00:20:04 +01:00
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
rippleEnabled?: boolean;
2026-01-31 00:20:04 +01:00
}): 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,
rippleEnabled,
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
);
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
}
addClipEffect({
trackId,
elementId,
effectType,
}: {
trackId: string;
elementId: string;
effectType: string;
}): string {
const command = new AddClipEffectCommand({
trackId,
elementId,
effectType,
});
this.editor.command.execute({ command });
return command.getEffectId() ?? "";
}
removeClipEffect({
trackId,
elementId,
effectId,
}: {
trackId: string;
elementId: string;
effectId: string;
}): void {
const command = new RemoveClipEffectCommand({
trackId,
elementId,
effectId,
});
this.editor.command.execute({ command });
}
updateClipEffectParams({
trackId,
elementId,
effectId,
params,
pushHistory = true,
}: {
trackId: string;
elementId: string;
effectId: string;
params: Partial<EffectParamValues>;
pushHistory?: boolean;
}): void {
const command = new UpdateClipEffectParamsCommand({
trackId,
elementId,
effectId,
params,
});
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
toggleClipEffect({
trackId,
elementId,
effectId,
}: {
trackId: string;
elementId: string;
effectId: string;
}): void {
const command = new ToggleClipEffectCommand({
trackId,
elementId,
effectId,
});
this.editor.command.execute({ command });
}
reorderClipEffects({
trackId,
elementId,
fromIndex,
toIndex,
}: {
trackId: string;
elementId: string;
fromIndex: number;
toIndex: number;
}): void {
const command = new ReorderClipEffectsCommand({
trackId,
elementId,
fromIndex,
toIndex,
});
this.editor.command.execute({ command });
}
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 });
}
upsertEffectParamKeyframe({
trackId,
elementId,
effectId,
paramKey,
time,
value,
interpolation,
keyframeId,
}: {
trackId: string;
elementId: string;
effectId: string;
paramKey: string;
time: number;
value: number;
interpolation?: "linear" | "hold";
keyframeId?: string;
}): void {
const command = new UpsertEffectParamKeyframeCommand({
trackId,
elementId,
effectId,
paramKey,
time,
value,
interpolation,
keyframeId,
});
this.editor.command.execute({ command });
}
removeEffectParamKeyframe({
trackId,
elementId,
effectId,
paramKey,
keyframeId,
}: {
trackId: string;
elementId: string;
effectId: string;
paramKey: string;
keyframeId: string;
}): void {
const command = new RemoveEffectParamKeyframeCommand({
trackId,
elementId,
effectId,
paramKey,
keyframeId,
});
this.editor.command.execute({ command });
}
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>;
}>;
}): 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();
}
}