diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc
index 1822f7a7f6..f5b1617118 100644
--- a/.fallowrc.jsonc
+++ b/.fallowrc.jsonc
@@ -176,6 +176,22 @@
"withLane",
],
},
+ // automationShapes is part of the audio-automation stack: its consumer is
+ // the UI layer that uses shape generators one PR upstack, so a per-PR audit
+ // diffing against the merge base sees these as unused. Consumed for real once
+ // the stack merges; safe to drop this entry then.
+ {
+ "file": "packages/studio/src/player/components/automationShapes.ts",
+ "exports": ["AUTOMATION_SHAPES"],
+ },
+ // automationSimplify is part of the audio-automation stack: its consumer is
+ // the UI layer one PR upstack, so a per-PR audit diffing against the merge
+ // base sees these as unused. Consumed for real once the stack merges; safe
+ // to drop this entry then.
+ {
+ "file": "packages/studio/src/player/components/automationSimplify.ts",
+ "exports": ["simplifyPoints"],
+ },
// propertyPanelAutomation is the shared reader for both panel sections; the
// FX group that consumes these two lands one PR upstack, so a per-PR audit
// against the merge base sees them as unused.
@@ -708,6 +724,11 @@
"packages/parsers/src/gsapParser.ts",
// htmlParser.ts has pre-existing complexity (moved from packages/core).
"packages/parsers/src/htmlParser.ts",
+ // automationSimplify.ts: Ramer–Douglas–Peucker algorithm inherently requires
+ // nested loops and stack-based control flow (12 cyclomatic / 20 cognitive);
+ // this complexity is by design and not refactorable. Consumed by the UI
+ // layer one PR upstack in the audio-automation feature stack.
+ "packages/studio/src/player/components/automationSimplify.ts",
// studio-server files: pre-existing complexity (moved from packages/core/src/studio-api/).
// files.ts: executeGsapMutationRecast/Acorn are CRITICAL; excluded as files.ts
// was already in health.ignore at the old path (packages/core/src/studio-api/routes/files.ts).
diff --git a/packages/studio/src/player/components/AutomationSelectionMenu.tsx b/packages/studio/src/player/components/AutomationSelectionMenu.tsx
new file mode 100644
index 0000000000..4a2bd0d057
--- /dev/null
+++ b/packages/studio/src/player/components/AutomationSelectionMenu.tsx
@@ -0,0 +1,68 @@
+/**
+ * Context menu for a right-click inside an automation time selection: the four
+ * utility shapes, then Simplify. Portal + dismiss handling mirror
+ * TrackGapContextMenu; rows never vanish — an inapplicable Simplify dims with
+ * a reason instead of leaving a shorter menu.
+ */
+import { memo } from "react";
+import { createPortal } from "react-dom";
+import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
+import { AUTOMATION_SHAPES, type AutomationShapeId } from "./automationShapes";
+
+interface AutomationSelectionMenuProps {
+ x: number;
+ y: number;
+ onClose(): void;
+ onInsertShape(shape: AutomationShapeId): void;
+ onSimplify(): void;
+ /** At least three points in the range — fewer has nothing to thin. */
+ canSimplify: boolean;
+}
+
+export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
+ x,
+ y,
+ onClose,
+ onInsertShape,
+ onSimplify,
+ canSimplify,
+}: AutomationSelectionMenuProps) {
+ const menuRef = useContextMenuDismiss(onClose);
+ const row =
+ "block w-full px-2 py-1 text-left text-[11px] text-panel-text-1 hover:bg-panel-bg-3 disabled:opacity-40";
+ return createPortal(
+
+ {AUTOMATION_SHAPES.map((shape) => (
+
{
+ onInsertShape(shape.id);
+ onClose();
+ }}
+ >
+ {shape.label}
+
+ ))}
+
+
{
+ onSimplify();
+ onClose();
+ }}
+ >
+ Simplify
+
+
,
+ document.body,
+ );
+});
diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx
index 1ae4bca663..c84a019201 100644
--- a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx
+++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx
@@ -649,3 +649,40 @@ describe("TimelineAutomationLane range selection", () => {
expect(props.onCommit).toHaveBeenCalled();
});
});
+
+describe("TimelineAutomationLane selection menu", () => {
+ it("right-click inside the selection opens the shape menu", () => {
+ const { container, svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
+ fire(svg, "contextmenu", at(2, 0.5));
+ expect(document.querySelector(".hf-automation-menu")).not.toBeNull();
+ // The menu portals to document.body, outside `container` — dismiss it via
+ // Escape before tearing down, or it leaks into the next test's DOM query.
+ const escape = new Event("keydown", { bubbles: true, cancelable: true });
+ Object.assign(escape, { key: "Escape" });
+ act(() => {
+ document.dispatchEvent(escape);
+ });
+ expect(document.querySelector(".hf-automation-menu")).toBeNull();
+ act(() => container.remove());
+ });
+
+ it("inserting a swell replaces the range and commits once", () => {
+ const { svg, props } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
+ fire(svg, "contextmenu", at(2, 0.5));
+ const swell = Array.from(
+ document.querySelectorAll(".hf-automation-menu button"),
+ ).find((b) => b.textContent === "Swell");
+ expect(swell).toBeTruthy();
+ act(() => swell?.click());
+ expect(props.onCommit).toHaveBeenCalledTimes(1);
+ const points =
+ (props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? [];
+ expect(points.some((p) => p.t === 2 && p.v === 1)).toBe(true); // peak at range.max
+ });
+
+ it("right-click outside the selection does not open it", () => {
+ const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } });
+ fire(svg, "contextmenu", at(3.8, 0.5));
+ expect(document.querySelector(".hf-automation-menu")).toBeNull();
+ });
+});
diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx
index eeaf53895e..835c8111c5 100644
--- a/packages/studio/src/player/components/TimelineAutomationLane.tsx
+++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx
@@ -14,7 +14,14 @@
* same principle the property panel's controls follow.
*/
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type MouseEvent as ReactMouseEvent,
+} from "react";
import {
resolveAutomationRange,
sampleAutomationLane,
@@ -34,7 +41,11 @@ import {
} from "./automationLaneGeometry";
import { useAutomationLaneGestures } from "./useAutomationLaneGestures";
import { AutomationValueInput } from "./AutomationValueInput";
+import { AutomationSelectionMenu } from "./AutomationSelectionMenu";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
+import { generateShape, type AutomationShapeId } from "./automationShapes";
+import { simplifyPoints } from "./automationSimplify";
+import { pointsIn, replaceRange } from "./automationLaneSelection";
import { getTimelineLaneTop } from "./timelineLayout";
import type { TimelineElement } from "../store/playerStore";
import type { UseAutomationLanesResult } from "./useAutomationLanes";
@@ -207,6 +218,44 @@ export function TimelineAutomationLane({
[lane, commitPoints, readOnly],
);
+ /** Client-coordinate position of an open selection menu, or null when closed. */
+ const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
+
+ const insertShape = useCallback(
+ (shape: AutomationShapeId): void => {
+ if (!rangeSelection) return;
+ const inner = generateShape({
+ shape,
+ lane,
+ range,
+ t0: rangeSelection.t0,
+ t1: rangeSelection.t1,
+ });
+ commitPoints(replaceRange({ lane, range, ...rangeSelection, inner }), true);
+ },
+ [rangeSelection, lane, range, commitPoints],
+ );
+
+ const simplifySelection = useCallback((): void => {
+ if (!rangeSelection) return;
+ const inner = simplifyPoints(pointsIn(lane, rangeSelection.t0, rangeSelection.t1), range);
+ commitPoints(replaceRange({ lane, range, ...rangeSelection, inner }), true);
+ }, [rangeSelection, lane, range, commitPoints]);
+
+ // A point's own right-click already stops propagation and still deletes;
+ // this only fires when the press lands on the background inside the
+ // active selection.
+ const onSvgContextMenu = useCallback(
+ (e: ReactMouseEvent): void => {
+ if (readOnly || !rangeSelection) return;
+ const { t } = pointAt(e.clientX, e.clientY);
+ if (t < rangeSelection.t0 || t > rangeSelection.t1) return;
+ e.preventDefault();
+ setMenuAt({ x: e.clientX, y: e.clientY });
+ },
+ [readOnly, rangeSelection, pointAt],
+ );
+
const currentValue =
lane.points.length > 0 && playheadSec !== null
? sampleAutomationLane(lane, playheadSec, range.scale)
@@ -247,6 +296,7 @@ export function TimelineAutomationLane({
onPointerUp={gestures.endDrag}
onPointerCancel={gestures.endDrag}
onDoubleClick={gestures.onDoubleClick}
+ onContextMenu={onSvgContextMenu}
role="group"
aria-label={`${range.label} automation`}
>
@@ -347,6 +397,17 @@ export function TimelineAutomationLane({
{hint}
) : null}
+
+ {menuAt && rangeSelection ? (
+ setMenuAt(null)}
+ onInsertShape={insertShape}
+ onSimplify={simplifySelection}
+ canSimplify={pointsIn(lane, rangeSelection.t0, rangeSelection.t1).length >= 3}
+ />
+ ) : null}
);
}
diff --git a/packages/studio/src/player/components/automationShapes.test.ts b/packages/studio/src/player/components/automationShapes.test.ts
new file mode 100644
index 0000000000..398fd95625
--- /dev/null
+++ b/packages/studio/src/player/components/automationShapes.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+import { generateShape } from "./automationShapes";
+import { resolveAutomationRange, VOLUME_RANGE } from "@hyperframes/core/audio-automation";
+import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
+
+const flat: HfAutomationLane = {
+ target: "volume",
+ points: [
+ { t: 0, v: 0.8 },
+ { t: 6, v: 0.8 },
+ ],
+};
+
+describe("generateShape", () => {
+ it("ramp-up fades in from the floor to the envelope's own value", () => {
+ const pts = generateShape({ shape: "ramp-up", lane: flat, range: VOLUME_RANGE, t0: 1, t1: 3 });
+ expect(pts).toEqual([
+ { t: 1, v: VOLUME_RANGE.min },
+ { t: 3, v: 0.8 },
+ ]);
+ });
+
+ it("ramp-down fades out from the envelope's own value", () => {
+ const pts = generateShape({
+ shape: "ramp-down",
+ lane: flat,
+ range: VOLUME_RANGE,
+ t0: 1,
+ t1: 3,
+ });
+ expect(pts).toEqual([
+ { t: 1, v: 0.8 },
+ { t: 3, v: VOLUME_RANGE.min },
+ ]);
+ });
+
+ it("swell peaks at range max mid-selection, smoothed", () => {
+ const pts = generateShape({ shape: "swell", lane: flat, range: VOLUME_RANGE, t0: 1, t1: 3 });
+ expect(pts).toHaveLength(3);
+ expect(pts[1]).toMatchObject({ t: 2, v: VOLUME_RANGE.max });
+ expect(pts[0]?.curve).toBeDefined(); // eased, not a triangle
+ });
+
+ it("dip ducks to a quarter of the edge value in unit space", () => {
+ const pts = generateShape({ shape: "dip", lane: flat, range: VOLUME_RANGE, t0: 1, t1: 3 });
+ // volume is linear 0..1: unit(0.8) = 0.8, floor = 0.2
+ expect(pts[1]?.v).toBeCloseTo(0.2, 5);
+ });
+
+ it("computes in unit space on a log lane", () => {
+ const range = resolveAutomationRange("fx.n1.frequency", {
+ version: 1,
+ nodes: [{ type: "lowpass", id: "n1", params: {} }],
+ });
+ expect(range?.scale).toBe("log");
+ if (!range) return;
+ const lane: HfAutomationLane = {
+ target: "fx.n1.frequency",
+ points: [
+ { t: 0, v: 2000 },
+ { t: 6, v: 2000 },
+ ],
+ };
+ const pts = generateShape({ shape: "dip", lane, range, t0: 1, t1: 3 });
+ const floor = pts[1]?.v ?? 0;
+ // A quarter of the way up the LOG axis, not 500 Hz.
+ expect(floor).toBeGreaterThan(range.min);
+ expect(floor).toBeLessThan(2000 * 0.25);
+ });
+
+ it("uses the range default when the lane is empty", () => {
+ const empty: HfAutomationLane = { target: "volume", points: [] };
+ const pts = generateShape({
+ shape: "ramp-down",
+ lane: empty,
+ range: VOLUME_RANGE,
+ t0: 1,
+ t1: 3,
+ });
+ expect(pts[0]?.v).toBe(VOLUME_RANGE.default);
+ });
+});
diff --git a/packages/studio/src/player/components/automationShapes.ts b/packages/studio/src/player/components/automationShapes.ts
new file mode 100644
index 0000000000..53904e7181
--- /dev/null
+++ b/packages/studio/src/player/components/automationShapes.ts
@@ -0,0 +1,70 @@
+/**
+ * The utility shapes a video author reaches for: fade in, fade out, swell,
+ * duck. One shape scaled to the selection — this is not a DAW, nobody needs a
+ * tempo-synced LFO. Edge values come from the envelope itself so a shape
+ * splices into whatever is already there; vertical maths runs in unit space so
+ * a log knob (frequency) behaves like the lane that draws it.
+ */
+import {
+ sampleAutomationLane,
+ type AutomationRange,
+ type HfAutomationLane,
+ type HfAutomationPoint,
+} from "@hyperframes/core/audio-automation";
+import { fromUnit, toUnit } from "./automationLaneGeometry";
+
+export type AutomationShapeId = "ramp-up" | "ramp-down" | "swell" | "dip";
+
+export const AUTOMATION_SHAPES: ReadonlyArray<{ id: AutomationShapeId; label: string }> = [
+ { id: "ramp-up", label: "Ramp up" },
+ { id: "ramp-down", label: "Ramp down" },
+ { id: "swell", label: "Swell" },
+ { id: "dip", label: "Dip" },
+];
+
+/** Ease used on the segments entering/leaving a swell or dip midpoint. */
+const SMOOTH = 0.4;
+/** A dip ducks to this fraction of the edge value, in unit space. */
+const DIP_FLOOR = 0.25;
+
+function edgeValue(lane: HfAutomationLane, range: AutomationRange, t: number): number {
+ if (lane.points.length === 0) return range.default ?? (range.min + range.max) / 2;
+ return sampleAutomationLane(lane, t, range.scale);
+}
+
+export function generateShape(input: {
+ shape: AutomationShapeId;
+ lane: HfAutomationLane;
+ range: AutomationRange;
+ t0: number;
+ t1: number;
+}): HfAutomationPoint[] {
+ const { shape, lane, range, t0, t1 } = input;
+ const v0 = edgeValue(lane, range, t0);
+ const v1 = edgeValue(lane, range, t1);
+ const mid = (t0 + t1) / 2;
+ switch (shape) {
+ case "ramp-up":
+ return [
+ { t: t0, v: range.min },
+ { t: t1, v: v1 },
+ ];
+ case "ramp-down":
+ return [
+ { t: t0, v: v0 },
+ { t: t1, v: range.min },
+ ];
+ case "swell":
+ return [
+ { t: t0, v: v0, curve: SMOOTH },
+ { t: mid, v: range.max, curve: -SMOOTH },
+ { t: t1, v: v1 },
+ ];
+ case "dip":
+ return [
+ { t: t0, v: v0, curve: -SMOOTH },
+ { t: mid, v: fromUnit(range, toUnit(range, v0) * DIP_FLOOR), curve: SMOOTH },
+ { t: t1, v: v1 },
+ ];
+ }
+}
diff --git a/packages/studio/src/player/components/automationSimplify.test.ts b/packages/studio/src/player/components/automationSimplify.test.ts
new file mode 100644
index 0000000000..31878b19e0
--- /dev/null
+++ b/packages/studio/src/player/components/automationSimplify.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+import { simplifyPoints } from "./automationSimplify";
+import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
+import { toUnit } from "./automationLaneGeometry";
+import type { HfAutomationPoint } from "@hyperframes/core/audio-automation";
+
+describe("simplifyPoints", () => {
+ it("collapses collinear runs to their endpoints", () => {
+ const line: HfAutomationPoint[] = Array.from({ length: 50 }, (_, i) => ({
+ t: i * 0.1,
+ v: 1 - i * 0.01,
+ }));
+ const out = simplifyPoints(line, VOLUME_RANGE);
+ expect(out).toHaveLength(2);
+ expect(out[0]).toEqual(line[0]);
+ expect(out[out.length - 1]).toEqual(line[line.length - 1]);
+ });
+
+ it("keeps every survivor within epsilon of the original", () => {
+ const wave: HfAutomationPoint[] = Array.from({ length: 100 }, (_, i) => ({
+ t: i * 0.05,
+ v: 0.5 + 0.4 * Math.sin(i * 0.2),
+ }));
+ const out = simplifyPoints(wave, VOLUME_RANGE, 0.02);
+ expect(out.length).toBeLessThan(wave.length / 2);
+ // Every dropped point must sit within epsilon (unit space) of the
+ // simplified polyline — check by linear interpolation between survivors.
+ for (const p of wave) {
+ const rIdx = out.findIndex((q) => q.t >= p.t);
+ const b = out[rIdx] ?? out[out.length - 1];
+ const a = out[rIdx - 1] ?? b;
+ if (!a || !b) continue;
+ const span = b.t - a.t;
+ const f = span > 0 ? (p.t - a.t) / span : 0;
+ const approx =
+ toUnit(VOLUME_RANGE, a.v) + f * (toUnit(VOLUME_RANGE, b.v) - toUnit(VOLUME_RANGE, a.v));
+ expect(Math.abs(approx - toUnit(VOLUME_RANGE, p.v))).toBeLessThanOrEqual(0.021);
+ }
+ });
+
+ it("returns short inputs untouched", () => {
+ const two: HfAutomationPoint[] = [
+ { t: 0, v: 1 },
+ { t: 1, v: 0 },
+ ];
+ expect(simplifyPoints(two, VOLUME_RANGE)).toEqual(two);
+ });
+});
diff --git a/packages/studio/src/player/components/automationSimplify.ts b/packages/studio/src/player/components/automationSimplify.ts
new file mode 100644
index 0000000000..7a9ea99e32
--- /dev/null
+++ b/packages/studio/src/player/components/automationSimplify.ts
@@ -0,0 +1,51 @@
+/**
+ * Ramer–Douglas–Peucker over an envelope's points, deviation measured
+ * VERTICALLY in unit space. Vertical (not perpendicular) because an envelope
+ * is a function of time — what matters is how far the value strays, and it
+ * keeps the metric independent of the time axis' units. Exists for dense
+ * producers: carve output and heavy hand edits.
+ */
+import type { AutomationRange, HfAutomationPoint } from "@hyperframes/core/audio-automation";
+import { toUnit } from "./automationLaneGeometry";
+
+export function simplifyPoints(
+ points: HfAutomationPoint[],
+ range: AutomationRange,
+ epsilon = 0.02,
+): HfAutomationPoint[] {
+ if (points.length <= 2) return points;
+ const keep = new Array(points.length).fill(false);
+ const last = keep.length - 1;
+ keep[0] = true;
+ keep[last] = true;
+
+ const stack: Array<[number, number]> = [[0, last]];
+ while (stack.length > 0) {
+ const seg = stack.pop();
+ if (!seg) break;
+ const [a, b] = seg;
+ const pa = points[a];
+ const pb = points[b];
+ if (!pa || !pb || b - a < 2) continue;
+ const ua = toUnit(range, pa.v);
+ const ub = toUnit(range, pb.v);
+ const span = pb.t - pa.t;
+ let worst = -1;
+ let worstDev = epsilon;
+ for (let i = a + 1; i < b; i += 1) {
+ const p = points[i];
+ if (!p) continue;
+ const f = span > 0 ? (p.t - pa.t) / span : 0;
+ const dev = Math.abs(toUnit(range, p.v) - (ua + f * (ub - ua)));
+ if (dev > worstDev) {
+ worstDev = dev;
+ worst = i;
+ }
+ }
+ if (worst >= 0) {
+ keep[worst] = true;
+ stack.push([a, worst], [worst, b]);
+ }
+ }
+ return points.filter((_, i) => keep[i]);
+}