diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-26 22:13:01 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-26 22:13:01 +0200 |
| commit | 12539fedf3020468fded3945d5dfc4c8396e7750 (patch) | |
| tree | a2c4122bed6a8aa31e76d0598287afca45104541 | |
| parent | 3fcf892dfa19e0fb2f1487b91971f48dcc26fc50 (diff) | |
| download | opencut-12539fedf3020468fded3945d5dfc4c8396e7750.tar.gz | |
feat: single undo step for freeze frame
The handler fired four separate commands (asset add, split, move, insert),
producing four undo steps. It now builds one BatchCommand and executes it
once. The new ShiftSplitRemainderCommand resolves the split's right-side
elements lazily at execute() so it can be composed after the split in the
batch, and rebuilds the inner move on redo since a re-executed split
generates fresh element ids.
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(); + } +} |
