@hyperframes/studio 0.8.10 → 0.8.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-DoTrWV8q.js → hyperframes-player-B243szNa.js} +1 -1
- package/dist/assets/{index-DV45YLqR.js → index-BTP86sD-.js} +1 -1
- package/dist/assets/index-BZfUJ2He.js +431 -0
- package/dist/assets/{index-BQs1fahB.js → index-DQ_lkuqY.js} +1 -1
- package/dist/assets/index-yGhfxxoL.css +1 -0
- package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
- package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
- package/dist/index.d.ts +84 -43
- package/dist/index.html +2 -2
- package/dist/index.js +6440 -5514
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +0 -2
- package/src/components/StudioRightPanel.tsx +15 -57
- package/src/components/editor/PropertyPanel.test.tsx +101 -3
- package/src/components/editor/PropertyPanel.tsx +15 -1
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
- package/src/components/editor/PropertyPanelFlat.tsx +82 -21
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
- package/src/components/editor/TimelineFxPopover.tsx +24 -4
- package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
- package/src/components/editor/audioFxRevealTarget.ts +113 -0
- package/src/components/editor/audioFxSignalPath.test.ts +50 -0
- package/src/components/editor/audioFxSignalPath.ts +71 -0
- package/src/components/editor/audioFxSummary.test.ts +8 -0
- package/src/components/editor/audioFxSummary.ts +28 -16
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
- package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
- package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
- package/src/components/editor/propertyPanelFxControls.tsx +10 -2
- package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
- package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
- package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
- package/src/components/editor/propertyPanelFxSection.tsx +63 -4
- package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
- package/src/components/editor/useAudioFxRevealSection.ts +64 -0
- package/src/components/editor/useAuditionTransport.test.ts +36 -0
- package/src/components/editor/useAuditionTransport.ts +78 -0
- package/src/components/editor/useFxAudition.ts +12 -1
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
- package/src/components/editor/useFxCarveGrouping.ts +139 -4
- package/src/components/editor/useFxCarveNodes.ts +153 -0
- package/src/components/editor/useFxLevelling.ts +5 -34
- package/src/components/nle/NLEContext.tsx +3 -10
- package/src/components/nle/PreviewPane.tsx +0 -1
- package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
- package/src/contexts/DesignPanelInputContext.tsx +6 -5
- package/src/contexts/DomEditContext.tsx +10 -3
- package/src/contexts/FileManagerContext.tsx +3 -2
- package/src/contexts/PanelLayoutContext.tsx +3 -2
- package/src/contexts/StudioContext.tsx +7 -3
- package/src/contexts/TimelineEditContext.tsx +6 -2
- package/src/contexts/VariablePromoteContext.tsx +6 -2
- package/src/contexts/ViewModeContext.tsx +2 -2
- package/src/hooks/domEditDeleteMembers.ts +34 -0
- package/src/hooks/timelineAudioGroupCreate.ts +345 -0
- package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
- package/src/hooks/timelineAudioGroupVolume.ts +135 -3
- package/src/hooks/timelineEditingHelpers.test.ts +86 -0
- package/src/hooks/timelineEditingHelpers.ts +19 -7
- package/src/hooks/timelineElementFxAttribute.ts +0 -3
- package/src/hooks/timelineTrackVisibility.test.ts +95 -13
- package/src/hooks/timelineTrackVisibility.ts +6 -182
- package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
- package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
- package/src/hooks/useDomEditAttributeCommits.ts +13 -0
- package/src/hooks/useDomEditSession.test.tsx +57 -2
- package/src/hooks/useDomEditSession.ts +23 -24
- package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
- package/src/hooks/useLivePlayheadTime.ts +3 -1
- package/src/hooks/useRemoveBackground.ts +70 -0
- package/src/hooks/useTimelineEditing.ts +5 -19
- package/src/hooks/useTimelineEditingTypes.ts +7 -0
- package/src/player/components/AutomationSelectionMenu.tsx +4 -0
- package/src/player/components/LayerDisclosureRow.tsx +59 -25
- package/src/player/components/PlayerControls.tsx +0 -38
- package/src/player/components/Timeline.test.ts +40 -1
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
- package/src/player/components/TimelineAutomationLane.tsx +89 -12
- package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
- package/src/player/components/TimelineFxButton.test.tsx +184 -7
- package/src/player/components/TimelineFxButton.tsx +171 -23
- package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
- package/src/player/components/TimelineGroupHeader.tsx +100 -102
- package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
- package/src/player/components/TimelineGroupRow.test.tsx +146 -0
- package/src/player/components/TimelineGroupRow.tsx +123 -64
- package/src/player/components/TimelineLanes.test.tsx +3 -3
- package/src/player/components/TimelineLanes.tsx +25 -23
- package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
- package/src/player/components/TimelineTrackHeader.tsx +301 -381
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
- package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
- package/src/player/components/automationLaneData.test.ts +94 -0
- package/src/player/components/automationLaneData.ts +23 -0
- package/src/player/components/groupAutomationElement.test.ts +51 -0
- package/src/player/components/groupAutomationElement.ts +37 -0
- package/src/player/components/timelineCallbacks.ts +6 -2
- package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
- package/src/player/components/timelineKeyboardNavigation.ts +25 -8
- package/src/player/components/timelineLayout.ts +0 -1
- package/src/player/components/timelineViewModel.ts +30 -1
- package/src/player/components/trackHeaderLabelRows.tsx +328 -0
- package/src/player/components/useTimelineClipDisclosure.ts +41 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
- package/src/player/components/useTimelineLogicalFocus.ts +2 -2
- package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
- package/src/player/components/useTimelineLogicalRows.ts +3 -3
- package/src/player/components/useTimelineTrackDerivations.ts +47 -8
- package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
- package/src/player/components/useTimelineTrackLayout.ts +12 -4
- package/src/player/hooks/previewMessageRouter.ts +4 -11
- package/src/player/hooks/timelineSyncHydration.ts +395 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
- package/src/player/hooks/useTimelinePlayer.ts +2 -7
- package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
- package/src/player/lib/automationStoreSync.test.ts +26 -0
- package/src/player/lib/automationStoreSync.ts +28 -4
- package/src/player/lib/runtimeAudioId.test.ts +58 -0
- package/src/player/lib/timelineDOM.test.ts +115 -0
- package/src/player/lib/timelineDOM.ts +3 -32
- package/src/player/lib/timelineElementHelpers.ts +27 -0
- package/src/player/lib/timelineGroupInfo.ts +134 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
- package/src/player/lib/timelineIframeHelpers.ts +17 -0
- package/src/player/store/keyframeSlice.ts +79 -5
- package/src/player/store/playerStore.ts +37 -47
- package/src/player/store/playerStoreDevHandle.ts +22 -0
- package/src/player/store/playerStoreSelection.ts +41 -0
- package/src/player/store/timelineElement.ts +32 -0
- package/src/utils/hmrStableContext.ts +64 -0
- package/src/utils/timelineInspector.test.ts +35 -1
- package/src/utils/timelineInspector.ts +38 -0
- package/dist/assets/index-Ba9zbpT9.css +0 -1
- package/dist/assets/index-C6m2nHiX.js +0 -428
- package/src/hooks/useAudioSoloBridge.ts +0 -61
- package/src/hooks/useGroupLevel.ts +0 -36
- package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
- package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
- package/src/player/components/TimelineSoloButton.tsx +0 -32
- package/src/player/store/audioSoloSlice.test.ts +0 -113
- package/src/player/store/audioSoloSlice.ts +0 -43
- package/src/player/store/groupLevels.ts +0 -33
- /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
- /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
|
@@ -77,8 +77,8 @@ export interface BuildTimelineLogicalRowsInput {
|
|
|
77
77
|
selectedElementId: string | null;
|
|
78
78
|
selectedElementIds: ReadonlySet<string>;
|
|
79
79
|
expandedClipIds: ReadonlySet<string>;
|
|
80
|
-
/** Groups
|
|
81
|
-
|
|
80
|
+
/** Groups the caret has COLLAPSED — absent means expanded, the default. */
|
|
81
|
+
collapsedGroupIds: ReadonlySet<string>;
|
|
82
82
|
/** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
|
|
83
83
|
expandedLaneOwnerIds: ReadonlySet<string>;
|
|
84
84
|
groups: readonly TimelineTrackGroupInfo[];
|
|
@@ -251,7 +251,7 @@ export function buildTimelineLogicalRows({
|
|
|
251
251
|
selectedElementId,
|
|
252
252
|
selectedElementIds,
|
|
253
253
|
expandedClipIds,
|
|
254
|
-
|
|
254
|
+
collapsedGroupIds,
|
|
255
255
|
expandedLaneOwnerIds,
|
|
256
256
|
groups,
|
|
257
257
|
trackGroupOf,
|
|
@@ -273,7 +273,8 @@ export function buildTimelineLogicalRows({
|
|
|
273
273
|
selectedElementIds,
|
|
274
274
|
gsapAnimations,
|
|
275
275
|
);
|
|
276
|
-
const
|
|
276
|
+
const disclosable = isTrackDisclosable(elements, lanes.length);
|
|
277
|
+
const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && disclosable;
|
|
277
278
|
rows.push({
|
|
278
279
|
id: trackId,
|
|
279
280
|
kind: "row",
|
|
@@ -282,7 +283,7 @@ export function buildTimelineLogicalRows({
|
|
|
282
283
|
level,
|
|
283
284
|
parentId,
|
|
284
285
|
elementId: activeId,
|
|
285
|
-
expandable:
|
|
286
|
+
expandable: disclosable,
|
|
286
287
|
expanded,
|
|
287
288
|
items: clipItems(trackId, elements),
|
|
288
289
|
});
|
|
@@ -300,7 +301,7 @@ export function buildTimelineLogicalRows({
|
|
|
300
301
|
// count.
|
|
301
302
|
function emitGroup(group: TimelineTrackGroupInfo): void {
|
|
302
303
|
const groupRowId = timelineGroupRowId(group.id);
|
|
303
|
-
const groupExpanded =
|
|
304
|
+
const groupExpanded = !collapsedGroupIds.has(group.id);
|
|
304
305
|
rows.push({
|
|
305
306
|
id: groupRowId,
|
|
306
307
|
kind: "row",
|
|
@@ -315,8 +316,10 @@ export function buildTimelineLogicalRows({
|
|
|
315
316
|
items: [],
|
|
316
317
|
});
|
|
317
318
|
if (expandedLaneOwnerIds.has(group.id)) {
|
|
318
|
-
|
|
319
|
-
|
|
319
|
+
// The group's own member list, not `trackMap`: a COLLAPSED group can have
|
|
320
|
+
// its lane shelf open, and its members are absent from the display list —
|
|
321
|
+
// so looking them up there emitted zero lane rows for exactly that case.
|
|
322
|
+
for (const laneGroup of groupAutomationLanes(group.memberElements)) {
|
|
320
323
|
rows.push({
|
|
321
324
|
id: `${groupRowId}::${laneGroup.key}`,
|
|
322
325
|
kind: "row",
|
|
@@ -452,3 +455,17 @@ export function resolveTimelineFocusFallback(
|
|
|
452
455
|
}
|
|
453
456
|
return nextRows[previous.rowIndex] ?? nextRows[previous.rowIndex - 1] ?? null;
|
|
454
457
|
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Does a track have anything to open — the header's own `disclosable`.
|
|
461
|
+
*
|
|
462
|
+
* `TimelineTrackHeader` is `lanes.length > 0 || automationRows.length > 0`, and
|
|
463
|
+
* keyed on tweens alone here an audio track whose only disclosable content is
|
|
464
|
+
* AUTOMATION drew the `∿` while reporting itself unexpandable to the treegrid,
|
|
465
|
+
* so ArrowRight could not open it. Automation rows are counted per shared
|
|
466
|
+
* PROPERTY across the track's clips, the way the header counts them, not per
|
|
467
|
+
* clip.
|
|
468
|
+
*/
|
|
469
|
+
function isTrackDisclosable(elements: readonly TimelineElement[], laneCount: number): boolean {
|
|
470
|
+
return laneCount > 0 || groupAutomationLanes(elements).length > 0;
|
|
471
|
+
}
|
|
@@ -7,7 +7,6 @@ export const GUTTER = 32;
|
|
|
7
7
|
export const LABEL_COL_W = 232;
|
|
8
8
|
export const TRACK_H = 48;
|
|
9
9
|
export const LANE_H = 28;
|
|
10
|
-
export const STRIP_H = 40; // group bus strip (B7) — one fixed block, not per-property like LANE_H
|
|
11
10
|
export const RULER_H = 24;
|
|
12
11
|
export const CLIP_Y = 3;
|
|
13
12
|
export const CLIP_HANDLE_W = 18;
|
|
@@ -4,7 +4,7 @@ import type { ResizingClipState } from "./timelineClipDragTypes";
|
|
|
4
4
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
5
5
|
import { animationContributesLane } from "./TimelinePropertyLanes";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
function hasKeyframedTimelineClips(
|
|
8
8
|
animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
|
|
9
9
|
): boolean {
|
|
10
10
|
return Array.from(animationsByElement.values()).some((animations) =>
|
|
@@ -12,6 +12,35 @@ export function hasKeyframedTimelineClips(
|
|
|
12
12
|
);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Does the timeline need the wide label column?
|
|
17
|
+
*
|
|
18
|
+
* Keyframed clips need it for their property-lane names. Audio GROUPS need it
|
|
19
|
+
* for the same reason a keyframed clip does — a row whose name has nowhere else
|
|
20
|
+
* to go. A track row survives a narrow gutter because its CLIPS carry the name
|
|
21
|
+
* on the bar; a group row has no clips at all, so in the 80px gutter its label
|
|
22
|
+
* rendered at zero width and its FX and lane buttons were clipped off the
|
|
23
|
+
* side.
|
|
24
|
+
*
|
|
25
|
+
* Widening the column for the whole timeline, rather than letting just the
|
|
26
|
+
* group row overhang: the header is sticky and opaque, so an oversized one
|
|
27
|
+
* painted a slab across the rest of its own row, stayed pinned there through
|
|
28
|
+
* horizontal scroll, and had the playhead drawn straight through it.
|
|
29
|
+
*/
|
|
30
|
+
export function timelineNeedsLabelColumn(
|
|
31
|
+
animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
|
|
32
|
+
elements: readonly TimelineElement[],
|
|
33
|
+
): boolean {
|
|
34
|
+
return (
|
|
35
|
+
hasKeyframedTimelineClips(animationsByElement) ||
|
|
36
|
+
// The column exists FOR the group rows, so this must stay in step with
|
|
37
|
+
// whatever decides they are drawn (`useTimelineTrackDerivations`) —
|
|
38
|
+
// widening it with no group row on screen is a 232px shift of every clip
|
|
39
|
+
// with nothing to explain it.
|
|
40
|
+
elements.some((element) => Boolean(element.audioGroup))
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
15
44
|
export function getEffectiveTimelineDuration(
|
|
16
45
|
duration: number,
|
|
17
46
|
elements: readonly TimelineElement[],
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rows a track header draws BELOW its own line: one per disclosed keyframe
|
|
3
|
+
* property group, one per automation envelope.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `TimelineTrackHeader.tsx`, which owned all of this and stood at
|
|
6
|
+
* 763 lines against the studio's 600-line cap. Nothing else moved: these three
|
|
7
|
+
* components never read the header's state, only their own props, which is why
|
|
8
|
+
* they are the seam.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
12
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
13
|
+
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
14
|
+
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
|
15
|
+
import {
|
|
16
|
+
resolveLaneHeaderState,
|
|
17
|
+
type KeyframeNavigationState,
|
|
18
|
+
type TimelinePropertyLane,
|
|
19
|
+
} from "./trackHeaderLaneState";
|
|
20
|
+
import { valueReadout } from "./trackHeaderLaneValues";
|
|
21
|
+
import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
|
|
22
|
+
|
|
23
|
+
// Figma layout: prev-keyframe ‹, the add/remove toggle (children), next ›.
|
|
24
|
+
function PropertyGroupNavigation({
|
|
25
|
+
navigation,
|
|
26
|
+
label,
|
|
27
|
+
expandedElement,
|
|
28
|
+
onSeek,
|
|
29
|
+
children,
|
|
30
|
+
}: {
|
|
31
|
+
navigation: KeyframeNavigationState;
|
|
32
|
+
label: string;
|
|
33
|
+
expandedElement: TimelineElement;
|
|
34
|
+
onSeek?: (time: number) => void;
|
|
35
|
+
children: React.ReactNode;
|
|
36
|
+
}) {
|
|
37
|
+
// The 12x20px glyph is all the lane row has room for, so the WCAG 24x24
|
|
38
|
+
// target is met with a centered transparent ::before overlay instead of a
|
|
39
|
+
// bigger box; focus-visible matches every other control in this header.
|
|
40
|
+
const CHEVRON_BUTTON_CLASS =
|
|
41
|
+
"relative h-5 w-3 border-0 bg-transparent p-0 text-white/55 hover:text-white disabled:text-white/15 " +
|
|
42
|
+
"focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] " +
|
|
43
|
+
"before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 " +
|
|
44
|
+
"before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']";
|
|
45
|
+
const seekTo = (keyframe: { percentage: number } | null) => {
|
|
46
|
+
if (keyframe) {
|
|
47
|
+
onSeek?.(expandedElement.start + (keyframe.percentage / 100) * expandedElement.duration);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
return (
|
|
51
|
+
<span className="flex shrink-0 items-center gap-0.5">
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
aria-label={`Previous ${label} keyframe`}
|
|
55
|
+
disabled={!navigation.prevKeyframe}
|
|
56
|
+
className={CHEVRON_BUTTON_CLASS}
|
|
57
|
+
onClick={(event) => {
|
|
58
|
+
event.stopPropagation();
|
|
59
|
+
seekTo(navigation.prevKeyframe);
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
‹
|
|
63
|
+
</button>
|
|
64
|
+
{children}
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
aria-label={`Next ${label} keyframe`}
|
|
68
|
+
disabled={!navigation.nextKeyframe}
|
|
69
|
+
className={CHEVRON_BUTTON_CLASS}
|
|
70
|
+
onClick={(event) => {
|
|
71
|
+
event.stopPropagation();
|
|
72
|
+
seekTo(navigation.nextKeyframe);
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
›
|
|
76
|
+
</button>
|
|
77
|
+
</span>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function PropertyGroupHeaderRow({
|
|
82
|
+
lanesId,
|
|
83
|
+
lane,
|
|
84
|
+
laneIndex,
|
|
85
|
+
isLastLane,
|
|
86
|
+
expandedElement,
|
|
87
|
+
currentTime,
|
|
88
|
+
clipPercentage,
|
|
89
|
+
gutterBackground,
|
|
90
|
+
columnWidth,
|
|
91
|
+
onTogglePropertyGroupKeyframe,
|
|
92
|
+
onSeek,
|
|
93
|
+
rovingTargetId = null,
|
|
94
|
+
}: {
|
|
95
|
+
lanesId: string;
|
|
96
|
+
lane: TimelinePropertyLane;
|
|
97
|
+
laneIndex: number;
|
|
98
|
+
isLastLane: boolean;
|
|
99
|
+
expandedElement: TimelineElement;
|
|
100
|
+
currentTime: number;
|
|
101
|
+
clipPercentage: number;
|
|
102
|
+
gutterBackground: string;
|
|
103
|
+
columnWidth: number;
|
|
104
|
+
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
105
|
+
onSeek?: (time: number) => void;
|
|
106
|
+
rovingTargetId: string | null;
|
|
107
|
+
}) {
|
|
108
|
+
const elementId = expandedElement.key ?? expandedElement.id;
|
|
109
|
+
const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
|
|
110
|
+
lane,
|
|
111
|
+
currentTime,
|
|
112
|
+
clipPercentage,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
id={timelineLogicalRowCellId(lanesId, timelinePropertyRowId(elementId, lane.group), "header")}
|
|
118
|
+
data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
|
|
119
|
+
data-timeline-element-id={elementId}
|
|
120
|
+
tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
|
|
121
|
+
data-property-group={lane.group}
|
|
122
|
+
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
|
|
123
|
+
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
|
|
124
|
+
style={{
|
|
125
|
+
top: getTimelineLaneTop(laneIndex),
|
|
126
|
+
// The header column narrows to contentOrigin whenever that is under
|
|
127
|
+
// LABEL_COL_W; a lane row pinned to LABEL_COL_W then hangs its value
|
|
128
|
+
// readout over the canvas, on top of the clips it is labelling.
|
|
129
|
+
width: columnWidth,
|
|
130
|
+
height: LANE_H,
|
|
131
|
+
background: gutterBackground,
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
{/* Tree connector: vertical spine (top-half on the last lane) + branch tick. */}
|
|
135
|
+
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
|
|
136
|
+
<span
|
|
137
|
+
className="absolute left-1.5 top-0 w-px bg-white/15"
|
|
138
|
+
style={{ height: isLastLane ? "50%" : "100%" }}
|
|
139
|
+
/>
|
|
140
|
+
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
|
|
141
|
+
</span>
|
|
142
|
+
<span className="w-[46px] shrink-0 truncate text-white" title={label}>
|
|
143
|
+
{label}
|
|
144
|
+
</span>
|
|
145
|
+
<PropertyGroupNavigation
|
|
146
|
+
navigation={navigation}
|
|
147
|
+
label={label}
|
|
148
|
+
expandedElement={expandedElement}
|
|
149
|
+
onSeek={onSeek}
|
|
150
|
+
>
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
aria-pressed={!!navigation.currentKeyframe}
|
|
154
|
+
aria-label={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
|
|
155
|
+
title={`${navigation.currentKeyframe ? "Remove" : "Add"} ${label} keyframe`}
|
|
156
|
+
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target; the ◆ glyph stays 11px.
|
|
157
|
+
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]"
|
|
158
|
+
onClick={(event) => {
|
|
159
|
+
// Same as the disclosure caret and the eye: a control in the label
|
|
160
|
+
// column owns its click, it does not also hit the track row behind it.
|
|
161
|
+
event.stopPropagation();
|
|
162
|
+
if (toggleTarget) {
|
|
163
|
+
void onTogglePropertyGroupKeyframe?.(expandedElement, toggleTarget);
|
|
164
|
+
}
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
{navigation.currentKeyframe ? "◆" : "◇"}
|
|
168
|
+
</button>
|
|
169
|
+
</PropertyGroupNavigation>
|
|
170
|
+
<span
|
|
171
|
+
className="min-w-0 flex-1 truncate text-right tabular-nums text-white/45"
|
|
172
|
+
title={valueReadout(lane.group, values)}
|
|
173
|
+
>
|
|
174
|
+
{valueReadout(lane.group, values)}
|
|
175
|
+
</span>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* One envelope's row in the label column.
|
|
182
|
+
*
|
|
183
|
+
* Named here rather than inside the lane, on the same tree connector the
|
|
184
|
+
* keyframe rows use: an automation lane is a child of its clip exactly as a
|
|
185
|
+
* property group is, and drawing its name over the envelope put the label on top
|
|
186
|
+
* of the curve it describes and scrolled it away from its own row.
|
|
187
|
+
*/
|
|
188
|
+
export function AutomationLaneHeaderRow({
|
|
189
|
+
target,
|
|
190
|
+
label,
|
|
191
|
+
name,
|
|
192
|
+
param,
|
|
193
|
+
alsoAutomatedBy,
|
|
194
|
+
top,
|
|
195
|
+
isLastLane,
|
|
196
|
+
gutterBackground,
|
|
197
|
+
columnWidth,
|
|
198
|
+
onRemove,
|
|
199
|
+
isCarve,
|
|
200
|
+
onReveal,
|
|
201
|
+
}: {
|
|
202
|
+
/** The lane the ACTIVE clip draws in this row, or null when it draws none —
|
|
203
|
+
* the row belongs to the property, and a clip may be absent from it. */
|
|
204
|
+
target: string | null;
|
|
205
|
+
/** The whole thing on one line: the row's identity, its tooltip, and the
|
|
206
|
+
* remove button's name. */
|
|
207
|
+
label: string;
|
|
208
|
+
/** What the effect is — "Peaking EQ 1.6 kHz". */
|
|
209
|
+
name: string;
|
|
210
|
+
/** Which knob the envelope drives. Empty when there is no second line to draw. */
|
|
211
|
+
param: string;
|
|
212
|
+
/** Set when this clip's group automates the SAME parameter. Gain stages
|
|
213
|
+
* multiply — 0.42 on the group under 0.80 here plays at 0.34 — so an author
|
|
214
|
+
* who drew one curve and then another hears something quieter than either
|
|
215
|
+
* with nothing on screen to say why (groups doc §5). Not a warning; an
|
|
216
|
+
* explanation. */
|
|
217
|
+
alsoAutomatedBy?: string;
|
|
218
|
+
top: number;
|
|
219
|
+
isLastLane: boolean;
|
|
220
|
+
gutterBackground: string;
|
|
221
|
+
columnWidth: number;
|
|
222
|
+
onRemove?: (target: string) => void;
|
|
223
|
+
/** The carve owns this envelope and rewrites it on every re-run, so it is
|
|
224
|
+
* shown but not the author's to edit or delete. */
|
|
225
|
+
isCarve?: boolean;
|
|
226
|
+
/** Reveal this parameter in the rack. Absent, the name is inert rather than a
|
|
227
|
+
* button that looks live and does nothing. */
|
|
228
|
+
onReveal?: () => void;
|
|
229
|
+
}) {
|
|
230
|
+
return (
|
|
231
|
+
<div
|
|
232
|
+
data-automation-lane-label={label}
|
|
233
|
+
data-timeline-lane-top={top}
|
|
234
|
+
className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
|
|
235
|
+
style={{
|
|
236
|
+
top,
|
|
237
|
+
width: columnWidth,
|
|
238
|
+
height: AUTOMATION_LANE_H,
|
|
239
|
+
background: gutterBackground,
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
{/* Tree connector, as the keyframe rows draw it: spine down the row, branch
|
|
243
|
+
tick at the name's own height. */}
|
|
244
|
+
<span className="relative h-full w-3 shrink-0" aria-hidden="true">
|
|
245
|
+
<span
|
|
246
|
+
className="absolute left-1.5 top-0 w-px bg-white/15"
|
|
247
|
+
style={{ height: isLastLane ? "50%" : "100%" }}
|
|
248
|
+
/>
|
|
249
|
+
<span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
|
|
250
|
+
</span>
|
|
251
|
+
{/* Two lines: what the effect is, then which knob the envelope drives. On
|
|
252
|
+
one line a band's own name was the first thing truncated in a column this
|
|
253
|
+
narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
|
|
254
|
+
bands apart. */}
|
|
255
|
+
{/* A button, because the name IS the way to the knob: clicking it opens
|
|
256
|
+
the rack on the effect this envelope drives and scrolls to it. Nothing
|
|
257
|
+
else in the timeline can get there — a lane names a parameter, and the
|
|
258
|
+
rack is where a parameter is set. */}
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
tabIndex={-1}
|
|
262
|
+
aria-label={onReveal ? `Show ${label} in the effect rack` : undefined}
|
|
263
|
+
title={onReveal ? `Show ${label} in the effect rack` : label}
|
|
264
|
+
disabled={!onReveal}
|
|
265
|
+
className="flex min-w-0 flex-1 flex-col justify-center rounded border-0 bg-transparent p-0 text-left leading-tight enabled:hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
|
|
266
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
267
|
+
onClick={(event) => {
|
|
268
|
+
// The label column owns its click; it does not also fall through to
|
|
269
|
+
// the track row behind it and move the selection.
|
|
270
|
+
event.stopPropagation();
|
|
271
|
+
onReveal?.();
|
|
272
|
+
}}
|
|
273
|
+
>
|
|
274
|
+
<span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
|
|
275
|
+
{name}
|
|
276
|
+
</span>
|
|
277
|
+
{param ? (
|
|
278
|
+
<span
|
|
279
|
+
data-automation-lane-param=""
|
|
280
|
+
className="truncate font-mono text-[9px] text-white/40"
|
|
281
|
+
>
|
|
282
|
+
{param}
|
|
283
|
+
</span>
|
|
284
|
+
) : null}
|
|
285
|
+
{alsoAutomatedBy ? (
|
|
286
|
+
<span
|
|
287
|
+
data-automation-lane-also=""
|
|
288
|
+
className="truncate text-[9px] text-[#F5C542]/80"
|
|
289
|
+
title={`${alsoAutomatedBy} is also fading this — the two multiply.`}
|
|
290
|
+
>
|
|
291
|
+
{alsoAutomatedBy} is also fading this.
|
|
292
|
+
</span>
|
|
293
|
+
) : null}
|
|
294
|
+
</button>
|
|
295
|
+
{/* Beside the name it labels, because that is the only place an envelope is
|
|
296
|
+
named at all: a carve writes its own lanes, and the FX panel's automate
|
|
297
|
+
toggle can only reach a parameter it still lists — so without this an
|
|
298
|
+
envelope could be created and never removed.
|
|
299
|
+
|
|
300
|
+
Only for the clip the header is showing, since that is the only one a
|
|
301
|
+
write can reach: a shared row's other envelopes belong to clips that
|
|
302
|
+
are not selected, and a button that silently removed one of them (or
|
|
303
|
+
none) would be worse than no button. */}
|
|
304
|
+
{/* Not on a carve's own lane: the carve regenerates it on the next
|
|
305
|
+
analysis, so removing one band would come back and reads as the
|
|
306
|
+
button not working. Switching the carve off in the rack is what
|
|
307
|
+
removes them, all together, which is how they were made. */}
|
|
308
|
+
{onRemove && target !== null && !isCarve && (
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
aria-label={`Remove ${label} automation`}
|
|
312
|
+
title={`Remove ${label} automation`}
|
|
313
|
+
// h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
|
|
314
|
+
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]"
|
|
315
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
316
|
+
onClick={(event) => {
|
|
317
|
+
// A control in the label column owns its click; it does not also hit
|
|
318
|
+
// the track row behind it and change the selection.
|
|
319
|
+
event.stopPropagation();
|
|
320
|
+
onRemove(target);
|
|
321
|
+
}}
|
|
322
|
+
>
|
|
323
|
+
×
|
|
324
|
+
</button>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening and closing a track's keyframe property lanes, with the telemetry that
|
|
3
|
+
* goes with it.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `TimelineLanes.tsx` to keep that file under the studio's 600-line
|
|
6
|
+
* cap. Both callbacks were already the only place the disclosure state and its
|
|
7
|
+
* `keyframe_lane_expand` event were written together, which is what makes them a
|
|
8
|
+
* seam rather than a shuffle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
12
|
+
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
|
|
13
|
+
|
|
14
|
+
export interface TimelineClipDisclosure {
|
|
15
|
+
/** The caret belongs to the ROW, so it opens and closes every clip on it at
|
|
16
|
+
* once. Toggling only the active clip left the row's state depending on which
|
|
17
|
+
* sibling happened to be selected: expand one, click another, and the row
|
|
18
|
+
* collapsed under a caret that still pointed down. */
|
|
19
|
+
toggleRowExpanded: (keys: readonly string[]) => void;
|
|
20
|
+
toggleClipExpanded: (key: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useTimelineClipDisclosure(): TimelineClipDisclosure {
|
|
24
|
+
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
|
25
|
+
const expandClips = usePlayerStore((s) => s.expandClips);
|
|
26
|
+
const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
|
|
27
|
+
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
toggleRowExpanded: (keys) => {
|
|
31
|
+
const willExpand = !keys.some((key) => expandedClipIds.has(key));
|
|
32
|
+
trackStudioKeyframeLaneExpand({ expanded: willExpand });
|
|
33
|
+
if (willExpand) expandClips(keys);
|
|
34
|
+
else for (const key of keys) setClipExpanded(key, false);
|
|
35
|
+
},
|
|
36
|
+
toggleClipExpanded: (key) => {
|
|
37
|
+
trackStudioKeyframeLaneExpand({ expanded: !expandedClipIds.has(key) });
|
|
38
|
+
toggleClipExpanded(key);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -6,7 +6,7 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
|
|
6
6
|
/** The four pieces of group-disclosure state a group row's header reads and writes. */
|
|
7
7
|
export function useTimelineGroupDisclosure() {
|
|
8
8
|
return {
|
|
9
|
-
|
|
9
|
+
collapsedGroupIds: usePlayerStore((s) => s.collapsedGroupIds),
|
|
10
10
|
expandedLaneOwnerIds: usePlayerStore((s) => s.expandedLaneOwnerIds),
|
|
11
11
|
toggleGroupExpanded: usePlayerStore((s) => s.toggleGroupExpanded),
|
|
12
12
|
toggleLaneOwnerExpanded: usePlayerStore((s) => s.toggleLaneOwnerExpanded),
|
|
@@ -35,7 +35,7 @@ interface TimelineLogicalFocusInput {
|
|
|
35
35
|
|
|
36
36
|
export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
|
|
37
37
|
const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
|
|
38
|
-
const
|
|
38
|
+
const collapsedGroupIds = usePlayerStore((state) => state.collapsedGroupIds);
|
|
39
39
|
const expandedLaneOwnerIds = usePlayerStore((state) => state.expandedLaneOwnerIds);
|
|
40
40
|
const projectId = usePlayerStore((state) => state.timelineProjectId);
|
|
41
41
|
const logicalRows = useTimelineLogicalRows({
|
|
@@ -45,7 +45,7 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
|
|
|
45
45
|
selectedElementId: input.selectedElementId,
|
|
46
46
|
selectedElementIds: input.selectedElementIds,
|
|
47
47
|
expandedClipIds,
|
|
48
|
-
|
|
48
|
+
collapsedGroupIds,
|
|
49
49
|
expandedLaneOwnerIds,
|
|
50
50
|
groups: input.groups,
|
|
51
51
|
trackGroupOf: input.trackGroupOf,
|
|
@@ -22,7 +22,7 @@ const displayTrackOrder = tracks.map(([track]) => track);
|
|
|
22
22
|
const laneCounts = new Map<string, number>();
|
|
23
23
|
const selectedElementIds = new Set<string>();
|
|
24
24
|
const expandedClipIds = new Set<string>();
|
|
25
|
-
const
|
|
25
|
+
const collapsedGroupIds = new Set<string>();
|
|
26
26
|
const expandedLaneOwnerIds = new Set<string>();
|
|
27
27
|
const groups: never[] = [];
|
|
28
28
|
const trackGroupOf = new Map();
|
|
@@ -37,7 +37,7 @@ function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]
|
|
|
37
37
|
selectedElementId: null,
|
|
38
38
|
selectedElementIds,
|
|
39
39
|
expandedClipIds,
|
|
40
|
-
|
|
40
|
+
collapsedGroupIds,
|
|
41
41
|
expandedLaneOwnerIds,
|
|
42
42
|
groups,
|
|
43
43
|
trackGroupOf,
|
|
@@ -14,7 +14,7 @@ export function useTimelineLogicalRows({
|
|
|
14
14
|
selectedElementId,
|
|
15
15
|
selectedElementIds,
|
|
16
16
|
expandedClipIds,
|
|
17
|
-
|
|
17
|
+
collapsedGroupIds,
|
|
18
18
|
expandedLaneOwnerIds,
|
|
19
19
|
groups,
|
|
20
20
|
trackGroupOf,
|
|
@@ -29,7 +29,7 @@ export function useTimelineLogicalRows({
|
|
|
29
29
|
selectedElementId,
|
|
30
30
|
selectedElementIds,
|
|
31
31
|
expandedClipIds,
|
|
32
|
-
|
|
32
|
+
collapsedGroupIds,
|
|
33
33
|
expandedLaneOwnerIds,
|
|
34
34
|
groups,
|
|
35
35
|
trackGroupOf,
|
|
@@ -38,7 +38,7 @@ export function useTimelineLogicalRows({
|
|
|
38
38
|
[
|
|
39
39
|
displayTrackOrder,
|
|
40
40
|
expandedClipIds,
|
|
41
|
-
|
|
41
|
+
collapsedGroupIds,
|
|
42
42
|
expandedLaneOwnerIds,
|
|
43
43
|
groups,
|
|
44
44
|
trackGroupOf,
|