summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/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/lib/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/lib/timeline/group-move')
-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
6 files changed, 0 insertions, 759 deletions
diff --git a/apps/web/src/lib/timeline/group-move/build-group.ts b/apps/web/src/lib/timeline/group-move/build-group.ts
deleted file mode 100644
index 16d720eb..00000000
--- a/apps/web/src/lib/timeline/group-move/build-group.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-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
deleted file mode 100644
index 82bd3895..00000000
--- a/apps/web/src/lib/timeline/group-move/index.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-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
deleted file mode 100644
index fbe4cbda..00000000
--- a/apps/web/src/lib/timeline/group-move/resolve-move.ts
+++ /dev/null
@@ -1,470 +0,0 @@
-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
deleted file mode 100644
index 3502ae30..00000000
--- a/apps/web/src/lib/timeline/group-move/snap.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-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
deleted file mode 100644
index d301e5ef..00000000
--- a/apps/web/src/lib/timeline/group-move/track-placement.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-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
deleted file mode 100644
index 5ac1e207..00000000
--- a/apps/web/src/lib/timeline/group-move/types.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-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[];
-}