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

936 lines
20 KiB
TypeScript

import type { EditorCore } from "@/core";
import type { ElementBounds } from "@/preview/element-bounds";
import type { ParamValues } from "@/params";
import type {
SceneTracks,
TrackType,
TimelineTrack,
TimelineElement,
RetimeConfig,
} from "@/timeline";
import { calculateTotalDuration } from "@/timeline";
import { TimelineDragSource } from "@/timeline/drag-source";
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
import { lastFrameMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
import {
canElementBeHidden,
canElementHaveAudio,
} from "@/timeline/element-utils";
import { isElementMuted } from "@/timeline/audio-state";
import type {
AnimationPath,
AnimationInterpolation,
ScalarCurveKeyframePatch,
} from "@/animation/types";
import type { ParamValue } from "@/params";
import {
getElementLocalTime,
resolveAnimationPathValueAtTime,
} from "@/animation";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
import { BatchCommand } from "@/commands";
import {
AddTrackCommand,
RemoveTrackCommand,
ToggleTrackMuteCommand,
ToggleTrackVisibilityCommand,
InsertElementCommand,
DeleteElementsCommand,
DuplicateElementsCommand,
UpdateElementsCommand,
SplitElementsCommand,
MoveElementCommand,
TracksSnapshotCommand,
UpsertKeyframeCommand,
RemoveKeyframeCommand,
RetimeKeyframeCommand,
UpdateScalarKeyframeCurveCommand,
AddClipEffectCommand,
DeleteFreeformPathMaskPointsCommand,
InsertFreeformPathMaskPointCommand,
RemoveClipEffectCommand,
UpdateClipEffectParamsCommand,
ToggleClipEffectCommand,
ReorderClipEffectsCommand,
RemoveMaskCommand,
ToggleMaskInvertedCommand,
UpsertEffectParamKeyframeCommand,
RemoveEffectParamKeyframeCommand,
ToggleSourceAudioSeparationCommand,
} from "@/commands/timeline";
import type { InsertElementParams } from "@/commands/timeline/element/insert-element";
import type {
PlannedElementMove,
PlannedTrackCreation,
} from "@/timeline/group-move";
export class TimelineManager {
private listeners = new Set<() => void>();
private previewOverlay = new Map<string, Partial<TimelineElement>>();
private previewTracks: SceneTracks | null = null;
public readonly dragSource = new TimelineDragSource();
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,
pushHistory = true,
}: {
elementId: string;
trimStart: MediaTime;
trimEnd: MediaTime;
startTime?: MediaTime;
duration?: MediaTime;
pushHistory?: boolean;
}): void {
const trackId = this.findTrackIdForElement({ elementId });
if (!trackId) {
return;
}
const nextUpdates: Partial<TimelineElement> = {
trimStart,
trimEnd,
};
if (startTime !== undefined) {
nextUpdates.startTime = startTime;
}
if (duration !== undefined) {
nextUpdates.duration = duration;
}
this.updateElements({
updates: [
{
trackId,
elementId,
patch: nextUpdates,
},
],
pushHistory,
});
}
updateElementRetime({
trackId,
elementId,
retime,
pushHistory = true,
}: {
trackId: string;
elementId: string;
retime?: RetimeConfig;
pushHistory?: boolean;
}): void {
this.updateElements({
updates: [
{
trackId,
elementId,
patch: {
retime,
},
},
],
pushHistory,
});
}
moveElements({
moves,
createTracks,
}: {
moves: PlannedElementMove[];
createTracks?: PlannedTrackCreation[];
}): void {
if (moves.length === 0) {
return;
}
const command = new MoveElementCommand({
moves,
createTracks,
});
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: MediaTime;
retainSide?: "both" | "left" | "right";
}): { trackId: string; elementId: string }[] {
const command = new SplitElementsCommand({
elements,
splitTime,
retainSide,
});
this.editor.command.execute({ command });
return command.getRightSideElements();
}
getTotalDuration(): MediaTime {
const activeScene = this.editor.scenes.getActiveSceneOrNull();
if (!activeScene) {
return ZERO_MEDIA_TIME;
}
return calculateTotalDuration({ tracks: activeScene.tracks });
}
getLastFrameTime(): MediaTime {
const duration = this.getTotalDuration();
const fps = this.editor.project.getActive()?.settings.fps;
if (!fps || duration <= 0) return duration;
return lastFrameMediaTime({ duration, fps });
}
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
const activeScene = this.editor.scenes.getActiveSceneOrNull();
if (!activeScene) {
return null;
}
return findTrackInSceneTracks({ tracks: activeScene.tracks, trackId });
}
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;
}
deleteElements({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const command = new DeleteElementsCommand({ elements });
this.editor.command.execute({ command });
}
toggleSourceAudioSeparation({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): void {
const command = new ToggleSourceAudioSeparationCommand({
trackId,
elementId,
});
this.editor.command.execute({ command });
}
updateElements({
updates,
pushHistory = true,
}: {
updates: Array<{
trackId: string;
elementId: string;
patch: Partial<TimelineElement>;
}>;
pushHistory?: boolean;
}): void {
if (updates.length === 0) {
return;
}
const command = new UpdateElementsCommand({
updates,
});
if (pushHistory) {
this.editor.command.execute({ command });
} else {
command.execute();
}
}
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 });
}
removeMask({
trackId,
elementId,
maskId,
}: {
trackId: string;
elementId: string;
maskId: string;
}): void {
const command = new RemoveMaskCommand({
trackId,
elementId,
maskId,
});
this.editor.command.execute({ command });
}
deleteFreeformPathMaskPoints({
trackId,
elementId,
maskId,
pointIds,
}: {
trackId: string;
elementId: string;
maskId: string;
pointIds: string[];
}): void {
if (pointIds.length === 0) {
return;
}
const command = new DeleteFreeformPathMaskPointsCommand({
trackId,
elementId,
maskId,
pointIds,
});
this.editor.command.execute({ command });
}
insertFreeformPathMaskPoint({
trackId,
elementId,
maskId,
segmentIndex,
canvasPoint,
bounds,
}: {
trackId: string;
elementId: string;
maskId: string;
segmentIndex: number;
canvasPoint: { x: number; y: number };
bounds: ElementBounds;
}): void {
const command = new InsertFreeformPathMaskPointCommand({
trackId,
elementId,
maskId,
segmentIndex,
canvasPoint,
bounds,
});
this.editor.command.execute({ command });
}
updateClipEffectParams({
trackId,
elementId,
effectId,
params,
pushHistory = true,
}: {
trackId: string;
elementId: string;
effectId: string;
params: Partial<ParamValues>;
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 });
}
toggleMaskInverted({
trackId,
elementId,
maskId,
}: {
trackId: string;
elementId: string;
maskId: string;
}): void {
const command = new ToggleMaskInvertedCommand({
trackId,
elementId,
maskId,
});
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 });
}
upsertKeyframes({
keyframes,
}: {
keyframes: Array<{
trackId: string;
elementId: string;
propertyPath: AnimationPath;
time: MediaTime;
value: ParamValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}>;
}): void {
if (keyframes.length === 0) {
return;
}
const commands = keyframes.map(
({
trackId,
elementId,
propertyPath,
time,
value,
interpolation,
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: AnimationPath;
keyframeId: string;
}>;
}): void {
if (keyframes.length === 0) {
return;
}
// Pre-sample values at playhead for each (element, property) pair.
// This preserves "what you see is what you get" when all keyframes are deleted.
const playheadTime = this.editor.playback.getCurrentTime();
const valueAtPlayheadMap = new Map<string, ParamValue | null>();
for (const { trackId, elementId, propertyPath } of keyframes) {
const key = `${elementId}:${propertyPath}`;
if (valueAtPlayheadMap.has(key)) {
continue;
}
const element = this.getElementByRef({ trackId, elementId });
if (!element) {
valueAtPlayheadMap.set(key, null);
continue;
}
const localTime = getElementLocalTime({
timelineTime: playheadTime,
elementStartTime: element.startTime,
elementDuration: element.duration,
});
const target = resolveAnimationTarget({ element, path: propertyPath });
const baseValue = target?.getBaseValue() ?? null;
if (baseValue === null) {
valueAtPlayheadMap.set(key, null);
continue;
}
const value = resolveAnimationPathValueAtTime({
animations: element.animations,
propertyPath,
localTime,
fallbackValue: baseValue,
});
valueAtPlayheadMap.set(key, value);
}
const commands = keyframes.map(
({ trackId, elementId, propertyPath, keyframeId }) =>
new RemoveKeyframeCommand({
trackId,
elementId,
propertyPath,
keyframeId,
valueAtPlayhead:
valueAtPlayheadMap.get(`${elementId}:${propertyPath}`) ?? null,
}),
);
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: AnimationPath;
keyframeId: string;
time: MediaTime;
}): void {
const command = new RetimeKeyframeCommand({
trackId,
elementId,
propertyPath,
keyframeId,
nextTime: time,
});
this.editor.command.execute({ command });
}
updateKeyframeCurves({
keyframes,
}: {
keyframes: Array<{
trackId: string;
elementId: string;
propertyPath: AnimationPath;
componentKey: string;
keyframeId: string;
patch: ScalarCurveKeyframePatch;
}>;
}): void {
if (keyframes.length === 0) {
return;
}
const commands = keyframes.map(
({ trackId, elementId, propertyPath, componentKey, keyframeId, patch }) =>
new UpdateScalarKeyframeCurveCommand({
trackId,
elementId,
propertyPath,
componentKey,
keyframeId,
patch,
}),
);
const command =
commands.length === 1 ? commands[0] : new BatchCommand(commands);
this.editor.command.execute({ command });
}
upsertEffectParamKeyframe({
trackId,
elementId,
effectId,
paramKey,
time,
value,
interpolation,
keyframeId,
}: {
trackId: string;
elementId: string;
effectId: string;
paramKey: string;
time: MediaTime;
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.previewOverlay.size > 0;
}
previewElements({
updates,
}: {
updates: readonly {
trackId: string;
elementId: string;
updates: Partial<TimelineElement>;
}[];
}): void {
let changedOverlayCount = 0;
for (const { elementId, updates: elementUpdates } of updates) {
const existingOverlay = this.previewOverlay.get(elementId);
const changed = Object.entries(elementUpdates).some(([key, value]) => {
return !Object.is(
existingOverlay?.[key as keyof TimelineElement],
value,
);
});
if (changed) {
changedOverlayCount += 1;
const mergedOverlay = {
...existingOverlay,
...elementUpdates,
} as Partial<TimelineElement>;
this.previewOverlay.set(elementId, mergedOverlay);
}
}
const committedTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks;
if (!committedTracks) {
return;
}
if (changedOverlayCount === 0) {
return;
}
this.previewTracks = this.applyPreviewOverlay(committedTracks);
this.notify();
}
commitPreview(): void {
if (this.previewOverlay.size === 0) return;
const committedTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks;
if (!committedTracks) {
return;
}
const afterTracks =
this.previewTracks ?? this.applyPreviewOverlay(committedTracks);
const command = new TracksSnapshotCommand({
before: committedTracks,
after: afterTracks,
});
this.editor.command.push({ command });
this.previewOverlay.clear();
this.previewTracks = null;
this.updateTracks(afterTracks);
}
discardPreview(): void {
if (this.previewOverlay.size === 0) return;
this.previewOverlay.clear();
this.previewTracks = null;
this.notify();
}
private applyPreviewOverlay(tracks: SceneTracks): SceneTracks {
if (this.previewOverlay.size === 0) return tracks;
const applyTrackOverlay = <TTrack extends TimelineTrack>(
track: TTrack,
): TTrack => {
const hasOverlay = track.elements.some((element) =>
this.previewOverlay.has(element.id),
);
if (!hasOverlay) {
return track;
}
const nextElements = track.elements.map((element) => {
const overlay = this.previewOverlay.get(element.id);
return overlay
? ({ ...element, ...overlay } as TimelineElement)
: element;
});
return { ...track, elements: nextElements } as TTrack;
};
return {
overlay: tracks.overlay.map((track) => applyTrackOverlay(track)),
main: applyTrackOverlay(tracks.main),
audio: tracks.audio.map((track) => applyTrackOverlay(track)),
};
}
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 shouldHide = elements.some(({ trackId, elementId }) => {
const element = this.getElementByRef({ trackId, elementId });
return element && canElementBeHidden(element) && !element.hidden;
});
const nextUpdates = elements.flatMap(({ trackId, elementId }) => {
const element = this.getElementByRef({ trackId, elementId });
if (!element || !canElementBeHidden(element)) {
return [];
}
return [
{
trackId,
elementId,
patch: { hidden: shouldHide },
},
];
});
this.updateElements({ updates: nextUpdates });
}
toggleElementsMuted({
elements,
}: {
elements: { trackId: string; elementId: string }[];
}): void {
const shouldMute = elements.some(({ trackId, elementId }) => {
const element = this.getElementByRef({ trackId, elementId });
return element && canElementHaveAudio(element) && !isElementMuted({ element });
});
const nextUpdates = elements.flatMap(({ trackId, elementId }) => {
const element = this.getElementByRef({ trackId, elementId });
if (!element || !canElementHaveAudio(element)) {
return [];
}
return [
{
trackId,
elementId,
patch: { params: { muted: shouldMute } },
},
];
});
this.updateElements({ updates: nextUpdates });
}
getPreviewTracks(): SceneTracks | null {
return (
this.previewTracks ??
this.editor.scenes.getActiveSceneOrNull()?.tracks ??
null
);
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => {
fn();
});
}
private getElementByRef({
trackId,
elementId,
}: {
trackId: string;
elementId: string;
}): TimelineElement | undefined {
return this.getTrackById({ trackId })?.elements.find(
(element) => element.id === elementId,
);
}
private findTrackIdForElement({
elementId,
}: {
elementId: string;
}): string | null {
const activeScene = this.editor.scenes.getActiveSceneOrNull();
if (!activeScene) {
return null;
}
if (
activeScene.tracks.main.elements.some(
(element) => element.id === elementId,
)
) {
return activeScene.tracks.main.id;
}
for (const track of activeScene.tracks.overlay) {
if (track.elements.some((element) => element.id === elementId)) {
return track.id;
}
}
for (const track of activeScene.tracks.audio) {
if (track.elements.some((element) => element.id === elementId)) {
return track.id;
}
}
return null;
}
updateTracks(newTracks: SceneTracks): void {
this.previewOverlay.clear();
this.previewTracks = null;
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
this.notify();
}
}