summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/src/actions/use-editor-actions.ts46
-rw-r--r--apps/web/src/commands/timeline/element/__tests__/shift-split-remainder.test.ts185
-rw-r--r--apps/web/src/commands/timeline/element/index.ts1
-rw-r--r--apps/web/src/commands/timeline/element/shift-split-remainder.ts50
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();
+ }
+}