summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline/group-resize/compute-resize.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-15 05:43:29 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-15 05:43:29 +0200
commitac43a5a73fa905cdac905bc956763b6093d6738e (patch)
tree8d9ff8c3a9ae55bac8860215bec69cc5b6094a1c /apps/web/src/lib/timeline/group-resize/compute-resize.ts
parent6312fc5b078689bc82c52fda9b7f92ee0a21d311 (diff)
downloadopencut-ac43a5a73fa905cdac905bc956763b6093d6738e.tar.gz
feat: move/resize multiple elements
Diffstat (limited to 'apps/web/src/lib/timeline/group-resize/compute-resize.ts')
-rw-r--r--apps/web/src/lib/timeline/group-resize/compute-resize.ts290
1 files changed, 290 insertions, 0 deletions
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
+ );
+}