mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'feature/track-placement'
This commit is contained in:
@@ -6,13 +6,12 @@ import type {
|
||||
ClipboardItem,
|
||||
} from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
getHighestInsertIndexForTrack,
|
||||
applyPlacement,
|
||||
resolveTrackPlacement,
|
||||
isMainTrack,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
} from "@/lib/timeline/placement";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
|
||||
export class PasteCommand extends Command {
|
||||
@@ -59,17 +58,22 @@ export class PasteCommand extends Command {
|
||||
const sourceTrackIndex = updatedTracks.findIndex(
|
||||
(track) => track.id === trackId,
|
||||
);
|
||||
const resolvedTargetIndex = resolveTargetTrackIndex({
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks: updatedTracks,
|
||||
sourceTrackIndex,
|
||||
trackType,
|
||||
elements: elementsToAdd,
|
||||
timeSpans: elementsToAdd.map((element) => ({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
})),
|
||||
strategy: { type: "aboveSource", sourceTrackIndex },
|
||||
});
|
||||
if (!placementResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolvedTargetIndex >= 0) {
|
||||
const targetTrack = updatedTracks[resolvedTargetIndex];
|
||||
let adjustedElements = elementsToAdd;
|
||||
|
||||
let elementsForPlacement = elementsToAdd;
|
||||
if (placementResult.kind === "existingTrack") {
|
||||
const targetTrack = updatedTracks[placementResult.trackIndex];
|
||||
if (isMainTrack(targetTrack)) {
|
||||
const earliestElement = elementsToAdd.reduce((earliest, element) =>
|
||||
element.startTime < earliest.startTime ? element : earliest,
|
||||
@@ -82,39 +86,28 @@ export class PasteCommand extends Command {
|
||||
const delta = adjustedEarliestStartTime - earliestElement.startTime;
|
||||
|
||||
if (delta !== 0) {
|
||||
adjustedElements = elementsToAdd.map((element) => ({
|
||||
elementsForPlacement = elementsToAdd.map((element) => ({
|
||||
...element,
|
||||
startTime: Math.max(0, element.startTime + delta),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedTracks[resolvedTargetIndex] = {
|
||||
...targetTrack,
|
||||
elements: [...targetTrack.elements, ...adjustedElements],
|
||||
} as TimelineTrack;
|
||||
for (const element of adjustedElements) {
|
||||
this.pastedElements.push({
|
||||
trackId: targetTrack.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
const applied = applyPlacement({
|
||||
tracks: updatedTracks,
|
||||
placementResult,
|
||||
elements: elementsForPlacement,
|
||||
});
|
||||
if (!applied) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const insertIndex = resolveInsertIndexForNewTrack({
|
||||
tracks: updatedTracks,
|
||||
sourceTrackIndex,
|
||||
trackType,
|
||||
});
|
||||
const newTrack = buildTrackWithElements({
|
||||
trackType,
|
||||
elements: elementsToAdd,
|
||||
});
|
||||
updatedTracks.splice(insertIndex, 0, newTrack);
|
||||
for (const element of elementsToAdd) {
|
||||
updatedTracks = applied.updatedTracks;
|
||||
|
||||
for (const element of elementsForPlacement) {
|
||||
this.pastedElements.push({
|
||||
trackId: newTrack.id,
|
||||
trackId: applied.targetTrackId,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
@@ -187,107 +180,3 @@ function buildPastedElements({
|
||||
return elementsToAdd;
|
||||
}
|
||||
|
||||
function resolveTargetTrackIndex({
|
||||
tracks,
|
||||
sourceTrackIndex,
|
||||
trackType,
|
||||
elements,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
sourceTrackIndex: number;
|
||||
trackType: ClipboardItem["trackType"];
|
||||
elements: TimelineElement[];
|
||||
}): number {
|
||||
if (sourceTrackIndex >= 0) {
|
||||
const aboveIndex = sourceTrackIndex - 1;
|
||||
if (aboveIndex < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const aboveTrack = tracks[aboveIndex];
|
||||
if (aboveTrack.type !== trackType) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const canPlaceOnAbove = canPlaceElementsOnTrack({
|
||||
track: aboveTrack,
|
||||
elements,
|
||||
});
|
||||
return canPlaceOnAbove ? aboveIndex : -1;
|
||||
}
|
||||
|
||||
const highestCompatibleIndex = tracks.findIndex(
|
||||
(track) => track.type === trackType,
|
||||
);
|
||||
if (highestCompatibleIndex < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const highestCompatibleTrack = tracks[highestCompatibleIndex];
|
||||
const canPlaceOnHighest = canPlaceElementsOnTrack({
|
||||
track: highestCompatibleTrack,
|
||||
elements,
|
||||
});
|
||||
|
||||
return canPlaceOnHighest ? highestCompatibleIndex : -1;
|
||||
}
|
||||
|
||||
function resolveInsertIndexForNewTrack({
|
||||
tracks,
|
||||
sourceTrackIndex,
|
||||
trackType,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
sourceTrackIndex: number;
|
||||
trackType: ClipboardItem["trackType"];
|
||||
}): number {
|
||||
if (sourceTrackIndex >= 0) {
|
||||
return sourceTrackIndex;
|
||||
}
|
||||
|
||||
const highestCompatibleIndex = tracks.findIndex(
|
||||
(track) => track.type === trackType,
|
||||
);
|
||||
if (highestCompatibleIndex >= 0) {
|
||||
return highestCompatibleIndex;
|
||||
}
|
||||
|
||||
return getHighestInsertIndexForTrack({ tracks, trackType });
|
||||
}
|
||||
|
||||
function canPlaceElementsOnTrack({
|
||||
track,
|
||||
elements,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
elements: TimelineElement[];
|
||||
}): boolean {
|
||||
for (const element of elements) {
|
||||
const endTime = element.startTime + element.duration;
|
||||
const hasOverlap = wouldElementOverlap({
|
||||
elements: track.elements,
|
||||
startTime: element.startTime,
|
||||
endTime,
|
||||
});
|
||||
if (hasOverlap) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildTrackWithElements({
|
||||
trackType,
|
||||
elements,
|
||||
}: {
|
||||
trackType: ClipboardItem["trackType"];
|
||||
elements: TimelineElement[];
|
||||
}): TimelineTrack {
|
||||
const newTrackId = generateUUID();
|
||||
const newTrackBase = buildEmptyTrack({ id: newTrackId, type: trackType });
|
||||
return {
|
||||
...newTrackBase,
|
||||
elements,
|
||||
} as TimelineTrack;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
|
||||
import { rippleShiftElements } from "@/lib/timeline";
|
||||
import { isMainTrack } from "@/lib/timeline/placement";
|
||||
|
||||
export class DeleteElementsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
@@ -2,10 +2,7 @@ import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
getHighestInsertIndexForTrack,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement";
|
||||
import { cloneAnimations } from "@/lib/animation";
|
||||
|
||||
interface DuplicateElementsParams {
|
||||
@@ -45,22 +42,12 @@ export class DuplicateElementsCommand extends Command {
|
||||
);
|
||||
const newTrackElements: TimelineElement[] = [];
|
||||
|
||||
const newTrackId = generateUUID();
|
||||
const newTrackBase = buildEmptyTrack({
|
||||
id: newTrackId,
|
||||
type: track.type,
|
||||
});
|
||||
|
||||
for (const element of track.elements) {
|
||||
if (!elementIdsToDuplicate.has(element.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newId = generateUUID();
|
||||
this.duplicatedElements.push({
|
||||
trackId: newTrackId,
|
||||
elementId: newId,
|
||||
});
|
||||
newTrackElements.push(
|
||||
buildDuplicateElement({
|
||||
element,
|
||||
@@ -70,16 +57,33 @@ export class DuplicateElementsCommand extends Command {
|
||||
);
|
||||
}
|
||||
|
||||
const newTrack = {
|
||||
...newTrackBase,
|
||||
elements: newTrackElements,
|
||||
} as TimelineTrack;
|
||||
|
||||
const insertIndex = getHighestInsertIndexForTrack({
|
||||
const placementResult = resolveTrackPlacement({
|
||||
tracks: updatedTracks,
|
||||
trackType: track.type,
|
||||
timeSpans: [],
|
||||
strategy: { type: "alwaysNew", position: "highest" },
|
||||
});
|
||||
updatedTracks.splice(insertIndex, 0, newTrack);
|
||||
if (!placementResult || placementResult.kind !== "newTrack") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const applied = applyPlacement({
|
||||
tracks: updatedTracks,
|
||||
placementResult,
|
||||
elements: newTrackElements,
|
||||
});
|
||||
if (!applied) {
|
||||
continue;
|
||||
}
|
||||
|
||||
updatedTracks = applied.updatedTracks;
|
||||
|
||||
for (const element of newTrackElements) {
|
||||
this.duplicatedElements.push({
|
||||
trackId: applied.targetTrackId,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
|
||||
@@ -5,23 +5,18 @@ import type {
|
||||
TimelineTrack,
|
||||
TimelineElement,
|
||||
TrackType,
|
||||
ElementType,
|
||||
} from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import {
|
||||
requiresMediaId,
|
||||
wouldElementOverlap,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
canElementGoOnTrack,
|
||||
getDefaultInsertIndexForTrack,
|
||||
validateElementTrackCompatibility,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import { requiresMediaId } from "@/lib/timeline/element-utils";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
|
||||
import {
|
||||
applyPlacement,
|
||||
canElementGoOnTrack,
|
||||
resolveTrackPlacement,
|
||||
validateElementTrackCompatibility,
|
||||
} from "@/lib/timeline/placement";
|
||||
|
||||
type InsertElementPlacement =
|
||||
| { mode: "explicit"; trackId: string }
|
||||
@@ -67,7 +62,7 @@ export class InsertElementCommand extends Command {
|
||||
const isFirstElement = totalElementsInTimeline === 0;
|
||||
|
||||
const newElement = this.buildElement({ element: this.element });
|
||||
const updateResult = this.resolveTracksWithElement({
|
||||
const updateResult = this.applyPlacementResult({
|
||||
tracks: this.savedState,
|
||||
element: newElement,
|
||||
});
|
||||
@@ -190,7 +185,7 @@ export class InsertElementCommand extends Command {
|
||||
return true;
|
||||
}
|
||||
|
||||
private resolveTracksWithElement({
|
||||
private applyPlacementResult({
|
||||
tracks,
|
||||
element,
|
||||
}: {
|
||||
@@ -199,160 +194,71 @@ export class InsertElementCommand extends Command {
|
||||
}): { 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 adjustedElement = this.adjustElementForMainTrack({
|
||||
tracks,
|
||||
targetTrackId: targetTrack.id,
|
||||
element,
|
||||
});
|
||||
|
||||
const updatedTracks = tracks.map((track) =>
|
||||
track.id === targetTrack.id
|
||||
? {
|
||||
...track,
|
||||
elements: [...track.elements, adjustedElement],
|
||||
}
|
||||
: track,
|
||||
) as TimelineTrack[];
|
||||
|
||||
return { updatedTracks, targetTrackId: targetTrack.id };
|
||||
}
|
||||
|
||||
const trackType =
|
||||
placement.trackType ?? this.getTrackTypeForElement({ element });
|
||||
|
||||
if (
|
||||
placement.mode === "auto" &&
|
||||
placement.trackType &&
|
||||
!canElementGoOnTrack({
|
||||
elementType: element.type,
|
||||
trackType,
|
||||
trackType: placement.trackType,
|
||||
})
|
||||
) {
|
||||
console.error(
|
||||
`${element.type} elements cannot be placed on ${trackType} tracks`,
|
||||
`${element.type} elements cannot be placed on ${placement.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;
|
||||
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.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 !wouldElementOverlap({
|
||||
elements: track.elements,
|
||||
startTime: element.startTime,
|
||||
endTime: elementEndTime,
|
||||
});
|
||||
});
|
||||
|
||||
if (existingTrack) {
|
||||
const adjustedElement = this.adjustElementForMainTrack({
|
||||
tracks,
|
||||
targetTrackId: existingTrack.id,
|
||||
element,
|
||||
});
|
||||
|
||||
const updatedTracks = tracks.map((track) =>
|
||||
track.id === existingTrack.id
|
||||
? {
|
||||
...track,
|
||||
elements: [...track.elements, adjustedElement],
|
||||
}
|
||||
: track,
|
||||
) as TimelineTrack[];
|
||||
|
||||
return { updatedTracks, targetTrackId: existingTrack.id };
|
||||
return null;
|
||||
}
|
||||
|
||||
const newTrackId = generateUUID();
|
||||
const newTrack = buildEmptyTrack({
|
||||
id: newTrackId,
|
||||
type: trackType,
|
||||
});
|
||||
const newTrackWithElement = {
|
||||
...newTrack,
|
||||
elements: [...newTrack.elements, element],
|
||||
} as TimelineTrack;
|
||||
const elementToPlace =
|
||||
placementResult.kind === "existingTrack"
|
||||
? {
|
||||
...element,
|
||||
startTime:
|
||||
placementResult.adjustedStartTime ?? element.startTime,
|
||||
}
|
||||
: element;
|
||||
|
||||
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({
|
||||
return applyPlacement({
|
||||
tracks,
|
||||
trackType,
|
||||
placementResult,
|
||||
elements: [elementToPlace],
|
||||
newTrackInsertIndexOverride:
|
||||
placement.mode === "auto" && typeof placement.insertIndex === "number"
|
||||
? placement.insertIndex
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private adjustElementForMainTrack({
|
||||
tracks,
|
||||
targetTrackId,
|
||||
element,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
targetTrackId: string;
|
||||
element: TimelineElement;
|
||||
}): TimelineElement {
|
||||
const adjustedStartTime = enforceMainTrackStart({
|
||||
tracks,
|
||||
targetTrackId,
|
||||
requestedStartTime: element.startTime,
|
||||
});
|
||||
return { ...element, startTime: adjustedStartTime };
|
||||
}
|
||||
|
||||
private getTrackTypeForElement({
|
||||
element,
|
||||
}: {
|
||||
element: { type: ElementType };
|
||||
}): TrackType {
|
||||
return ELEMENT_TRACK_MAP[element.type];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
isMainTrack,
|
||||
validateElementTrackCompatibility,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
} from "@/lib/timeline/placement";
|
||||
import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
|
||||
|
||||
export class MoveElementCommand extends Command {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
|
||||
import { enforceMainTrackStart } from "@/lib/timeline/placement";
|
||||
|
||||
export class UpdateElementStartTimeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
|
||||
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
|
||||
import { enforceMainTrackStart } from "@/lib/timeline/placement";
|
||||
|
||||
export class UpdateElementTrimCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { EditorCore } from "@/core";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
getDefaultInsertIndexForTrack,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
} from "@/lib/timeline/placement";
|
||||
|
||||
export class AddTrackCommand extends Command {
|
||||
private trackId: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { getMainTrack } from "@/lib/timeline";
|
||||
import { getMainTrack } from "@/lib/timeline/placement";
|
||||
|
||||
export class RemoveTrackCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
Reference in New Issue
Block a user