Files
OpenCut/apps/web/src/commands/timeline/element/insert-element.ts
T

292 lines
7.2 KiB
TypeScript

import { Command, type CommandResult } from "@/commands/base-command";
import { EditorCore } from "@/core";
import type {
CreateTimelineElement,
SceneTracks,
TimelineElement,
TrackType,
} from "@/timeline";
import { generateUUID } from "@/utils/id";
import { requiresMediaId } from "@/timeline/element-utils";
import type { MediaAsset } from "@/media/types";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
import { floatToFrameRate } from "@/fps/utils";
import { graphicsRegistry, registerDefaultGraphics } from "@/graphics";
import {
applyPlacement,
canElementGoOnTrack,
resolveTrackPlacement,
validateElementTrackCompatibility,
} from "@/timeline/placement";
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: SceneTracks | 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(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedState = editor.scenes.getActiveScene().tracks;
if (!this.validateElementBasics({ element: this.element })) {
return;
}
const totalElementsInTimeline =
this.savedState.main.elements.length +
this.savedState.overlay.reduce(
(total, track) => total + track.elements.length,
0,
) +
this.savedState.audio.reduce(
(total, track) => total + track.elements.length,
0,
);
const isFirstElement = totalElementsInTimeline === 0;
const newElement = this.buildElement({ element: this.element });
const updateResult = this.applyPlacementResult({
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: floatToFrameRate(asset.fps) },
pushHistory: false,
});
}
}
editor.timeline.updateTracks(updatedTracks);
return {
selection: {
selectedElements: [
{ trackId: targetTrackId, elementId: this.elementId },
],
selectedKeyframes: [],
keyframeSelectionAnchor: null,
selectedMaskPoints: null,
},
};
}
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 ?? DEFAULT_NEW_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.stickerId) {
console.error("Sticker element must have stickerId");
return false;
}
if (element.type === "graphic") {
registerDefaultGraphics();
if (
!element.definitionId ||
!graphicsRegistry.has(element.definitionId)
) {
console.error("Graphic element must have a valid definitionId");
return false;
}
}
if (element.type === "text" && !element.content) {
console.error("Text element must have content");
return false;
}
if (element.type === "effect" && !element.effectType) {
console.error("Effect element must have effectType");
return false;
}
return true;
}
private applyPlacementResult({
tracks,
element,
}: {
tracks: SceneTracks;
element: TimelineElement;
}): { updatedTracks: SceneTracks; targetTrackId: string } | null {
const placement = this.placement;
if (
placement.mode === "auto" &&
placement.trackType &&
!canElementGoOnTrack({
elementType: element.type,
trackType: placement.trackType,
})
) {
console.error(
`${element.type} elements cannot be placed on ${placement.trackType} tracks`,
);
return null;
}
const placementResult = resolveTrackPlacement({
tracks,
...(placement.mode === "auto" && placement.trackType
? { trackType: placement.trackType }
: { elementType: element.type }),
timeSpans: [
{
startTime: element.startTime,
duration: element.duration,
},
],
strategy:
placement.mode === "explicit"
? { type: "explicit", trackId: placement.trackId }
: { type: "firstAvailable" },
});
if (!placementResult) {
if (placement.mode === "explicit") {
const targetTrack =
tracks.main.id === placement.trackId
? tracks.main
: (tracks.overlay.find((track) => track.id === placement.trackId) ??
tracks.audio.find((track) => track.id === placement.trackId));
if (!targetTrack) {
console.error("Track not found:", placement.trackId);
return null;
}
const validation = validateElementTrackCompatibility({
element,
track: targetTrack,
});
console.error(validation.errorMessage);
}
return null;
}
const elementToPlace =
placementResult.kind === "existingTrack"
? {
...element,
startTime: placementResult.adjustedStartTime ?? element.startTime,
}
: element;
const appliedPlacement = applyPlacement({
tracks,
placementResult,
elements: [elementToPlace],
newTrackInsertIndexOverride:
placement.mode === "auto" && typeof placement.insertIndex === "number"
? placement.insertIndex
: undefined,
});
if (!appliedPlacement) {
return null;
}
return {
updatedTracks: appliedPlacement.updatedTracks,
targetTrackId: appliedPlacement.targetTrackId,
};
}
}