diff --git a/packages/studio/src/hooks/useAppHotkeys.test.ts b/packages/studio/src/hooks/useAppHotkeys.test.ts new file mode 100644 index 0000000000..7d7d88fc1d --- /dev/null +++ b/packages/studio/src/hooks/useAppHotkeys.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { dispatchPlainKey } from "./useAppHotkeys"; +import { usePlayerStore } from "../player/store/playerStore"; +import type { TimelineElement } from "../player/store/timelineElement"; + +/** Minimal valid fixture — TimelineElement only requires these five fields. */ +const bgmElement: TimelineElement = { + id: "bgm", + key: "bgm", + tag: "audio", + start: 0, + duration: 6, + track: 0, +}; + +/** Every callback dispatchPlainKey can reach, so a test can assert which one + * a key resolved to. Unannotated on purpose: the parameter type is not + * exported, and structural inference checks it at the call site. */ +function callbacks() { + return { + handleTimelineElementDelete: vi.fn(async () => {}), + handleTimelineElementSplit: vi.fn(async () => {}), + handleDomEditElementDelete: vi.fn(async () => {}), + handleUndo: vi.fn(async () => {}), + handleRedo: vi.fn(async () => {}), + handleCopy: vi.fn(() => false), + handlePaste: vi.fn(async () => {}), + handleCut: vi.fn(async () => false), + onResetKeyframes: vi.fn(() => true), + onDeleteSelectedKeyframes: vi.fn(), + showToast: vi.fn(), + leftSidebarRef: { current: null }, + domEditSelectionRef: { current: null }, + }; +} + +const press = (key: string) => + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + +afterEach(() => { + usePlayerStore.getState().clearAutomationSelection(); + usePlayerStore.setState({ + elements: [], + selectedElementId: null, + selectedElementIds: new Set(), + selectedKeyframes: new Set(), + }); +}); + +describe("dispatchPlainKey — Delete arbitration", () => { + const selectBgm = () => + usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" }); + + const selectRange = () => + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); + + it("deletes the selected clip when no automation range is active", () => { + selectBgm(); + const cb = callbacks(); + const e = press("Delete"); + dispatchPlainKey(e, "delete", cb); + // The pre-existing contract, pinned so the new guard cannot widen. + expect(cb.handleTimelineElementDelete).toHaveBeenCalledTimes(1); + expect(e.defaultPrevented).toBe(true); + }); + + it("leaves the clip alone when an automation range is active", () => { + // The bug: this listener is on window/capture so it runs BEFORE + // useAutomationSelectionKeyboard's document/capture handler. Without the + // guard, clearing a 2s automation range deleted the whole audio clip. + selectBgm(); + selectRange(); + const cb = callbacks(); + const e = press("Delete"); + dispatchPlainKey(e, "delete", cb); + expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled(); + // Must NOT be consumed: the automation handler downstream still needs it. + expect(e.defaultPrevented).toBe(false); + }); + + it("leaves keyframe reset alone when an automation range is active", () => { + // Backspace's reset-keyframes branch sits below the guard, so it has to be + // covered too — otherwise Backspace wiped every keyframe on the clip. + selectBgm(); + usePlayerStore.setState({ + keyframeCache: new Map([["bgm", { targets: [], version: 0 }]]), + }); + selectRange(); + const cb = callbacks(); + const e = press("Backspace"); + dispatchPlainKey(e, "backspace", cb); + expect(cb.onResetKeyframes).not.toHaveBeenCalled(); + expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(false); + }); + + it("still lets a keyframe selection win over an automation range", () => { + // Ordering: the keyframe guard precedes the automation one, so a keyframe + // selection keeps Delete even with a range showing. + selectBgm(); + selectRange(); + usePlayerStore.setState({ selectedKeyframes: new Set(["bgm:opacity:0"]) }); + const cb = callbacks(); + const e = press("Delete"); + dispatchPlainKey(e, "delete", cb); + expect(cb.onDeleteSelectedKeyframes).toHaveBeenCalledTimes(1); + expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(true); + }); +}); diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 1cae6af5b5..9998b50d98 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -222,7 +222,10 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba } // fallow-ignore-next-line complexity -function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void { +/** Exported for tests: the unmodified-key half of the dispatcher, so the + * Delete arbitration between keyframes, an automation range and the clip can + * be asserted without standing up the whole hook. */ +export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): void { if (key === "f" && !event.shiftKey && !event.altKey) { event.preventDefault(); if (document.fullscreenElement) void document.exitFullscreen(); @@ -288,6 +291,13 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks event.preventDefault(); return; } + // An active automation range owns Delete: useAutomationSelectionKeyboard + // empties the range in place, pinning the anchors. Fall through WITHOUT + // preventDefault so that document-level handler still sees the key — this + // listener is on window/capture, so it runs first and everything below + // would otherwise win. Without this the press reaches the clip delete + // below and destroys the whole clip the lane belongs to. + if (usePlayerStore.getState().automationSelection) return; if (event.key === "Backspace") { const { selectedElementId, keyframeCache } = usePlayerStore.getState(); if (selectedElementId && keyframeCache.has(selectedElementId) && cb.onResetKeyframes()) { diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx new file mode 100644 index 0000000000..fd24507356 --- /dev/null +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { usePlayerStore } from "../player/store/playerStore"; +import { useAutomationSelectionKeyboard } from "./useAutomationSelectionKeyboard"; +import type { + AutomationLaneBinding, + UseAutomationLanesResult, +} from "../player/components/useAutomationLanes"; +import type { TimelineElement } from "../player/store/timelineElement"; + +/** Minimal valid fixture — TimelineElement only requires these five fields. */ +const bgmElement: TimelineElement = { + id: "bgm", + key: "bgm", + tag: "audio", + start: 0, + duration: 6, + track: 0, +}; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function Host({ lanes }: { lanes: UseAutomationLanesResult }) { + useAutomationSelectionKeyboard({ lanes }); + return null; +} + +const key = (k: string) => { + const e = new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }); + act(() => void document.dispatchEvent(e)); +}; + +describe("useAutomationSelectionKeyboard", () => { + const setup = (binding: Partial) => { + const onCommit = vi.fn(); + const lanes: UseAutomationLanesResult = { + bind: () => ({ + automation: { + version: 1, + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 1 }, + { t: 2, v: 0.5 }, + { t: 4, v: 0 }, + ], + }, + ], + }, + lanes: [], + chain: null, + onPreview: vi.fn(), + onCommit, + onSelect: vi.fn(), + readOnly: false, + selection: null, + onRangeSelect: vi.fn(), + onRangeClear: vi.fn(), + ...binding, + }), + }; + const host = document.createElement("div"); + document.body.append(host); + act(() => createRoot(host).render()); + return { onCommit }; + }; + + it("Delete empties the selected range and pins anchors", () => { + usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }); + const { onCommit } = setup({}); + key("Delete"); + const written = onCommit.mock.calls.at(-1)?.[0]; + const points = written?.lanes?.[0]?.points ?? []; + expect(points.map((p: { t: number }) => p.t)).toEqual([0, 1, 3, 4]); + }); + + it("Escape clears the selection", () => { + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }); + setup({}); + key("Escape"); + expect(usePlayerStore.getState().automationSelection).toBeNull(); + }); + + it("is inert while a text input has focus", () => { + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }); + const { onCommit } = setup({}); + const input = document.createElement("input"); + document.body.append(input); + input.focus(); + key("Delete"); + expect(onCommit).not.toHaveBeenCalled(); + input.remove(); + }); +}); diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts new file mode 100644 index 0000000000..786a0c683e --- /dev/null +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts @@ -0,0 +1,79 @@ +/** + * Keyboard surface for the active automation selection: Escape clears, + * Delete/Backspace empties the range (anchors pinned, envelope outside + * untouched). Sibling of useKeyframeKeyboard and copies its contract: + * capture phase so playback shortcuts cannot swallow keys we act on, inert + * while any text input has focus, and a key is only consumed when it does + * something. + */ +import { useEffect } from "react"; +import { usePlayerStore, type TimelineElement } from "../player/store/playerStore"; +import { laneFor, withLane } from "../player/components/automationLaneGeometry"; +import { replaceRange } from "../player/components/automationLaneSelection"; +import { resolveAutomationRange, type HfAutomation } from "@hyperframes/core/audio-automation"; +import type { AutomationSelection } from "../player/store/automationSelectionSlice"; +import type { UseAutomationLanesResult } from "../player/components/useAutomationLanes"; + +function isTextInput(el: Element | null): boolean { + if (!el) return false; + const tag = el.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + return el instanceof HTMLElement && el.isContentEditable; +} + +/** + * The write that empties the active selection, or null when there is nothing + * to do: the clip is gone, its lane is read-only, the target no longer + * resolves to a range, or the lane already has no points in it. Split out of + * the keydown handler so each stays under the complexity a single branch of + * keyboard dispatch should carry. + */ +function resolveDeleteWrite( + state: { elements: TimelineElement[]; selectedElementId: string | null }, + lanes: UseAutomationLanesResult, + sel: AutomationSelection, +): { onCommit(next: HfAutomation): void; next: HfAutomation } | null { + const element = state.elements.find((el) => (el.key ?? el.id) === sel.elementKey); + if (!element) return null; + const binding = lanes.bind(element, sel.elementKey === state.selectedElementId); + if (binding.readOnly) return null; + const lane = laneFor(binding.automation, sel.target); + const range = resolveAutomationRange(sel.target, binding.chain ?? undefined); + if (!range || lane.points.length === 0) return null; + const points = replaceRange({ lane, range, t0: sel.t0, t1: sel.t1, inner: [] }); + return { + onCommit: binding.onCommit, + next: withLane(binding.automation, { target: sel.target, points }), + }; +} + +export function useAutomationSelectionKeyboard({ + lanes, +}: { + lanes: UseAutomationLanesResult; +}): void { + useEffect(() => { + const handler = (e: KeyboardEvent): void => { + if (isTextInput(document.activeElement)) return; + const state = usePlayerStore.getState(); + const sel = state.automationSelection; + if (!sel) return; + + if (e.key === "Escape") { + state.clearAutomationSelection(); + return; + } + const isDeleteKey = e.key === "Delete" || e.key === "Backspace"; + if (!isDeleteKey || e.metaKey || e.ctrlKey) return; + + const write = resolveDeleteWrite(state, lanes, sel); + if (!write) return; + + e.preventDefault(); + e.stopImmediatePropagation(); + write.onCommit(write.next); + }; + document.addEventListener("keydown", handler, true); + return () => document.removeEventListener("keydown", handler, true); + }, [lanes]); +} diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx index e4d23529c0..1ae4bca663 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx @@ -459,33 +459,33 @@ describe("TimelineAutomationLane", () => { }); }); -describe("TimelineAutomationLane modifiers", () => { - /** The lane's own box, so pointer coordinates map to clip time and value. */ - const BOX = { left: 100, top: 0, width: 400 + PAD * 2, height: AUTOMATION_LANE_H }; - - /** x for a clip time, y for a 0..1 unit height, in client coordinates. The - * 6px inset and the height have to match the lane's own, or a point sits - * outside the grab radius and a press silently does nothing. */ - const at = (t: number, unit: number) => ({ - clientX: BOX.left + PAD + (t / 4) * 400, - clientY: BOX.top + 6 + (1 - unit) * (AUTOMATION_LANE_H - 12), - }); - - const mount = (automation: HfAutomation, over: Record = {}) => { - const base = laneProps({ automation, ...over }); - // Narrowed once here: laneProps types these as the prop signature, and every - // assertion below reads the calls the lane made. - const props = { - ...base, - onPreview: base.onPreview as ReturnType, - onCommit: base.onCommit as ReturnType, - }; - const { container } = render(); - const svg = container.querySelector("svg")!; - stubBox(svg, BOX); - return { container, svg, props }; +/** The lane's own box, so pointer coordinates map to clip time and value. */ +const BOX = { left: 100, top: 0, width: 400 + PAD * 2, height: AUTOMATION_LANE_H }; + +/** x for a clip time, y for a 0..1 unit height, in client coordinates. The + * 6px inset and the height have to match the lane's own, or a point sits + * outside the grab radius and a press silently does nothing. */ +const at = (t: number, unit: number) => ({ + clientX: BOX.left + PAD + (t / 4) * 400, + clientY: BOX.top + 6 + (1 - unit) * (AUTOMATION_LANE_H - 12), +}); + +const mount = (automation: HfAutomation, over: Record = {}) => { + const base = laneProps({ automation, ...over }); + // Narrowed once here: laneProps types these as the prop signature, and every + // assertion below reads the calls the lane made. + const props = { + ...base, + onPreview: base.onPreview as ReturnType, + onCommit: base.onCommit as ReturnType, }; + const { container } = render(); + const svg = container.querySelector("svg")!; + stubBox(svg, BOX); + return { container, svg, props }; +}; +describe("TimelineAutomationLane modifiers", () => { it("bends a segment when it is Alt-dragged, and leaves the points where they were", () => { // `curve` was honoured everywhere it is read — drawn, sampled in preview, // baked into the render — with no gesture that could set it. @@ -608,3 +608,44 @@ describe("TimelineAutomationLane modifiers", () => { expect(committed?.lanes[0]?.points[0]?.v).toBe(VOLUME_RANGE.max); }); }); + +describe("TimelineAutomationLane range selection", () => { + it("drag on the background selects a range, snapped to the grid", () => { + const onRangeSelect = vi.fn(); + const { svg } = mount(ramp, { snapTimes: [1], onRangeSelect }); + fire(svg, "pointerdown", at(0.98, 0.5)); // background: no point within grab radius + fire(svg, "pointermove", at(3, 0.5)); + fire(svg, "pointerup", at(3, 0.5)); + const last = onRangeSelect.mock.calls.at(-1); + expect(last?.[0]).toBe(1); // snapped to the beat + expect(last?.[1]).toBeCloseTo(3, 1); + }); + + it("a sub-threshold click clears instead of selecting", () => { + const onRangeSelect = vi.fn(); + const onRangeClear = vi.fn(); + const { svg } = mount(ramp, { onRangeSelect, onRangeClear }); + fire(svg, "pointerdown", at(1, 0.5)); + fire(svg, "pointerup", at(1.001, 0.5)); + expect(onRangeSelect).not.toHaveBeenCalled(); + expect(onRangeClear).toHaveBeenCalled(); + }); + + it("draws the selection rect between its endpoints", () => { + const { container } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } }); + const rect = container.querySelector("[data-automation-selection]"); + expect(rect).not.toBeNull(); + expect(Number(rect?.getAttribute("x"))).toBeCloseTo(PAD + 100, 0); // xOf(1) at 400px/4s + expect(Number(rect?.getAttribute("width"))).toBeCloseTo(200, 0); + }); + + it("point drags still win over range selection", () => { + const onRangeSelect = vi.fn(); + const { svg, props } = mount(ramp, { onRangeSelect }); + fire(svg, "pointerdown", at(0, 1)); // exactly on a point + fire(svg, "pointermove", at(1, 0.8)); + fire(svg, "pointerup", at(1, 0.8)); + expect(onRangeSelect).not.toHaveBeenCalled(); + expect(props.onCommit).toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx index fb5fca3130..eeaf53895e 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx @@ -72,6 +72,10 @@ export interface TimelineAutomationLaneProps { readOnly?: boolean; /** Called when a read-only lane is pressed: selects the clip so it goes live. */ onSelect?(): void; + /** Active selection on THIS lane, or null. */ + rangeSelection?: { t0: number; t1: number } | null | undefined; + onRangeSelect?: ((t0: number, t1: number) => void) | undefined; + onRangeClear?: (() => void) | undefined; } export function TimelineAutomationLane({ @@ -89,6 +93,9 @@ export function TimelineAutomationLane({ snapTimes, readOnly, onSelect, + rangeSelection, + onRangeSelect, + onRangeClear, }: TimelineAutomationLaneProps) { const stored = laneFor(automation, target); @@ -183,6 +190,9 @@ export function TimelineAutomationLane({ snapTimes, readOnly, onSelect, + onRangeSelect, + onRangeClear, + duration, }); const { dragIndex, curveIndex, hint, editing } = gestures; @@ -255,6 +265,31 @@ export function TimelineAutomationLane({ stroke="rgba(255,255,255,0.08)" strokeDasharray="3 4" /> + {rangeSelection ? ( + <> + + {[rangeSelection.t0, rangeSelection.t1].map((t) => ( + + ))} + + ) : null} { + const target = bound.selection?.target; + if (target !== undefined && !bound.lanes.some((lane) => lane.target === target)) { + bound.onRangeClear(); + } + }, [bound]); if (bound.lanes.length === 0) return null; const inClip = currentTime >= element.start && currentTime <= element.start + element.duration; const top = getTimelineLaneTop(laneCount); @@ -382,6 +427,13 @@ export function TimelineAutomationLaneSlot({ onSelect={bound.onSelect} snapTimes={snapTimes} readOnly={bound.readOnly} + rangeSelection={ + bound.selection?.target === lane.target + ? { t0: bound.selection.t0, t1: bound.selection.t1 } + : null + } + onRangeSelect={(t0, t1) => bound.onRangeSelect(lane.target, t0, t1)} + onRangeClear={bound.onRangeClear} /> ); })} diff --git a/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx new file mode 100644 index 0000000000..c08f25af44 --- /dev/null +++ b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { createRoot } from "react-dom/client"; +import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane"; +import type { AutomationLaneBinding, UseAutomationLanesResult } from "./useAutomationLanes"; +import type { TimelineElement } from "../store/timelineElement"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const element: TimelineElement = { + id: "bgm", + key: "bgm", + tag: "audio", + start: 0, + duration: 6, + track: 0, +}; + +function mountSlot(binding: Partial) { + const onRangeClear = vi.fn(); + const lanes: UseAutomationLanesResult = { + bind: () => ({ + automation: { version: 1, lanes: [] }, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + chain: null, + onPreview: vi.fn(), + onCommit: vi.fn(), + onSelect: vi.fn(), + readOnly: false, + selection: null, + onRangeSelect: vi.fn(), + onRangeClear, + ...binding, + }), + }; + const host = document.createElement("div"); + document.body.append(host); + act(() => { + createRoot(host).render( + , + ); + }); + return { onRangeClear }; +} + +describe("TimelineAutomationLaneSlot stale-selection guard", () => { + it("clears the selection when its lane's target no longer exists", () => { + const { onRangeClear } = mountSlot({ + selection: { elementKey: "bgm", target: "fx.gone.wet", t0: 1, t1: 2 }, + }); + expect(onRangeClear).toHaveBeenCalledTimes(1); + }); + + it("leaves an in-scope selection alone", () => { + const { onRangeClear } = mountSlot({ + selection: { elementKey: "bgm", target: "volume", t0: 1, t1: 2 }, + }); + expect(onRangeClear).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index c04ef3119e..6fe8f530f7 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -5,22 +5,21 @@ import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane"; import { useAutomationLanes } from "./useAutomationLanes"; +import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { resolveTrackKeyframeClip, trackShowsBeatStrip } from "./useTimelineTrackLayout"; import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; import { clipTimingStart } from "../../hooks/gsapShared"; import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing"; import { CLIP_Y, CLIP_HANDLE_W, TRACK_H } from "./timelineLayout"; -import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { usePlayerStore } from "../store/playerStore"; import { isMultiDragActive, isMultiDragPassenger, multiDragDeltaSeconds, - type MultiDragPreviewInput, multiDragPassengerOffsetPx, } from "./timelineMultiDragPreview"; -import type { TimelineLaneBaseProps } from "./timelineLaneProps"; -import type { TimelineEditCallbacks } from "./timelineCallbacks"; +import type { TimelineLanesProps } from "./timelineLaneProps"; import { trackStudioKeyframeLaneExpand } from "../../telemetry/events"; import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit"; import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector"; @@ -30,18 +29,6 @@ import { isTimelineClipActive } from "./useTimelineActiveClips"; import { queryTimelineClipIndex } from "../lib/timelineClipIndex"; import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; -interface TimelineLanesProps extends TimelineLaneBaseProps { - /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */ - draggedElement: TimelineElement | null; - multiDragPreview: MultiDragPreviewInput | null; - onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; - onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; - onResizeElement: TimelineEditCallbacks["onResizeElement"]; - onMoveElement: TimelineEditCallbacks["onMoveElement"]; - onRazorSplit: TimelineEditCallbacks["onRazorSplit"]; - onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"]; -} - export function TimelineLanes({ pps, contentOrigin, @@ -109,6 +96,7 @@ export function TimelineLanes({ const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`; const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); const automationLanes = useAutomationLanes(); + useAutomationSelectionKeyboard({ lanes: automationLanes }); const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded); const toggleClipExpandedTracked = (key: string) => { const willExpand = !expandedClipIds.has(key); diff --git a/packages/studio/src/player/components/automationLaneSelection.test.ts b/packages/studio/src/player/components/automationLaneSelection.test.ts new file mode 100644 index 0000000000..a7f0296add --- /dev/null +++ b/packages/studio/src/player/components/automationLaneSelection.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { pointsIn, replaceRange } from "./automationLaneSelection"; +import { sampleAutomationLane, VOLUME_RANGE } from "@hyperframes/core/audio-automation"; +import type { HfAutomationLane } from "@hyperframes/core/audio-automation"; + +const ramp: HfAutomationLane = { + target: "volume", + points: [ + { t: 0, v: 1 }, + { t: 2, v: 0.6 }, + { t: 3, v: 0.4 }, + { t: 6, v: 0 }, + ], +}; + +describe("pointsIn", () => { + it("returns only the points inside the range, endpoints inclusive", () => { + expect(pointsIn(ramp, 2, 3).map((p) => p.t)).toEqual([2, 3]); + expect(pointsIn(ramp, 2.1, 2.9)).toEqual([]); + }); +}); + +describe("replaceRange", () => { + it("never moves the envelope outside the selection", () => { + // THE invariant. Deleting the middle of a ramp must not reshape the rest. + const next: HfAutomationLane = { + target: "volume", + points: replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: [] }), + }; + for (const t of [0, 0.5, 1.0, 1.5, 3.5, 4, 5, 6]) { + expect(sampleAutomationLane(next, t, "linear")).toBeCloseTo( + sampleAutomationLane(ramp, t, "linear"), + 5, + ); + } + }); + + it("pins anchors at both edges when the interior empties", () => { + const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: [] }); + const times = pts.map((p) => p.t); + expect(times).toContain(1.5); + expect(times).toContain(3.5); + expect(times).not.toContain(2); + expect(times).not.toContain(3); + }); + + it("lets inner points at the edges stand in for the anchors", () => { + // A ramp generator emits its own boundary points; pinning a second anchor + // at the same time would fight it. + const pts = replaceRange({ + lane: ramp, + range: VOLUME_RANGE, + t0: 2, + t1: 3, + inner: [ + { t: 2, v: 0 }, + { t: 3, v: 1 }, + ], + }); + expect(pts.filter((p) => p.t === 2)).toHaveLength(1); + expect(pts.find((p) => p.t === 2)?.v).toBe(0); + }); + + it("sorts and respects the point cap", () => { + const dense = Array.from({ length: 600 }, (_, i) => ({ t: 1.5 + i * 0.001, v: 0.5 })); + const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: dense }); + expect(pts.length).toBeLessThanOrEqual(512); + expect([...pts].sort((a, b) => a.t - b.t)).toEqual(pts); + }); + + it("keeps a constant flat when the lane has no points", () => { + const empty: HfAutomationLane = { target: "volume", points: [] }; + const pts = replaceRange({ lane: empty, range: VOLUME_RANGE, t0: 1, t1: 2, inner: [] }); + // Nothing to preserve, nothing to pin: an empty lane stays empty. + expect(pts).toEqual([]); + }); + + it("keeps the far anchor and every outside point when inner would overflow the cap", () => { + const dense = Array.from({ length: 600 }, (_, i) => ({ t: 1.5 + i * 0.001, v: 0.5 })); + const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: dense }); + const times = pts.map((p) => p.t); + expect(times).toContain(1.5); // near anchor + expect(times).toContain(3.5); // far anchor — this is what the bug dropped + expect(times).toContain(0); // outside point before the range + expect(times).toContain(6); // outside point after the range + expect(pts.length).toBeLessThanOrEqual(512); + }); + + it("thins the interior evenly rather than dropping its tail", () => { + const dense = Array.from({ length: 2001 }, (_, i) => ({ t: 1.5 + i * 0.001, v: 0.5 })); + const pts = replaceRange({ lane: ramp, range: VOLUME_RANGE, t0: 1.5, t1: 3.5, inner: dense }); + const innerTimes = pts.map((p) => p.t).filter((t) => t > 1.5 && t < 3.5); + // Evenly spread across the range, not clustered at the start. + expect(Math.max(...innerTimes)).toBeGreaterThan(3.0); + }); +}); diff --git a/packages/studio/src/player/components/automationLaneSelection.ts b/packages/studio/src/player/components/automationLaneSelection.ts new file mode 100644 index 0000000000..3e8e1fb703 --- /dev/null +++ b/packages/studio/src/player/components/automationLaneSelection.ts @@ -0,0 +1,74 @@ +/** + * Range operations over one automation lane. + * + * `replaceRange` is the only mutator every range feature (delete, shapes, + * paste, stretch) composes, and it carries the invariant that makes them safe: + * the envelope OUTSIDE the selection never moves. It samples the lane at both + * edges first and pins anchor points there, so cutting the middle out of a + * ramp cannot reshape the rest of the clip. + * + * Exact for linear segments. A curved segment straddling an edge keeps its + * edge VALUE but reshapes slightly between its own start and the anchor — the + * curve exponent now runs over a shorter span. Accepted: the alternative is + * splitting curves analytically for a difference the ear cannot place. + */ + +import { + MAX_AUTOMATION_POINTS, + sampleAutomationLane, + type AutomationRange, + type HfAutomationLane, + type HfAutomationPoint, +} from "@hyperframes/core/audio-automation"; +import { POINT_MERGE_SEC } from "./automationLaneGeometry"; + +/** Points inside [t0, t1], endpoints inclusive. */ +export function pointsIn(lane: HfAutomationLane, t0: number, t1: number): HfAutomationPoint[] { + return lane.points.filter((p) => p.t >= t0 && p.t <= t1); +} + +/** An anchor, unless `inner` already provides the edge within the merge radius. */ +function anchor( + lane: HfAutomationLane, + range: AutomationRange, + t: number, + inner: readonly HfAutomationPoint[], +): HfAutomationPoint[] { + if (inner.some((p) => Math.abs(p.t - t) <= POINT_MERGE_SEC)) return []; + return [{ t, v: sampleAutomationLane(lane, t, range.scale) }]; +} + +/** Evenly subsample items to a budget, preserving first and last. */ +function decimateEvenly(items: readonly T[], budget: number): T[] { + if (budget <= 0) return []; + if (items.length <= budget) return [...items]; + if (budget === 1) return [items[0]!]; + const out: T[] = []; + const step = (items.length - 1) / (budget - 1); + for (let i = 0; i < budget; i += 1) { + const item = items[Math.round(i * step)]; + if (item) out.push(item); + } + return out; +} + +export function replaceRange(input: { + lane: HfAutomationLane; + range: AutomationRange; + t0: number; + t1: number; + inner: HfAutomationPoint[]; +}): HfAutomationPoint[] { + const { lane, range, t0, t1, inner } = input; + // An empty lane draws a flat default; there is nothing to preserve, and + // pinning anchors would turn "no automation" into a constant lane. + if (lane.points.length === 0 && inner.length === 0) return []; + const outside = lane.points.filter((p) => p.t < t0 || p.t > t1); + const edges = + lane.points.length === 0 + ? [] + : [...anchor(lane, range, t0, inner), ...anchor(lane, range, t1, inner)]; + const budget = Math.max(0, MAX_AUTOMATION_POINTS - outside.length - edges.length); + const cappedInner = inner.length <= budget ? inner : decimateEvenly(inner, budget); + return [...outside, ...edges, ...cappedInner].sort((a, b) => a.t - b.t); +} diff --git a/packages/studio/src/player/components/timelineLaneProps.ts b/packages/studio/src/player/components/timelineLaneProps.ts index b16a03b3b3..c28931bf55 100644 --- a/packages/studio/src/player/components/timelineLaneProps.ts +++ b/packages/studio/src/player/components/timelineLaneProps.ts @@ -9,6 +9,8 @@ import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./us import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex"; import type { TimelineRowGeometry } from "./timelineLayout"; import type { TimelineVirtualRow } from "./useTimelineVirtualRows"; +import type { MultiDragPreviewInput } from "./timelineMultiDragPreview"; +import type { TimelineEditCallbacks } from "./timelineCallbacks"; /** * Props shared by the scroll container ({@link import("./TimelineCanvas")}) and @@ -92,3 +94,20 @@ export interface TimelineLaneBaseProps { onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void; beatAnalysis?: MusicBeatAnalysis | null; } + +/** + * {@link TimelineLaneBaseProps} plus the handful of props only the lane + * renderer ({@link import("./TimelineLanes")}) itself needs — the drag-preview + * state and the edit callbacks TimelineCanvas does not otherwise touch. + */ +export interface TimelineLanesProps extends TimelineLaneBaseProps { + /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */ + draggedElement: TimelineElement | null; + multiDragPreview: MultiDragPreviewInput | null; + onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; + onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; + onResizeElement: TimelineEditCallbacks["onResizeElement"]; + onMoveElement: TimelineEditCallbacks["onMoveElement"]; + onRazorSplit: TimelineEditCallbacks["onRazorSplit"]; + onRazorSplitAll: TimelineEditCallbacks["onRazorSplitAll"]; +} diff --git a/packages/studio/src/player/components/useAutomationLaneGestures.ts b/packages/studio/src/player/components/useAutomationLaneGestures.ts index 24c12db9d9..cb00c83efe 100644 --- a/packages/studio/src/player/components/useAutomationLaneGestures.ts +++ b/packages/studio/src/player/components/useAutomationLaneGestures.ts @@ -56,6 +56,10 @@ export interface UseAutomationLaneGesturesInput { snapTimes?: readonly number[] | undefined; readOnly?: boolean | undefined; onSelect?: (() => void) | undefined; + /** Live range-select callbacks; absent = background drags do nothing (read-only lanes). */ + onRangeSelect?: ((t0: number, t1: number) => void) | undefined; + onRangeClear?: (() => void) | undefined; + duration: number; // clamp bound for range endpoints } export interface UseAutomationLaneGesturesResult { @@ -90,6 +94,9 @@ export function useAutomationLaneGestures({ snapTimes, readOnly, onSelect, + onRangeSelect, + onRangeClear, + duration, }: UseAutomationLaneGesturesInput): UseAutomationLaneGesturesResult { const [dragIndex, setDragIndex] = useState(null); const [curveIndex, setCurveIndex] = useState(null); @@ -98,6 +105,11 @@ export function useAutomationLaneGestures({ const dragOrigin = useRef<{ t: number; v: number } | null>(null); /** Point whose value is being typed, and the text so far. */ const [editing, setEditing] = useState<{ index: number; text: string } | null>(null); + /** A background drag in progress: its start and live end, in clip seconds. */ + const [rangeDrag, setRangeDrag] = useState<{ from: number; to: number } | null>(null); + /** Whether the live drag has crossed the pixel threshold that turns a press + * into an actual range, rather than a click that should just clear one. */ + const rangeCrossed = useRef(false); /** Index of a point under the pointer, or null. */ const hitIndex = useCallback( @@ -155,7 +167,22 @@ export function useAutomationLaneGestures({ return; } const gesture = gestureAt(e); - if (!gesture) return; + if (!gesture) { + // Neither a point nor an Alt-held segment: the press landed on the + // lane's empty background. That is a range selection's gesture, not + // nothing — but only when a caller wants to hear about one; a + // read-only lane already returned above, so this is a live one with no + // range feature wired up. + if (!onRangeSelect) return; + e.preventDefault(); + capturePointer(e); + const raw = pointAt(e.clientX, e.clientY).t; + const clamped = Math.min(duration, Math.max(0, raw)); + const t = e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC); + rangeCrossed.current = false; + setRangeDrag({ from: t, to: t }); + return; + } e.preventDefault(); capturePointer(e); if (gesture.curve) { @@ -165,7 +192,7 @@ export function useAutomationLaneGestures({ dragOrigin.current = originOf(lane.points[gesture.index]); setDragIndex(gesture.index); }, - [gestureAt, lane, readOnly, onSelect], + [gestureAt, lane, readOnly, onSelect, onRangeSelect, pointAt, duration, snapTimes], ); /** Bend the segment under the pointer, which is what Alt-dragging the line does. */ @@ -214,16 +241,46 @@ export function useAutomationLaneGestures({ const onPointerMove = useCallback( (e: ReactPointerEvent): void => { + if (rangeDrag !== null) { + e.stopPropagation(); + const raw = pointAt(e.clientX, e.clientY).t; + const clamped = Math.min(duration, Math.max(0, raw)); + const t = e.altKey ? clamped : snapLaneTime(clamped, snapTimes ?? [], SNAP_SEC); + setRangeDrag({ from: rangeDrag.from, to: t }); + if (Math.abs(xOf(t) - xOf(rangeDrag.from)) > 3) { + rangeCrossed.current = true; + onRangeSelect?.(Math.min(rangeDrag.from, t), Math.max(rangeDrag.from, t)); + } + return; + } if (curveIndex === null && dragIndex === null) return; e.stopPropagation(); if (curveIndex !== null) bendSegment(e.clientX, e.clientY); else movePoint(e); }, - [bendSegment, curveIndex, dragIndex, movePoint], + [ + rangeDrag, + pointAt, + duration, + snapTimes, + xOf, + onRangeSelect, + bendSegment, + curveIndex, + dragIndex, + movePoint, + ], ); const endDrag = useCallback( (e: ReactPointerEvent): void => { + if (rangeDrag !== null) { + e.stopPropagation(); + if (!rangeCrossed.current) onRangeClear?.(); + rangeCrossed.current = false; + setRangeDrag(null); + return; + } if (dragIndex === null && curveIndex === null) return; e.stopPropagation(); setDragIndex(null); @@ -232,7 +289,7 @@ export function useAutomationLaneGestures({ setHint(null); commitPoints(lane.points, true); }, - [curveIndex, dragIndex, lane, commitPoints], + [rangeDrag, onRangeClear, curveIndex, dragIndex, lane, commitPoints], ); const onDoubleClick = useCallback( diff --git a/packages/studio/src/player/components/useAutomationLanes.ts b/packages/studio/src/player/components/useAutomationLanes.ts index e85f1f3b77..1274b12499 100644 --- a/packages/studio/src/player/components/useAutomationLanes.ts +++ b/packages/studio/src/player/components/useAutomationLanes.ts @@ -19,7 +19,9 @@ import { } from "@hyperframes/core/audio-automation"; import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; -import type { TimelineElement } from "../store/playerStore"; +import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import type { AutomationSelection } from "../store/automationSelectionSlice"; import { elementAutomation, elementFxChain } from "./automationLaneData"; export interface AutomationLaneBinding { @@ -39,6 +41,13 @@ export interface AutomationLaneBinding { */ onSelect(): void; readOnly: boolean; + /** This element's active time selection, or null if none / it belongs to a + * different element. */ + selection: AutomationSelection | null; + /** Live write while dragging a range on the given lane; does not persist — + * the selection is ephemeral store state, not part of the composition. */ + onRangeSelect(target: string, t0: number, t1: number): void; + onRangeClear(): void; } export interface UseAutomationLanesResult { @@ -49,11 +58,15 @@ export function useAutomationLanes(): UseAutomationLanesResult { // Optional: the player also runs outside Studio, where there is no edit // session. There the lanes render read-only, which is the right fallback. const domEdit = useDomEditActionsContextOptional(); + const automationSelection = usePlayerStore((s) => s.automationSelection); + const setAutomationSelection = usePlayerStore((s) => s.setAutomationSelection); + const clearAutomationSelection = usePlayerStore((s) => s.clearAutomationSelection); const bind = useCallback( (element: TimelineElement, isSelected: boolean): AutomationLaneBinding => { const chain = elementFxChain(element); const automation = elementAutomation(element); + const elementKey = getTimelineElementIdentity(element); const write = (next: HfAutomation, persist: boolean): void => { if (!domEdit || !isSelected) return; @@ -80,9 +93,15 @@ export function useAutomationLanes(): UseAutomationLanesResult { // Selecting is its own gesture; the lane goes live after it. onSelect: () => void domEdit?.handleTimelineElementSelect(element), readOnly: !domEdit || !isSelected, + selection: automationSelection?.elementKey === elementKey ? automationSelection : null, + onRangeSelect: (target, t0, t1) => { + if (!domEdit || !isSelected) return; + setAutomationSelection({ elementKey, target, t0, t1 }); + }, + onRangeClear: () => clearAutomationSelection(), }; }, - [domEdit], + [domEdit, automationSelection, setAutomationSelection, clearAutomationSelection], ); return useMemo(() => ({ bind }), [bind]); diff --git a/packages/studio/src/player/store/automationSelectionSlice.test.ts b/packages/studio/src/player/store/automationSelectionSlice.test.ts new file mode 100644 index 0000000000..cb1af6c155 --- /dev/null +++ b/packages/studio/src/player/store/automationSelectionSlice.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { usePlayerStore } from "./playerStore"; + +describe("automationSelectionSlice", () => { + it("stores one ordered selection and clears it", () => { + const store = usePlayerStore.getState(); + store.setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 1 }); + const sel = usePlayerStore.getState().automationSelection; + // Ordered on write, so every consumer can assume t0 < t1. + expect(sel).toEqual({ elementKey: "bgm", target: "volume", t0: 1, t1: 2 }); + usePlayerStore.getState().clearAutomationSelection(); + expect(usePlayerStore.getState().automationSelection).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/store/automationSelectionSlice.ts b/packages/studio/src/player/store/automationSelectionSlice.ts new file mode 100644 index 0000000000..8af1440237 --- /dev/null +++ b/packages/studio/src/player/store/automationSelectionSlice.ts @@ -0,0 +1,38 @@ +/** + * The active time selection on one automation lane. + * + * A store slice, not lane-local state, for the same reason keyframe selection + * is one: Delete/copy/paste handlers and the shape menu live outside the lane + * component and need to read it. Ephemeral by construction — nothing + * serializes store state, and the selection must never survive into a render. + */ +import type { StoreApi } from "zustand"; + +export interface AutomationSelection { + /** TimelineElement key (key ?? id) of the clip that owns the lane. */ + elementKey: string; + /** Lane target: "volume" or "fx..". */ + target: string; + /** Clip-local seconds; always t0 < t1 (ordered on write). */ + t0: number; + t1: number; +} + +export interface AutomationSelectionSlice { + automationSelection: AutomationSelection | null; + setAutomationSelection: (sel: AutomationSelection) => void; + clearAutomationSelection: () => void; +} + +export function createAutomationSelectionSlice( + set: StoreApi["setState"], +): AutomationSelectionSlice { + return { + automationSelection: null, + setAutomationSelection: (sel) => + set({ + automationSelection: sel.t0 <= sel.t1 ? sel : { ...sel, t0: sel.t1, t1: sel.t0 }, + }), + clearAutomationSelection: () => set({ automationSelection: null }), + }; +} diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 275021f300..e6274ca347 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -10,6 +10,10 @@ import { } from "../../utils/studioUiPreferences"; import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom"; import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice"; +import { + createAutomationSelectionSlice, + type AutomationSelectionSlice, +} from "./automationSelectionSlice"; export type { KeyframeCacheEntry } from "./keyframeSlice"; @@ -40,7 +44,7 @@ function resolveElementSelection( }; } -interface PlayerState extends KeyframeSlice { +interface PlayerState extends KeyframeSlice, AutomationSelectionSlice { isPlaying: boolean; currentTime: number; duration: number; @@ -296,6 +300,8 @@ export const usePlayerStore = create((set, get) => ({ timelineSessionEpoch: get().timelineSessionEpoch, })), + ...createAutomationSelectionSlice(set), + activeKeyframePct: null, setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }), motionPathArmed: false,