mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
313 lines
7.8 KiB
TypeScript
313 lines
7.8 KiB
TypeScript
import { Command } from "@/lib/commands/base-command";
|
|
import { EditorCore } from "@/core";
|
|
import type {
|
|
CreateTimelineElement,
|
|
TimelineTrack,
|
|
TimelineElement,
|
|
TrackType,
|
|
ElementType,
|
|
} from "@/types/timeline";
|
|
import { generateUUID } from "@/utils/id";
|
|
import {
|
|
requiresMediaId,
|
|
wouldElementOverlap,
|
|
} from "@/lib/timeline/element-utils";
|
|
import {
|
|
buildEmptyTrack,
|
|
canElementGoOnTrack,
|
|
getDefaultInsertIndexForTrack,
|
|
validateElementTrackCompatibility,
|
|
} from "@/lib/timeline/track-utils";
|
|
import type { MediaAsset } from "@/types/assets";
|
|
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
|
|
|
type InsertElementPlacement =
|
|
| { mode: "explicit"; trackId: string }
|
|
| { mode: "auto"; trackType?: TrackType; insertIndex?: number };
|
|
|
|
export interface InsertElementParams {
|
|
element: CreateTimelineElement;
|
|
placement: InsertElementPlacement;
|
|
};
|
|
|
|
export class InsertElementCommand extends Command {
|
|
private elementId: string;
|
|
private savedState: TimelineTrack[] | null = null;
|
|
private targetTrackId: string | null = null;
|
|
|
|
constructor({ element, placement }: InsertElementParams) {
|
|
super();
|
|
this.elementId = generateUUID();
|
|
this.element = element;
|
|
this.placement = placement;
|
|
}
|
|
|
|
private element: CreateTimelineElement;
|
|
private placement: InsertElementPlacement;
|
|
|
|
execute(): void {
|
|
const editor = EditorCore.getInstance();
|
|
this.savedState = editor.timeline.getTracks();
|
|
|
|
if (!this.savedState) {
|
|
console.error("Tracks not available");
|
|
return;
|
|
}
|
|
|
|
if (!this.validateElementBasics({ element: this.element })) {
|
|
return;
|
|
}
|
|
|
|
const totalElementsInTimeline = this.savedState.reduce(
|
|
(total, t) => total + t.elements.length,
|
|
0,
|
|
);
|
|
const isFirstElement = totalElementsInTimeline === 0;
|
|
|
|
const newElement = this.buildElement({ element: this.element });
|
|
const updateResult = this.resolveTracksWithElement({
|
|
tracks: this.savedState,
|
|
element: newElement,
|
|
});
|
|
|
|
if (!updateResult) {
|
|
return;
|
|
}
|
|
|
|
const { updatedTracks, targetTrackId } = updateResult;
|
|
this.targetTrackId = targetTrackId;
|
|
|
|
const isVisualMedia =
|
|
newElement.type === "video" || newElement.type === "image";
|
|
|
|
if (isFirstElement && isVisualMedia) {
|
|
const mediaAssets = editor.media.getAssets();
|
|
const activeProject = editor.project.getActive();
|
|
const asset = mediaAssets.find(
|
|
(item: MediaAsset) => item.id === newElement.mediaId,
|
|
);
|
|
|
|
if (asset?.width && asset?.height) {
|
|
const nextCanvasSize = { width: asset.width, height: asset.height };
|
|
const shouldSetOriginalCanvasSize =
|
|
!activeProject?.settings.originalCanvasSize;
|
|
editor.project.updateSettings({
|
|
settings: {
|
|
canvasSize: nextCanvasSize,
|
|
...(shouldSetOriginalCanvasSize
|
|
? { originalCanvasSize: nextCanvasSize }
|
|
: {}),
|
|
},
|
|
pushHistory: false,
|
|
});
|
|
}
|
|
|
|
if (asset?.type === "video" && asset?.fps) {
|
|
editor.project.updateSettings({
|
|
settings: { fps: asset.fps },
|
|
pushHistory: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
editor.timeline.updateTracks(updatedTracks);
|
|
}
|
|
|
|
undo(): void {
|
|
if (this.savedState) {
|
|
const editor = EditorCore.getInstance();
|
|
editor.timeline.updateTracks(this.savedState);
|
|
}
|
|
}
|
|
|
|
getElementId(): string {
|
|
return this.elementId;
|
|
}
|
|
|
|
getTrackId(): string | null {
|
|
return this.targetTrackId;
|
|
}
|
|
|
|
private buildElement({
|
|
element,
|
|
}: {
|
|
element: CreateTimelineElement;
|
|
}): TimelineElement {
|
|
return {
|
|
...element,
|
|
id: this.elementId,
|
|
startTime: element.startTime,
|
|
trimStart: element.trimStart ?? 0,
|
|
trimEnd: element.trimEnd ?? 0,
|
|
duration:
|
|
element.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
|
|
} as TimelineElement;
|
|
}
|
|
|
|
private validateElementBasics({
|
|
element,
|
|
}: {
|
|
element: CreateTimelineElement;
|
|
}): boolean {
|
|
if (requiresMediaId({ element }) && !("mediaId" in element)) {
|
|
console.error("Element requires mediaId");
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
element.type === "audio" &&
|
|
element.sourceType === "library" &&
|
|
!element.sourceUrl
|
|
) {
|
|
console.error("Library audio element must have sourceUrl");
|
|
return false;
|
|
}
|
|
|
|
if (element.type === "sticker" && !element.iconName) {
|
|
console.error("Sticker element must have iconName");
|
|
return false;
|
|
}
|
|
|
|
if (element.type === "text" && !element.content) {
|
|
console.error("Text element must have content");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private resolveTracksWithElement({
|
|
tracks,
|
|
element,
|
|
}: {
|
|
tracks: TimelineTrack[];
|
|
element: TimelineElement;
|
|
}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
|
|
const placement = this.placement;
|
|
|
|
if (placement.mode === "explicit") {
|
|
const targetTrack = tracks.find(
|
|
(track) => track.id === placement.trackId,
|
|
);
|
|
|
|
if (!targetTrack) {
|
|
console.error("Track not found:", placement.trackId);
|
|
return null;
|
|
}
|
|
|
|
const validation = validateElementTrackCompatibility({
|
|
element,
|
|
track: targetTrack,
|
|
});
|
|
|
|
if (!validation.isValid) {
|
|
console.error(validation.errorMessage);
|
|
return null;
|
|
}
|
|
|
|
const updatedTracks = tracks.map((track) =>
|
|
track.id === targetTrack.id
|
|
? { ...track, elements: [...track.elements, element] }
|
|
: track,
|
|
) as TimelineTrack[];
|
|
|
|
return { updatedTracks, targetTrackId: targetTrack.id };
|
|
}
|
|
|
|
const trackType =
|
|
placement.trackType ?? this.getTrackTypeForElement({ element });
|
|
|
|
if (
|
|
placement.trackType &&
|
|
!canElementGoOnTrack({
|
|
elementType: element.type,
|
|
trackType,
|
|
})
|
|
) {
|
|
console.error(
|
|
`${element.type} elements cannot be placed on ${trackType} tracks`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const elementEndTime = element.startTime + element.duration;
|
|
const existingTrack = tracks.find((track) => {
|
|
if (
|
|
!canElementGoOnTrack({
|
|
elementType: element.type,
|
|
trackType: track.type,
|
|
})
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return !wouldElementOverlap({
|
|
elements: track.elements,
|
|
startTime: element.startTime,
|
|
endTime: elementEndTime,
|
|
});
|
|
});
|
|
|
|
if (existingTrack) {
|
|
const updatedTracks = tracks.map((track) =>
|
|
track.id === existingTrack.id
|
|
? { ...track, elements: [...track.elements, element] }
|
|
: track,
|
|
) as TimelineTrack[];
|
|
|
|
return { updatedTracks, targetTrackId: existingTrack.id };
|
|
}
|
|
|
|
const newTrackId = generateUUID();
|
|
const newTrack = buildEmptyTrack({
|
|
id: newTrackId,
|
|
type: trackType,
|
|
});
|
|
const newTrackWithElement = {
|
|
...newTrack,
|
|
elements: [...newTrack.elements, element],
|
|
} as TimelineTrack;
|
|
|
|
const updatedTracks = [...tracks];
|
|
const insertIndex =
|
|
placement.insertIndex ??
|
|
this.getAutoInsertIndex({ tracks: updatedTracks, trackType });
|
|
updatedTracks.splice(insertIndex, 0, newTrackWithElement);
|
|
|
|
return { updatedTracks, targetTrackId: newTrackId };
|
|
}
|
|
|
|
private getAutoInsertIndex({
|
|
tracks,
|
|
trackType,
|
|
}: {
|
|
tracks: TimelineTrack[];
|
|
trackType: TrackType;
|
|
}): number {
|
|
if (trackType === "text") {
|
|
const firstVideoTrackIndex = tracks.findIndex(
|
|
(track) => track.type === "video",
|
|
);
|
|
if (firstVideoTrackIndex >= 0) {
|
|
return firstVideoTrackIndex;
|
|
}
|
|
}
|
|
|
|
return getDefaultInsertIndexForTrack({
|
|
tracks,
|
|
trackType,
|
|
});
|
|
}
|
|
|
|
private getTrackTypeForElement({
|
|
element,
|
|
}: {
|
|
element: { type: ElementType };
|
|
}): TrackType {
|
|
if (element.type === "video" || element.type === "image") {
|
|
return "video";
|
|
}
|
|
return element.type;
|
|
}
|
|
}
|