diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index e63222559e..3a29f215bf 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -176,6 +176,22 @@ "withLane", ], }, + // automationShapes is part of the audio-automation stack: its consumer is + // the UI layer that uses shape generators one PR upstack, so a per-PR audit + // diffing against the merge base sees these as unused. Consumed for real once + // the stack merges; safe to drop this entry then. + { + "file": "packages/studio/src/player/components/automationShapes.ts", + "exports": ["AUTOMATION_SHAPES"], + }, + // automationSimplify is part of the audio-automation stack: its consumer is + // the UI layer one PR upstack, so a per-PR audit diffing against the merge + // base sees these as unused. Consumed for real once the stack merges; safe + // to drop this entry then. + { + "file": "packages/studio/src/player/components/automationSimplify.ts", + "exports": ["simplifyPoints"], + }, // propertyPanelAutomation is the shared reader for both panel sections; the // FX group that consumes these two lands one PR upstack, so a per-PR audit // against the merge base sees them as unused. @@ -747,6 +763,11 @@ "packages/parsers/src/gsapParser.ts", // htmlParser.ts has pre-existing complexity (moved from packages/core). "packages/parsers/src/htmlParser.ts", + // automationSimplify.ts: Ramer–Douglas–Peucker algorithm inherently requires + // nested loops and stack-based control flow (12 cyclomatic / 20 cognitive); + // this complexity is by design and not refactorable. Consumed by the UI + // layer one PR upstack in the audio-automation feature stack. + "packages/studio/src/player/components/automationSimplify.ts", // studio-server files: pre-existing complexity (moved from packages/core/src/studio-api/). // files.ts: executeGsapMutationRecast/Acorn are CRITICAL; excluded as files.ts // was already in health.ignore at the old path (packages/core/src/studio-api/routes/files.ts). diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 1ab9230fe8..365504b8d0 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -117,6 +117,13 @@ export function useDomEditSelectionContext(): DomEditSelectionValue { return ctx; } +/** Optional counterpart to useDomEditActionsContextOptional — same reason: the + * player package's own components mount outside a provider in standalone and + * test trees, where "no dom-edit selection" is the correct answer. */ +export function useDomEditSelectionContextOptional(): DomEditSelectionValue | null { + return useContext(DomEditSelectionContext); +} + /** @deprecated Prefer useDomEditActionsContext or useDomEditSelectionContext. */ export function useDomEditContext(): DomEditValue { return { ...useDomEditActionsContext(), ...useDomEditSelectionContext() }; diff --git a/packages/studio/src/hooks/useAppHotkeys.test.ts b/packages/studio/src/hooks/useAppHotkeys.test.ts index 7d7d88fc1d..2920d0f309 100644 --- a/packages/studio/src/hooks/useAppHotkeys.test.ts +++ b/packages/studio/src/hooks/useAppHotkeys.test.ts @@ -1,7 +1,9 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from "vitest"; -import { dispatchPlainKey } from "./useAppHotkeys"; +import { dispatchModifierKey, dispatchPlainKey } from "./useAppHotkeys"; import { usePlayerStore } from "../player/store/playerStore"; +import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard"; +import { VOLUME_RANGE } from "@hyperframes/core/audio-automation"; import type { TimelineElement } from "../player/store/timelineElement"; /** Minimal valid fixture — TimelineElement only requires these five fields. */ @@ -38,7 +40,11 @@ function callbacks() { const press = (key: string) => new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); +const chord = (key: string) => + new KeyboardEvent("keydown", { key, metaKey: true, bubbles: true, cancelable: true }); + afterEach(() => { + clearAutomationClipboard(); usePlayerStore.getState().clearAutomationSelection(); usePlayerStore.setState({ elements: [], @@ -111,3 +117,82 @@ describe("dispatchPlainKey — Delete arbitration", () => { expect(e.defaultPrevented).toBe(true); }); }); + +describe("dispatchModifierKey — Cmd+C/Cmd+V arbitration", () => { + const clip: TimelineElement = { + id: "bgm", + key: "bgm", + tag: "audio", + start: 0, + duration: 6, + track: 0, + }; + + it("lets the clip clipboard have Cmd+C when no automation range is active", () => { + usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" }); + const cb = callbacks(); + dispatchModifierKey(chord("c"), "c", cb); + expect(cb.handleCopy).toHaveBeenCalled(); + }); + + it("keeps Cmd+C from the clip clipboard when an automation range is active", () => { + // Both clipboards arming on one press double-wrote and toasted "Copied clip". + usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" }); + usePlayerStore.getState().setAutomationSelection({ + elementKey: "bgm", + target: "volume", + t0: 1, + t1: 3, + }); + const cb = callbacks(); + const e = chord("c"); + expect(dispatchModifierKey(e, "c", cb)).toBe(true); + expect(cb.handleCopy).not.toHaveBeenCalled(); + // No preventDefault: the automation handler downstream still needs the key. + expect(e.defaultPrevented).toBe(false); + }); + + it("lets the clip clipboard have Cmd+V when the automation clipboard is empty", () => { + // Nothing to paste means nothing to claim — the clip paste should still run. + clearAutomationClipboard(); + usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" }); + usePlayerStore.getState().setAutomationSelection({ + elementKey: "bgm", + target: "volume", + t0: 1, + t1: 3, + }); + const cb = callbacks(); + dispatchModifierKey(chord("v"), "v", cb); + expect(cb.handlePaste).toHaveBeenCalled(); + }); + + it("keeps Cmd+V from duplicating the clip while an automation paste is pending", () => { + clearAutomationClipboard(); + copyRange( + null, + { + target: "volume", + points: [ + { t: 1, v: 1 }, + { t: 3, v: 0.25 }, + ], + }, + VOLUME_RANGE, + 1, + 3, + ); + usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" }); + usePlayerStore.getState().setAutomationSelection({ + elementKey: "bgm", + target: "volume", + t0: 1, + t1: 3, + }); + const cb = callbacks(); + const e = chord("v"); + dispatchModifierKey(e, "v", cb); + expect(cb.handlePaste).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(false); + }); +}); diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index da12f3f2d4..3ae69a7e0d 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -1,10 +1,12 @@ import { useCallback, useEffect, useRef } from "react"; +import { automationOwnsKey } from "./useAutomationSelectionKeyboard"; import { usePlayerStore } from "../player"; import type { TimelineElement } from "../player"; import type { DomEditSelection } from "../components/editor/domEditing"; import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar"; import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion"; import { isTypingTarget } from "../utils/typingTarget"; +import { isEditableTarget } from "../utils/timelineDiscovery"; import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers"; import { canSplitElement } from "../utils/timelineElementSplit"; import { trackStudioEvent } from "../utils/studioTelemetry"; @@ -158,7 +160,14 @@ interface HotkeyCallbacks { showToast: (message: string, tone?: "error" | "info") => void; } -function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): boolean { +/** Exported for tests, like dispatchPlainKey below: lets the Cmd+C/Cmd+V + * arbitration between an automation range and the clip clipboard be asserted + * without standing up the whole hook. */ +export function dispatchModifierKey( + event: KeyboardEvent, + key: string, + cb: HotkeyCallbacks, +): boolean { if ( !shouldIgnoreHistoryShortcut(event.target) && handleUndoRedoKey( @@ -195,7 +204,15 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba return true; } - if (!event.shiftKey && !event.altKey && !isTypingTarget(event.target)) { + if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) { + // An active automation range owns Cmd+C/Cmd+V, the same way it owns Delete + // below. This listener is on window/capture and runs before + // useAutomationSelectionKeyboard's document/capture handler, so without + // this the clip clipboard also claimed the key: Cmd+V duplicated the clip + // while the automation paste wrote the same file, and Cmd+C armed both + // clipboards and toasted "Copied clip". Return without preventDefault so + // the downstream handler still sees the key. + if (automationOwnsKey(event)) return true; if (key === "c") { if (cb.handleCopy()) { event.preventDefault(); diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx index fd24507356..a7f57d4b68 100644 --- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx @@ -1,9 +1,15 @@ // @vitest-environment happy-dom import { act } from "react"; -import { describe, expect, it, vi } from "vitest"; -import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRoot, type Root } from "react-dom/client"; import { usePlayerStore } from "../player/store/playerStore"; import { useAutomationSelectionKeyboard } from "./useAutomationSelectionKeyboard"; +import { + clearAutomationClipboard, + copyRange, + readClipboard, +} from "../player/components/automationClipboard"; +import { VOLUME_RANGE } from "@hyperframes/core/audio-automation"; import type { AutomationLaneBinding, UseAutomationLanesResult, @@ -32,30 +38,60 @@ const key = (k: string) => { act(() => void document.dispatchEvent(e)); }; +/** Cmd/Ctrl-modified key combo, returning the event so tests can inspect + * `defaultPrevented` for the "falls through" cases. */ +const combo = (k: string) => { + const e = new KeyboardEvent("keydown", { + key: k, + metaKey: true, + bubbles: true, + cancelable: true, + }); + act(() => void document.dispatchEvent(e)); + return e; +}; + describe("useAutomationSelectionKeyboard", () => { + // Each setup() mounts a Host whose effect adds a document-level keydown + // listener. Without unmounting the previous one, listeners from earlier + // tests linger and can consume later tests' events first (stopping + // propagation before the current test's own listener ever runs) — so this + // must run before every test, not just the ones that call setup() twice. + let mountedRoot: { root: Root; host: HTMLElement } | null = null; + afterEach(() => { + if (!mountedRoot) return; + act(() => mountedRoot?.root.unmount()); + mountedRoot.host.remove(); + mountedRoot = null; + }); + 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 }, - ], - }, + const automation = { + version: 1, + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 1 }, + { t: 2, v: 0.5 }, + { t: 4, v: 0 }, ], }, - lanes: [], + ], + }; + const lanes: UseAutomationLanesResult = { + bind: () => ({ + automation, + // Same list as `automation.lanes`, matching useAutomationLanes' real + // binding — the paste fallback (no active selection) reads this. + lanes: automation.lanes, chain: null, onPreview: vi.fn(), onCommit, onSelect: vi.fn(), readOnly: false, + commitTargetKey: "bgm", selection: null, onRangeSelect: vi.fn(), onRangeClear: vi.fn(), @@ -64,7 +100,9 @@ describe("useAutomationSelectionKeyboard", () => { }; const host = document.createElement("div"); document.body.append(host); - act(() => createRoot(host).render()); + const root = createRoot(host); + act(() => root.render()); + mountedRoot = { root, host }; return { onCommit }; }; @@ -101,4 +139,168 @@ describe("useAutomationSelectionKeyboard", () => { expect(onCommit).not.toHaveBeenCalled(); input.remove(); }); + + it("Cmd+C copies the active selection", () => { + clearAutomationClipboard(); + usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); + setup({}); + combo("c"); + const entry = readClipboard(null); + expect(entry?.span).toBe(2); + expect(entry?.points.map((p) => p.t)).toEqual([0, 2]); + }); + + it("Cmd+V with no selection pastes at the playhead and selects the pasted span", () => { + clearAutomationClipboard(); + // Duration wide enough that the playhead (5s) is not clamped down by the + // 0..duration-span bound — this is a paste-at-playhead test, not a + // clamp-boundary test. + usePlayerStore.setState({ + elements: [{ ...bgmElement, duration: 10 }], + selectedElementId: "bgm", + currentTime: 5, + }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); + const { onCommit } = setup({}); + combo("c"); + expect(readClipboard(null)?.span).toBe(2); + usePlayerStore.getState().clearAutomationSelection(); + + combo("v"); + const written = onCommit.mock.calls.at(-1)?.[0]; + const times = (written?.lanes?.[0]?.points ?? []).map((p: { t: number }) => p.t); + expect(times).toContain(5); // playhead 5s − element start 0 + expect(times).toContain(7); // + clipboard span 2 + + // Pasting again immediately should land right after the first paste. + expect(usePlayerStore.getState().automationSelection).toEqual({ + elementKey: "bgm", + target: "volume", + t0: 5, + t1: 7, + }); + }); + + it("chains a second Cmd+V after the first instead of overwriting it", () => { + // The regression this pins: paste leaves its own span selected, so anchoring + // at sel.t0 unconditionally made every later press recompute the same atT. + clearAutomationClipboard(); + usePlayerStore.setState({ + elements: [{ ...bgmElement, duration: 10 }], + selectedElementId: "bgm", + }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); + const { onCommit } = setup({}); + combo("c"); + + combo("v"); + const first = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map( + (p: { t: number }) => p.t, + ); + expect(first).toContain(2); + expect(first).toContain(4); + + combo("v"); + const second = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map( + (p: { t: number }) => p.t, + ); + expect(second).toContain(4); + expect(second).toContain(6); + expect(usePlayerStore.getState().automationSelection).toEqual({ + elementKey: "bgm", + target: "volume", + t0: 4, + t1: 6, + }); + }); + + it("refuses to paste when the dom-edit layer would write to a different clip", () => { + // selectedElementId says "bgm" but the commit channel is still on the + // previously selected clip — writing here would serialize bgm's automation + // onto that other clip and leave bgm untouched. + clearAutomationClipboard(); + copyRange(null, { target: "volume", points: [{ t: 0, v: 0.5 }] }, VOLUME_RANGE, 0, 2); + usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); + const { onCommit } = setup({ commitTargetKey: "some-other-clip" }); + const e = combo("v"); + expect(e.defaultPrevented).toBe(false); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("does not paste from a playhead outside the clip", () => { + // No selection on this clip, and the playhead is past its end — there is no + // anchor. This used to collapse to the clip's own t=0. + clearAutomationClipboard(); + usePlayerStore.setState({ + elements: [bgmElement], + selectedElementId: "bgm", + currentTime: 2, + }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); + const { onCommit } = setup({}); + combo("c"); + usePlayerStore.getState().clearAutomationSelection(); + usePlayerStore.setState({ currentTime: 50 }); + onCommit.mockClear(); + + const e = combo("v"); + expect(e.defaultPrevented).toBe(false); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("Cmd+C over an empty lane leaves an earlier clipboard alone", () => { + // An empty capture is byte-identical to the Delete payload, so arming the + // clipboard with it turns every later Cmd+V into a destructive flatten. + clearAutomationClipboard(); + copyRange(null, { target: "volume", points: [{ t: 0, v: 0.5 }] }, VOLUME_RANGE, 0, 3); + usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 1, t1: 2 }); + setup({ automation: { version: 1, lanes: [{ target: "volume", points: [] }] } }); + + const e = combo("c"); + expect(e.defaultPrevented).toBe(false); + expect(readClipboard(null)?.span).toBe(3); + }); + + it("pastes with CapsLock on, where e.key is an uppercase V", () => { + clearAutomationClipboard(); + usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" }); + usePlayerStore + .getState() + .setAutomationSelection({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); + const { onCommit } = setup({}); + combo("C"); + expect(readClipboard(null)?.span).toBe(2); + + onCommit.mockClear(); + const e = combo("V"); + expect(e.defaultPrevented).toBe(true); + expect(onCommit).toHaveBeenCalled(); + }); + + it("Cmd+V with clipboard content but no resolvable element falls through", () => { + clearAutomationClipboard(); + copyRange(null, { target: "volume", points: [{ t: 0, v: 1 }] }, VOLUME_RANGE, 0, 1); + expect(readClipboard(null)).not.toBeNull(); + usePlayerStore.setState({ elements: [bgmElement], selectedElementId: null }); + usePlayerStore.getState().clearAutomationSelection(); + const { onCommit } = setup({}); + const e = combo("v"); + expect(e.defaultPrevented).toBe(false); + expect(onCommit).not.toHaveBeenCalled(); + }); }); diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts index 786a0c683e..354fd7342b 100644 --- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts @@ -1,18 +1,43 @@ /** * 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. + * untouched), Cmd/Ctrl+C copies it, Cmd/Ctrl+V pastes at the selection's + * start (or the playhead) onto the selected clip's lane. 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. + * + * Falling through is NOT enough to keep clip-level copy/paste working: + * useAppHotkeys listens on `window` with capture, so it always runs before this + * document-level listener and `stopImmediatePropagation` here comes too late. + * `automationOwnsKey` below is the arbitration that actually works — the + * central dispatcher asks it first and stands down. */ 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 { + copyRange, + isLastPasteSpan, + markLastPaste, + pastePoints, + readClipboard, +} from "../player/components/automationClipboard"; +import { + resolveAutomationRange, + type AutomationRange, + type HfAutomation, + type HfAutomationLane, +} from "@hyperframes/core/audio-automation"; +import { clampNumber } from "../utils/studioHelpers"; import type { AutomationSelection } from "../player/store/automationSelectionSlice"; -import type { UseAutomationLanesResult } from "../player/components/useAutomationLanes"; +import type { + AutomationLaneBinding, + UseAutomationLanesResult, +} from "../player/components/useAutomationLanes"; + +type PlayerState = ReturnType; function isTextInput(el: Element | null): boolean { if (!el) return false; @@ -21,6 +46,46 @@ function isTextInput(el: Element | null): boolean { return el instanceof HTMLElement && el.isContentEditable; } +/** + * A Cmd/Ctrl+ chord. `e.key` is normalised because CapsLock makes it + * "V"/"C", and useAppHotkeys already lowercases — a raw `e.key === "v"` test + * would silently drop the keystroke here while that dispatcher still acted on + * it. Shift and Alt are excluded for the same parity reason (useAppHotkeys + * gates its own copy/paste on `!shiftKey && !altKey`). + */ +function isChord(e: KeyboardEvent, letter: string): boolean { + return (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === letter; +} + +/** A `TimelineElement`'s identity as the selection and lane bindings key by. */ +function elementKeyOf(element: TimelineElement): string { + return element.key ?? element.id; +} + +function findElement(elements: TimelineElement[], key: string | null): TimelineElement | null { + if (!key) return null; + return elements.find((el) => elementKeyOf(el) === key) ?? null; +} + +/** + * A selection's element, binding, lane and range — the resolution Delete and + * copy both need. Null when the clip is gone, its lane is read-only, or the + * target no longer resolves to a range. + */ +function resolveSelectionContext( + state: PlayerState, + lanes: UseAutomationLanesResult, + sel: AutomationSelection, +): { binding: AutomationLaneBinding; lane: HfAutomationLane; range: AutomationRange } | null { + const element = findElement(state.elements, sel.elementKey); + if (!element) return null; + const binding = lanes.bind(element, sel.elementKey === state.selectedElementId); + if (binding.readOnly) return null; + const range = resolveAutomationRange(sel.target, binding.chain ?? undefined); + if (!range) return null; + return { binding, lane: laneFor(binding.automation, sel.target), range }; +} + /** * 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 @@ -29,24 +94,231 @@ function isTextInput(el: Element | null): boolean { * keyboard dispatch should carry. */ function resolveDeleteWrite( - state: { elements: TimelineElement[]; selectedElementId: string | null }, + state: PlayerState, 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: [] }); + const ctx = resolveSelectionContext(state, lanes, sel); + if (!ctx || ctx.lane.points.length === 0) return null; + const points = replaceRange({ + lane: ctx.lane, + range: ctx.range, + t0: sel.t0, + t1: sel.t1, + inner: [], + }); return { - onCommit: binding.onCommit, - next: withLane(binding.automation, { target: sel.target, points }), + onCommit: ctx.binding.onCommit, + next: withLane(ctx.binding.automation, { target: sel.target, points }), }; } +/** + * The lane target Cmd+V writes to: the active selection's, when the + * selection belongs to the same clip the paste is landing on, else the + * clip's first automation lane. A selection left over on a different clip + * does not redirect the paste. + */ +function pasteTargetName( + binding: AutomationLaneBinding, + elementKey: string, + sel: AutomationSelection | null, +): string | undefined { + if (sel && sel.elementKey === elementKey) return sel.target; + return binding.lanes[0]?.target; +} + +/** + * Where Cmd+V lands, or null when nothing is selected, the clip's lanes are + * read-only, it has no automation lane to fall back to, or the dom-edit layer + * would write the result to a DIFFERENT clip. + * + * That last guard is the one with teeth. `binding.onCommit` persists through + * handleDomAttributeQuietCommit, which targets whatever the dom-edit layer + * currently has selected — not the element `bind()` was handed (see the + * doc-comment on `onSelect` in useAutomationLanes). Selecting a clip in the + * timeline sets `selectedElementId` synchronously but resolves the dom-edit + * selection asynchronously, so clicking clip B and immediately pressing Cmd+V + * would serialize B's automation onto A. Every other lane path is a pointer + * gesture on the lane itself, which cannot run before the selection lands; + * paste is the only one that can, so it refuses rather than write blind. + */ +function resolvePasteTarget( + state: PlayerState, + lanes: UseAutomationLanesResult, + sel: AutomationSelection | null, +): { + elementKey: string; + element: TimelineElement; + target: string; + binding: AutomationLaneBinding; + lane: HfAutomationLane; + range: AutomationRange; +} | null { + const element = findElement(state.elements, state.selectedElementId); + if (!element) return null; + const elementKey = elementKeyOf(element); + const binding = lanes.bind(element, true); + if (binding.readOnly) return null; + if (binding.commitTargetKey !== elementKey) return null; + const target = pasteTargetName(binding, elementKey, sel); + if (!target) return null; + const range = resolveAutomationRange(target, binding.chain ?? undefined); + if (!range) return null; + return { elementKey, element, target, binding, lane: laneFor(binding.automation, target), range }; +} + +/** Is the playhead over this clip? Mirrors TimelineAutomationLaneSlot's own + * in-clip test, which is what decides a lane draws a playhead at all. */ +function playheadInClip(state: PlayerState, element: TimelineElement): boolean { + return ( + state.currentTime >= element.start && state.currentTime <= element.start + element.duration + ); +} + +/** + * Clip-local seconds a `span`-wide paste should start at, or null when nothing + * can anchor it. + * + * Three refusals, all of which used to be silent mispastes: + * - A span wider than the clip has nowhere to go. Clamping the start to 0 still + * writes breakpoints past the clip's end and leaves a selection whose far + * edge can never be grabbed again. + * - A playhead outside the clip is not an anchor. It used to collapse to the + * clip's own t=0, so a playhead at 0:00 pasted into the head of a clip + * starting at 0:30. + * - No selection on this clip and no in-clip playhead means no anchor at all. + * + * A repeated Cmd+V chains. Paste leaves its own span selected (the user's only + * feedback that it landed), so anchoring at `sel.t0` unconditionally made the + * second press overwrite the first. When the live selection is exactly the mark + * the last paste left, anchor at its END; a selection the user drew themselves + * still pastes at its start. + */ +function pasteAnchor( + state: PlayerState, + element: TimelineElement, + span: number, + sel: AutomationSelection | null, +): number | null { + if (span > element.duration) return null; + const onThisElement = sel !== null && sel.elementKey === elementKeyOf(element); + const raw = onThisElement + ? isLastPasteSpan(sel) + ? sel.t1 + : sel.t0 + : playheadInClip(state, element) + ? state.currentTime - element.start + : null; + if (raw === null) return null; + // Keeps the whole pasted span inside the clip — including a chain that has + // walked to the end — so its own selection stays grabbable. + return clampNumber(raw, 0, element.duration - span); +} + +/** + * Cmd/Ctrl+V: paste the clipboard onto the selected clip's lane, at the active + * selection or the playhead. Returns false (untouched event) when the chord + * doesn't match, there is nothing to paste, or no lane can take it. + * Checked ahead of the "no selection" guard in the handler below: paste must + * work from the playhead with no active selection at all. + */ +function handlePaste( + e: KeyboardEvent, + state: PlayerState, + lanes: UseAutomationLanesResult, +): boolean { + if (!isChord(e, "v")) return false; + const clip = readClipboard(state.timelineProjectId); + if (!clip) return false; + const sel = state.automationSelection; + const paste = resolvePasteTarget(state, lanes, sel); + if (!paste) return false; + const atT = pasteAnchor(state, paste.element, clip.span, sel); + if (atT === null) return false; + + const t1 = atT + clip.span; + const inner = pastePoints(clip, paste.range, atT); + const points = replaceRange({ lane: paste.lane, range: paste.range, t0: atT, t1, inner }); + + e.preventDefault(); + e.stopImmediatePropagation(); + paste.binding.onCommit(withLane(paste.binding.automation, { target: paste.target, points })); + // Select the pasted span — the only feedback that it landed — and mark it, so + // an immediate second Cmd+V recognises this selection as the paste's own and + // chains right after it instead of overwriting it. + const mark = { elementKey: paste.elementKey, target: paste.target, t0: atT, t1 }; + state.setAutomationSelection(mark); + markLastPaste(mark); + return true; +} + +/** Cmd/Ctrl+C on the active selection. Returns false when the chord doesn't + * match, the selection no longer resolves to a copyable lane, or the lane is + * empty so there is no shape to capture. */ +function handleCopy( + e: KeyboardEvent, + state: PlayerState, + lanes: UseAutomationLanesResult, + sel: AutomationSelection, +): boolean { + if (!isChord(e, "c")) return false; + const ctx = resolveSelectionContext(state, lanes, sel); + if (!ctx) return false; + if (!copyRange(state.timelineProjectId, ctx.lane, ctx.range, sel.t0, sel.t1)) return false; + e.preventDefault(); + e.stopImmediatePropagation(); + return true; +} + +/** + * Will an automation range claim this keystroke? Asked by useAppHotkeys, which + * listens on `window` with capture and therefore always runs BEFORE this + * hook's document listener — so `stopImmediatePropagation` cannot arbitrate and + * the dispatcher has to stand down of its own accord. Without it Cmd+C armed + * both clipboards and Cmd+V ran the whole clip-duplication path (read file → + * insert → save with history → reload preview) alongside the automation write: + * two async read-modify-writes of one file from one keypress. + * + * Store and clipboard only, no lane binding, so the dispatcher can call it + * without holding the binding factory. That leaves one accepted residual: the + * predicate cannot see a read-only lane, an unresolvable target, or the + * dom-edit target mismatch `resolvePasteTarget` guards, so in those rare cases + * the keystroke is a no-op instead of falling through to clip copy/paste. A + * dead key beats today's double write. + */ +export function automationOwnsKey(e: KeyboardEvent): boolean { + if (isTextInput(document.activeElement)) return false; + const state = usePlayerStore.getState(); + if (isChord(e, "c")) return state.automationSelection !== null; + if (!isChord(e, "v")) return false; + const clip = readClipboard(state.timelineProjectId); + if (!clip) return false; + const element = findElement(state.elements, state.selectedElementId); + if (!element) return false; + // Same anchor resolution the handler uses, so predicate and handler cannot + // disagree about whether the paste has somewhere to land. + return pasteAnchor(state, element, clip.span, state.automationSelection) !== null; +} + +/** Delete/Backspace on the active selection. Returns false when the key + * doesn't match or there is nothing to empty. */ +function handleDelete( + e: KeyboardEvent, + state: PlayerState, + lanes: UseAutomationLanesResult, + sel: AutomationSelection, +): boolean { + const isDeleteKey = e.key === "Delete" || e.key === "Backspace"; + if (!isDeleteKey || e.metaKey || e.ctrlKey) return false; + const write = resolveDeleteWrite(state, lanes, sel); + if (!write) return false; + e.preventDefault(); + e.stopImmediatePropagation(); + write.onCommit(write.next); + return true; +} + export function useAutomationSelectionKeyboard({ lanes, }: { @@ -55,7 +327,16 @@ export function useAutomationSelectionKeyboard({ useEffect(() => { const handler = (e: KeyboardEvent): void => { if (isTextInput(document.activeElement)) return; + // Somebody upstream already claimed this key. useAppHotkeys is on + // window/capture so it always runs first, and it deliberately lets a + // keyframe selection outrank an automation range on Delete — without + // this, that keystroke deleted the keyframes there AND emptied the range + // here, two edits from one press. preventDefault does not stop + // propagation, so the claim has to be read, not assumed. + if (e.defaultPrevented) return; const state = usePlayerStore.getState(); + if (handlePaste(e, state, lanes)) return; + const sel = state.automationSelection; if (!sel) return; @@ -63,15 +344,8 @@ export function useAutomationSelectionKeyboard({ 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); + if (handleCopy(e, state, lanes, sel)) return; + handleDelete(e, state, lanes, sel); }; document.addEventListener("keydown", handler, true); return () => document.removeEventListener("keydown", handler, true); diff --git a/packages/studio/src/player/components/AutomationSelectionMenu.tsx b/packages/studio/src/player/components/AutomationSelectionMenu.tsx new file mode 100644 index 0000000000..4a2bd0d057 --- /dev/null +++ b/packages/studio/src/player/components/AutomationSelectionMenu.tsx @@ -0,0 +1,68 @@ +/** + * Context menu for a right-click inside an automation time selection: the four + * utility shapes, then Simplify. Portal + dismiss handling mirror + * TrackGapContextMenu; rows never vanish — an inapplicable Simplify dims with + * a reason instead of leaving a shorter menu. + */ +import { memo } from "react"; +import { createPortal } from "react-dom"; +import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss"; +import { AUTOMATION_SHAPES, type AutomationShapeId } from "./automationShapes"; + +interface AutomationSelectionMenuProps { + x: number; + y: number; + onClose(): void; + onInsertShape(shape: AutomationShapeId): void; + onSimplify(): void; + /** At least three points in the range — fewer has nothing to thin. */ + canSimplify: boolean; +} + +export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({ + x, + y, + onClose, + onInsertShape, + onSimplify, + canSimplify, +}: AutomationSelectionMenuProps) { + const menuRef = useContextMenuDismiss(onClose); + const row = + "block w-full px-2 py-1 text-left text-[11px] text-panel-text-1 hover:bg-panel-bg-3 disabled:opacity-40"; + return createPortal( +
+ {AUTOMATION_SHAPES.map((shape) => ( + + ))} +
+ +
, + document.body, + ); +}); diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx index 1ae4bca663..c84a019201 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx @@ -649,3 +649,40 @@ describe("TimelineAutomationLane range selection", () => { expect(props.onCommit).toHaveBeenCalled(); }); }); + +describe("TimelineAutomationLane selection menu", () => { + it("right-click inside the selection opens the shape menu", () => { + const { container, svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } }); + fire(svg, "contextmenu", at(2, 0.5)); + expect(document.querySelector(".hf-automation-menu")).not.toBeNull(); + // The menu portals to document.body, outside `container` — dismiss it via + // Escape before tearing down, or it leaks into the next test's DOM query. + const escape = new Event("keydown", { bubbles: true, cancelable: true }); + Object.assign(escape, { key: "Escape" }); + act(() => { + document.dispatchEvent(escape); + }); + expect(document.querySelector(".hf-automation-menu")).toBeNull(); + act(() => container.remove()); + }); + + it("inserting a swell replaces the range and commits once", () => { + const { svg, props } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } }); + fire(svg, "contextmenu", at(2, 0.5)); + const swell = Array.from( + document.querySelectorAll(".hf-automation-menu button"), + ).find((b) => b.textContent === "Swell"); + expect(swell).toBeTruthy(); + act(() => swell?.click()); + expect(props.onCommit).toHaveBeenCalledTimes(1); + const points = + (props.onCommit.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0]?.points ?? []; + expect(points.some((p) => p.t === 2 && p.v === 1)).toBe(true); // peak at range.max + }); + + it("right-click outside the selection does not open it", () => { + const { svg } = mount(ramp, { rangeSelection: { t0: 1, t1: 3 } }); + fire(svg, "contextmenu", at(3.8, 0.5)); + expect(document.querySelector(".hf-automation-menu")).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx index eeaf53895e..835c8111c5 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx @@ -14,7 +14,14 @@ * same principle the property panel's controls follow. */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent as ReactMouseEvent, +} from "react"; import { resolveAutomationRange, sampleAutomationLane, @@ -34,7 +41,11 @@ import { } from "./automationLaneGeometry"; import { useAutomationLaneGestures } from "./useAutomationLaneGestures"; import { AutomationValueInput } from "./AutomationValueInput"; +import { AutomationSelectionMenu } from "./AutomationSelectionMenu"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; +import { generateShape, type AutomationShapeId } from "./automationShapes"; +import { simplifyPoints } from "./automationSimplify"; +import { pointsIn, replaceRange } from "./automationLaneSelection"; import { getTimelineLaneTop } from "./timelineLayout"; import type { TimelineElement } from "../store/playerStore"; import type { UseAutomationLanesResult } from "./useAutomationLanes"; @@ -207,6 +218,44 @@ export function TimelineAutomationLane({ [lane, commitPoints, readOnly], ); + /** Client-coordinate position of an open selection menu, or null when closed. */ + const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null); + + const insertShape = useCallback( + (shape: AutomationShapeId): void => { + if (!rangeSelection) return; + const inner = generateShape({ + shape, + lane, + range, + t0: rangeSelection.t0, + t1: rangeSelection.t1, + }); + commitPoints(replaceRange({ lane, range, ...rangeSelection, inner }), true); + }, + [rangeSelection, lane, range, commitPoints], + ); + + const simplifySelection = useCallback((): void => { + if (!rangeSelection) return; + const inner = simplifyPoints(pointsIn(lane, rangeSelection.t0, rangeSelection.t1), range); + commitPoints(replaceRange({ lane, range, ...rangeSelection, inner }), true); + }, [rangeSelection, lane, range, commitPoints]); + + // A point's own right-click already stops propagation and still deletes; + // this only fires when the press lands on the background inside the + // active selection. + const onSvgContextMenu = useCallback( + (e: ReactMouseEvent): void => { + if (readOnly || !rangeSelection) return; + const { t } = pointAt(e.clientX, e.clientY); + if (t < rangeSelection.t0 || t > rangeSelection.t1) return; + e.preventDefault(); + setMenuAt({ x: e.clientX, y: e.clientY }); + }, + [readOnly, rangeSelection, pointAt], + ); + const currentValue = lane.points.length > 0 && playheadSec !== null ? sampleAutomationLane(lane, playheadSec, range.scale) @@ -247,6 +296,7 @@ export function TimelineAutomationLane({ onPointerUp={gestures.endDrag} onPointerCancel={gestures.endDrag} onDoubleClick={gestures.onDoubleClick} + onContextMenu={onSvgContextMenu} role="group" aria-label={`${range.label} automation`} > @@ -347,6 +397,17 @@ export function TimelineAutomationLane({ {hint}
) : null} + + {menuAt && rangeSelection ? ( + setMenuAt(null)} + onInsertShape={insertShape} + onSimplify={simplifySelection} + canSimplify={pointsIn(lane, rangeSelection.t0, rangeSelection.t1).length >= 3} + /> + ) : null} ); } diff --git a/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx index c08f25af44..3684a85524 100644 --- a/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx @@ -28,6 +28,7 @@ function mountSlot(binding: Partial) { onCommit: vi.fn(), onSelect: vi.fn(), readOnly: false, + commitTargetKey: "bgm", selection: null, onRangeSelect: vi.fn(), onRangeClear, diff --git a/packages/studio/src/player/components/automationClipboard.test.ts b/packages/studio/src/player/components/automationClipboard.test.ts new file mode 100644 index 0000000000..38527a5ebf --- /dev/null +++ b/packages/studio/src/player/components/automationClipboard.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearAutomationClipboard, + copyRange, + pastePoints, + readClipboard, +} from "./automationClipboard"; +import { resolveAutomationRange, VOLUME_RANGE } from "@hyperframes/core/audio-automation"; +import type { HfAutomationLane } from "@hyperframes/core/audio-automation"; + +const duck: HfAutomationLane = { + target: "volume", + points: [ + { t: 2, v: 1, curve: -0.4 }, + { t: 3, v: 0.25 }, + { t: 4, v: 1 }, + ], +}; + +beforeEach(clearAutomationClipboard); + +describe("automation clipboard", () => { + it("copies the range rebased to zero", () => { + copyRange("project-a", duck, VOLUME_RANGE, 2, 4); + const entry = readClipboard("project-a"); + expect(entry?.span).toBe(2); + expect(entry?.points.map((p) => p.t)).toEqual([0, 1, 2]); + expect(entry?.points[0]?.curve).toBe(-0.4); + }); + + it("pastes at a new time on the same axis unchanged", () => { + copyRange("project-a", duck, VOLUME_RANGE, 2, 4); + const entry = readClipboard("project-a"); + expect(entry).not.toBeNull(); + if (!entry) return; + const pts = pastePoints(entry, VOLUME_RANGE, 10); + expect(pts.map((p) => p.t)).toEqual([10, 11, 12]); + expect(pts.map((p) => p.v)).toEqual([1, 0.25, 1]); + }); + + it("maps values through unit space onto a different parameter", () => { + const wet = resolveAutomationRange("fx.r.wet", { + version: 1, + nodes: [{ type: "reverb", id: "r", params: {} }], + }); + expect(wet).toBeTruthy(); + if (!wet) return; + copyRange("project-a", duck, VOLUME_RANGE, 2, 4); + const entry = readClipboard("project-a"); + if (!entry) return; + const pts = pastePoints(entry, wet, 0); + // volume 1 (unit 1) → wet max; volume 0.25 (unit 0.25) → a quarter up wet's axis + expect(pts[0]?.v).toBeCloseTo(wet.max, 5); + expect(pts[1]?.v).toBeCloseTo(wet.min + 0.25 * (wet.max - wet.min), 5); + }); + + it("reads null when nothing was copied", () => { + expect(readClipboard("project-a")).toBeNull(); + }); + + it("does not hand a range copied in one project to another", () => { + copyRange("project-a", duck, VOLUME_RANGE, 2, 4); + expect(readClipboard("project-b")).toBeNull(); + }); + + it("drops the entry for good once another project has read past it", () => { + copyRange("project-a", duck, VOLUME_RANGE, 2, 4); + readClipboard("project-b"); + // Not merely hidden from B: switching back must not resurrect a shape whose + // source clip may have been edited or deleted while the project was closed. + expect(readClipboard("project-a")).toBeNull(); + }); + + it("keeps serving the entry inside its own project", () => { + copyRange("project-a", duck, VOLUME_RANGE, 2, 4); + expect(readClipboard("project-a")?.span).toBe(2); + expect(readClipboard("project-a")?.span).toBe(2); + }); +}); diff --git a/packages/studio/src/player/components/automationClipboard.ts b/packages/studio/src/player/components/automationClipboard.ts new file mode 100644 index 0000000000..37bd71a198 --- /dev/null +++ b/packages/studio/src/player/components/automationClipboard.ts @@ -0,0 +1,136 @@ +/** + * Internal clipboard for automation ranges. Module-level, not the OS + * clipboard — points are not text, and useClipboard is already the DOM-element + * channel. Values cross parameters through unit space, so a volume duck + * pasted onto a log-scaled wet knob lands proportionally, not literally. + * + * Project-scoped by itself rather than by a caller, because the failure is + * destructive and silent: a range copied in project A pasted into B is remapped + * through A's captured `sourceRange` for an FX node B may not even have, and the + * keystroke is consumed so clip paste never runs. Every entry point carries the + * project it is speaking for and a mismatch empties the module, so no future + * caller can forget the guard — the same shape `keyframeSlice` uses to discard a + * request from a previous session. + */ +import { + sampleAutomationLane, + type AutomationRange, + type HfAutomationLane, + type HfAutomationPoint, +} from "@hyperframes/core/audio-automation"; +import { fromUnit, toUnit } from "./automationLaneGeometry"; +import { pointsIn } from "./automationLaneSelection"; + +export interface AutomationClipboardEntry { + sourceRange: AutomationRange; + span: number; + points: HfAutomationPoint[]; +} + +/** The span one paste covered, in the same shape as the selection it left. */ +export interface AutomationPasteMark { + elementKey: string; + target: string; + t0: number; + t1: number; +} + +let ownerProjectId: string | null = null; +let entry: AutomationClipboardEntry | null = null; +let lastPaste: AutomationPasteMark | null = null; + +/** + * Rebind the module to `projectId`, discarding anything captured under a + * different one. Called by both entry points, so the mark is scoped + * transitively: `isLastPasteSpan` can only ever see a mark left by a paste in + * the project that most recently read the clipboard. + * + * `null` (no timeline session yet) is a project id like any other — it can hold + * an entry, and the first real session id evicts it. + */ +function useProject(projectId: string | null): void { + if (projectId === ownerProjectId) return; + ownerProjectId = projectId; + entry = null; + lastPaste = null; +} + +/** + * Capture `[t0, t1]` rebased to zero. Returns false — leaving the clipboard as + * it was — when the lane has nothing to capture. + * + * `pointsIn` reports only explicit breakpoints, so a range drawn over a smooth + * stretch of envelope has none. Storing that as `points: []` would arm a + * clipboard whose paste is byte-identical to the Delete write, so every later + * Cmd+V would erase its destination instead of reproducing the copied shape. + * Over a lane that HAS an envelope the range is a real flat or sloped segment, + * captured by sampling both edges (what `replaceRange`'s own anchors do). Over + * an entirely empty lane there is no shape at all, so nothing is stored — a + * failed copy leaves an earlier clipboard alone rather than destroying it. + */ +export function copyRange( + projectId: string | null, + lane: HfAutomationLane, + range: AutomationRange, + t0: number, + t1: number, +): boolean { + const inner = pointsIn(lane, t0, t1).map((p) => ({ ...p, t: p.t - t0 })); + if (inner.length === 0 && lane.points.length === 0) return false; + useProject(projectId); + entry = { + sourceRange: range, + span: t1 - t0, + points: + inner.length > 0 + ? inner + : [ + { t: 0, v: sampleAutomationLane(lane, t0, range.scale) }, + { t: t1 - t0, v: sampleAutomationLane(lane, t1, range.scale) }, + ], + }; + return true; +} + +export function readClipboard(projectId: string | null): AutomationClipboardEntry | null { + useProject(projectId); + return entry; +} + +export function pastePoints( + from: AutomationClipboardEntry, + target: AutomationRange, + atT: number, +): HfAutomationPoint[] { + return from.points.map((p) => ({ + ...p, + t: atT + p.t, + v: fromUnit(target, toUnit(from.sourceRange, p.v)), + })); +} + +/** + * Remember the span a paste just covered and left selected, so the next Cmd+V + * can tell that selection apart from one the user drew and chain after it + * instead of overwriting it. + */ +export function markLastPaste(mark: AutomationPasteMark): void { + lastPaste = { ...mark }; +} + +/** True when `mark` is exactly the span the last paste left selected. */ +export function isLastPasteSpan(mark: AutomationPasteMark): boolean { + if (!lastPaste) return false; + return ( + lastPaste.elementKey === mark.elementKey && + lastPaste.target === mark.target && + lastPaste.t0 === mark.t0 && + lastPaste.t1 === mark.t1 + ); +} + +export function clearAutomationClipboard(): void { + ownerProjectId = null; + entry = null; + lastPaste = null; +} diff --git a/packages/studio/src/player/components/automationShapes.test.ts b/packages/studio/src/player/components/automationShapes.test.ts new file mode 100644 index 0000000000..398fd95625 --- /dev/null +++ b/packages/studio/src/player/components/automationShapes.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { generateShape } from "./automationShapes"; +import { resolveAutomationRange, VOLUME_RANGE } from "@hyperframes/core/audio-automation"; +import type { HfAutomationLane } from "@hyperframes/core/audio-automation"; + +const flat: HfAutomationLane = { + target: "volume", + points: [ + { t: 0, v: 0.8 }, + { t: 6, v: 0.8 }, + ], +}; + +describe("generateShape", () => { + it("ramp-up fades in from the floor to the envelope's own value", () => { + const pts = generateShape({ shape: "ramp-up", lane: flat, range: VOLUME_RANGE, t0: 1, t1: 3 }); + expect(pts).toEqual([ + { t: 1, v: VOLUME_RANGE.min }, + { t: 3, v: 0.8 }, + ]); + }); + + it("ramp-down fades out from the envelope's own value", () => { + const pts = generateShape({ + shape: "ramp-down", + lane: flat, + range: VOLUME_RANGE, + t0: 1, + t1: 3, + }); + expect(pts).toEqual([ + { t: 1, v: 0.8 }, + { t: 3, v: VOLUME_RANGE.min }, + ]); + }); + + it("swell peaks at range max mid-selection, smoothed", () => { + const pts = generateShape({ shape: "swell", lane: flat, range: VOLUME_RANGE, t0: 1, t1: 3 }); + expect(pts).toHaveLength(3); + expect(pts[1]).toMatchObject({ t: 2, v: VOLUME_RANGE.max }); + expect(pts[0]?.curve).toBeDefined(); // eased, not a triangle + }); + + it("dip ducks to a quarter of the edge value in unit space", () => { + const pts = generateShape({ shape: "dip", lane: flat, range: VOLUME_RANGE, t0: 1, t1: 3 }); + // volume is linear 0..1: unit(0.8) = 0.8, floor = 0.2 + expect(pts[1]?.v).toBeCloseTo(0.2, 5); + }); + + it("computes in unit space on a log lane", () => { + const range = resolveAutomationRange("fx.n1.frequency", { + version: 1, + nodes: [{ type: "lowpass", id: "n1", params: {} }], + }); + expect(range?.scale).toBe("log"); + if (!range) return; + const lane: HfAutomationLane = { + target: "fx.n1.frequency", + points: [ + { t: 0, v: 2000 }, + { t: 6, v: 2000 }, + ], + }; + const pts = generateShape({ shape: "dip", lane, range, t0: 1, t1: 3 }); + const floor = pts[1]?.v ?? 0; + // A quarter of the way up the LOG axis, not 500 Hz. + expect(floor).toBeGreaterThan(range.min); + expect(floor).toBeLessThan(2000 * 0.25); + }); + + it("uses the range default when the lane is empty", () => { + const empty: HfAutomationLane = { target: "volume", points: [] }; + const pts = generateShape({ + shape: "ramp-down", + lane: empty, + range: VOLUME_RANGE, + t0: 1, + t1: 3, + }); + expect(pts[0]?.v).toBe(VOLUME_RANGE.default); + }); +}); diff --git a/packages/studio/src/player/components/automationShapes.ts b/packages/studio/src/player/components/automationShapes.ts new file mode 100644 index 0000000000..53904e7181 --- /dev/null +++ b/packages/studio/src/player/components/automationShapes.ts @@ -0,0 +1,70 @@ +/** + * The utility shapes a video author reaches for: fade in, fade out, swell, + * duck. One shape scaled to the selection — this is not a DAW, nobody needs a + * tempo-synced LFO. Edge values come from the envelope itself so a shape + * splices into whatever is already there; vertical maths runs in unit space so + * a log knob (frequency) behaves like the lane that draws it. + */ +import { + sampleAutomationLane, + type AutomationRange, + type HfAutomationLane, + type HfAutomationPoint, +} from "@hyperframes/core/audio-automation"; +import { fromUnit, toUnit } from "./automationLaneGeometry"; + +export type AutomationShapeId = "ramp-up" | "ramp-down" | "swell" | "dip"; + +export const AUTOMATION_SHAPES: ReadonlyArray<{ id: AutomationShapeId; label: string }> = [ + { id: "ramp-up", label: "Ramp up" }, + { id: "ramp-down", label: "Ramp down" }, + { id: "swell", label: "Swell" }, + { id: "dip", label: "Dip" }, +]; + +/** Ease used on the segments entering/leaving a swell or dip midpoint. */ +const SMOOTH = 0.4; +/** A dip ducks to this fraction of the edge value, in unit space. */ +const DIP_FLOOR = 0.25; + +function edgeValue(lane: HfAutomationLane, range: AutomationRange, t: number): number { + if (lane.points.length === 0) return range.default ?? (range.min + range.max) / 2; + return sampleAutomationLane(lane, t, range.scale); +} + +export function generateShape(input: { + shape: AutomationShapeId; + lane: HfAutomationLane; + range: AutomationRange; + t0: number; + t1: number; +}): HfAutomationPoint[] { + const { shape, lane, range, t0, t1 } = input; + const v0 = edgeValue(lane, range, t0); + const v1 = edgeValue(lane, range, t1); + const mid = (t0 + t1) / 2; + switch (shape) { + case "ramp-up": + return [ + { t: t0, v: range.min }, + { t: t1, v: v1 }, + ]; + case "ramp-down": + return [ + { t: t0, v: v0 }, + { t: t1, v: range.min }, + ]; + case "swell": + return [ + { t: t0, v: v0, curve: SMOOTH }, + { t: mid, v: range.max, curve: -SMOOTH }, + { t: t1, v: v1 }, + ]; + case "dip": + return [ + { t: t0, v: v0, curve: -SMOOTH }, + { t: mid, v: fromUnit(range, toUnit(range, v0) * DIP_FLOOR), curve: SMOOTH }, + { t: t1, v: v1 }, + ]; + } +} diff --git a/packages/studio/src/player/components/automationSimplify.test.ts b/packages/studio/src/player/components/automationSimplify.test.ts new file mode 100644 index 0000000000..31878b19e0 --- /dev/null +++ b/packages/studio/src/player/components/automationSimplify.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { simplifyPoints } from "./automationSimplify"; +import { VOLUME_RANGE } from "@hyperframes/core/audio-automation"; +import { toUnit } from "./automationLaneGeometry"; +import type { HfAutomationPoint } from "@hyperframes/core/audio-automation"; + +describe("simplifyPoints", () => { + it("collapses collinear runs to their endpoints", () => { + const line: HfAutomationPoint[] = Array.from({ length: 50 }, (_, i) => ({ + t: i * 0.1, + v: 1 - i * 0.01, + })); + const out = simplifyPoints(line, VOLUME_RANGE); + expect(out).toHaveLength(2); + expect(out[0]).toEqual(line[0]); + expect(out[out.length - 1]).toEqual(line[line.length - 1]); + }); + + it("keeps every survivor within epsilon of the original", () => { + const wave: HfAutomationPoint[] = Array.from({ length: 100 }, (_, i) => ({ + t: i * 0.05, + v: 0.5 + 0.4 * Math.sin(i * 0.2), + })); + const out = simplifyPoints(wave, VOLUME_RANGE, 0.02); + expect(out.length).toBeLessThan(wave.length / 2); + // Every dropped point must sit within epsilon (unit space) of the + // simplified polyline — check by linear interpolation between survivors. + for (const p of wave) { + const rIdx = out.findIndex((q) => q.t >= p.t); + const b = out[rIdx] ?? out[out.length - 1]; + const a = out[rIdx - 1] ?? b; + if (!a || !b) continue; + const span = b.t - a.t; + const f = span > 0 ? (p.t - a.t) / span : 0; + const approx = + toUnit(VOLUME_RANGE, a.v) + f * (toUnit(VOLUME_RANGE, b.v) - toUnit(VOLUME_RANGE, a.v)); + expect(Math.abs(approx - toUnit(VOLUME_RANGE, p.v))).toBeLessThanOrEqual(0.021); + } + }); + + it("returns short inputs untouched", () => { + const two: HfAutomationPoint[] = [ + { t: 0, v: 1 }, + { t: 1, v: 0 }, + ]; + expect(simplifyPoints(two, VOLUME_RANGE)).toEqual(two); + }); +}); diff --git a/packages/studio/src/player/components/automationSimplify.ts b/packages/studio/src/player/components/automationSimplify.ts new file mode 100644 index 0000000000..7a9ea99e32 --- /dev/null +++ b/packages/studio/src/player/components/automationSimplify.ts @@ -0,0 +1,51 @@ +/** + * Ramer–Douglas–Peucker over an envelope's points, deviation measured + * VERTICALLY in unit space. Vertical (not perpendicular) because an envelope + * is a function of time — what matters is how far the value strays, and it + * keeps the metric independent of the time axis' units. Exists for dense + * producers: carve output and heavy hand edits. + */ +import type { AutomationRange, HfAutomationPoint } from "@hyperframes/core/audio-automation"; +import { toUnit } from "./automationLaneGeometry"; + +export function simplifyPoints( + points: HfAutomationPoint[], + range: AutomationRange, + epsilon = 0.02, +): HfAutomationPoint[] { + if (points.length <= 2) return points; + const keep = new Array(points.length).fill(false); + const last = keep.length - 1; + keep[0] = true; + keep[last] = true; + + const stack: Array<[number, number]> = [[0, last]]; + while (stack.length > 0) { + const seg = stack.pop(); + if (!seg) break; + const [a, b] = seg; + const pa = points[a]; + const pb = points[b]; + if (!pa || !pb || b - a < 2) continue; + const ua = toUnit(range, pa.v); + const ub = toUnit(range, pb.v); + const span = pb.t - pa.t; + let worst = -1; + let worstDev = epsilon; + for (let i = a + 1; i < b; i += 1) { + const p = points[i]; + if (!p) continue; + const f = span > 0 ? (p.t - pa.t) / span : 0; + const dev = Math.abs(toUnit(range, p.v) - (ua + f * (ub - ua))); + if (dev > worstDev) { + worstDev = dev; + worst = i; + } + } + if (worst >= 0) { + keep[worst] = true; + stack.push([a, worst], [worst, b]); + } + } + return points.filter((_, i) => keep[i]); +} diff --git a/packages/studio/src/player/components/useAutomationLanes.ts b/packages/studio/src/player/components/useAutomationLanes.ts index 1274b12499..1862d4d33c 100644 --- a/packages/studio/src/player/components/useAutomationLanes.ts +++ b/packages/studio/src/player/components/useAutomationLanes.ts @@ -18,7 +18,11 @@ import { type HfAutomationLane, } from "@hyperframes/core/audio-automation"; import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; -import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; +import { + useDomEditActionsContextOptional, + useDomEditSelectionContextOptional, +} from "../../contexts/DomEditContext"; +import { resolveTimelineIdForSelection } from "../../utils/studioHelpers"; import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import type { AutomationSelection } from "../store/automationSelectionSlice"; @@ -41,6 +45,15 @@ export interface AutomationLaneBinding { */ onSelect(): void; readOnly: boolean; + /** + * The timeline clip `onCommit`/`onPreview` will ACTUALLY persist to, which is + * whatever the dom-edit layer has selected — not necessarily the element this + * binding was made for (see `onSelect` below). Null outside an edit session or + * when the dom-edit selection maps to no clip. Resolved exactly the way + * applyDomSelection resolves it, so in a settled selection it equals the bound + * element's key; it lags only in the window a non-gesture caller can hit. + */ + commitTargetKey: string | null; /** This element's active time selection, or null if none / it belongs to a * different element. */ selection: AutomationSelection | null; @@ -58,10 +71,23 @@ 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 domEditSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null; + const elements = usePlayerStore((s) => s.elements); const automationSelection = usePlayerStore((s) => s.automationSelection); const setAutomationSelection = usePlayerStore((s) => s.setAutomationSelection); const clearAutomationSelection = usePlayerStore((s) => s.clearAutomationSelection); + // Read from the SAME render as the commit handlers below: both contexts update + // in one commit, so a handler and this key can never describe different + // moments. activeCompPath is not needed — resolveTimelineIdForSelection only + // uses it as a fallback for a selection with no sourceFile of its own, and + // DomEditSelection always carries one. + const commitTargetKey = useMemo( + () => + domEditSelection ? resolveTimelineIdForSelection(domEditSelection, elements, null) : null, + [domEditSelection, elements], + ); + const bind = useCallback( (element: TimelineElement, isSelected: boolean): AutomationLaneBinding => { const chain = elementFxChain(element); @@ -93,6 +119,7 @@ export function useAutomationLanes(): UseAutomationLanesResult { // Selecting is its own gesture; the lane goes live after it. onSelect: () => void domEdit?.handleTimelineElementSelect(element), readOnly: !domEdit || !isSelected, + commitTargetKey: domEdit ? commitTargetKey : null, selection: automationSelection?.elementKey === elementKey ? automationSelection : null, onRangeSelect: (target, t0, t1) => { if (!domEdit || !isSelected) return; @@ -101,7 +128,13 @@ export function useAutomationLanes(): UseAutomationLanesResult { onRangeClear: () => clearAutomationSelection(), }; }, - [domEdit, automationSelection, setAutomationSelection, clearAutomationSelection], + [ + domEdit, + commitTargetKey, + automationSelection, + setAutomationSelection, + clearAutomationSelection, + ], ); return useMemo(() => ({ bind }), [bind]); diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts index d03b4238cf..6d0d9d24b1 100644 --- a/packages/studio/src/player/store/playerStore.test.ts +++ b/packages/studio/src/player/store/playerStore.test.ts @@ -557,6 +557,21 @@ describe("usePlayerStore", () => { expectResettableDefaults(usePlayerStore.getState()); }); + it("drops an automation time selection on reset and on a project switch", () => { + const sel = { elementKey: "bgm", target: "volume", t0: 1, t1: 2 }; + + usePlayerStore.getState().setAutomationSelection(sel); + usePlayerStore.getState().reset(); + expect(usePlayerStore.getState().automationSelection).toBeNull(); + + // The switch matters more than reset(): a stale elementKey can match a + // same-keyed clip in the new project and redirect a paste to its old t0. + usePlayerStore.getState().beginTimelineSession("project-a"); + usePlayerStore.getState().setAutomationSelection(sel); + usePlayerStore.getState().beginTimelineSession("project-b"); + expect(usePlayerStore.getState().automationSelection).toBeNull(); + }); + it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => { const store = usePlayerStore.getState(); store.setPlaybackRate(2); diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index ef60fb1e8a..4523d85771 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -237,6 +237,10 @@ export function createTimelineResetState() { motionPathArmed: false, motionPathCreateAvailable: false, selectedKeyframes: new Set(), + // Ephemeral like every other selection here. A range surviving a project + // switch can match a same-keyed clip in the new project and redirect a + // paste through `sel.elementKey === paste.elementKey` to a stale t0. + automationSelection: null, expandedClipIds: new Set(), focusedEaseSegment: null, selectedElementIds: new Set(),