diff options
Diffstat (limited to 'apps/web/src/lib/timeline/group-move')
| -rw-r--r-- | apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts | 287 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/group-move/build-group.ts | 87 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/group-move/index.ts | 12 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/group-move/resolve-move.ts | 470 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/group-move/snap.ts | 71 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/group-move/track-placement.ts | 83 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/group-move/types.ts | 36 |
7 files changed, 1046 insertions, 0 deletions
diff --git a/apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts b/apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts new file mode 100644 index 00000000..e6c86e41 --- /dev/null +++ b/apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, test } from "bun:test"; +import type { + AudioTrack, + OverlayTrack, + SceneTracks, + TextElement, + TextTrack, + VideoElement, + VideoTrack, +} from "@/lib/timeline/types"; +import type { Transform } from "@/lib/rendering"; +import { buildMoveGroup } from "@/lib/timeline/group-move/build-group"; +import { resolveGroupMove } from "@/lib/timeline/group-move/resolve-move"; + +function buildTransform(): Transform { + return { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }; +} + +function buildVideoElement({ + id, + startTime, + duration, +}: { + id: string; + startTime: number; + duration: number; +}): VideoElement { + return { + id, + type: "video", + name: id, + startTime, + duration, + trimStart: 0, + trimEnd: 0, + mediaId: `${id}-media`, + transform: buildTransform(), + opacity: 1, + }; +} + +function buildTextElement({ + id, + startTime, + duration, +}: { + id: string; + startTime: number; + duration: number; +}): TextElement { + return { + id, + type: "text", + name: id, + startTime, + duration, + trimStart: 0, + trimEnd: 0, + content: id, + fontSize: 32, + fontFamily: "Inter", + color: "#ffffff", + background: { + enabled: false, + color: "#000000", + }, + textAlign: "left", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + transform: buildTransform(), + opacity: 1, + }; +} + +function buildVideoTrack({ + id, + elements = [], +}: { + id: string; + elements?: VideoTrack["elements"]; +}): VideoTrack { + return { + id, + type: "video", + name: id, + elements, + muted: false, + hidden: false, + }; +} + +function buildTextTrack({ + id, + elements = [], +}: { + id: string; + elements?: TextTrack["elements"]; +}): TextTrack { + return { + id, + type: "text", + name: id, + elements, + hidden: false, + }; +} + +function buildTracks({ + overlay, + main, + audio = [], +}: { + overlay: OverlayTrack[]; + main: VideoTrack; + audio?: AudioTrack[]; +}): SceneTracks { + return { + overlay, + main, + audio, + }; +} + +describe("group move", () => { + test("buildMoveGroup keeps rigid time offsets", () => { + const tracks = buildTracks({ + overlay: [ + buildVideoTrack({ + id: "overlay-video", + elements: [ + buildVideoElement({ id: "video-1", startTime: 5, duration: 4 }), + ], + }), + buildTextTrack({ + id: "overlay-text", + elements: [ + buildTextElement({ id: "text-1", startTime: 8, duration: 3 }), + ], + }), + ], + main: buildVideoTrack({ id: "main" }), + }); + + const group = buildMoveGroup({ + anchorRef: { trackId: "overlay-video", elementId: "video-1" }, + selectedElements: [ + { trackId: "overlay-video", elementId: "video-1" }, + { trackId: "overlay-text", elementId: "text-1" }, + ], + tracks, + }); + + expect(group).not.toBeNull(); + expect(group?.members.map((member) => member.timeOffset)).toEqual([0, 3]); + }); + + test("resolveGroupMove preserves order and clamps rigidly at time zero", () => { + const tracks = buildTracks({ + overlay: [ + buildTextTrack({ + id: "overlay-text", + elements: [ + buildTextElement({ id: "text-1", startTime: 0, duration: 3 }), + ], + }), + buildVideoTrack({ + id: "overlay-video", + elements: [ + buildVideoElement({ id: "video-1", startTime: 5, duration: 4 }), + ], + }), + ], + main: buildVideoTrack({ id: "main" }), + }); + const group = buildMoveGroup({ + anchorRef: { trackId: "overlay-video", elementId: "video-1" }, + selectedElements: [ + { trackId: "overlay-video", elementId: "video-1" }, + { trackId: "overlay-text", elementId: "text-1" }, + ], + tracks, + }); + if (!group) { + throw new Error("Expected group"); + } + + const result = resolveGroupMove({ + group, + tracks, + anchorStartTime: 7, + target: { + kind: "existingTrack", + anchorTargetTrackId: "main", + }, + }); + + expect(result?.moves).toEqual([ + { + sourceTrackId: "overlay-video", + elementId: "video-1", + targetTrackId: "main", + newStartTime: 5, + }, + { + sourceTrackId: "overlay-text", + elementId: "text-1", + targetTrackId: "overlay-text", + newStartTime: 0, + }, + ]); + }); + + test("resolveGroupMove falls back to new overlay tracks when the group cannot cross main", () => { + const tracks = buildTracks({ + overlay: [ + buildVideoTrack({ + id: "overlay-video", + elements: [ + buildVideoElement({ id: "video-1", startTime: 5, duration: 4 }), + ], + }), + buildTextTrack({ + id: "overlay-text", + elements: [ + buildTextElement({ id: "text-1", startTime: 5, duration: 3 }), + ], + }), + ], + main: buildVideoTrack({ id: "main" }), + }); + const group = buildMoveGroup({ + anchorRef: { trackId: "overlay-video", elementId: "video-1" }, + selectedElements: [ + { trackId: "overlay-video", elementId: "video-1" }, + { trackId: "overlay-text", elementId: "text-1" }, + ], + tracks, + }); + if (!group) { + throw new Error("Expected group"); + } + + const result = resolveGroupMove({ + group, + tracks, + anchorStartTime: 6, + target: { + kind: "newTracks", + anchorInsertIndex: 2, + newTrackIds: ["new-video-track", "new-text-track"], + }, + }); + + expect(result?.createTracks).toEqual([ + { + id: "new-video-track", + type: "video", + index: 2, + }, + { + id: "new-text-track", + type: "text", + index: 3, + }, + ]); + expect(result?.moves).toEqual([ + { + sourceTrackId: "overlay-video", + elementId: "video-1", + targetTrackId: "new-video-track", + newStartTime: 6, + }, + { + sourceTrackId: "overlay-text", + elementId: "text-1", + targetTrackId: "new-text-track", + newStartTime: 6, + }, + ]); + }); +}); diff --git a/apps/web/src/lib/timeline/group-move/build-group.ts b/apps/web/src/lib/timeline/group-move/build-group.ts new file mode 100644 index 00000000..16d720eb --- /dev/null +++ b/apps/web/src/lib/timeline/group-move/build-group.ts @@ -0,0 +1,87 @@ +import type { ElementRef, SceneTracks } from "@/lib/timeline"; +import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update"; +import type { GroupMember, MoveGroup } from "./types"; +import { getTrackPlacementById } from "./track-placement"; + +export function buildMoveGroup({ + anchorRef, + selectedElements, + tracks, +}: { + anchorRef: ElementRef; + selectedElements: ElementRef[]; + tracks: SceneTracks; +}): MoveGroup | null { + const anchorTrack = findTrackInSceneTracks({ + tracks, + trackId: anchorRef.trackId, + }); + const anchorElement = anchorTrack?.elements.find( + (element) => element.id === anchorRef.elementId, + ); + const anchorPlacement = getTrackPlacementById({ + tracks, + trackId: anchorRef.trackId, + }); + if (!anchorTrack || !anchorElement || !anchorPlacement) { + return null; + } + + const seen = new Set<string>(); + const orderedRefs = [anchorRef, ...selectedElements].filter((elementRef) => { + if (seen.has(elementRef.elementId)) { + return false; + } + + seen.add(elementRef.elementId); + return true; + }); + + const members = orderedRefs.flatMap((elementRef): GroupMember[] => { + const track = findTrackInSceneTracks({ + tracks, + trackId: elementRef.trackId, + }); + const element = track?.elements.find( + (trackElement) => trackElement.id === elementRef.elementId, + ); + const placement = getTrackPlacementById({ + tracks, + trackId: elementRef.trackId, + }); + if (!track || !element || !placement) { + return []; + } + + return [ + { + trackId: track.id, + elementId: element.id, + elementType: element.type, + duration: element.duration, + timeOffset: element.startTime - anchorElement.startTime, + trackSection: placement.section, + sectionIndex: placement.sectionIndex, + displayIndex: placement.displayIndex, + }, + ]; + }); + + if (members.length === 0) { + return null; + } + + const anchor = members.find( + (member) => + member.trackId === anchorRef.trackId && + member.elementId === anchorRef.elementId, + ); + if (!anchor) { + return null; + } + + return { + anchor, + members, + }; +} diff --git a/apps/web/src/lib/timeline/group-move/index.ts b/apps/web/src/lib/timeline/group-move/index.ts new file mode 100644 index 00000000..82bd3895 --- /dev/null +++ b/apps/web/src/lib/timeline/group-move/index.ts @@ -0,0 +1,12 @@ +export { buildMoveGroup } from "./build-group"; +export { resolveGroupMove } from "./resolve-move"; +export { snapGroupEdges } from "./snap"; + +export type { + GroupMember, + GroupMoveResult, + GroupTrackSection, + MoveGroup, + PlannedElementMove, + PlannedTrackCreation, +} from "./types"; diff --git a/apps/web/src/lib/timeline/group-move/resolve-move.ts b/apps/web/src/lib/timeline/group-move/resolve-move.ts new file mode 100644 index 00000000..fbe4cbda --- /dev/null +++ b/apps/web/src/lib/timeline/group-move/resolve-move.ts @@ -0,0 +1,470 @@ +import type { SceneTracks } from "@/lib/timeline"; +import { getTrackTypeForElementType } from "@/lib/timeline/placement/compatibility"; +import { canPlaceTimeSpansOnTrack } from "@/lib/timeline/placement/overlap"; +import type { + GroupMoveResult, + MoveGroup, + PlannedElementMove, + PlannedTrackCreation, +} from "./types"; +import { + getDisplayTracks, + getTrackPlacementByDisplayIndex, + getTrackPlacementById, +} from "./track-placement"; + +type GroupMoveTarget = + | { + kind: "existingTrack"; + anchorTargetTrackId: string; + } + | { + kind: "newTracks"; + anchorInsertIndex: number; + newTrackIds: string[]; + }; + +export function resolveGroupMove({ + group, + tracks, + anchorStartTime, + target, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: number; + target: GroupMoveTarget; +}): GroupMoveResult | null { + if (target.kind === "newTracks") { + return resolveNewTrackMove({ + group, + tracks, + anchorStartTime, + anchorInsertIndex: target.anchorInsertIndex, + newTrackIds: target.newTrackIds, + }); + } + + return resolveExistingTrackMove({ + group, + tracks, + anchorStartTime, + anchorTargetTrackId: target.anchorTargetTrackId, + }); +} + +function resolveExistingTrackMove({ + group, + tracks, + anchorStartTime, + anchorTargetTrackId, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: number; + anchorTargetTrackId: string; +}): GroupMoveResult | null { + const anchorTargetPlacement = getTrackPlacementById({ + tracks, + trackId: anchorTargetTrackId, + }); + if (!anchorTargetPlacement) { + return null; + } + + const targetTrackIdsByElementId = resolveExistingTrackIdsByElementId({ + group, + tracks, + anchorTargetDisplayIndex: anchorTargetPlacement.displayIndex, + }); + if (!targetTrackIdsByElementId) { + return null; + } + + const clampedAnchorStartTime = clampAnchorStartTime({ + group, + tracks, + anchorStartTime, + targetTrackIdsByElementId, + }); + + const moves = group.members.map((member) => ({ + sourceTrackId: member.trackId, + targetTrackId: + targetTrackIdsByElementId.get(member.elementId) ?? member.trackId, + elementId: member.elementId, + newStartTime: clampedAnchorStartTime + member.timeOffset, + })); + + if (!canApplyMovesToExistingTracks({ tracks, moves })) { + return null; + } + + return { + moves, + createTracks: [], + targetSelection: moves.map(({ elementId, targetTrackId }) => ({ + trackId: targetTrackId, + elementId, + })), + }; +} + +function resolveNewTrackMove({ + group, + tracks, + anchorStartTime, + anchorInsertIndex, + newTrackIds, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: number; + anchorInsertIndex: number; + newTrackIds: string[]; +}): GroupMoveResult | null { + const sortedMembers = [...group.members].sort( + (leftMember, rightMember) => + leftMember.displayIndex - rightMember.displayIndex, + ); + const anchorMemberIndex = sortedMembers.findIndex( + (member) => member.elementId === group.anchor.elementId, + ); + if (anchorMemberIndex < 0 || newTrackIds.length < sortedMembers.length) { + return null; + } + + const hasAudioMember = sortedMembers.some( + (member) => member.trackSection === "audio", + ); + const hasNonAudioMember = sortedMembers.some( + (member) => member.trackSection !== "audio", + ); + if (hasAudioMember && hasNonAudioMember) { + return null; + } + + const clampedAnchorStartTime = clampAnchorStartTime({ + group, + tracks, + anchorStartTime, + targetTrackIdsByElementId: new Map(), + }); + const blockStartIndex = hasAudioMember + ? clampAudioInsertIndex({ + tracks, + insertIndex: anchorInsertIndex - anchorMemberIndex, + }) + : Math.max( + 0, + Math.min(anchorInsertIndex - anchorMemberIndex, tracks.overlay.length), + ); + + const createTracks: PlannedTrackCreation[] = sortedMembers.map( + (member, memberIndex) => ({ + id: newTrackIds[memberIndex], + type: getTrackTypeForElementType({ + elementType: member.elementType, + }), + index: blockStartIndex + memberIndex, + }), + ); + const moves = sortedMembers.map((member, memberIndex) => ({ + sourceTrackId: member.trackId, + targetTrackId: newTrackIds[memberIndex], + elementId: member.elementId, + newStartTime: clampedAnchorStartTime + member.timeOffset, + })); + + return { + moves, + createTracks, + targetSelection: moves.map(({ elementId, targetTrackId }) => ({ + trackId: targetTrackId, + elementId, + })), + }; +} + +function clampAudioInsertIndex({ + tracks, + insertIndex, +}: { + tracks: SceneTracks; + insertIndex: number; +}): number { + const minimumAudioInsertIndex = tracks.overlay.length + 1; + return Math.max( + minimumAudioInsertIndex, + Math.min(insertIndex, minimumAudioInsertIndex + tracks.audio.length), + ); +} + +function resolveExistingTrackIdsByElementId({ + group, + tracks, + anchorTargetDisplayIndex, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorTargetDisplayIndex: number; +}): Map<string, string> | null { + const sortedMembers = [...group.members].sort( + (leftMember, rightMember) => + leftMember.displayIndex - rightMember.displayIndex, + ); + const anchorMemberIndex = sortedMembers.findIndex( + (member) => member.elementId === group.anchor.elementId, + ); + if (anchorMemberIndex < 0) { + return null; + } + + const targetTrackIdsByElementId = new Map<string, string>(); + const usedTrackIds = new Set<string>(); + const anchorPlacement = getTrackPlacementByDisplayIndex({ + tracks, + displayIndex: anchorTargetDisplayIndex, + }); + if (!anchorPlacement) { + return null; + } + + targetTrackIdsByElementId.set( + group.anchor.elementId, + anchorPlacement.trackId, + ); + usedTrackIds.add(anchorPlacement.trackId); + + let upperBoundaryIndex = anchorTargetDisplayIndex; + for ( + let memberIndex = anchorMemberIndex - 1; + memberIndex >= 0; + memberIndex -= 1 + ) { + const member = sortedMembers[memberIndex]; + const targetPlacement = findCompatibleTrackPlacement({ + tracks, + requiredTrackType: getTrackTypeForElementType({ + elementType: member.elementType, + }), + startDisplayIndex: upperBoundaryIndex - 1, + step: -1, + usedTrackIds, + }); + if (!targetPlacement) { + return null; + } + + targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId); + usedTrackIds.add(targetPlacement.trackId); + upperBoundaryIndex = targetPlacement.displayIndex; + } + + let lowerBoundaryIndex = anchorTargetDisplayIndex; + for ( + let memberIndex = anchorMemberIndex + 1; + memberIndex < sortedMembers.length; + memberIndex += 1 + ) { + const member = sortedMembers[memberIndex]; + const targetPlacement = findCompatibleTrackPlacement({ + tracks, + requiredTrackType: getTrackTypeForElementType({ + elementType: member.elementType, + }), + startDisplayIndex: lowerBoundaryIndex + 1, + step: 1, + usedTrackIds, + }); + if (!targetPlacement) { + return null; + } + + targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId); + usedTrackIds.add(targetPlacement.trackId); + lowerBoundaryIndex = targetPlacement.displayIndex; + } + + return targetTrackIdsByElementId; +} + +function findCompatibleTrackPlacement({ + tracks, + requiredTrackType, + startDisplayIndex, + step, + usedTrackIds, +}: { + tracks: SceneTracks; + requiredTrackType: ReturnType<typeof getTrackTypeForElementType>; + startDisplayIndex: number; + step: -1 | 1; + usedTrackIds: Set<string>; +}) { + for ( + let displayIndex = startDisplayIndex; + displayIndex >= 0 && + displayIndex < tracks.overlay.length + 1 + tracks.audio.length; + displayIndex += step + ) { + const placement = getTrackPlacementByDisplayIndex({ + tracks, + displayIndex, + }); + if (!placement) { + continue; + } + + if ( + placement.trackType === requiredTrackType && + !usedTrackIds.has(placement.trackId) + ) { + return placement; + } + } + + return null; +} + +function clampAnchorStartTime({ + group, + tracks, + anchorStartTime, + targetTrackIdsByElementId, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: number; + targetTrackIdsByElementId: Map<string, string>; +}): number { + const minimumAnchorStartTime = Math.max( + 0, + ...group.members.map((member) => -member.timeOffset), + ); + let clampedAnchorStartTime = Math.max( + minimumAnchorStartTime, + anchorStartTime, + ); + + const memberOnMainTrack = group.members.find( + (member) => + targetTrackIdsByElementId.get(member.elementId) === tracks.main.id, + ); + if (!memberOnMainTrack) { + return clampedAnchorStartTime; + } + + const movingElementIds = new Set( + group.members.map((member) => member.elementId), + ); + const requestedMainStartTime = + clampedAnchorStartTime + memberOnMainTrack.timeOffset; + const earliestStationaryMainStartTime = tracks.main.elements + .filter((element) => !movingElementIds.has(element.id)) + .reduce<number | null>((earliestStartTime, element) => { + if (earliestStartTime == null || element.startTime < earliestStartTime) { + return element.startTime; + } + + return earliestStartTime; + }, null); + if ( + earliestStationaryMainStartTime == null || + requestedMainStartTime <= earliestStationaryMainStartTime + ) { + clampedAnchorStartTime = Math.max( + minimumAnchorStartTime, + -memberOnMainTrack.timeOffset, + ); + } + + return clampedAnchorStartTime; +} + +function canApplyMovesToExistingTracks({ + tracks, + moves, +}: { + tracks: SceneTracks; + moves: PlannedElementMove[]; +}): boolean { + const movingElementIds = new Set(moves.map((move) => move.elementId)); + const sourceElements = new Map( + getDisplayTracks({ tracks }).flatMap((track) => + track.elements.map((element) => [element.id, element] as const), + ), + ); + const movesByTargetTrackId = new Map<string, PlannedElementMove[]>(); + for (const move of moves) { + const targetMoves = movesByTargetTrackId.get(move.targetTrackId) ?? []; + targetMoves.push(move); + movesByTargetTrackId.set(move.targetTrackId, targetMoves); + } + + for (const [targetTrackId, targetMoves] of movesByTargetTrackId) { + const targetPlacement = getTrackPlacementById({ + tracks, + trackId: targetTrackId, + }); + if (!targetPlacement) { + return false; + } + + const targetTrack = getDisplayTracks({ tracks })[ + targetPlacement.displayIndex + ]; + if (!targetTrack) { + return false; + } + + const timeSpans = targetMoves.map((move) => { + const sourceElement = sourceElements.get(move.elementId); + return { + startTime: move.newStartTime, + duration: sourceElement?.duration ?? 0, + }; + }); + if (hasOverlappingTimeSpans({ timeSpans })) { + return false; + } + + if ( + !canPlaceTimeSpansOnTrack({ + track: { + elements: targetTrack.elements.filter( + (element) => !movingElementIds.has(element.id), + ), + }, + timeSpans, + }) + ) { + return false; + } + } + + return true; +} + +function hasOverlappingTimeSpans({ + timeSpans, +}: { + timeSpans: Array<{ startTime: number; duration: number }>; +}): boolean { + const sortedSpans = [...timeSpans].sort( + (leftSpan, rightSpan) => leftSpan.startTime - rightSpan.startTime, + ); + + for (let spanIndex = 1; spanIndex < sortedSpans.length; spanIndex += 1) { + const previousSpan = sortedSpans[spanIndex - 1]; + const currentSpan = sortedSpans[spanIndex]; + if ( + previousSpan.startTime + previousSpan.duration > + currentSpan.startTime + ) { + return true; + } + } + + return false; +} diff --git a/apps/web/src/lib/timeline/group-move/snap.ts b/apps/web/src/lib/timeline/group-move/snap.ts new file mode 100644 index 00000000..3502ae30 --- /dev/null +++ b/apps/web/src/lib/timeline/group-move/snap.ts @@ -0,0 +1,71 @@ +import type { SceneTracks } from "@/lib/timeline"; +import { + findSnapPoints, + snapToNearestPoint, + type SnapPoint, +} from "@/lib/timeline/snap-utils"; +import type { MoveGroup } from "./types"; + +export function snapGroupEdges({ + group, + anchorStartTime, + tracks, + playheadTime, + zoomLevel, +}: { + group: MoveGroup; + anchorStartTime: number; + tracks: SceneTracks; + playheadTime: number; + zoomLevel: number; +}): { + snappedAnchorStartTime: number; + snapPoint: SnapPoint | null; +} { + const snapPoints = findSnapPoints({ + tracks, + playheadTime, + excludeElementIds: new Set(group.members.map((member) => member.elementId)), + }); + + let closestSnapDistance = Infinity; + let snappedAnchorStartTime = anchorStartTime; + let snapPoint: SnapPoint | null = null; + + for (const member of group.members) { + const memberStartTime = anchorStartTime + member.timeOffset; + const memberStartSnap = snapToNearestPoint({ + targetTime: memberStartTime, + snapPoints, + zoomLevel, + }); + if ( + memberStartSnap.snapPoint && + memberStartSnap.snapDistance < closestSnapDistance + ) { + closestSnapDistance = memberStartSnap.snapDistance; + snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset; + snapPoint = memberStartSnap.snapPoint; + } + + const memberEndSnap = snapToNearestPoint({ + targetTime: memberStartTime + member.duration, + snapPoints, + zoomLevel, + }); + if ( + memberEndSnap.snapPoint && + memberEndSnap.snapDistance < closestSnapDistance + ) { + closestSnapDistance = memberEndSnap.snapDistance; + snappedAnchorStartTime = + memberEndSnap.snappedTime - member.duration - member.timeOffset; + snapPoint = memberEndSnap.snapPoint; + } + } + + return { + snappedAnchorStartTime, + snapPoint, + }; +} diff --git a/apps/web/src/lib/timeline/group-move/track-placement.ts b/apps/web/src/lib/timeline/group-move/track-placement.ts new file mode 100644 index 00000000..d301e5ef --- /dev/null +++ b/apps/web/src/lib/timeline/group-move/track-placement.ts @@ -0,0 +1,83 @@ +import type { SceneTracks, TimelineTrack } from "@/lib/timeline"; +import type { GroupTrackSection } from "./types"; + +export interface TrackPlacement { + trackId: string; + trackType: TimelineTrack["type"]; + section: GroupTrackSection; + sectionIndex: number; + displayIndex: number; +} + +export function getDisplayTracks({ + tracks, +}: { + tracks: SceneTracks; +}): TimelineTrack[] { + return [...tracks.overlay, tracks.main, ...tracks.audio]; +} + +export function getTrackPlacementById({ + tracks, + trackId, +}: { + tracks: SceneTracks; + trackId: string; +}): TrackPlacement | null { + if (tracks.main.id === trackId) { + return { + trackId, + trackType: tracks.main.type, + section: "main", + sectionIndex: -1, + displayIndex: tracks.overlay.length, + }; + } + + const overlayTrackIndex = tracks.overlay.findIndex( + (track) => track.id === trackId, + ); + if (overlayTrackIndex >= 0) { + return { + trackId, + trackType: tracks.overlay[overlayTrackIndex].type, + section: "overlay", + sectionIndex: overlayTrackIndex, + displayIndex: overlayTrackIndex, + }; + } + + const audioTrackIndex = tracks.audio.findIndex( + (track) => track.id === trackId, + ); + if (audioTrackIndex >= 0) { + return { + trackId, + trackType: tracks.audio[audioTrackIndex].type, + section: "audio", + sectionIndex: audioTrackIndex, + displayIndex: tracks.overlay.length + 1 + audioTrackIndex, + }; + } + + return null; +} + +export function getTrackPlacementByDisplayIndex({ + tracks, + displayIndex, +}: { + tracks: SceneTracks; + displayIndex: number; +}): TrackPlacement | null { + const displayTracks = getDisplayTracks({ tracks }); + const track = displayTracks[displayIndex]; + if (!track) { + return null; + } + + return getTrackPlacementById({ + tracks, + trackId: track.id, + }); +} diff --git a/apps/web/src/lib/timeline/group-move/types.ts b/apps/web/src/lib/timeline/group-move/types.ts new file mode 100644 index 00000000..5ac1e207 --- /dev/null +++ b/apps/web/src/lib/timeline/group-move/types.ts @@ -0,0 +1,36 @@ +import type { ElementRef, ElementType, TrackType } from "@/lib/timeline"; + +export type GroupTrackSection = "overlay" | "main" | "audio"; + +export interface GroupMember extends ElementRef { + elementType: ElementType; + duration: number; + timeOffset: number; + trackSection: GroupTrackSection; + sectionIndex: number; + displayIndex: number; +} + +export interface MoveGroup { + anchor: GroupMember; + members: GroupMember[]; +} + +export interface PlannedTrackCreation { + id: string; + type: TrackType; + index: number; +} + +export interface PlannedElementMove { + sourceTrackId: string; + targetTrackId: string; + elementId: string; + newStartTime: number; +} + +export interface GroupMoveResult { + moves: PlannedElementMove[]; + createTracks: PlannedTrackCreation[]; + targetSelection: ElementRef[]; +} |
