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 ( {children} ); } 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 (
{/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
); } /** * 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 (
{/* Tree connector, as the keyframe rows draw it: spine down the row, branch tick at the name's own height. */}
); } // 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 (
{!keyframeClip || !disclosable ? ( <> el.audioGroupHidden)} isSoloed={soloTargetId !== null && soloed.has(soloTargetId)} onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined} onToggleTrackHidden={onToggleTrackHidden} /> {singleAudioClip && isCanaryEnabled("audio-fx-rack") && ( 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") && ( )} ) : ( <> 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. */} {/* 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) => ( ))} {/* 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) => ( ))} )}
); }