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) => ( + + ))} +
+ +
, + 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]); +}