summaryrefslogtreecommitdiff
path: root/apps/web/src/commands/timeline/element/insert-element.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/commands/timeline/element/insert-element.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/commands/timeline/element/insert-element.ts')
-rw-r--r--apps/web/src/commands/timeline/element/insert-element.ts291
1 files changed, 291 insertions, 0 deletions
diff --git a/apps/web/src/commands/timeline/element/insert-element.ts b/apps/web/src/commands/timeline/element/insert-element.ts
new file mode 100644
index 00000000..49ac6f2d
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/insert-element.ts
@@ -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,
+ };
+ }
+}