summaryrefslogtreecommitdiff
path: root/apps/web/src/commands/timeline/element/__tests__
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-26 22:13:01 +0200
committercashmere <cashmere@autistici.org>2026-07-26 22:13:01 +0200
commit12539fedf3020468fded3945d5dfc4c8396e7750 (patch)
treea2c4122bed6a8aa31e76d0598287afca45104541 /apps/web/src/commands/timeline/element/__tests__
parent3fcf892dfa19e0fb2f1487b91971f48dcc26fc50 (diff)
downloadopencut-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.
Diffstat (limited to 'apps/web/src/commands/timeline/element/__tests__')
-rw-r--r--apps/web/src/commands/timeline/element/__tests__/shift-split-remainder.test.ts185
1 files changed, 185 insertions, 0 deletions
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);
+ });
+});