summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/group-move
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/group-move
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/group-move')
-rw-r--r--apps/web/src/timeline/group-move/build-group.ts87
-rw-r--r--apps/web/src/timeline/group-move/index.ts12
-rw-r--r--apps/web/src/timeline/group-move/resolve-move.ts470
-rw-r--r--apps/web/src/timeline/group-move/snap.ts85
-rw-r--r--apps/web/src/timeline/group-move/track-placement.ts83
-rw-r--r--apps/web/src/timeline/group-move/types.ts36
6 files changed, 773 insertions, 0 deletions
diff --git a/apps/web/src/timeline/group-move/build-group.ts b/apps/web/src/timeline/group-move/build-group.ts
new file mode 100644
index 00000000..00f39d55
--- /dev/null
+++ b/apps/web/src/timeline/group-move/build-group.ts
@@ -0,0 +1,87 @@
+import type { ElementRef, SceneTracks } from "@/timeline";
+import { findTrackInSceneTracks } from "@/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/timeline/group-move/index.ts b/apps/web/src/timeline/group-move/index.ts
new file mode 100644
index 00000000..82bd3895
--- /dev/null
+++ b/apps/web/src/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/timeline/group-move/resolve-move.ts b/apps/web/src/timeline/group-move/resolve-move.ts
new file mode 100644
index 00000000..60940315
--- /dev/null
+++ b/apps/web/src/timeline/group-move/resolve-move.ts
@@ -0,0 +1,470 @@
+import type { SceneTracks } from "@/timeline";
+import { getTrackTypeForElementType } from "@/timeline/placement/compatibility";
+import { canPlaceTimeSpansOnTrack } from "@/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/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts
new file mode 100644
index 00000000..32c0a2f1
--- /dev/null
+++ b/apps/web/src/timeline/group-move/snap.ts
@@ -0,0 +1,85 @@
+import type { SceneTracks } from "@/timeline";
+import {
+ buildTimelineSnapPoints,
+ getTimelineSnapThresholdInTicks,
+ resolveTimelineSnap,
+ type SnapPoint,
+} from "@/timeline/snapping";
+import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
+import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
+import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
+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 excludeElementIds = new Set(
+ group.members.map((member) => member.elementId),
+ );
+ const snapPoints = buildTimelineSnapPoints({
+ sources: [
+ () => getElementEdgeSnapPoints({ tracks, excludeElementIds }),
+ () => getPlayheadSnapPoints({ playheadTime }),
+ () =>
+ getAnimationKeyframeSnapPointsForTimeline({
+ tracks,
+ excludeElementIds,
+ }),
+ ],
+ });
+ const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel });
+
+ let closestSnapDistance = Infinity;
+ let snappedAnchorStartTime = anchorStartTime;
+ let snapPoint: SnapPoint | null = null;
+
+ for (const member of group.members) {
+ const memberStartTime = anchorStartTime + member.timeOffset;
+ const memberStartSnap = resolveTimelineSnap({
+ targetTime: memberStartTime,
+ snapPoints,
+ maxSnapDistance,
+ });
+ if (
+ memberStartSnap.snapPoint &&
+ memberStartSnap.snapDistance < closestSnapDistance
+ ) {
+ closestSnapDistance = memberStartSnap.snapDistance;
+ snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset;
+ snapPoint = memberStartSnap.snapPoint;
+ }
+
+ const memberEndSnap = resolveTimelineSnap({
+ targetTime: memberStartTime + member.duration,
+ snapPoints,
+ maxSnapDistance,
+ });
+ 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/timeline/group-move/track-placement.ts b/apps/web/src/timeline/group-move/track-placement.ts
new file mode 100644
index 00000000..c4c5efb5
--- /dev/null
+++ b/apps/web/src/timeline/group-move/track-placement.ts
@@ -0,0 +1,83 @@
+import type { SceneTracks, TimelineTrack } from "@/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/timeline/group-move/types.ts b/apps/web/src/timeline/group-move/types.ts
new file mode 100644
index 00000000..daf9c6e9
--- /dev/null
+++ b/apps/web/src/timeline/group-move/types.ts
@@ -0,0 +1,36 @@
+import type { ElementRef, ElementType, TrackType } from "@/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[];
+}