('button[aria-label$=" lanes"]')?.click());
const before = [...host.querySelectorAll(".hf-automation-lane")];
expect(before).toHaveLength(2);
diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx
index b279102c90..a5f71d9056 100644
--- a/packages/studio/src/player/components/Timeline.tsx
+++ b/packages/studio/src/player/components/Timeline.tsx
@@ -1,6 +1,5 @@
import { useRef, useMemo, useCallback, useState, memo } from "react";
-import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
-import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
+import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
import { defaultTimelineTheme } from "./timelineTheme";
@@ -39,7 +38,6 @@ import {
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
import { useTimelineTicks } from "./useTimelineTicks";
-import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
import { useTimelineActiveClips } from "./useTimelineActiveClips";
@@ -109,13 +107,7 @@ export const Timeline = memo(function Timeline({
useMusicBeatAnalysis();
const rawElements = usePlayerStore((s) => s.elements);
const expandedElements = useExpandedTimelineElements();
- const beatAnalysis = usePlayerStore((s) => s.beatAnalysis);
- const musicElement = usePlayerStore((s) => getTimelineElementIndexes(s.elements).musicElement);
- const beatEdits = usePlayerStore((s) => s.beatEdits);
- const adjustedBeatAnalysis = useMemo(
- () => remapBeatAnalysisToComposition(beatAnalysis, musicElement, beatEdits),
- [beatAnalysis, musicElement, beatEdits],
- );
+ const adjustedBeatAnalysis = useAdjustedBeatAnalysis();
const duration = usePlayerStore((s) => s.duration);
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
const timelineReady = usePlayerStore((s) => s.timelineReady);
@@ -158,6 +150,8 @@ export const Timeline = memo(function Timeline({
laneCounts,
rowGeometry,
rowGeometryRef,
+ groups,
+ trackGroupOf,
} = useTimelineTrackLayout(
expandedElements,
gsapAnimations,
@@ -289,6 +283,8 @@ export const Timeline = memo(function Timeline({
laneCounts,
selectedElementId,
selectedElementIds,
+ groups,
+ trackGroupOf,
gsapAnimations,
elements: expandedElements,
pixelsPerSecond: pps,
@@ -509,6 +505,7 @@ export const Timeline = memo(function Timeline({
trackOrder={trackOrder}
tracks={tracks}
trackStyles={trackStyles}
+ groups={groups}
laneCounts={laneCounts}
selectedElementId={selectedElementId}
selectedElementIds={selectedElementIds}
diff --git a/packages/studio/src/player/components/TimelineGroupHeader.tsx b/packages/studio/src/player/components/TimelineGroupHeader.tsx
new file mode 100644
index 0000000000..fcdcd8f961
--- /dev/null
+++ b/packages/studio/src/player/components/TimelineGroupHeader.tsx
@@ -0,0 +1,101 @@
+import { TRACK_H } from "./timelineLayout";
+import type { TimelineTheme } from "./timelineTheme";
+
+interface TimelineGroupHeaderProps {
+ label: string;
+ memberCount: number;
+ /** Caret: shows/hides the member rows beneath this group (structural). */
+ isExpanded: boolean;
+ onToggleExpanded: () => void;
+ /** `∿`: shows/hides the group's own automation-lane rows. */
+ laneCount: number;
+ isLaneOpen: boolean;
+ onToggleLanes: () => void;
+ columnWidth: number;
+ theme: TimelineTheme;
+}
+
+/**
+ * A group's own row header: caret (member disclosure) + `▤` + label + `∿ n`
+ * (lane disclosure). Mute/solo (B5) and the FX entry point (C1) land here as
+ * siblings once those steps exist — nothing to reserve for them yet.
+ */
+export function TimelineGroupHeader({
+ label,
+ memberCount,
+ isExpanded,
+ onToggleExpanded,
+ laneCount,
+ isLaneOpen,
+ onToggleLanes,
+ columnWidth,
+ theme,
+}: TimelineGroupHeaderProps) {
+ return (
+
+
+
+ ▤
+
+
+ {label}
+
+
+ {memberCount}
+
+
+
+ );
+}
diff --git a/packages/studio/src/player/components/TimelineGroupRow.tsx b/packages/studio/src/player/components/TimelineGroupRow.tsx
new file mode 100644
index 0000000000..59998132c2
--- /dev/null
+++ b/packages/studio/src/player/components/TimelineGroupRow.tsx
@@ -0,0 +1,77 @@
+import type { TimelineElement } from "../store/playerStore";
+import type { TimelineTheme } from "./timelineTheme";
+import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
+import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
+import { TimelineTrackRow } from "./TimelineTrackRow";
+import { TimelineGroupHeader } from "./TimelineGroupHeader";
+import { groupAutomationLanes } from "./automationLaneData";
+import { LABEL_COL_W } from "./timelineLayout";
+
+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;
+ expandedLaneOwnerIds: ReadonlySet;
+ toggleGroupExpanded: (id: string) => void;
+ toggleLaneOwnerExpanded: (id: string) => void;
+}
+
+/** A group's own row: the accessible shell (shared with track rows) plus the group header. */
+export function TimelineGroupRow({
+ index,
+ rowKey,
+ group,
+ logicalRow,
+ tracks,
+ top,
+ height,
+ virtualized,
+ contentOrigin,
+ theme,
+ rovingTargetId = null,
+ expandedGroupIds,
+ expandedLaneOwnerIds,
+ toggleGroupExpanded,
+ toggleLaneOwnerExpanded,
+}: TimelineGroupRowProps) {
+ const memberElements = group.memberTracks.flatMap(
+ (track) => tracks.find(([t]) => t === track)?.[1] ?? [],
+ );
+ return (
+
+ toggleGroupExpanded(group.id)}
+ laneCount={groupAutomationLanes(memberElements).length}
+ isLaneOpen={expandedLaneOwnerIds.has(group.id)}
+ onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
+ columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
+ theme={theme}
+ />
+
+ );
+}
diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx
index 0fb47204e7..86b61ae6e3 100644
--- a/packages/studio/src/player/components/TimelineLanes.test.tsx
+++ b/packages/studio/src/player/components/TimelineLanes.test.tsx
@@ -118,6 +118,10 @@ function renderLanes(options: RenderLanesOptions = {}): {
selectedElementId: null,
selectedElementIds: next.selectedElementIds ?? new Set(),
expandedClipIds: new Set(next.expandedClipIds ?? []),
+ expandedGroupIds: new Set(),
+ expandedLaneOwnerIds: new Set(),
+ groups: [],
+ trackGroupOf: new Map(),
gsapAnimations,
})}
clipIndex={createTimelineClipIndex(tracks)}
@@ -127,6 +131,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
trackOrder={displayTrackOrder}
tracks={tracks}
trackStyles={new Map()}
+ groups={[]}
laneCounts={laneCounts}
selectedElementId={null}
selectedElementIds={next.selectedElementIds ?? new Set()}
diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx
index d0993d81f0..8f0161e85d 100644
--- a/packages/studio/src/player/components/TimelineLanes.tsx
+++ b/packages/studio/src/player/components/TimelineLanes.tsx
@@ -1,4 +1,4 @@
-import { Fragment, useId, useMemo } from "react";
+import { Fragment, useId } from "react";
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
import { TimelineClip } from "./TimelineClip";
import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
@@ -7,6 +7,8 @@ import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane";
import { useAutomationLanes } from "./useAutomationLanes";
import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard";
import { TimelineTrackHeader } from "./TimelineTrackHeader";
+import { TimelineGroupRow } from "./TimelineGroupRow";
+import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes";
import {
isTrackRowExpanded,
resolveTrackKeyframeClip,
@@ -17,12 +19,8 @@ import { clipTimingStart } from "../../hooks/gsapShared";
import { getTimelineEditCapabilities } from "./timelineEditing";
import { CLIP_Y, TRACK_H } from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
-import {
- isMultiDragActive,
- isMultiDragPassenger,
- multiDragDeltaSeconds,
- multiDragPassengerOffsetPx,
-} from "./timelineMultiDragPreview";
+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";
@@ -32,7 +30,6 @@ import { TimelineTrackRow } from "./TimelineTrackRow";
import { isTimelineClipActive } from "./useTimelineActiveClips";
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
-import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import { timelineClipFocusId } from "./timelineNavigationIdentity";
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
@@ -55,6 +52,7 @@ export function TimelineLanes({
trackOrder,
tracks,
trackStyles,
+ groups,
laneCounts,
selectedElementId,
selectedElementIds,
@@ -102,20 +100,14 @@ 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 } =
+ useTimelineGroupDisclosure();
const automationLanes = useAutomationLanes();
useAutomationSelectionKeyboard({ lanes: automationLanes });
const expandClips = usePlayerStore((s) => s.expandClips);
const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
- const logicalRowsByTrack = useMemo(() => {
- const byTrack = new Map();
- for (const logicalRow of logicalRows) {
- const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? [];
- trackRows.push(logicalRow);
- byTrack.set(logicalRow.physicalTrackKey, trackRows);
- }
- return byTrack;
- }, [logicalRows]);
+ 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
@@ -131,22 +123,11 @@ export function TimelineLanes({
trackStudioKeyframeLaneExpand({ expanded: willExpand });
toggleClipExpanded(key);
};
- const multiDragDelta =
- multiDragPreview && isMultiDragActive(multiDragPreview)
- ? multiDragDeltaSeconds(multiDragPreview)
- : 0;
- const actorWindows =
- rowsVirtualized && multiDragPreview && multiDragDelta !== 0
- ? [
- {
- range: {
- start: renderTimeRange.start - multiDragDelta,
- end: renderTimeRange.end - multiDragDelta,
- },
- identities: multiDragPreview.selectedKeys,
- },
- ]
- : [];
+ const actorWindows = useTimelineMultiDragActorWindows(
+ multiDragPreview,
+ rowsVirtualized,
+ renderTimeRange,
+ );
const keyboard = useTimelineKeyboardActor({
logicalRows,
focusedTargetId,
@@ -171,6 +152,31 @@ export function TimelineLanes({
virtualRows.map(({ index: row, rowKey }) => {
const trackNum = displayTrackOrder[row];
if (trackNum === undefined) return null;
+ const group = groupByAnchor.get(trackNum);
+ if (group) {
+ const groupLogicalRow = logicalRowsByTrack.get(trackNum)?.[0];
+ if (!groupLogicalRow) return null;
+ return (
+
+ );
+ }
const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
const trackLogicalRows = logicalRowsByTrack.get(trackNum) ?? [];
const logicalRow = trackLogicalRows[0];
diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts
index 041f2a8f60..988c5a47d9 100644
--- a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts
+++ b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts
@@ -58,6 +58,10 @@ function model(overrides: Partial[0]
selectedElementId: "active",
selectedElementIds: new Set(),
expandedClipIds: new Set(["active"]),
+ expandedGroupIds: new Set(),
+ expandedLaneOwnerIds: new Set(),
+ groups: [],
+ trackGroupOf: new Map(),
gsapAnimations: new Map([
[
"active",
@@ -244,6 +248,10 @@ describe("resolveTimelineNavigationTarget", () => {
selectedElementId: null,
selectedElementIds: new Set(),
expandedClipIds: new Set(),
+ expandedGroupIds: new Set(),
+ expandedLaneOwnerIds: new Set(),
+ groups: [],
+ trackGroupOf: new Map(),
gsapAnimations: new Map(),
});
diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts
index 7ba14a8249..7921e03189 100644
--- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts
+++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts
@@ -1,6 +1,7 @@
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
import type { TimelineElement } from "../store/playerStore";
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
+import { groupAutomationLanes } from "./automationLaneData";
import {
timelineKeyframeSelectionKey,
type TimelineKeyframeTarget,
@@ -8,11 +9,13 @@ import {
import {
timelineClipFocusId,
timelineEaseFocusId,
+ timelineGroupRowId,
timelineKeyframeFocusId,
timelinePropertyRowId,
timelineTrackRowId,
} from "./timelineNavigationIdentity";
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
+import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
export type TimelineNavigationKey =
| "ArrowLeft"
@@ -54,9 +57,11 @@ export interface TimelineLogicalRow {
kind: "row";
physicalTrackKey: number;
logicalIndex: number;
- level: 1 | 2;
+ level: 1 | 2 | 3;
parentId: string | null;
elementId: string | null;
+ /** Set only on a group's own row (level 1, no clips of its own). */
+ groupId?: string;
expandable: boolean;
expanded: boolean;
propertyGroup?: PropertyGroupName;
@@ -65,13 +70,19 @@ export interface TimelineLogicalRow {
export type TimelineLogicalTarget = TimelineLogicalRow | TimelineLogicalItem;
-interface BuildTimelineLogicalRowsInput {
+export interface BuildTimelineLogicalRowsInput {
tracks: readonly (readonly [number, readonly TimelineElement[]])[];
displayTrackOrder: readonly number[];
laneCounts: ReadonlyMap;
selectedElementId: string | null;
selectedElementIds: ReadonlySet;
expandedClipIds: ReadonlySet;
+ /** Groups whose member rows the caret has shown (structural, not lanes). */
+ expandedGroupIds: ReadonlySet;
+ /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
+ expandedLaneOwnerIds: ReadonlySet;
+ groups: readonly TimelineTrackGroupInfo[];
+ trackGroupOf: ReadonlyMap;
gsapAnimations: ReadonlyMap;
}
@@ -106,6 +117,35 @@ function clipItems(rowId: string, elements: readonly TimelineElement[]): Timelin
});
}
+/** A track's active clip (if any), its element id, and its automation lanes. */
+function resolveActiveTrackClip(
+ elements: readonly TimelineElement[],
+ laneCounts: BuildTimelineLogicalRowsInput["laneCounts"],
+ selectedElementId: string | null,
+ selectedElementIds: ReadonlySet,
+ gsapAnimations: BuildTimelineLogicalRowsInput["gsapAnimations"],
+): {
+ activeClip: TimelineElement | null;
+ activeId: string | null;
+ lanes: ReturnType;
+} {
+ const activeClip = resolveTrackKeyframeClip(
+ elements,
+ laneCounts,
+ selectedElementId,
+ selectedElementIds,
+ );
+ const activeId = activeClip ? elementId(activeClip) : null;
+ const lanes = activeClip
+ ? getTimelinePropertyLanes(
+ gsapAnimations.get(elementId(activeClip)) ?? [],
+ activeClip.start,
+ activeClip.duration,
+ )
+ : [];
+ return { activeClip, activeId, lanes };
+}
+
function keyframeTarget(
keyframe: ReturnType[number]["keyframes"][number],
): TimelineKeyframeTarget {
@@ -167,6 +207,42 @@ function propertyItems(
return items;
}
+/** A clip's lanes are visible when either the caret or the `∿` button opened it. */
+function isRowOpen(
+ activeId: string | null,
+ expandedClipIds: ReadonlySet,
+ expandedLaneOwnerIds: ReadonlySet,
+): boolean {
+ if (activeId === null) return false;
+ return expandedClipIds.has(activeId) || expandedLaneOwnerIds.has(activeId);
+}
+
+/** A single automation-lane row, one level deeper than the track/group row that owns it. */
+function buildLaneRow(
+ track: number,
+ logicalIndex: number,
+ activeId: string,
+ activeClip: TimelineElement,
+ lane: ReturnType[number],
+ level: 2 | 3,
+ parentId: string,
+): TimelineLogicalRow {
+ const laneRowId = timelinePropertyRowId(activeId, lane.group);
+ return {
+ id: laneRowId,
+ kind: "row",
+ physicalTrackKey: track,
+ logicalIndex,
+ level,
+ parentId,
+ elementId: activeId,
+ expandable: false,
+ expanded: false,
+ propertyGroup: lane.group,
+ items: propertyItems(laneRowId, activeClip, lane.keyframes),
+ };
+}
+
/** Canonical model of the treegrid, independent of which virtual rows or clips are mounted. */
export function buildTimelineLogicalRows({
tracks,
@@ -175,57 +251,98 @@ export function buildTimelineLogicalRows({
selectedElementId,
selectedElementIds,
expandedClipIds,
+ expandedGroupIds,
+ expandedLaneOwnerIds,
+ groups,
+ trackGroupOf,
gsapAnimations,
}: BuildTimelineLogicalRowsInput): TimelineLogicalRow[] {
const trackMap = new Map(tracks);
+ const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
const rows: TimelineLogicalRow[] = [];
- for (const track of displayTrackOrder) {
+
+ // A real track's own row (level 1 ungrouped, level 2 under a group) plus,
+ // when its clip's lanes are open, the lane rows one level deeper.
+ function emitTrack(track: number, level: 1 | 2, parentId: string | null): void {
const elements = trackMap.get(track) ?? [];
const trackId = timelineTrackRowId(track);
- const activeClip = resolveTrackKeyframeClip(
+ const { activeClip, activeId, lanes } = resolveActiveTrackClip(
elements,
laneCounts,
selectedElementId,
selectedElementIds,
+ gsapAnimations,
);
- const activeId = activeClip ? elementId(activeClip) : null;
- const lanes = activeClip
- ? getTimelinePropertyLanes(
- gsapAnimations.get(elementId(activeClip)) ?? [],
- activeClip.start,
- activeClip.duration,
- )
- : [];
- const expanded = activeId !== null && expandedClipIds.has(activeId) && lanes.length > 0;
+ const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && lanes.length > 0;
rows.push({
id: trackId,
kind: "row",
physicalTrackKey: track,
logicalIndex: rows.length,
- level: 1,
- parentId: null,
+ level,
+ parentId,
elementId: activeId,
expandable: lanes.length > 0,
expanded,
items: clipItems(trackId, elements),
});
- if (!expanded || !activeClip) continue;
+ if (!expanded || !activeClip || !activeId) return;
for (const lane of lanes) {
- const rowId = timelinePropertyRowId(activeId, lane.group);
- rows.push({
- id: rowId,
- kind: "row",
- physicalTrackKey: track,
- logicalIndex: rows.length,
- level: 2,
- parentId: trackId,
- elementId: activeId,
- expandable: false,
- expanded: false,
- propertyGroup: lane.group,
- items: propertyItems(rowId, activeClip, lane.keyframes),
- });
+ rows.push(
+ buildLaneRow(track, rows.length, activeId, activeClip, lane, level === 1 ? 2 : 3, trackId),
+ );
+ }
+ }
+
+ // A group's own row (level 1) plus, when its `∿` is open, its own
+ // automation-lane rows (level 2) — structural content deferred to whatever
+ // step wires group automation editing; this reserves the rows and their
+ // count.
+ function emitGroup(group: TimelineTrackGroupInfo): void {
+ const groupRowId = timelineGroupRowId(group.id);
+ const groupExpanded = expandedGroupIds.has(group.id);
+ rows.push({
+ id: groupRowId,
+ kind: "row",
+ physicalTrackKey: group.anchorKey,
+ logicalIndex: rows.length,
+ level: 1,
+ parentId: null,
+ elementId: null,
+ groupId: group.id,
+ expandable: group.memberTracks.length > 0,
+ expanded: groupExpanded,
+ items: [],
+ });
+ if (expandedLaneOwnerIds.has(group.id)) {
+ const memberElements = group.memberTracks.flatMap((track) => trackMap.get(track) ?? []);
+ for (const laneGroup of groupAutomationLanes(memberElements)) {
+ rows.push({
+ id: `${groupRowId}::${laneGroup.key}`,
+ kind: "row",
+ physicalTrackKey: group.anchorKey,
+ logicalIndex: rows.length,
+ level: 2,
+ parentId: groupRowId,
+ elementId: null,
+ expandable: false,
+ expanded: false,
+ items: [],
+ });
+ }
+ }
+ if (!groupExpanded) return;
+ for (const track of group.memberTracks) emitTrack(track, 2, groupRowId);
+ }
+
+ for (const key of displayTrackOrder) {
+ const group = groupByAnchor.get(key);
+ if (group) {
+ emitGroup(group);
+ continue;
}
+ if (trackGroupOf.has(key)) continue; // emitted above, under its group
+ emitTrack(key, 1, null);
}
return rows;
}
diff --git a/packages/studio/src/player/components/timelineLaneProps.ts b/packages/studio/src/player/components/timelineLaneProps.ts
index 4f5725b5eb..be09495287 100644
--- a/packages/studio/src/player/components/timelineLaneProps.ts
+++ b/packages/studio/src/player/components/timelineLaneProps.ts
@@ -13,6 +13,7 @@ import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import type { TimelineClipRenderContext } from "./TimelineTypes";
+import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
/**
* Props shared by the scroll container ({@link import("./TimelineCanvas")}) and
@@ -99,6 +100,8 @@ export interface TimelineLaneBaseProps {
*/
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
beatAnalysis?: MusicBeatAnalysis | null;
+ /** Resolved audio groups, positioned in row order — see useTimelineTrackDerivations. */
+ groups: readonly TimelineTrackGroupInfo[];
}
/**
diff --git a/packages/studio/src/player/components/timelineNavigationIdentity.ts b/packages/studio/src/player/components/timelineNavigationIdentity.ts
index 2641190b7b..2557f8a86b 100644
--- a/packages/studio/src/player/components/timelineNavigationIdentity.ts
+++ b/packages/studio/src/player/components/timelineNavigationIdentity.ts
@@ -13,6 +13,10 @@ export function timelineTrackRowId(track: number): string {
return stableId("track", track);
}
+export function timelineGroupRowId(groupId: string): string {
+ return stableId("group", groupId);
+}
+
export function timelinePropertyRowId(elementId: string, group: PropertyGroupName): string {
return stableId("property", elementId, group);
}
diff --git a/packages/studio/src/player/components/useTimelineLaneRowIndexes.ts b/packages/studio/src/player/components/useTimelineLaneRowIndexes.ts
new file mode 100644
index 0000000000..3a5fa83750
--- /dev/null
+++ b/packages/studio/src/player/components/useTimelineLaneRowIndexes.ts
@@ -0,0 +1,38 @@
+import { useMemo } from "react";
+import { usePlayerStore } from "../store/playerStore";
+import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
+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),
+ expandedLaneOwnerIds: usePlayerStore((s) => s.expandedLaneOwnerIds),
+ toggleGroupExpanded: usePlayerStore((s) => s.toggleGroupExpanded),
+ toggleLaneOwnerExpanded: usePlayerStore((s) => s.toggleLaneOwnerExpanded),
+ };
+}
+
+/** Two lookups TimelineLanes needs once per render: a row's logical rows by
+ * physical key, and which physical key is a group's own anchor row. */
+export function useTimelineLaneRowIndexes(
+ logicalRows: readonly TimelineLogicalRow[],
+ groups: readonly TimelineTrackGroupInfo[],
+) {
+ const logicalRowsByTrack = useMemo(() => {
+ const byTrack = new Map();
+ for (const logicalRow of logicalRows) {
+ const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? [];
+ trackRows.push(logicalRow);
+ byTrack.set(logicalRow.physicalTrackKey, trackRows);
+ }
+ return byTrack;
+ }, [logicalRows]);
+
+ const groupByAnchor = useMemo(
+ () => new Map(groups.map((group) => [group.anchorKey, group])),
+ [groups],
+ );
+
+ return { logicalRowsByTrack, groupByAnchor };
+}
diff --git a/packages/studio/src/player/components/useTimelineLogicalFocus.ts b/packages/studio/src/player/components/useTimelineLogicalFocus.ts
index 36f6de3dbe..ecc8bc8cea 100644
--- a/packages/studio/src/player/components/useTimelineLogicalFocus.ts
+++ b/packages/studio/src/player/components/useTimelineLogicalFocus.ts
@@ -3,6 +3,7 @@ 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";
@@ -15,6 +16,8 @@ interface TimelineLogicalFocusInput {
laneCounts: ReadonlyMap;
selectedElementId: string | null;
selectedElementIds: ReadonlySet;
+ groups: readonly TimelineTrackGroupInfo[];
+ trackGroupOf: ReadonlyMap;
gsapAnimations: ReadonlyMap;
elements: readonly TimelineElement[];
pixelsPerSecond: number;
@@ -32,6 +35,8 @@ interface TimelineLogicalFocusInput {
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,
@@ -40,6 +45,10 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
selectedElementId: input.selectedElementId,
selectedElementIds: input.selectedElementIds,
expandedClipIds,
+ expandedGroupIds,
+ expandedLaneOwnerIds,
+ groups: input.groups,
+ trackGroupOf: input.trackGroupOf,
gsapAnimations: input.gsapAnimations,
});
const focus = useTimelineFocusCoordinator({
diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx
index f20f2699a1..25e171e5e6 100644
--- a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx
+++ b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx
@@ -22,6 +22,10 @@ const displayTrackOrder = tracks.map(([track]) => track);
const laneCounts = new Map();
const selectedElementIds = new Set();
const expandedClipIds = new Set();
+const expandedGroupIds = new Set();
+const expandedLaneOwnerIds = new Set();
+const groups: never[] = [];
+const trackGroupOf = new Map();
const gsapAnimations = new Map();
function Harness({ snapshots }: { snapshots: Array }) {
@@ -33,6 +37,10 @@ function Harness({ snapshots }: { snapshots: Array;
- selectedElementId: string | null;
- selectedElementIds: ReadonlySet;
- expandedClipIds: ReadonlySet;
- gsapAnimations: ReadonlyMap;
-}
+type TimelineLogicalRowsInput = BuildTimelineLogicalRowsInput;
/** Shared by rendering and focus coordination; stable input refs preserve memo identity. */
export function useTimelineLogicalRows({
@@ -21,6 +14,10 @@ export function useTimelineLogicalRows({
selectedElementId,
selectedElementIds,
expandedClipIds,
+ expandedGroupIds,
+ expandedLaneOwnerIds,
+ groups,
+ trackGroupOf,
gsapAnimations,
}: TimelineLogicalRowsInput) {
return useMemo(
@@ -32,11 +29,19 @@ export function useTimelineLogicalRows({
selectedElementId,
selectedElementIds,
expandedClipIds,
+ expandedGroupIds,
+ expandedLaneOwnerIds,
+ groups,
+ trackGroupOf,
gsapAnimations,
}),
[
displayTrackOrder,
expandedClipIds,
+ expandedGroupIds,
+ expandedLaneOwnerIds,
+ groups,
+ trackGroupOf,
gsapAnimations,
laneCounts,
selectedElementId,
diff --git a/packages/studio/src/player/components/useTimelineMultiDragActorWindows.ts b/packages/studio/src/player/components/useTimelineMultiDragActorWindows.ts
new file mode 100644
index 0000000000..9bab0c5f29
--- /dev/null
+++ b/packages/studio/src/player/components/useTimelineMultiDragActorWindows.ts
@@ -0,0 +1,30 @@
+import { isMultiDragActive, multiDragDeltaSeconds } from "./timelineMultiDragPreview";
+import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
+import type { TimelineTimeRange } from "../lib/timelineClipIndex";
+
+/** The extra render window a live multi-drag needs: its passengers still draw
+ * at their ORIGIN position, offset by the drag delta, outside the normal
+ * virtualized range. Empty when nothing is dragging. */
+export function useTimelineMultiDragActorWindows(
+ multiDragPreview: MultiDragPreviewInput | null,
+ rowsVirtualized: boolean,
+ renderTimeRange: TimelineTimeRange,
+) {
+ const multiDragDelta =
+ multiDragPreview && isMultiDragActive(multiDragPreview)
+ ? multiDragDeltaSeconds(multiDragPreview)
+ : 0;
+ const actorWindows =
+ rowsVirtualized && multiDragPreview && multiDragDelta !== 0
+ ? [
+ {
+ range: {
+ start: renderTimeRange.start - multiDragDelta,
+ end: renderTimeRange.end - multiDragDelta,
+ },
+ identities: multiDragPreview.selectedKeys,
+ },
+ ]
+ : [];
+ return actorWindows;
+}
diff --git a/packages/studio/src/player/components/useTimelineTrackDerivations.ts b/packages/studio/src/player/components/useTimelineTrackDerivations.ts
index b6bace824d..b6e4fea129 100644
--- a/packages/studio/src/player/components/useTimelineTrackDerivations.ts
+++ b/packages/studio/src/player/components/useTimelineTrackDerivations.ts
@@ -1,20 +1,106 @@
import { useMemo } from "react";
import type { TimelineElement } from "../store/playerStore";
+import { isCanaryEnabled } from "../../telemetry/canary";
import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
+/** One resolved audio group, positioned in the row order. */
+export interface TimelineTrackGroupInfo {
+ id: string;
+ label: string;
+ /**
+ * Synthetic sort key for the group's own row — the same fractional-key
+ * convention sub-composition expansion already uses (see
+ * timelineTrackDisplay.ts): the first (lowest) member track's number minus
+ * 0.5, so it slots in immediately above that member.
+ */
+ anchorKey: number;
+ /** Member track numbers, ascending. */
+ memberTracks: number[];
+}
+
+interface GroupMembership {
+ trackToGroupId: Map;
+ memberTracksByGroup: Map;
+ labelByGroup: Map;
+}
+
+/** Which track belongs to which group, and each group's label — one pass over raw tracks. */
+function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): GroupMembership {
+ const trackToGroupId = new Map();
+ const memberTracksByGroup = new Map();
+ const labelByGroup = new Map();
+ for (const [trackNum, elements] of rawTracks) {
+ const owner = elements.find((el) => el.audioGroup);
+ if (!owner?.audioGroup) continue;
+ trackToGroupId.set(trackNum, owner.audioGroup);
+ if (!labelByGroup.has(owner.audioGroup)) {
+ labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
+ }
+ const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
+ members.push(trackNum);
+ memberTracksByGroup.set(owner.audioGroup, members);
+ }
+ return { trackToGroupId, memberTracksByGroup, labelByGroup };
+}
+
+/**
+ * Pull each group's members out of raw ascending track order and re-emit them
+ * contiguously, directly beneath a synthetic anchor row. Ungrouped tracks keep
+ * 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[]][]): {
+ tracks: [number, TimelineElement[]][];
+ groups: TimelineTrackGroupInfo[];
+ trackGroupOf: Map;
+} {
+ const { trackToGroupId, memberTracksByGroup, labelByGroup } = resolveGroupMembership(rawTracks);
+ const rawByTrack = new Map(rawTracks);
+ const groups: TimelineTrackGroupInfo[] = [];
+ const trackGroupOf = new Map();
+ const emitted = new Set();
+ const tracks: [number, TimelineElement[]][] = [];
+
+ for (const [trackNum, elements] of rawTracks) {
+ const groupId = trackToGroupId.get(trackNum);
+ if (!groupId) {
+ tracks.push([trackNum, elements]);
+ continue;
+ }
+ if (emitted.has(groupId)) continue;
+ emitted.add(groupId);
+ const memberTracks = [...(memberTracksByGroup.get(groupId) ?? [])].sort((a, b) => a - b);
+ const info: TimelineTrackGroupInfo = {
+ id: groupId,
+ label: labelByGroup.get(groupId) ?? groupId,
+ anchorKey: (memberTracks[0] ?? trackNum) - 0.5,
+ memberTracks,
+ };
+ groups.push(info);
+ tracks.push([info.anchorKey, []]);
+ for (const member of memberTracks) {
+ trackGroupOf.set(member, info);
+ tracks.push([member, rawByTrack.get(member) ?? []]);
+ }
+ }
+ return { tracks, groups, trackGroupOf };
+}
+
/**
* Per-render track derivations Timeline.tsx feeds the canvas/lanes: the lane →
- * clip grouping (`tracks`, ascending), per-lane visual styles, the ascending
- * `trackOrder`, and the z-override badge set. Extracted from Timeline.tsx as a
- * cohesive unit (600-line studio cap); each memo keys on the expanded display
- * element set exactly as before.
+ * clip grouping (`tracks`, group-aware order), per-lane visual styles, the
+ * matching `trackOrder`, and audio-group membership. Extracted from
+ * Timeline.tsx as a cohesive unit (600-line studio cap); each memo keys on the
+ * expanded display element set exactly as before.
*/
export function useTimelineTrackDerivations(expandedElements: TimelineElement[]): {
tracks: [number, TimelineElement[]][];
trackStyles: Map;
trackOrder: number[];
+ groups: TimelineTrackGroupInfo[];
+ trackGroupOf: Map;
} {
- const tracks = useMemo(() => {
+ const rawTracks = useMemo(() => {
const map = new Map();
for (const el of expandedElements) {
const list = map.get(el.track) ?? [];
@@ -24,6 +110,17 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
return Array.from(map.entries()).sort(([a], [b]) => a - b);
}, [expandedElements]);
+ const { tracks, groups, trackGroupOf } = useMemo(() => {
+ if (!isCanaryEnabled("audio-groups")) {
+ return {
+ tracks: rawTracks,
+ groups: [],
+ trackGroupOf: new Map(),
+ };
+ }
+ return groupTimelineTracks(rawTracks);
+ }, [rawTracks]);
+
const trackStyles = useMemo(() => {
const map = new Map();
for (const [trackNum, els] of tracks) {
@@ -34,5 +131,5 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
- return { tracks, trackStyles, trackOrder };
+ return { tracks, trackStyles, trackOrder, groups, trackGroupOf };
}
diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts
index 7e91aa02d5..bd4af1e693 100644
--- a/packages/studio/src/player/components/useTimelineTrackLayout.ts
+++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts
@@ -188,7 +188,8 @@ export function useTimelineTrackLayout(
selectedElementId: string | null,
selectedElementIds: ReadonlySet,
) {
- const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements);
+ const { tracks, trackStyles, trackOrder, groups, trackGroupOf } =
+ useTimelineTrackDerivations(expandedElements);
const trackOrderRef = useRef(trackOrder);
trackOrderRef.current = trackOrder;
const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights(
@@ -207,6 +208,8 @@ export function useTimelineTrackLayout(
rowGeometry,
rowGeometryRef,
rowHeights,
+ groups,
+ trackGroupOf,
};
}
@@ -227,7 +230,23 @@ function useDisplayRowHeights(
function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
return useMemo(() => {
if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
- return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
+ // A group's members sit out of raw numeric order (pulled under their
+ // anchor row), so a plain numeric sort here would undo that grouping the
+ // moment a clip drags onto a brand-new track. Insert the new preview
+ // track only relative to other REAL (integer) tracks, leaving any
+ // fractional group-anchor keys exactly where grouping placed them.
+ const preview = draggedClip.previewTrack;
+ const result: number[] = [];
+ let inserted = false;
+ for (const key of trackOrder) {
+ if (!inserted && Number.isInteger(key) && key > preview) {
+ result.push(preview);
+ inserted = true;
+ }
+ result.push(key);
+ }
+ if (!inserted) result.push(preview);
+ return result;
}, [draggedClip, trackOrder]);
}
diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts
index e109822ce9..de9f6232e3 100644
--- a/packages/studio/src/player/lib/timelineDOM.ts
+++ b/packages/studio/src/player/lib/timelineDOM.ts
@@ -12,6 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
import type { ClipManifestClip } from "./playbackTypes";
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
import { readClipTiming } from "@hyperframes/core/composition-contract";
+import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
import {
resolveMediaElement,
applyMediaMetadataFromElement,
@@ -67,6 +68,20 @@ function resolveClipTag(clip: ClipManifestClip): string {
return clip.tagName || clip.kind || "div";
}
+// One `` scan per document, not per clip — resolveAudioGroups
+// walks the whole tree, and a parse touches every clip in it.
+const groupLabelCache = new WeakMap>();
+
+function groupLabelFor(doc: Document | null | undefined, groupId: string): string {
+ if (!doc) return groupId;
+ let labels = groupLabelCache.get(doc);
+ if (!labels) {
+ labels = new Map(resolveAudioGroups(doc).map((group) => [group.id, group.label]));
+ groupLabelCache.set(doc, labels);
+ }
+ return labels.get(groupId) ?? groupId;
+}
+
// fallow-ignore-next-line complexity
export function createTimelineElementFromManifestClip(params: {
clip: ClipManifestClip;
@@ -138,6 +153,11 @@ export function createTimelineElementFromManifestClip(params: {
if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
const timelineRole = hostEl.getAttribute("data-timeline-role");
if (timelineRole) entry.timelineRole = timelineRole;
+ const audioGroup = hostEl.getAttribute("data-audio-group");
+ if (audioGroup) {
+ entry.audioGroup = audioGroup;
+ entry.audioGroupLabel = groupLabelFor(doc ?? hostEl.ownerDocument, audioGroup);
+ }
const fxChain = hostEl.getAttribute("data-fx-chain");
if (fxChain) entry.fxChain = fxChain;
const automation = hostEl.getAttribute("data-automation");
@@ -356,6 +376,12 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
const timelineRole = el.getAttribute("data-timeline-role");
if (timelineRole) entry.timelineRole = timelineRole;
+ const domAudioGroup = el.getAttribute("data-audio-group");
+ if (domAudioGroup) {
+ entry.audioGroup = domAudioGroup;
+ entry.audioGroupLabel = groupLabelFor(doc, domAudioGroup);
+ }
+
// Sub-compositions
const compSrc =
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
diff --git a/packages/studio/src/player/store/keyframeSlice.ts b/packages/studio/src/player/store/keyframeSlice.ts
index 00b0155aa1..0e8a5b7864 100644
--- a/packages/studio/src/player/store/keyframeSlice.ts
+++ b/packages/studio/src/player/store/keyframeSlice.ts
@@ -63,6 +63,14 @@ 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;
+ toggleGroupExpanded: (id: string) => void;
+
+ /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
+ expandedLaneOwnerIds: Set;
+ toggleLaneOwnerExpanded: (id: string) => void;
+
/**
* Project/session/element-scoped request. Its nonce is monotonic across store
* resets so a stale consumer can never collide with a later request.
@@ -119,6 +127,24 @@ export function createKeyframeSlice(
return { expandedClipIds: next };
}),
+ expandedGroupIds: new Set(),
+ toggleGroupExpanded: (id) =>
+ set((state) => {
+ const next = new Set(state.expandedGroupIds);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return { expandedGroupIds: next };
+ }),
+
+ expandedLaneOwnerIds: new Set(),
+ toggleLaneOwnerExpanded: (id) =>
+ set((state) => {
+ const next = new Set(state.expandedLaneOwnerIds);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return { expandedLaneOwnerIds: next };
+ }),
+
focusedEaseSegment: null,
focusedEaseRequestNonce: 0,
setFocusedEaseSegment: (target) =>
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index 966a031fbc..1fb026f4fe 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -268,6 +268,8 @@ export function createTimelineResetState() {
// paste through `sel.elementKey === paste.elementKey` to a stale t0.
automationSelection: null,
expandedClipIds: new Set(),
+ expandedGroupIds: new Set(),
+ expandedLaneOwnerIds: new Set(),
focusedEaseSegment: null,
selectedElementIds: new Set(),
requestedSeekTime: null,
diff --git a/packages/studio/src/player/store/timelineElement.ts b/packages/studio/src/player/store/timelineElement.ts
index 104e969890..5b506605e7 100644
--- a/packages/studio/src/player/store/timelineElement.ts
+++ b/packages/studio/src/player/store/timelineElement.ts
@@ -67,6 +67,10 @@ export interface TimelineElement {
hidden?: boolean;
/** Value of data-timeline-role attribute — used to identify music vs. voiceover. */
timelineRole?: string;
+ /** Verbatim `data-audio-group` — the id of the `` this clip belongs to, when any. */
+ audioGroup?: string;
+ /** The owning group's `data-label` (falls back to its id) — resolved once per parse. */
+ audioGroupLabel?: string;
/**
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
* child: the absolute master-timeline start of the sub-comp host the child