summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/controllers
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-08-16 06:16:56 +0200
committercashmere <cashmere@autistici.org>2026-08-16 06:16:56 +0200
commitf1043734438c8518a302f698d9ae6583a75f0c14 (patch)
treed3e636a96e6349c1ad17afa7b28c9ffe5c882e37 /apps/web/src/timeline/controllers
parent37d22b1e390bd6cea04bf8e15069281ee125cf10 (diff)
downloadopencut-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.ts190
-rw-r--r--apps/web/src/timeline/controllers/drag-drop-controller.ts71
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,