Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
68 changes: 68 additions & 0 deletions packages/studio/src/player/components/AutomationSelectionMenu.tsx
Original file line number Diff line number Diff line change
@@ -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(
<div
ref={menuRef}
className="hf-automation-menu fixed z-50 min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
style={{ left: x, top: y }}
>
{AUTOMATION_SHAPES.map((shape) => (
<button
key={shape.id}
type="button"
className={row}
onClick={() => {
onInsertShape(shape.id);
onClose();
}}
>
{shape.label}
</button>
))}
<div className="my-1 border-t border-panel-border-input" />
<button
type="button"
className={row}
disabled={!canSimplify}
title={canSimplify ? undefined : "Fewer than three points in the selection"}
onClick={() => {
onSimplify();
onClose();
}}
>
Simplify
</button>
</div>,
document.body,
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>(".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();
});
});
63 changes: 62 additions & 1 deletion packages/studio/src/player/components/TimelineAutomationLane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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<SVGSVGElement>): 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)
Expand Down Expand Up @@ -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`}
>
Expand Down Expand Up @@ -347,6 +397,17 @@ export function TimelineAutomationLane({
{hint}
</div>
) : null}

{menuAt && rangeSelection ? (
<AutomationSelectionMenu
x={menuAt.x}
y={menuAt.y}
onClose={() => setMenuAt(null)}
onInsertShape={insertShape}
onSimplify={simplifySelection}
canSimplify={pointsIn(lane, rangeSelection.t0, rangeSelection.t1).length >= 3}
/>
) : null}
</div>
);
}
Expand Down
82 changes: 82 additions & 0 deletions packages/studio/src/player/components/automationShapes.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
70 changes: 70 additions & 0 deletions packages/studio/src/player/components/automationShapes.ts
Original file line number Diff line number Diff line change
@@ -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 },
];
}
}
Loading
Loading