diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/group-resize | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/group-resize')
| -rw-r--r-- | apps/web/src/timeline/group-resize/compute-resize.ts | 249 | ||||
| -rw-r--r-- | apps/web/src/timeline/group-resize/index.ts | 9 | ||||
| -rw-r--r-- | apps/web/src/timeline/group-resize/types.ts | 36 |
3 files changed, 294 insertions, 0 deletions
diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts new file mode 100644 index 00000000..dd542967 --- /dev/null +++ b/apps/web/src/timeline/group-resize/compute-resize.ts @@ -0,0 +1,249 @@ +import { roundToFrame } from "opencut-wasm"; +import { + getSourceSpanAtClipTime, + getTimelineDurationForSourceSpan, +} from "@/retime"; +import { TICKS_PER_SECOND } from "@/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)); + + // Snap the drag delta to a frame exactly once, then derive every patch + // field from that single snapped value. This keeps the invariant + // `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same + // delta is added on one side of the element and removed from the other, + // so the rounding cancels by construction. Per-field rounding (the old + // approach) couldn't preserve this because the individual rounds don't + // compose when `sourceDuration` isn't frame-aligned. + const snappedDeltaTime = + roundToFrame({ time: clampedDeltaTime, rate: fps }) ?? clampedDeltaTime; + // Re-clamp after rounding. Bounds derived from other elements are + // frame-aligned, so this is normally a no-op; at the source-extent limit + // the bound may not be frame-aligned, and honouring the bound takes + // precedence over frame alignment (you can't extend past real content). + const finalDeltaTime = + minimumDeltaTime > maximumDeltaTime + ? minimumDeltaTime + : Math.min( + maximumDeltaTime, + Math.max(minimumDeltaTime, snappedDeltaTime), + ); + + return { + deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime, + updates: members.map((member) => + buildResizeUpdate({ + member, + side, + deltaTime: finalDeltaTime, + }), + ), + }; +} + +function buildResizeUpdate({ + member, + side, + deltaTime, +}: { + member: GroupResizeMember; + side: ResizeSide; + deltaTime: number; +}): GroupResizeUpdate { + const sourceDelta = getSourceDeltaForClipDelta({ + member, + clipDelta: deltaTime, + }); + + if (side === "left") { + return { + trackId: member.trackId, + elementId: member.elementId, + patch: { + trimStart: Math.max(0, member.trimStart + sourceDelta), + trimEnd: member.trimEnd, + startTime: member.startTime + deltaTime, + duration: 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: member.duration + deltaTime, + }, + }; +} + +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/timeline/group-resize/index.ts b/apps/web/src/timeline/group-resize/index.ts new file mode 100644 index 00000000..bc0a7b35 --- /dev/null +++ b/apps/web/src/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/timeline/group-resize/types.ts b/apps/web/src/timeline/group-resize/types.ts new file mode 100644 index 00000000..b07e6fe6 --- /dev/null +++ b/apps/web/src/timeline/group-resize/types.ts @@ -0,0 +1,36 @@ +import type { FrameRate } from "opencut-wasm"; +import type { ElementRef, RetimeConfig } from "@/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; +} |
