Files
hyperframes/packages/studio/src/player/components/useTimelineLogicalFocus.ts
T
Vance IngallsandClaude Sonnet 5 acfa7c55a2 feat(studio): group rows in the timeline, and a split disclosure (#3286)
* 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 <noreply@anthropic.com>

* feat(core): the audio group model — element, membership, helpers

Introduces <hf-audio-group> 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 <hf-audio-group> 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 09:34:59 -07:00

90 lines
3.6 KiB
TypeScript

import type { RefObject } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { TimelineElement } from "../store/playerStore";
import type { TimelineRowGeometry } from "./timelineLayout";
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
import { usePlayerStore } from "../store/playerStore";
import { useTimelineLogicalRows } from "./useTimelineLogicalRows";
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
interface TimelineLogicalFocusInput {
scrollRef: RefObject<HTMLDivElement | null>;
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
layout: { displayTrackOrder: readonly number[]; rowGeometry: TimelineRowGeometry };
laneCounts: ReadonlyMap<string, number>;
selectedElementId: string | null;
selectedElementIds: ReadonlySet<string>;
groups: readonly TimelineTrackGroupInfo[];
trackGroupOf: ReadonlyMap<number, TimelineTrackGroupInfo>;
gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
elements: readonly TimelineElement[];
pixelsPerSecond: number;
contentOrigin: number;
allowHorizontal: boolean;
viewport: TimelineScrollViewportSnapshot;
sessionEpoch: number;
draggedRowKey?: number;
resizingElementIds?: readonly string[];
clipContextMenuRowKey?: number;
keyframeContextMenuRowKey?: number;
lastScrollLeftRef: RefObject<number>;
syncScrollViewport: (element: HTMLDivElement) => void;
}
export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
const expandedGroupIds = usePlayerStore((state) => state.expandedGroupIds);
const expandedLaneOwnerIds = usePlayerStore((state) => state.expandedLaneOwnerIds);
const projectId = usePlayerStore((state) => state.timelineProjectId);
const logicalRows = useTimelineLogicalRows({
tracks: input.tracks,
displayTrackOrder: input.layout.displayTrackOrder,
laneCounts: input.laneCounts,
selectedElementId: input.selectedElementId,
selectedElementIds: input.selectedElementIds,
expandedClipIds,
expandedGroupIds,
expandedLaneOwnerIds,
groups: input.groups,
trackGroupOf: input.trackGroupOf,
gsapAnimations: input.gsapAnimations,
});
const focus = useTimelineFocusCoordinator({
scrollRef: input.scrollRef,
logicalRows,
elements: input.elements,
rowGeometry: input.layout.rowGeometry,
pixelsPerSecond: input.pixelsPerSecond,
contentOrigin: input.contentOrigin,
allowHorizontal: input.allowHorizontal,
viewportVersion: input.viewport,
projectId,
sessionEpoch: input.sessionEpoch,
syncScrollViewport: input.syncScrollViewport,
});
const rows = useTimelineRowVirtualization({
scrollRef: input.scrollRef,
viewport: input.viewport,
rowGeometry: input.layout.rowGeometry,
sessionEpoch: input.sessionEpoch,
elements: input.elements,
selectedElementId: input.selectedElementId,
focusedRowKey: focus.focusedRowKey,
draggedRowKey: input.draggedRowKey,
resizingElementIds: input.resizingElementIds,
clipContextMenuRowKey: input.clipContextMenuRowKey,
keyframeContextMenuRowKey: input.keyframeContextMenuRowKey,
lastScrollLeftRef: input.lastScrollLeftRef,
syncScrollViewport: input.syncScrollViewport,
});
return {
logicalRows,
...focus,
rowVirtualizationActive: rows.enabled,
virtualRows: rows.virtualRows,
timelineFocusProps: rows.timelineFocusProps,
};
}