@hyperframes/studio 0.8.11 → 0.8.13
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-C6J6fsAD.js → hyperframes-player-BP9rShvS.js} +1 -1
- package/dist/assets/{index-1vMlv05L.js → index-CBm9bYHQ.js} +1 -1
- package/dist/assets/{index-BX0piiWc.js → index-CDerRsMw.js} +1 -1
- package/dist/assets/index-CQMHN4nk.js +431 -0
- 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 +7646 -6514
- 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/domSelectionTimelineMirror.ts +12 -3
- 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/useDomSelectionSelectionGuards.test.ts +115 -0
- 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/AutomationEnvelopePaths.tsx +54 -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 +180 -0
- package/src/player/components/TimelineAutomationLane.tsx +129 -36
- package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
- package/src/player/components/TimelineFxButton.test.tsx +167 -21
- package/src/player/components/TimelineFxButton.tsx +156 -31
- package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
- package/src/player/components/TimelineGroupHeader.tsx +100 -102
- package/src/player/components/TimelineGroupLaneLabels.tsx +99 -0
- package/src/player/components/TimelineGroupRow.test.tsx +222 -0
- package/src/player/components/TimelineGroupRow.tsx +140 -71
- 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/automationLaneDragMath.ts +28 -0
- package/src/player/components/automationLaneGeometry.ts +52 -13
- 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/useAutomationLaneGestures.ts +66 -18
- package/src/player/components/useAutomationSegmentDrag.ts +110 -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-DoW5v5eS.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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { groupAutomationElement } from "./groupAutomationElement";
|
|
3
|
+
import { groupAutomationLanes } from "./automationLaneData";
|
|
4
|
+
|
|
5
|
+
const GROUP = {
|
|
6
|
+
id: "voiceover",
|
|
7
|
+
label: "Voiceover",
|
|
8
|
+
anchorKey: 1.5,
|
|
9
|
+
automation: JSON.stringify({
|
|
10
|
+
version: 1,
|
|
11
|
+
lanes: [
|
|
12
|
+
{
|
|
13
|
+
target: "volume",
|
|
14
|
+
points: [
|
|
15
|
+
{ t: 0, v: 1 },
|
|
16
|
+
{ t: 5, v: 0.4 },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("groupAutomationElement", () => {
|
|
24
|
+
// §1.3: "A group's automation clock is COMPOSITION time — decide it, do not
|
|
25
|
+
// inherit it." A clip-local span would land the group's fade at a different
|
|
26
|
+
// moment in preview than the render bakes it.
|
|
27
|
+
it("spans the whole composition from zero, so lane times are composition time", () => {
|
|
28
|
+
const el = groupAutomationElement(GROUP, 60);
|
|
29
|
+
expect(el.start).toBe(0);
|
|
30
|
+
expect(el.duration).toBe(60);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// The lane machinery filters with `isAudioTimelineElement`; a group that does
|
|
34
|
+
// not pass it renders nothing at all, silently.
|
|
35
|
+
it("is admitted by the lane machinery and yields the group's own lanes", () => {
|
|
36
|
+
const lanes = groupAutomationLanes([groupAutomationElement(GROUP, 60)]);
|
|
37
|
+
expect(lanes).toHaveLength(1);
|
|
38
|
+
expect(lanes[0]?.name).toBeTruthy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// The write has to land on `<hf-audio-group>`, not on a member clip.
|
|
42
|
+
it("carries the group's DOM id, so a lane edit addresses the group element", () => {
|
|
43
|
+
expect(groupAutomationElement(GROUP, 60).domId).toBe("voiceover");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// A group with no automation draws no lanes — and must not throw doing it.
|
|
47
|
+
it("yields no lanes when the group automates nothing", () => {
|
|
48
|
+
const bare = { id: "sfx", label: "SFX", anchorKey: 2.5 };
|
|
49
|
+
expect(groupAutomationLanes([groupAutomationElement(bare, 60)])).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The group, as the automation lanes see it.
|
|
3
|
+
*
|
|
4
|
+
* Lanes are keyed by `TimelineElement` everywhere — the slot, the binder, the
|
|
5
|
+
* identity used for selection. A group is not one: it has no clip id, no start
|
|
6
|
+
* and no duration, which is §1.9's "a group is the first real audio entity in
|
|
7
|
+
* the system" problem showing up in the row model. Rather than give lanes a
|
|
8
|
+
* second, parallel path, the group borrows the shape it does not have.
|
|
9
|
+
*
|
|
10
|
+
* `start: 0` and `duration` = the composition's is not a placeholder, it is the
|
|
11
|
+
* clock: the design doc fixes a group's automation clock as COMPOSITION time
|
|
12
|
+
* (§1.3), and a missing `data-start` parses as 0, which is exactly that. So a
|
|
13
|
+
* lane drawn against this element lands at the same seconds the render bakes.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
17
|
+
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
|
18
|
+
|
|
19
|
+
/** The synthetic element's track number — the group row's own anchor. */
|
|
20
|
+
export function groupAutomationElement(
|
|
21
|
+
group: Pick<TimelineTrackGroupInfo, "id" | "label" | "automation" | "fxChain" | "anchorKey">,
|
|
22
|
+
compositionDuration: number,
|
|
23
|
+
): TimelineElement {
|
|
24
|
+
return {
|
|
25
|
+
id: group.id,
|
|
26
|
+
// The DOM id, so a write lands on the `<hf-audio-group>` and not on a clip.
|
|
27
|
+
domId: group.id,
|
|
28
|
+
label: group.label,
|
|
29
|
+
// Audio, so `isAudioTimelineElement` admits it and the lanes render at all.
|
|
30
|
+
tag: "audio",
|
|
31
|
+
start: 0,
|
|
32
|
+
duration: compositionDuration,
|
|
33
|
+
track: group.anchorKey,
|
|
34
|
+
...(group.automation ? { automation: group.automation } : {}),
|
|
35
|
+
...(group.fxChain ? { fxChain: group.fxChain } : {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -73,7 +73,7 @@ export interface TimelineEditCallbacks {
|
|
|
73
73
|
* two different orderings: the header's comes from the group-aware row list
|
|
74
74
|
* (synthetic anchor rows, members pulled contiguous), the history's from a
|
|
75
75
|
* plain ascending sort of element-bearing keys. Once a group exists those
|
|
76
|
-
* disagree, so the same click said "
|
|
76
|
+
* disagree, so the same click said "Hide track 2" and recorded "Hide track 1".
|
|
77
77
|
* Passing the rendered number keeps one answer instead of two derivations.
|
|
78
78
|
*/
|
|
79
79
|
onToggleTrackHidden?: (
|
|
@@ -93,7 +93,11 @@ export interface TimelineEditCallbacks {
|
|
|
93
93
|
/** C1's ungrouped-track FX pointer: "Group these clips" — write
|
|
94
94
|
* `data-audio-group` on every one of them, atomically. Same shape B6's
|
|
95
95
|
* carve auto-grouping uses. */
|
|
96
|
-
onGroupClips?: (
|
|
96
|
+
onGroupClips?: (
|
|
97
|
+
clipIds: readonly string[],
|
|
98
|
+
groupId: string,
|
|
99
|
+
groupLabel?: string,
|
|
100
|
+
) => Promise<void>;
|
|
97
101
|
/** C1's single-clip FX write: addressed by the clip itself rather than the
|
|
98
102
|
* current selection, mirroring `onSetAudioGroupAttributeLive/Quiet`. */
|
|
99
103
|
onSetElementAttributeLive?: (
|
|
@@ -58,7 +58,7 @@ function model(overrides: Partial<Parameters<typeof buildTimelineLogicalRows>[0]
|
|
|
58
58
|
selectedElementId: "active",
|
|
59
59
|
selectedElementIds: new Set(),
|
|
60
60
|
expandedClipIds: new Set(["active"]),
|
|
61
|
-
|
|
61
|
+
collapsedGroupIds: new Set(),
|
|
62
62
|
expandedLaneOwnerIds: new Set(),
|
|
63
63
|
groups: [],
|
|
64
64
|
trackGroupOf: new Map(),
|
|
@@ -248,7 +248,7 @@ describe("resolveTimelineNavigationTarget", () => {
|
|
|
248
248
|
selectedElementId: null,
|
|
249
249
|
selectedElementIds: new Set(),
|
|
250
250
|
expandedClipIds: new Set(),
|
|
251
|
-
|
|
251
|
+
collapsedGroupIds: new Set(),
|
|
252
252
|
expandedLaneOwnerIds: new Set(),
|
|
253
253
|
groups: [],
|
|
254
254
|
trackGroupOf: new Map(),
|
|
@@ -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
|
+
}
|