From f1043734438c8518a302f698d9ae6583a75f0c14 Mon Sep 17 00:00:00 2001 From: cashmere Date: Sun, 16 Aug 2026 06:16:56 +0200 Subject: feat: add transitions, track mixing, and voiceover controls --- apps/web/src/timeline/components/drop-target.ts | 90 +++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 5 deletions(-) (limited to 'apps/web/src/timeline/components/drop-target.ts') diff --git a/apps/web/src/timeline/components/drop-target.ts b/apps/web/src/timeline/components/drop-target.ts index f68a4b72..e22c8af9 100644 --- a/apps/web/src/timeline/components/drop-target.ts +++ b/apps/web/src/timeline/components/drop-target.ts @@ -1,5 +1,9 @@ -import type { TimelineTrack, TimelineElement } from "@/timeline"; -import type { ComputeDropTargetParams, DropTarget } from "@/timeline"; +import type { + ComputeDropTargetParams, + DropTarget, + TimelineElement, + TimelineTrack, +} from "@/timeline"; import { resolveTrackPlacement } from "@/timeline/placement"; import { TIMELINE_TRACK_GAP_PX } from "./layout"; import { getTrackHeight } from "./track-layout"; @@ -97,6 +101,71 @@ function fallbackNewTrackDropTarget({ insertPosition: null, xPosition, targetElement: EMPTY_TARGET_ELEMENT, + targetCut: null, + }; +} + +export function computeTransitionCutDropTarget({ + mouseX, + mouseY, + tracks, + pixelsPerSecond, + zoomLevel, +}: { + mouseX: number; + mouseY: number; + tracks: ComputeDropTargetParams["tracks"]; + pixelsPerSecond: number; + zoomLevel: number; +}): DropTarget | null { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + const trackAtMouse = getTrackAtY({ mouseY, tracks: orderedTracks }); + if (!trackAtMouse) return null; + + const track = orderedTracks[trackAtMouse.trackIndex]; + if (!track || track.type !== "video") return null; + + const elements = track.elements + .filter((element) => element.type === "video" || element.type === "image") + .slice() + .sort((left, right) => left.startTime - right.startTime); + let nearest: { + outgoingElementId: string; + incomingElementId: string; + time: MediaTime; + distance: number; + } | null = null; + + for (let index = 1; index < elements.length; index += 1) { + const outgoing = elements[index - 1]; + const incoming = elements[index]; + const cutTime = incoming.startTime; + if (outgoing.startTime + outgoing.duration !== cutTime) continue; + + const cutX = (cutTime / TICKS_PER_SECOND) * pixelsPerSecond * zoomLevel; + const distance = Math.abs(mouseX - cutX); + if (distance > 12 || (nearest && nearest.distance <= distance)) continue; + nearest = { + outgoingElementId: outgoing.id, + incomingElementId: incoming.id, + time: cutTime, + distance, + }; + } + + if (!nearest) return null; + return { + trackIndex: trackAtMouse.trackIndex, + isNewTrack: false, + insertPosition: null, + xPosition: nearest.time, + targetElement: null, + targetCut: { + trackId: track.id, + outgoingElementId: nearest.outgoingElementId, + incomingElementId: nearest.incomingElementId, + time: nearest.time, + }, }; } @@ -133,7 +202,9 @@ export function computeDropTarget({ const placementResult = resolveTrackPlacement({ tracks, elementType, - timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], + timeSpans: [ + { startTime: xPosition, duration: elementDuration, excludeElementId }, + ], strategy: { type: "preferIndex", trackIndex: 0, @@ -153,6 +224,7 @@ export function computeDropTarget({ insertPosition: emptyTimelineResult.insertPosition, xPosition, targetElement: EMPTY_TARGET_ELEMENT, + targetCut: null, }; } @@ -168,7 +240,9 @@ export function computeDropTarget({ const placementResult = resolveTrackPlacement({ tracks, elementType, - timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], + timeSpans: [ + { startTime: xPosition, duration: elementDuration, excludeElementId }, + ], strategy: { type: "preferIndex", trackIndex: isAboveAllTracks ? 0 : orderedTracks.length - 1, @@ -188,6 +262,7 @@ export function computeDropTarget({ insertPosition: outOfBoundsResult.insertPosition, xPosition, targetElement: EMPTY_TARGET_ELEMENT, + targetCut: null, }; } @@ -210,6 +285,7 @@ export function computeDropTarget({ insertPosition: null, xPosition, targetElement, + targetCut: null, }; } } @@ -218,7 +294,9 @@ export function computeDropTarget({ const placementResult = resolveTrackPlacement({ tracks, elementType, - timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], + timeSpans: [ + { startTime: xPosition, duration: elementDuration, excludeElementId }, + ], strategy: { type: "preferIndex", trackIndex, @@ -242,6 +320,7 @@ export function computeDropTarget({ insertPosition: null, xPosition: adjustedXPosition, targetElement: EMPTY_TARGET_ELEMENT, + targetCut: null, }; } @@ -251,6 +330,7 @@ export function computeDropTarget({ insertPosition: placementResult.insertPosition, xPosition, targetElement: EMPTY_TARGET_ELEMENT, + targetCut: null, }; } -- cgit v1.3.1