mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: restructure files to their domains, new preview overlay system, and dep graph
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user