summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-18 01:40:18 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-18 01:40:18 +0200
commit011f51051c9750706978d6c89ce84dd15560c335 (patch)
tree2e7b23ebebb798934d6cb6aeace855f31699b633 /apps/web/src/lib/timeline
parentc46d28891fee62ca2d3132da876af6411a41d9da (diff)
downloadopencut-011f51051c9750706978d6c89ce84dd15560c335.tar.gz
feat: clip volume line and timeline waveform improvements
Diffstat (limited to 'apps/web/src/lib/timeline')
-rw-r--r--apps/web/src/lib/timeline/audio-display.ts64
-rw-r--r--apps/web/src/lib/timeline/group-resize/compute-resize.ts97
2 files changed, 92 insertions, 69 deletions
diff --git a/apps/web/src/lib/timeline/audio-display.ts b/apps/web/src/lib/timeline/audio-display.ts
new file mode 100644
index 00000000..9958c472
--- /dev/null
+++ b/apps/web/src/lib/timeline/audio-display.ts
@@ -0,0 +1,64 @@
+import { clampDb } from "./audio-state";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
+
+const SLIDER_CURVE_EXPONENT = 2;
+const MIN_DISPLAY_DB = -40;
+const WAVEFORM_BAR_EXPONENT = 1.5;
+const MIN_LINEAR_GAIN = 10 ** (VOLUME_DB_MIN / 20);
+const MAX_LINEAR_GAIN = 10 ** (VOLUME_DB_MAX / 20);
+const LINEAR_GAIN_RANGE = MAX_LINEAR_GAIN - MIN_LINEAR_GAIN;
+
+function getNormalizedGainFromDb({ db }: { db: number }): number {
+ const clampedDb = clampDb(db);
+ const linearGain = 10 ** (clampedDb / 20);
+ return (linearGain - MIN_LINEAR_GAIN) / LINEAR_GAIN_RANGE;
+}
+
+/**
+ * Maps the clip's volume setting to the line position. The curve is defined in
+ * linear gain space so dragging near 0 dB is precise while mute compresses into
+ * the bottom of the clip.
+ */
+export function getLinePosFromDb({ db }: { db: number }): number {
+ const normalizedGain = Math.max(
+ 0,
+ Math.min(1, getNormalizedGainFromDb({ db })),
+ );
+ const progress = normalizedGain ** (1 / SLIDER_CURVE_EXPONENT);
+ return (1 - progress) * 100;
+}
+
+/**
+ * Inverse of getLinePosFromDb. Converts a drag position back into the clip's
+ * volume setting without depending on the underlying audio content.
+ */
+export function getDbFromLinePos({ percent }: { percent: number }): number {
+ const clampedPercent = Math.max(0, Math.min(100, percent));
+ const progress = 1 - clampedPercent / 100;
+ const normalizedGain = progress ** SLIDER_CURVE_EXPONENT;
+ const linearGain = MIN_LINEAR_GAIN + normalizedGain * LINEAR_GAIN_RANGE;
+ return clampDb(20 * Math.log10(linearGain));
+}
+
+/**
+ * Maps an output amplitude (raw sample amplitude × gain) to a visible waveform
+ * height fraction using a dB scale. Agnostic to whether the input is peak or
+ * RMS — the caller decides which measure feeds this function; the mapping
+ * curve is the same either way.
+ *
+ * The log scale keeps quiet content visible while reserving the top of the
+ * element for amplitudes that approach 0 dBFS.
+ */
+export function getBarFractionFromOutputAmplitude({
+ outputAmplitude,
+}: {
+ outputAmplitude: number;
+}): number {
+ if (outputAmplitude <= 0) return 0;
+ const db = 20 * Math.log10(outputAmplitude);
+ if (db <= MIN_DISPLAY_DB) return 0;
+ return Math.min(
+ 1,
+ ((db - MIN_DISPLAY_DB) / -MIN_DISPLAY_DB) ** WAVEFORM_BAR_EXPONENT,
+ );
+}
diff --git a/apps/web/src/lib/timeline/group-resize/compute-resize.ts b/apps/web/src/lib/timeline/group-resize/compute-resize.ts
index 8d7d9b2d..5730b117 100644
--- a/apps/web/src/lib/timeline/group-resize/compute-resize.ts
+++ b/apps/web/src/lib/timeline/group-resize/compute-resize.ts
@@ -44,14 +44,34 @@ export function computeGroupResize({
? 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(clampedDeltaTime, -0) ? 0 : clampedDeltaTime,
+ deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime,
updates: members.map((member) =>
buildResizeUpdate({
member,
side,
- deltaTime: clampedDeltaTime,
- fps,
+ deltaTime: finalDeltaTime,
}),
),
};
@@ -61,98 +81,37 @@ 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,
+ trimStart: Math.max(0, member.trimStart + sourceDelta),
trimEnd: member.trimEnd,
- startTime:
- roundToFrame({ time: rawStartTime, rate: fps }) ?? rawStartTime,
- duration: nextDuration,
+ startTime: member.startTime + deltaTime,
+ duration: member.duration - deltaTime,
},
};
}
- 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,
+ trimEnd: Math.max(0, member.trimEnd - sourceDelta),
startTime: member.startTime,
- duration: roundToFrame({ time: rawDuration, rate: fps }) ?? rawDuration,
+ duration: member.duration + deltaTime,
},
};
}