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/components/__tests__ | |
| parent | 37d22b1e390bd6cea04bf8e15069281ee125cf10 (diff) | |
| download | opencut-f1043734438c8518a302f698d9ae6583a75f0c14.tar.gz | |
feat: add transitions, track mixing, and voiceover controls
Diffstat (limited to 'apps/web/src/timeline/components/__tests__')
| -rw-r--r-- | apps/web/src/timeline/components/__tests__/drop-target-transition.test.ts | 221 |
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(); + }); +}); |
