summaryrefslogtreecommitdiff
path: root/apps/web/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib')
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts199
-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
-rw-r--r--apps/web/src/lib/timeline/group-resize/compute-resize.ts290
-rw-r--r--apps/web/src/lib/timeline/group-resize/index.ts9
-rw-r--r--apps/web/src/lib/timeline/group-resize/types.ts36
-rw-r--r--apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts7
-rw-r--r--apps/web/src/lib/timeline/placement/overlap.ts8
-rw-r--r--apps/web/src/lib/timeline/snap-utils.ts11
14 files changed, 1501 insertions, 105 deletions
diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts
index c1fa6897..533c85b4 100644
--- a/apps/web/src/lib/commands/timeline/element/move-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts
@@ -3,140 +3,123 @@ import { EditorCore } from "@/core";
import type {
SceneTracks,
TimelineElement,
- TrackType,
TimelineTrack,
} from "@/lib/timeline";
import {
buildEmptyTrack,
validateElementTrackCompatibility,
- enforceMainTrackStart,
} from "@/lib/timeline/placement";
-import {
- findTrackInSceneTracks,
- updateTrackInSceneTracks,
-} from "@/lib/timeline/track-element-update";
+import type {
+ PlannedElementMove,
+ PlannedTrackCreation,
+} from "@/lib/timeline/group-move";
+import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update";
export class MoveElementCommand extends Command {
private savedState: SceneTracks | null = null;
- private readonly sourceTrackId: string;
- private readonly targetTrackId: string;
- private readonly elementId: string;
- private readonly newStartTime: number;
- private readonly createTrack: { type: TrackType; index: number } | undefined;
constructor({
- sourceTrackId,
- targetTrackId,
- elementId,
- newStartTime,
- createTrack,
+ moves,
+ createTracks = [],
}: {
- sourceTrackId: string;
- targetTrackId: string;
- elementId: string;
- newStartTime: number;
- createTrack?: { type: TrackType; index: number };
+ moves: PlannedElementMove[];
+ createTracks?: PlannedTrackCreation[];
}) {
super();
- this.sourceTrackId = sourceTrackId;
- this.targetTrackId = targetTrackId;
- this.elementId = elementId;
- this.newStartTime = newStartTime;
- this.createTrack = createTrack;
+ this.moves = moves;
+ this.createTracks = createTracks;
}
+ private readonly moves: PlannedElementMove[];
+ private readonly createTracks: PlannedTrackCreation[];
+
execute(): CommandResult | undefined {
const editor = EditorCore.getInstance();
this.savedState = editor.scenes.getActiveScene().tracks;
- const sourceTrack = findTrackInSceneTracks({
- tracks: this.savedState,
- trackId: this.sourceTrackId,
- });
- const element = sourceTrack?.elements.find(
- (trackElement) => trackElement.id === this.elementId,
- );
-
- if (!sourceTrack || !element) {
- throw new Error("Source track or element not found");
- }
-
- let targetTrack = findTrackInSceneTracks({
- tracks: this.savedState,
- trackId: this.targetTrackId,
- });
let tracksToUpdate = this.savedState;
- if (!targetTrack && this.createTrack) {
- const newTrack = buildEmptyTrack({
- id: this.targetTrackId,
- type: this.createTrack.type,
- });
+ for (const createTrack of [...this.createTracks].sort(
+ (firstTrack, secondTrack) => firstTrack.index - secondTrack.index,
+ )) {
tracksToUpdate = insertTrackAtDisplayIndex({
- tracks: this.savedState,
- track: newTrack,
- insertIndex: this.createTrack.index,
+ tracks: tracksToUpdate,
+ track: buildEmptyTrack({
+ id: createTrack.id,
+ type: createTrack.type,
+ }),
+ insertIndex: createTrack.index,
});
- targetTrack = newTrack;
}
- if (!targetTrack) {
- throw new Error("Target track not found");
+
+ const movedElementsById = new Map<string, TimelineElement>();
+ for (const move of this.moves) {
+ const sourceTrack = findTrackInSceneTracks({
+ tracks: this.savedState,
+ trackId: move.sourceTrackId,
+ });
+ const sourceElement = sourceTrack?.elements.find(
+ (trackElement) => trackElement.id === move.elementId,
+ );
+ if (!sourceTrack || !sourceElement) {
+ throw new Error("Source track or element not found");
+ }
+
+ const targetTrack = findTrackInSceneTracks({
+ tracks: tracksToUpdate,
+ trackId: move.targetTrackId,
+ });
+ if (!targetTrack) {
+ throw new Error("Target track not found");
+ }
+
+ const validation = validateElementTrackCompatibility({
+ element: sourceElement,
+ track: targetTrack,
+ });
+ if (!validation.isValid) {
+ throw new Error(validation.errorMessage);
+ }
+
+ movedElementsById.set(move.elementId, {
+ ...sourceElement,
+ startTime: move.newStartTime,
+ });
}
- const validation = validateElementTrackCompatibility({
- element,
- track: targetTrack,
- });
+ const movedElementIds = new Set(this.moves.map((move) => move.elementId));
+ const movedElementsByTargetTrackId = new Map<string, TimelineElement[]>();
+ for (const move of this.moves) {
+ const movedElement = movedElementsById.get(move.elementId);
+ if (!movedElement) {
+ continue;
+ }
- if (!validation.isValid) {
- throw new Error(validation.errorMessage);
+ const nextTargetElements =
+ movedElementsByTargetTrackId.get(move.targetTrackId) ?? [];
+ nextTargetElements.push(movedElement);
+ movedElementsByTargetTrackId.set(move.targetTrackId, nextTargetElements);
}
- const adjustedStartTime = enforceMainTrackStart({
+ const updatedTracks = mapSceneTracks({
tracks: tracksToUpdate,
- targetTrackId: this.targetTrackId,
- requestedStartTime: this.newStartTime,
- excludeElementId: this.elementId,
+ update: (track) => ({
+ ...track,
+ elements: [
+ ...track.elements.filter(
+ (element) => !movedElementIds.has(element.id),
+ ),
+ ...(movedElementsByTargetTrackId.get(track.id) ?? []),
+ ],
+ }),
});
- // keyframe times remain clip-local, so moving only changes element startTime.
- const movedElement: TimelineElement = {
- ...element,
- startTime: adjustedStartTime,
- };
-
- const isSameTrack = this.sourceTrackId === this.targetTrackId;
-
- const updatedTracks = isSameTrack
- ? updateTrackInSceneTracks({
- tracks: tracksToUpdate,
- trackId: this.sourceTrackId,
- update: (track) => ({
- ...track,
- elements: track.elements.map((trackElement) =>
- trackElement.id === this.elementId ? movedElement : trackElement,
- ),
- }),
- })
- : updateTrackInSceneTracks({
- tracks: updateTrackInSceneTracks({
- tracks: tracksToUpdate,
- trackId: this.sourceTrackId,
- update: (track) => ({
- ...track,
- elements: track.elements.filter(
- (trackElement) => trackElement.id !== this.elementId,
- ),
- }),
- }),
- trackId: this.targetTrackId,
- update: (track) => ({
- ...track,
- elements: [...track.elements, movedElement],
- }),
- });
-
editor.timeline.updateTracks(updatedTracks);
- return undefined;
+ return {
+ select: this.moves.map(({ elementId, targetTrackId }) => ({
+ trackId: targetTrackId,
+ elementId,
+ })),
+ };
}
undo(): void {
@@ -147,6 +130,20 @@ export class MoveElementCommand extends Command {
}
}
+function mapSceneTracks({
+ tracks,
+ update,
+}: {
+ tracks: SceneTracks;
+ update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack;
+}): SceneTracks {
+ return {
+ overlay: tracks.overlay.map((track) => update(track)),
+ main: update(tracks.main),
+ audio: tracks.audio.map((track) => update(track)),
+ };
+}
+
function insertTrackAtDisplayIndex({
tracks,
track,
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,
});