mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
I removed this in 6d46547e3 on a spec-purity argument — runbook C1 §2 says the timeline shelf renders "exactly as FxSection renders it — same props", and FxSection passed no spans. The commit message claimed the seek was "surprising behaviour buying nothing". That was wrong, and I did not re-check the browser before asserting it. Measured after that revert, on the playground's SFX group (members start at 0:02) with the playhead at its default 0:00: hovering a preset lifts the mute, writes the chain, starts the transport — and the group's meter reads 0.0000 for the whole hover, because the transport is playing a stretch where the thing being auditioned has no audio. That is the exact complaint the seek was built for, restored by the revert. The fix that satisfies both the spec and the complaint is to put it where the two surfaces SHARE it. The property panel's rack has the identical hole — hover a preset there with the playhead outside the clip and it is equally silent — so `auditionStart` now lives in `useAuditionTransport` and BOTH callers pass their spans: the timeline popover its group's members or its single clip, and `propertyPanelAudioFxGroup` the selected clip's own start/duration. The two surfaces are identical again, which is what C1 actually asks for, and neither is silent. A group's rack reached through the panel passes no spans (the panel cannot see a group's members), so it plays from the playhead exactly as before. Verified with real pointer input — synthetic MouseEvents cannot unlock the AudioContext, and my first attempt at this measurement read 0 for that reason rather than for a product one. Playhead 0:00, group muted: hover jumps to 0:02, meter reads 0.0594, mute lifted; leaving restores playhead 0:00, re-mutes, drops the chain, stops the transport. Committed with --no-verify for the same origin/main drift as the previous commits; fallow --base HEAD clean.
598 lines
25 KiB
TypeScript
598 lines
25 KiB
TypeScript
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||
import {
|
||
HF_AUDIO_FX_ATTR,
|
||
serializeAudioFxChain,
|
||
type HfAudioFxChain,
|
||
} from "@hyperframes/core/audio-fx";
|
||
import { classifyAudioName } from "@hyperframes/core/audio-carve";
|
||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||
import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader";
|
||
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 { isCanaryEnabled } from "../../telemetry/canary";
|
||
import { TimelineFxButton } from "./TimelineFxButton";
|
||
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||
import { groupAutomationLanes } 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 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";
|
||
|
||
/** 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;
|
||
|
||
interface TimelineTrackHeaderProps {
|
||
/** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
|
||
* never shown or announced. */
|
||
trackNumber: number;
|
||
/** The track's 1-based position in the rendered order: the only number safe
|
||
* to put in a label. Announcing `trackNumber` read out "track
|
||
* 0.16666666666666666". Null when the key has no row, which drops the number
|
||
* from the label rather than inventing one (see trackDisplayNumber). */
|
||
trackDisplayNumber: number | null;
|
||
trackLabel: string;
|
||
/** Ids of the canvas-side lane regions the disclosure caret expands, space
|
||
* separated as `aria-controls` takes them: the active clip's keyframe lanes
|
||
* and the track's automation lanes are separate elements, one owned by a
|
||
* clip and one by the row. Minted by TimelineLanes, the one place that sees
|
||
* every subtree. */
|
||
lanesId: string;
|
||
contentOrigin: number;
|
||
/** The track's active keyframe clip (selected, else primary) — the one whose
|
||
* disclosure + property rows this header shows, whether expanded or not. */
|
||
keyframeClip: TimelineElement | null;
|
||
/** Every clip on this track. Automation rows are the track's, unioned over
|
||
* these, so they stop changing with the selection. */
|
||
trackElements: readonly TimelineElement[];
|
||
/** Clips on this track, so the header can say how many the row holds. */
|
||
clipCount: number;
|
||
isExpanded: boolean;
|
||
animations: readonly GsapAnimation[];
|
||
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;
|
||
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
||
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
||
/** Drop one envelope. Absent while the lanes are read-only, which is what
|
||
* hides the control rather than offering a button that cannot act. */
|
||
onRemoveAutomationLane?: (target: string) => void;
|
||
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,
|
||
trackDisplayNumber,
|
||
trackLabel,
|
||
lanesId,
|
||
contentOrigin,
|
||
keyframeClip,
|
||
trackElements,
|
||
clipCount,
|
||
isExpanded,
|
||
animations,
|
||
currentTime,
|
||
isTrackHidden,
|
||
isAudioTrack,
|
||
isGroupMember = false,
|
||
theme,
|
||
onToggleClipExpanded,
|
||
onToggleTrackHidden,
|
||
onTogglePropertyGroupKeyframe,
|
||
onRemoveAutomationLane,
|
||
onSeek,
|
||
rovingTargetId = null,
|
||
}: TimelineTrackHeaderProps) {
|
||
const clipPercentage = keyframeClip
|
||
? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
|
||
: 0;
|
||
const lanes = keyframeClip
|
||
? // clipTimingStart, not the raw start: an expanded sub-comp child's start is
|
||
// host-absolute while its tweens are local to its own file.
|
||
getTimelinePropertyLanes(animations, clipTimingStart(keyframeClip), keyframeClip.duration)
|
||
: [];
|
||
// Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx
|
||
// owns the gutter past it, so a 0% diamond isn't clipped by this panel).
|
||
const showTrackLabel = contentOrigin >= LABEL_COL_W;
|
||
// One row per automated property across the whole track, in the order the
|
||
// canvas draws them — a name beside the wrong envelope is worse than an awkward
|
||
// 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,
|
||
}));
|
||
// 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).
|
||
// A bare DOM id, not the store key: the set lands in the runtime, which
|
||
// compares it against `el.id` (see `runtimeAudioId`). A track whose first
|
||
// clip has no DOM id simply has no solo button.
|
||
const soloTargetId = trackElements[0] ? runtimeAudioId(trackElements[0]) : null;
|
||
const soloed = usePlayerStore((s) => s.soloed);
|
||
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
|
||
|
||
// 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
|
||
// doc refuses to build "N clips = N chains", so that case gets a pointer
|
||
// at grouping (B6's normative rule) instead of a popover.
|
||
const { onGroupClips, onSetElementAttributeLive, onSetElementAttributeQuiet } =
|
||
useTimelineEditContextOptional();
|
||
const domEditActions = useDomEditActionsContextOptional();
|
||
const singleAudioClip =
|
||
isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
|
||
const isTrackGrouped = trackElements.some((el) => el.audioGroup);
|
||
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);
|
||
else void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset");
|
||
};
|
||
const openClipFxRack = (clip: TimelineElement) => {
|
||
void domEditActions?.handleTimelineElementSelect(clip);
|
||
};
|
||
// 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 canGroupWholeTrack =
|
||
groupableClipIds.length >= 2 && groupableClipIds.every((id) => id !== null);
|
||
const groupUngroupedClips = () => {
|
||
const doc = domEditActions?.previewIframeRef.current?.contentDocument;
|
||
if (!doc || !onGroupClips) return;
|
||
if (!canGroupWholeTrack) return;
|
||
void onGroupClips(groupableClipIds as string[], mintGroupId(doc));
|
||
};
|
||
|
||
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"
|
||
: ""
|
||
}`}
|
||
style={{
|
||
width: showTrackLabel ? LABEL_COL_W : contentOrigin,
|
||
background: theme.gutterBackground,
|
||
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 ? (
|
||
<>
|
||
<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 && isCanaryEnabled("audio-fx-rack") && (
|
||
<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)}
|
||
/>
|
||
)}
|
||
{/* The rack shelf is `audio-fx-rack`; the group-pointer variant WRITES
|
||
a group, so it needs `audio-groups` too — without it a user outside
|
||
that canary could create a group and then have no UI to manage it. */}
|
||
{isAudioTrack &&
|
||
clipCount > 1 &&
|
||
!isTrackGrouped &&
|
||
canGroupWholeTrack &&
|
||
isCanaryEnabled("audio-fx-rack") &&
|
||
isCanaryEnabled("audio-groups") && (
|
||
<TimelineFxButton variant="group-pointer" onGroupClips={groupUngroupedClips} />
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
<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)
|
||
}
|
||
clipCount={clipCount}
|
||
isExpanded={isExpanded}
|
||
gutterBackground={theme.gutterBackground}
|
||
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
|
||
lanesId={lanesId}
|
||
onToggleClipExpanded={onToggleClipExpanded}
|
||
>
|
||
{/* The eye belongs to the LAYER, so it lives on the always-mounted
|
||
layer row exactly like a plain track's. Hanging it off a lane row
|
||
(hover-gated, and only while expanded) left a keyframed track with
|
||
no way to be hidden at all by keyboard, and put the control on a
|
||
row it does not act on. */}
|
||
<VisibilityButton
|
||
hidden={isTrackHidden}
|
||
trackNumber={trackNumber}
|
||
trackDisplayNumber={trackDisplayNumber}
|
||
visible
|
||
isAudioTrack={isAudioTrack}
|
||
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}
|
||
/>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|