diff options
| author | cashmere <cashmere@autistici.org> | 2026-08-16 06:16:56 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-08-16 06:16:56 +0200 |
| commit | f1043734438c8518a302f698d9ae6583a75f0c14 (patch) | |
| tree | d3e636a96e6349c1ad17afa7b28c9ffe5c882e37 /apps/web/src/timeline/controllers | |
| parent | 37d22b1e390bd6cea04bf8e15069281ee125cf10 (diff) | |
| download | opencut-f1043734438c8518a302f698d9ae6583a75f0c14.tar.gz | |
feat: add transitions, track mixing, and voiceover controls
Diffstat (limited to 'apps/web/src/timeline/controllers')
| -rw-r--r-- | apps/web/src/timeline/controllers/__tests__/drag-drop-controller-transition.test.ts | 190 | ||||
| -rw-r--r-- | apps/web/src/timeline/controllers/drag-drop-controller.ts | 71 |
2 files changed, 257 insertions, 4 deletions
diff --git a/apps/web/src/timeline/controllers/__tests__/drag-drop-controller-transition.test.ts b/apps/web/src/timeline/controllers/__tests__/drag-drop-controller-transition.test.ts new file mode 100644 index 00000000..b24f9ad9 --- /dev/null +++ b/apps/web/src/timeline/controllers/__tests__/drag-drop-controller-transition.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { DragEvent } from "react"; +import type { SceneTracks, VideoElement, VideoTrack } from "@/timeline"; +import { + DragDropController, + type DragDropConfig, + type DragDropConfigRef, +} from "@/timeline/controllers/drag-drop-controller"; +import { TimelineDragSource } from "@/timeline/drag-source"; +import { mediaTime, TICKS_PER_SECOND, ZERO_MEDIA_TIME } from "@/wasm"; + +function buildVideo({ + id, + start, +}: { + id: string; + start: number; +}): VideoElement { + return { + id, + type: "video", + name: id, + mediaId: `media-${id}`, + startTime: mediaTime({ ticks: start }), + duration: mediaTime({ ticks: TICKS_PER_SECOND }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, + params: { opacity: id === "incoming" ? 0.8 : 1 }, + }; +} + +function buildTracks(): SceneTracks { + const main: VideoTrack = { + id: "visual", + type: "video", + name: "Visual", + elements: [ + buildVideo({ id: "outgoing", start: 0 }), + buildVideo({ id: "incoming", start: TICKS_PER_SECOND }), + ], + muted: false, + hidden: false, + }; + return { overlay: [], main, audio: [] }; +} + +function buildDataTransfer(): DataTransfer { + return { + types: [], + files: [], + dropEffect: "none", + effectAllowed: "none", + setData: mock(() => {}), + } as unknown as DataTransfer; +} + +function buildDragEvent({ + clientX, + dataTransfer, +}: { + clientX: number; + dataTransfer: DataTransfer; +}): DragEvent { + return { + clientX, + clientY: 10, + dataTransfer, + preventDefault: mock(() => {}), + } as unknown as DragEvent; +} + +function setup() { + const dragSource = new TimelineDragSource(); + const updateElements = mock<DragDropConfig["updateElements"]>(() => {}); + const insertElement = mock<DragDropConfig["insertElement"]>(() => {}); + const executeCommand = mock<DragDropConfig["executeCommand"]>(() => {}); + const tracksElement = { + scrollLeft: 0, + scrollTop: 0, + getBoundingClientRect: () => ({ left: 0, top: 0 }), + } as unknown as HTMLDivElement; + const config: DragDropConfig = { + zoomLevel: 1, + getContainerEl: () => tracksElement, + getHeaderEl: () => null, + getTracksScrollEl: () => tracksElement, + getActiveProjectFps: () => null, + getActiveProjectId: () => null, + getSceneTracks: buildTracks, + getCurrentPlayheadTime: () => ZERO_MEDIA_TIME, + getMediaAssets: () => [], + dragSource, + addMediaAsset: () => Promise.resolve(null), + executeCommand, + insertElement, + updateElements, + addClipEffect: mock(() => {}), + }; + const configRef: DragDropConfigRef = { current: config }; + return { + controller: new DragDropController({ configRef }), + dragSource, + updateElements, + insertElement, + executeCommand, + }; +} + +function beginTransitionDrag( + dragSource: TimelineDragSource, + dataTransfer: DataTransfer, +) { + dragSource.begin({ + dataTransfer, + dragData: { + type: "transition", + id: "push-left", + name: "Slide left", + transitionKind: "push-left", + inDuration: 0.45, + outDuration: 0, + }, + }); +} + +describe("DragDropController transition drops", () => { + test("hovers and applies a transition only to the incoming clip", () => { + const { + controller, + dragSource, + updateElements, + insertElement, + executeCommand, + } = setup(); + const dataTransfer = buildDataTransfer(); + beginTransitionDrag(dragSource, dataTransfer); + + controller.onDragOver(buildDragEvent({ clientX: 50, dataTransfer })); + + expect(dataTransfer.dropEffect).toBe("copy"); + expect(controller.dropTarget?.targetCut).toEqual({ + trackId: "visual", + outgoingElementId: "outgoing", + incomingElementId: "incoming", + time: mediaTime({ ticks: TICKS_PER_SECOND }), + }); + + controller.onDrop(buildDragEvent({ clientX: 50, dataTransfer })); + + expect(updateElements).toHaveBeenCalledTimes(1); + expect(updateElements).toHaveBeenCalledWith({ + updates: [ + { + trackId: "visual", + elementId: "incoming", + patch: { + params: { + opacity: 0.8, + "transition.kind": "push-left", + "transition.inDuration": 0.45, + "transition.outDuration": 0, + }, + }, + }, + ], + }); + expect(insertElement).not.toHaveBeenCalled(); + expect(executeCommand).not.toHaveBeenCalled(); + }); + + test("does nothing when the transition has no cut target", () => { + const { + controller, + dragSource, + updateElements, + insertElement, + executeCommand, + } = setup(); + const dataTransfer = buildDataTransfer(); + beginTransitionDrag(dragSource, dataTransfer); + + controller.onDragOver(buildDragEvent({ clientX: 80, dataTransfer })); + expect(controller.dropTarget).toBeNull(); + controller.onDrop(buildDragEvent({ clientX: 80, dataTransfer })); + + expect(updateElements).not.toHaveBeenCalled(); + expect(insertElement).not.toHaveBeenCalled(); + expect(executeCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts index a419101f..6b4c8bbf 100644 --- a/apps/web/src/timeline/controllers/drag-drop-controller.ts +++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts @@ -17,7 +17,10 @@ import { import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline"; import { BatchCommand } from "@/commands"; import type { Command } from "@/commands/base-command"; -import { computeDropTarget } from "@/timeline/components/drop-target"; +import { + computeDropTarget, + computeTransitionCutDropTarget, +} from "@/timeline/components/drop-target"; import type { TimelineDragSource } from "@/timeline/drag-source"; import type { TrackType, @@ -26,11 +29,13 @@ import type { SceneTracks, TimelineTrack, CreateTimelineElement, + TimelineElement, } from "@/timeline"; import type { TimelineDragData } from "@/timeline/drag"; import type { MediaAsset } from "@/media/types"; import type { ProcessedMediaAsset } from "@/media/processing"; import { roundFrameTime, type MediaTime } from "@/wasm"; +import { withVisualTransitionParams } from "@/rendering/transition"; // --- Config --- @@ -54,6 +59,14 @@ export interface DragDropConfig { placement: { mode: "explicit"; trackId: string }; element: CreateTimelineElement; }) => void; + updateElements: (args: { + updates: Array<{ + trackId: string; + elementId: string; + patch: Partial<TimelineElement>; + }>; + pushHistory?: boolean; + }) => void; addClipEffect: (args: { trackId: string; elementId: string; @@ -85,7 +98,7 @@ interface TimelineCoords { function elementTypeFromDrag({ dragData, }: { - dragData: TimelineDragData; + dragData: Exclude<TimelineDragData, { type: "transition" }>; }): ElementType { switch (dragData.type) { case "text": @@ -104,7 +117,7 @@ function elementTypeFromDrag({ function getTargetElementTypesForDrag({ dragData, }: { - dragData: TimelineDragData; + dragData: Exclude<TimelineDragData, { type: "transition" }>; }): string[] | undefined { if (dragData.type === "effect") return dragData.targetElementTypes; if (dragData.type === "media") return dragData.targetElementTypes; @@ -115,7 +128,7 @@ function getDurationForDrag({ dragData, mediaAssets, }: { - dragData: TimelineDragData; + dragData: Exclude<TimelineDragData, { type: "transition" }>; mediaAssets: MediaAsset[]; }): MediaTime { if (dragData.type !== "media") return DEFAULT_NEW_ELEMENT_DURATION; @@ -202,6 +215,18 @@ export class DragDropController { } return; } + if (dragData.type === "transition") { + const target = computeTransitionCutDropTarget({ + mouseX: coords.mouseX, + mouseY: coords.mouseY, + tracks: this.config.getSceneTracks(), + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel: this.config.zoomLevel, + }); + this.setOver({ dropTarget: target, elementType: null }); + if (target) event.dataTransfer.dropEffect = "copy"; + return; + } const elementType = elementTypeFromDrag({ dragData }); const duration = getDurationForDrag({ @@ -374,9 +399,47 @@ export class DragDropController { case "media": this.executeMediaDrop({ target, dragData }); return; + case "transition": + this.executeTransitionDrop({ target, dragData }); + return; } } + private executeTransitionDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract<TimelineDragData, { type: "transition" }>; + }): void { + const cut = target.targetCut; + if (!cut) return; + + const tracks = orderedTracks({ sceneTracks: this.config.getSceneTracks() }); + const track = tracks.find(({ id }) => id === cut.trackId); + const incoming = track?.elements.find( + ({ id }) => id === cut.incomingElementId, + ); + if (!incoming) return; + + this.config.updateElements({ + updates: [ + { + trackId: cut.trackId, + elementId: cut.incomingElementId, + patch: { + params: withVisualTransitionParams({ + params: incoming.params, + transitionKind: dragData.transitionKind, + inDuration: dragData.inDuration, + outDuration: dragData.outDuration, + }), + }, + }, + ], + }); + } + private executeTextDrop({ target, dragData, |
