summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline/group-move
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-15 05:43:29 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-15 05:43:29 +0200
commitac43a5a73fa905cdac905bc956763b6093d6738e (patch)
tree8d9ff8c3a9ae55bac8860215bec69cc5b6094a1c /apps/web/src/lib/timeline/group-move
parent6312fc5b078689bc82c52fda9b7f92ee0a21d311 (diff)
downloadopencut-ac43a5a73fa905cdac905bc956763b6093d6738e.tar.gz
feat: move/resize multiple elements
Diffstat (limited to 'apps/web/src/lib/timeline/group-move')
-rw-r--r--apps/web/src/lib/timeline/group-move/__tests__/group-move.test.ts287
-rw-r--r--apps/web/src/lib/timeline/group-move/build-group.ts87
-rw-r--r--apps/web/src/lib/timeline/group-move/index.ts12
-rw-r--r--apps/web/src/lib/timeline/group-move/resolve-move.ts470
-rw-r--r--apps/web/src/lib/timeline/group-move/snap.ts71
-rw-r--r--apps/web/src/lib/timeline/group-move/track-placement.ts83
-rw-r--r--apps/web/src/lib/timeline/group-move/types.ts36
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[];
+}