fix(studio): align audio rows, automation lanes and headers (#3451)

* fix(core): harden audio FX and group identity

* fix(core): address audio group review feedback

* fix(core): align preview transport with grouped audio

* test(core): pin audio group gain ceiling

* fix(core): preserve solo bridge through stack

* fix(engine): harden grouped audio rendering

* docs(engine): explain grouped mix fallback invariant

* test(engine): allow grouped mixes to finish on Windows

* feat(lint): validate audio group membership and timing

* test(lint): pin audio group membership guards

* fix(studio): unify audio IDs and group state

* fix(studio): make audio-group edits transactional

* fix(studio): keep preview state synchronized

* fix(studio): align audio rows, automation lanes and headers

* fix(studio): stabilize timeline audio derivations

* refactor(studio): simplify group metadata memoization

* style(studio): keep timeline layout within size gate

* fix(studio): keep timeline preset apply off auditions
This commit is contained in:
Vance Ingalls
2026-08-23 18:48:08 -07:00
committed by GitHub
parent 89069d24c3
commit 4ea018a4a7
45 changed files with 2784 additions and 896 deletions
@@ -91,7 +91,11 @@ export function TimelineFxPopover({
onOpenRack,
}: TimelineFxPopoverProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const { audition, clearAudition } = useFxAudition(chain, onChainPreview, onAuditionTransport);
const { audition, clearAudition, storedChain } = useFxAudition(
chain,
onChainPreview,
onAuditionTransport,
);
// Outside click dismisses like any other popover; the button itself is
// excluded by pointerdown timing (the button's own click hasn't happened yet).
@@ -104,7 +108,7 @@ export function TimelineFxPopover({
}, [onClose]);
const applyPreset = (id: string) => {
const next = applyPresetToChain(chain, id, trackKind);
const next = applyPresetToChain(storedChain(), id, trackKind);
if (!next) return;
clearAudition();
onChainChange(next);
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { auditionStart } from "./useAuditionTransport";
const SPANS = [
{ start: 2, duration: 7 },
{ start: 18, duration: 7 },
];
describe("auditionStart", () => {
// Nothing to aim at — a caller with no spans keeps the old behaviour: play
// from wherever the author left the playhead.
it("stays put when there are no spans", () => {
expect(auditionStart(undefined, 0)).toBeNull();
expect(auditionStart([], 0)).toBeNull();
});
// Already inside the clip: moving the playhead here would be the UI taking a
// decision it was not asked for, and it would cost the author their place for
// no gain.
it("stays put when the playhead is already inside a span", () => {
expect(auditionStart(SPANS, 2)).toBeNull();
expect(auditionStart(SPANS, 8.9)).toBeNull();
});
// The bug this exists for: hovering a preset at 0:00 on a group whose members
// start at 0:02 played silence under the effect.
it("jumps to the next span when the playhead is before or between them", () => {
expect(auditionStart(SPANS, 0)).toBe(2);
expect(auditionStart(SPANS, 9)).toBe(18);
});
// Past everything, wrap to the first rather than play out the tail in silence.
it("wraps to the first span when the playhead is past them all", () => {
expect(auditionStart(SPANS, 40)).toBe(2);
});
});
@@ -0,0 +1,78 @@
/**
* 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 must render the preset shelf "exactly as FxSection
* renders it — same props" (runbook C1 §2) and was passing no transport at all.
*/
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";
/** A clip the audition is meant to be heard through. */
export interface AuditionSpan {
start: number;
duration: number;
}
/**
* Where to start playing so the audition is actually audible, or null to stay.
*
* Playing "from the playhead" only works when the thing being auditioned is
* sounding there. A group whose members start at 0:02, hovered with the
* playhead at 0:00, plays the rest of the mix unchanged — the transport runs,
* the chain is in the graph, and the author hears nothing of the preset. So:
* inside a span, stay; otherwise jump to the next one, wrapping to the first
* when the playhead is past them all.
*
* Shared rather than the popover's own, which is where it went wrong the first
* time: the property panel's rack has the identical hole, and giving the two
* surfaces different audition behaviour is exactly what runbook C1 §2 forbids
* when it says the shelf renders "exactly as FxSection renders it".
*/
export function auditionStart(
spans: readonly AuditionSpan[] | undefined,
at: number,
): number | null {
if (!spans || spans.length === 0) return null;
if (spans.some((span) => at >= span.start && at < span.start + span.duration)) return null;
const starts = spans.map((span) => span.start).sort((a, b) => a - b);
return starts.find((start) => start > at) ?? starts[0] ?? null;
}
export function useAuditionTransport(): (on: boolean, spans?: readonly AuditionSpan[]) => 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, spans?: readonly AuditionSpan[]): void => {
const store = usePlayerStore.getState();
if (on) {
if (store.isPlaying || auditionReturn.current !== null) return;
// Recorded BEFORE the seek, so leaving returns the author to where they
// actually were rather than to the clip this jumped to.
auditionReturn.current = store.currentTime;
const from = auditionStart(spans, store.currentTime);
if (from !== null) store.requestSeek(from);
store.requestPlayback(true);
return;
}
const returnTo = auditionReturn.current;
if (returnTo === null) return;
auditionReturn.current = null;
store.requestPlayback(false, returnTo);
};
}
@@ -52,6 +52,17 @@ export function useFxAudition(
[chain, onChainPreview, onAuditionTransport],
);
/**
* The chain as the DOCUMENT has it, ignoring whatever is being auditioned.
*
* An audition writes through the preview channel, and the `chain` prop is
* read back from that same live attribute — so mid-hover it is the hovered
* preset, not the stored chain. Applying on top of it stacked the auditioned
* preset into the saved chain: hover a reverb, click a different preset, and
* both were persisted, which is heard as the effect running twice.
*/
const storedChain = useCallback(() => auditionBase.current ?? chain, [chain]);
/**
* Drop whatever is being auditioned WITHOUT reverting the preview, for a
* caller that is about to mutate the real chain anyway — reverting first
@@ -91,5 +102,5 @@ export function useFxAudition(
[],
);
return { audition, clearAudition };
return { audition, clearAudition, storedChain };
}
@@ -41,6 +41,10 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
return createPortal(
<div
ref={menuRef}
// z-[200] for the same reason the timeline's FX popover uses it: this is
// portaled to `document.body`, but the ruler's sticky header sits at z-70
// in the SAME root stacking context, so a z-50 menu opened near the top of
// the timeline is painted through by the ruler and the playhead.
className="hf-automation-menu fixed z-[200] min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
style={{ left: adjustedX, top: adjustedY }}
>
@@ -1,11 +1,56 @@
import { TRACK_H } from "./timelineLayout";
import { TrackClipCount } from "./TrackClipCount";
// Layer row (Figma order: disclosure ∿, diamond, name) — the disclosure lives
// Layer row (diamond, name, then the ∿ disclosure on the right edge) — the
// disclosure lives
// here, not on the clip bar, and re-expands a collapsed layer. `∿` (not a
// caret) because a group's own row keeps the caret for its structural
// disclosure (member rows) — this button only ever means "show this row's
// lanes", so it needs its own distinct glyph.
/**
* The `∿` that shows or hides a row's lanes.
*
* Shared, because two layouts need the identical control: the keyframe layer
* row below, and the plain track header — an audio track with automation keeps
* its own look (music glyph, indent) and gains this, rather than being
* re-rendered as a keyframe layer to get at the button.
*/
export function LaneToggleButton({
name,
isExpanded,
lanesId,
onToggle,
}: {
name: string;
isExpanded: boolean;
lanesId: string;
onToggle: () => void;
}) {
return (
<button
type="button"
// ponytail: No focus id here; keyboard routing belongs to the enclosing logical row.
tabIndex={-1}
aria-expanded={isExpanded}
aria-controls={lanesId}
aria-label={`${isExpanded ? "Hide" : "Show"} ${name} lanes`}
title={`${isExpanded ? "Hide" : "Show"} lanes`}
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
// only the hit box grows.
className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggle();
}}
>
<span aria-hidden="true">∿</span>
</button>
);
}
export function LayerDisclosureRow({
name,
clipCount,
@@ -45,37 +90,26 @@ export function LayerDisclosureRow({
background: gutterBackground,
}}
>
<button
type="button"
// ponytail: No focus id here; keyboard routing belongs to the enclosing logical row.
tabIndex={-1}
aria-expanded={isExpanded}
aria-controls={lanesId}
aria-label={`${isExpanded ? "Hide" : "Show"} ${name} lanes`}
title={`${isExpanded ? "Hide" : "Show"} lanes`}
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
// only the hit box grows.
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleClipExpanded();
}}
>
<span aria-hidden="true">∿</span>
</button>
{/* Decorative: the disclosure button above already names the row's keyframe
state, and aria-label on a plain span is not exposed reliably anyway. */}
<span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
◇
</span>
<span className="min-w-0 flex-1 truncate font-medium" title={name}>
{name}
</span>
{/* Wraps rather than truncating: a truncated name needs a hover to be
read, which a scanned column cannot rely on. */}
<span className="min-w-0 flex-1 break-words font-medium leading-tight">{name}</span>
<TrackClipCount clipCount={clipCount} />
{children}
{/* Anchored right, on every header that has one: the lane toggle is the
row's last word about itself, and a left-hand ∿ put it where the eye
looks for identity instead. `ml-auto` rather than a spacer so it holds
the edge whatever else the row grows. */}
<LaneToggleButton
name={name}
isExpanded={isExpanded}
lanesId={lanesId}
onToggle={onToggleClipExpanded}
/>
</div>
);
}
@@ -6,7 +6,6 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
import { trackStudioEvent } from "../../utils/studioTelemetry";
import { Tooltip } from "../../components/ui";
import { useMountEffect } from "../../hooks/useMountEffect";
import { useSoloBannerText } from "../../hooks/useAudioSoloBridge";
import { ShortcutsPanel } from "./ShortcutsPanel";
import { SpeedMenu } from "./SpeedMenu";
import { VolumeControl } from "./VolumeControl";
@@ -156,47 +155,12 @@ const FullscreenButton = memo(function FullscreenButton({
/* ── Main component ──────────────────────────────────────────────── */
/**
* "Hearing only this" notice. Solo is a PREVIEW-only gate — it never touches an
* attribute and never reaches the export — so the state has to say so out loud,
* or a soloed session reads as a broken mix.
*/
const SoloBanner = memo(function SoloBanner({
previewIframeRef,
}: {
previewIframeRef: { current: HTMLIFrameElement | null };
}) {
const bannerText = useSoloBannerText(previewIframeRef);
const clearSolo = usePlayerStore.getState().clearSolo;
if (bannerText === null) return null;
return (
<div
role="status"
className="flex h-7 items-center justify-center gap-2 border-b border-neutral-800 bg-neutral-900/90 px-3 text-[11px] text-neutral-300"
>
<span>
Hearing only <span className="font-medium text-neutral-100">{bannerText}</span> — your
export is not affected
</span>
<button
type="button"
onClick={() => clearSolo()}
className="rounded px-1.5 py-0.5 font-medium text-studio-accent transition-colors hover:text-white"
>
Clear
</button>
</div>
);
});
interface PlayerControlsProps {
onTogglePlay: () => void;
onSeek: (time: number) => void;
disabled?: boolean;
isFullscreen?: boolean;
onToggleFullscreen?: () => void;
/** Needed to read the soloed clips' labels out of the preview document. */
previewIframeRef?: { current: HTMLIFrameElement | null };
}
export const PlayerControls = memo(function PlayerControls({
@@ -205,7 +169,6 @@ export const PlayerControls = memo(function PlayerControls({
disabled = false,
isFullscreen = false,
onToggleFullscreen,
previewIframeRef,
}: PlayerControlsProps) {
const isPlaying = usePlayerStore((s) => s.isPlaying);
const duration = usePlayerStore((s) => s.duration);
@@ -258,7 +221,6 @@ export const PlayerControls = memo(function PlayerControls({
return (
<div>
{previewIframeRef && <SoloBanner previewIframeRef={previewIframeRef} />}
<div
className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
aria-disabled={disabled || undefined}
@@ -299,6 +299,42 @@ describe("Timeline provider boundary", () => {
act(() => root.unmount());
});
// The same assertion, with a group present. It passed on an empty fixture
// while TimelineGroupRow called the THROWING context hook — one grouped clip
// and the whole timeline render died, not just the row.
it("renders without the provider even when a group row is on screen", () => {
const host = createSizedTimelineHost(640);
usePlayerStore.setState({
duration: 4,
timelineReady: true,
elements: [
{
id: "voice-1",
domId: "voice-1",
tag: "audio",
start: 0,
duration: 2,
track: 0,
audioGroup: "voiceover",
},
{
id: "voice-2",
domId: "voice-2",
tag: "audio",
start: 2,
duration: 2,
track: 1,
audioGroup: "voiceover",
},
],
});
const root = createRoot(host);
act(() => root.render(React.createElement(Timeline)));
expect(host.querySelector('[role="treegrid"]')).not.toBeNull();
act(() => root.unmount());
});
it("renders the complete track list while row virtualization is gated off", () => {
const host = createSizedTimelineHost(640);
usePlayerStore.setState({
@@ -389,7 +425,10 @@ describe("Timeline provider boundary", () => {
button.click();
});
const row = button.parentElement?.parentElement;
// Up from the rowheader rather than counting parents: the header now lays
// its name and its controls out on two lines, so the button sits one level
// deeper than it used to.
const row = button.closest('[role="rowheader"]')?.parentElement;
// Row children: [TimelineTrackHeader (sticky column), time-mapped content].
const trackContent = row?.children.item(1);
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false, 1);
@@ -33,7 +33,7 @@ import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemet
import {
getEffectiveTimelineDuration,
getTimelinePreviewElement,
hasKeyframedTimelineClips,
timelineNeedsLabelColumn,
} from "./timelineViewModel";
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
@@ -115,7 +115,10 @@ export const Timeline = memo(function Timeline({
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
const labelMode = useMemo(() => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations]);
const labelMode = useMemo(
() => timelineNeedsLabelColumn(gsapAnimations, expandedElements),
[gsapAnimations, expandedElements],
);
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
const contentGutter = labelMode ? GUTTER : 0;
@@ -1747,3 +1747,70 @@ describe("TimelineAutomationLane stretch", () => {
expect(svg.style.cursor).not.toBe("col-resize");
});
});
describe("TimelineAutomationLane — a read-only lane offers nothing to grab", () => {
/** Hover the lane the way a pointer entering it does. */
function hover(container: HTMLElement): SVGSVGElement {
const svg = container.querySelector("svg")!;
stubBox(svg, { left: 0, top: 0, width: 400, height: 48 });
// React implements onPointerEnter through the delegated `pointerover`
// event, not a native `pointerenter` — which does not bubble and so never
// reaches its listener.
fire(svg, "pointerover");
return svg as SVGSVGElement;
}
const handles = (container: HTMLElement) =>
[...container.querySelectorAll("circle[data-automation-point]")] as SVGCircleElement[];
// A grab handle raised on hover is an offer, and a carve lane cannot honour
// it: the analysis rewrites these envelopes on every run, so a point moved
// here is discarded rather than saved. Dimming alone did not say that — the
// handles still came up under the cursor and the drag silently did nothing.
it("keeps its point handles hidden on hover", () => {
const { container } = render(
<TimelineAutomationLane
{...laneProps({ automation: ramp, readOnly: true, readOnlyNote: "Owned by the carve." })}
/>,
);
hover(container);
const drawn = handles(container);
expect(drawn.length).toBeGreaterThan(0);
for (const c of drawn) {
expect(c.style.opacity).toBe("0");
expect(c.style.cursor).toBe("default");
}
});
it("still raises them on an editable lane, so the gate is readOnly and not hover", () => {
const { container } = render(<TimelineAutomationLane {...laneProps({ automation: ramp })} />);
hover(container);
const drawn = handles(container);
expect(drawn.length).toBeGreaterThan(0);
for (const c of drawn) {
expect(c.style.opacity).toBe("1");
expect(c.style.cursor).toBe("grab");
}
});
it("says why, in the lane, once hovered", () => {
const { container } = render(
<TimelineAutomationLane
{...laneProps({ automation: ramp, readOnly: true, readOnlyNote: "Owned by the carve." })}
/>,
);
expect(container.querySelector("[data-automation-readonly-note]")).toBeNull();
hover(container);
expect(container.querySelector("[data-automation-readonly-note]")?.textContent).toContain(
"Owned by the carve.",
);
});
it("says nothing when no reason was given", () => {
const { container } = render(
<TimelineAutomationLane {...laneProps({ automation: ramp, readOnly: true })} />,
);
hover(container);
expect(container.querySelector("[data-automation-readonly-note]")).toBeNull();
});
});
@@ -86,6 +86,75 @@ function pointCircleStyle(
}
/** Pointer shape: a read-only lane can only be selected, a live one edited. */
/**
* Whether a point's grab handle is up.
*
* A raised handle is an offer to drag, so a read-only lane keeps them down: the
* carve rewrites its envelopes from the analysis on every run, and a point
* moved here is discarded rather than saved. Hidden rather than unmounted
* either way, so the hit area survives — a point dragged past the lane's edge
* fires pointerleave mid-gesture, and a handle that vanished then would drop
* the drag. A live drag and a selected range both keep them up regardless: the
* range is the subject of a pending Delete, and which points it caught cannot
* depend on where the mouse is.
*/
/** The svg's tooltip: what this lane's gestures actually are. */
function laneTitle(readOnly: boolean | undefined): string {
return readOnly
? "Drag a box to select points, which also selects this clip; then double-click to add a point"
: "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid.";
}
/**
* Why this lane will not take an edit, in the lane itself.
*
* Dimming and a default cursor say "not editable" but not WHY, and the why is
* the part that matters: the carve rewrites these envelopes from its own
* analysis on every run, so a point moved here is discarded rather than saved.
* Only while hovered, so a stack of read-only lanes is not a stack of notices,
* and never over a selection the author is about to act on.
*
* Separate from the gesture `hint` slot, which belongs to handlers that do not
* run on a read-only lane at all.
*/
function ReadOnlyNote({
readOnly,
note,
hovered,
hasRange,
leftPx,
widthPx,
}: {
readOnly: boolean | undefined;
note: string | undefined;
hovered: boolean;
/** A selection is the subject of a pending action; do not cover it. */
hasRange: boolean;
leftPx: number;
widthPx: number;
}) {
if (!readOnly || !note || !hovered || hasRange) return null;
return (
<div
data-automation-readonly-note=""
className="hf-automation-readonly-note pointer-events-none absolute rounded-[3px] bg-black/85 px-1.5 py-0.5 text-[9px] text-white/80"
style={{ left: leftPx + 6, top: 2, zIndex: 3, maxWidth: Math.max(120, widthPx - 12) }}
>
{note}
</div>
);
}
function pointHandleOpacity(args: {
readOnly: boolean | undefined;
hovered: boolean;
dragging: boolean;
hasRange: boolean;
}): number {
if (args.dragging || args.hasRange) return 1;
return !args.readOnly && args.hovered ? 1 : 0;
}
function laneCursor(readOnly: boolean | undefined, dragging: boolean, stretching: boolean): string {
// A stretch handle wins over everything it might also sit above: the handle is
// a few px wide and always overlaps whatever is under the selection edge, so
@@ -120,6 +189,9 @@ export interface TimelineAutomationLaneProps {
snapTimes?: readonly number[];
/** Editing writes to the selected element, so an unselected clip is read-only. */
readOnly?: boolean;
/** Why `readOnly`, shown in the lane on hover. Without it the lane only looks
* disabled; the author still has to guess what would let them edit it. */
readOnlyNote?: string;
/** Called when a read-only lane is pressed: selects the clip so it goes live. */
onSelect?(): void;
/** Active selection box on THIS lane, or null. */
@@ -142,6 +214,7 @@ export function TimelineAutomationLane({
onCommit,
snapTimes,
readOnly,
readOnlyNote,
onSelect,
rangeSelection,
onRangeSelect,
@@ -372,11 +445,7 @@ export function TimelineAutomationLane({
role="group"
aria-label={`${range.label} automation`}
>
<title>
{readOnly
? "Drag a box to select points, which also selects this clip; then double-click to add a point"
: "Double-click to add a point, drag to shape, double-click a point to type a value, right-click or Shift+click to remove it. Drag the background to draw a box around points, then Delete to remove them or drag one to move them all. Alt-drag the line to curve it. Shift locks an axis mid-drag; Alt ignores the grid."}
</title>
<title>{laneTitle(readOnly)}</title>
{/* No plate behind the envelope: the lane used to darken its clip's width,
which drew a box inside the row and made a stack of lanes read as tiles
rather than as rows of one timeline. The row background shows through, and
@@ -433,15 +502,14 @@ export function TimelineAutomationLane({
fill={accentColor}
stroke={stroke}
strokeWidth={strokeWidth}
// Hidden rather than unmounted, so the hit area survives: a point
// dragged past the lane's edge fires pointerleave mid-gesture, and a
// handle that vanishes then would drop the drag. A drag in progress
// and a selected range both keep them up for the same reason — the
// range is the subject of a pending Delete, and which points it
// caught cannot depend on where the mouse is.
style={{
cursor: readOnly ? "default" : "grab",
opacity: hovered || dragIndex !== null || rangeSelection ? 1 : 0,
opacity: pointHandleOpacity({
readOnly,
hovered,
dragging: dragIndex !== null,
hasRange: Boolean(rangeSelection),
}),
}}
onContextMenu={(e) => {
e.preventDefault();
@@ -484,6 +552,15 @@ export function TimelineAutomationLane({
</div>
) : null}
<ReadOnlyNote
readOnly={readOnly}
note={readOnlyNote}
hovered={hovered}
hasRange={Boolean(rangeSelection)}
leftPx={leftPx}
widthPx={widthPx}
/>
{menuAt && rangeSelection ? (
<AutomationSelectionMenu
x={menuAt.x}
@@ -12,7 +12,7 @@ import { resolveAutomationRange, type HfAutomationLane } from "@hyperframes/core
import { TimelineAutomationLane } from "./TimelineAutomationLane";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { getTimelineLaneTop } from "./timelineLayout";
import { groupAutomationLanes } from "./automationLaneData";
import { groupAutomationLanes, isCarveLane } from "./automationLaneData";
import { isAudioTimelineElement } from "../../utils/timelineInspector";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import type { TimelineElement } from "../store/playerStore";
@@ -100,7 +100,21 @@ function ClipAutomationLanes({
onCommit={bound.onCommit}
onSelect={bound.onSelect}
snapTimes={snapTimes}
readOnly={bound.readOnly}
// The carve owns its own envelopes and rewrites them on every
// re-run, so a drag would be silently discarded — shown, but not
// editable. Per LANE, not per binding: a carved bed can carry the
// author's own volume curve beside the carve's bands.
readOnly={bound.readOnly || isCarveLane(lane.target, bound.chain)}
// Two distinct reasons, so two notes. The carve one names the way
// out — change strength, or switch the carve off and own the chain
// — because "not editable" without that reads as broken.
readOnlyNote={
isCarveLane(lane.target, bound.chain)
? "Owned by the voiceover carve — re-derived on every analysis. Change strength in the FX rack, or turn the carve off to edit these by hand."
: bound.readOnly
? "Read-only here."
: undefined
}
rangeSelection={
bound.selection?.target === lane.target
? {
@@ -128,6 +142,10 @@ export interface TimelineAutomationLaneSlotProps {
pps: number;
/** Keyframe lanes already stacked above, which automation sits under. */
laneCount: number;
/** Exact y for the first lane, overriding `laneCount`. A group's lanes sit
* directly under its header row rather than under a stack of keyframe
* lanes, so it cannot be said in `laneCount`. */
topOffset?: number;
accentColor: string;
/** Composition-time playhead; the slot converts it to clip-local. */
currentTime: number;
@@ -151,6 +169,7 @@ export function TimelineAutomationLaneSlot({
lanes,
pps,
laneCount,
topOffset,
accentColor,
currentTime,
beatTimes,
@@ -165,7 +184,7 @@ export function TimelineAutomationLaneSlot({
else rowsByClip.set(key, [{ lane: entry.lane, rowIndex }]);
}
});
const top = getTimelineLaneTop(laneCount);
const top = topOffset ?? getTimelineLaneTop(laneCount);
return (
<>
{clips.map((element) => (
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { act } from "react";
import React, { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRoot } from "react-dom/client";
import { serializeAudioFxChain } from "@hyperframes/core/audio-fx";
@@ -68,45 +68,191 @@ describe("TimelineFxButton", () => {
expect(document.querySelector('[role="dialog"]')).toBeTruthy();
});
// The reported symptom was "the grouping button did nothing". The dialog WAS
// opening — it positioned at `anchorRect.bottom + 4` with no flip and no
// clamp, and this button lives in a track header at the bottom of the studio
// window, so it opened past the viewport edge. The test below it passed
// throughout: happy-dom reports an all-zero rect for an unlaid-out button,
// which lands the dialog at top:4 — on screen, and nothing like the app.
// A muted target auditions anyway — the mute is lifted on the running graph
// for the hover and put back on the way out. The read has to happen on the
// way IN: the live unmute flows back into this component's props, so a
// restore that re-read `isMuted` would find it false and never re-mute.
it("borrows a muted target's mute for the audition and returns it", () => {
const onSetMutedLive = vi.fn();
const host = mount(
<TimelineFxButton
variant="chain"
fxChainRaw={undefined}
isMuted
onSetMutedLive={onSetMutedLive}
onChainChange={vi.fn()}
onChainPreview={vi.fn()}
onOpenRack={vi.fn()}
/>,
);
act(() => byTextButton(host, "FX")?.click());
const preset = document.querySelector<HTMLButtonElement>(".hf-fx-preset-item");
act(() => preset?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
expect(onSetMutedLive).toHaveBeenLastCalledWith(false);
const shelf = document.querySelector(".hf-fx-preset-menu");
act(() => shelf?.dispatchEvent(new MouseEvent("mouseout", { bubbles: true })));
expect(onSetMutedLive).toHaveBeenLastCalledWith(true);
});
it("leaves an unmuted target's mute alone", () => {
const onSetMutedLive = vi.fn();
const host = mount(
<TimelineFxButton
variant="chain"
fxChainRaw={undefined}
onSetMutedLive={onSetMutedLive}
onChainChange={vi.fn()}
onChainPreview={vi.fn()}
onOpenRack={vi.fn()}
/>,
);
act(() => byTextButton(host, "FX")?.click());
const preset = document.querySelector<HTMLButtonElement>(".hf-fx-preset-item");
act(() => preset?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
expect(onSetMutedLive).not.toHaveBeenCalled();
});
// Hovering writes the preset through the preview channel, and the chain prop
// is read back from that same live attribute. Applying while a DIFFERENT
// preset is being auditioned used to save both — heard as the effect running
// twice — so the apply has to land on the stored chain.
it("applies onto the stored chain, not the one being auditioned", () => {
const onChainChange = vi.fn();
// The write-back the real timeline does: a preview patches the live
// attribute, and the row re-reads it into `fxChainRaw`. Without this the
// prop never moves and the bug cannot show.
function Harness() {
const [raw, setRaw] = React.useState<string | undefined>(undefined);
return (
<TimelineFxButton
variant="chain"
fxChainRaw={raw}
onChainChange={onChainChange}
onChainPreview={(next) => setRaw(serializeAudioFxChain(next))}
onOpenRack={vi.fn()}
/>
);
}
const host = mount(<Harness />);
act(() => byTextButton(host, "FX")?.click());
const items = Array.from(document.querySelectorAll<HTMLButtonElement>(".hf-fx-preset-item"));
const [hovered, clicked] = [items[0], items[1]];
act(() => hovered?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })));
act(() => clicked?.click());
const saved = onChainChange.mock.calls.at(-1)?.[0];
const presets = new Set(saved?.nodes.map((n: { fromPreset?: string }) => n.fromPreset));
expect(presets.size).toBe(1);
});
// The design doc calls the sentence this dialog carries "the highest-leverage
// copy in this plan": it is the concept of a submix bus delivered without the
// word, to an author who has never met one. The old pointer auto-named the
// group on one click and never mentioned the shared volume.
it("group-pointer variant names the group and explains what one is", () => {
const onGroupClips = vi.fn();
const host = mount(
<TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={onGroupClips} />,
);
act(() => byTextButton(host, "FX")?.click());
const dialog = document.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain(
"Effects you add to the group apply to both clips at once, and they share one volume.",
);
const group = Array.from(document.body.querySelectorAll("button")).find(
(b) => b.textContent === "Group",
);
act(() => group?.click());
expect(onGroupClips).toHaveBeenCalledWith("Voiceover");
});
// Three or more must not read "both".
it("counts the clips in the explanation", () => {
mount(<TimelineFxButton variant="group-pointer" clipCount={3} onGroupClips={vi.fn()} />);
act(() => byTextButton(document.body as HTMLElement, "FX")?.click());
expect(document.querySelector('[role="dialog"]')?.textContent).toContain("all 3 clips at once");
});
// §1.4 keeps groups audio-only in v1, and §5 is explicit that a deliberate
// limit must be stated: "silent ones just send authors hunting for something
// that was never built."
it("states the video limit instead of offering a name field", () => {
const onGroupClips = vi.fn();
const host = mount(
<TimelineFxButton
variant="group-pointer"
clipCount={2}
refusal="Video audio can't be grouped yet — only audio clips can join a group."
onGroupClips={onGroupClips}
/>,
);
act(() => byTextButton(host, "FX")?.click());
const dialog = document.querySelector('[role="dialog"]');
expect(dialog?.textContent).toContain("Video audio can't be grouped yet");
expect(document.querySelector('input[aria-label="Group name"]')).toBeNull();
expect(
Array.from(document.body.querySelectorAll("button")).some((b) => b.textContent === "Group"),
).toBe(false);
});
// #3421: the dialog positioned itself at `anchorRect.bottom + 4` with no flip
// and no clamp, and this button lives in a track header at the bottom of the
// studio window — so it opened past the viewport edge and was reported as
// "the grouping button did nothing". Numbers derive from GROUP_DIALOG_SIZE
// (256x160) against happy-dom's 1024x768 window and a 12px margin.
it("flips the group dialog above the anchor when there is no room below", () => {
const host = mount(<TimelineFxButton variant="group-pointer" onGroupClips={vi.fn()} />);
const host = mount(
<TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={vi.fn()} />,
);
const fx = byTextButton(host, "FX");
// A track header near the bottom edge of the (1024x768) window.
fx!.getBoundingClientRect = () =>
({ left: 300, top: 760, right: 320, bottom: 776, width: 20, height: 16 }) as DOMRect;
act(() => fx?.click());
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
expect(dialog).toBeTruthy();
// Above the anchor, and fully inside the viewport: 644 + 112 === 756.
expect(dialog.style.top).toBe("644px");
const top = Number.parseFloat(dialog.style.top);
// Fully inside the viewport on both edges, which the unclamped form was not.
expect(top).toBeGreaterThanOrEqual(12);
expect(top + 160).toBeLessThanOrEqual(768 - 12);
});
it("keeps the group dialog inside the right edge of the window", () => {
const host = mount(<TimelineFxButton variant="group-pointer" onGroupClips={vi.fn()} />);
const host = mount(
<TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={vi.fn()} />,
);
const fx = byTextButton(host, "FX");
// Hard against the right edge: 224px wide + a 12px margin has to fit.
fx!.getBoundingClientRect = () =>
({ left: 1010, top: 100, right: 1024, bottom: 116, width: 14, height: 16 }) as DOMRect;
act(() => fx?.click());
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
expect(dialog.style.left).toBe("788px");
expect(dialog.style.top).toBe("120px");
const left = Number.parseFloat(dialog.style.left);
expect(left).toBeGreaterThanOrEqual(12);
expect(left + 256).toBeLessThanOrEqual(1024 - 12);
});
it("group-pointer variant offers Group instead of a popover", () => {
it("carries the typed name into the group it creates", () => {
const onGroupClips = vi.fn();
const host = mount(<TimelineFxButton variant="group-pointer" onGroupClips={onGroupClips} />);
const host = mount(
<TimelineFxButton variant="group-pointer" clipCount={2} onGroupClips={onGroupClips} />,
);
act(() => byTextButton(host, "FX")?.click());
const groupButton = document.body.querySelectorAll("button");
const group = Array.from(groupButton).find((b) => b.textContent === "Group");
expect(group).toBeDefined();
const input = document.querySelector<HTMLInputElement>('input[aria-label="Group name"]');
expect(input).not.toBeNull();
// React tracks the input's value on the node, so assigning `.value`
// directly is swallowed — the native setter is what makes onChange fire.
const setValue = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
act(() => {
if (input && setValue) {
setValue.call(input, "SFX");
input.dispatchEvent(new Event("input", { bubbles: true }));
}
});
const group = Array.from(document.body.querySelectorAll("button")).find(
(b) => b.textContent === "Group",
);
act(() => group?.click());
expect(onGroupClips).toHaveBeenCalledTimes(1);
expect(onGroupClips).toHaveBeenCalledWith("SFX");
});
});
@@ -9,7 +9,7 @@
* it gets the grouping pointer instead of the popover).
*/
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
enabledAudioFxNodes,
@@ -19,14 +19,31 @@ import {
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
import { TimelineFxPopover } from "../../components/editor/TimelineFxPopover.js";
import { resolveFloatingPanelPosition } from "../../components/editor/floatingPanel.js";
import {
useAuditionTransport,
type AuditionSpan,
} from "../../components/editor/useAuditionTransport.js";
// Estimated, like FORMAT_PANEL_SIZE in RenderQueue: `w-56` is exact, and only
/**
* Naming a group is the moment the whole feature is explained.
*
* The design doc calls the sentence below "the highest-leverage copy in this
* plan and it should be written before the routing is" — it is the concept of a
* submix bus delivered without the word, to an author who has never met one.
* The old pointer skipped both the name and the sentence: it made an
* auto-named group on one click and said only "Group these clips to add effects
* to all of them", which leaves out the shared volume entirely.
*/
// Estimated, like FORMAT_PANEL_SIZE in RenderQueue: `w-64` is exact, and only
// the flip decision uses the height — the clamp keeps the dialog on screen
// either way.
const GROUP_DIALOG_SIZE = { width: 224, height: 112 };
const GROUP_DIALOG_SIZE = { width: 256, height: 160 };
/** Where the grouping dialog goes: flipped above the anchor when there is no
* room below, and clamped so neither edge leaves the viewport. */
* room below, and clamped so neither edge leaves the viewport. This button
* lives in a track header at the BOTTOM of the studio window, so an unclamped
* `anchorRect.bottom + 4` opened it past the viewport edge and read as "the
* grouping button did nothing". */
function groupDialogPosition(anchorRect: DOMRect): { left: number; top: number } {
const { left, top } = resolveFloatingPanelPosition(
anchorRect,
@@ -37,6 +54,94 @@ function groupDialogPosition(anchorRect: DOMRect): { left: number; top: number }
return { left, top };
}
function GroupNameDialog({
anchorRect,
clipCount,
defaultLabel,
refusal,
onCancel,
onConfirm,
}: {
anchorRect: DOMRect;
clipCount: number;
defaultLabel?: string;
refusal?: string;
onCancel: () => void;
onConfirm: (label: string) => void;
}) {
const [label, setLabel] = useState(defaultLabel ?? "Voiceover");
const inputRef = useRef<HTMLInputElement | null>(null);
// Focused on open so the name can be typed over without a second click —
// the field is the only thing here that wants input.
useEffect(() => inputRef.current?.select(), []);
if (refusal) {
return (
<div
role="dialog"
aria-label="This track cannot be grouped"
className="z-[200] w-64 rounded-md border border-white/10 bg-[#1b1b1f] p-3 text-[11px] leading-snug text-white/75 shadow-xl"
style={{ position: "fixed", ...groupDialogPosition(anchorRect) }}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "Escape") return;
event.stopPropagation();
onCancel();
}}
>
<p>{refusal}</p>
</div>
);
}
const confirm = () => onConfirm(label.trim() || defaultLabel || "Voiceover");
return (
<div
role="dialog"
aria-label="Name this group"
className="z-[200] w-64 rounded-md border border-white/10 bg-[#1b1b1f] p-3 text-[11px] text-white/75 shadow-xl"
style={{ position: "fixed", ...groupDialogPosition(anchorRect) }}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
onCancel();
}
if (event.key === "Enter") confirm();
}}
>
<p className="mb-1.5 font-medium text-white">Name this group</p>
<input
ref={inputRef}
type="text"
aria-label="Group name"
value={label}
onChange={(event) => setLabel(event.currentTarget.value)}
className="w-full rounded border border-white/20 bg-black/30 px-1.5 py-1 text-[11px] text-white outline-none focus:border-[#3CE6AC]"
/>
{/* The sentence. No jargon, and it names both things a bus does. */}
<p className="mt-2 leading-snug">
Effects you add to the group apply to {clipCount === 2 ? "both" : `all ${clipCount}`} clips
at once, and they share one volume.
</p>
<div className="mt-2.5 flex justify-end gap-1.5">
<button
type="button"
className="rounded border border-white/20 px-2 py-1 text-[10px] text-white/75 hover:bg-white/10"
onClick={onCancel}
>
Cancel
</button>
<button
type="button"
className="rounded border border-[#3CE6AC] bg-[#3CE6AC]/15 px-2 py-1 text-[10px] font-semibold text-[#3CE6AC] hover:bg-[#3CE6AC]/25"
onClick={confirm}
>
Group
</button>
</div>
</div>
);
}
function parseFxChainOrEmpty(raw: string | undefined): HfAudioFxChain {
if (!raw) return { version: 1, nodes: [] };
try {
@@ -53,12 +158,31 @@ interface TimelineFxButtonChainProps {
fxChainRaw: string | undefined;
onChainChange: (next: HfAudioFxChain) => void;
onChainPreview?: (next: HfAudioFxChain) => void;
onAuditionTransport?: (on: boolean) => void;
/** The clips this chain is heard through, so an audition starts where they
* actually sound rather than from a playhead parked before the first. */
auditionSpans?: readonly AuditionSpan[];
/** Whether this target is muted right now. */
isMuted?: boolean;
/** Set this target's mute on the running graph WITHOUT touching the document,
* so an audition can lift a mute and put it back. Hovering a preset on a
* muted bus is a question about the preset, not about the mute. */
onSetMutedLive?: (muted: boolean) => void;
}
interface TimelineFxButtonGroupPointerProps {
variant: "group-pointer";
onGroupClips: () => void;
/** Create the group under this name. */
onGroupClips: (label: string) => void;
/** How many clips are about to be grouped, for the copy that explains it. */
clipCount: number;
/** Seeded into the name field — "Voiceover" per the design's own mockup. */
defaultLabel?: string;
/** Why this track cannot be grouped at all. Present, the dialog states the
* limit instead of offering a name field — groups are audio-only in v1
* (§1.4), and the doc is explicit that a deliberate limit has to be said:
* "silent ones just send authors hunting for something that was never
* built." */
refusal?: string;
}
type TimelineFxButtonProps = TimelineFxButtonChainProps | TimelineFxButtonGroupPointerProps;
@@ -67,6 +191,12 @@ 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 transport = useAuditionTransport();
/** Whether THIS audition lifted a mute, so only it puts one back. */
const borrowedMute = useRef(false);
const openAt = () => {
setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null);
@@ -94,30 +224,17 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
{open &&
anchorRect &&
createPortal(
<div
role="dialog"
aria-label="Group these clips to add effects"
className="z-[200] w-56 rounded-md border border-white/10 bg-[#1b1b1f] p-2.5 text-[11px] text-white/75 shadow-xl"
// Was `top: anchorRect.bottom + 4` with no flip and no clamp. This
// button lives in a track header at the BOTTOM of the studio
// window, so the dialog opened past the viewport edge and the
// click read as doing nothing at all. Same helper the other body
// portals position with.
style={{ position: "fixed", ...groupDialogPosition(anchorRect) }}
onPointerDown={(event) => event.stopPropagation()}
>
<p>Group these clips to add effects to all of them.</p>
<button
type="button"
className="mt-2 w-full rounded border border-white/20 py-1 text-[10px] font-semibold text-white hover:bg-white/10"
onClick={() => {
setOpen(false);
props.onGroupClips();
}}
>
Group
</button>
</div>,
<GroupNameDialog
refusal={props.refusal}
anchorRect={anchorRect}
clipCount={props.clipCount}
defaultLabel={props.defaultLabel}
onCancel={() => setOpen(false)}
onConfirm={(label) => {
setOpen(false);
props.onGroupClips(label);
}}
/>,
document.body,
)}
</div>
@@ -156,7 +273,15 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
onClose={() => setOpen(false)}
onChainChange={props.onChainChange}
onChainPreview={props.onChainPreview}
onAuditionTransport={props.onAuditionTransport}
onAuditionTransport={(on) => {
// Read on the way IN and remembered: the live unmute flows back
// into the row's props, so by the time the audition ends the
// target no longer looks muted and the mute would never return.
if (on) borrowedMute.current = props.isMuted === true;
if (borrowedMute.current) props.onSetMutedLive?.(!on);
if (!on) borrowedMute.current = false;
transport(on, props.auditionSpans);
}}
onOpenRack={props.onOpenRack}
/>,
document.body,
@@ -23,11 +23,6 @@ function renderHeader(
laneCount={0}
isLaneOpen={false}
onToggleLanes={vi.fn()}
hidden={false}
onToggleHidden={vi.fn()}
isSoloed={false}
isHalfLitSolo={false}
onToggleSolo={vi.fn()}
onFxChainChange={vi.fn()}
onOpenFxRack={vi.fn()}
columnWidth={220}
@@ -1,8 +1,8 @@
import { SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
import { TRACK_H } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
import { TimelineFxButton } from "./TimelineFxButton";
import type { AuditionSpan } from "../../components/editor/useAuditionTransport.js";
interface TimelineGroupHeaderProps {
label: string;
@@ -14,20 +14,13 @@ interface TimelineGroupHeaderProps {
laneCount: number;
isLaneOpen: boolean;
onToggleLanes: () => void;
/** The group element's own `data-hidden` — mutes every member at once. */
hidden: boolean;
onToggleHidden: () => void;
/** This group id is itself in the soloed set (fully lit). */
isSoloed: boolean;
/** Not soloed itself, but at least one member is (half-lit). */
isHalfLitSolo: boolean;
/** `add: true` (⌘/Ctrl-click) toggles membership; a plain click is exclusive. */
onToggleSolo: (options?: { add?: boolean }) => void;
/** C1: the group's serialized `data-fx-chain`, when set. */
fxChain?: string;
onFxChainChange: (next: HfAudioFxChain) => void;
onFxChainPreview?: (next: HfAudioFxChain) => void;
onFxAuditionTransport?: (on: boolean) => void;
/** Member clips, so hovering a preset auditions where the group sounds. */
auditionSpans?: readonly AuditionSpan[];
onOpenFxRack: () => void;
columnWidth: number;
theme: TimelineTheme;
@@ -35,8 +28,57 @@ interface TimelineGroupHeaderProps {
/**
* A group's own row header: caret (member disclosure) + `▤` + label + count +
* mute + solo + FX + `∿ n` (lane disclosure).
* FX + `∿ n` (lane disclosure).
*/
/**
* The group's name, which IS the way into its rack — a group is an element
* carrying `data-fx-chain`, so selecting it is what puts the chain in the
* property panel. Its own component because the header it sits in already
* carries six controls and was over the complexity gate with this inline.
*/
function GroupNameButton({
label,
memberCount,
onOpenFxRack,
}: {
label: string;
memberCount: number;
onOpenFxRack: () => void;
}) {
return (
<button
type="button"
tabIndex={-1}
aria-label={`Open ${label} effects`}
title="Open effects"
// No `flex-1`: the row's control group owns the slack now (`ml-auto`), so
// claiming it here would push the controls off the right edge — and the
// count with them, since it rides inside this button.
className="flex h-6 min-w-0 items-center gap-1.5 rounded border-0 bg-transparent p-0 text-left text-[11px] text-white hover:text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onOpenFxRack();
}}
>
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
▤
</span>
{/* Wraps rather than truncating — a name that needs a hover to be read
is no use in a column you scan. */}
<span className="min-w-0 break-words text-left font-medium leading-tight">{label}</span>
<span
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
aria-hidden="true"
title={`${memberCount} tracks`}
>
{memberCount}
</span>
</button>
);
}
export function TimelineGroupHeader({
label,
memberCount,
@@ -45,15 +87,10 @@ export function TimelineGroupHeader({
laneCount,
isLaneOpen,
onToggleLanes,
hidden,
onToggleHidden,
isSoloed,
isHalfLitSolo,
onToggleSolo,
fxChain,
onFxChainChange,
onFxChainPreview,
onFxAuditionTransport,
auditionSpans,
onOpenFxRack,
columnWidth,
theme,
@@ -71,16 +108,19 @@ export function TimelineGroupHeader({
borderRight: `1px solid ${theme.gutterBorder}`,
}}
>
{/* One line, like a track header's: caret and name, then every control
anchored to the right edge. The name wraps and the controls are
`shrink-0`, so they hold the edge and the name gives way — no second
line needed to keep five controls off the label. */}
<button
type="button"
tabIndex={-1}
aria-expanded={isExpanded}
aria-label={`${isExpanded ? "Hide" : "Show"} ${label} tracks`}
title={`${isExpanded ? "Hide" : "Show"} tracks`}
// Mono, like both of the property panel's disclosure carets. The size is
// this row's own call and not a match to either of them: the node-body
// caret sits under `text-[9px]`, and `hf-fx-preset-run-caret` has no
// size rule at all, so there is no measured target to match.
// 13px mono, matching the property panel's preset-run caret
// (`hf-fx-preset-run-caret`) — the same disclosure, so the same glyph
// at the same size rather than a smaller one unique to this row.
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 font-mono text-[13px] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isExpanded ? "text-white" : "text-white/55 hover:text-white"
}`}
@@ -90,91 +130,49 @@ export function TimelineGroupHeader({
onToggleExpanded();
}}
>
{/* Swapped, not rotated: both panel carets swap, and a rotated ▸ sits
off-centre in its box because the glyph is not square. */}
{/* Swapped, not rotated: the panel's caret swaps too, and a rotated
▸ sits off-centre in its box because the glyph is not square. */}
<span aria-hidden="true">{isExpanded ? "▾" : "▸"}</span>
</button>
<span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
▤
</span>
<span className="min-w-0 flex-1 truncate font-medium" title={label}>
{label}
</span>
<span
className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
aria-hidden="true"
title={`${memberCount} tracks`}
>
{memberCount}
</span>
<button
type="button"
tabIndex={-1}
aria-label={hidden ? "Unmute group" : `Mute group ${label}`}
title={hidden ? "Unmute group" : `Mute group ${label}`}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleHidden();
}}
>
{hidden ? (
<SpeakerSlash size={14} weight="bold" aria-hidden="true" />
) : (
<SpeakerHigh size={14} weight="bold" aria-hidden="true" />
)}
</button>
<button
type="button"
tabIndex={-1}
aria-pressed={isSoloed}
aria-label="Hear only this"
title="Hear only this"
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[13px] font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
isSoloed
? "text-[#F5C542] hover:text-white"
: isHalfLitSolo
? "text-[#F5C542]/50 hover:text-white"
: "text-white/35 hover:text-white/75"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleSolo({ add: event.metaKey || event.ctrlKey });
}}
>
<span aria-hidden="true">⌗</span>
</button>
<TimelineFxButton
fxChainRaw={fxChain}
onChainChange={onFxChainChange}
onChainPreview={onFxChainPreview}
onAuditionTransport={onFxAuditionTransport}
onOpenRack={onOpenFxRack}
/>
<button
type="button"
tabIndex={-1}
aria-expanded={isLaneOpen}
aria-label={`${isLaneOpen ? "Hide" : "Show"} ${label} lanes`}
title={`${isLaneOpen ? "Hide" : "Show"} lanes`}
className={`flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isLaneOpen ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleLanes();
}}
>
<span aria-hidden="true">∿</span>
<GroupNameButton label={label} memberCount={memberCount} onOpenFxRack={onOpenFxRack} />
{/* `ml-auto` absorbs the slack the truncating name leaves, so the controls
sit on the edge whatever the name's length. */}
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<TimelineFxButton
fxChainRaw={fxChain}
onChainChange={onFxChainChange}
onChainPreview={onFxChainPreview}
auditionSpans={auditionSpans}
onOpenRack={onOpenFxRack}
/>
{/* No lanes, no control: an author who opens it meets an empty row and
learns nothing. A track header already gates its own `∿` this way
(`disclosable`); the group's was the one that still offered a
disclosure over nothing. Automation appears by being written — from
the rack or a keyframe — not by opening this, so nothing is
unreachable while it is hidden. */}
{laneCount > 0 && (
<span className="text-[9px] tabular-nums text-white/55">{laneCount}</span>
<button
type="button"
tabIndex={-1}
aria-expanded={isLaneOpen}
aria-label={`${isLaneOpen ? "Hide" : "Show"} ${label} lanes`}
title={`${isLaneOpen ? "Hide" : "Show"} lanes`}
// Anchored right, matching every other header's lane toggle.
className={`ml-auto flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
isLaneOpen ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleLanes();
}}
>
<span aria-hidden="true">∿</span>
<span className="text-[9px] tabular-nums text-white/55">{laneCount}</span>
</button>
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,88 @@
/**
* The label column beside a group's own automation lanes.
*
* The designs draw a group lane as `▤ Volume 0.42` on an accent rail — and
* the rail is load-bearing, not decoration: "Scope is carried by colour, not by
* depth — a lane the group owns has an accent rail and names the group; a
* clip's lane is neutral." Two lanes both called Volume, doing entirely
* different things, otherwise sit eight pixels apart with nothing to tell them
* apart.
*
* The number is the value AT THE PLAYHEAD, not the stored seed: a readout that
* showed the seed would stand still while the curve is audibly working.
*/
import { sampleAutomationLane } from "@hyperframes/core/audio-automation";
import { groupAutomationLanes } from "./automationLaneData";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import type { TimelineElement } from "../store/playerStore";
import { useLivePlayheadTime } from "../../hooks/useLivePlayheadTime";
export function TimelineGroupLaneLabels({
groupElement,
groupLabel,
top,
columnWidth,
gutterBackground,
accentColor,
}: {
/** The group wearing a clip's shape — see `groupAutomationElement`. */
groupElement: TimelineElement;
groupLabel: string;
/** y of the first lane, matching the canvas slot's own offset. */
top: number;
columnWidth: number;
gutterBackground: string;
accentColor: string;
}) {
// The LIVE playhead, not the row's `currentTime` prop — that one only moves
// on seek, so the readout sat frozen while the curve was audibly working,
// which is precisely the failure this number exists to prevent.
const currentTime = useLivePlayheadTime();
// The SAME source the curves and the reserved height use
// (`TimelineGroupRow`), not raw `elementAutomationLanes`. Raw lanes are
// neither deduped by property nor filtered for resolvability, and the old
// `if (!parts) return null` consumed an index without drawing — so one
// unresolvable target slid every later label one row off the curve it names.
const laneGroups = groupAutomationLanes([groupElement]);
return (
<>
{laneGroups.map((laneGroup, index) => {
const lane = laneGroup.entries[0]?.lane;
if (!lane) return null;
const parts = { name: laneGroup.name, param: laneGroup.param };
// A group's clock is composition time (§1.3), so the playhead needs no
// clip-local rebase here — unlike a clip's lane.
const value = sampleAutomationLane(lane, currentTime);
return (
<div
key={lane.target}
data-group-lane-label={lane.target}
className="absolute left-0 flex items-center gap-1.5 overflow-hidden px-1.5 text-[10px] text-white/65"
style={{
top: top + index * AUTOMATION_LANE_H,
width: columnWidth,
height: AUTOMATION_LANE_H,
background: gutterBackground,
borderLeft: `2px solid ${accentColor}`,
}}
title={`${groupLabel} · ${parts.param ? `${parts.name} · ${parts.param}` : parts.name}`}
>
<span aria-hidden="true" className="shrink-0 text-[11px] text-white/40">
▤
</span>
<span className="flex min-w-0 flex-1 flex-col justify-center leading-tight">
<span className="truncate font-mono text-[9px] text-white/70">{parts.name}</span>
{parts.param ? (
<span className="truncate font-mono text-[9px] text-white/40">{parts.param}</span>
) : null}
</span>
<span className="shrink-0 font-mono text-[9px] tabular-nums text-white/55">
{value.toFixed(2)}
</span>
</div>
);
})}
</>
);
}
@@ -0,0 +1,146 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TimelineGroupRow } from "./TimelineGroupRow";
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
import { defaultTimelineTheme } from "./timelineTheme";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import type { TimelineElement } from "../store/playerStore";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("../../telemetry/canary", () => ({ isCanaryEnabled: () => true }));
afterEach(() => {
document.body.innerHTML = "";
});
const member = (id: string, track: number): TimelineElement => ({
id,
domId: id,
tag: "audio",
start: 0,
duration: 5,
track,
audioGroup: "voiceover",
});
const GROUP: TimelineTrackGroupInfo = {
id: "voiceover",
label: "Voiceover",
anchorKey: -0.5,
memberTracks: [0, 1],
memberElements: [member("vo-1", 0), member("vo-2", 1)],
volume: 1,
hidden: false,
};
function renderRow(overrides: Partial<TimelineTrackGroupInfo> = {}) {
const onSetAudioGroupAttributeQuiet = vi.fn();
const onSetElementAttributeQuiet = vi.fn();
const host = document.createElement("div");
document.body.append(host);
act(() =>
createRoot(host).render(
<TimelineEditProvider value={{ onSetAudioGroupAttributeQuiet, onSetElementAttributeQuiet }}>
<TimelineGroupRow
index={0}
rowKey={0}
group={{ ...GROUP, ...overrides }}
logicalRow={{ id: "g", level: 1, kind: "track" } as never}
top={0}
height={48}
virtualized={false}
contentOrigin={232}
theme={defaultTimelineTheme}
collapsedGroupIds={new Set()}
expandedLaneOwnerIds={new Set()}
toggleGroupExpanded={vi.fn()}
toggleLaneOwnerExpanded={vi.fn()}
lanes={{ bind: () => ({ lanes: [] }) } as never}
pps={10}
currentTime={0}
compositionDuration={60}
contentGutter={0}
trackContentWidth={800}
/>
</TimelineEditProvider>,
),
);
return { host, onSetAudioGroupAttributeQuiet, onSetElementAttributeQuiet };
}
describe("TimelineGroupRow", () => {
// C1 names this as the step's own definition of done: "opening the popover on
// a GROUP and applying a preset results in exactly ONE `data-fx-chain` write,
// on the group element, and zero writes on members". A group IS a bus — a
// write that fanned out to the members would be batch-apply wearing a bus's
// clothes, which is the one thing §1 rules out.
it("applies a preset to the group element only, never to its members", () => {
const { host, onSetAudioGroupAttributeQuiet, onSetElementAttributeQuiet } = renderRow();
const fx = Array.from(host.querySelectorAll("button")).find((b) =>
b.getAttribute("aria-label")?.startsWith("Effects"),
);
act(() => fx?.click());
const preset = document.querySelector<HTMLButtonElement>(".hf-fx-preset-item");
act(() => preset?.click());
expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledTimes(1);
const [groupId, attr] = onSetAudioGroupAttributeQuiet.mock.calls[0] ?? [];
expect(groupId).toBe("voiceover");
expect(attr).toBe("data-fx-chain");
// The members are the point: not one write reaches them.
expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
});
// A disclosure over nothing tells the author their group has no automation
// only AFTER they open an empty row. Track headers already gate their own
// toggle on having something to disclose; the group's did not.
it("hides the lane toggle until the group actually automates something", () => {
const laneToggle = (host: HTMLElement) =>
Array.from(host.querySelectorAll("button")).find((b) =>
/lanes$/.test(b.getAttribute("aria-label") ?? ""),
);
expect(laneToggle(renderRow().host)).toBeUndefined();
const automated = renderRow({
fxChain: JSON.stringify({
version: 1,
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
}),
automation: JSON.stringify({
version: 1,
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
}),
});
expect(laneToggle(automated.host)).toBeDefined();
});
// Same shape as a track header: caret and name on the left, every control in
// one right-anchored group. It was two lines — name, then controls — which is
// what let a stray child overflow the 48px box on the track side.
it("keeps the caret, the name and every control on one line", () => {
const { host } = renderRow({
fxChain: JSON.stringify({
version: 1,
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
}),
automation: JSON.stringify({
version: 1,
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
}),
});
const header = host.querySelector<HTMLElement>('[role="rowheader"]');
// Caret, name, control group — no second line.
expect(header?.children).toHaveLength(3);
const controls = header?.lastElementChild as HTMLElement | null;
expect(controls?.className).toContain("ml-auto");
// Both controls live in that group, so they share the right edge.
expect(controls?.querySelectorAll("button")).toHaveLength(2);
// The member count rides with the name, not out with the controls.
expect(controls?.querySelector('[title="2 tracks"]')).toBeNull();
act(() => undefined);
});
});
@@ -1,6 +1,3 @@
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import { isGroupHalfLitUnderSolo } from "../store/audioSoloSlice";
import {
HF_AUDIO_FX_ATTR,
serializeAudioFxChain,
@@ -11,28 +8,43 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import { TimelineTrackRow } from "./TimelineTrackRow";
import { TimelineGroupHeader } from "./TimelineGroupHeader";
import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
import { groupAutomationLanes } from "./automationLaneData";
import { LABEL_COL_W } from "./timelineLayout";
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
import { groupAutomationElement } from "./groupAutomationElement";
import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot";
import { TimelineGroupLaneLabels } from "./TimelineGroupLaneLabels";
import { LABEL_COL_W, TRACK_H } from "./timelineLayout";
import type { UseAutomationLanesResult } from "./useAutomationLanes";
import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
/** Accent rail on a group-owned lane — the same green the member rail uses, so
* "this belongs to the group" reads the same in both places (groups doc §5). */
const GROUP_LANE_ACCENT = "#3CE6AC";
interface TimelineGroupRowProps {
index: number;
rowKey: number;
group: TimelineTrackGroupInfo;
logicalRow: TimelineLogicalRow;
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
top: number;
height: number;
virtualized: boolean;
contentOrigin: number;
theme: TimelineTheme;
rovingTargetId?: string | null;
expandedGroupIds: ReadonlySet<string>;
collapsedGroupIds: ReadonlySet<string>;
expandedLaneOwnerIds: ReadonlySet<string>;
toggleGroupExpanded: (id: string) => void;
toggleLaneOwnerExpanded: (id: string) => void;
lanes: UseAutomationLanesResult;
pps: number;
currentTime: number;
/** A group's lanes are in composition time (§1.3), so this is their span. */
compositionDuration: number;
beatTimes?: readonly number[];
contentGutter: number;
trackContentWidth: number;
}
/** A group's own row: the accessible shell (shared with track rows) plus the group header. */
@@ -41,31 +53,45 @@ export function TimelineGroupRow({
rowKey,
group,
logicalRow,
tracks,
top,
height,
virtualized,
contentOrigin,
theme,
rovingTargetId = null,
expandedGroupIds,
collapsedGroupIds,
expandedLaneOwnerIds,
toggleGroupExpanded,
toggleLaneOwnerExpanded,
lanes,
pps,
currentTime,
compositionDuration,
beatTimes,
contentGutter,
trackContentWidth,
}: TimelineGroupRowProps) {
const memberElements = group.memberTracks.flatMap(
(track) => tracks.find(([t]) => t === track)?.[1] ?? [],
);
const memberLabels = group.memberTracks.map((track, i) => {
const owner = tracks.find(([t]) => t === track)?.[1]?.find((el) => el.audioGroup);
return owner?.label ?? owner?.id ?? `track ${i + 1}`;
});
// From the group, NOT from `tracks`: a collapsed group emits no member rows
// into the display list, and every one of these reads silently degraded to
// empty in that (default) state — the lane count
// read 0, and the bus strip fell back to "track 1", "track 2".
const memberElements = group.memberElements;
// The group wearing a clip's shape so the lane machinery can render it — see
// `groupAutomationElement` for why that beats a second, parallel lane path.
const groupElement = groupAutomationElement(group, compositionDuration);
// The binder writes through the dom-edit selection, so a group lane is
// editable exactly when the group is the selected element — which clicking
// its name in the header does.
const domSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null;
const isGroupSelected = domSelection?.id === group.id;
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = useTimelineEditContext();
// Optional, like every sibling row: Timeline renders outside the edit
// provider in read-only hosts (Timeline.test.ts asserts it), and the throwing
// hook took the whole timeline down with it the moment a group existed —
// not just this row.
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } =
useTimelineEditContextOptional();
const domEditActions = useDomEditActionsContextOptional();
const soloed = usePlayerStore((s) => s.soloed);
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
const memberIds = memberElements.map((el) => el.key ?? el.id);
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
@@ -95,51 +121,84 @@ export function TimelineGroupRow({
borderColor={theme.rowBorder}
rovingTargetId={rovingTargetId}
>
<TimelineGroupHeader
label={group.label}
memberCount={group.memberTracks.length}
isExpanded={expandedGroupIds.has(group.id)}
onToggleExpanded={() => toggleGroupExpanded(group.id)}
laneCount={groupAutomationLanes(memberElements).length}
isLaneOpen={isLaneOpen}
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
hidden={group.hidden}
onToggleHidden={() =>
onSetAudioGroupAttributeQuiet?.(
group.id,
"data-hidden",
group.hidden ? null : "",
group.hidden ? "Unmute group" : `Mute group ${group.label}`,
)
}
isSoloed={soloed.has(group.id)}
isHalfLitSolo={isGroupHalfLitUnderSolo(soloed, group.id, memberIds)}
onToggleSolo={(options) => toggleSolo(group.id, options)}
fxChain={group.fxChain}
onFxChainChange={(next) => writeGroupFxChain(next, false)}
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
onOpenFxRack={openGroupFxRack}
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
theme={theme}
/>
{isLaneOpen && (
<TimelineGroupBusStrip
groupId={group.id}
volume={group.volume}
memberLabels={memberLabels}
onVolumeChange={(value) =>
onSetAudioGroupAttributeLive?.(group.id, "data-volume", String(value))
}
onVolumeCommit={(value) =>
onSetAudioGroupAttributeQuiet?.(
group.id,
"data-volume",
String(value),
"Set group volume",
)
}
{/* Header and its lane labels in ONE sticky column — the shape
`TimelineTrackHeader` already uses: a fixed TRACK_H line box with the
lane rows absolutely positioned beneath it, the whole thing pinned.
The labels used to be SIBLINGS of the header, so `absolute left-0`
resolved against the ROW, and the row is what scrolls horizontally —
they slid away with the canvas. Sticky lives here rather than on the
header, which keeps its own box inside; a zero-width sticky wrapper
around the labels alone does not work either, because as a flex item
after the header it starts at x = columnWidth, i.e. inside the lanes. */}
<div
className="sticky left-0 z-[12] shrink-0"
style={{ width: contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin }}
>
<TimelineGroupHeader
label={group.label}
memberCount={group.memberTracks.length}
isExpanded={!collapsedGroupIds.has(group.id)}
onToggleExpanded={() => toggleGroupExpanded(group.id)}
// The GROUP's own lanes, not its members'. `∿` is per-row (groups doc
// §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), and
// counting the members' here made the group advertise curves it does
// not own and cannot show.
laneCount={groupAutomationLanes([groupElement]).length}
isLaneOpen={isLaneOpen}
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
fxChain={group.fxChain}
onFxChainChange={(next) => writeGroupFxChain(next, false)}
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
auditionSpans={memberElements}
onOpenFxRack={openGroupFxRack}
// Same width as every other row's header. The group row needs a real
// label column, but it gets one by turning `labelMode` on for the whole
// timeline (see Timeline.tsx) rather than by overhanging alone — an
// overhanging header paints opaquely across the rest of its row and
// stays pinned there through horizontal scroll.
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
theme={theme}
/>
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
clip's: the binder writes through the dom-edit selection, so a lane is
editable once the group is selected — which clicking its name does. */}
{/* The label column for those lanes, on the accent rail — inside the
sticky column above, so they pin with the header. */}
{isLaneOpen && (
<TimelineGroupLaneLabels
groupElement={groupElement}
groupLabel={group.label}
top={TRACK_H}
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
gutterBackground={theme.gutterBackground}
accentColor={GROUP_LANE_ACCENT}
/>
)}
</div>
{isLaneOpen && (
// The same offset content cell a track row wraps its lanes in — the
// slot positions absolutely, so mounted straight on the row it resolved
// against the row instead and drew the envelope across the label gutter
// from x=0.
<div
role="gridcell"
aria-colindex={2}
style={{ width: trackContentWidth, marginLeft: contentGutter }}
className="relative"
>
<TimelineAutomationLaneSlot
elements={[groupElement]}
isSelected={() => isGroupSelected}
lanes={lanes}
pps={pps}
// Below the strip, which sits directly under the header row.
laneCount={0}
topOffset={TRACK_H}
accentColor={GROUP_LANE_ACCENT}
currentTime={currentTime}
beatTimes={beatTimes}
/>
</div>
)}
</TimelineTrackRow>
);
@@ -118,7 +118,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
selectedElementId: null,
selectedElementIds: next.selectedElementIds ?? new Set(),
expandedClipIds: new Set(next.expandedClipIds ?? []),
expandedGroupIds: new Set(),
collapsedGroupIds: new Set(),
expandedLaneOwnerIds: new Set(),
groups: [],
trackGroupOf: new Map(),
@@ -203,8 +203,8 @@ describe("TimelineLanes track numbering", () => {
const second = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 2"]');
act(() => second?.click());
// Third argument is the display row the button announced, so the undo entry
// records the same row the user just read off it.
// Both, and they are different numbers: the real key acts, the display row
// is what the undo-history label must announce (see `onToggleTrackHidden`).
expect(onToggleTrackHidden).toHaveBeenCalledWith(TRACK_B, true, 2);
act(() => view.root.unmount());
});
@@ -1,4 +1,4 @@
import { Fragment, useId } from "react";
import { Fragment, useId, useMemo } from "react";
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
import { TimelineClip } from "./TimelineClip";
import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
@@ -9,6 +9,7 @@ import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelecti
import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { TimelineGroupRow } from "./TimelineGroupRow";
import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes";
import { useTimelineClipDisclosure } from "./useTimelineClipDisclosure";
import {
isTrackRowExpanded,
resolveTrackKeyframeClip,
@@ -22,7 +23,6 @@ import { usePlayerStore } from "../store/playerStore";
import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview";
import { useTimelineMultiDragActorWindows } from "./useTimelineMultiDragActorWindows";
import type { TimelineLanesProps } from "./timelineLaneProps";
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
import { createClipGestureHandlers } from "./timelineClipGestureHandlers";
import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren";
@@ -100,29 +100,24 @@ export function TimelineLanes({
// from resolving into a second timeline that renders the same logical rows.
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const { expandedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
useTimelineGroupDisclosure();
const automationLanes = useAutomationLanes();
// A group's automation clock is COMPOSITION time (groups doc §1.3), so its
// synthetic lane element spans the whole composition rather than a clip.
const compositionDuration = usePlayerStore((s) => s.duration);
useAutomationSelectionKeyboard({ lanes: automationLanes });
const expandClips = usePlayerStore((s) => s.expandClips);
const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
const { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
// The caret belongs to the ROW, so it opens and closes every clip on it at
// once. Toggling only the active clip left the row's state depending on which
// sibling happened to be selected: expand one, click another, and the row
// collapsed under a caret that still pointed down.
const toggleRowExpandedTracked = (keys: readonly string[]) => {
const willExpand = !keys.some((key) => expandedClipIds.has(key));
trackStudioKeyframeLaneExpand({ expanded: willExpand });
if (willExpand) expandClips(keys);
else for (const key of keys) setClipExpanded(key, false);
};
const toggleClipExpandedTracked = (key: string) => {
const willExpand = !expandedClipIds.has(key);
trackStudioKeyframeLaneExpand({ expanded: willExpand });
toggleClipExpanded(key);
};
// Which tracks are group MEMBERS, so their headers can render the level-2
// nesting their `aria-level` already reports.
const groupMemberTracks = useMemo(
() => new Set(groups.flatMap((group) => group.memberTracks)),
[groups],
);
const {
toggleRowExpanded: toggleRowExpandedTracked,
toggleClipExpanded: toggleClipExpandedTracked,
} = useTimelineClipDisclosure();
const actorWindows = useTimelineMultiDragActorWindows(
multiDragPreview,
rowsVirtualized,
@@ -163,17 +158,23 @@ export function TimelineLanes({
rowKey={rowKey}
group={group}
logicalRow={groupLogicalRow}
tracks={tracks}
top={rowGeometry.getRowTop(row)}
height={rowGeometry.getRowHeight(row)}
virtualized={rowsVirtualized}
contentOrigin={contentOrigin}
theme={theme}
rovingTargetId={keyboard.rovingTargetId}
expandedGroupIds={expandedGroupIds}
collapsedGroupIds={collapsedGroupIds}
expandedLaneOwnerIds={expandedLaneOwnerIds}
toggleGroupExpanded={toggleGroupExpanded}
toggleLaneOwnerExpanded={toggleLaneOwnerExpanded}
lanes={automationLanes}
pps={pps}
currentTime={currentTime}
compositionDuration={compositionDuration}
beatTimes={beatAnalysis?.beatTimes}
contentGutter={contentGutter}
trackContentWidth={trackContentWidth}
/>
);
}
@@ -290,6 +291,7 @@ export function TimelineLanes({
currentTime={currentTime}
isTrackHidden={isTrackHidden}
isAudioTrack={isAudioTrack}
isGroupMember={groupMemberTracks.has(trackNum)}
theme={theme}
onToggleClipExpanded={() => {
const keys = els.map(getTimelineElementIdentity);
@@ -7,9 +7,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { defaultTimelineTheme } from "./timelineTheme";
import type { TimelineElement } from "../store/playerStore";
import { type TimelineElement } from "../store/playerStore";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { getTimelineLaneTop, LABEL_COL_W } from "./timelineLayout";
import { getTimelineLaneTop, LABEL_COL_W, TRACK_H } from "./timelineLayout";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -72,6 +72,9 @@ interface RenderHeaderOptions {
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
onRemoveAutomationLane?: (target: string) => void;
isAudioTrack?: boolean;
isGroupMember?: boolean;
isTrackHidden?: boolean;
}
function renderHeader(options: RenderHeaderOptions = {}): {
@@ -82,7 +85,20 @@ function renderHeader(options: RenderHeaderOptions = {}): {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const render = (next: RenderHeaderOptions) => {
const render = (raw: RenderHeaderOptions) => {
// Defaults resolved once, up front, rather than as a `??` per prop in the
// JSX — a dozen of those is a dozen branches through one arrow.
const next = {
keyframeClip: ELEMENT,
clipCount: 1,
animations: [POSITION, OPACITY],
currentTime: 0,
isAudioTrack: false,
isGroupMember: false,
isTrackHidden: false,
onToggleTrackHidden: vi.fn(),
...raw,
};
act(() => {
root.render(
<TimelineTrackHeader
@@ -93,17 +109,18 @@ function renderHeader(options: RenderHeaderOptions = {}): {
trackLabel="Hero card"
lanesId="timeline-lanes-track-0"
contentOrigin={LABEL_COL_W}
keyframeClip={next.keyframeClip ?? ELEMENT}
trackElements={next.trackElements ?? [next.keyframeClip ?? ELEMENT]}
clipCount={next.clipCount ?? 1}
keyframeClip={next.keyframeClip}
trackElements={next.trackElements ?? [next.keyframeClip]}
clipCount={next.clipCount}
isExpanded={next.expanded !== false}
animations={next.animations ?? [POSITION, OPACITY]}
currentTime={next.currentTime ?? 0}
isTrackHidden={false}
isAudioTrack={false}
animations={next.animations}
currentTime={next.currentTime}
isTrackHidden={next.isTrackHidden}
isAudioTrack={next.isAudioTrack}
isGroupMember={next.isGroupMember}
theme={defaultTimelineTheme}
onToggleClipExpanded={vi.fn()}
onToggleTrackHidden={next.onToggleTrackHidden ?? vi.fn()}
onToggleTrackHidden={next.onToggleTrackHidden}
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
onRemoveAutomationLane={next.onRemoveAutomationLane}
onSeek={next.onSeek}
@@ -122,6 +139,63 @@ function click(host: HTMLElement, label: string) {
}
describe("TimelineTrackHeader", () => {
// §5: gain stages multiply. A group fading to 0.42 under a clip fading to
// 0.80 plays at 0.34, and an author who drew both hears something quieter
// than either with nothing on screen to say why. Not a warning; an
// explanation.
it("says so when the clip's group is fading the same parameter", () => {
const automation = JSON.stringify({
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 2, v: 0.4 },
],
},
],
});
const clip: TimelineElement = {
...ELEMENT,
tag: "audio",
automation,
audioGroup: "voiceover",
audioGroupLabel: "Voiceover",
audioGroupAutomation: automation,
};
const view = renderHeader({ keyframeClip: clip, trackElements: [clip], animations: [] });
expect(view.host.textContent).toContain("Voiceover is also fading this.");
act(() => view.root.unmount());
});
// The same clip with an un-automated group must stay quiet — the note is
// only honest when the two curves actually multiply.
it("stays quiet when the group automates nothing", () => {
const automation = JSON.stringify({
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 2, v: 0.4 },
],
},
],
});
const clip: TimelineElement = {
...ELEMENT,
tag: "audio",
automation,
audioGroup: "voiceover",
audioGroupLabel: "Voiceover",
};
const view = renderHeader({ keyframeClip: clip, trackElements: [clip], animations: [] });
expect(view.host.textContent).not.toContain("is also fading this");
act(() => view.root.unmount());
});
// An expanded sub-composition child sits on the MASTER timeline at a
// host-absolute start, but its tweens are parsed from its own file and are
// local to it. Feeding the raw start straight into the clip-% math put every
@@ -180,6 +254,55 @@ describe("TimelineTrackHeader", () => {
act(() => view.root.unmount());
});
// The visibility control is the old hide eye. On an audio track it silences
// rather than hides, and the row already says so with a speaker elsewhere —
// so the eye's slot stays empty there. A non-audio track is untouched.
it("keeps the visibility control off audio track headers", () => {
const audio: TimelineElement = { ...ELEMENT, tag: "audio" };
const view = renderHeader({
keyframeClip: audio,
trackElements: [audio],
isAudioTrack: true,
animations: [],
});
const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
b.getAttribute("aria-label"),
);
expect(labels.some((l) => l && /^(Hide|Show) track/.test(l))).toBe(false);
expect(labels).not.toContain("Mute");
act(() => view.root.unmount());
});
// The escape hatch. `data-hidden` on audio silences it in preview and drops it
// from the render; the panel's "Muted" is the unrelated HTML `muted`
// attribute, and nothing else writes it. Withholding the eye unconditionally
// meant a track hidden by "Hide all" (or by hand, or before that rule existed)
// was silent with no control anywhere to bring it back.
it("offers the eye on an audio track that is already hidden, so it can be restored", () => {
const audio: TimelineElement = { ...ELEMENT, tag: "audio" };
const view = renderHeader({
keyframeClip: audio,
trackElements: [audio],
isAudioTrack: true,
isTrackHidden: true,
animations: [],
});
const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
b.getAttribute("aria-label"),
);
expect(labels.some((l) => l && /^Show track/.test(l))).toBe(true);
act(() => view.root.unmount());
});
it("keeps it on a non-audio track", () => {
const view = renderHeader({ isAudioTrack: false });
const labels = Array.from(view.host.querySelectorAll("button")).map((b) =>
b.getAttribute("aria-label"),
);
expect(labels.some((l) => l && /^Hide track/.test(l))).toBe(true);
act(() => view.root.unmount());
});
// The eye acts on the layer, so it has to be reachable without a pointer and
// in every disclosure state — a hover-gated eye is unusable by keyboard.
it("keeps the visibility eye mounted whether the layer is expanded or collapsed", () => {
@@ -614,7 +737,9 @@ describe("TimelineTrackHeader", () => {
it("names the header for the track, not for one of the clips on it", () => {
const view = renderHeader({ ...ROW, keyframeClip: NARRATION_2 });
expect(view.host.textContent).not.toContain("narration-2");
expect(view.host.querySelector('[title="Track 1"]')?.textContent).toBe("Track 1");
// Asserted on the rendered text, not a `title`: the name wraps now rather
// than truncating, so it no longer carries a tooltip to be found by.
expect(view.host.textContent).toContain("Track 1");
// Alone on the track it is still named for itself.
view.rerender({
...ROW,
@@ -622,7 +747,170 @@ describe("TimelineTrackHeader", () => {
trackElements: [NARRATION_2],
clipCount: 1,
});
expect(view.host.querySelector('[title="narration-2"]')?.textContent).toBe("narration-2");
expect(view.host.textContent).toContain("narration-2");
act(() => view.root.unmount());
});
});
describe("audio ids and canary gates", () => {
const VOICE: TimelineElement = {
id: "voice-1",
key: "index.html#voice-1",
domId: "voice-1",
tag: "audio",
start: 0,
duration: 5,
track: 0,
};
const VOICE_2: TimelineElement = {
...VOICE,
id: "voice-2",
key: "index.html#voice-2",
domId: "voice-2",
};
// The set is pushed straight into the runtime, which compares it against
// `el.id`. A store key here matches nothing, `isAudibleUnderSolo` returns
// false for every element, and soloing silences the whole preview.
// A member row is `aria-level="2"`, and without this it looked identical to
// every top-level row — the nesting existed for a screen reader and not for
// an eye. B2's design called for the accent rail; only the semantics shipped.
it("indents a group member's row and gives it the accent rail", () => {
const view = renderHeader({
keyframeClip: VOICE,
animations: [],
expanded: false,
isAudioTrack: true,
});
const header = () => view.host.querySelector<HTMLElement>('[role="rowheader"]');
expect(header()?.style.paddingLeft).toBe("");
expect(header()?.style.borderLeft).toBe("");
expect(header()?.style.background).not.toContain("linear-gradient");
view.rerender({
keyframeClip: VOICE,
animations: [],
expanded: false,
isAudioTrack: true,
isGroupMember: true,
});
expect(header()?.style.paddingLeft).toBe("14px");
expect(header()?.style.borderLeft).toContain("2px");
// And a lighter gutter, so the row reads as sitting INSIDE its group
// rather than beside it. Overlaid on the theme's own fill rather than a
// hard-coded colour, so it follows whatever the gutter is.
expect(header()?.style.background).toContain("linear-gradient");
act(() => view.root.unmount());
});
it("offers the FX button on every audio track", () => {
const view = renderHeader({
keyframeClip: VOICE,
animations: [],
expanded: false,
isAudioTrack: true,
});
expect(view.host.querySelector('button[aria-label="Effects"]')).not.toBeNull();
act(() => view.root.unmount());
});
// A visual track has no chain to open, so the button must not follow the
// header onto every row.
it("withholds the FX button from a non-audio track", () => {
const view = renderHeader({
keyframeClip: ELEMENT,
animations: [],
expanded: false,
});
expect(view.host.querySelector('button[aria-label="Effects"]')).toBeNull();
act(() => view.root.unmount());
});
// A chain belongs to ONE bus, so a track carrying several ungrouped clips
// gets the pointer instead of the FX button — group first, then mix.
it("shows the group pointer on a multi-clip ungrouped audio track", () => {
const opts = {
keyframeClip: VOICE,
trackElements: [VOICE, VOICE_2],
clipCount: 2,
animations: [],
expanded: false,
isAudioTrack: true,
};
const pointer = (host: HTMLElement) =>
host.querySelector('button[aria-label="Effects — group these clips first"]');
const view = renderHeader(opts);
expect(pointer(view.host)).not.toBeNull();
// One clip needs no grouping — the real FX button takes its place.
view.rerender({ ...opts, trackElements: [VOICE], clipCount: 1 });
expect(pointer(view.host)).toBeNull();
expect(view.host.querySelector('button[aria-label="Effects"]')).not.toBeNull();
act(() => view.root.unmount());
});
// The header is a 48px column of exactly TWO lines — what the row is, then
// what you can do to it. The group pointer used to render as a sibling of
// both, making a third: 17 + 24 + 24 + gaps in a 48px box, which
// `justify-center` then spilled evenly out of the top and bottom. The name
// rode 10px above its own row and the pointer collided with the row below.
// The header GROWS by AUTOMATION_LANE_H for every open lane, and the lanes
// are absolutely positioned from its top. `justify-center` on the header
// itself therefore centred the two lines in the FULL height, so opening a
// lane pushed the name and its controls down on top of the lane rows.
it("pins the two lines to the top TRACK_H, whatever the header grows to", () => {
const automated: TimelineElement = {
...VOICE,
automation: JSON.stringify({
version: 1,
lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }],
}),
};
const view = renderHeader({
keyframeClip: automated,
trackElements: [automated],
clipCount: 1,
animations: [],
expanded: true,
isAudioTrack: true,
});
const header = view.host.querySelector<HTMLElement>('[role="rowheader"]');
const lines = header?.children[0] as HTMLElement | undefined;
expect(lines?.style.height).toBe(`${TRACK_H}px`);
// The lane row is a sibling of the wrapper, not inside it — it stacks
// BELOW the two lines rather than sharing their box.
expect((header?.children.length ?? 0) > 1).toBe(true);
act(() => view.root.unmount());
});
// The header is ONE line: name, clip count, then every control anchored to
// the right edge. It was two — a name line and a control line — which is
// what let a stray third child overflow the 48px box; now there is a single
// row and the controls share one right-aligned group.
it("keeps the name and every control on one line, controls to the right", () => {
const view = renderHeader({
keyframeClip: VOICE,
trackElements: [VOICE, VOICE_2],
clipCount: 2,
animations: [],
expanded: false,
isAudioTrack: true,
});
const header = view.host.querySelector<HTMLElement>('[role="rowheader"]');
// One TRACK_H-tall wrapper holding one line.
const lines = header?.children[0];
expect(lines?.children).toHaveLength(1);
// The controls live in a right-anchored group at the end of that line,
// after the name and the clip count — `ml-auto` is what holds the edge.
const line = lines?.children[0];
const controls = line?.lastElementChild as HTMLElement | null;
expect(controls?.className).toContain("ml-auto");
expect(
controls?.querySelector('button[aria-label="Effects — group these clips first"]'),
).not.toBeNull();
// And the clip count is beside the name, not out with the controls.
expect(controls?.querySelector('[aria-label="2 clips"]')).toBeNull();
expect(line?.querySelector('[aria-label="2 clips"]')).not.toBeNull();
act(() => view.root.unmount());
});
});
@@ -11,22 +11,34 @@ import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
import { runtimeAudioId } from "../lib/timelineElementHelpers";
import { TimelineFxButton } from "./TimelineFxButton";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
import { groupAutomationLanes } from "./automationLaneData";
import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationLaneData";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { clipTimingStart } from "../../hooks/gsapShared";
import { LayerDisclosureRow } from "./LayerDisclosureRow";
import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
import { LaneToggleButton, LayerDisclosureRow } from "./LayerDisclosureRow";
import { LABEL_COL_W, TRACK_H, getTimelineLaneTop } from "./timelineLayout";
import type { TimelineTheme } from "./timelineTheme";
import {
resolveLaneHeaderState,
type KeyframeNavigationState,
type TimelinePropertyLane,
} from "./trackHeaderLaneState";
import { valueReadout } from "./trackHeaderLaneValues";
import { trackDisplaySuffix } from "./timelineTrackDisplay";
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
import { AutomationLaneHeaderRow, PropertyGroupHeaderRow } from "./trackHeaderLabelRows";
import { useMemo } from "react";
/** Accent rail + inset marking a row as a group MEMBER, matching the level-2
* nesting its `aria-level` already reports. */
const GROUP_MEMBER_RAIL = "#3CE6AC59";
const GROUP_MEMBER_INDENT = 14;
/** A hair lighter than `gutterBackground`, so a member row reads as sitting
* INSIDE its group rather than beside it. Overlaid rather than hard-coded so
* it tracks whatever the theme's gutter is. */
const GROUP_MEMBER_TINT = "rgba(255,255,255,0.035)";
/** The gutter fill for a row, tinted when it belongs to a group. */
function gutterFill(base: string, isGroupMember: boolean): string {
return isGroupMember
? `linear-gradient(${GROUP_MEMBER_TINT}, ${GROUP_MEMBER_TINT}), ${base}`
: base;
}
interface TimelineTrackHeaderProps {
/** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
@@ -58,6 +70,8 @@ interface TimelineTrackHeaderProps {
currentTime: number;
isTrackHidden: boolean;
isAudioTrack: boolean;
/** This track is a member of an audio group — indents the row under its header. */
isGroupMember?: boolean;
rovingTargetId?: string | null;
theme: TimelineTheme;
onToggleClipExpanded: () => void;
@@ -69,267 +83,6 @@ interface TimelineTrackHeaderProps {
onSeek?: (time: number) => void;
}
// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
function PropertyGroupNavigation({
navigation,
label,
expandedElement,
onSeek,
children,
}: {
navigation: KeyframeNavigationState;
label: string;
expandedElement: TimelineElement;
onSeek?: (time: number) => void;
children: React.ReactNode;
}) {
// The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
// target is met with a centered transparent ::before overlay instead of a
// bigger box; focus-visible matches every other control in this header.
const CHEVRON_BUTTON_CLASS =
"relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
"focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
"before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
"before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
const seekTo = (keyframe: { percentage: number } | null) => {
if (keyframe) {
onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
}
};
return (
<span className="flex shrink-0 items-center gap-0.5">
<button
type="button"
aria-label={`Previous ${label} keyframe`}
disabled={!navigation.prevKeyframe}
className={CHEVRON_BUTTON_CLASS}
onClick={(event) => {
event.stopPropagation();
seekTo(navigation.prevKeyframe);
}}
>
‹
</button>
{children}
<button
type="button"
aria-label={`Next ${label} keyframe`}
disabled={!navigation.nextKeyframe}
className={CHEVRON_BUTTON_CLASS}
onClick={(event) => {
event.stopPropagation();
seekTo(navigation.nextKeyframe);
}}
>
›
</button>
</span>
);
}
function PropertyGroupHeaderRow({
lanesId,
lane,
laneIndex,
isLastLane,
expandedElement,
currentTime,
clipPercentage,
gutterBackground,
columnWidth,
onTogglePropertyGroupKeyframe,
onSeek,
rovingTargetId = null,
}: {
lanesId: string;
lane: TimelinePropertyLane;
laneIndex: number;
isLastLane: boolean;
expandedElement: TimelineElement;
currentTime: number;
clipPercentage: number;
gutterBackground: string;
columnWidth: number;
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
onSeek?: (time: number) => void;
rovingTargetId: string | null;
}) {
const elementId = expandedElement.key ?? expandedElement.id;
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
lane,
currentTime,
clipPercentage,
);
return (
<div
id={timelineLogicalRowCellId(lanesId, timelinePropertyRowId(elementId, lane.group), "header")}
data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
data-timeline-element-id={elementId}
tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
data-property-group={lane.group}
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
style={{
top: getTimelineLaneTop(laneIndex),
// The header column narrows to contentOrigin whenever that is under
// LABEL_COL_W; a lane row pinned to LABEL_COL_W then hangs its value
// readout over the canvas, on top of the clips it is labelling.
width: columnWidth,
height: LANE_H,
background: gutterBackground,
}}
>
{/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
<span
className="absolute left-1.5 top-0 w-px bg-white/15"
style={{ height: isLastLane ? "50%" : "100%" }}
/>
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
</span>
<span className="w-[46px] shrink-0 truncate text-white" title={label}>
{label}
</span>
<PropertyGroupNavigation
navigation={navigation}
label={label}
expandedElement={expandedElement}
onSeek={onSeek}
>
<button
type="button"
aria-pressed={!!navigation.currentKeyframe}
aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
onClick={(event) => {
// Same as the disclosure caret and the eye: a control in the label
// column owns its click, it does not also hit the track row behind it.
event.stopPropagation();
if (toggleTarget) {
void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
}
}}
>
{navigation.currentKeyframe ? "◆" : "◇"}
</button>
</PropertyGroupNavigation>
<span
className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45"
title={valueReadout(lane.group, values)}
>
{valueReadout(lane.group, values)}
</span>
</div>
);
}
/**
* One envelope's row in the label column.
*
* Named here rather than inside the lane, on the same tree connector the
* keyframe rows use: an automation lane is a child of its clip exactly as a
* property group is, and drawing its name over the envelope put the label on top
* of the curve it describes and scrolled it away from its own row.
*/
function AutomationLaneHeaderRow({
target,
label,
name,
param,
top,
isLastLane,
gutterBackground,
columnWidth,
onRemove,
}: {
/** The lane the ACTIVE clip draws in this row, or null when it draws none —
* the row belongs to the property, and a clip may be absent from it. */
target: string | null;
/** The whole thing on one line: the row's identity, its tooltip, and the
* remove button's name. */
label: string;
/** What the effect is — "Peaking EQ 1.6 kHz". */
name: string;
/** Which knob the envelope drives. Empty when there is no second line to draw. */
param: string;
top: number;
isLastLane: boolean;
gutterBackground: string;
columnWidth: number;
onRemove?: (target: string) => void;
}) {
return (
<div
data-automation-lane-label={label}
data-timeline-lane-top={top}
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
style={{
top,
width: columnWidth,
height: AUTOMATION_LANE_H,
background: gutterBackground,
}}
>
{/* Tree connector, as the keyframe rows draw it: spine down the row, branch
tick at the name's own height. */}
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
<span
className="absolute left-1.5 top-0 w-px bg-white/15"
style={{ height: isLastLane ? "50%" : "100%" }}
/>
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
</span>
{/* Two lines: what the effect is, then which knob the envelope drives. On
one line a band's own name was the first thing truncated in a column this
narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
bands apart. */}
<span className="flex min-w-0 flex-1 flex-col justify-center leading-tight" title={label}>
<span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
{name}
</span>
{param ? (
<span
data-automation-lane-param=""
className="truncate font-mono text-[9px] text-white/40"
>
{param}
</span>
) : null}
</span>
{/* Beside the name it labels, because that is the only place an envelope is
named at all: a carve writes its own lanes, and the FX panel's automate
toggle can only reach a parameter it still lists — so without this an
envelope could be created and never removed.
Only for the clip the header is showing, since that is the only one a
write can reach: a shared row's other envelopes belong to clips that
are not selected, and a button that silently removed one of them (or
none) would be worse than no button. */}
{onRemove && target !== null && (
<button
type="button"
aria-label={`Remove ${label} automation`}
title={`Remove ${label} automation`}
// h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-white/35 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
// A control in the label column owns its click; it does not also hit
// the track row behind it and change the selection.
event.stopPropagation();
onRemove(target);
}}
>
×
</button>
)}
</div>
);
}
// fallow-ignore-next-line complexity
export function TimelineTrackHeader({
trackNumber,
@@ -345,6 +98,7 @@ export function TimelineTrackHeader({
currentTime,
isTrackHidden,
isAudioTrack,
isGroupMember = false,
theme,
onToggleClipExpanded,
onToggleTrackHidden,
@@ -369,26 +123,94 @@ export function TimelineTrackHeader({
// order. `target` is the ACTIVE clip's lane in that row, which is the only one
// the remove button can write to; null when the row belongs to its siblings.
const activeKey = keyframeClip ? (keyframeClip.key ?? keyframeClip.id) : null;
const automationRows = groupAutomationLanes(trackElements).map((group) => ({
key: group.key,
label: group.key,
name: group.name,
param: group.param,
target:
group.entries.find((entry) => (entry.element.key ?? entry.element.id) === activeKey)?.lane
.target ?? null,
}));
const groupOwner = useMemo(
() => trackElements.find((element) => element.audioGroup)?.audioGroup,
[trackElements],
);
const groupLabelForNote = useMemo(
() => trackElements.find((element) => element.audioGroupLabel)?.audioGroupLabel,
[trackElements],
);
const groupAutomationRaw = useMemo(
() => trackElements.find((element) => element.audioGroupAutomation)?.audioGroupAutomation,
[trackElements],
);
const groupFxChainRaw = useMemo(
() => trackElements.find((element) => element.audioGroupFxChain)?.audioGroupFxChain,
[trackElements],
);
// Which parameters this track's GROUP also automates. Gain stages multiply,
// and §5 asks for the explanation rather than leaving the author to wonder
// why two curves they drew sound quieter than either.
const groupAutomatedTargets = useMemo(
() =>
new Set(
groupAutomationLanes(
groupOwner
? [
{
id: groupOwner,
tag: "audio",
start: 0,
duration: 0,
track: 0,
...(groupAutomationRaw ? { automation: groupAutomationRaw } : {}),
...(groupFxChainRaw ? { fxChain: groupFxChainRaw } : {}),
},
]
: [],
).map((lane) => lane.key),
),
[groupAutomationRaw, groupFxChainRaw, groupOwner],
);
const revealAudioFx = usePlayerStore((s) => s.setRevealedAudioFxTarget);
/**
* Which element's rack a lane's reveal opens, in the PANEL's id space.
*
* The bare dom id, not the timeline's `sourceFile#domId` composite: the
* property panel identifies its element by `element.id`, so a composite would
* never match — the same boundary `runtimeAudioId` exists for.
*/
const revealElementId = keyframeClip ? runtimeAudioId(keyframeClip) : null;
const automationRows = useMemo(
() =>
groupAutomationLanes(trackElements).map((group) => {
const active = group.entries.find(
(entry) => (entry.element.key ?? entry.element.id) === activeKey,
);
return {
key: group.key,
label: group.key,
name: group.name,
param: group.param,
target: active?.lane.target ?? null,
// Every entry in a row is the same parameter, so the first answers for the
// row when the active clip is absent from it.
isCarve: (() => {
const entry = active ?? group.entries[0];
return entry ? isCarveLane(entry.lane.target, elementFxChain(entry.element)) : false;
})(),
};
}),
[activeKey, trackElements],
);
// Automation counts as something to disclose: gating the caret on tweens alone
// left an audio clip's envelopes unreachable, since the track could not expand.
const disclosable = lanes.length > 0 || automationRows.length > 0;
const isKeyframeLayer = !!keyframeClip && disclosable;
// Solo is per-clip/per-group, never per track (design doc §2.2) — this header
// acts on the track's first clip as a pragmatic stand-in for "this track",
// the same simplification the mute button doesn't need to make (it patches
// every clip on the track at once).
const soloTargetId = trackElements[0] ? (trackElements[0].key ?? trackElements[0].id) : null;
const soloed = usePlayerStore((s) => s.soloed);
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
// Which HEADER LAYOUT the row wears — not the same question as `disclosable`.
// An audio track that automates something is still an audio track: it keeps
// the music glyph and the group indent and gains the `∿`. Tying layout to
// disclosability swapped it for the keyframe-layer row (a `◇`, no indent) the
// moment an envelope appeared.
const isKeyframeLayer = !!keyframeClip && disclosable && !isAudioTrack;
// What the lane disclosure calls this row. A row of several clips is named
// for the TRACK, not for whichever is selected — the lanes are the track's,
// shared per property, so "Narration 2 lanes" read as if they were that one
// slice's. Shared by both layouts so the name cannot change with the layout.
const laneOwnerName =
clipCount > 1
? `Track${trackDisplaySuffix(trackDisplayNumber)}`
: (keyframeClip?.label ?? keyframeClip?.domId ?? keyframeClip?.id ?? trackLabel);
// C1: the FX entry point. A single audio clip has one chain to point at; a
// track holding several ungrouped ones has no single chain — the design
@@ -400,6 +222,11 @@ export function TimelineTrackHeader({
const singleAudioClip =
isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
const isTrackGrouped = trackElements.some((el) => el.audioGroup);
// A video track carries sound the render mixes but preview never routes
// through Web Audio, which is why §1.4 keeps groups audio-only. It still
// needs to be TOLD that, so it earns the button and a refusal.
const isVideoWithAudioTrack =
!isAudioTrack && trackElements.some((el) => el.tag.toLowerCase() === "video");
const writeClipFxChain = (clip: TimelineElement, next: HfAudioFxChain, live: boolean) => {
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
if (live) onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
@@ -408,74 +235,138 @@ export function TimelineTrackHeader({
const openClipFxRack = (clip: TimelineElement) => {
void domEditActions?.handleTimelineElementSelect(clip);
};
const groupUngroupedClips = () => {
// DOM ids, matching the carve picker's other caller — membership is read back
// by `resolveAudioGroups`, which only ever sees the document. A clip with no
// DOM id cannot be a member (resolveAudioGroups skips it), so a track holding
// one cannot be grouped WHOLE — and grouping the rest would quietly leave
// those clips outside the bus, past every fader, mute and effect, while the
// UI showed the track as grouped. The button is withheld instead of acting on
// a subset, which is also why the carve path's loud guard cannot catch this:
// the unresolvable ids were filtered out before the call.
const groupableClipIds = trackElements.map(runtimeAudioId);
const allGroupableClipIds = groupableClipIds.every((id): id is string => id !== null)
? groupableClipIds
: null;
const canGroupWholeTrack = (allGroupableClipIds?.length ?? 0) >= 2;
const groupUngroupedClips = (label: string) => {
const doc = domEditActions?.previewIframeRef.current?.contentDocument;
if (!doc || !onGroupClips) return;
const clipIds = trackElements.map((el) => el.key ?? el.id);
void onGroupClips(clipIds, mintGroupId(doc));
if (!allGroupableClipIds || allGroupableClipIds.length < 2) return;
void onGroupClips(allGroupableClipIds, mintGroupId(doc), label);
};
return (
<div
role="rowheader"
aria-colindex={1}
className={`sticky left-0 z-[12] shrink-0 ${
!isKeyframeLayer
? showTrackLabel
? "flex items-center gap-1 px-1.5 text-white/55"
: "flex flex-col items-center justify-center gap-0.5"
: ""
}`}
className="sticky left-0 z-[12] shrink-0"
style={{
width: showTrackLabel ? LABEL_COL_W : contentOrigin,
background: theme.gutterBackground,
background: gutterFill(theme.gutterBackground, isGroupMember),
borderRight: `1px solid ${theme.gutterBorder}`,
// A group's member rows are `aria-level="2"`, and until this they read
// as level 2 to a screen reader while looking identical to every
// top-level row on screen. The rail is the accent-tinted left border
// B2's design called for; the inset is what actually makes the nesting
// legible. Padding rather than margin so the rail stays flush with the
// gutter's own edge.
...(isGroupMember
? {
borderLeft: `2px solid ${GROUP_MEMBER_RAIL}`,
paddingLeft: GROUP_MEMBER_INDENT,
}
: {}),
}}
>
{!keyframeClip || !disclosable ? (
{!isKeyframeLayer ? (
<>
<PlainTrackHeader
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
trackLabel={trackLabel}
clipCount={clipCount}
showTrackLabel={showTrackLabel}
isTrackHidden={isTrackHidden}
isAudioTrack={isAudioTrack}
isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
onToggleTrackHidden={onToggleTrackHidden}
/>
{singleAudioClip && (
<TimelineFxButton
variant="chain"
fxChainRaw={singleAudioClip.fxChain}
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
onOpenRack={() => openClipFxRack(singleAudioClip)}
{/* The two lines own exactly TRACK_H, not the whole header.
`justify-center` on the header itself centred them in its FULL
height — which grows by AUTOMATION_LANE_H per open lane — so
opening one pushed the name and its controls down THROUGH the lane
rows below, which are absolutely positioned from the top. */}
<div
className={
showTrackLabel
? "flex flex-col justify-center gap-0.5 px-1.5 text-white/55"
: "flex flex-col items-center justify-center gap-0.5"
}
style={{ height: TRACK_H }}
>
<PlainTrackHeader
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
trackLabel={trackLabel}
clipCount={clipCount}
showTrackLabel={showTrackLabel}
isTrackHidden={isTrackHidden}
isAudioTrack={isAudioTrack}
onToggleTrackHidden={onToggleTrackHidden}
// On the control line rather than a third row of its own.
trailing={
<>
{singleAudioClip && (
<TimelineFxButton
variant="chain"
fxChainRaw={singleAudioClip.fxChain}
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
// Muted, an audition is silent — so the hover lifts the mute on
// the running graph and puts it back on the way out, the same
// borrow-and-return it already does with the playhead.
auditionSpans={[singleAudioClip]}
isMuted={isTrackHidden}
onSetMutedLive={(muted) =>
onSetElementAttributeLive?.(
singleAudioClip,
"data-hidden",
muted ? "" : null,
)
}
onOpenRack={() => openClipFxRack(singleAudioClip)}
/>
)}
{clipCount > 1 &&
!isTrackGrouped &&
(isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) && (
<TimelineFxButton
variant="group-pointer"
clipCount={trackElements.length}
defaultLabel={trackLabel}
// Groups are audio-only in v1 (§1.4). A video track showing no
// button at all is the silent limit §5 forbids, so it gets the
// button and a reason instead.
refusal={
isAudioTrack
? undefined
: "Video audio can't be grouped yet — only audio clips can join a group."
}
onGroupClips={groupUngroupedClips}
/>
)}
{/* The lane disclosure, on the row's own layout rather than by
swapping it for a keyframe-layer row. */}
{disclosable && (
<LaneToggleButton
name={laneOwnerName}
isExpanded={isExpanded}
lanesId={lanesId}
onToggle={onToggleClipExpanded}
/>
)}
</>
}
/>
)}
{isAudioTrack && clipCount > 1 && !isTrackGrouped && (
<TimelineFxButton variant="group-pointer" onGroupClips={groupUngroupedClips} />
)}
</div>
</>
) : (
<>
<LayerDisclosureRow
name={
// A row holding several clips is named for the track, not for
// whichever of them is selected — the lanes below it are the
// track's, shared per property, and naming it "Narration 2" read
// as if they all belonged to that one slice.
clipCount > 1
? `Track${trackDisplaySuffix(trackDisplayNumber)}`
: (keyframeClip.label ?? keyframeClip.domId ?? keyframeClip.id)
}
name={laneOwnerName}
clipCount={clipCount}
isExpanded={isExpanded}
gutterBackground={theme.gutterBackground}
gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)}
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
lanesId={lanesId}
onToggleClipExpanded={onToggleClipExpanded}
@@ -489,57 +380,86 @@ export function TimelineTrackHeader({
hidden={isTrackHidden}
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
visible
isAudioTrack={isAudioTrack}
// Audio: only while hidden — see the plain header.
visible={!isAudioTrack || isTrackHidden}
onToggle={onToggleTrackHidden}
/>
</LayerDisclosureRow>
{/* The caret expands TWO disjoint subtrees: these label-column rows,
which carry the per-lane keyframe controls, and the diamond lanes
on the canvas. `lanesId` names the canvas lanes (rendered by
TimelineLanes), because that is what a sighted user watches appear
and what following the reference has to land on. These rows are not
empty and are not the target; they are absolutely positioned inside
the sticky column, which is what made a wrapper HERE compute to
0x0 and hold no diamonds. */}
{isExpanded &&
lanes.map((lane, laneIndex) => (
<PropertyGroupHeaderRow
key={lane.group}
lanesId={lanesId}
lane={lane}
laneIndex={laneIndex}
isLastLane={laneIndex === lanes.length - 1 && automationRows.length === 0}
expandedElement={keyframeClip}
currentTime={currentTime}
clipPercentage={clipPercentage}
gutterBackground={theme.gutterBackground}
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
onSeek={onSeek}
rovingTargetId={rovingTargetId}
/>
))}
{/* Below the keyframe rows and stepping by its own height, which is how
TimelineAutomationLaneSlot lays the envelopes out on the canvas. The
two have to agree or a name labels the wrong curve. */}
{isExpanded &&
automationRows.map((row, index) => (
<AutomationLaneHeaderRow
key={row.key}
target={row.target}
label={row.label}
name={row.name}
param={row.param}
top={getTimelineLaneTop(lanes.length) + index * AUTOMATION_LANE_H}
isLastLane={index === automationRows.length - 1}
gutterBackground={theme.gutterBackground}
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
onRemove={onRemoveAutomationLane}
/>
))}
</>
)}
{/* The caret expands TWO disjoint subtrees: these label-column rows,
which carry the per-lane keyframe controls, and the diamond lanes
on the canvas. `lanesId` names the canvas lanes (rendered by
TimelineLanes), because that is what a sighted user watches appear
and what following the reference has to land on. These rows are not
empty and are not the target; they are absolutely positioned inside
the sticky column, which is what made a wrapper HERE compute to
0x0 and hold no diamonds. */}
{isExpanded &&
keyframeClip &&
lanes.map((lane, laneIndex) => (
<PropertyGroupHeaderRow
key={lane.group}
lanesId={lanesId}
lane={lane}
laneIndex={laneIndex}
isLastLane={laneIndex === lanes.length - 1 && automationRows.length === 0}
expandedElement={keyframeClip}
currentTime={currentTime}
clipPercentage={clipPercentage}
gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)}
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
onSeek={onSeek}
rovingTargetId={rovingTargetId}
/>
))}
{/* Below the keyframe rows and stepping by its own height, which is how
TimelineAutomationLaneSlot lays the envelopes out on the canvas. The
two have to agree or a name labels the wrong curve. */}
{isExpanded &&
automationRows.map((row, index) => {
// Bound outside the closure so it narrows: `row.target` is null on a
// row the active clip is absent from.
const revealTarget = row.target;
return (
<AutomationLaneHeaderRow
key={row.key}
target={row.target}
label={row.label}
name={row.name}
param={row.param}
alsoAutomatedBy={
groupAutomatedTargets.has(row.key) ? (groupLabelForNote ?? groupOwner) : undefined
}
top={getTimelineLaneTop(lanes.length) + index * AUTOMATION_LANE_H}
isLastLane={index === automationRows.length - 1}
gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)}
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
onRemove={onRemoveAutomationLane}
isCarve={row.isCarve}
// Only for a lane the ACTIVE clip actually draws: the rack shows one
// element, so a shared row's other envelopes belong to clips it is
// not showing and there would be nothing to reveal.
onReveal={
revealTarget && revealElementId && keyframeClip
? () => {
// Select FIRST: the rack is the property panel's view of
// the selected element, so a reveal aimed at an unselected
// clip lands on a panel that says "Nothing selected". The
// request survives the selection — it is stored, not an
// event — so the rack consumes it as it mounts.
openClipFxRack(keyframeClip);
revealAudioFx({
elementKey: revealElementId,
automationTarget: revealTarget,
});
}
: undefined
}
/>
);
})}
</div>
);
}
@@ -3,12 +3,15 @@
/**
* The visibility control's accessible contract.
*
* The audio wording ran behind the `audio-track-mute` canary at 0%, so it had
* never rendered in any suite: it returned "Muted" / "Mute", which named the
* CURRENT state rather than the action, and dropped the track suffix so every
* audio row shared one accessible name. Music plus VO is the ordinary case, so
* that is two identical buttons. Pinned here because the label and the icon are
* the whole identity of this control.
* The wording ran behind the `audio-track-mute` canary at 0%, so it had never
* rendered in any suite: it returned "Muted" / "Mute", which named the CURRENT
* state rather than the action, and dropped the track suffix so every audio row
* shared one accessible name. Pinned here because the label, the icon and the
* callback's arguments are the whole identity of this control.
*
* The mute presentation itself is gone (`remove mute and solo from tracks and
* groups`): the control is the visibility eye it always was, and an audio row
* renders it only when already hidden, which is the way back out.
*/
import React, { act } from "react";
@@ -24,7 +27,7 @@ afterEach(() => {
function renderButton(props: {
hidden: boolean;
isAudioTrack?: boolean;
visible?: boolean;
trackDisplayNumber: number | null;
}): { host: HTMLElement; unmount: () => void; onToggle: ReturnType<typeof vi.fn> } {
const host = document.createElement("div");
@@ -37,8 +40,7 @@ function renderButton(props: {
hidden: props.hidden,
trackNumber: 7,
trackDisplayNumber: props.trackDisplayNumber,
visible: true,
isAudioTrack: props.isAudioTrack,
visible: props.visible ?? true,
onToggle,
}),
),
@@ -49,31 +51,7 @@ function renderButton(props: {
const labelOf = (host: HTMLElement) => host.querySelector("button")?.getAttribute("aria-label");
describe("VisibilityButton", () => {
it("names the action, not the state, on an audible audio track", () => {
const view = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 2 });
expect(labelOf(view.host)).toBe("Mute track 2");
view.unmount();
});
// The half that was wrong: a muted row read "Muted", so nothing told a
// screen-reader user that activating it would unmute.
it("promises the un-mute when the audio track is already muted", () => {
const view = renderButton({ hidden: true, isAudioTrack: true, trackDisplayNumber: 2 });
expect(labelOf(view.host)).toBe("Unmute track 2");
view.unmount();
});
it("keeps each audio row's name unique, so two tracks are distinguishable", () => {
const first = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 1 });
const second = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 3 });
expect(labelOf(first.host)).toBe("Mute track 1");
expect(labelOf(second.host)).toBe("Mute track 3");
expect(labelOf(first.host)).not.toBe(labelOf(second.host));
first.unmount();
second.unmount();
});
it("still says Hide/Show on a visual track", () => {
it("names the action, not the state", () => {
const shown = renderButton({ hidden: false, trackDisplayNumber: 2 });
expect(labelOf(shown.host)).toBe("Hide track 2");
shown.unmount();
@@ -82,15 +60,34 @@ describe("VisibilityButton", () => {
hiddenRow.unmount();
});
it("keeps each row's name unique, so two tracks are distinguishable", () => {
const first = renderButton({ hidden: false, trackDisplayNumber: 1 });
const second = renderButton({ hidden: false, trackDisplayNumber: 3 });
expect(labelOf(first.host)).toBe("Hide track 1");
expect(labelOf(second.host)).toBe("Hide track 3");
expect(labelOf(first.host)).not.toBe(labelOf(second.host));
first.unmount();
second.unmount();
});
// The callback acts on the REAL track key; the display row rides along so the
// undo-history label announces the same row this button just did, instead of
// re-deriving it from an ordering that has no group anchors in it.
it("toggles the real track number and passes the row it announced", () => {
const view = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 2 });
const view = renderButton({ hidden: false, trackDisplayNumber: 2 });
view.host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(view.onToggle).toHaveBeenCalledWith(7, true, 2);
view.unmount();
});
// `visible={false}` renders a SPACER, not nothing: every row's control
// columns have to line up whether or not this row offers the control.
it("holds the column with a spacer when withheld", () => {
const view = renderButton({ hidden: false, visible: false, trackDisplayNumber: 2 });
expect(view.host.querySelector("button")).toBeNull();
expect(view.host.querySelector("span")).toBeTruthy();
view.unmount();
});
});
describe("PlainTrackHeader", () => {
@@ -98,7 +95,6 @@ describe("PlainTrackHeader", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const onToggleSolo = vi.fn();
act(() =>
root.render(
React.createElement(PlainTrackHeader, {
@@ -110,52 +106,34 @@ describe("PlainTrackHeader", () => {
isAudioTrack: true,
onToggleTrackHidden: vi.fn(),
showTrackLabel: true,
isGroupMuted: false,
isSoloed: false,
onToggleSolo,
...overrides,
}),
),
);
return { host, unmount: () => act(() => root.unmount()), onToggleSolo };
return { host, unmount: () => act(() => root.unmount()) };
}
const soloButton = (host: HTMLElement) =>
host.querySelector('button[aria-label="Hear only this"]');
const labelSpan = (host: HTMLElement) => host.querySelector("span.min-w-0");
it("offers solo on an audio track and reports its pressed state", () => {
const view = renderHeader({ isSoloed: true });
expect(soloButton(view.host)?.getAttribute("aria-pressed")).toBe("true");
view.unmount();
});
it("withholds solo from a visual track", () => {
const view = renderHeader({ isAudioTrack: false });
expect(soloButton(view.host)).toBeNull();
view.unmount();
});
// A group-muted member is silent without being hidden itself, so the strike
// is the only thing that says so — and the title has to explain why, since a
// user who never touched THIS row's mute is looking for the reason.
it("strikes the label through when the row's own mute is on", () => {
const view = renderHeader({ isTrackHidden: true });
expect(labelSpan(view.host)?.className).toContain("line-through");
view.unmount();
});
it("strikes it through for a group mute too, and says which", () => {
const view = renderHeader({ isGroupMuted: true });
expect(labelSpan(view.host)?.className).toContain("line-through");
expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover (group muted)");
view.unmount();
});
it("leaves an audible row unstruck", () => {
// A row that says what it is with a speaker does not also need the hide
// affordance sitting in the eye's slot.
it("withholds the eye from an audible audio track", () => {
const view = renderHeader();
expect(labelSpan(view.host)?.className).not.toContain("line-through");
expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover");
expect(view.host.querySelector("button")).toBeNull();
view.unmount();
});
// Withholding it unconditionally withheld the only way back: `data-hidden`
// silences the clip in preview and drops it from the render, and nothing else
// writes it, so a track hidden by hand or by "Hide all" was silent with no
// control anywhere to restore it.
it("offers it back once the audio track is hidden", () => {
const view = renderHeader({ isTrackHidden: true });
expect(labelOf(view.host)).toBe("Show track 1");
view.unmount();
});
it("keeps the eye on a visual track", () => {
const view = renderHeader({ isAudioTrack: false });
expect(labelOf(view.host)).toBe("Hide track 1");
view.unmount();
});
});
@@ -1,29 +1,18 @@
import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
import type React from "react";
import { Eye, EyeSlash } from "@phosphor-icons/react";
import { Music } from "../../icons/SystemIcons";
import { TimelineSoloButton } from "./TimelineSoloButton";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { TrackClipCount } from "./TrackClipCount";
import { trackDisplaySuffix } from "./timelineTrackDisplay";
/**
* Audio tracks say "Mute", not "Hide" — the eye IS mute for sound-only rows.
*
* Action-phrased and per-track, like the visual branch: `hidden` here is the
* CURRENT state, so the name has to promise the opposite. It read "Muted" /
* "Mute" until the rollout, which named the state instead of the action (a
* screen-reader user could not tell that activating an already-muted row would
* unmute it) and dropped `suffix`, so every audio row shared one accessible
* name — music plus VO being the ordinary case. The wording matches the undo
* entry `timelineTrackVisibility` writes for the same click, where `hidden` is
* the INCOMING state and the two therefore read inverted.
*/
function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
if (showAsMute) return hidden ? `Unmute track${suffix}` : `Mute track${suffix}`;
// Hide, plainly. The speaker variant was the mute presentation; with mute gone
// this is the visibility eye it always was, and audio rows do not render it.
function visibilityButtonLabel(hidden: boolean, suffix: string): string {
return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
}
function visibilityButtonIcon(showAsMute: boolean, hidden: boolean) {
const Icon = showAsMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye;
function visibilityButtonIcon(hidden: boolean) {
const Icon = hidden ? EyeSlash : Eye;
return <Icon size={14} weight="bold" aria-hidden="true" />;
}
@@ -32,22 +21,19 @@ export function VisibilityButton({
trackNumber,
trackDisplayNumber,
visible,
isAudioTrack,
onToggle,
}: {
hidden: boolean;
trackNumber: number;
trackDisplayNumber: number | null;
visible: boolean;
isAudioTrack?: boolean;
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
}) {
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
// Display number in the text, real key in the callback. The two must not be
// conflated in either direction.
const suffix = trackDisplaySuffix(trackDisplayNumber);
const showAsMute = Boolean(isAudioTrack);
const label = visibilityButtonLabel(showAsMute, hidden, suffix);
const label = visibilityButtonLabel(hidden, suffix);
return (
<button
type="button"
@@ -59,10 +45,12 @@ export function VisibilityButton({
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
// Display number alongside the real key: the undo-history label must
// announce the same row this button just did (see `onToggleTrackHidden`).
void onToggle?.(trackNumber, !hidden, trackDisplayNumber);
}}
>
{visibilityButtonIcon(showAsMute, hidden)}
{visibilityButtonIcon(hidden)}
</button>
);
}
@@ -77,10 +65,8 @@ export function PlainTrackHeader({
showTrackLabel,
isTrackHidden,
isAudioTrack,
isGroupMuted,
isSoloed,
onToggleSolo,
onToggleTrackHidden,
trailing,
}: {
trackNumber: number;
trackDisplayNumber: number | null;
@@ -90,37 +76,61 @@ export function PlainTrackHeader({
isAudioTrack: boolean;
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
showTrackLabel: boolean;
isGroupMuted: boolean;
isSoloed: boolean;
onToggleSolo?: (options?: { add?: boolean }) => void;
/** Trailing controls that belong on the control line — the FX entry points,
* which the caller owns because only it knows the clip they act on. */
trailing?: React.ReactNode;
}) {
return (
<>
{isAudioTrack && (
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
)}
{showTrackLabel && (
<span
className={`min-w-0 flex-1 truncate text-[11px] ${
isAudioTrack && (isTrackHidden || isGroupMuted) ? "line-through" : ""
}`}
title={isGroupMuted && !isTrackHidden ? `${trackLabel} (group muted)` : trackLabel}
>
{trackLabel}
</span>
)}
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
<VisibilityButton
hidden={isTrackHidden}
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
visible
isAudioTrack={isAudioTrack}
onToggle={onToggleTrackHidden}
/>
{isAudioTrack && onToggleSolo && (
<TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
)}
{/* One line: the name, then every control pushed to the right edge. The
two-line split this replaced existed to stop four controls truncating
the name — but the name already truncates on its own (`min-w-0` plus
`truncate`), and the controls are `shrink-0`, so they hold the edge
and the name gives way instead. */}
<div className="flex min-w-0 items-center gap-1">
{isAudioTrack && (
<Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
)}
{/* No `flex-1`: the name takes only the width it needs, so the clip
count sits against it rather than being pushed out to meet the
controls. The slack goes to the `ml-auto` group below instead.
Wraps rather than truncating: a truncated name needs a hover to be
read at all, and a tooltip is no use to a name you are scanning a
column of. `break-words` so a long single token breaks instead of
forcing the column wider. */}
{showTrackLabel && (
<span className="min-w-0 break-words text-[11px] leading-tight">{trackLabel}</span>
)}
{showTrackLabel && <TrackClipCount clipCount={clipCount} />}
{/* `ml-auto` is what anchors the group right: it absorbs the slack the
truncating name leaves, so the controls sit on the edge whatever the
name's length. */}
<div className="ml-auto flex shrink-0 items-center gap-1">
{/* Not on an audio track. The control is the old visibility eye, and
on audio it silences rather than hides — but a row that already says
what it is with a speaker does not also need the hide affordance
sitting in the eye's slot. `visible={false}` rather than omitting the
element, so the spacer keeps every row's control columns aligned.
EXCEPT when the audio track is ALREADY hidden. Withholding the
control unconditionally withheld the only way back: `data-hidden`
silences the clip in preview and drops it from the render, the
panel's "Muted" is the unrelated HTML `muted` attribute, and nothing
else writes it — so a track hidden before this rule (or by "Hide
all", or by hand) was silent with no control anywhere to restore it.
Offering the eye only in that state keeps the affordance off a normal
audio row while leaving the door open from the inside. */}
<VisibilityButton
hidden={isTrackHidden}
trackNumber={trackNumber}
trackDisplayNumber={trackDisplayNumber}
visible={!isAudioTrack || isTrackHidden}
onToggle={onToggleTrackHidden}
/>
{trailing}
</div>
</div>
</>
);
}
@@ -4,9 +4,11 @@ import {
automationLaneLabel,
automationLaneLabelParts,
elementAutomation,
elementAutomationLanes,
groupAutomationLanes,
laneGroupKey,
elementFxChain,
isCarveLane,
} from "./automationLaneData";
import type { TimelineElement } from "../store/timelineElement";
@@ -268,3 +270,95 @@ describe("groupAutomationLanes", () => {
expect(groupAutomationLanes([stale]).map((g) => g.key)).toEqual(["Volume"]);
});
});
// A carve writes its own filter bands AND the lanes that drive them, and
// `withoutCarveLanes` replaces every one of them on each re-run — so a drag on
// one is discarded the next time the carve analyses. The rack also counts the
// carve as ONE module rather than the filters it compiles to, so a lane per
// band reads as "automation on effects I removed", which is exactly how it was
// reported.
describe("carve-generated lanes", () => {
const carved = (): TimelineElement => ({
id: "vo",
tag: "audio",
start: 0,
duration: 5,
track: 0,
fxChain: JSON.stringify({
version: 1,
nodes: [
{
type: "peaking",
id: "n1",
fromCarve: true,
params: { frequency: 160, gain: -6, q: 1.4 },
},
{ type: "peaking", id: "n2", params: { frequency: 900, gain: -3, q: 1 } },
],
}),
automation: JSON.stringify({
version: 1,
lanes: [
{
target: "fx.n1.gain",
points: [
{ t: 0, v: 0 },
{ t: 1, v: -4 },
],
},
{
target: "fx.n2.gain",
points: [
{ t: 0, v: 0 },
{ t: 1, v: -2 },
],
},
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 1, v: 0.5 },
],
},
],
}),
});
// These lanes used to be withheld. A bed whose EVERY lane is the carve's —
// which is what a plain voiceover carve produces — then showed no automation
// at all and no control to reveal any, so the carve looked like it had done
// nothing. The ducking curve is what a carve IS; it is shown, and marked
// read-only because the carve rewrites it on each analysis.
it("draws the carve's lanes alongside the author's", () => {
const targets = elementAutomationLanes(carved()).map((lane) => lane.target);
expect(targets).toContain("fx.n1.gain");
expect(targets).toContain("fx.n2.gain");
expect(targets).toContain("volume");
});
it("counts a carve band as a timeline row, so the row reserves its height", () => {
const rows = groupAutomationLanes([carved()]);
expect(rows).toHaveLength(3);
});
it("tells a carve's lane from the author's on the same element", () => {
const chain = elementFxChain(carved());
expect(isCarveLane("fx.n1.gain", chain)).toBe(true);
// n2 is a hand-built peaking band with the same parameter — only the
// `fromCarve` tag separates them.
expect(isCarveLane("fx.n2.gain", chain)).toBe(false);
expect(isCarveLane("volume", chain)).toBe(false);
});
it("treats every lane as the author's when nothing is carve-tagged", () => {
const plain = {
...carved(),
fxChain: JSON.stringify({
version: 1,
nodes: [{ type: "peaking", id: "n1", params: { frequency: 160, gain: -6, q: 1.4 } }],
}),
};
expect(elementAutomationLanes(plain).map((l) => l.target)).toContain("fx.n1.gain");
expect(isCarveLane("fx.n1.gain", elementFxChain(plain))).toBe(false);
});
});
@@ -91,7 +91,30 @@ export function elementAutomation(element: TimelineElement): HfAutomation {
});
}
/**
* Is this lane one the CARVE wrote, rather than the author?
*
* The carve compiles to tagged nodes and rewrites their envelopes on every
* re-run (`withoutCarveLanes` replaces each one), so a drag on such a lane is
* silently discarded the next time it analyses. Read-only rather than hidden:
* the ducking curve is what a carve IS, and the whole reason to look at a
* carved bed in the timeline is to see where it makes room. Hiding them left a
* bed whose every lane was the carve's showing no automation at all and no
* control to reveal any — which reads as the carve having done nothing.
*/
export function isCarveLane(target: string, chain: HfAudioFxChain | null): boolean {
return (chain?.nodes ?? []).some(
(node) => node.fromCarve && node.id && target.startsWith(`fx.${node.id}.`),
);
}
/** Lanes in the order they are drawn, one row each. */
/**
* The lanes a TIMELINE row should draw.
*
* Every lane the element carries, the carve's included — see `isCarveLane` for
* why those are shown read-only instead of withheld.
*/
export function elementAutomationLanes(element: TimelineElement): HfAutomationLane[] {
return elementAutomation(element).lanes;
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { groupAutomationElement } from "./groupAutomationElement";
import { groupAutomationLanes } from "./automationLaneData";
const GROUP = {
id: "voiceover",
label: "Voiceover",
anchorKey: 1.5,
automation: JSON.stringify({
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 5, v: 0.4 },
],
},
],
}),
};
describe("groupAutomationElement", () => {
// §1.3: "A group's automation clock is COMPOSITION time — decide it, do not
// inherit it." A clip-local span would land the group's fade at a different
// moment in preview than the render bakes it.
it("spans the whole composition from zero, so lane times are composition time", () => {
const el = groupAutomationElement(GROUP, 60);
expect(el.start).toBe(0);
expect(el.duration).toBe(60);
});
// The lane machinery filters with `isAudioTimelineElement`; a group that does
// not pass it renders nothing at all, silently.
it("is admitted by the lane machinery and yields the group's own lanes", () => {
const lanes = groupAutomationLanes([groupAutomationElement(GROUP, 60)]);
expect(lanes).toHaveLength(1);
expect(lanes[0]?.name).toBeTruthy();
});
// The write has to land on `<hf-audio-group>`, not on a member clip.
it("carries the group's DOM id, so a lane edit addresses the group element", () => {
expect(groupAutomationElement(GROUP, 60).domId).toBe("voiceover");
});
// A group with no automation draws no lanes — and must not throw doing it.
it("yields no lanes when the group automates nothing", () => {
const bare = { id: "sfx", label: "SFX", anchorKey: 2.5 };
expect(groupAutomationLanes([groupAutomationElement(bare, 60)])).toHaveLength(0);
});
});
@@ -0,0 +1,37 @@
/**
* The group, as the automation lanes see it.
*
* Lanes are keyed by `TimelineElement` everywhere — the slot, the binder, the
* identity used for selection. A group is not one: it has no clip id, no start
* and no duration, which is §1.9's "a group is the first real audio entity in
* the system" problem showing up in the row model. Rather than give lanes a
* second, parallel path, the group borrows the shape it does not have.
*
* `start: 0` and `duration` = the composition's is not a placeholder, it is the
* clock: the design doc fixes a group's automation clock as COMPOSITION time
* (§1.3), and a missing `data-start` parses as 0, which is exactly that. So a
* lane drawn against this element lands at the same seconds the render bakes.
*/
import type { TimelineElement } from "../store/playerStore";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
/** The synthetic element's track number — the group row's own anchor. */
export function groupAutomationElement(
group: Pick<TimelineTrackGroupInfo, "id" | "label" | "automation" | "fxChain" | "anchorKey">,
compositionDuration: number,
): TimelineElement {
return {
id: group.id,
// The DOM id, so a write lands on the `<hf-audio-group>` and not on a clip.
domId: group.id,
label: group.label,
// Audio, so `isAudioTimelineElement` admits it and the lanes render at all.
tag: "audio",
start: 0,
duration: compositionDuration,
track: group.anchorKey,
...(group.automation ? { automation: group.automation } : {}),
...(group.fxChain ? { fxChain: group.fxChain } : {}),
};
}
@@ -73,7 +73,7 @@ export interface TimelineEditCallbacks {
* two different orderings: the header's comes from the group-aware row list
* (synthetic anchor rows, members pulled contiguous), the history's from a
* plain ascending sort of element-bearing keys. Once a group exists those
* disagree, so the same click said "Mute track 2" and recorded "Mute track 1".
* disagree, so the same click said "Hide track 2" and recorded "Hide track 1".
* Passing the rendered number keeps one answer instead of two derivations.
*/
onToggleTrackHidden?: (
@@ -93,7 +93,11 @@ export interface TimelineEditCallbacks {
/** C1's ungrouped-track FX pointer: "Group these clips" — write
* `data-audio-group` on every one of them, atomically. Same shape B6's
* carve auto-grouping uses. */
onGroupClips?: (clipIds: readonly string[], groupId: string) => Promise<void>;
onGroupClips?: (
clipIds: readonly string[],
groupId: string,
groupLabel?: string,
) => Promise<void>;
/** C1's single-clip FX write: addressed by the clip itself rather than the
* current selection, mirroring `onSetAudioGroupAttributeLive/Quiet`. */
onSetElementAttributeLive?: (
@@ -58,7 +58,7 @@ function model(overrides: Partial<Parameters<typeof buildTimelineLogicalRows>[0]
selectedElementId: "active",
selectedElementIds: new Set(),
expandedClipIds: new Set(["active"]),
expandedGroupIds: new Set(),
collapsedGroupIds: new Set(),
expandedLaneOwnerIds: new Set(),
groups: [],
trackGroupOf: new Map(),
@@ -248,7 +248,7 @@ describe("resolveTimelineNavigationTarget", () => {
selectedElementId: null,
selectedElementIds: new Set(),
expandedClipIds: new Set(),
expandedGroupIds: new Set(),
collapsedGroupIds: new Set(),
expandedLaneOwnerIds: new Set(),
groups: [],
trackGroupOf: new Map(),
@@ -77,8 +77,8 @@ export interface BuildTimelineLogicalRowsInput {
selectedElementId: string | null;
selectedElementIds: ReadonlySet<string>;
expandedClipIds: ReadonlySet<string>;
/** Groups whose member rows the caret has shown (structural, not lanes). */
expandedGroupIds: ReadonlySet<string>;
/** Groups the caret has COLLAPSED — absent means expanded, the default. */
collapsedGroupIds: ReadonlySet<string>;
/** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
expandedLaneOwnerIds: ReadonlySet<string>;
groups: readonly TimelineTrackGroupInfo[];
@@ -251,7 +251,7 @@ export function buildTimelineLogicalRows({
selectedElementId,
selectedElementIds,
expandedClipIds,
expandedGroupIds,
collapsedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
@@ -273,7 +273,8 @@ export function buildTimelineLogicalRows({
selectedElementIds,
gsapAnimations,
);
const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && lanes.length > 0;
const disclosable = isTrackDisclosable(elements, lanes.length);
const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && disclosable;
rows.push({
id: trackId,
kind: "row",
@@ -282,7 +283,7 @@ export function buildTimelineLogicalRows({
level,
parentId,
elementId: activeId,
expandable: lanes.length > 0,
expandable: disclosable,
expanded,
items: clipItems(trackId, elements),
});
@@ -300,7 +301,7 @@ export function buildTimelineLogicalRows({
// count.
function emitGroup(group: TimelineTrackGroupInfo): void {
const groupRowId = timelineGroupRowId(group.id);
const groupExpanded = expandedGroupIds.has(group.id);
const groupExpanded = !collapsedGroupIds.has(group.id);
rows.push({
id: groupRowId,
kind: "row",
@@ -315,8 +316,10 @@ export function buildTimelineLogicalRows({
items: [],
});
if (expandedLaneOwnerIds.has(group.id)) {
const memberElements = group.memberTracks.flatMap((track) => trackMap.get(track) ?? []);
for (const laneGroup of groupAutomationLanes(memberElements)) {
// The group's own member list, not `trackMap`: a COLLAPSED group can have
// its lane shelf open, and its members are absent from the display list —
// so looking them up there emitted zero lane rows for exactly that case.
for (const laneGroup of groupAutomationLanes(group.memberElements)) {
rows.push({
id: `${groupRowId}::${laneGroup.key}`,
kind: "row",
@@ -452,3 +455,17 @@ export function resolveTimelineFocusFallback(
}
return nextRows[previous.rowIndex] ?? nextRows[previous.rowIndex - 1] ?? null;
}
/**
* Does a track have anything to open — the header's own `disclosable`.
*
* `TimelineTrackHeader` is `lanes.length > 0 || automationRows.length > 0`, and
* keyed on tweens alone here an audio track whose only disclosable content is
* AUTOMATION drew the `∿` while reporting itself unexpandable to the treegrid,
* so ArrowRight could not open it. Automation rows are counted per shared
* PROPERTY across the track's clips, the way the header counts them, not per
* clip.
*/
function isTrackDisclosable(elements: readonly TimelineElement[], laneCount: number): boolean {
return laneCount > 0 || groupAutomationLanes(elements).length > 0;
}
@@ -7,7 +7,7 @@ export const GUTTER = 32;
export const LABEL_COL_W = 232;
export const TRACK_H = 48;
export const LANE_H = 28;
export const STRIP_H = 40; // group bus strip (B7) — one fixed block, not per-property like LANE_H
export const STRIP_H = 40; // compatibility bridge; removed later in the stack
export const RULER_H = 24;
export const CLIP_Y = 3;
export const CLIP_HANDLE_W = 18;
@@ -4,7 +4,7 @@ import type { ResizingClipState } from "./timelineClipDragTypes";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { animationContributesLane } from "./TimelinePropertyLanes";
export function hasKeyframedTimelineClips(
function hasKeyframedTimelineClips(
animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
): boolean {
return Array.from(animationsByElement.values()).some((animations) =>
@@ -12,6 +12,35 @@ export function hasKeyframedTimelineClips(
);
}
/**
* Does the timeline need the wide label column?
*
* Keyframed clips need it for their property-lane names. Audio GROUPS need it
* for the same reason a keyframed clip does — a row whose name has nowhere else
* to go. A track row survives a narrow gutter because its CLIPS carry the name
* on the bar; a group row has no clips at all, so in the 80px gutter its label
* rendered at zero width and its FX and lane buttons were clipped off the
* side.
*
* Widening the column for the whole timeline, rather than letting just the
* group row overhang: the header is sticky and opaque, so an oversized one
* painted a slab across the rest of its own row, stayed pinned there through
* horizontal scroll, and had the playhead drawn straight through it.
*/
export function timelineNeedsLabelColumn(
animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
elements: readonly TimelineElement[],
): boolean {
return (
hasKeyframedTimelineClips(animationsByElement) ||
// The column exists FOR the group rows, so this must stay in step with
// whatever decides they are drawn (`useTimelineTrackDerivations`) —
// widening it with no group row on screen is a 232px shift of every clip
// with nothing to explain it.
elements.some((element) => Boolean(element.audioGroup))
);
}
export function getEffectiveTimelineDuration(
duration: number,
elements: readonly TimelineElement[],
@@ -0,0 +1,328 @@
/**
* The rows a track header draws BELOW its own line: one per disclosed keyframe
* property group, one per automation envelope.
*
* Split out of `TimelineTrackHeader.tsx`, which owned all of this and stood at
* 763 lines against the studio's 600-line cap. Nothing else moved: these three
* components never read the header's state, only their own props, which is why
* they are the seam.
*/
import type { TimelineElement } from "../store/playerStore";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
import {
resolveLaneHeaderState,
type KeyframeNavigationState,
type TimelinePropertyLane,
} from "./trackHeaderLaneState";
import { valueReadout } from "./trackHeaderLaneValues";
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
function PropertyGroupNavigation({
navigation,
label,
expandedElement,
onSeek,
children,
}: {
navigation: KeyframeNavigationState;
label: string;
expandedElement: TimelineElement;
onSeek?: (time: number) => void;
children: React.ReactNode;
}) {
// The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
// target is met with a centered transparent ::before overlay instead of a
// bigger box; focus-visible matches every other control in this header.
const CHEVRON_BUTTON_CLASS =
"relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
"focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
"before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
"before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
const seekTo = (keyframe: { percentage: number } | null) => {
if (keyframe) {
onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
}
};
return (
<span className="flex shrink-0 items-center gap-0.5">
<button
type="button"
aria-label={`Previous ${label} keyframe`}
disabled={!navigation.prevKeyframe}
className={CHEVRON_BUTTON_CLASS}
onClick={(event) => {
event.stopPropagation();
seekTo(navigation.prevKeyframe);
}}
>
‹
</button>
{children}
<button
type="button"
aria-label={`Next ${label} keyframe`}
disabled={!navigation.nextKeyframe}
className={CHEVRON_BUTTON_CLASS}
onClick={(event) => {
event.stopPropagation();
seekTo(navigation.nextKeyframe);
}}
>
›
</button>
</span>
);
}
export function PropertyGroupHeaderRow({
lanesId,
lane,
laneIndex,
isLastLane,
expandedElement,
currentTime,
clipPercentage,
gutterBackground,
columnWidth,
onTogglePropertyGroupKeyframe,
onSeek,
rovingTargetId = null,
}: {
lanesId: string;
lane: TimelinePropertyLane;
laneIndex: number;
isLastLane: boolean;
expandedElement: TimelineElement;
currentTime: number;
clipPercentage: number;
gutterBackground: string;
columnWidth: number;
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
onSeek?: (time: number) => void;
rovingTargetId: string | null;
}) {
const elementId = expandedElement.key ?? expandedElement.id;
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
lane,
currentTime,
clipPercentage,
);
return (
<div
id={timelineLogicalRowCellId(lanesId, timelinePropertyRowId(elementId, lane.group), "header")}
data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
data-timeline-element-id={elementId}
tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
data-property-group={lane.group}
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
style={{
top: getTimelineLaneTop(laneIndex),
// The header column narrows to contentOrigin whenever that is under
// LABEL_COL_W; a lane row pinned to LABEL_COL_W then hangs its value
// readout over the canvas, on top of the clips it is labelling.
width: columnWidth,
height: LANE_H,
background: gutterBackground,
}}
>
{/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
<span
className="absolute left-1.5 top-0 w-px bg-white/15"
style={{ height: isLastLane ? "50%" : "100%" }}
/>
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
</span>
<span className="w-[46px] shrink-0 truncate text-white" title={label}>
{label}
</span>
<PropertyGroupNavigation
navigation={navigation}
label={label}
expandedElement={expandedElement}
onSeek={onSeek}
>
<button
type="button"
aria-pressed={!!navigation.currentKeyframe}
aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-[#3CE6AC] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
onClick={(event) => {
// Same as the disclosure caret and the eye: a control in the label
// column owns its click, it does not also hit the track row behind it.
event.stopPropagation();
if (toggleTarget) {
void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
}
}}
>
{navigation.currentKeyframe ? "◆" : "◇"}
</button>
</PropertyGroupNavigation>
<span
className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45"
title={valueReadout(lane.group, values)}
>
{valueReadout(lane.group, values)}
</span>
</div>
);
}
/**
* One envelope's row in the label column.
*
* Named here rather than inside the lane, on the same tree connector the
* keyframe rows use: an automation lane is a child of its clip exactly as a
* property group is, and drawing its name over the envelope put the label on top
* of the curve it describes and scrolled it away from its own row.
*/
export function AutomationLaneHeaderRow({
target,
label,
name,
param,
alsoAutomatedBy,
top,
isLastLane,
gutterBackground,
columnWidth,
onRemove,
isCarve,
onReveal,
}: {
/** The lane the ACTIVE clip draws in this row, or null when it draws none —
* the row belongs to the property, and a clip may be absent from it. */
target: string | null;
/** The whole thing on one line: the row's identity, its tooltip, and the
* remove button's name. */
label: string;
/** What the effect is — "Peaking EQ 1.6 kHz". */
name: string;
/** Which knob the envelope drives. Empty when there is no second line to draw. */
param: string;
/** Set when this clip's group automates the SAME parameter. Gain stages
* multiply — 0.42 on the group under 0.80 here plays at 0.34 — so an author
* who drew one curve and then another hears something quieter than either
* with nothing on screen to say why (groups doc §5). Not a warning; an
* explanation. */
alsoAutomatedBy?: string;
top: number;
isLastLane: boolean;
gutterBackground: string;
columnWidth: number;
onRemove?: (target: string) => void;
/** The carve owns this envelope and rewrites it on every re-run, so it is
* shown but not the author's to edit or delete. */
isCarve?: boolean;
/** Reveal this parameter in the rack. Absent, the name is inert rather than a
* button that looks live and does nothing. */
onReveal?: () => void;
}) {
return (
<div
data-automation-lane-label={label}
data-timeline-lane-top={top}
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
style={{
top,
width: columnWidth,
height: AUTOMATION_LANE_H,
background: gutterBackground,
}}
>
{/* Tree connector, as the keyframe rows draw it: spine down the row, branch
tick at the name's own height. */}
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
<span
className="absolute left-1.5 top-0 w-px bg-white/15"
style={{ height: isLastLane ? "50%" : "100%" }}
/>
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
</span>
{/* Two lines: what the effect is, then which knob the envelope drives. On
one line a band's own name was the first thing truncated in a column this
narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
bands apart. */}
{/* A button, because the name IS the way to the knob: clicking it opens
the rack on the effect this envelope drives and scrolls to it. Nothing
else in the timeline can get there — a lane names a parameter, and the
rack is where a parameter is set. */}
<button
type="button"
tabIndex={-1}
aria-label={onReveal ? `Show ${label} in the effect rack` : undefined}
title={onReveal ? `Show ${label} in the effect rack` : label}
disabled={!onReveal}
className="flex min-w-0 flex-1 flex-col justify-center rounded border-0 bg-transparent p-0 text-left leading-tight enabled:hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
// The label column owns its click; it does not also fall through to
// the track row behind it and move the selection.
event.stopPropagation();
onReveal?.();
}}
>
<span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
{name}
</span>
{param ? (
<span
data-automation-lane-param=""
className="truncate font-mono text-[9px] text-white/40"
>
{param}
</span>
) : null}
{alsoAutomatedBy ? (
<span
data-automation-lane-also=""
className="truncate text-[9px] text-[#F5C542]/80"
title={`${alsoAutomatedBy} is also fading this — the two multiply.`}
>
{alsoAutomatedBy} is also fading this.
</span>
) : null}
</button>
{/* Beside the name it labels, because that is the only place an envelope is
named at all: a carve writes its own lanes, and the FX panel's automate
toggle can only reach a parameter it still lists — so without this an
envelope could be created and never removed.
Only for the clip the header is showing, since that is the only one a
write can reach: a shared row's other envelopes belong to clips that
are not selected, and a button that silently removed one of them (or
none) would be worse than no button. */}
{/* Not on a carve's own lane: the carve regenerates it on the next
analysis, so removing one band would come back and reads as the
button not working. Switching the carve off in the rack is what
removes them, all together, which is how they were made. */}
{onRemove && target !== null && !isCarve && (
<button
type="button"
aria-label={`Remove ${label} automation`}
title={`Remove ${label} automation`}
// h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-white/35 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
// A control in the label column owns its click; it does not also hit
// the track row behind it and change the selection.
event.stopPropagation();
onRemove(target);
}}
>
×
</button>
)}
</div>
);
}
@@ -0,0 +1,41 @@
/**
* Opening and closing a track's keyframe property lanes, with the telemetry that
* goes with it.
*
* Split out of `TimelineLanes.tsx` to keep that file under the studio's 600-line
* cap. Both callbacks were already the only place the disclosure state and its
* `keyframe_lane_expand` event were written together, which is what makes them a
* seam rather than a shuffle.
*/
import { usePlayerStore } from "../store/playerStore";
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
export interface TimelineClipDisclosure {
/** The caret belongs to the ROW, so it opens and closes every clip on it at
* once. Toggling only the active clip left the row's state depending on which
* sibling happened to be selected: expand one, click another, and the row
* collapsed under a caret that still pointed down. */
toggleRowExpanded: (keys: readonly string[]) => void;
toggleClipExpanded: (key: string) => void;
}
export function useTimelineClipDisclosure(): TimelineClipDisclosure {
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const expandClips = usePlayerStore((s) => s.expandClips);
const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
return {
toggleRowExpanded: (keys) => {
const willExpand = !keys.some((key) => expandedClipIds.has(key));
trackStudioKeyframeLaneExpand({ expanded: willExpand });
if (willExpand) expandClips(keys);
else for (const key of keys) setClipExpanded(key, false);
},
toggleClipExpanded: (key) => {
trackStudioKeyframeLaneExpand({ expanded: !expandedClipIds.has(key) });
toggleClipExpanded(key);
},
};
}
@@ -6,7 +6,7 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
/** The four pieces of group-disclosure state a group row's header reads and writes. */
export function useTimelineGroupDisclosure() {
return {
expandedGroupIds: usePlayerStore((s) => s.expandedGroupIds),
collapsedGroupIds: usePlayerStore((s) => s.collapsedGroupIds),
expandedLaneOwnerIds: usePlayerStore((s) => s.expandedLaneOwnerIds),
toggleGroupExpanded: usePlayerStore((s) => s.toggleGroupExpanded),
toggleLaneOwnerExpanded: usePlayerStore((s) => s.toggleLaneOwnerExpanded),
@@ -35,7 +35,7 @@ interface TimelineLogicalFocusInput {
export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
const expandedGroupIds = usePlayerStore((state) => state.expandedGroupIds);
const collapsedGroupIds = usePlayerStore((state) => state.collapsedGroupIds);
const expandedLaneOwnerIds = usePlayerStore((state) => state.expandedLaneOwnerIds);
const projectId = usePlayerStore((state) => state.timelineProjectId);
const logicalRows = useTimelineLogicalRows({
@@ -45,7 +45,7 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
selectedElementId: input.selectedElementId,
selectedElementIds: input.selectedElementIds,
expandedClipIds,
expandedGroupIds,
collapsedGroupIds,
expandedLaneOwnerIds,
groups: input.groups,
trackGroupOf: input.trackGroupOf,
@@ -22,7 +22,7 @@ const displayTrackOrder = tracks.map(([track]) => track);
const laneCounts = new Map<string, number>();
const selectedElementIds = new Set<string>();
const expandedClipIds = new Set<string>();
const expandedGroupIds = new Set<string>();
const collapsedGroupIds = new Set<string>();
const expandedLaneOwnerIds = new Set<string>();
const groups: never[] = [];
const trackGroupOf = new Map();
@@ -37,7 +37,7 @@ function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]
selectedElementId: null,
selectedElementIds,
expandedClipIds,
expandedGroupIds,
collapsedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
@@ -14,7 +14,7 @@ export function useTimelineLogicalRows({
selectedElementId,
selectedElementIds,
expandedClipIds,
expandedGroupIds,
collapsedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
@@ -29,7 +29,7 @@ export function useTimelineLogicalRows({
selectedElementId,
selectedElementIds,
expandedClipIds,
expandedGroupIds,
collapsedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
@@ -38,7 +38,7 @@ export function useTimelineLogicalRows({
[
displayTrackOrder,
expandedClipIds,
expandedGroupIds,
collapsedGroupIds,
expandedLaneOwnerIds,
groups,
trackGroupOf,
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
/** One resolved audio group, positioned in the row order. */
@@ -15,12 +15,26 @@ export interface TimelineTrackGroupInfo {
anchorKey: number;
/** Member track numbers, ascending. */
memberTracks: number[];
/**
* Every clip under this group, in member-track order — INDEPENDENT of whether
* the group is expanded.
*
* Collapsing a group stops emitting its member rows into `tracks`, so anything
* that recovered member elements by looking them up there got an empty list in
* the default (collapsed) state — silently disabling the
* automation-lane count, and the bus strip's member labels. Membership is not
* a display concern, so it does not travel through the display list.
*/
memberElements: TimelineElement[];
/** The group element's `data-volume`, mirrored from a member's parse (B7's slider). */
volume: number;
/** The group element's `data-hidden`, mirrored from a member's parse (B5's group mute). */
hidden: boolean;
/** The group element's serialized `data-fx-chain`, mirrored from a member's parse (C1's FX entry). */
fxChain?: string;
/** The group element's serialized `data-automation` — the group's OWN lanes,
* which are what its `∿` discloses (groups doc §5). */
automation?: string;
}
interface GroupMembership {
@@ -30,6 +44,7 @@ interface GroupMembership {
volumeByGroup: Map<string, number>;
hiddenByGroup: Map<string, boolean>;
fxChainByGroup: Map<string, string | undefined>;
automationByGroup: Map<string, string | undefined>;
}
/** Which track belongs to which group, and each group's label/volume/hidden/fxChain — one pass over raw tracks. */
@@ -40,6 +55,7 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
const volumeByGroup = new Map<string, number>();
const hiddenByGroup = new Map<string, boolean>();
const fxChainByGroup = new Map<string, string | undefined>();
const automationByGroup = new Map<string, string | undefined>();
for (const [trackNum, elements] of rawTracks) {
const owner = elements.find((el) => el.audioGroup);
if (!owner?.audioGroup) continue;
@@ -49,6 +65,7 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
hiddenByGroup.set(owner.audioGroup, owner.audioGroupHidden ?? false);
fxChainByGroup.set(owner.audioGroup, owner.audioGroupFxChain);
automationByGroup.set(owner.audioGroup, owner.audioGroupAutomation);
}
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
members.push(trackNum);
@@ -61,6 +78,7 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
volumeByGroup,
hiddenByGroup,
fxChainByGroup,
automationByGroup,
};
}
@@ -69,33 +87,50 @@ function buildGroupInfo(
groupId: string,
fallbackTrackNum: number,
membership: GroupMembership,
rawByTrack: ReadonlyMap<number, TimelineElement[]>,
): TimelineTrackGroupInfo {
const memberTracks = [...(membership.memberTracksByGroup.get(groupId) ?? [])].sort(
(a, b) => a - b,
);
const fxChain = membership.fxChainByGroup.get(groupId);
const automation = membership.automationByGroup.get(groupId);
return {
id: groupId,
label: membership.labelByGroup.get(groupId) ?? groupId,
// Exactly x.5, which sub-composition child rows are now kept strictly below
// (`useExpandedTimelineElements`) — they used to be able to land here and
// collide, duplicating the group header.
anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
memberTracks,
memberElements: memberTracks.flatMap((track) => rawByTrack.get(track) ?? []),
volume: membership.volumeByGroup.get(groupId) ?? 1,
hidden: membership.hiddenByGroup.get(groupId) ?? false,
...(fxChain ? { fxChain } : {}),
...(automation ? { automation } : {}),
};
}
/** Push a group's synthetic anchor row plus its members' rows, contiguously. */
/**
* Push a group's synthetic anchor row plus its members' rows, contiguously.
*
* A collapsed group emits its anchor and nothing else. `buildTimelineLogicalRows`
* already stops at the anchor when collapsed, so leaving the member rows in
* `tracks` left row geometry reserving a full row each for rows that then
* rendered as `null` — a header trailed by its members' worth of blank,
* unreachable dead space. Membership still lands in `trackGroupOf`: a collapsed
* member is hidden, not ungrouped.
*/
function emitGroupRows(
info: TimelineTrackGroupInfo,
rawByTrack: ReadonlyMap<number, TimelineElement[]>,
trackGroupOf: Map<number, TimelineTrackGroupInfo>,
tracks: [number, TimelineElement[]][],
expanded: boolean,
): void {
tracks.push([info.anchorKey, []]);
for (const member of info.memberTracks) {
trackGroupOf.set(member, info);
tracks.push([member, rawByTrack.get(member) ?? []]);
if (expanded) tracks.push([member, rawByTrack.get(member) ?? []]);
}
}
@@ -105,7 +140,10 @@ function emitGroupRows(
* their position; a group's members move up to sit under its anchor even when
* other (ungrouped) tracks were interleaved between them.
*/
function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
function groupTimelineTracks(
rawTracks: [number, TimelineElement[]][],
collapsedGroupIds: ReadonlySet<string>,
): {
tracks: [number, TimelineElement[]][];
groups: TimelineTrackGroupInfo[];
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
@@ -125,9 +163,9 @@ function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
}
if (emitted.has(groupId)) continue;
emitted.add(groupId);
const info = buildGroupInfo(groupId, trackNum, membership);
const info = buildGroupInfo(groupId, trackNum, membership, rawByTrack);
groups.push(info);
emitGroupRows(info, rawByTrack, trackGroupOf, tracks);
emitGroupRows(info, rawByTrack, trackGroupOf, tracks, !collapsedGroupIds.has(groupId));
}
return { tracks, groups, trackGroupOf };
}
@@ -156,9 +194,10 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
return Array.from(map.entries()).sort(([a], [b]) => a - b);
}, [expandedElements]);
const collapsedGroupIds = usePlayerStore((s) => s.collapsedGroupIds);
const { tracks, groups, trackGroupOf } = useMemo(
() => groupTimelineTracks(rawTracks),
[rawTracks],
() => groupTimelineTracks(rawTracks, collapsedGroupIds),
[rawTracks, collapsedGroupIds],
);
const trackStyles = useMemo(() => {
@@ -38,6 +38,125 @@ function renderTrackLayout(
return { layout, unmount: () => act(() => root.unmount()) };
}
describe("collapsed audio groups", () => {
const member = (id: string, track: number): TimelineElement => ({
id,
domId: id,
tag: "audio",
start: 0,
duration: 5,
track,
audioGroup: "voiceover",
});
/** `collapsed` seeds the collapsed set — expanded is the default state. */
function renderGrouped(collapsed = false): {
layout: ReturnType<typeof useTimelineTrackLayout>;
unmount: () => void;
} {
if (collapsed) usePlayerStore.setState({ collapsedGroupIds: new Set(["voiceover"]) });
const elements = [member("voice-1", 0), member("voice-2", 1)];
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
function Probe() {
layout = useTimelineTrackLayout(elements, new Map(), null, new Set());
return null;
}
const root = createRoot(document.createElement("div"));
act(() => root.render(React.createElement(Probe)));
if (!layout) throw new Error("Timeline track layout did not render");
return { layout, unmount: () => act(() => root.unmount()) };
}
// The `∿` area holds the group's own automation rows. Sized without them,
// every lane the count had just promised was clipped out of the row — which
// is what "expanding automation on a group doesn't show the automation"
// looked like from outside.
it("reserves room for the group's own automation rows, not just the strip", () => {
const automation = JSON.stringify({
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 5, v: 0.4 },
],
},
],
});
const elements = [
{ ...member("voice-1", 0), audioGroupAutomation: automation },
{ ...member("voice-2", 1), audioGroupAutomation: automation },
];
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
function Probe() {
layout = useTimelineTrackLayout(elements, new Map(), null, new Set());
return null;
}
const root = createRoot(document.createElement("div"));
act(() => {
usePlayerStore.setState({ expandedLaneOwnerIds: new Set(["voiceover"]) });
root.render(React.createElement(Probe));
});
// The group's anchor row: the first member track minus 0.5.
const anchorIndex = layout!.tracks.findIndex(([track]) => track === -0.5);
expect(anchorIndex).toBeGreaterThanOrEqual(0);
const openHeight = layout!.rowHeights[anchorIndex];
// One lane of headroom beyond the header row itself.
expect(openHeight).toBe(TRACK_H + AUTOMATION_LANE_H);
act(() => root.unmount());
});
// buildTimelineLogicalRows stops emitting member rows once a group is
// collapsed, so TimelineLanes renders null for them. Rows left in `tracks`
// still reserve height, turning that null into visible dead space — the row
// list and the logical rows have to agree.
it("emits only the anchor row while the group is collapsed", () => {
const { layout, unmount } = renderGrouped(true);
expect(layout.groups).toHaveLength(1);
expect(layout.groups[0]!.memberTracks).toEqual([0, 1]);
// The anchor (0 - 0.5) and nothing else.
expect(layout.trackOrder).toEqual([-0.5]);
expect(layout.rowGeometry.rowHeights).toHaveLength(1);
// Membership still resolves — collapsed is hidden, not ungrouped.
expect(layout.trackGroupOf.get(0)?.id).toBe("voiceover");
unmount();
});
// Membership is not a display concern. Half-lit solo, the automation-lane
// count and the bus strip's member labels all read the group's members, and
// all three silently degraded to empty when those were recovered from the
// display list — which a collapsed group does not appear in. Collapsed is the
// default, so that was every group until someone opened it.
it("carries its member elements even while collapsed", () => {
const { layout, unmount } = renderGrouped(true);
expect(layout.trackOrder).toEqual([-0.5]); // collapsed: no member rows
expect(layout.groups[0]!.memberElements.map((el) => el.id)).toEqual(["voice-1", "voice-2"]);
unmount();
});
// The reason the set is stored inverted. As an expanded-set, "absent" could
// not tell never-touched from deliberately-collapsed, so a freshly created
// group started collapsed — grouping three tracks made all three vanish
// behind a header the user had not yet learned to open.
it("is expanded by default, with nothing seeded", () => {
const { layout, unmount } = renderGrouped();
expect(usePlayerStore.getState().collapsedGroupIds.size).toBe(0);
expect(layout.trackOrder).toEqual([-0.5, 0, 1]);
unmount();
});
it("emits the member rows once the group is expanded", () => {
// Expanded is the default now — nothing to seed.
const { layout, unmount } = renderGrouped();
expect(layout.trackOrder).toEqual([-0.5, 0, 1]);
for (const track of layout.groups[0]!.memberTracks) {
expect(layout.rowGeometry.getRowHeight(layout.rowGeometry.getRowIndex(track))).toBe(TRACK_H);
}
unmount();
});
});
describe("useTimelineTrackLayout", () => {
it("counts a flat tween lane and reserves its expanded row height", () => {
const elements: TimelineElement[] = [
@@ -7,7 +7,6 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { DraggedClipState } from "./timelineClipDragTypes";
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
import {
STRIP_H,
TRACK_H,
createTimelineRowGeometry,
type TimelineRowGeometry,
@@ -15,6 +14,13 @@ import {
type TimelineTrackHeightClip,
} from "./timelineLayout";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import { groupAutomationElement } from "./groupAutomationElement";
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
/** Automation rows the GROUP itself owns — its `data-automation`, not its members'. */
function groupOwnLaneCount(group: TimelineTrackGroupInfo): number {
return groupAutomationLanes([groupAutomationElement(group, 0)]).length;
}
export { getTrackStyle } from "./timelineIcons";
@@ -132,8 +138,8 @@ function computeLaneCounts(
/** Group anchor rows have no elements of their own (`groupTimelineTracks`
* pushes them as `[anchorKey, []]`), so `trackHeights` — which only ever
* looks at a row's clips — always gives them TRACK_H. Override those
* specific rows post-hoc: TRACK_H while collapsed, +STRIP_H once the
* group's own `∿` (bus strip) is open. */
* specific rows post-hoc: TRACK_H while collapsed, plus the group's own
* automation rows once its `∿` is open. */
function applyGroupStripHeights(
tracks: readonly (readonly [number, readonly TimelineElement[]])[],
rowHeights: number[],
@@ -145,7 +151,9 @@ function applyGroupStripHeights(
return tracks.map(([track], index) => {
const group = groupByAnchor.get(track);
if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? TRACK_H;
return TRACK_H + STRIP_H;
// The group's own automation rows, which its `∿` discloses. A row sized
// without them clipped every lane it had just promised in the count.
return TRACK_H + groupOwnLaneCount(group) * AUTOMATION_LANE_H;
});
}
@@ -84,8 +84,16 @@ export interface KeyframeSlice {
/** Union-expand clips (keyframed clips are expanded by default on load). */
expandClips: (ids: readonly string[]) => void;
/** Groups whose member rows the caret has shown (structural, not lanes). */
expandedGroupIds: Set<string>;
/**
* Groups whose member rows the caret has HIDDEN (structural, not lanes).
*
* Inverted deliberately. As an expanded-set, "not in the set" could not tell
* never-touched from deliberately-collapsed, so every group defaulted to
* collapsed — and since nothing seeds the set on create, grouping three
* tracks made all three vanish behind a header the user had not yet learned
* to open. Groups are expanded until someone closes one.
*/
collapsedGroupIds: Set<string>;
toggleGroupExpanded: (id: string) => void;
/** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
@@ -161,13 +169,13 @@ export function createKeyframeSlice(
return { expandedClipIds: next };
}),
expandedGroupIds: new Set(),
collapsedGroupIds: new Set(),
toggleGroupExpanded: (id) =>
set((state) => {
const next = new Set(state.expandedGroupIds);
const next = new Set(state.collapsedGroupIds);
if (next.has(id)) next.delete(id);
else next.add(id);
return { expandedGroupIds: next };
return { collapsedGroupIds: next };
}),
expandedLaneOwnerIds: new Set(),
@@ -271,7 +271,8 @@ export function createTimelineResetState() {
// paste through `sel.elementKey === paste.elementKey` to a stale t0.
automationSelection: null,
expandedClipIds: new Set<string>(),
expandedGroupIds: new Set<string>(),
// Per-composition: ids from comp A match nothing in B, silencing all of it.
collapsedGroupIds: new Set<string>(),
expandedLaneOwnerIds: new Set<string>(),
focusedEaseSegment: null,
revealedAudioFxTarget: null,