mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 13:27:13 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user