diff options
4 files changed, 261 insertions, 21 deletions
diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts index 41408635..598e73b0 100644 --- a/apps/web/src/actions/use-editor-actions.ts +++ b/apps/web/src/actions/use-editor-actions.ts @@ -28,6 +28,13 @@ import { import { useCommittedRef } from "@/hooks/use-committed-ref"; import { toast } from "sonner"; import { generateImageThumbnail } from "@/media/processing"; +import { + AddMediaAssetCommand, + BatchCommand, + InsertElementCommand, + ShiftSplitRemainderCommand, + SplitElementsCommand, +} from "@/commands"; export function useEditorActions() { const editor = useEditor(); @@ -413,7 +420,9 @@ export function useEditorActions() { const { thumbnailUrl } = await generateImageThumbnail({ imageFile: file, }); - const asset = await editor.media.addMediaAsset({ + + const freezeDuration = mediaTimeFromSeconds({ seconds: 3 }); + const addMediaCommand = new AddMediaAssetCommand({ projectId: activeProject.metadata.id, asset: { file, @@ -425,32 +434,19 @@ export function useEditorActions() { height: activeProject.settings.canvasSize.height, }, }); - if (!asset) { - return; - } - - const freezeDuration = mediaTimeFromSeconds({ seconds: 3 }); - const rightSideElements = editor.timeline.splitElements({ + const splitCommand = new SplitElementsCommand({ elements: [ { trackId: target.track.id, elementId: target.element.id }, ], splitTime: currentTime, }); - - if (rightSideElements.length > 0) { - editor.timeline.moveElements({ - moves: rightSideElements.map((element) => ({ - sourceTrackId: element.trackId, - targetTrackId: element.trackId, - elementId: element.elementId, - newStartTime: addMediaTime({ a: currentTime, b: freezeDuration }), - })), - }); - } - - editor.timeline.insertElement({ + const shiftCommand = new ShiftSplitRemainderCommand({ + split: splitCommand, + newStartTime: addMediaTime({ a: currentTime, b: freezeDuration }), + }); + const insertCommand = new InsertElementCommand({ element: buildElementFromMedia({ - mediaId: asset.id, + mediaId: addMediaCommand.getAssetId(), mediaType: "image", name: "Freeze frame", duration: freezeDuration, @@ -458,6 +454,14 @@ export function useEditorActions() { }), placement: { mode: "explicit", trackId: target.track.id }, }); + editor.command.execute({ + command: new BatchCommand([ + addMediaCommand, + splitCommand, + shiftCommand, + insertCommand, + ]), + }); }, undefined, ); diff --git a/apps/web/src/commands/timeline/element/__tests__/shift-split-remainder.test.ts b/apps/web/src/commands/timeline/element/__tests__/shift-split-remainder.test.ts new file mode 100644 index 00000000..36d742fe --- /dev/null +++ b/apps/web/src/commands/timeline/element/__tests__/shift-split-remainder.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { EditorCore } from "@/core"; +import { + BatchCommand, + InsertElementCommand, + ShiftSplitRemainderCommand, + SplitElementsCommand, +} from "@/commands"; +import { buildDefaultScene } from "@/timeline/scenes"; +import { buildElementFromMedia, type VideoElement } from "@/timeline"; +import { + addMediaTime, + mediaTimeFromSeconds, + ZERO_MEDIA_TIME, +} from "@/wasm"; + +const SPLIT_TIME = mediaTimeFromSeconds({ seconds: 4 }); +const FREEZE_DURATION = mediaTimeFromSeconds({ seconds: 3 }); +const VIDEO_DURATION = mediaTimeFromSeconds({ seconds: 10 }); + +function buildVideoElement(): VideoElement { + return { + id: "video-1", + type: "video", + name: "video-1", + startTime: ZERO_MEDIA_TIME, + duration: VIDEO_DURATION, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + mediaId: "media-video-1", + params: { + "transform.positionX": 0, + "transform.positionY": 0, + "transform.scaleX": 1, + "transform.scaleY": 1, + "transform.rotate": 0, + opacity: 1, + }, + }; +} + +function seedSceneWithVideo(): { editor: EditorCore; trackId: string } { + const editor = EditorCore.getInstance(); + const scene = buildDefaultScene({ name: "Main scene", isMain: true }); + scene.tracks.main.elements = [buildVideoElement()]; + editor.project.setActiveProject({ + project: { + metadata: { + id: "project-1", + name: "Test project", + duration: VIDEO_DURATION, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + }, + scenes: [scene], + currentSceneId: scene.id, + settings: { + fps: { numerator: 30, denominator: 1 }, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + version: 1, + }, + }); + editor.scenes.setScenes({ scenes: [scene], activeSceneId: scene.id }); + return { editor, trackId: scene.tracks.main.id }; +} + +function buildFreezeBatch({ trackId }: { trackId: string }): BatchCommand { + const splitCommand = new SplitElementsCommand({ + elements: [{ trackId, elementId: "video-1" }], + splitTime: SPLIT_TIME, + }); + const shiftCommand = new ShiftSplitRemainderCommand({ + split: splitCommand, + newStartTime: addMediaTime({ a: SPLIT_TIME, b: FREEZE_DURATION }), + }); + const insertCommand = new InsertElementCommand({ + element: buildElementFromMedia({ + mediaId: "media-freeze-1", + mediaType: "image", + name: "Freeze frame", + duration: FREEZE_DURATION, + startTime: SPLIT_TIME, + }), + placement: { mode: "explicit", trackId }, + }); + return new BatchCommand([splitCommand, shiftCommand, insertCommand]); +} + +describe("ShiftSplitRemainderCommand", () => { + beforeEach(() => { + EditorCore.reset(); + }); + + test("splits, shifts the right side, and inserts into the gap", () => { + const { editor, trackId } = seedSceneWithVideo(); + + buildFreezeBatch({ trackId }).execute(); + + const elements = editor.scenes + .getActiveScene() + .tracks.main.elements.slice() + .sort((a, b) => (a.startTime > b.startTime ? 1 : -1)); + expect(elements.map((element) => element.type)).toEqual([ + "video", + "image", + "video", + ]); + + const [left, image, right] = elements; + expect(left.startTime).toBe(ZERO_MEDIA_TIME); + expect(left.duration).toBe(SPLIT_TIME); + expect(image.startTime).toBe(SPLIT_TIME); + expect(image.duration).toBe(FREEZE_DURATION); + expect(right.startTime).toBe( + addMediaTime({ a: SPLIT_TIME, b: FREEZE_DURATION }), + ); + expect(right.duration).toBe( + addMediaTime({ a: VIDEO_DURATION, b: mediaTimeFromSeconds({ seconds: -4 }) }), + ); + }); + + test("undo restores the original element as a single step", () => { + const { editor, trackId } = seedSceneWithVideo(); + const batch = buildFreezeBatch({ trackId }); + + batch.execute(); + batch.undo(); + + const elements = editor.scenes.getActiveScene().tracks.main.elements; + expect(elements).toHaveLength(1); + expect(elements[0].id).toBe("video-1"); + expect(elements[0].startTime).toBe(ZERO_MEDIA_TIME); + expect(elements[0].duration).toBe(VIDEO_DURATION); + }); + + test("redo after undo re-applies the shift to the re-split elements", () => { + const { editor, trackId } = seedSceneWithVideo(); + const batch = buildFreezeBatch({ trackId }); + + batch.execute(); + batch.undo(); + batch.redo(); + + const elements = editor.scenes + .getActiveScene() + .tracks.main.elements.slice() + .sort((a, b) => (a.startTime > b.startTime ? 1 : -1)); + expect(elements.map((element) => element.type)).toEqual([ + "video", + "image", + "video", + ]); + // The re-executed split generates fresh right-side ids; the shift must + // still land on them. + const right = elements.at(-1); + expect(right?.type).toBe("video"); + expect(right?.startTime).toBe( + addMediaTime({ a: SPLIT_TIME, b: FREEZE_DURATION }), + ); + }); + + test("is a no-op when the split produces no right side", () => { + const { editor, trackId } = seedSceneWithVideo(); + + const splitCommand = new SplitElementsCommand({ + elements: [{ trackId, elementId: "video-1" }], + splitTime: ZERO_MEDIA_TIME, + }); + const shiftCommand = new ShiftSplitRemainderCommand({ + split: splitCommand, + newStartTime: FREEZE_DURATION, + }); + const batch = new BatchCommand([splitCommand, shiftCommand]); + + expect(() => { + batch.execute(); + batch.undo(); + }).not.toThrow(); + expect( + editor.scenes.getActiveScene().tracks.main.elements, + ).toHaveLength(1); + }); +}); diff --git a/apps/web/src/commands/timeline/element/index.ts b/apps/web/src/commands/timeline/element/index.ts index ff365801..cd6dc8f9 100644 --- a/apps/web/src/commands/timeline/element/index.ts +++ b/apps/web/src/commands/timeline/element/index.ts @@ -2,6 +2,7 @@ export { InsertElementCommand } from "./insert-element"; export { DeleteElementsCommand } from "./delete-elements"; export { DuplicateElementsCommand } from "./duplicate-elements"; export { SplitElementsCommand } from "./split-elements"; +export { ShiftSplitRemainderCommand } from "./shift-split-remainder"; export { UpdateElementsCommand } from "./update-elements"; export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation"; export { MoveElementCommand } from "./move-elements"; diff --git a/apps/web/src/commands/timeline/element/shift-split-remainder.ts b/apps/web/src/commands/timeline/element/shift-split-remainder.ts new file mode 100644 index 00000000..3799c8f9 --- /dev/null +++ b/apps/web/src/commands/timeline/element/shift-split-remainder.ts @@ -0,0 +1,50 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import type { MediaTime } from "@/wasm"; +import { MoveElementCommand } from "./move-elements"; +import type { SplitElementsCommand } from "./split-elements"; + +/** + * Moves the right-side elements produced by a SplitElementsCommand to a new + * start time. Must run after the split inside a BatchCommand: the right-side + * element ids only exist once the split has executed. Rebuilds the inner move + * on every execute() so redo picks up the fresh ids a re-executed split + * generates. + */ +export class ShiftSplitRemainderCommand extends Command { + private inner: MoveElementCommand | null = null; + + constructor({ + split, + newStartTime, + }: { + split: SplitElementsCommand; + newStartTime: MediaTime; + }) { + super(); + this.split = split; + this.newStartTime = newStartTime; + } + + private readonly split: SplitElementsCommand; + private readonly newStartTime: MediaTime; + + execute(): CommandResult | undefined { + const rightSideElements = this.split.getRightSideElements(); + if (rightSideElements.length === 0) { + return undefined; + } + this.inner = new MoveElementCommand({ + moves: rightSideElements.map((element) => ({ + sourceTrackId: element.trackId, + targetTrackId: element.trackId, + elementId: element.elementId, + newStartTime: this.newStartTime, + })), + }); + return this.inner.execute(); + } + + undo(): void { + this.inner?.undo(); + } +} |
