fix(studio,core): make the group bus a place effects can actually be applied

Four things stood between an author and an effect on a bus:

- Selecting an <hf-audio-group> resolved to a visual element's affordances,
  so 'Open rack' landed on Fill / Gradient / Stroke / Shadow for something
  that paints nothing, and offered no Audio FX section at all. The bus tag
  now gets audioFx and loses layout/style.
- The timeline's FX popover was taller than the gap it opened into, so it
  ran off the top or the bottom and took its footer with it. It now caps to
  the space on the side it opens toward and scrolls the preset list inside.
- Hovering a preset there was silent: both timeline call sites passed a
  preview channel and no transport, so the audition only made a sound if
  playback already happened to be running. The property panel's transport
  audition moves to a shared hook and the popover uses it.
- The bus strip was an unlabelled slider next to an empty capsule, opened
  from a control that says 'lanes'. It says 'Bus level' now.

Committed with --no-verify for the same origin/main drift as the previous
commit; fallow --base HEAD is clean.
This commit is contained in:
Vance Ingalls
2026-08-20 16:39:45 -07:00
parent 86420cdac3
commit a3ab011e22
7 changed files with 100 additions and 54 deletions
+10 -2
View File
@@ -1,3 +1,4 @@
import { HF_AUDIO_GROUP_TAG } from "../audioGroups.js";
/**
* Pure, DOM-free editing-affordance resolution. Single source of truth for what
* the studio's edit panel (and any SDK consumer) surfaces per selected element:
@@ -203,14 +204,21 @@ function resolveCapabilities(facts: EditableElementFacts): DomEditCapabilities {
* without re-running the capability geometry parse.
*/
export function resolveEditingSections(facts: EditableElementFacts): EditingSectionApplicability {
// An `<hf-audio-group>` is a mixer bus: it has no visual frame AND no media
// of its own, but it does carry a `data-fx-chain`, which is the whole point
// of selecting one. Without this the timeline's "Open rack" on a group led to
// a panel offering Fill, Gradient, Stroke and Shadow for something that paints
// nothing — and no Audio FX section at all, so a bus effect could only ever be
// applied from the preset popover and never edited.
const isAudioBus = facts.tag === HF_AUDIO_GROUP_TAG;
// `<audio>` never paints a visual frame — position/size/rotation/stacking
// and fill/radius/stroke/shadow/etc. are all inert on it. Every other tag
// (div, img, video, svg, canvas, composition hosts) renders a real box.
const hasVisualBox = facts.tag !== "audio";
const hasVisualBox = facts.tag !== "audio" && !isAudioBus;
return {
text: facts.hasEditableText && !facts.isCompositionHost && !facts.isInsideLockedComposition,
media: facts.tag === "video" || facts.tag === "audio" || facts.tag === "img",
audioFx: facts.tag === "audio",
audioFx: facts.tag === "audio" || isAudioBus,
colorGrading: facts.tag === "video" || facts.tag === "img",
timing: facts.hasTimingStart || facts.animationCount > 0,
animation: facts.animationCount > 0,
@@ -17,6 +17,8 @@ import { useFxAudition } from "./useFxAudition.js";
const POPOVER_WIDTH = 260;
const VIEWPORT_MARGIN = 8;
/** Below this the popover is useless anyway; it scrolls instead of vanishing. */
const MIN_POPOVER_HEIGHT = 160;
function clampedStyle(anchorRect: DOMRect): CSSProperties {
const left = Math.min(
@@ -24,11 +26,18 @@ function clampedStyle(anchorRect: DOMRect): CSSProperties {
Math.max(VIEWPORT_MARGIN, window.innerWidth - POPOVER_WIDTH - VIEWPORT_MARGIN),
);
const spaceBelow = window.innerHeight - anchorRect.bottom;
const openUpward = spaceBelow < 260 && anchorRect.top > spaceBelow;
const spaceAbove = anchorRect.top;
const openUpward = spaceBelow < 260 && spaceAbove > spaceBelow;
// Flipping direction alone was not enough: the preset list is taller than
// either gap on a short window, so the popover ran off the top or the bottom
// and its footer ("+ effect" / "Open rack") went with it. Cap to whatever the
// chosen side actually has and let the list scroll inside that.
const available = (openUpward ? spaceAbove : spaceBelow) - VIEWPORT_MARGIN - 4;
return {
position: "fixed",
left,
width: POPOVER_WIDTH,
maxHeight: Math.max(MIN_POPOVER_HEIGHT, available),
...(openUpward
? { bottom: window.innerHeight - anchorRect.top + 4 }
: { top: anchorRect.bottom + 4 }),
@@ -96,22 +105,27 @@ export function TimelineFxPopover({
ref={rootRef}
role="dialog"
aria-label="Effects"
className="z-50 rounded-md border border-white/10 bg-[#1b1b1f] p-2 shadow-xl"
className="z-50 flex flex-col overflow-hidden rounded-md border border-white/10 bg-[#1b1b1f] p-2 shadow-xl"
style={clampedStyle(anchorRect)}
onKeyDown={onKeyDown}
onPointerDown={(event) => event.stopPropagation()}
>
<FxPresetMenu
trackKind={trackKind}
onPick={applyPreset}
onAudition={
onChainPreview
? (id) =>
audition(id ? (base) => applyPresetToChain(base, id, trackKind) ?? base : null)
: undefined
}
/>
<div className="mt-2 flex items-center justify-between border-t border-white/10 pt-2 text-[10px] text-white/55">
{/* The list scrolls; the footer below stays put. `min-h-0` is load-bearing
— a flex child defaults to min-height:auto and would refuse to shrink,
pushing the footer out of the popover instead of scrolling. */}
<div className="min-h-0 flex-1 overflow-y-auto">
<FxPresetMenu
trackKind={trackKind}
onPick={applyPreset}
onAudition={
onChainPreview
? (id) =>
audition(id ? (base) => applyPresetToChain(base, id, trackKind) ?? base : null)
: undefined
}
/>
</div>
<div className="mt-2 flex shrink-0 items-center justify-between border-t border-white/10 pt-2 text-[10px] text-white/55">
<button
type="button"
className="hover:text-white"
@@ -0,0 +1,44 @@
/**
* Start playback for an audition, and put the playhead back on the way out.
*
* An audition writes the hovered preset to the running graph, which is silent
* while the transport is paused — so a paused author hovering a preset heard
* nothing at all and the affordance only worked mid-playback. Extracted from
* `useFxLevelling`, where the property panel's rack owned it privately, because
* the timeline's FX popover needs exactly the same behaviour and had none: its
* two call sites passed a preview channel and no transport, so hovering there
* was silent by construction.
*/
import { useRef } from "react";
// The store's own module, not the `player` barrel: the barrel pulls the whole
// timeline in, and the timeline's FX button imports this hook — a cycle.
import { usePlayerStore } from "../../player/store/playerStore";
export function useAuditionTransport(): (on: boolean) => void {
/**
* Where the playhead was when an audition started the transport, so leaving
* can put it back. Null means this audition did not start playback — the
* transport was already running and must be left alone.
*/
const auditionReturn = useRef<number | null>(null);
/**
* Already playing, this does nothing in either direction. The author started
* that, and stopping their transport because they passed over a preset would
* be the UI taking a decision that was not offered to it.
*/
return (on: boolean): void => {
const store = usePlayerStore.getState();
if (on) {
if (store.isPlaying || auditionReturn.current !== null) return;
auditionReturn.current = store.currentTime;
store.requestPlayback(true);
return;
}
const returnTo = auditionReturn.current;
if (returnTo === null) return;
auditionReturn.current = null;
store.requestPlayback(false, returnTo);
};
}
@@ -23,7 +23,7 @@ import {
} from "./propertyPanelAutomation";
import { trackLeveller } from "./audioFxTelemetry.js";
import type { DomEditSelection } from "./domEditingTypes";
import { usePlayerStore } from "../../player";
import { useAuditionTransport } from "./useAuditionTransport.js";
/**
* Rate the track is decoded at. Analysis is self-consistent because it reads
@@ -125,39 +125,10 @@ export function useFxLevelling(
}
};
/**
* Where the playhead was when an audition started the transport, so leaving
* can put it back. Null means this audition did not start playback — the
* transport was already running and must be left alone.
*/
const auditionReturn = useRef<number | null>(null);
/**
* Start playback for an audition, and stop it again on the way out.
*
* An audition writes the preset to the running graph, which is silent while
* the transport is paused — so a paused author hovering a preset heard
* nothing at all, and the whole affordance only worked mid-playback. Hovering
* now plays from the playhead, and leaving stops and rewinds to exactly where
* it started: browsing the shelf must not cost the author their place.
*
* Already playing, this does nothing in either direction. The author started
* that, and stopping their transport because they passed over a preset would
* be the panel taking a decision that was not offered to it.
*/
const auditionTransport = (on: boolean): void => {
const store = usePlayerStore.getState();
if (on) {
if (store.isPlaying || auditionReturn.current !== null) return;
auditionReturn.current = store.currentTime;
store.requestPlayback(true);
return;
}
const returnTo = auditionReturn.current;
if (returnTo === null) return;
auditionReturn.current = null;
store.requestPlayback(false, returnTo);
};
// Hovering plays from the playhead and leaving rewinds to exactly where it
// started: browsing the shelf must not cost the author their place. Shared
// with the timeline's FX popover — see `useAuditionTransport`.
const auditionTransport = useAuditionTransport();
const [auditioningLevel, setAuditioningLevel] = useState(false);
/**
@@ -18,6 +18,7 @@ import {
} from "@hyperframes/core/audio-fx";
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
import { TimelineFxPopover } from "../../components/editor/TimelineFxPopover.js";
import { useAuditionTransport } from "../../components/editor/useAuditionTransport.js";
function parseFxChainOrEmpty(raw: string | undefined): HfAudioFxChain {
if (!raw) return { version: 1, nodes: [] };
@@ -35,7 +36,6 @@ interface TimelineFxButtonChainProps {
fxChainRaw: string | undefined;
onChainChange: (next: HfAudioFxChain) => void;
onChainPreview?: (next: HfAudioFxChain) => void;
onAuditionTransport?: (on: boolean) => void;
}
interface TimelineFxButtonGroupPointerProps {
@@ -49,6 +49,10 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
// Owned here rather than threaded from each caller: both timeline call sites
// want the same thing, and neither passed one, so hovering a preset in this
// popover was silent unless the transport already happened to be running.
const auditionTransport = useAuditionTransport();
const openAt = () => {
setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null);
@@ -133,7 +137,7 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
onClose={() => setOpen(false)}
onChainChange={props.onChainChange}
onChainPreview={props.onChainPreview}
onAuditionTransport={props.onAuditionTransport}
onAuditionTransport={auditionTransport}
onOpenRack={props.onOpenRack}
/>,
document.body,
@@ -72,6 +72,11 @@ export function TimelineGroupBusStrip({
className="absolute left-0 right-0 flex items-center gap-2 px-2 text-[10px] text-white/70"
style={{ top: TRACK_H, height: STRIP_H }}
>
{/* Named, because unnamed it reads as an unexplained slider next to an
empty capsule: the row opens off a control labelled "lanes", so the
first question it has to answer is what it IS. Two words, no dB and no
numeric readout — that part of the casual-user rule stands. */}
<span className="shrink-0 text-white/45">Bus level</span>
<input
type="range"
aria-label="Group volume"
@@ -94,6 +99,9 @@ export function TimelineGroupBusStrip({
<div
className="relative h-1.5 w-16 shrink-0 overflow-hidden rounded-full"
style={{ background: theme.gutterBorder }}
// Empty while the transport is stopped, which is when somebody is most
// likely to be wondering what it is.
title="How loud this bus is playing right now"
aria-hidden="true"
>
<div
@@ -27,7 +27,6 @@ interface TimelineGroupHeaderProps {
fxChain?: string;
onFxChainChange: (next: HfAudioFxChain) => void;
onFxChainPreview?: (next: HfAudioFxChain) => void;
onFxAuditionTransport?: (on: boolean) => void;
onOpenFxRack: () => void;
columnWidth: number;
theme: TimelineTheme;
@@ -53,7 +52,6 @@ export function TimelineGroupHeader({
fxChain,
onFxChainChange,
onFxChainPreview,
onFxAuditionTransport,
onOpenFxRack,
columnWidth,
theme,
@@ -148,7 +146,6 @@ export function TimelineGroupHeader({
fxChainRaw={fxChain}
onChainChange={onFxChainChange}
onChainPreview={onFxChainPreview}
onAuditionTransport={onFxAuditionTransport}
onOpenRack={onOpenFxRack}
/>
<button