summaryrefslogtreecommitdiff
path: root/apps/web/src/commands/timeline/element
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
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')
-rw-r--r--apps/web/src/commands/timeline/element/delete-elements.ts72
-rw-r--r--apps/web/src/commands/timeline/element/duplicate-elements.ts134
-rw-r--r--apps/web/src/commands/timeline/element/effects/add-effect.ts75
-rw-r--r--apps/web/src/commands/timeline/element/effects/index.ts5
-rw-r--r--apps/web/src/commands/timeline/element/effects/remove-effect.ts66
-rw-r--r--apps/web/src/commands/timeline/element/effects/reorder-effect.ts74
-rw-r--r--apps/web/src/commands/timeline/element/effects/toggle-effect.ts68
-rw-r--r--apps/web/src/commands/timeline/element/effects/update-effect-params.ts87
-rw-r--r--apps/web/src/commands/timeline/element/index.ts11
-rw-r--r--apps/web/src/commands/timeline/element/insert-element.ts291
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/index.ts6
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts69
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts107
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts75
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts85
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts104
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts96
-rw-r--r--apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts131
-rw-r--r--apps/web/src/commands/timeline/element/masks/index.ts4
-rw-r--r--apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts169
-rw-r--r--apps/web/src/commands/timeline/element/masks/remove-mask.ts65
-rw-r--r--apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts76
-rw-r--r--apps/web/src/commands/timeline/element/move-elements.ts183
-rw-r--r--apps/web/src/commands/timeline/element/split-elements.ts182
-rw-r--r--apps/web/src/commands/timeline/element/toggle-source-audio-separation.ts145
-rw-r--r--apps/web/src/commands/timeline/element/update-elements.ts75
26 files changed, 2455 insertions, 0 deletions
diff --git a/apps/web/src/commands/timeline/element/delete-elements.ts b/apps/web/src/commands/timeline/element/delete-elements.ts
new file mode 100644
index 00000000..ad0267b7
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/delete-elements.ts
@@ -0,0 +1,72 @@
+import { Command, type CommandResult } from "@/commands/base-command";
+import type { SceneTracks } from "@/timeline";
+import { EditorCore } from "@/core";
+import type { TimelineTrack } from "@/timeline";
+
+function removeTrackElements<TTrack extends TimelineTrack>({
+ track,
+ elements,
+}: {
+ track: TTrack;
+ elements: { trackId: string; elementId: string }[];
+}): TTrack {
+ const nextElements = track.elements.filter(
+ (element) =>
+ !elements.some(
+ (target) =>
+ target.trackId === track.id && target.elementId === element.id,
+ ),
+ );
+
+ return { ...track, elements: nextElements } as TTrack;
+}
+
+export class DeleteElementsCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly elements: { trackId: string; elementId: string }[];
+
+ constructor({
+ elements,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ }) {
+ super();
+ this.elements = elements;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const updatedTracks: SceneTracks = {
+ overlay: this.savedState.overlay.map((track) =>
+ removeTrackElements({ track, elements: this.elements }),
+ ),
+ main: removeTrackElements({
+ track: this.savedState.main,
+ elements: this.elements,
+ }),
+ audio: this.savedState.audio.map((track) =>
+ removeTrackElements({ track, elements: this.elements }),
+ ),
+ };
+
+ editor.timeline.updateTracks(updatedTracks);
+
+ return {
+ selection: {
+ selectedElements: [],
+ selectedKeyframes: [],
+ keyframeSelectionAnchor: null,
+ selectedMaskPoints: null,
+ },
+ };
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/duplicate-elements.ts b/apps/web/src/commands/timeline/element/duplicate-elements.ts
new file mode 100644
index 00000000..8f65f7c7
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/duplicate-elements.ts
@@ -0,0 +1,134 @@
+import {
+ Command,
+ createElementSelectionResult,
+ type CommandResult,
+} from "@/commands/base-command";
+import type { SceneTracks, TimelineElement } from "@/timeline";
+import { generateUUID } from "@/utils/id";
+import { EditorCore } from "@/core";
+import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement";
+import { cloneAnimations } from "@/animation";
+
+interface DuplicateElementsParams {
+ elements: { trackId: string; elementId: string }[];
+}
+
+export class DuplicateElementsCommand extends Command {
+ private duplicatedElements: { trackId: string; elementId: string }[] = [];
+ private savedState: SceneTracks | null = null;
+ private elements: DuplicateElementsParams["elements"];
+
+ constructor({ elements }: DuplicateElementsParams) {
+ super();
+ this.elements = elements;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+ this.duplicatedElements = [];
+
+ let updatedTracks = this.savedState;
+
+ for (const track of [
+ ...this.savedState.overlay,
+ this.savedState.main,
+ ...this.savedState.audio,
+ ]) {
+ const elementsToDuplicate = this.elements.filter(
+ (elementEntry) => elementEntry.trackId === track.id,
+ );
+
+ if (elementsToDuplicate.length === 0) {
+ continue;
+ }
+
+ const elementIdsToDuplicate = new Set(
+ elementsToDuplicate.map((element) => element.elementId),
+ );
+ const newTrackElements: TimelineElement[] = [];
+
+ for (const element of track.elements) {
+ if (!elementIdsToDuplicate.has(element.id)) {
+ continue;
+ }
+
+ const newId = generateUUID();
+ newTrackElements.push(
+ buildDuplicateElement({
+ element,
+ id: newId,
+ startTime: element.startTime,
+ }),
+ );
+ }
+
+ const placementResult = resolveTrackPlacement({
+ tracks: updatedTracks,
+ trackType: track.type,
+ timeSpans: [],
+ strategy: { type: "alwaysNew", position: "highest" },
+ });
+ 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);
+
+ if (this.duplicatedElements.length > 0) {
+ return createElementSelectionResult(this.duplicatedElements);
+ }
+ return undefined;
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+
+ getDuplicatedElements(): { trackId: string; elementId: string }[] {
+ return this.duplicatedElements;
+ }
+}
+
+function buildDuplicateElement({
+ element,
+ id,
+ startTime,
+}: {
+ element: TimelineElement;
+ id: string;
+ startTime: number;
+}): TimelineElement {
+ return {
+ ...element,
+ id,
+ name: `${element.name} (copy)`,
+ startTime,
+ animations: cloneAnimations({
+ animations: element.animations,
+ shouldRegenerateKeyframeIds: true,
+ }),
+ };
+}
diff --git a/apps/web/src/commands/timeline/element/effects/add-effect.ts b/apps/web/src/commands/timeline/element/effects/add-effect.ts
new file mode 100644
index 00000000..be08b433
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/effects/add-effect.ts
@@ -0,0 +1,75 @@
+import { Command, type CommandResult } from "@/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
+import type { SceneTracks, VisualElement } from "@/timeline";
+import { buildDefaultEffectInstance } from "@/effects";
+
+function addEffectToElement({
+ element,
+ effectType,
+}: {
+ element: VisualElement;
+ effectType: string;
+}): VisualElement {
+ const instance = buildDefaultEffectInstance({ effectType });
+ const currentEffects = element.effects ?? [];
+ return { ...element, effects: [...currentEffects, instance] };
+}
+
+export class AddClipEffectCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private effectId: string | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectType: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectType,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectType: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectType = effectType;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ const updated = addEffectToElement({
+ element: element as VisualElement,
+ effectType: this.effectType,
+ });
+ const effects = updated.effects ?? [];
+ this.effectId = effects[effects.length - 1]?.id ?? null;
+ return updated;
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+
+ getEffectId(): string | null {
+ return this.effectId;
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/effects/index.ts b/apps/web/src/commands/timeline/element/effects/index.ts
new file mode 100644
index 00000000..133ce661
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/effects/index.ts
@@ -0,0 +1,5 @@
+export { AddClipEffectCommand } from "./add-effect";
+export { RemoveClipEffectCommand } from "./remove-effect";
+export { ToggleClipEffectCommand } from "./toggle-effect";
+export { UpdateClipEffectParamsCommand } from "./update-effect-params";
+export { ReorderClipEffectsCommand } from "./reorder-effect";
diff --git a/apps/web/src/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/commands/timeline/element/effects/remove-effect.ts
new file mode 100644
index 00000000..e6ef9934
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/effects/remove-effect.ts
@@ -0,0 +1,66 @@
+import { Command, type CommandResult } from "@/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
+import type { SceneTracks, VisualElement } from "@/timeline";
+
+function removeEffectFromElement({
+ element,
+ effectId,
+}: {
+ element: VisualElement;
+ effectId: string;
+}): VisualElement {
+ const currentEffects = element.effects ?? [];
+ const filtered = currentEffects.filter((effect) => effect.id !== effectId);
+ return { ...element, effects: filtered };
+}
+
+export class RemoveClipEffectCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return removeEffectFromElement({
+ element: element as VisualElement,
+ effectId: this.effectId,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/commands/timeline/element/effects/reorder-effect.ts
new file mode 100644
index 00000000..554804e2
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/effects/reorder-effect.ts
@@ -0,0 +1,74 @@
+import { Command, type CommandResult } from "@/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
+import type { SceneTracks, VisualElement } from "@/timeline";
+
+function reorderEffectsOnElement({
+ element,
+ fromIndex,
+ toIndex,
+}: {
+ element: VisualElement;
+ fromIndex: number;
+ toIndex: number;
+}): VisualElement {
+ const effects = [...(element.effects ?? [])];
+ const [moved] = effects.splice(fromIndex, 1);
+ effects.splice(toIndex, 0, moved);
+ return { ...element, effects };
+}
+
+export class ReorderClipEffectsCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly fromIndex: number;
+ private readonly toIndex: number;
+
+ constructor({
+ trackId,
+ elementId,
+ fromIndex,
+ toIndex,
+ }: {
+ trackId: string;
+ elementId: string;
+ fromIndex: number;
+ toIndex: number;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.fromIndex = fromIndex;
+ this.toIndex = toIndex;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return reorderEffectsOnElement({
+ element: element as VisualElement,
+ fromIndex: this.fromIndex,
+ toIndex: this.toIndex,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/commands/timeline/element/effects/toggle-effect.ts
new file mode 100644
index 00000000..89149a79
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/effects/toggle-effect.ts
@@ -0,0 +1,68 @@
+import { Command, type CommandResult } from "@/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
+import type { SceneTracks, VisualElement } from "@/timeline";
+
+export function toggleEffectOnElement({
+ element,
+ effectId,
+}: {
+ element: VisualElement;
+ effectId: string;
+}): VisualElement {
+ const currentEffects = element.effects ?? [];
+ const updated = currentEffects.map((effect) =>
+ effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect,
+ );
+ return { ...element, effects: updated };
+}
+
+export class ToggleClipEffectCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return toggleEffectOnElement({
+ element: element as VisualElement,
+ effectId: this.effectId,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/commands/timeline/element/effects/update-effect-params.ts
new file mode 100644
index 00000000..62b3c937
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/effects/update-effect-params.ts
@@ -0,0 +1,87 @@
+import { Command, type CommandResult } from "@/commands/base-command";
+import { EditorCore } from "@/core";
+import { isVisualElement, updateElementInSceneTracks } from "@/timeline";
+import type { ParamValues } from "@/params";
+import type { SceneTracks, VisualElement } from "@/timeline";
+
+function updateEffectParamsOnElement({
+ element,
+ effectId,
+ params,
+}: {
+ element: VisualElement;
+ effectId: string;
+ params: Partial<ParamValues>;
+}): VisualElement {
+ const currentEffects = element.effects ?? [];
+ const updated = currentEffects.map((effect) => {
+ if (effect.id !== effectId) {
+ return effect;
+ }
+
+ const nextParams = { ...effect.params };
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined) {
+ nextParams[key] = value;
+ }
+ }
+
+ return { ...effect, params: nextParams };
+ });
+ return { ...element, effects: updated };
+}
+
+export class UpdateClipEffectParamsCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+ private readonly params: Partial<ParamValues>;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ params,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ params: Partial<ParamValues>;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ this.params = params;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ return updateEffectParamsOnElement({
+ element: element as VisualElement,
+ effectId: this.effectId,
+ params: this.params,
+ });
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/index.ts b/apps/web/src/commands/timeline/element/index.ts
new file mode 100644
index 00000000..ff365801
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/index.ts
@@ -0,0 +1,11 @@
+export { InsertElementCommand } from "./insert-element";
+export { DeleteElementsCommand } from "./delete-elements";
+export { DuplicateElementsCommand } from "./duplicate-elements";
+export { SplitElementsCommand } from "./split-elements";
+export { UpdateElementsCommand } from "./update-elements";
+export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation";
+export { MoveElementCommand } from "./move-elements";
+
+export * from "./keyframes";
+export * from "./effects";
+export * from "./masks";
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,
+ };
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/keyframes/index.ts b/apps/web/src/commands/timeline/element/keyframes/index.ts
new file mode 100644
index 00000000..319a827b
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/keyframes/index.ts
@@ -0,0 +1,6 @@
+export * from "./remove-effect-param-keyframe";
+export * from "./remove-keyframe";
+export * from "./retime-keyframe";
+export * from "./update-scalar-keyframe-curve";
+export * from "./upsert-effect-param-keyframe";
+export * from "./upsert-keyframe";
diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
new file mode 100644
index 00000000..c049d5bb
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
@@ -0,0 +1,69 @@
+import { EditorCore } from "@/core";
+import { Command, type CommandResult } from "@/commands/base-command";
+import { removeEffectParamKeyframe } from "@/animation/effect-param-channel";
+import { updateElementInSceneTracks } from "@/timeline";
+import { isVisualElement } from "@/timeline/element-utils";
+import type { SceneTracks } from "@/timeline";
+
+export class RemoveEffectParamKeyframeCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+ private readonly paramKey: string;
+ private readonly keyframeId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ paramKey,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ paramKey: string;
+ keyframeId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ this.paramKey = paramKey;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ const animations = removeEffectParamKeyframe({
+ animations: element.animations,
+ effectId: this.effectId,
+ paramKey: this.paramKey,
+ keyframeId: this.keyframeId,
+ });
+ return { ...element, animations };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts
new file mode 100644
index 00000000..ed917406
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts
@@ -0,0 +1,107 @@
+import { EditorCore } from "@/core";
+import {
+ hasKeyframesForPath,
+ removeElementKeyframe,
+ resolveAnimationTarget,
+} from "@/animation";
+import { Command, type CommandResult } from "@/commands/base-command";
+import { updateElementInSceneTracks } from "@/timeline";
+import type { AnimationPath, AnimationValue } from "@/animation/types";
+import type { SceneTracks, TimelineElement } from "@/timeline";
+
+function removeKeyframeAndPersist({
+ element,
+ propertyPath,
+ keyframeId,
+ valueAtPlayhead,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+ valueAtPlayhead: AnimationValue | null;
+}): TimelineElement {
+ const target = resolveAnimationTarget({ element, path: propertyPath });
+ if (!target) {
+ return element;
+ }
+
+ const nextAnimations = removeElementKeyframe({
+ animations: element.animations,
+ propertyPath,
+ keyframeId,
+ });
+
+ const isChannelNowEmpty = !hasKeyframesForPath({
+ animations: nextAnimations,
+ propertyPath,
+ });
+
+ // When clearing all keyframes, preserve the value at the playhead (what the user was seeing)
+ const shouldPersistToBase = isChannelNowEmpty && valueAtPlayhead !== null;
+
+ const baseElement = shouldPersistToBase
+ ? target.setBaseValue({ value: valueAtPlayhead })
+ : element;
+
+ return { ...baseElement, animations: nextAnimations };
+}
+
+export class RemoveKeyframeCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPath;
+ private readonly keyframeId: string;
+ private readonly valueAtPlayhead: AnimationValue | null;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId,
+ valueAtPlayhead,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+ valueAtPlayhead: AnimationValue | null;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.keyframeId = keyframeId;
+ this.valueAtPlayhead = valueAtPlayhead;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ update: (element) =>
+ removeKeyframeAndPersist({
+ element,
+ propertyPath: this.propertyPath,
+ keyframeId: this.keyframeId,
+ valueAtPlayhead: this.valueAtPlayhead,
+ }),
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts
new file mode 100644
index 00000000..ccbdbc5d
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts
@@ -0,0 +1,75 @@
+import { EditorCore } from "@/core";
+import { resolveAnimationTarget, retimeElementKeyframe } from "@/animation";
+import { Command, type CommandResult } from "@/commands/base-command";
+import { updateElementInSceneTracks } from "@/timeline";
+import type { AnimationPath } from "@/animation/types";
+import type { SceneTracks } from "@/timeline";
+
+export class RetimeKeyframeCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPath;
+ private readonly keyframeId: string;
+ private readonly nextTime: number;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId,
+ nextTime,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+ nextTime: number;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.keyframeId = keyframeId;
+ this.nextTime = nextTime;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ update: (element) => {
+ if (!resolveAnimationTarget({ element, path: this.propertyPath })) {
+ return element;
+ }
+
+ const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
+ return {
+ ...element,
+ animations: retimeElementKeyframe({
+ animations: element.animations,
+ propertyPath: this.propertyPath,
+ keyframeId: this.keyframeId,
+ time: boundedTime,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts
new file mode 100644
index 00000000..4a3db39d
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts
@@ -0,0 +1,85 @@
+import { EditorCore } from "@/core";
+import {
+ resolveAnimationTarget,
+ updateScalarKeyframeCurve,
+} from "@/animation";
+import { Command, type CommandResult } from "@/commands/base-command";
+import { updateElementInSceneTracks } from "@/timeline";
+import type {
+ AnimationPath,
+ ScalarCurveKeyframePatch,
+} from "@/animation/types";
+import type { SceneTracks } from "@/timeline";
+
+export class UpdateScalarKeyframeCurveCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPath;
+ private readonly componentKey: string;
+ private readonly keyframeId: string;
+ private readonly patch: ScalarCurveKeyframePatch;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ componentKey,
+ keyframeId,
+ patch,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ keyframeId: string;
+ patch: ScalarCurveKeyframePatch;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.componentKey = componentKey;
+ this.keyframeId = keyframeId;
+ this.patch = patch;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ update: (element) => {
+ if (!resolveAnimationTarget({ element, path: this.propertyPath })) {
+ return element;
+ }
+
+ return {
+ ...element,
+ animations: updateScalarKeyframeCurve({
+ animations: element.animations,
+ propertyPath: this.propertyPath,
+ componentKey: this.componentKey,
+ keyframeId: this.keyframeId,
+ patch: this.patch,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
new file mode 100644
index 00000000..eae8f039
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
@@ -0,0 +1,104 @@
+import { EditorCore } from "@/core";
+import { Command, type CommandResult } from "@/commands/base-command";
+import {
+ buildEffectParamPath,
+ resolveAnimationTarget,
+ upsertPathKeyframe,
+} from "@/animation";
+import { updateElementInSceneTracks } from "@/timeline";
+import { isVisualElement } from "@/timeline/element-utils";
+import type { AnimationInterpolation } from "@/animation/types";
+import type { SceneTracks } from "@/timeline";
+
+export class UpsertEffectParamKeyframeCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly effectId: string;
+ private readonly paramKey: string;
+ private readonly time: number;
+ private readonly value: number | string | boolean;
+ private readonly interpolation: AnimationInterpolation | undefined;
+ private readonly keyframeId: string | undefined;
+
+ constructor({
+ trackId,
+ elementId,
+ effectId,
+ paramKey,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ effectId: string;
+ paramKey: string;
+ time: number;
+ value: number | string | boolean;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.effectId = effectId;
+ this.paramKey = paramKey;
+ this.time = time;
+ this.value = value;
+ this.interpolation = interpolation;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isVisualElement,
+ update: (element) => {
+ const boundedTime = Math.max(0, Math.min(this.time, element.duration));
+ const propertyPath = buildEffectParamPath({
+ effectId: this.effectId,
+ paramKey: this.paramKey,
+ });
+ const target = resolveAnimationTarget({
+ element,
+ path: propertyPath,
+ });
+ if (!target) {
+ return element;
+ }
+
+ const animations = upsertPathKeyframe({
+ animations: element.animations,
+ propertyPath,
+ time: boundedTime,
+ value: this.value,
+ interpolation: this.interpolation,
+ keyframeId: this.keyframeId,
+ kind: target.kind,
+ defaultInterpolation: target.defaultInterpolation,
+ coerceValue: target.coerceValue,
+ });
+ return { ...element, animations };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts
new file mode 100644
index 00000000..30514146
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts
@@ -0,0 +1,96 @@
+import { EditorCore } from "@/core";
+import { Command, type CommandResult } from "@/commands/base-command";
+import { resolveAnimationTarget, upsertPathKeyframe } from "@/animation";
+import { updateElementInSceneTracks } from "@/timeline";
+import type { SceneTracks } from "@/timeline";
+import type {
+ AnimationPath,
+ AnimationInterpolation,
+ AnimationValue,
+} from "@/animation/types";
+
+export class UpsertKeyframeCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPath;
+ private readonly time: number;
+ private readonly value: AnimationValue;
+ private readonly interpolation: AnimationInterpolation | undefined;
+ private readonly keyframeId: string | undefined;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.time = time;
+ this.value = value;
+ this.interpolation = interpolation;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ 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: upsertPathKeyframe({
+ animations: element.animations,
+ propertyPath: this.propertyPath,
+ time: boundedTime,
+ value: this.value,
+ interpolation: this.interpolation,
+ keyframeId: this.keyframeId,
+ kind: target.kind,
+ defaultInterpolation: target.defaultInterpolation,
+ coerceValue: target.coerceValue,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts
new file mode 100644
index 00000000..1445b1f8
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts
@@ -0,0 +1,131 @@
+import { EditorCore } from "@/core";
+import { Command, type CommandResult } from "@/commands/base-command";
+import {
+ getCustomMaskClosedStateAfterPointRemoval,
+ removeCustomMaskPoints,
+} from "@/masks/custom-path";
+import type { CustomMask } from "@/masks/types";
+import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
+import type { MaskableElement, SceneTracks } from "@/timeline";
+
+function deletePointsFromCustomMask({
+ mask,
+ pointIds,
+}: {
+ mask: CustomMask;
+ pointIds: string[];
+}): CustomMask {
+ const points = mask.params.path;
+ const nextPoints = removeCustomMaskPoints({ points, pointIds });
+ if (nextPoints.length === points.length) {
+ return mask;
+ }
+
+ return {
+ ...mask,
+ params: {
+ ...mask.params,
+ path: nextPoints,
+ closed: getCustomMaskClosedStateAfterPointRemoval({
+ wasClosed: mask.params.closed,
+ remainingPointCount: nextPoints.length,
+ }),
+ },
+ };
+}
+
+function deletePointsFromElementMask({
+ element,
+ maskId,
+ pointIds,
+}: {
+ element: MaskableElement;
+ maskId: string;
+ pointIds: string[];
+}): { element: MaskableElement; didDeletePoints: boolean } {
+ const currentMasks = element.masks ?? [];
+ let didDeletePoints = false;
+ const nextMasks = currentMasks.map((mask) => {
+ if (mask.id !== maskId || mask.type !== "custom") {
+ return mask;
+ }
+
+ const nextMask = deletePointsFromCustomMask({
+ mask,
+ pointIds,
+ });
+ didDeletePoints ||= nextMask !== mask;
+ return nextMask;
+ });
+
+ return {
+ element: didDeletePoints ? { ...element, masks: nextMasks } : element,
+ didDeletePoints,
+ };
+}
+
+export class DeleteCustomMaskPointsCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly maskId: string;
+ private readonly pointIds: string[];
+
+ constructor({
+ trackId,
+ elementId,
+ maskId,
+ pointIds,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ pointIds: string[];
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.maskId = maskId;
+ this.pointIds = pointIds;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ let didDeletePoints = false;
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isMaskableElement,
+ update: (element) => {
+ const result = deletePointsFromElementMask({
+ element: element as MaskableElement,
+ maskId: this.maskId,
+ pointIds: this.pointIds,
+ });
+ didDeletePoints ||= result.didDeletePoints;
+ return result.element;
+ },
+ });
+
+ if (didDeletePoints) {
+ editor.timeline.updateTracks(updatedTracks);
+ return {
+ selection: {
+ selectedMaskPoints: null,
+ },
+ };
+ }
+
+ return undefined;
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/masks/index.ts b/apps/web/src/commands/timeline/element/masks/index.ts
new file mode 100644
index 00000000..dd6373b1
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/masks/index.ts
@@ -0,0 +1,4 @@
+export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points";
+export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point";
+export { RemoveMaskCommand } from "./remove-mask";
+export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
diff --git a/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts
new file mode 100644
index 00000000..58559af6
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts
@@ -0,0 +1,169 @@
+import { EditorCore } from "@/core";
+import { Command, type CommandResult } from "@/commands/base-command";
+import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom";
+import type { ElementBounds } from "@/preview/element-bounds";
+import type { CustomMask } from "@/masks/types";
+import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
+import type { MaskableElement, SceneTracks } from "@/timeline";
+
+function insertPointIntoCustomMask({
+ mask,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+}: {
+ mask: CustomMask;
+ segmentIndex: number;
+ canvasPoint: { x: number; y: number };
+ bounds: ElementBounds;
+}): { mask: CustomMask; insertedPointId: string | null } {
+ const result = insertPointOnCustomMaskSegment({
+ params: mask.params,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+ });
+ if (!result) {
+ return {
+ mask,
+ insertedPointId: null,
+ };
+ }
+
+ return {
+ mask: {
+ ...mask,
+ params: result.params,
+ },
+ insertedPointId: result.pointId,
+ };
+}
+
+function insertPointIntoElementMask({
+ element,
+ maskId,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+}: {
+ element: MaskableElement;
+ maskId: string;
+ segmentIndex: number;
+ canvasPoint: { x: number; y: number };
+ bounds: ElementBounds;
+}): {
+ element: MaskableElement;
+ didInsertPoint: boolean;
+ insertedPointId: string | null;
+} {
+ const currentMasks = element.masks ?? [];
+ let insertedPointId: string | null = null;
+ let didInsertPoint = false;
+
+ const nextMasks = currentMasks.map((mask) => {
+ if (mask.id !== maskId || mask.type !== "custom") {
+ return mask;
+ }
+
+ const result = insertPointIntoCustomMask({
+ mask,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+ });
+ if (result.insertedPointId) {
+ insertedPointId = result.insertedPointId;
+ didInsertPoint = true;
+ }
+ return result.mask;
+ });
+
+ return {
+ element: didInsertPoint ? { ...element, masks: nextMasks } : element,
+ didInsertPoint,
+ insertedPointId,
+ };
+}
+
+export class InsertCustomMaskPointCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly maskId: string;
+ private readonly segmentIndex: number;
+ private readonly canvasPoint: { x: number; y: number };
+ private readonly bounds: ElementBounds;
+
+ constructor({
+ trackId,
+ elementId,
+ maskId,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ segmentIndex: number;
+ canvasPoint: { x: number; y: number };
+ bounds: ElementBounds;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.maskId = maskId;
+ this.segmentIndex = segmentIndex;
+ this.canvasPoint = canvasPoint;
+ this.bounds = bounds;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ let didInsertPoint = false;
+ let insertedPointId: string | null = null;
+ const updatedTracks = updateElementInSceneTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isMaskableElement,
+ update: (element) => {
+ const result = insertPointIntoElementMask({
+ element: element as MaskableElement,
+ maskId: this.maskId,
+ segmentIndex: this.segmentIndex,
+ canvasPoint: this.canvasPoint,
+ bounds: this.bounds,
+ });
+ didInsertPoint ||= result.didInsertPoint;
+ insertedPointId ??= result.insertedPointId;
+ return result.element;
+ },
+ });
+
+ if (!didInsertPoint || !insertedPointId) {
+ return undefined;
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+ return {
+ selection: {
+ selectedMaskPoints: {
+ trackId: this.trackId,
+ elementId: this.elementId,
+ maskId: this.maskId,
+ pointIds: [insertedPointId],
+ },
+ },
+ };
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/commands/timeline/element/masks/remove-mask.ts
new file mode 100644
index 00000000..b543bce1
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/masks/remove-mask.ts
@@ -0,0 +1,65 @@
+import { EditorCore } from "@/core";
+import { Command, type CommandResult } from "@/commands/base-command";
+import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
+import type { SceneTracks, MaskableElement } from "@/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: SceneTracks | 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(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const updatedTracks = updateElementInSceneTracks({
+ 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);
+ return undefined;
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts
new file mode 100644
index 00000000..fec9b923
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts
@@ -0,0 +1,76 @@
+import { EditorCore } from "@/core";
+import { Command, type CommandResult } from "@/commands/base-command";
+import { isMaskableElement, updateElementInSceneTracks } from "@/timeline";
+import type { Mask } from "@/masks/types";
+import type { SceneTracks, MaskableElement } from "@/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: SceneTracks | 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(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const updatedTracks = updateElementInSceneTracks({
+ 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);
+ return undefined;
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/move-elements.ts b/apps/web/src/commands/timeline/element/move-elements.ts
new file mode 100644
index 00000000..3ed31d42
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/move-elements.ts
@@ -0,0 +1,183 @@
+import {
+ Command,
+ createElementSelectionResult,
+ type CommandResult,
+} from "@/commands/base-command";
+import { EditorCore } from "@/core";
+import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline";
+import {
+ buildEmptyTrack,
+ validateElementTrackCompatibility,
+} from "@/timeline/placement";
+import type {
+ PlannedElementMove,
+ PlannedTrackCreation,
+} from "@/timeline/group-move";
+import { findTrackInSceneTracks } from "@/timeline/track-element-update";
+
+export class MoveElementCommand extends Command {
+ private savedState: SceneTracks | null = null;
+
+ constructor({
+ moves,
+ createTracks = [],
+ }: {
+ moves: PlannedElementMove[];
+ createTracks?: PlannedTrackCreation[];
+ }) {
+ super();
+ this.moves = moves;
+ this.createTracks = createTracks;
+ }
+
+ private readonly moves: PlannedElementMove[];
+ private readonly createTracks: PlannedTrackCreation[];
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ let tracksToUpdate = this.savedState;
+ for (const createTrack of [...this.createTracks].sort(
+ (firstTrack, secondTrack) => firstTrack.index - secondTrack.index,
+ )) {
+ tracksToUpdate = insertTrackAtDisplayIndex({
+ tracks: tracksToUpdate,
+ track: buildEmptyTrack({
+ id: createTrack.id,
+ type: createTrack.type,
+ }),
+ insertIndex: createTrack.index,
+ });
+ }
+
+ const movedElementsById = new Map<string, TimelineElement>();
+ for (const move of this.moves) {
+ const sourceTrack = findTrackInSceneTracks({
+ tracks: this.savedState,
+ trackId: move.sourceTrackId,
+ });
+ const sourceElement = sourceTrack?.elements.find(
+ (trackElement) => trackElement.id === move.elementId,
+ );
+ if (!sourceTrack || !sourceElement) {
+ throw new Error("Source track or element not found");
+ }
+
+ const targetTrack = findTrackInSceneTracks({
+ tracks: tracksToUpdate,
+ trackId: move.targetTrackId,
+ });
+ if (!targetTrack) {
+ throw new Error("Target track not found");
+ }
+
+ const validation = validateElementTrackCompatibility({
+ element: sourceElement,
+ track: targetTrack,
+ });
+ if (!validation.isValid) {
+ throw new Error(validation.errorMessage);
+ }
+
+ movedElementsById.set(move.elementId, {
+ ...sourceElement,
+ startTime: move.newStartTime,
+ });
+ }
+
+ const movedElementIds = new Set(this.moves.map((move) => move.elementId));
+ const movedElementsByTargetTrackId = new Map<string, TimelineElement[]>();
+ for (const move of this.moves) {
+ const movedElement = movedElementsById.get(move.elementId);
+ if (!movedElement) {
+ continue;
+ }
+
+ const nextTargetElements =
+ movedElementsByTargetTrackId.get(move.targetTrackId) ?? [];
+ nextTargetElements.push(movedElement);
+ movedElementsByTargetTrackId.set(move.targetTrackId, nextTargetElements);
+ }
+
+ const updatedTracks = mapSceneTracks({
+ tracks: tracksToUpdate,
+ update: (track) => ({
+ ...track,
+ elements: [
+ ...track.elements.filter(
+ (element) => !movedElementIds.has(element.id),
+ ),
+ ...(movedElementsByTargetTrackId.get(track.id) ?? []),
+ ],
+ }),
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ return createElementSelectionResult(
+ this.moves.map(({ elementId, targetTrackId }) => ({
+ trackId: targetTrackId,
+ elementId,
+ })),
+ );
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
+
+function mapSceneTracks({
+ tracks,
+ update,
+}: {
+ tracks: SceneTracks;
+ update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack;
+}): SceneTracks {
+ return {
+ overlay: tracks.overlay.map((track) => update(track)),
+ main: update(tracks.main),
+ audio: tracks.audio.map((track) => update(track)),
+ };
+}
+
+function insertTrackAtDisplayIndex({
+ tracks,
+ track,
+ insertIndex,
+}: {
+ tracks: SceneTracks;
+ track: TimelineTrack;
+ insertIndex: number;
+}): SceneTracks {
+ if (track.type === "audio") {
+ const audioInsertIndex = Math.max(
+ 0,
+ Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length),
+ );
+ return {
+ ...tracks,
+ audio: [
+ ...tracks.audio.slice(0, audioInsertIndex),
+ track,
+ ...tracks.audio.slice(audioInsertIndex),
+ ],
+ };
+ }
+
+ const overlayInsertIndex = Math.max(
+ 0,
+ Math.min(insertIndex, tracks.overlay.length),
+ );
+ return {
+ ...tracks,
+ overlay: [
+ ...tracks.overlay.slice(0, overlayInsertIndex),
+ track,
+ ...tracks.overlay.slice(overlayInsertIndex),
+ ],
+ };
+}
diff --git a/apps/web/src/commands/timeline/element/split-elements.ts b/apps/web/src/commands/timeline/element/split-elements.ts
new file mode 100644
index 00000000..11c317f4
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/split-elements.ts
@@ -0,0 +1,182 @@
+import {
+ Command,
+ createElementSelectionResult,
+ type CommandResult,
+} from "@/commands/base-command";
+import type { SceneTracks, TimelineElement } from "@/timeline";
+import { generateUUID } from "@/utils/id";
+import { EditorCore } from "@/core";
+import { isRetimableElement } from "@/timeline";
+import { splitAnimationsAtTime } from "@/animation";
+import { getSourceSpanAtClipTime } from "@/retime";
+
+export class SplitElementsCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private rightSideElements: { trackId: string; elementId: string }[] = [];
+ private readonly elements: { trackId: string; elementId: string }[];
+ private readonly splitTime: number;
+ private readonly retainSide: "both" | "left" | "right";
+
+ constructor({
+ elements,
+ splitTime,
+ retainSide = "both",
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ splitTime: number;
+ retainSide?: "both" | "left" | "right";
+ }) {
+ super();
+ this.elements = elements;
+ this.splitTime = splitTime;
+ this.retainSide = retainSide;
+ }
+
+ getRightSideElements(): { trackId: string; elementId: string }[] {
+ return this.rightSideElements;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+ this.rightSideElements = [];
+
+ const splitTrack = <
+ TTrack extends { id: string; elements: TimelineElement[] },
+ >(
+ track: TTrack,
+ ): TTrack => {
+ const elementsToSplit = this.elements.filter(
+ (target) => target.trackId === track.id,
+ );
+
+ if (elementsToSplit.length === 0) {
+ return track;
+ }
+
+ const elements = track.elements.flatMap((element) => {
+ const shouldSplit = elementsToSplit.some(
+ (target) => target.elementId === element.id,
+ );
+
+ if (!shouldSplit) {
+ return [element];
+ }
+
+ const effectiveStart = element.startTime;
+ const effectiveEnd = element.startTime + element.duration;
+
+ if (
+ this.splitTime <= effectiveStart ||
+ this.splitTime >= effectiveEnd
+ ) {
+ return [element];
+ }
+
+ 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,
+ shouldIncludeSplitBoundary: true,
+ });
+ let splitResult: TimelineElement[];
+
+ if (this.retainSide === "left") {
+ splitResult = [
+ {
+ ...element,
+ duration: leftVisibleDuration,
+ trimEnd: element.trimEnd + rightSourceSpan,
+ name: `${element.name} (left)`,
+ animations: leftAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
+ },
+ ];
+ } else if (this.retainSide === "right") {
+ const newId = generateUUID();
+ this.rightSideElements.push({
+ trackId: track.id,
+ elementId: newId,
+ });
+ splitResult = [
+ {
+ ...element,
+ id: newId,
+ startTime: this.splitTime,
+ duration: rightVisibleDuration,
+ trimStart: element.trimStart + leftSourceSpan,
+ name: `${element.name} (right)`,
+ animations: rightAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
+ },
+ ];
+ } else {
+ // "both" - split into two pieces
+ const secondElementId = generateUUID();
+ this.rightSideElements.push({
+ trackId: track.id,
+ elementId: secondElementId,
+ });
+ splitResult = [
+ {
+ ...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 } : {}),
+ },
+ ];
+ }
+
+ return splitResult;
+ });
+
+ return { ...track, elements } as TTrack;
+ };
+
+ const updatedTracks: SceneTracks = {
+ overlay: this.savedState.overlay.map((track) => splitTrack(track)),
+ main: splitTrack(this.savedState.main),
+ audio: this.savedState.audio.map((track) => splitTrack(track)),
+ };
+
+ editor.timeline.updateTracks(updatedTracks);
+
+ if (this.rightSideElements.length > 0) {
+ return createElementSelectionResult(this.rightSideElements);
+ }
+ return undefined;
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/commands/timeline/element/toggle-source-audio-separation.ts b/apps/web/src/commands/timeline/element/toggle-source-audio-separation.ts
new file mode 100644
index 00000000..36d4cd87
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/toggle-source-audio-separation.ts
@@ -0,0 +1,145 @@
+import { EditorCore } from "@/core";
+import { Command, type CommandResult } from "@/commands/base-command";
+import {
+ buildSeparatedAudioElement,
+ canExtractSourceAudio,
+ isSourceAudioSeparated,
+} from "@/timeline/audio-separation";
+import {
+ applyPlacement,
+ resolveTrackPlacement,
+} from "@/timeline/placement";
+import { updateElementInSceneTracks } from "@/timeline/track-element-update";
+import type {
+ SceneTracks,
+ TimelineElement,
+ VideoElement,
+} from "@/timeline/types";
+import { generateUUID } from "@/utils/id";
+
+export class ToggleSourceAudioSeparationCommand extends Command {
+ private savedState: SceneTracks | null = null;
+
+ constructor(
+ private readonly params: {
+ trackId: string;
+ elementId: string;
+ },
+ ) {
+ super();
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+
+ const sourceTrack = [
+ ...this.savedState.overlay,
+ this.savedState.main,
+ ...this.savedState.audio,
+ ].find((track) => track.id === this.params.trackId);
+ if (!sourceTrack) {
+ return;
+ }
+ const sourceElement = sourceTrack.elements.find(
+ (element) => element.id === this.params.elementId,
+ ) as TimelineElement | undefined;
+ if (!sourceElement || sourceElement.type !== "video") {
+ return;
+ }
+ const videoElement: VideoElement = sourceElement;
+
+ if (isSourceAudioSeparated({ element: videoElement })) {
+ editor.timeline.updateTracks(
+ updateSourceAudioEnabled({
+ tracks: this.savedState,
+ trackId: this.params.trackId,
+ elementId: this.params.elementId,
+ isSourceAudioEnabled: true,
+ }),
+ );
+ return;
+ }
+
+ const mediaAsset = editor.media
+ .getAssets()
+ .find((asset) => asset.id === videoElement.mediaId);
+ if (!canExtractSourceAudio(videoElement, mediaAsset)) {
+ return;
+ }
+ if (videoElement.duration <= 0) {
+ return;
+ }
+
+ const separatedAudioElement = {
+ ...buildSeparatedAudioElement({
+ sourceElement: videoElement,
+ }),
+ id: generateUUID(),
+ };
+ const placementResult = resolveTrackPlacement({
+ tracks: this.savedState,
+ trackType: "audio",
+ timeSpans: [
+ {
+ startTime: separatedAudioElement.startTime,
+ duration: separatedAudioElement.duration,
+ },
+ ],
+ strategy: { type: "firstAvailable" },
+ });
+ if (!placementResult) {
+ return;
+ }
+ const appliedPlacement = applyPlacement({
+ tracks: this.savedState,
+ placementResult,
+ elements: [separatedAudioElement],
+ });
+ if (!appliedPlacement) {
+ return;
+ }
+
+ editor.timeline.updateTracks(
+ updateSourceAudioEnabled({
+ tracks: appliedPlacement.updatedTracks,
+ trackId: this.params.trackId,
+ elementId: this.params.elementId,
+ isSourceAudioEnabled: false,
+ }),
+ );
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
+
+function updateSourceAudioEnabled({
+ tracks,
+ trackId,
+ elementId,
+ isSourceAudioEnabled,
+}: {
+ tracks: SceneTracks;
+ trackId: string;
+ elementId: string;
+ isSourceAudioEnabled: boolean;
+}): SceneTracks {
+ return updateElementInSceneTracks({
+ tracks,
+ trackId,
+ elementId,
+ elementPredicate: (element): element is VideoElement =>
+ element.type === "video",
+ update: (element) => ({
+ ...element,
+ isSourceAudioEnabled,
+ }),
+ });
+}
diff --git a/apps/web/src/commands/timeline/element/update-elements.ts b/apps/web/src/commands/timeline/element/update-elements.ts
new file mode 100644
index 00000000..f03f2498
--- /dev/null
+++ b/apps/web/src/commands/timeline/element/update-elements.ts
@@ -0,0 +1,75 @@
+import { EditorCore } from "@/core";
+import { Command, type CommandResult } from "@/commands/base-command";
+import type { SceneTracks, TimelineElement } from "@/timeline";
+import {
+ findTrackInSceneTracks,
+ updateElementInSceneTracks,
+} from "@/timeline";
+import { applyElementUpdate } from "@/timeline/update-pipeline";
+
+export class UpdateElementsCommand extends Command {
+ private savedState: SceneTracks | null = null;
+ private readonly updates: Array<{
+ trackId: string;
+ elementId: string;
+ patch: Partial<TimelineElement>;
+ }>;
+
+ constructor({
+ updates,
+ }: {
+ updates: Array<{
+ trackId: string;
+ elementId: string;
+ patch: Partial<TimelineElement>;
+ }>;
+ }) {
+ super();
+ this.updates = updates;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.scenes.getActiveScene().tracks;
+ let updatedTracks = this.savedState;
+
+ for (const updateEntry of this.updates) {
+ const currentTrack = findTrackInSceneTracks({
+ tracks: updatedTracks,
+ trackId: updateEntry.trackId,
+ });
+ const currentElement = currentTrack?.elements.find(
+ (element) => element.id === updateEntry.elementId,
+ );
+ if (!currentTrack || !currentElement) {
+ continue;
+ }
+
+ const nextElement = applyElementUpdate({
+ element: currentElement,
+ patch: updateEntry.patch,
+ context: {
+ tracks: updatedTracks,
+ trackId: updateEntry.trackId,
+ },
+ });
+
+ updatedTracks = updateElementInSceneTracks({
+ tracks: updatedTracks,
+ trackId: updateEntry.trackId,
+ elementId: updateEntry.elementId,
+ update: () => nextElement,
+ });
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}