mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: masks, properties refactor, shaders, storage migrations, and more
This commit is contained in:
@@ -1,433 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TimelineTrack, VideoElement } from "@/types/timeline";
|
||||
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
|
||||
import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
|
||||
import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
|
||||
import { SplitElementsCommand } from "@/lib/commands/timeline/element/split-elements";
|
||||
import { DuplicateElementsCommand } from "@/lib/commands/timeline/element/duplicate-elements";
|
||||
import { UpsertKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/upsert-keyframe";
|
||||
import { RemoveKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/remove-keyframe";
|
||||
import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
|
||||
|
||||
type MockEditor = {
|
||||
timeline: {
|
||||
getTracks: () => TimelineTrack[];
|
||||
updateTracks: (tracks: TimelineTrack[]) => void;
|
||||
};
|
||||
selection: {
|
||||
getSelectedElements: () => { trackId: string; elementId: string }[];
|
||||
setSelectedElements: ({
|
||||
elements,
|
||||
}: {
|
||||
elements: { trackId: string; elementId: string }[];
|
||||
}) => void;
|
||||
};
|
||||
};
|
||||
|
||||
const originalGetInstance = EditorCore.getInstance;
|
||||
|
||||
function mockEditorCore({ editor }: { editor: MockEditor }): void {
|
||||
(
|
||||
EditorCore as unknown as {
|
||||
getInstance: () => EditorCore;
|
||||
}
|
||||
).getInstance = () => editor as unknown as EditorCore;
|
||||
}
|
||||
|
||||
function restoreEditorCore(): void {
|
||||
(
|
||||
EditorCore as unknown as {
|
||||
getInstance: typeof EditorCore.getInstance;
|
||||
}
|
||||
).getInstance = originalGetInstance;
|
||||
}
|
||||
|
||||
function buildVideoElement(): VideoElement {
|
||||
return {
|
||||
id: "element-1",
|
||||
name: "Clip",
|
||||
type: "video",
|
||||
mediaId: "media-1",
|
||||
duration: 8,
|
||||
startTime: 1,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: DEFAULT_TRANSFORM,
|
||||
opacity: 1,
|
||||
animations: {
|
||||
channels: {
|
||||
"transform.scale": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{ id: "kf-a", time: 0, value: 1, interpolation: "linear" },
|
||||
{ id: "kf-b", time: 3, value: 1.5, interpolation: "linear" },
|
||||
{ id: "kf-c", time: 6, value: 2, interpolation: "linear" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTracks({ element }: { element: VideoElement }): TimelineTrack[] {
|
||||
return [
|
||||
{
|
||||
id: "track-1",
|
||||
name: "Main",
|
||||
type: "video",
|
||||
elements: [element],
|
||||
isMain: true,
|
||||
muted: false,
|
||||
hidden: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
restoreEditorCore();
|
||||
});
|
||||
|
||||
describe("keyframe-aware timeline commands", () => {
|
||||
test("duration updates clamp keyframes beyond the new duration", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpdateElementDurationCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
duration: 3,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = (updatedTracks[0].elements[0] as VideoElement).animations;
|
||||
expect(
|
||||
updatedElement?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 3]);
|
||||
});
|
||||
|
||||
test("trim updates clamp keyframes when duration is changed", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpdateElementTrimCommand({
|
||||
elementId: "element-1",
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
startTime: 1,
|
||||
duration: 2,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
expect(updatedElement.duration).toBe(2);
|
||||
expect(
|
||||
updatedElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0]);
|
||||
});
|
||||
|
||||
test("split rebases right-side keyframes and keeps continuity at split time", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new SplitElementsCommand({
|
||||
elements: [{ trackId: "track-1", elementId: "element-1" }],
|
||||
splitTime: 5,
|
||||
}).execute();
|
||||
|
||||
const leftElement = updatedTracks[0].elements.find(
|
||||
(element) => element.id === "element-1",
|
||||
) as VideoElement;
|
||||
const rightElement = updatedTracks[0].elements.find(
|
||||
(element) => element.id !== "element-1",
|
||||
) as VideoElement;
|
||||
|
||||
expect(
|
||||
leftElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 3, 4]);
|
||||
expect(
|
||||
rightElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 2]);
|
||||
expect(
|
||||
rightElement.animations?.channels["transform.scale"]?.keyframes[0]?.value,
|
||||
).toBeCloseTo(5 / 3, 4);
|
||||
});
|
||||
|
||||
test("duplicate creates independent keyframe ids for copied element", () => {
|
||||
const tracks = buildTracks({ element: buildVideoElement() });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [{ trackId: "track-1", elementId: "element-1" }],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new DuplicateElementsCommand({
|
||||
elements: [{ trackId: "track-1", elementId: "element-1" }],
|
||||
}).execute();
|
||||
|
||||
const originalElement = updatedTracks.find(
|
||||
(track) => track.id === "track-1",
|
||||
)?.elements[0] as VideoElement;
|
||||
const duplicatedTrack = updatedTracks.find((track) => track.id !== "track-1");
|
||||
const duplicatedElement = duplicatedTrack?.elements[0] as VideoElement;
|
||||
|
||||
expect(duplicatedElement).toBeDefined();
|
||||
expect(
|
||||
duplicatedElement.animations?.channels["transform.scale"]?.keyframes.map(
|
||||
(keyframe) => keyframe.time,
|
||||
),
|
||||
).toEqual([0, 3, 6]);
|
||||
expect(
|
||||
duplicatedElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
|
||||
).not.toBe(
|
||||
originalElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generic keyframe commands", () => {
|
||||
test("upsert adds or updates keyframe at target time", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpsertKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
time: 2,
|
||||
value: 2.5,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const keyframes =
|
||||
updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
|
||||
const atTwo = keyframes.find((keyframe) => Math.abs(keyframe.time - 2) < 0.001);
|
||||
expect(atTwo?.value).toBe(2.5);
|
||||
});
|
||||
|
||||
test("remove deletes keyframe by id", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new RemoveKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
keyframeId: "kf-b",
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const keyframes =
|
||||
updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
|
||||
expect(keyframes).toHaveLength(2);
|
||||
expect(keyframes.find((keyframe) => keyframe.id === "kf-b")).toBeUndefined();
|
||||
expect(updatedElement.transform.scale).toBe(1);
|
||||
});
|
||||
|
||||
test("remove persists value to base property when channel becomes empty", () => {
|
||||
const element: VideoElement = {
|
||||
...buildVideoElement(),
|
||||
transform: {
|
||||
...DEFAULT_TRANSFORM,
|
||||
scale: 1,
|
||||
},
|
||||
animations: {
|
||||
channels: {
|
||||
"transform.scale": {
|
||||
valueKind: "number",
|
||||
keyframes: [
|
||||
{
|
||||
id: "only-scale",
|
||||
time: 2,
|
||||
value: 1.43,
|
||||
interpolation: "linear",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new RemoveKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
keyframeId: "only-scale",
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
expect(updatedElement.transform.scale).toBe(1.43);
|
||||
expect(updatedElement.animations?.channels["transform.scale"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("upsert supports non-transform paths like opacity", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new UpsertKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "opacity",
|
||||
time: 1,
|
||||
value: 0.35,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const opacityChannel = updatedElement.animations?.channels.opacity;
|
||||
expect(opacityChannel?.valueKind).toBe("number");
|
||||
expect(opacityChannel?.keyframes[0]?.value).toBe(0.35);
|
||||
});
|
||||
|
||||
test("retime moves keyframe to new time", () => {
|
||||
const element = buildVideoElement();
|
||||
const tracks = buildTracks({ element });
|
||||
let updatedTracks: TimelineTrack[] = tracks;
|
||||
mockEditorCore({
|
||||
editor: {
|
||||
timeline: {
|
||||
getTracks: () => tracks,
|
||||
updateTracks: (nextTracks) => {
|
||||
updatedTracks = nextTracks;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
getSelectedElements: () => [],
|
||||
setSelectedElements: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
new RetimeKeyframeCommand({
|
||||
trackId: "track-1",
|
||||
elementId: "element-1",
|
||||
propertyPath: "transform.scale",
|
||||
keyframeId: "kf-b",
|
||||
nextTime: 4,
|
||||
}).execute();
|
||||
|
||||
const updatedElement = updatedTracks[0].elements[0] as VideoElement;
|
||||
const keyframe = updatedElement.animations?.channels["transform.scale"]?.keyframes.find(
|
||||
(existingKeyframe) => existingKeyframe.id === "kf-b",
|
||||
);
|
||||
expect(keyframe?.time).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
export { Command } from "./base-command";
|
||||
export { BatchCommand } from "./batch-command";
|
||||
export { PreviewTracker } from "./preview-tracker";
|
||||
|
||||
export * from "./timeline";
|
||||
export * from "./media";
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import { toast } from "sonner";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
||||
|
||||
export class AddMediaAssetCommand extends Command {
|
||||
private assetId: string;
|
||||
@@ -37,6 +39,36 @@ export class AddMediaAssetCommand extends Command {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to save media item:", error);
|
||||
|
||||
const currentAssets = editor.media.getAssets();
|
||||
editor.media.setAssets({
|
||||
assets: currentAssets.filter((asset) => asset.id !== this.assetId),
|
||||
});
|
||||
|
||||
const currentTracks = editor.timeline.getTracks();
|
||||
const orphanedElements: Array<{ trackId: string; elementId: string }> =
|
||||
[];
|
||||
|
||||
for (const track of currentTracks) {
|
||||
for (const element of track.elements) {
|
||||
if (hasMediaId(element) && element.mediaId === this.assetId) {
|
||||
orphanedElements.push({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanedElements.length > 0) {
|
||||
editor.timeline.deleteElements({ elements: orphanedElements });
|
||||
}
|
||||
|
||||
if (storageService.isQuotaExceededError({ error })) {
|
||||
toast.error("Not enough browser storage", {
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { storageService } from "@/services/storage/service";
|
||||
import { videoCache } from "@/services/video-cache/service";
|
||||
import { hasMediaId } from "@/lib/timeline/element-utils";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
|
||||
export class RemoveMediaAssetCommand extends Command {
|
||||
private savedAssets: MediaAsset[] | null = null;
|
||||
@@ -33,6 +33,13 @@ export class RemoveMediaAssetCommand extends Command {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.removedAsset.url) {
|
||||
URL.revokeObjectURL(this.removedAsset.url);
|
||||
}
|
||||
if (this.removedAsset.thumbnailUrl) {
|
||||
URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
|
||||
}
|
||||
|
||||
videoCache.clearVideo({ mediaId: this.assetId });
|
||||
|
||||
editor.media.setAssets({
|
||||
@@ -63,23 +70,30 @@ export class RemoveMediaAssetCommand extends Command {
|
||||
undo(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
|
||||
if (this.savedAssets) {
|
||||
editor.media.setAssets({ assets: this.savedAssets });
|
||||
}
|
||||
if (this.savedAssets && this.removedAsset) {
|
||||
const restoredAsset: MediaAsset = {
|
||||
...this.removedAsset,
|
||||
url: URL.createObjectURL(this.removedAsset.file),
|
||||
};
|
||||
|
||||
if (this.savedTracks) {
|
||||
editor.timeline.updateTracks(this.savedTracks);
|
||||
}
|
||||
editor.media.setAssets({
|
||||
assets: this.savedAssets.map((a) =>
|
||||
a.id === this.assetId ? restoredAsset : a,
|
||||
),
|
||||
});
|
||||
|
||||
if (this.removedAsset) {
|
||||
storageService
|
||||
.saveMediaAsset({
|
||||
projectId: this.projectId,
|
||||
mediaAsset: this.removedAsset,
|
||||
mediaAsset: restoredAsset,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to restore media item on undo:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.savedTracks) {
|
||||
editor.timeline.updateTracks(this.savedTracks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TProject, TProjectSettings } from "@/types/project";
|
||||
import type { TProject, TProjectSettings } from "@/lib/project/types";
|
||||
|
||||
export class UpdateProjectSettingsCommand extends Command {
|
||||
private savedSettings: TProjectSettings | null = null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/types/timeline";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { buildDefaultScene } from "@/lib/scenes";
|
||||
|
||||
export class CreateSceneCommand extends Command {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/types/timeline";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
|
||||
|
||||
export class DeleteSceneCommand extends Command {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/types/timeline";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { updateSceneInArray } from "@/lib/scenes";
|
||||
import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/types/timeline";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { updateSceneInArray } from "@/lib/scenes";
|
||||
import {
|
||||
getFrameTime,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/types/timeline";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { updateSceneInArray } from "@/lib/scenes";
|
||||
|
||||
export class RenameSceneCommand extends Command {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TScene } from "@/types/timeline";
|
||||
import type { TScene } from "@/lib/timeline";
|
||||
import { updateSceneInArray } from "@/lib/scenes";
|
||||
import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { Bookmark, TScene } from "@/types/timeline";
|
||||
import type { Bookmark, TScene } from "@/lib/timeline";
|
||||
import { updateSceneInArray } from "@/lib/scenes";
|
||||
import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
TimelineTrack,
|
||||
TimelineElement,
|
||||
ClipboardItem,
|
||||
} from "@/types/timeline";
|
||||
} from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/types/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
||||
import { buildDefaultEffectInstance } from "@/lib/effects";
|
||||
|
||||
function addEffectToElement({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/types/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
||||
|
||||
function removeEffectFromElement({
|
||||
element,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/types/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
||||
|
||||
function reorderEffectsOnElement({
|
||||
element,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/types/timeline";
|
||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
||||
|
||||
export function toggleEffectOnElement({
|
||||
element,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { EffectParamValues } from "@/types/effects";
|
||||
import type { TimelineTrack, VisualElement } from "@/types/timeline";
|
||||
import type { ParamValues } from "@/lib/params";
|
||||
import type { TimelineTrack, VisualElement } from "@/lib/timeline";
|
||||
|
||||
function updateEffectParamsOnElement({
|
||||
element,
|
||||
@@ -11,7 +11,7 @@ function updateEffectParamsOnElement({
|
||||
}: {
|
||||
element: VisualElement;
|
||||
effectId: string;
|
||||
params: Partial<EffectParamValues>;
|
||||
params: Partial<ParamValues>;
|
||||
}): VisualElement {
|
||||
const currentEffects = element.effects ?? [];
|
||||
const updated = currentEffects.map((effect) => {
|
||||
@@ -36,7 +36,7 @@ export class UpdateClipEffectParamsCommand extends Command {
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly effectId: string;
|
||||
private readonly params: Partial<EffectParamValues>;
|
||||
private readonly params: Partial<ParamValues>;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
@@ -47,7 +47,7 @@ export class UpdateClipEffectParamsCommand extends Command {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectId: string;
|
||||
params: Partial<EffectParamValues>;
|
||||
params: Partial<ParamValues>;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
@@ -65,12 +65,12 @@ export class UpdateClipEffectParamsCommand extends Command {
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isVisualElement,
|
||||
update: (element) => {
|
||||
return updateEffectParamsOnElement({
|
||||
element: element as VisualElement,
|
||||
effectId: this.effectId,
|
||||
params: this.params,
|
||||
});
|
||||
update: (element) => {
|
||||
return updateEffectParamsOnElement({
|
||||
element: element as VisualElement,
|
||||
effectId: this.effectId,
|
||||
params: this.params,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -11,3 +11,5 @@ export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
|
||||
export { MoveElementCommand } from "./move-elements";
|
||||
export * from "./keyframes";
|
||||
export * from "./effects";
|
||||
export * from "./masks";
|
||||
export * from "./retime";
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
TimelineElement,
|
||||
TrackType,
|
||||
ElementType,
|
||||
} from "@/types/timeline";
|
||||
} from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import {
|
||||
requiresMediaId,
|
||||
@@ -19,8 +19,9 @@ import {
|
||||
validateElementTrackCompatibility,
|
||||
enforceMainTrackStart,
|
||||
} from "@/lib/timeline/track-utils";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
|
||||
|
||||
type InsertElementPlacement =
|
||||
| { mode: "explicit"; trackId: string }
|
||||
@@ -168,6 +169,14 @@ export class InsertElementCommand extends Command {
|
||||
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;
|
||||
@@ -344,9 +353,6 @@ export class InsertElementCommand extends Command {
|
||||
}: {
|
||||
element: { type: ElementType };
|
||||
}): TrackType {
|
||||
if (element.type === "video" || element.type === "image") {
|
||||
return "video";
|
||||
}
|
||||
return element.type;
|
||||
return ELEMENT_TRACK_MAP[element.type];
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Command } from "@/lib/commands/base-command";
|
||||
import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
|
||||
export class RemoveEffectParamKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
@@ -2,15 +2,13 @@ import { EditorCore } from "@/core";
|
||||
import {
|
||||
getChannel,
|
||||
getChannelValueAtTime,
|
||||
getElementBaseValueForProperty,
|
||||
removeElementKeyframe,
|
||||
supportsAnimationProperty,
|
||||
withElementBaseValueForProperty,
|
||||
resolveAnimationTarget,
|
||||
} from "@/lib/animation";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import type { AnimationPropertyPath } from "@/types/animation";
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import type { AnimationPath, AnimationValue } from "@/lib/animation/types";
|
||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||
|
||||
function sampleValueBeforeRemoval({
|
||||
element,
|
||||
@@ -18,9 +16,9 @@ function sampleValueBeforeRemoval({
|
||||
keyframeId,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
}): number | null {
|
||||
}): AnimationValue | null {
|
||||
const channel = getChannel({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
@@ -32,17 +30,20 @@ function sampleValueBeforeRemoval({
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseValue = getElementBaseValueForProperty({ element, propertyPath });
|
||||
if (baseValue === null || typeof baseValue !== "number") {
|
||||
const target = resolveAnimationTarget({ element, path: propertyPath });
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const baseValue = target.getBaseValue();
|
||||
if (baseValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sampled = getChannelValueAtTime({
|
||||
return getChannelValueAtTime({
|
||||
channel,
|
||||
time: keyframe.time,
|
||||
fallbackValue: baseValue,
|
||||
});
|
||||
return typeof sampled === "number" ? sampled : null;
|
||||
}
|
||||
|
||||
function removeKeyframeAndPersist({
|
||||
@@ -51,9 +52,14 @@ function removeKeyframeAndPersist({
|
||||
keyframeId,
|
||||
}: {
|
||||
element: TimelineElement;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
}): TimelineElement {
|
||||
const target = resolveAnimationTarget({ element, path: propertyPath });
|
||||
if (!target) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const valueBefore = sampleValueBeforeRemoval({
|
||||
element,
|
||||
propertyPath,
|
||||
@@ -71,11 +77,7 @@ function removeKeyframeAndPersist({
|
||||
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
|
||||
|
||||
const baseElement = shouldPersistToBase
|
||||
? withElementBaseValueForProperty({
|
||||
element,
|
||||
propertyPath,
|
||||
value: valueBefore,
|
||||
})
|
||||
? target.setBaseValue(valueBefore)
|
||||
: element;
|
||||
|
||||
return { ...baseElement, animations: nextAnimations };
|
||||
@@ -85,7 +87,7 @@ export class RemoveKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPropertyPath;
|
||||
private readonly propertyPath: AnimationPath;
|
||||
private readonly keyframeId: string;
|
||||
|
||||
constructor({
|
||||
@@ -96,7 +98,7 @@ export class RemoveKeyframeCommand extends Command {
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
}) {
|
||||
super();
|
||||
@@ -114,11 +116,6 @@ export class RemoveKeyframeCommand extends Command {
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: (element) =>
|
||||
supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
}),
|
||||
update: (element) =>
|
||||
removeKeyframeAndPersist({
|
||||
element,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation";
|
||||
import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import type { AnimationPropertyPath } from "@/types/animation";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { AnimationPath } from "@/lib/animation/types";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
|
||||
export class RetimeKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPropertyPath;
|
||||
private readonly propertyPath: AnimationPath;
|
||||
private readonly keyframeId: string;
|
||||
private readonly nextTime: number;
|
||||
|
||||
@@ -22,7 +22,7 @@ export class RetimeKeyframeCommand extends Command {
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
propertyPath: AnimationPath;
|
||||
keyframeId: string;
|
||||
nextTime: number;
|
||||
}) {
|
||||
@@ -42,14 +42,12 @@ export class RetimeKeyframeCommand extends Command {
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: (element) =>
|
||||
supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
}),
|
||||
update: (element) => {
|
||||
if (!resolveAnimationTarget({ element, path: this.propertyPath })) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
|
||||
if (!Number.isFinite(boundedTime)) return element;
|
||||
return {
|
||||
...element,
|
||||
animations: retimeElementKeyframe({
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Command } from "@/lib/commands/base-command";
|
||||
import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
|
||||
export class UpsertEffectParamKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation";
|
||||
import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import type {
|
||||
AnimationPath,
|
||||
AnimationInterpolation,
|
||||
AnimationPropertyPath,
|
||||
AnimationValue,
|
||||
} from "@/types/animation";
|
||||
} from "@/lib/animation/types";
|
||||
|
||||
export class UpsertKeyframeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly propertyPath: AnimationPropertyPath;
|
||||
private readonly propertyPath: AnimationPath;
|
||||
private readonly time: number;
|
||||
private readonly value: AnimationValue;
|
||||
private readonly interpolation: AnimationInterpolation | undefined;
|
||||
@@ -30,7 +30,7 @@ export class UpsertKeyframeCommand extends Command {
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
propertyPath: AnimationPropertyPath;
|
||||
propertyPath: AnimationPath;
|
||||
time: number;
|
||||
value: AnimationValue;
|
||||
interpolation?: AnimationInterpolation;
|
||||
@@ -54,22 +54,28 @@ export class UpsertKeyframeCommand extends Command {
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: (element) =>
|
||||
supportsAnimationProperty({
|
||||
element,
|
||||
propertyPath: this.propertyPath,
|
||||
}),
|
||||
update: (element) => {
|
||||
const target = resolveAnimationTarget({
|
||||
element,
|
||||
path: this.propertyPath,
|
||||
});
|
||||
if (!target) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
||||
return {
|
||||
...element,
|
||||
animations: upsertElementKeyframe({
|
||||
animations: upsertPathKeyframe({
|
||||
animations: element.animations,
|
||||
propertyPath: this.propertyPath,
|
||||
time: boundedTime,
|
||||
value: this.value,
|
||||
interpolation: this.interpolation,
|
||||
keyframeId: this.keyframeId,
|
||||
valueKind: target.valueKind,
|
||||
defaultInterpolation: target.defaultInterpolation,
|
||||
numericRange: target.numericRange,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { RemoveMaskCommand } from "./remove-mask";
|
||||
export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
|
||||
@@ -0,0 +1,64 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { isMaskableElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { TimelineTrack, MaskableElement } from "@/lib/timeline";
|
||||
|
||||
function removeMaskFromElement({
|
||||
element,
|
||||
maskId,
|
||||
}: {
|
||||
element: MaskableElement;
|
||||
maskId: string;
|
||||
}): MaskableElement {
|
||||
const currentMasks = element.masks ?? [];
|
||||
const filteredMasks = currentMasks.filter((mask) => mask.id !== maskId);
|
||||
return { ...element, masks: filteredMasks };
|
||||
}
|
||||
|
||||
export class RemoveMaskCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly maskId: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.maskId = maskId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isMaskableElement,
|
||||
update: (element) =>
|
||||
removeMaskFromElement({
|
||||
element: element as MaskableElement,
|
||||
maskId: this.maskId,
|
||||
}),
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { isMaskableElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { Mask } from "@/lib/masks/types";
|
||||
import type { TimelineTrack, MaskableElement } from "@/lib/timeline";
|
||||
|
||||
export function toggleMaskInvertedOnElement({
|
||||
element,
|
||||
maskId,
|
||||
}: {
|
||||
element: MaskableElement;
|
||||
maskId: string;
|
||||
}): MaskableElement {
|
||||
const currentMasks = element.masks ?? [];
|
||||
const toggleMask = <TMask extends Mask>(mask: TMask): TMask => ({
|
||||
...mask,
|
||||
params: {
|
||||
...mask.params,
|
||||
inverted: !mask.params.inverted,
|
||||
},
|
||||
});
|
||||
const updatedMasks = currentMasks.map((mask) =>
|
||||
mask.id !== maskId ? mask : toggleMask(mask),
|
||||
);
|
||||
|
||||
return { ...element, masks: updatedMasks };
|
||||
}
|
||||
|
||||
export class ToggleMaskInvertedCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly maskId: string;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
maskId,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
maskId: string;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.maskId = maskId;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isMaskableElement,
|
||||
update: (element) =>
|
||||
toggleMaskInvertedOnElement({
|
||||
element: element as MaskableElement,
|
||||
maskId: this.maskId,
|
||||
}),
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
TimelineTrack,
|
||||
TimelineElement,
|
||||
TrackType,
|
||||
} from "@/types/timeline";
|
||||
} from "@/lib/timeline";
|
||||
import {
|
||||
buildEmptyTrack,
|
||||
isMainTrack,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { UpdateElementRetimeCommand } from "./update-element-retime";
|
||||
@@ -0,0 +1,114 @@
|
||||
import { EditorCore } from "@/core";
|
||||
import { clampRetimeRate } from "@/constants/retime-constants";
|
||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime";
|
||||
import { isRetimableElement, updateElementInTracks } from "@/lib/timeline";
|
||||
import type { RetimeConfig, TimelineTrack } from "@/lib/timeline";
|
||||
|
||||
function getSourceDuration({
|
||||
trimStart,
|
||||
trimEnd,
|
||||
duration,
|
||||
sourceDuration,
|
||||
retime,
|
||||
}: {
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
duration: number;
|
||||
sourceDuration?: number;
|
||||
retime?: RetimeConfig;
|
||||
}): number {
|
||||
if (typeof sourceDuration === "number") {
|
||||
return sourceDuration;
|
||||
}
|
||||
|
||||
return (
|
||||
trimStart +
|
||||
getSourceSpanAtClipTime({
|
||||
clipTime: duration,
|
||||
retime,
|
||||
}) +
|
||||
trimEnd
|
||||
);
|
||||
}
|
||||
|
||||
export class UpdateElementRetimeCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
private readonly trackId: string;
|
||||
private readonly elementId: string;
|
||||
private readonly retime: RetimeConfig | undefined;
|
||||
|
||||
constructor({
|
||||
trackId,
|
||||
elementId,
|
||||
retime,
|
||||
}: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
retime?: RetimeConfig;
|
||||
}) {
|
||||
super();
|
||||
this.trackId = trackId;
|
||||
this.elementId = elementId;
|
||||
this.retime = retime;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const editor = EditorCore.getInstance();
|
||||
this.savedState = editor.timeline.getTracks();
|
||||
|
||||
const updatedTracks = updateElementInTracks({
|
||||
tracks: this.savedState,
|
||||
trackId: this.trackId,
|
||||
elementId: this.elementId,
|
||||
elementPredicate: isRetimableElement,
|
||||
update: (element) => {
|
||||
if (!isRetimableElement(element)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const nextRetime = this.retime
|
||||
? {
|
||||
...this.retime,
|
||||
rate: clampRetimeRate({ rate: this.retime.rate }),
|
||||
}
|
||||
: undefined;
|
||||
const sourceDuration = getSourceDuration({
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
duration: element.duration,
|
||||
sourceDuration: element.sourceDuration,
|
||||
retime: element.retime,
|
||||
});
|
||||
const visibleSourceSpan = Math.max(
|
||||
0,
|
||||
sourceDuration - element.trimStart - element.trimEnd,
|
||||
);
|
||||
const nextDuration = getTimelineDurationForSourceSpan({
|
||||
sourceSpan: visibleSourceSpan,
|
||||
retime: nextRetime,
|
||||
});
|
||||
|
||||
return {
|
||||
...element,
|
||||
retime: nextRetime,
|
||||
duration: nextDuration,
|
||||
animations: clampAnimationsToDuration({
|
||||
animations: element.animations,
|
||||
duration: nextDuration,
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
editor.timeline.updateTracks(updatedTracks);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.savedState) {
|
||||
const editor = EditorCore.getInstance();
|
||||
editor.timeline.updateTracks(this.savedState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import { rippleShiftElements } from "@/lib/timeline";
|
||||
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
|
||||
import { splitAnimationsAtTime } from "@/lib/animation";
|
||||
import { getSourceSpanAtClipTime } from "@/lib/retime";
|
||||
|
||||
export class SplitElementsCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
@@ -75,6 +76,18 @@ export class SplitElementsCommand extends Command {
|
||||
const relativeTime = this.splitTime - element.startTime;
|
||||
const leftVisibleDuration = relativeTime;
|
||||
const rightVisibleDuration = element.duration - relativeTime;
|
||||
const retimeRef = isRetimableElement(element)
|
||||
? element.retime
|
||||
: undefined;
|
||||
const leftSourceSpan = getSourceSpanAtClipTime({
|
||||
clipTime: leftVisibleDuration,
|
||||
retime: retimeRef,
|
||||
});
|
||||
const totalSourceSpan = getSourceSpanAtClipTime({
|
||||
clipTime: element.duration,
|
||||
retime: retimeRef,
|
||||
});
|
||||
const rightSourceSpan = totalSourceSpan - leftSourceSpan;
|
||||
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
|
||||
animations: element.animations,
|
||||
splitTime: relativeTime,
|
||||
@@ -83,63 +96,67 @@ export class SplitElementsCommand extends Command {
|
||||
|
||||
if (this.retainSide === "left") {
|
||||
return [
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightVisibleDuration,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (this.retainSide === "right") {
|
||||
if (this.rippleEnabled && elementsToSplit.length === 1) {
|
||||
leftVisibleDurationForRipple = leftVisibleDuration;
|
||||
}
|
||||
const newId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
elementId: newId,
|
||||
});
|
||||
return [
|
||||
{
|
||||
...element,
|
||||
id: newId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftVisibleDuration,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// "both" - split into two pieces
|
||||
const secondElementId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
elementId: secondElementId,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
},
|
||||
{
|
||||
...element,
|
||||
id: secondElementId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftVisibleDuration,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (this.retainSide === "right") {
|
||||
if (this.rippleEnabled && elementsToSplit.length === 1) {
|
||||
leftVisibleDurationForRipple = leftVisibleDuration;
|
||||
}
|
||||
const newId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
elementId: newId,
|
||||
});
|
||||
return [
|
||||
{
|
||||
...element,
|
||||
id: newId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// "both" - split into two pieces
|
||||
const secondElementId = generateUUID();
|
||||
this.rightSideElements.push({
|
||||
trackId: track.id,
|
||||
elementId: secondElementId,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
{
|
||||
...element,
|
||||
id: secondElementId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (this.rippleEnabled && leftVisibleDurationForRipple !== null) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { canElementBeHidden } from "@/lib/timeline/element-utils";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { clampAnimationsToDuration } from "@/lib/animation";
|
||||
import { rippleShiftElements } from "@/lib/timeline";
|
||||
import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
|
||||
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
|
||||
|
||||
export class UpdateElementTrimCommand extends Command {
|
||||
private savedState: TimelineTrack[] | null = null;
|
||||
@@ -48,7 +49,13 @@ export class UpdateElementTrimCommand extends Command {
|
||||
if (!targetElement) return track;
|
||||
|
||||
const nextDuration = this.duration ?? targetElement.duration;
|
||||
const nextStartTime = this.startTime ?? targetElement.startTime;
|
||||
const requestedStartTime = this.startTime ?? targetElement.startTime;
|
||||
const nextStartTime = enforceMainTrackStart({
|
||||
tracks: this.savedState ?? [],
|
||||
targetTrackId: track.id,
|
||||
requestedStartTime,
|
||||
excludeElementId: this.elementId,
|
||||
});
|
||||
|
||||
const oldEndTime = targetElement.startTime + targetElement.duration;
|
||||
const newEndTime = nextStartTime + nextDuration;
|
||||
@@ -60,6 +67,9 @@ export class UpdateElementTrimCommand extends Command {
|
||||
trimEnd: this.trimEnd,
|
||||
startTime: nextStartTime,
|
||||
duration: nextDuration,
|
||||
...(isRetimableElement(targetElement)
|
||||
? { retime: targetElement.retime }
|
||||
: {}),
|
||||
animations: clampAnimationsToDuration({
|
||||
animations: targetElement.animations,
|
||||
duration: nextDuration,
|
||||
@@ -68,7 +78,9 @@ export class UpdateElementTrimCommand extends Command {
|
||||
|
||||
if (this.rippleEnabled && Math.abs(shiftAmount) > 0) {
|
||||
const shiftedOthers = rippleShiftElements({
|
||||
elements: track.elements.filter((element) => element.id !== this.elementId),
|
||||
elements: track.elements.filter(
|
||||
(element) => element.id !== this.elementId,
|
||||
),
|
||||
afterTime: oldEndTime,
|
||||
shiftAmount,
|
||||
});
|
||||
@@ -77,7 +89,8 @@ export class UpdateElementTrimCommand extends Command {
|
||||
elements: track.elements.map((element) =>
|
||||
element.id === this.elementId
|
||||
? updatedElement
|
||||
: (shiftedOthers.find((shifted) => shifted.id === element.id) ?? element)
|
||||
: (shiftedOthers.find((shifted) => shifted.id === element.id) ??
|
||||
element),
|
||||
),
|
||||
} as typeof track;
|
||||
}
|
||||
@@ -85,7 +98,7 @@ export class UpdateElementTrimCommand extends Command {
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.map((element) =>
|
||||
element.id === this.elementId ? updatedElement : element
|
||||
element.id === this.elementId ? updatedElement : element,
|
||||
),
|
||||
} as typeof track;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { updateElementInTracks } from "@/lib/timeline";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TrackType, TimelineTrack } from "@/types/timeline";
|
||||
import type { TrackType, TimelineTrack } from "@/lib/timeline";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import { EditorCore } from "@/core";
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import { EditorCore } from "@/core";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { getMainTrack } from "@/lib/timeline";
|
||||
|
||||
export class RemoveTrackCommand extends Command {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { canTracktHaveAudio } from "@/lib/timeline";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
import { canTrackBeHidden } from "@/lib/timeline";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "@/lib/commands/base-command";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineTrack } from "@/lib/timeline";
|
||||
import { EditorCore } from "@/core";
|
||||
|
||||
export class TracksSnapshotCommand extends Command {
|
||||
|
||||
Reference in New Issue
Block a user