diff --git a/packages/studio/src/hooks/domSelectionTimelineMirror.ts b/packages/studio/src/hooks/domSelectionTimelineMirror.ts index f67577925a..27dcdc2b15 100644 --- a/packages/studio/src/hooks/domSelectionTimelineMirror.ts +++ b/packages/studio/src/hooks/domSelectionTimelineMirror.ts @@ -1,4 +1,5 @@ import type { SelectElementOptions, TimelineElement } from "../player"; +import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups"; import { findMatchingTimelineElementId, findTimelineIdByAncestor } from "../utils/studioHelpers"; import type { DomEditSelection } from "../components/editor/domEditing"; import { logSelect } from "../utils/selectDebug"; @@ -60,9 +61,17 @@ export function announceTimelineSelection( anchor, anchorPublished: anchor != null && publishedMembers.has(anchor), }); - // A canvas target can be editable without owning a timeline row. Preserve that - // canvas-only selection when the timeline has nothing truthful to represent. - if (!timelineAnchor) return; + // A canvas target can be editable without owning a timeline row. Most such + // targets live inside a clip and must not erase its timeline context. A mixer + // bus is different: it is itself the editing target, so its title replaces any + // selected clips even though the bus has no clip row of its own. + if (!timelineAnchor) { + if (primary.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) { + setTimelineSelectionSet(new Set()); + setSelectedTimelineElementId(null); + } + return; + } // A late async primary that already belongs to the live set must preserve the // group. A fresh single click does not belong to it, so publish the singleton // first; otherwise `preserveSet` clears the set and sync wipes the canvas. diff --git a/packages/studio/src/hooks/useDomSelectionSelectionGuards.test.ts b/packages/studio/src/hooks/useDomSelectionSelectionGuards.test.ts index ca758b4945..98d211440e 100644 --- a/packages/studio/src/hooks/useDomSelectionSelectionGuards.test.ts +++ b/packages/studio/src/hooks/useDomSelectionSelectionGuards.test.ts @@ -130,6 +130,121 @@ describe("useDomSelection — Variables tab preservation", () => { }); }); +describe("useDomSelection — canvas-only targets replace timeline clips", () => { + beforeEach(() => { + deferreds.clear(); + usePlayerStore.getState().clearSelection(); + }); + afterEach(() => { + deferreds.clear(); + usePlayerStore.getState().clearSelection(); + }); + + it("deselects every clip when an audio bus is selected", () => { + const store = usePlayerStore.getState(); + store.setSelectedElementId("voice-1"); + store.setSelectedElementIds(new Set(["voice-1", "voice-2"])); + + const bus = document.createElement("hf-audio-group"); + bus.id = "voiceover"; + const harness = renderHarness({ + rightPanelTab: "design", + setRightPanelTab: vi.fn(), + iframe: null, + timelineElements: [ + { id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 }, + { id: "voice-2", tag: "audio", start: 1, duration: 1, track: 1 }, + ], + setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId, + setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds, + }); + + act(() => harness.current().applyDomSelection(makeSelection("Voiceover", bus))); + + expect(harness.current().domEditSelection?.id).toBe("voiceover"); + expect(usePlayerStore.getState().selectedElementId).toBeNull(); + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set()); + harness.cleanup(); + }); + + it("lets a bus supersede a clip selection that is still resolving", async () => { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const doc = iframe.contentDocument!; + const clipNode = doc.createElement("audio"); + clipNode.id = "voice-1"; + const busNode = doc.createElement("hf-audio-group"); + busNode.id = "voiceover"; + doc.body.append(clipNode, busNode); + + const clip: TimelineElement = { + id: "voice-1", + domId: "voice-1", + tag: "audio", + start: 0, + duration: 1, + track: 0, + }; + const bus: TimelineElement = { + id: "voiceover", + domId: "voiceover", + tag: "audio", + start: 0, + duration: 10, + track: -0.5, + }; + const harness = renderHarness({ + rightPanelTab: "design", + setRightPanelTab: vi.fn(), + iframe, + // The bus is a synthetic row target, not a clip in the store. + timelineElements: [clip], + setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId, + setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds, + }); + + let pendingClip = Promise.resolve(); + let pendingBus = Promise.resolve(); + act(() => { + pendingClip = harness.current().handleTimelineElementSelect(clip); + pendingBus = harness.current().handleTimelineElementSelect(bus); + }); + await act(async () => { + deferreds.get("voiceover")?.resolve(); + await pendingBus; + deferreds.get("voice-1")?.resolve(); + await pendingClip; + }); + + expect(harness.current().domEditSelection?.id).toBe("voiceover"); + expect(usePlayerStore.getState().selectedElementId).toBeNull(); + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set()); + harness.cleanup(); + iframe.remove(); + }); + + it("preserves clip context for a non-bus canvas-only selection", () => { + const store = usePlayerStore.getState(); + store.setSelectedElementId("voice-1"); + const decoration = document.createElement("div"); + decoration.id = "decoration"; + const harness = renderHarness({ + rightPanelTab: "design", + setRightPanelTab: vi.fn(), + iframe: null, + timelineElements: [{ id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 }], + setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId, + setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds, + }); + + act(() => harness.current().applyDomSelection(makeSelection("Decoration", decoration))); + + expect(usePlayerStore.getState().selectedElementId).toBe("voice-1"); + expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["voice-1"])); + harness.cleanup(); + }); +}); + describe("useDomSelection — timeline-select race guard", () => { beforeEach(() => deferreds.clear()); afterEach(() => deferreds.clear()); diff --git a/packages/studio/src/player/components/AutomationEnvelopePaths.tsx b/packages/studio/src/player/components/AutomationEnvelopePaths.tsx new file mode 100644 index 0000000000..a7284d5deb --- /dev/null +++ b/packages/studio/src/player/components/AutomationEnvelopePaths.tsx @@ -0,0 +1,54 @@ +/** The base automation envelope plus the heavier segment grab affordance. */ + +import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation"; +import { envelopeSegmentPath } from "./automationLaneGeometry"; + +interface AutomationEnvelopePathsProps { + path: string; + lane: HfAutomationLane; + range: AutomationRange; + accentColor: string; + activeSegment: number | null; + xOf(t: number): number; + yOf(v: number): number; +} + +export function AutomationEnvelopePaths({ + path, + lane, + range, + accentColor, + activeSegment, + xOf, + yOf, +}: AutomationEnvelopePathsProps) { + const activePath = + activeSegment === null + ? null + : envelopeSegmentPath({ lane, range, index: activeSegment, xOf, yOf }); + + return ( + <> + + {activePath ? ( + + ) : null} + + ); +} diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx index d1b9849c37..a869d4a496 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx @@ -10,6 +10,7 @@ import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain"; import { normalizeAutomation, resolveAutomationRange, + sampleAutomationLane, VOLUME_RANGE, type HfAutomation, } from "@hyperframes/core/audio-automation"; @@ -668,6 +669,118 @@ describe("TimelineAutomationLane point visibility", () => { }); }); +describe("TimelineAutomationLane segment drag", () => { + const four: HfAutomation = { + version: 1, + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 1 }, + { t: 1, v: 0.8 }, + { t: 2, v: 0.6 }, + { t: 3.5, v: 0.2 }, + ], + }, + ], + }; + + const previewedPoints = (props: { onPreview: ReturnType }) => + props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as { + t: number; + v: number; + viaX?: number; + viaY?: number; + }[]; + + it("thickens the segment and offers a grab cursor only within its hit proximity", () => { + const { container, svg } = mount(ramp); + const envelope = container.querySelector("[data-automation-envelope]"); + expect(envelope?.getAttribute("stroke-width")).toBe("1.5"); + + fire(svg, "pointermove", at(2, 0.5)); + const active = container.querySelector("[data-automation-segment-active]"); + expect(active).not.toBeNull(); + expect(active?.getAttribute("stroke-width")).toBe("3"); + expect(svg.style.cursor).toBe("grab"); + + // Same time span, but far enough above the drawn ramp to be background. + fire(svg, "pointermove", at(2, 0.9)); + expect(container.querySelector("[data-automation-segment-active]")).toBeNull(); + expect(svg.style.cursor).toBe("crosshair"); + }); + + it("moves both segment endpoints by the same time and value delta", () => { + const { svg, props } = mount(four); + // Midpoint of the segment from (1, .8) to (2, .6). + fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 }); + fire(svg, "pointermove", { ...at(2, 0.5), buttons: 1 }); + + const points = previewedPoints(props); + expect(points[0]).toEqual({ t: 0, v: 1 }); + expect(points[1]!.t).toBeCloseTo(1.5, 2); + expect(points[2]!.t).toBeCloseTo(2.5, 2); + expect(points[1]!.v).toBeCloseTo(0.6, 2); + expect(points[2]!.v).toBeCloseTo(0.4, 2); + expect(points[3]).toEqual({ t: 3.5, v: 0.2 }); + }); + + it("treats a press outside the line's proximity as a background range drag", () => { + const onRangeSelect = vi.fn(); + const { svg, props } = mount(four, { onRangeSelect }); + fire(svg, "pointerdown", { ...at(1.5, 0.95), buttons: 1 }); + fire(svg, "pointermove", { ...at(2.5, 0.95), buttons: 1 }); + expect(onRangeSelect).toHaveBeenCalled(); + expect(props.onPreview).not.toHaveBeenCalled(); + }); + + it("preserves the segment's curve while translating its endpoints", () => { + const curved: HfAutomation = { + version: 1, + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 1 }, + { t: 1, v: 0.8, viaX: 0.4, viaY: 0.7 }, + { t: 2, v: 0.6 }, + { t: 3.5, v: 0.2 }, + ], + }, + ], + }; + const { svg, props } = mount(curved); + const lineValue = sampleAutomationLane(curved.lanes[0]!, 1.7, "linear"); + fire(svg, "pointerdown", { ...at(1.7, lineValue), buttons: 1 }); + fire(svg, "pointermove", { ...at(2.1, lineValue - 0.1), buttons: 1 }); + const points = previewedPoints(props); + expect(points[1]?.viaX).toBe(0.4); + expect(points[1]?.viaY).toBe(0.7); + }); + + it("stops both endpoints together before the next breakpoint", () => { + const { svg, props } = mount(four); + fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 }); + fire(svg, "pointermove", { ...at(4, 0.7), buttons: 1, altKey: true }); + const points = previewedPoints(props); + expect(points[2]!.t).toBeLessThan(points[3]!.t); + expect(points[3]!.t - points[2]!.t).toBeCloseTo(0.001, 4); + expect(points[2]!.t - points[1]!.t).toBeCloseTo(1, 4); + }); + + it("previews every move and persists the segment once on release", () => { + const { svg, props } = mount(four); + fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 }); + for (const t of [1.7, 1.9, 2.1]) { + fire(svg, "pointermove", { ...at(t, 0.6), buttons: 1 }); + } + expect(props.onPreview).toHaveBeenCalledTimes(3); + expect(props.onCommit).not.toHaveBeenCalled(); + fire(svg, "pointerup", { ...at(2.1, 0.6), buttons: 0 }); + expect(props.onCommit).toHaveBeenCalledTimes(1); + }); +}); + /** `mount`, plus the re-render a real store update causes — the persisted * automation and the new selection coming back down as props. */ const mountRerenderable = (automation: HfAutomation, over: Record = {}) => { diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx index eb8dfa2c7a..2c3570fbe7 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx @@ -1,21 +1,15 @@ /** * Breakpoint automation over an audio clip, edited the way a DAW edits it: * double-click the line to add a point, drag one to shape it, right-click or - * Shift+click a point to remove it, Alt-drag the line between two points to bend - * it, and double-click a point to type an exact value. + * Shift+click a point to remove it, drag a line segment to move both endpoints, + * Alt-drag the line to bend it, and double-click a point to type an exact value. * - * Modifiers follow Ableton's, because that is the muscle memory an automation - * lane inherits: Shift locks a drag to one axis and fines the value down, Alt - * over a segment curves it, and Alt during a point drag ignores the grid. + * Ableton-style modifiers apply: Shift locks/fines a drag, while Alt bends a + * segment or ignores the grid during a point drag. Background drags select a + * set that can be moved, deleted, or shaped together. * - * Drag the background to draw a selection box around a set of breakpoints, then - * Delete to remove them, drag any one of them to move the whole set, or - * right-click inside the box for shapes over its span. - * - * The lane knows nothing about any particular effect. Which parameters it can - * offer, their ranges, units and whether they read logarithmically all come - * from the FX registry, so an effect gained upstream needs no change here — the - * same principle the property panel's controls follow. + * Effect parameters, ranges, units, and scaling come from the FX registry, so + * an upstream effect needs no lane-specific code here. */ import { @@ -42,6 +36,7 @@ import { generateShape, type AutomationShapeId } from "./automationShapes"; import { simplifyPoints } from "./automationSimplify"; import { pointInSelection, pointsIn, replaceRange } from "./automationLaneSelection"; import { defaultTimelineTheme } from "./timelineTheme"; +import { AutomationEnvelopePaths } from "./AutomationEnvelopePaths"; /** * Drawn radius of a breakpoint. @@ -102,7 +97,7 @@ function pointCircleStyle( function laneTitle(readOnly: boolean | undefined): string { return readOnly ? "Drag a box to select points, which also selects this clip; then double-click to add a point" - : "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid."; + : "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag a line segment to move both endpoints. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid."; } /** @@ -155,13 +150,19 @@ function pointHandleOpacity(args: { return !args.readOnly && args.hovered ? 1 : 0; } -function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string { +function laneCursor( + readOnly: boolean | undefined, + dragging: boolean, + stretching: boolean, + segmentHovering: boolean, +): string { // A stretch handle wins over everything it might also sit above: the handle is // a few px wide and always overlaps whatever is under the selection edge, so // any other cursor there would advertise a gesture the press will not start. if (stretching) return "col-resize"; if (readOnly) return "pointer"; - return dragging ? "grabbing" : "crosshair"; + if (dragging) return "grabbing"; + return segmentHovering ? "grab" : "crosshair"; } export interface TimelineAutomationLaneProps { @@ -318,7 +319,16 @@ export function TimelineAutomationLane({ duration, rangeSelection, }); - const { dragIndex, curveIndex, edgeDrag, edgeHover, hint, editing } = gestures; + const { + dragIndex, + curveIndex, + segmentDragIndex, + segmentHoverIndex, + edgeDrag, + edgeHover, + hint, + editing, + } = gestures; const removeAt = useCallback( (index: number): void => { @@ -426,8 +436,9 @@ export function TimelineAutomationLane({ height: h, cursor: laneCursor( readOnly, - dragIndex !== null || curveIndex !== null, + dragIndex !== null || curveIndex !== null || segmentDragIndex !== null, edgeDrag !== null || edgeHover, + segmentHoverIndex !== null, ), opacity: readOnly ? 0.55 : 1, touchAction: "none", @@ -435,7 +446,10 @@ export function TimelineAutomationLane({ width={widthPx + PAD_X * 2} height={h} onPointerEnter={() => setHovered(true)} - onPointerLeave={() => setHovered(false)} + onPointerLeave={() => { + setHovered(false); + gestures.onPointerLeave(); + }} onPointerDown={gestures.onPointerDown} onPointerMove={gestures.onPointerMove} onPointerUp={gestures.endDrag} @@ -478,12 +492,14 @@ export function TimelineAutomationLane({ pointerEvents="none" /> ) : null} - {lane.points.map((p, i) => { // Endpoint-inclusive, the same rule Delete uses, so what looks caught by diff --git a/packages/studio/src/player/components/TimelineGroupLaneLabels.tsx b/packages/studio/src/player/components/TimelineGroupLaneLabels.tsx index 6eb77512e0..2a4dc51008 100644 --- a/packages/studio/src/player/components/TimelineGroupLaneLabels.tsx +++ b/packages/studio/src/player/components/TimelineGroupLaneLabels.tsx @@ -25,6 +25,7 @@ export function TimelineGroupLaneLabels({ columnWidth, gutterBackground, accentColor, + onReveal, }: { /** The group wearing a clip's shape — see `groupAutomationElement`. */ groupElement: TimelineElement; @@ -34,6 +35,8 @@ export function TimelineGroupLaneLabels({ columnWidth: number; gutterBackground: string; accentColor: string; + /** Select the bus and reveal this lane's exact parameter in its rack. */ + onReveal?: (target: string) => void; }) { // The LIVE playhead, not the row's `currentTime` prop — that one only moves // on seek, so the readout sat frozen while the curve was audibly working, @@ -55,10 +58,13 @@ export function TimelineGroupLaneLabels({ // clip-local rebase here — unlike a clip's lane. const value = sampleAutomationLane(lane, currentTime); return ( -
event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + onReveal?.(lane.target); + }} >
+ ); })} diff --git a/packages/studio/src/player/components/TimelineGroupRow.test.tsx b/packages/studio/src/player/components/TimelineGroupRow.test.tsx index 7ecc34f84c..e72d54ba51 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.test.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.test.tsx @@ -6,14 +6,23 @@ import { TimelineGroupRow } from "./TimelineGroupRow"; import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; import { defaultTimelineTheme } from "./timelineTheme"; import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; -import type { TimelineElement } from "../store/playerStore"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; vi.mock("../../telemetry/canary", () => ({ isCanaryEnabled: () => true })); +const domEditMocks = vi.hoisted(() => ({ + handleTimelineElementSelect: vi.fn(async () => undefined), +})); +vi.mock("../../contexts/DomEditContext", () => ({ + useDomEditSelectionContextOptional: () => null, + useDomEditActionsContextOptional: () => domEditMocks, +})); afterEach(() => { document.body.innerHTML = ""; + domEditMocks.handleTimelineElementSelect.mockClear(); + usePlayerStore.setState({ revealedAudioFxTarget: null }); }); const member = (id: string, track: number): TimelineElement => ({ @@ -36,7 +45,10 @@ const GROUP: TimelineTrackGroupInfo = { hidden: false, }; -function renderRow(overrides: Partial = {}) { +function renderRow( + overrides: Partial = {}, + expandedLaneOwnerIds = new Set(), +) { const onSetAudioGroupAttributeQuiet = vi.fn(); const onSetElementAttributeQuiet = vi.fn(); const host = document.createElement("div"); @@ -55,10 +67,31 @@ function renderRow(overrides: Partial = {}) { contentOrigin={232} theme={defaultTimelineTheme} collapsedGroupIds={new Set()} - expandedLaneOwnerIds={new Set()} + expandedLaneOwnerIds={expandedLaneOwnerIds} toggleGroupExpanded={vi.fn()} toggleLaneOwnerExpanded={vi.fn()} - lanes={{ bind: () => ({ lanes: [] }) } as never} + lanes={ + { + bind: (element: TimelineElement) => { + const automation = element.automation + ? JSON.parse(element.automation) + : { version: 1, lanes: [] }; + return { + automation, + lanes: automation.lanes, + chain: element.fxChain ? JSON.parse(element.fxChain) : null, + onPreview: vi.fn(), + onCommit: vi.fn(), + onSelect: vi.fn(), + readOnly: true, + commitTargetKey: null, + selection: null, + onRangeSelect: vi.fn(), + onRangeClear: vi.fn(), + }; + }, + } as never + } pps={10} currentTime={0} compositionDuration={60} @@ -72,6 +105,49 @@ function renderRow(overrides: Partial = {}) { } describe("TimelineGroupRow", () => { + it("routes the group title through the guarded selection path", () => { + const { host } = renderRow(); + const title = host.querySelector( + 'button[aria-label="Open Voiceover effects"]', + ); + + act(() => title?.click()); + + expect(domEditMocks.handleTimelineElementSelect).toHaveBeenCalledWith( + expect.objectContaining({ id: "voiceover", domId: "voiceover" }), + ); + }); + + it("opens a group automation lane on its exact rack parameter", async () => { + const { host } = renderRow( + { + fxChain: JSON.stringify({ + version: 1, + nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }], + }), + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }], + }), + }, + new Set(["voiceover"]), + ); + const laneTitle = host.querySelector('[data-group-lane-label="fx.p1.gain"]'); + + await act(async () => { + laneTitle?.click(); + await Promise.resolve(); + }); + + expect(domEditMocks.handleTimelineElementSelect).toHaveBeenCalledWith( + expect.objectContaining({ id: "voiceover", domId: "voiceover" }), + ); + expect(usePlayerStore.getState().revealedAudioFxTarget).toMatchObject({ + elementKey: "voiceover", + automationTarget: "fx.p1.gain", + }); + }); + // C1 names this as the step's own definition of done: "opening the popover on // a GROUP and applying a preset results in exactly ONE `data-fx-chain` write, // on the group element, and zero writes on members". A group IS a bus — a diff --git a/packages/studio/src/player/components/TimelineGroupRow.tsx b/packages/studio/src/player/components/TimelineGroupRow.tsx index b41f982d14..427857bedd 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.tsx @@ -17,6 +17,7 @@ import type { UseAutomationLanesResult } from "./useAutomationLanes"; import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; +import { usePlayerStore } from "../store/playerStore"; /** Accent rail on a group-owned lane — the same green the member rail uses, so * "this belongs to the group" reads the same in both places (groups doc §5). */ @@ -92,19 +93,27 @@ export function TimelineGroupRow({ const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = useTimelineEditContextOptional(); const domEditActions = useDomEditActionsContextOptional(); + const revealAudioFx = usePlayerStore((state) => state.setRevealedAudioFxTarget); const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => { const value = next.nodes.length ? serializeAudioFxChain(next) : null; if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value); else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset"); }; - const openGroupFxRack = () => { - const target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById( - group.id, + const openGroupFxRack = (automationTarget?: string) => { + // Use the guarded timeline-selection path even though the bus is synthetic: + // it invalidates an older clip selection that may still be resolving. The + // old direct build/apply path let that late clip reclaim the rack. + const selection = domEditActions?.handleTimelineElementSelect(groupElement); + if (!selection || !automationTarget) return; + // Bus selection clears the clip selection, which intentionally retires any + // old reveal request. Publish this one afterwards so the newly mounted bus + // rack can consume it rather than losing it during that clear. + void selection.then(() => + revealAudioFx({ + elementKey: group.id, + automationTarget, + }), ); - if (!target) return; - void domEditActions - ?.buildDomSelectionFromTarget(target) - .then((selection) => selection && domEditActions.applyDomSelection(selection)); }; return ( writeGroupFxChain(next, false)} onFxChainPreview={(next) => writeGroupFxChain(next, true)} auditionSpans={memberElements} - onOpenFxRack={openGroupFxRack} + onOpenFxRack={() => openGroupFxRack()} // Same width as every other row's header. The group row needs a real // label column, but it gets one by turning `labelMode` on for the whole // timeline (see Timeline.tsx) rather than by overhanging alone — an @@ -172,6 +181,7 @@ export function TimelineGroupRow({ columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin} gutterBackground={theme.gutterBackground} accentColor={GROUP_LANE_ACCENT} + onReveal={openGroupFxRack} /> )} diff --git a/packages/studio/src/player/components/automationLaneDragMath.ts b/packages/studio/src/player/components/automationLaneDragMath.ts index 79b029a66d..394ac1c702 100644 --- a/packages/studio/src/player/components/automationLaneDragMath.ts +++ b/packages/studio/src/player/components/automationLaneDragMath.ts @@ -59,6 +59,34 @@ export function armGroupDrag( }; } +/** + * Snapshot the two endpoints owned by a segment drag. + * + * The pointer itself is the anchor: a line can be grabbed anywhere along its + * span, and the segment must not jump so either endpoint takes the pointer's + * place when the gesture starts. + */ +export function armSegmentDrag( + lane: HfAutomationLane, + index: number, + anchor: { t: number; v: number }, +): GroupDragSnapshot | null { + const a = lane.points[index]; + const b = lane.points[index + 1]; + if (!a || !b) return null; + return { + points: lane.points.map((p) => ({ ...p })), + indices: [index, index + 1], + anchor: { ...anchor }, + selection: { + t0: a.t, + t1: b.t, + v0: Math.min(a.v, b.v), + v1: Math.max(a.v, b.v), + }, + }; +} + export interface GroupMoveResult { points: HfAutomationLane["points"]; selection: AutomationSelectionBox; diff --git a/packages/studio/src/player/components/automationLaneGeometry.ts b/packages/studio/src/player/components/automationLaneGeometry.ts index ef9966f2b9..c1dd990737 100644 --- a/packages/studio/src/player/components/automationLaneGeometry.ts +++ b/packages/studio/src/player/components/automationLaneGeometry.ts @@ -35,6 +35,8 @@ export const POINT_MERGE_SEC = 0.02; export const MIN_POINT_GAP_SEC = 0.001; /** Hit radius for grabbing a point, in px. */ export const GRAB_PX = 7; +/** Distance from the drawn envelope that offers a segment drag, in px. */ +export const SEGMENT_GRAB_PX = 5; /** Samples used to draw a segment the eye should see as curved. */ export const DRAW_SAMPLES = 64; /** @@ -222,6 +224,29 @@ export function snapLaneTime(t: number, targets: readonly number[], thresholdSec return best; } +/** Draw commands from one breakpoint to the next, sampled when it is curved. */ +function segmentLineCommands(input: { + lane: HfAutomationLane; + range: AutomationRange; + index: number; + xOf(t: number): number; + yOf(v: number): number; +}): string[] { + const { lane, range, index, xOf, yOf } = input; + const a = lane.points[index]; + const b = lane.points[index + 1]; + if (!a || !b) return []; + // A via point bends the segment with no `curve` of its own, so the + // straight-line shortcut has to rule out both. + if (!a.curve && a.viaX === undefined && range.scale === "linear") { + return [`L ${xOf(b.t)} ${yOf(b.v)}`]; + } + return Array.from({ length: DRAW_SAMPLES }, (_, sample) => { + const t = a.t + ((b.t - a.t) * (sample + 1)) / DRAW_SAMPLES; + return `L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`; + }); +} + /** * The svg path for one lane's envelope. * @@ -247,24 +272,38 @@ export function envelopePath(input: { } const pts = [`M ${PAD_X} ${yOf(first.v)}`, `L ${xOf(first.t)} ${yOf(first.v)}`]; for (let i = 0; i + 1 < lane.points.length; i += 1) { - const a = lane.points[i]; - const b = lane.points[i + 1]; - if (!a || !b) continue; - // A via point bends the segment with no `curve` of its own, so the - // straight-line shortcut has to rule out both. - if (!a.curve && a.viaX === undefined && range.scale === "linear") { - pts.push(`L ${xOf(b.t)} ${yOf(b.v)}`); - continue; - } - for (let k = 1; k <= DRAW_SAMPLES; k += 1) { - const t = a.t + ((b.t - a.t) * k) / DRAW_SAMPLES; - pts.push(`L ${xOf(t)} ${yOf(sampleAutomationLane(lane, t, range.scale))}`); - } + pts.push(...segmentLineCommands({ lane, range, index: i, xOf, yOf })); } pts.push(`L ${PAD_X + widthPx} ${yOf(last.v)}`); return pts.join(" "); } +/** + * The visible path for one segment, without the lane's constant extensions. + * + * Used for the hover/drag affordance: only the segment under the pointer grows + * heavier, rather than making the entire envelope look selected. It samples by + * the same rule as `envelopePath`, so a curved or logarithmic segment's hover + * stroke sits exactly on the line the audio model draws. + */ +export function envelopeSegmentPath(input: { + lane: HfAutomationLane; + range: AutomationRange; + index: number; + xOf(t: number): number; + yOf(v: number): number; +}): string | null { + const { lane, range, index, xOf, yOf } = input; + const a = lane.points[index]; + const b = lane.points[index + 1]; + if (!a || !b) return null; + const pts = [ + `M ${xOf(a.t)} ${yOf(a.v)}`, + ...segmentLineCommands({ lane, range, index, xOf, yOf }), + ]; + return pts.join(" "); +} + export function laneFor(automation: HfAutomation, target: string): HfAutomationLane { return automation.lanes.find((l) => l.target === target) ?? { target, points: [] }; } diff --git a/packages/studio/src/player/components/useAutomationLaneGestures.ts b/packages/studio/src/player/components/useAutomationLaneGestures.ts index de166cf5e9..f9bd6cdf38 100644 --- a/packages/studio/src/player/components/useAutomationLaneGestures.ts +++ b/packages/studio/src/player/components/useAutomationLaneGestures.ts @@ -25,6 +25,7 @@ import { type GroupDragSnapshot, type ShiftAxis, } from "./automationLaneDragMath"; +import { useAutomationSegmentDrag } from "./useAutomationSegmentDrag"; /** How far a press may travel and still count as a click rather than a drag. */ const CLICK_SLOP_PX = 3; @@ -63,10 +64,16 @@ export interface UseAutomationLaneGesturesResult { dragIndex: number | null; /** Segment being bent, identified by the point that owns its curve. */ curveIndex: number | null; + /** Segment whose two endpoints are being translated together. */ + segmentDragIndex: number | null; + /** Segment close enough to the pointer to offer that translation. */ + segmentHoverIndex: number | null; /** Value readout to show while a gesture is live. */ hint: string | null; hitIndex(clientX: number, clientY: number): number | null; segmentIndex(clientX: number, clientY: number): number | null; + /** Clear hover feedback when the pointer leaves the lane. */ + onPointerLeave(): void; onPointerDown(e: ReactPointerEvent): void; onPointerMove(e: ReactPointerEvent): void; /** Edge being stretched, for the cursor. Null when no stretch is live. */ @@ -227,16 +234,37 @@ export function useAutomationLaneGestures({ [lane, pointAt], ); - /** What a press starts: moving a point, or — with Alt on the line — bending it. */ + const segmentDrag = useAutomationSegmentDrag({ + getBox, + lane, + range, + pointAt, + xOf, + yOf, + segmentIndex, + commitPoints, + duration, + snapTimes, + readOnly, + onHint: setHint, + }); + + /** What a press starts: point move, segment move, or Alt segment bend. */ const gestureAt = useCallback( - (e: ReactPointerEvent): { curve: boolean; index: number } | null => { + ( + e: ReactPointerEvent, + ): { kind: "point" | "segment" | "curve"; index: number } | null => { const index = hitIndex(e.clientX, e.clientY); - if (index !== null) return { curve: false, index }; - if (!e.altKey) return null; - const segment = segmentIndex(e.clientX, e.clientY); - return segment === null ? null : { curve: true, index: segment }; + if (index !== null) return { kind: "point", index }; + // Alt is an explicit bend gesture and retains its span-wide hit target. + // The unmodified translation is offered only close to the drawn line. + const segment = e.altKey + ? segmentIndex(e.clientX, e.clientY) + : segmentDrag.hitIndex(e.clientX, e.clientY); + if (segment === null) return null; + return { kind: e.altKey ? "curve" : "segment", index: segment }; }, - [hitIndex, segmentIndex], + [hitIndex, segmentDrag, segmentIndex], ); const onPointerDown = useCallback( @@ -274,10 +302,15 @@ export function useAutomationLaneGestures({ } e.preventDefault(); capturePointer(e); - if (gesture.curve) { + segmentDrag.clearHover(); + if (gesture.kind === "curve") { setCurveIndex(gesture.index); return; } + if (gesture.kind === "segment") { + segmentDrag.arm(gesture.index, e.clientX, e.clientY); + return; + } dragOrigin.current = originOf(lane.points[gesture.index]); // Pressing one of a selected set drags the whole set. Pressing a point // outside the selection is an ordinary single-point drag, selection or no. @@ -299,6 +332,7 @@ export function useAutomationLaneGestures({ // selecting a range read the previous selection, or none. rangeSelection, stretch, + segmentDrag, ], ); @@ -394,6 +428,20 @@ export function useAutomationLaneGestures({ [dragIndex, duration, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf, moveGroup], ); + /** Route a live point, bend, or segment gesture after global gestures stand down. */ + const moveLiveGesture = useCallback( + (e: ReactPointerEvent): void => { + const from = pressAt.current; + if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) { + pressTravelled.current = true; + } + if (curveIndex !== null) bendSegment(e.clientX, e.clientY); + else if (segmentDrag.dragIndex !== null) segmentDrag.move(e); + else movePoint(e); + }, + [bendSegment, curveIndex, movePoint, segmentDrag], + ); + const onPointerMove = useCallback( (e: ReactPointerEvent): void => { if (stretch.edge !== null) { @@ -409,19 +457,15 @@ export function useAutomationLaneGestures({ rangeDrag.move(e); return; } - if (curveIndex === null && dragIndex === null) { + if (curveIndex === null && dragIndex === null && segmentDrag.dragIndex === null) { stretch.updateHover(e); + segmentDrag.updateHover(e.clientX, e.clientY); return; } e.stopPropagation(); - const from = pressAt.current; - if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) { - pressTravelled.current = true; - } - if (curveIndex !== null) bendSegment(e.clientX, e.clientY); - else movePoint(e); + moveLiveGesture(e); }, - [rangeDrag, curveIndex, dragIndex, bendSegment, movePoint, stretch], + [rangeDrag, curveIndex, dragIndex, segmentDrag, moveLiveGesture, stretch], ); const endDrag = useCallback( @@ -436,12 +480,13 @@ export function useAutomationLaneGestures({ rangeDrag.finish(); return; } - if (dragIndex === null && curveIndex === null) return; + if (dragIndex === null && curveIndex === null && segmentDrag.dragIndex === null) return; e.stopPropagation(); const index = dragIndex; const shiftClicked = index !== null && e.shiftKey && !pressTravelled.current; setDragIndex(null); setCurveIndex(null); + segmentDrag.finish(); dragOrigin.current = null; groupDrag.current = null; shiftAxis.current = null; @@ -459,7 +504,7 @@ export function useAutomationLaneGestures({ } commitPoints(lane.points, true); }, - [rangeDrag, curveIndex, dragIndex, lane, commitPoints, stretch], + [rangeDrag, curveIndex, dragIndex, segmentDrag, lane, commitPoints, stretch], ); /** @@ -519,12 +564,15 @@ export function useAutomationLaneGestures({ return { dragIndex, curveIndex, + segmentDragIndex: segmentDrag.dragIndex, + segmentHoverIndex: segmentDrag.hoverIndex, edgeDrag: stretch.edge, edgeHover: stretch.hover, cancelDrag, hint, hitIndex, segmentIndex, + onPointerLeave: segmentDrag.clearHover, onPointerDown, onPointerMove, endDrag, diff --git a/packages/studio/src/player/components/useAutomationSegmentDrag.ts b/packages/studio/src/player/components/useAutomationSegmentDrag.ts new file mode 100644 index 0000000000..69d452df35 --- /dev/null +++ b/packages/studio/src/player/components/useAutomationSegmentDrag.ts @@ -0,0 +1,110 @@ +/** + * The grab target and translation gesture for one automation segment. + * + * Kept separate from the lane's gesture router: proximity is measured against + * the sampled envelope, while movement reuses the same shape-preserving group + * math as a box selection. + */ + +import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import { + sampleAutomationLane, + type AutomationRange, + type HfAutomationLane, +} from "@hyperframes/core/audio-automation"; +import { SEGMENT_GRAB_PX } from "./automationLaneGeometry"; +import { armSegmentDrag, computeGroupMove, type GroupDragSnapshot } from "./automationLaneDragMath"; + +interface UseAutomationSegmentDragInput { + getBox(): DOMRect | null; + lane: HfAutomationLane; + range: AutomationRange; + pointAt(clientX: number, clientY: number): { t: number; v: number }; + xOf(t: number): number; + yOf(v: number): number; + segmentIndex(clientX: number, clientY: number): number | null; + commitPoints(points: HfAutomationLane["points"], persist: boolean): void; + duration: number; + snapTimes: readonly number[] | undefined; + readOnly: boolean | undefined; + onHint(hint: string | null): void; +} + +export function useAutomationSegmentDrag({ + getBox, + lane, + range, + pointAt, + xOf, + yOf, + segmentIndex, + commitPoints, + duration, + snapTimes, + readOnly, + onHint, +}: UseAutomationSegmentDragInput) { + const [dragIndex, setDragIndex] = useState(null); + const [hoverIndex, setHoverIndex] = useState(null); + const snapshot = useRef(null); + + /** Segment whose drawn line is close enough to grab, or null. */ + const hitIndex = useCallback( + (clientX: number, clientY: number): number | null => { + const box = getBox(); + if (!box) return null; + const index = segmentIndex(clientX, clientY); + if (index === null) return null; + const { t } = pointAt(clientX, clientY); + const value = sampleAutomationLane(lane, t, range.scale); + return Math.abs(yOf(value) - (clientY - box.top)) <= SEGMENT_GRAB_PX ? index : null; + }, + [getBox, lane, pointAt, range.scale, segmentIndex, yOf], + ); + + const arm = useCallback( + (index: number, clientX: number, clientY: number): void => { + snapshot.current = armSegmentDrag(lane, index, pointAt(clientX, clientY)); + setHoverIndex(null); + setDragIndex(index); + }, + [lane, pointAt], + ); + + const move = useCallback( + (e: ReactPointerEvent): void => { + const group = snapshot.current; + if (!group) return; + const moved = computeGroupMove({ + group, + raw: pointAt(e.clientX, e.clientY), + shiftKey: e.shiftKey, + altKey: e.altKey, + range, + duration, + snapTimes, + xOf, + yOf, + }); + onHint(moved.hint); + commitPoints(moved.points, false); + }, + [commitPoints, duration, onHint, pointAt, range, snapTimes, xOf, yOf], + ); + + const finish = useCallback((): void => { + snapshot.current = null; + setDragIndex(null); + }, []); + + const updateHover = useCallback( + (clientX: number, clientY: number): void => { + setHoverIndex(readOnly ? null : hitIndex(clientX, clientY)); + }, + [hitIndex, readOnly], + ); + + const clearHover = useCallback((): void => setHoverIndex(null), []); + + return { dragIndex, hoverIndex, hitIndex, arm, move, finish, updateHover, clearHover }; +}