summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts
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/components/__tests__/drop-target-transition.test.ts
parent37d22b1e390bd6cea04bf8e15069281ee125cf10 (diff)
downloadopencut-f1043734438c8518a302f698d9ae6583a75f0c14.tar.gz
feat: add transitions, track mixing, and voiceover controls
Diffstat (limited to 'apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts')
-rw-r--r--apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts221
1 files changed, 221 insertions, 0 deletions
diff --git a/apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts b/apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts
new file mode 100644
index 00000000..d3246a19
--- /dev/null
+++ b/apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts
@@ -0,0 +1,221 @@
+import { describe, expect, test } from "bun:test";
+import type {
+ AudioTrack,
+ EffectTrack,
+ ImageElement,
+ SceneTracks,
+ TextTrack,
+ VideoElement,
+ VideoTrack,
+} from "@/timeline";
+import { computeTransitionCutDropTarget } from "@/timeline/components/drop-target";
+import { mediaTime, TICKS_PER_SECOND, ZERO_MEDIA_TIME } from "@/wasm";
+
+const PIXELS_PER_SECOND = 50;
+
+function buildImage({
+ id,
+ start,
+ duration,
+}: {
+ id: string;
+ start: number;
+ duration: number;
+}): ImageElement {
+ return {
+ id,
+ type: "image",
+ name: id,
+ mediaId: `media-${id}`,
+ startTime: mediaTime({ ticks: start }),
+ duration: mediaTime({ ticks: duration }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ params: { opacity: 0.75 },
+ };
+}
+
+function buildVideo({
+ id,
+ start,
+ duration,
+}: {
+ id: string;
+ start: number;
+ duration: number;
+}): VideoElement {
+ return {
+ id,
+ type: "video",
+ name: id,
+ mediaId: `media-${id}`,
+ startTime: mediaTime({ ticks: start }),
+ duration: mediaTime({ ticks: duration }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ params: { opacity: 1 },
+ };
+}
+
+function buildVideoTrack({
+ elements,
+}: {
+ elements: VideoTrack["elements"];
+}): VideoTrack {
+ return {
+ id: "visual",
+ type: "video",
+ name: "Visual",
+ elements,
+ muted: false,
+ hidden: false,
+ };
+}
+
+function buildTracks({
+ main,
+ overlay = [],
+ audio = [],
+}: {
+ main: VideoTrack;
+ overlay?: SceneTracks["overlay"];
+ audio?: AudioTrack[];
+}): SceneTracks {
+ return { main, overlay, audio };
+}
+
+function resolve({
+ tracks,
+ mouseX,
+ mouseY = 10,
+}: {
+ tracks: SceneTracks;
+ mouseX: number;
+ mouseY?: number;
+}) {
+ return computeTransitionCutDropTarget({
+ mouseX,
+ mouseY,
+ tracks,
+ pixelsPerSecond: PIXELS_PER_SECOND,
+ zoomLevel: 1,
+ });
+}
+
+const oneSecond = TICKS_PER_SECOND;
+
+function adjacentTrack(): VideoTrack {
+ return buildVideoTrack({
+ elements: [
+ buildImage({ id: "outgoing", start: 0, duration: oneSecond }),
+ buildVideo({ id: "incoming", start: oneSecond, duration: oneSecond }),
+ ],
+ });
+}
+
+describe("computeTransitionCutDropTarget", () => {
+ test("resolves an adjacent image and video cut within 12 screen pixels", () => {
+ const target = resolve({
+ tracks: buildTracks({ main: adjacentTrack() }),
+ mouseX: 55,
+ });
+
+ expect(target).toEqual({
+ trackIndex: 0,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition: mediaTime({ ticks: oneSecond }),
+ targetElement: null,
+ targetCut: {
+ trackId: "visual",
+ outgoingElementId: "outgoing",
+ incomingElementId: "incoming",
+ time: mediaTime({ ticks: oneSecond }),
+ },
+ });
+ });
+
+ test("rejects a pointer beyond 12 screen pixels", () => {
+ expect(
+ resolve({ tracks: buildTracks({ main: adjacentTrack() }), mouseX: 63 }),
+ ).toBeNull();
+ });
+
+ test("rejects gaps and overlaps", () => {
+ const incoming = buildVideo({
+ id: "incoming",
+ start: oneSecond,
+ duration: oneSecond,
+ });
+ const gap = buildVideoTrack({
+ elements: [
+ buildImage({ id: "outgoing", start: 0, duration: oneSecond - 1 }),
+ incoming,
+ ],
+ });
+ const overlap = buildVideoTrack({
+ elements: [
+ buildImage({ id: "outgoing", start: 0, duration: oneSecond + 1 }),
+ incoming,
+ ],
+ });
+
+ expect(
+ resolve({ tracks: buildTracks({ main: gap }), mouseX: 50 }),
+ ).toBeNull();
+ expect(
+ resolve({ tracks: buildTracks({ main: overlap }), mouseX: 50 }),
+ ).toBeNull();
+ });
+
+ test("rejects the first visual clip boundary", () => {
+ expect(
+ resolve({ tracks: buildTracks({ main: adjacentTrack() }), mouseX: 0 }),
+ ).toBeNull();
+ });
+
+ test("rejects audio, text, and effect tracks", () => {
+ const textTrack: TextTrack = {
+ id: "text",
+ type: "text",
+ name: "Text",
+ elements: [],
+ hidden: false,
+ };
+ const effectTrack: EffectTrack = {
+ id: "effect",
+ type: "effect",
+ name: "Effect",
+ elements: [],
+ hidden: false,
+ };
+ const audioTrack: AudioTrack = {
+ id: "audio",
+ type: "audio",
+ name: "Audio",
+ elements: [],
+ muted: false,
+ };
+ const main = adjacentTrack();
+
+ expect(
+ resolve({
+ tracks: buildTracks({ main, overlay: [textTrack] }),
+ mouseX: 50,
+ }),
+ ).toBeNull();
+ expect(
+ resolve({
+ tracks: buildTracks({ main, overlay: [effectTrack] }),
+ mouseX: 50,
+ }),
+ ).toBeNull();
+ expect(
+ resolve({
+ tracks: buildTracks({ main, audio: [audioTrack] }),
+ mouseX: 50,
+ mouseY: 72,
+ }),
+ ).toBeNull();
+ });
+});