diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-15 05:43:29 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-15 05:43:29 +0200 |
| commit | ac43a5a73fa905cdac905bc956763b6093d6738e (patch) | |
| tree | 8d9ff8c3a9ae55bac8860215bec69cc5b6094a1c /apps/web/src/lib/timeline | |
| parent | 6312fc5b078689bc82c52fda9b7f92ee0a21d311 (diff) | |
| download | opencut-ac43a5a73fa905cdac905bc956763b6093d6738e.tar.gz | |
feat: move/resize multiple elements
Diffstat (limited to 'apps/web/src/lib/timeline')
| -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 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/group-resize/compute-resize.ts | 290 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/group-resize/index.ts | 9 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/group-resize/types.ts | 36 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts | 7 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/placement/overlap.ts | 8 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline/snap-utils.ts | 11 |
13 files changed, 1403 insertions, 4 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[]; +} diff --git a/apps/web/src/lib/timeline/group-resize/compute-resize.ts b/apps/web/src/lib/timeline/group-resize/compute-resize.ts new file mode 100644 index 00000000..8d7d9b2d --- /dev/null +++ b/apps/web/src/lib/timeline/group-resize/compute-resize.ts @@ -0,0 +1,290 @@ +import { roundToFrame } from "opencut-wasm"; +import { + getSourceSpanAtClipTime, + getTimelineDurationForSourceSpan, +} from "@/lib/retime"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; +import type { + ComputeGroupResizeArgs, + GroupResizeMember, + GroupResizeResult, + GroupResizeUpdate, + ResizeSide, +} from "./types"; + +export function computeGroupResize({ + members, + side, + deltaTime, + fps, +}: ComputeGroupResizeArgs): GroupResizeResult { + const minDuration = Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ); + const minimumDeltaTime = Math.max( + ...members.map((member) => + getMinimumAllowedDeltaTime({ + member, + side, + minDuration, + }), + ), + ); + const maximumDeltaTime = Math.min( + ...members.map((member) => + getMaximumAllowedDeltaTime({ + member, + side, + minDuration, + }), + ), + ); + const clampedDeltaTime = + minimumDeltaTime > maximumDeltaTime + ? minimumDeltaTime + : Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime)); + + return { + deltaTime: Object.is(clampedDeltaTime, -0) ? 0 : clampedDeltaTime, + updates: members.map((member) => + buildResizeUpdate({ + member, + side, + deltaTime: clampedDeltaTime, + fps, + }), + ), + }; +} + +function buildResizeUpdate({ + member, + side, + deltaTime, + fps, +}: { + member: GroupResizeMember; + side: ResizeSide; + deltaTime: number; + fps: ComputeGroupResizeArgs["fps"]; +}): GroupResizeUpdate { + const totalSourceDuration = getSourceDuration({ member }); + const sourceDelta = getSourceDeltaForClipDelta({ + member, + clipDelta: deltaTime, + }); + const visibleSourceSpan = getVisibleSourceSpanForDuration({ + member, + duration: member.duration, + }); + + if (side === "left") { + if (deltaTime < 0 && member.sourceDuration == null) { + const rawStartTime = member.startTime + deltaTime; + const rawDuration = member.duration - deltaTime; + return { + trackId: member.trackId, + elementId: member.elementId, + patch: { + trimStart: Math.max(0, member.trimStart + sourceDelta), + trimEnd: member.trimEnd, + startTime: + roundToFrame({ time: rawStartTime, rate: fps }) ?? rawStartTime, + duration: + roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration, + }, + }; + } + + const nextTrimStart = Math.max(0, member.trimStart + sourceDelta); + const nextVisibleSourceSpan = Math.max( + 0, + totalSourceDuration - nextTrimStart - member.trimEnd, + ); + const rawDuration = getDurationForVisibleSourceSpan({ + member, + sourceSpan: nextVisibleSourceSpan, + }); + const nextDuration = + roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration; + const rawStartTime = member.startTime + (member.duration - nextDuration); + return { + trackId: member.trackId, + elementId: member.elementId, + patch: { + trimStart: + roundToFrame({ time: nextTrimStart, rate: fps }) ?? nextTrimStart, + trimEnd: member.trimEnd, + startTime: + roundToFrame({ time: rawStartTime, rate: fps }) ?? rawStartTime, + duration: nextDuration, + }, + }; + } + + const nextVisibleSourceSpan = Math.max(0, visibleSourceSpan + sourceDelta); + if (deltaTime > 0 && member.sourceDuration == null) { + const rawDuration = member.duration + deltaTime; + return { + trackId: member.trackId, + elementId: member.elementId, + patch: { + trimStart: member.trimStart, + trimEnd: Math.max(0, member.trimEnd - sourceDelta), + startTime: member.startTime, + duration: roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration, + }, + }; + } + + const nextTrimEnd = Math.max( + 0, + totalSourceDuration - member.trimStart - nextVisibleSourceSpan, + ); + const rawDuration = getDurationForVisibleSourceSpan({ + member, + sourceSpan: nextVisibleSourceSpan, + }); + return { + trackId: member.trackId, + elementId: member.elementId, + patch: { + trimStart: member.trimStart, + trimEnd: roundToFrame({ time: nextTrimEnd, rate: fps }) ?? nextTrimEnd, + startTime: member.startTime, + duration: roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration, + }, + }; +} + +function getMinimumAllowedDeltaTime({ + member, + side, + minDuration, +}: { + member: GroupResizeMember; + side: ResizeSide; + minDuration: number; +}): number { + if (side === "right") { + return minDuration - member.duration; + } + + const leftNeighborFloor = Number.isFinite(member.leftNeighborBound) + ? member.leftNeighborBound - member.startTime + : -member.startTime; + if (member.sourceDuration == null) { + return leftNeighborFloor; + } + + const maximumSourceExtension = + getDurationForVisibleSourceSpan({ + member, + sourceSpan: + getVisibleSourceSpanForDuration({ + member, + duration: member.duration, + }) + member.trimStart, + }) - member.duration; + return Math.max(leftNeighborFloor, -maximumSourceExtension); +} + +function getMaximumAllowedDeltaTime({ + member, + side, + minDuration, +}: { + member: GroupResizeMember; + side: ResizeSide; + minDuration: number; +}): number { + if (side === "left") { + return member.duration - minDuration; + } + + const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound) + ? member.rightNeighborBound - (member.startTime + member.duration) + : Infinity; + if (member.sourceDuration == null) { + return rightNeighborCeiling; + } + + const maximumVisibleSourceSpan = + getSourceDuration({ member }) - member.trimStart; + const maximumDuration = getDurationForVisibleSourceSpan({ + member, + sourceSpan: maximumVisibleSourceSpan, + }); + return Math.min(rightNeighborCeiling, maximumDuration - member.duration); +} + +function getSourceDeltaForClipDelta({ + member, + clipDelta, +}: { + member: GroupResizeMember; + clipDelta: number; +}): number { + if (!member.retime) { + return clipDelta; + } + + return clipDelta >= 0 + ? getSourceSpanAtClipTime({ + clipTime: clipDelta, + retime: member.retime, + }) + : -getSourceSpanAtClipTime({ + clipTime: Math.abs(clipDelta), + retime: member.retime, + }); +} + +function getVisibleSourceSpanForDuration({ + member, + duration, +}: { + member: GroupResizeMember; + duration: number; +}): number { + if (!member.retime) { + return duration; + } + + return getSourceSpanAtClipTime({ + clipTime: duration, + retime: member.retime, + }); +} + +function getDurationForVisibleSourceSpan({ + member, + sourceSpan, +}: { + member: GroupResizeMember; + sourceSpan: number; +}): number { + if (!member.retime) { + return sourceSpan; + } + + return getTimelineDurationForSourceSpan({ + sourceSpan, + retime: member.retime, + }); +} + +function getSourceDuration({ member }: { member: GroupResizeMember }): number { + if (typeof member.sourceDuration === "number") { + return member.sourceDuration; + } + + return ( + member.trimStart + + getVisibleSourceSpanForDuration({ + member, + duration: member.duration, + }) + + member.trimEnd + ); +} diff --git a/apps/web/src/lib/timeline/group-resize/index.ts b/apps/web/src/lib/timeline/group-resize/index.ts new file mode 100644 index 00000000..bc0a7b35 --- /dev/null +++ b/apps/web/src/lib/timeline/group-resize/index.ts @@ -0,0 +1,9 @@ +export { computeGroupResize } from "./compute-resize"; + +export type { + ComputeGroupResizeArgs, + GroupResizeMember, + GroupResizeResult, + GroupResizeUpdate, + ResizeSide, +} from "./types"; diff --git a/apps/web/src/lib/timeline/group-resize/types.ts b/apps/web/src/lib/timeline/group-resize/types.ts new file mode 100644 index 00000000..d5f00c1b --- /dev/null +++ b/apps/web/src/lib/timeline/group-resize/types.ts @@ -0,0 +1,36 @@ +import type { FrameRate } from "opencut-wasm"; +import type { ElementRef, RetimeConfig } from "@/lib/timeline/types"; + +export type ResizeSide = "left" | "right"; + +export interface GroupResizeMember extends ElementRef { + startTime: number; + duration: number; + trimStart: number; + trimEnd: number; + sourceDuration?: number; + retime?: RetimeConfig; + leftNeighborBound: number; + rightNeighborBound: number; +} + +export interface GroupResizeUpdate extends ElementRef { + patch: { + trimStart: number; + trimEnd: number; + startTime: number; + duration: number; + }; +} + +export interface GroupResizeResult { + deltaTime: number; + updates: GroupResizeUpdate[]; +} + +export interface ComputeGroupResizeArgs { + members: GroupResizeMember[]; + side: ResizeSide; + deltaTime: number; + fps: FrameRate; +} diff --git a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts index 4d39f5d8..7cadb9b7 100644 --- a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts +++ b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts @@ -329,7 +329,12 @@ describe("resolveTrackPlacement", () => { id: "graphic-1", type: "graphic", elements: [ - buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }), + buildElement({ + id: "a", + type: "graphic", + startTime: 0, + duration: 5, + }), ], }), ], diff --git a/apps/web/src/lib/timeline/placement/overlap.ts b/apps/web/src/lib/timeline/placement/overlap.ts index e5f95fa9..5e37c052 100644 --- a/apps/web/src/lib/timeline/placement/overlap.ts +++ b/apps/web/src/lib/timeline/placement/overlap.ts @@ -1,6 +1,10 @@ -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { TimelineElement } from "@/lib/timeline"; import type { PlacementTimeSpan } from "./types"; +interface TrackWithElements { + elements: TimelineElement[]; +} + function wouldElementOverlap({ elements, startTime, @@ -26,7 +30,7 @@ export function canPlaceTimeSpansOnTrack({ track, timeSpans, }: { - track: TimelineTrack; + track: TrackWithElements; timeSpans: PlacementTimeSpan[]; }): boolean { return timeSpans.every(({ startTime, duration, excludeElementId }) => { diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts index c1768deb..b2be95a9 100644 --- a/apps/web/src/lib/timeline/snap-utils.ts +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -22,6 +22,7 @@ export function findSnapPoints({ tracks, playheadTime, excludeElementId, + excludeElementIds, bookmarks = [], excludeBookmarkTime, enableElementSnapping = true, @@ -32,6 +33,7 @@ export function findSnapPoints({ tracks: SceneTracks; playheadTime: number; excludeElementId?: string; + excludeElementIds?: Set<string>; bookmarks?: Array<Bookmark>; excludeBookmarkTime?: number; enableElementSnapping?: boolean; @@ -41,10 +43,14 @@ export function findSnapPoints({ }): SnapPoint[] { const snapPoints: SnapPoint[] = []; const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + const blockedElementIds = new Set(excludeElementIds); + if (excludeElementId) { + blockedElementIds.add(excludeElementId); + } for (const track of orderedTracks) { for (const element of track.elements) { - if (element.id === excludeElementId) continue; + if (blockedElementIds.has(element.id)) continue; if (enableElementSnapping) { snapPoints.push( @@ -136,6 +142,7 @@ export function snapElementEdge({ playheadTime, zoomLevel, excludeElementId, + excludeElementIds, snapToStart = true, bookmarks = [], }: { @@ -145,6 +152,7 @@ export function snapElementEdge({ playheadTime: number; zoomLevel: number; excludeElementId?: string; + excludeElementIds?: Set<string>; snapToStart?: boolean; bookmarks?: Array<Bookmark>; }): SnapResult { @@ -152,6 +160,7 @@ export function snapElementEdge({ tracks, playheadTime, excludeElementId, + excludeElementIds, bookmarks, }); |
