From acfa7c55a2f3dcdb070e304bfb8e7b819d2ef3cf Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Fri, 21 Aug 2026 09:34:59 -0700 Subject: [PATCH] feat(studio): group rows in the timeline, and a split disclosure (#3286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core,studio): the character presets pitch shift unlocks Chipmunk, Giant, and Monster ship as presets on the pitchshift worklet P1 added: Chipmunk pitches up and adds sparkle, Giant pitches down with weight and a compressor to hold the extra low end together, Monster pitches down further with saturation growl and a close, tight reverb. Every param verified against the live effect registry rather than sketched — the compressor/reverb/saturate/shelf keys all match exactly. Each gets its own title treatment (font, size, tracking, hue) so the FX rack's per-preset styling coverage and hue-distance/background-uniqueness tests extend cleanly to the three new entries, and complaint-line copy in the non-voice vocabulary the audit test enforces (no speech words — "Giant" over CapCut's "Deep Voice", as the design doc records). Updates plans/audio-fx-presets.md's two limits paragraphs to record that pitch shift landed and this half of the character list now ships; Robot and Alien stay out of scope (ring modulation, still unbuilt). Co-Authored-By: Claude Sonnet 5 * feat(core): the audio group model — element, membership, helpers Introduces and data-audio-group as the group model B2–B7 and C1 build on: a non-rendering group element carries a label and (later) an FX chain, membership lives on the member's own data-audio-group attribute rather than DOM nesting, so a track removed from the document simply drops out of the group on the next resolve — nothing dangles. Groups do not nest: data-audio-group on the group element itself is ignored. A group with members but no element still resolves, label falling back to the id, so hand-authored HTML degrades gracefully. Audio only in v1 — video members are ignored. Parse-only: nothing routes or sums audio yet (B3/B4). Adds the audio-groups canary at percentage: 0 gating the future Studio UI; the element and attribute parse and play regardless of enrollment. Verified rather than assumed per this plan's standing rule: the timeline's clip-collection selector ([data-start], [data-track-index], [data-composition-id], video, audio, img) already excludes the group element with zero changes, and no lint rule flags unknown elements or data-* attributes, so neither needed touching — confirmed by grep and by running `hyperframes lint` against a fixture containing the element (0 findings referencing it). The step doc's suggested display:none injection point (an existing base stylesheet in the runtime) does not exist in this codebase; skipped rather than inventing new infrastructure, since an empty, childless custom element already renders as a zero-size inline box with no visible output — the same reasoning the lint check above confirms empirically. Co-Authored-By: Claude Sonnet 5 * feat(studio): group rows in the timeline, and a split disclosure A group renders as its own row with member rows beneath it, and disclosure splits into two independent controls: caret shows/hides a group's member rows (structural), `∿` shows/hides any row's automation-lane rows. Plain tracks lose their caret (nothing to disclose structurally) and keep only `∿`. `expandedClipIds` keeps its existing keyframe-lane-state job; `expandedGroupIds`/`expandedLaneOwnerIds` are new, independent sets. Groups get a real position in the row/geometry pipeline rather than a visual-only overlay: `useTimelineTrackDerivations` re-emits a group's member tracks contiguously under a synthetic fractional anchor key (firstMember - 0.5, the same fractional-key convention sub-composition expansion already uses), so `rowGeometry`/keyboard-nav/virtualization treat a group row as a first-class row without widening their key type away from number. `TimelineLogicalRow.level` widens `1 | 2` to `1 | 2 | 3` (group / member-under-group / lane), lanes always `owner.level + 1`. All of it — grouped row emission, the header, the new expansion state — is gated behind `isCanaryEnabled("audio-groups")`; disabled, `groups` resolves empty and every new code path no-ops. `TimelineElement.audioGroup` (+ `audioGroupLabel`, resolved once per document via `resolveAudioGroups` from B1) is parsed unconditionally, mirroring how `hidden`/`fxChain` already flow DOM → manifest → TimelineElement — inert without the canary. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- .../studio/src/hooks/useMusicBeatAnalysis.ts | 13 ++ .../player/components/LayerDisclosureRow.tsx | 25 ++- .../src/player/components/Timeline.test.ts | 14 +- .../studio/src/player/components/Timeline.tsx | 17 +- .../player/components/TimelineGroupHeader.tsx | 101 ++++++++++ .../player/components/TimelineGroupRow.tsx | 77 ++++++++ .../player/components/TimelineLanes.test.tsx | 5 + .../src/player/components/TimelineLanes.tsx | 72 +++---- .../timelineKeyboardNavigation.test.ts | 8 + .../components/timelineKeyboardNavigation.ts | 177 +++++++++++++++--- .../player/components/timelineLaneProps.ts | 3 + .../components/timelineNavigationIdentity.ts | 4 + .../components/useTimelineLaneRowIndexes.ts | 38 ++++ .../components/useTimelineLogicalFocus.ts | 9 + .../useTimelineLogicalRows.test.tsx | 8 + .../components/useTimelineLogicalRows.ts | 29 +-- .../useTimelineMultiDragActorWindows.ts | 30 +++ .../components/useTimelineTrackDerivations.ts | 109 ++++++++++- .../components/useTimelineTrackLayout.ts | 23 ++- packages/studio/src/player/lib/timelineDOM.ts | 26 +++ .../studio/src/player/store/keyframeSlice.ts | 26 +++ .../studio/src/player/store/playerStore.ts | 2 + .../src/player/store/timelineElement.ts | 4 + 23 files changed, 707 insertions(+), 113 deletions(-) create mode 100644 packages/studio/src/player/components/TimelineGroupHeader.tsx create mode 100644 packages/studio/src/player/components/TimelineGroupRow.tsx create mode 100644 packages/studio/src/player/components/useTimelineLaneRowIndexes.ts create mode 100644 packages/studio/src/player/components/useTimelineMultiDragActorWindows.ts diff --git a/packages/studio/src/hooks/useMusicBeatAnalysis.ts b/packages/studio/src/hooks/useMusicBeatAnalysis.ts index 078def68f..8d0f2dc58 100644 --- a/packages/studio/src/hooks/useMusicBeatAnalysis.ts +++ b/packages/studio/src/hooks/useMusicBeatAnalysis.ts @@ -4,6 +4,8 @@ import { resolveBeatSourceTrack } from "../utils/timelineInspector"; import { analyzeMusicFromUrl } from "@hyperframes/core/beats"; import { useFileManagerContextOptional } from "../contexts/FileManagerContext"; import { mergeUserBeats } from "../utils/beatEditing"; +import { getTimelineElementIndexes } from "../player/lib/timelineElementIndexes"; +import { remapBeatAnalysisToComposition } from "../utils/beatEditActions"; import { audioRelPathForSrc, beatFilePathForSrc, @@ -90,6 +92,17 @@ async function loadBeatAnalysis( } } +/** The current beat analysis, remapped onto the composition's edited beat grid. */ +export function useAdjustedBeatAnalysis() { + const beatAnalysis = usePlayerStore((s) => s.beatAnalysis); + const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement); + const beatEdits = usePlayerStore((s) => s.beatEdits); + return useMemo( + () => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits), + [beatAnalysis, musicElement, beatEdits], + ); +} + export function useMusicBeatAnalysis(): void { const elements = usePlayerStore((s) => s.elements); const setBeatAnalysis = usePlayerStore((s) => s.setBeatAnalysis); diff --git a/packages/studio/src/player/components/LayerDisclosureRow.tsx b/packages/studio/src/player/components/LayerDisclosureRow.tsx index 4dd05436a..1dc27408d 100644 --- a/packages/studio/src/player/components/LayerDisclosureRow.tsx +++ b/packages/studio/src/player/components/LayerDisclosureRow.tsx @@ -1,9 +1,11 @@ -import { CaretRight } from "@phosphor-icons/react"; import { TRACK_H } from "./timelineLayout"; import { TrackClipCount } from "./TrackClipCount"; -// Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives -// here, not on the clip bar, and re-expands a collapsed layer. +// Layer row (Figma order: disclosure ∿, diamond, name) — 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. export function LayerDisclosureRow({ name, clipCount, @@ -49,23 +51,20 @@ export function LayerDisclosureRow({ tabIndex={-1} aria-expanded={isExpanded} aria-controls={lanesId} - aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`} - title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`} - // h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The caret glyph stays 11px; + 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-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]" + 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(); }} > -