@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
|
@@ -130,6 +130,121 @@ describe("useDomSelection — Variables tab preservation", () => {
|
|
|
130
130
|
});
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
describe("useDomSelection — canvas-only targets replace timeline clips", () => {
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
deferreds.clear();
|
|
136
|
+
usePlayerStore.getState().clearSelection();
|
|
137
|
+
});
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
deferreds.clear();
|
|
140
|
+
usePlayerStore.getState().clearSelection();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("deselects every clip when an audio bus is selected", () => {
|
|
144
|
+
const store = usePlayerStore.getState();
|
|
145
|
+
store.setSelectedElementId("voice-1");
|
|
146
|
+
store.setSelectedElementIds(new Set(["voice-1", "voice-2"]));
|
|
147
|
+
|
|
148
|
+
const bus = document.createElement("hf-audio-group");
|
|
149
|
+
bus.id = "voiceover";
|
|
150
|
+
const harness = renderHarness({
|
|
151
|
+
rightPanelTab: "design",
|
|
152
|
+
setRightPanelTab: vi.fn(),
|
|
153
|
+
iframe: null,
|
|
154
|
+
timelineElements: [
|
|
155
|
+
{ id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 },
|
|
156
|
+
{ id: "voice-2", tag: "audio", start: 1, duration: 1, track: 1 },
|
|
157
|
+
],
|
|
158
|
+
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
|
|
159
|
+
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
act(() => harness.current().applyDomSelection(makeSelection("Voiceover", bus)));
|
|
163
|
+
|
|
164
|
+
expect(harness.current().domEditSelection?.id).toBe("voiceover");
|
|
165
|
+
expect(usePlayerStore.getState().selectedElementId).toBeNull();
|
|
166
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
|
|
167
|
+
harness.cleanup();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("lets a bus supersede a clip selection that is still resolving", async () => {
|
|
171
|
+
const iframe = document.createElement("iframe");
|
|
172
|
+
document.body.append(iframe);
|
|
173
|
+
const doc = iframe.contentDocument!;
|
|
174
|
+
const clipNode = doc.createElement("audio");
|
|
175
|
+
clipNode.id = "voice-1";
|
|
176
|
+
const busNode = doc.createElement("hf-audio-group");
|
|
177
|
+
busNode.id = "voiceover";
|
|
178
|
+
doc.body.append(clipNode, busNode);
|
|
179
|
+
|
|
180
|
+
const clip: TimelineElement = {
|
|
181
|
+
id: "voice-1",
|
|
182
|
+
domId: "voice-1",
|
|
183
|
+
tag: "audio",
|
|
184
|
+
start: 0,
|
|
185
|
+
duration: 1,
|
|
186
|
+
track: 0,
|
|
187
|
+
};
|
|
188
|
+
const bus: TimelineElement = {
|
|
189
|
+
id: "voiceover",
|
|
190
|
+
domId: "voiceover",
|
|
191
|
+
tag: "audio",
|
|
192
|
+
start: 0,
|
|
193
|
+
duration: 10,
|
|
194
|
+
track: -0.5,
|
|
195
|
+
};
|
|
196
|
+
const harness = renderHarness({
|
|
197
|
+
rightPanelTab: "design",
|
|
198
|
+
setRightPanelTab: vi.fn(),
|
|
199
|
+
iframe,
|
|
200
|
+
// The bus is a synthetic row target, not a clip in the store.
|
|
201
|
+
timelineElements: [clip],
|
|
202
|
+
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
|
|
203
|
+
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
let pendingClip = Promise.resolve();
|
|
207
|
+
let pendingBus = Promise.resolve();
|
|
208
|
+
act(() => {
|
|
209
|
+
pendingClip = harness.current().handleTimelineElementSelect(clip);
|
|
210
|
+
pendingBus = harness.current().handleTimelineElementSelect(bus);
|
|
211
|
+
});
|
|
212
|
+
await act(async () => {
|
|
213
|
+
deferreds.get("voiceover")?.resolve();
|
|
214
|
+
await pendingBus;
|
|
215
|
+
deferreds.get("voice-1")?.resolve();
|
|
216
|
+
await pendingClip;
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(harness.current().domEditSelection?.id).toBe("voiceover");
|
|
220
|
+
expect(usePlayerStore.getState().selectedElementId).toBeNull();
|
|
221
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
|
|
222
|
+
harness.cleanup();
|
|
223
|
+
iframe.remove();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("preserves clip context for a non-bus canvas-only selection", () => {
|
|
227
|
+
const store = usePlayerStore.getState();
|
|
228
|
+
store.setSelectedElementId("voice-1");
|
|
229
|
+
const decoration = document.createElement("div");
|
|
230
|
+
decoration.id = "decoration";
|
|
231
|
+
const harness = renderHarness({
|
|
232
|
+
rightPanelTab: "design",
|
|
233
|
+
setRightPanelTab: vi.fn(),
|
|
234
|
+
iframe: null,
|
|
235
|
+
timelineElements: [{ id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 }],
|
|
236
|
+
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
|
|
237
|
+
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
act(() => harness.current().applyDomSelection(makeSelection("Decoration", decoration)));
|
|
241
|
+
|
|
242
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("voice-1");
|
|
243
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["voice-1"]));
|
|
244
|
+
harness.cleanup();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
133
248
|
describe("useDomSelection — timeline-select race guard", () => {
|
|
134
249
|
beforeEach(() => deferreds.clear());
|
|
135
250
|
afterEach(() => deferreds.clear());
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
2
|
import type { TimelineElement } from "../player/store/timelineElement";
|
|
3
|
+
import { getEffectiveTimelineDuration } from "../player/components/timelineViewModel";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The stored `duration` lags a moment behind an edit that pushes an element
|
|
@@ -10,11 +11,12 @@ export function useEffectiveTimelineDuration(
|
|
|
10
11
|
timelineDuration: number,
|
|
11
12
|
timelineElements: readonly TimelineElement[],
|
|
12
13
|
): number {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
// Delegates to `getEffectiveTimelineDuration` rather than restating the
|
|
15
|
+
// arithmetic: that one guards a non-finite stored duration and a non-finite
|
|
16
|
+
// result (an element with NaN timing), which this copy did not — it would
|
|
17
|
+
// return NaN and every downstream width became NaN with it.
|
|
18
|
+
return useMemo(
|
|
19
|
+
() => getEffectiveTimelineDuration(timelineDuration, timelineElements),
|
|
20
|
+
[timelineDuration, timelineElements],
|
|
21
|
+
);
|
|
20
22
|
}
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* dragged as well as while it is playing.
|
|
13
13
|
*/
|
|
14
14
|
import { useEffect, useRef, useState } from "react";
|
|
15
|
-
|
|
15
|
+
// The store's own module, not the `player` barrel: the barrel pulls the whole
|
|
16
|
+
// timeline in, and a timeline component importing this hook closes a cycle.
|
|
17
|
+
import { liveTime, usePlayerStore } from "../player/store/playerStore";
|
|
16
18
|
|
|
17
19
|
/** Long enough to be much cheaper than a frame, short enough to read as motion. */
|
|
18
20
|
const THROTTLE_MS = 33;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import { waitForMediaJob } from "../components/studioMediaJobs";
|
|
3
|
+
import type { BackgroundRemovalProgress } from "../components/editor/propertyPanelTypes";
|
|
4
|
+
|
|
5
|
+
interface RemoveBackgroundOptions {
|
|
6
|
+
createBackgroundPlate?: boolean;
|
|
7
|
+
quality?: "fast" | "balanced" | "best";
|
|
8
|
+
onProgress?: (progress: BackgroundRemovalProgress) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One removal in flight at a time: starting a second one aborts whichever job
|
|
13
|
+
* is still running, so a stale progress callback can't overwrite a newer
|
|
14
|
+
* result. Unmounting aborts too, or the job would keep running against a
|
|
15
|
+
* panel that is no longer there to show its progress.
|
|
16
|
+
*/
|
|
17
|
+
export function useRemoveBackground(
|
|
18
|
+
projectId: string,
|
|
19
|
+
refreshFileTree: () => Promise<void>,
|
|
20
|
+
showToast: (message: string, kind?: "info" | "error") => void,
|
|
21
|
+
) {
|
|
22
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
23
|
+
|
|
24
|
+
useEffect(
|
|
25
|
+
() => () => {
|
|
26
|
+
abortRef.current?.abort();
|
|
27
|
+
},
|
|
28
|
+
[],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return useCallback(
|
|
32
|
+
// fallow-ignore-next-line complexity
|
|
33
|
+
async (inputPath: string, options: RemoveBackgroundOptions) => {
|
|
34
|
+
const response = await fetch(
|
|
35
|
+
`/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
|
|
36
|
+
{
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
inputPath,
|
|
41
|
+
createBackgroundPlate: options.createBackgroundPlate === true,
|
|
42
|
+
quality: options.quality ?? "balanced",
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
const data = (await response.json().catch(() => ({}))) as {
|
|
47
|
+
jobId?: string;
|
|
48
|
+
error?: string;
|
|
49
|
+
};
|
|
50
|
+
if (!response.ok || !data.jobId) {
|
|
51
|
+
throw new Error(data.error || `Background removal failed (${response.status})`);
|
|
52
|
+
}
|
|
53
|
+
showToast("Removing background...", "info");
|
|
54
|
+
abortRef.current?.abort();
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
abortRef.current = controller;
|
|
57
|
+
try {
|
|
58
|
+
const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal);
|
|
59
|
+
await refreshFileTree();
|
|
60
|
+
showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info");
|
|
61
|
+
return result;
|
|
62
|
+
} finally {
|
|
63
|
+
if (abortRef.current === controller) {
|
|
64
|
+
abortRef.current = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
[projectId, refreshFileTree, showToast],
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -20,25 +20,21 @@ import {
|
|
|
20
20
|
syncPreviewContentDuration,
|
|
21
21
|
} from "./timelineTimingSync";
|
|
22
22
|
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
23
|
-
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
|
24
23
|
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
|
25
|
-
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
26
24
|
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
|
25
|
+
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
|
26
|
+
import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
|
|
27
27
|
import {
|
|
28
|
-
useAudioGroupCarveAssignment,
|
|
29
28
|
useTimelineElementVisibilityEditing,
|
|
30
29
|
useTimelineTrackVisibilityEditing,
|
|
31
30
|
} from "./timelineTrackVisibility";
|
|
32
31
|
import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
|
|
32
|
+
import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
|
|
33
33
|
import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
|
34
34
|
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
|
35
|
-
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
35
|
+
import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
36
36
|
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
37
37
|
|
|
38
|
-
type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
|
|
39
|
-
stackingReorder?: TimelineStackingReorderIntent | null;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
38
|
export function useTimelineEditing({
|
|
43
39
|
projectId,
|
|
44
40
|
activeCompPath,
|
|
@@ -61,9 +57,7 @@ export function useTimelineEditing({
|
|
|
61
57
|
}: UseTimelineEditingOptions) {
|
|
62
58
|
const projectIdRef = useRef(projectId);
|
|
63
59
|
projectIdRef.current = projectId;
|
|
64
|
-
|
|
65
60
|
const editQueueRef = useRef(Promise.resolve());
|
|
66
|
-
const lastBlockedTimelineToastAtRef = useRef(0);
|
|
67
61
|
|
|
68
62
|
const enqueueEdit = useCallback(
|
|
69
63
|
(
|
|
@@ -451,15 +445,7 @@ export function useTimelineEditing({
|
|
|
451
445
|
observeProjectFileVersion,
|
|
452
446
|
});
|
|
453
447
|
|
|
454
|
-
const handleBlockedTimelineEdit =
|
|
455
|
-
(_element: TimelineElement) => {
|
|
456
|
-
const now = Date.now();
|
|
457
|
-
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
|
|
458
|
-
lastBlockedTimelineToastAtRef.current = now;
|
|
459
|
-
showToast("This clip can't be moved or resized from the timeline yet.", "info");
|
|
460
|
-
},
|
|
461
|
-
[showToast],
|
|
462
|
-
);
|
|
448
|
+
const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
|
|
463
449
|
|
|
464
450
|
const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
|
|
465
451
|
projectId,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { MutableRefObject, RefObject } from "react";
|
|
2
2
|
import type { Composition } from "@hyperframes/sdk";
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
|
+
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
|
4
5
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
6
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
6
7
|
|
|
@@ -55,3 +56,9 @@ export type TimelineFileDropHandler = (
|
|
|
55
56
|
files: File[],
|
|
56
57
|
placement?: { start: number; track: number },
|
|
57
58
|
) => Promise<void>;
|
|
59
|
+
|
|
60
|
+
/** What a timeline move commits: the new start and track, plus the z-index
|
|
61
|
+
* reorder a vertical drag resolves to (absent for a pure horizontal move). */
|
|
62
|
+
export type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
|
|
63
|
+
stackingReorder?: TimelineStackingReorderIntent | null;
|
|
64
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** The base automation envelope plus the heavier segment grab affordance. */
|
|
2
|
+
|
|
3
|
+
import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
|
|
4
|
+
import { envelopeSegmentPath } from "./automationLaneGeometry";
|
|
5
|
+
|
|
6
|
+
interface AutomationEnvelopePathsProps {
|
|
7
|
+
path: string;
|
|
8
|
+
lane: HfAutomationLane;
|
|
9
|
+
range: AutomationRange;
|
|
10
|
+
accentColor: string;
|
|
11
|
+
activeSegment: number | null;
|
|
12
|
+
xOf(t: number): number;
|
|
13
|
+
yOf(v: number): number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function AutomationEnvelopePaths({
|
|
17
|
+
path,
|
|
18
|
+
lane,
|
|
19
|
+
range,
|
|
20
|
+
accentColor,
|
|
21
|
+
activeSegment,
|
|
22
|
+
xOf,
|
|
23
|
+
yOf,
|
|
24
|
+
}: AutomationEnvelopePathsProps) {
|
|
25
|
+
const activePath =
|
|
26
|
+
activeSegment === null
|
|
27
|
+
? null
|
|
28
|
+
: envelopeSegmentPath({ lane, range, index: activeSegment, xOf, yOf });
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<path
|
|
33
|
+
data-automation-envelope=""
|
|
34
|
+
d={path}
|
|
35
|
+
fill="none"
|
|
36
|
+
stroke={accentColor}
|
|
37
|
+
strokeWidth={1.5}
|
|
38
|
+
opacity={lane.points.length === 0 ? 0.35 : 0.95}
|
|
39
|
+
/>
|
|
40
|
+
{activePath ? (
|
|
41
|
+
<path
|
|
42
|
+
data-automation-segment-active={activeSegment ?? undefined}
|
|
43
|
+
d={activePath}
|
|
44
|
+
fill="none"
|
|
45
|
+
stroke={accentColor}
|
|
46
|
+
strokeWidth={3}
|
|
47
|
+
strokeLinecap="round"
|
|
48
|
+
opacity={1}
|
|
49
|
+
pointerEvents="none"
|
|
50
|
+
/>
|
|
51
|
+
) : null}
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -41,6 +41,10 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
|
|
|
41
41
|
return createPortal(
|
|
42
42
|
<div
|
|
43
43
|
ref={menuRef}
|
|
44
|
+
// z-[200] for the same reason the timeline's FX popover uses it: this is
|
|
45
|
+
// portaled to `document.body`, but the ruler's sticky header sits at z-70
|
|
46
|
+
// in the SAME root stacking context, so a z-50 menu opened near the top of
|
|
47
|
+
// the timeline is painted through by the ruler and the playhead.
|
|
44
48
|
className="hf-automation-menu fixed z-[200] min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
|
|
45
49
|
style={{ left: adjustedX, top: adjustedY }}
|
|
46
50
|
>
|
|
@@ -1,11 +1,56 @@
|
|
|
1
1
|
import { TRACK_H } from "./timelineLayout";
|
|
2
2
|
import { TrackClipCount } from "./TrackClipCount";
|
|
3
3
|
|
|
4
|
-
// Layer row (
|
|
4
|
+
// Layer row (diamond, name, then the ∿ disclosure on the right edge) — the
|
|
5
|
+
// disclosure lives
|
|
5
6
|
// here, not on the clip bar, and re-expands a collapsed layer. `∿` (not a
|
|
6
7
|
// caret) because a group's own row keeps the caret for its structural
|
|
7
8
|
// disclosure (member rows) — this button only ever means "show this row's
|
|
8
9
|
// lanes", so it needs its own distinct glyph.
|
|
10
|
+
/**
|
|
11
|
+
* The `∿` that shows or hides a row's lanes.
|
|
12
|
+
*
|
|
13
|
+
* Shared, because two layouts need the identical control: the keyframe layer
|
|
14
|
+
* row below, and the plain track header — an audio track with automation keeps
|
|
15
|
+
* its own look (music glyph, indent) and gains this, rather than being
|
|
16
|
+
* re-rendered as a keyframe layer to get at the button.
|
|
17
|
+
*/
|
|
18
|
+
export function LaneToggleButton({
|
|
19
|
+
name,
|
|
20
|
+
isExpanded,
|
|
21
|
+
lanesId,
|
|
22
|
+
onToggle,
|
|
23
|
+
}: {
|
|
24
|
+
name: string;
|
|
25
|
+
isExpanded: boolean;
|
|
26
|
+
lanesId: string;
|
|
27
|
+
onToggle: () => void;
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
// ponytail: No focus id here; keyboard routing belongs to the enclosing logical row.
|
|
33
|
+
tabIndex={-1}
|
|
34
|
+
aria-expanded={isExpanded}
|
|
35
|
+
aria-controls={lanesId}
|
|
36
|
+
aria-label={`${isExpanded ? "Hide" : "Show"} ${name} lanes`}
|
|
37
|
+
title={`${isExpanded ? "Hide" : "Show"} lanes`}
|
|
38
|
+
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
|
|
39
|
+
// only the hit box grows.
|
|
40
|
+
className={`ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
|
41
|
+
isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
|
|
42
|
+
}`}
|
|
43
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
44
|
+
onClick={(event) => {
|
|
45
|
+
event.stopPropagation();
|
|
46
|
+
onToggle();
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<span aria-hidden="true">∿</span>
|
|
50
|
+
</button>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
9
54
|
export function LayerDisclosureRow({
|
|
10
55
|
name,
|
|
11
56
|
clipCount,
|
|
@@ -45,37 +90,26 @@ export function LayerDisclosureRow({
|
|
|
45
90
|
background: gutterBackground,
|
|
46
91
|
}}
|
|
47
92
|
>
|
|
48
|
-
<button
|
|
49
|
-
type="button"
|
|
50
|
-
// ponytail: No focus id here; keyboard routing belongs to the enclosing logical row.
|
|
51
|
-
tabIndex={-1}
|
|
52
|
-
aria-expanded={isExpanded}
|
|
53
|
-
aria-controls={lanesId}
|
|
54
|
-
aria-label={`${isExpanded ? "Hide" : "Show"} ${name} lanes`}
|
|
55
|
-
title={`${isExpanded ? "Hide" : "Show"} lanes`}
|
|
56
|
-
// h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
|
|
57
|
-
// only the hit box grows.
|
|
58
|
-
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
|
|
59
|
-
isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
|
|
60
|
-
}`}
|
|
61
|
-
onPointerDown={(event) => event.stopPropagation()}
|
|
62
|
-
onClick={(event) => {
|
|
63
|
-
event.stopPropagation();
|
|
64
|
-
onToggleClipExpanded();
|
|
65
|
-
}}
|
|
66
|
-
>
|
|
67
|
-
<span aria-hidden="true">∿</span>
|
|
68
|
-
</button>
|
|
69
93
|
{/* Decorative: the disclosure button above already names the row's keyframe
|
|
70
94
|
state, and aria-label on a plain span is not exposed reliably anyway. */}
|
|
71
95
|
<span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
|
|
72
96
|
◇
|
|
73
97
|
</span>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
</span>
|
|
98
|
+
{/* Wraps rather than truncating: a truncated name needs a hover to be
|
|
99
|
+
read, which a scanned column cannot rely on. */}
|
|
100
|
+
<span className="min-w-0 flex-1 break-words font-medium leading-tight">{name}</span>
|
|
77
101
|
<TrackClipCount clipCount={clipCount} />
|
|
78
102
|
{children}
|
|
103
|
+
{/* Anchored right, on every header that has one: the lane toggle is the
|
|
104
|
+
row's last word about itself, and a left-hand ∿ put it where the eye
|
|
105
|
+
looks for identity instead. `ml-auto` rather than a spacer so it holds
|
|
106
|
+
the edge whatever else the row grows. */}
|
|
107
|
+
<LaneToggleButton
|
|
108
|
+
name={name}
|
|
109
|
+
isExpanded={isExpanded}
|
|
110
|
+
lanesId={lanesId}
|
|
111
|
+
onToggle={onToggleClipExpanded}
|
|
112
|
+
/>
|
|
79
113
|
</div>
|
|
80
114
|
);
|
|
81
115
|
}
|
|
@@ -6,7 +6,6 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
|
6
6
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
7
7
|
import { Tooltip } from "../../components/ui";
|
|
8
8
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
9
|
-
import { useSoloBannerText } from "../../hooks/useAudioSoloBridge";
|
|
10
9
|
import { ShortcutsPanel } from "./ShortcutsPanel";
|
|
11
10
|
import { SpeedMenu } from "./SpeedMenu";
|
|
12
11
|
import { VolumeControl } from "./VolumeControl";
|
|
@@ -156,47 +155,12 @@ const FullscreenButton = memo(function FullscreenButton({
|
|
|
156
155
|
|
|
157
156
|
/* ── Main component ──────────────────────────────────────────────── */
|
|
158
157
|
|
|
159
|
-
/**
|
|
160
|
-
* "Hearing only this" notice. Solo is a PREVIEW-only gate — it never touches an
|
|
161
|
-
* attribute and never reaches the export — so the state has to say so out loud,
|
|
162
|
-
* or a soloed session reads as a broken mix.
|
|
163
|
-
*/
|
|
164
|
-
const SoloBanner = memo(function SoloBanner({
|
|
165
|
-
previewIframeRef,
|
|
166
|
-
}: {
|
|
167
|
-
previewIframeRef: { current: HTMLIFrameElement | null };
|
|
168
|
-
}) {
|
|
169
|
-
const bannerText = useSoloBannerText(previewIframeRef);
|
|
170
|
-
const clearSolo = usePlayerStore.getState().clearSolo;
|
|
171
|
-
if (bannerText === null) return null;
|
|
172
|
-
return (
|
|
173
|
-
<div
|
|
174
|
-
role="status"
|
|
175
|
-
className="flex h-7 items-center justify-center gap-2 border-b border-neutral-800 bg-neutral-900/90 px-3 text-[11px] text-neutral-300"
|
|
176
|
-
>
|
|
177
|
-
<span>
|
|
178
|
-
Hearing only <span className="font-medium text-neutral-100">{bannerText}</span> — your
|
|
179
|
-
export is not affected
|
|
180
|
-
</span>
|
|
181
|
-
<button
|
|
182
|
-
type="button"
|
|
183
|
-
onClick={() => clearSolo()}
|
|
184
|
-
className="rounded px-1.5 py-0.5 font-medium text-studio-accent transition-colors hover:text-white"
|
|
185
|
-
>
|
|
186
|
-
Clear
|
|
187
|
-
</button>
|
|
188
|
-
</div>
|
|
189
|
-
);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
158
|
interface PlayerControlsProps {
|
|
193
159
|
onTogglePlay: () => void;
|
|
194
160
|
onSeek: (time: number) => void;
|
|
195
161
|
disabled?: boolean;
|
|
196
162
|
isFullscreen?: boolean;
|
|
197
163
|
onToggleFullscreen?: () => void;
|
|
198
|
-
/** Needed to read the soloed clips' labels out of the preview document. */
|
|
199
|
-
previewIframeRef?: { current: HTMLIFrameElement | null };
|
|
200
164
|
}
|
|
201
165
|
|
|
202
166
|
export const PlayerControls = memo(function PlayerControls({
|
|
@@ -205,7 +169,6 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
205
169
|
disabled = false,
|
|
206
170
|
isFullscreen = false,
|
|
207
171
|
onToggleFullscreen,
|
|
208
|
-
previewIframeRef,
|
|
209
172
|
}: PlayerControlsProps) {
|
|
210
173
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
211
174
|
const duration = usePlayerStore((s) => s.duration);
|
|
@@ -258,7 +221,6 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
258
221
|
|
|
259
222
|
return (
|
|
260
223
|
<div>
|
|
261
|
-
{previewIframeRef && <SoloBanner previewIframeRef={previewIframeRef} />}
|
|
262
224
|
<div
|
|
263
225
|
className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
|
|
264
226
|
aria-disabled={disabled || undefined}
|
|
@@ -299,6 +299,42 @@ describe("Timeline provider boundary", () => {
|
|
|
299
299
|
act(() => root.unmount());
|
|
300
300
|
});
|
|
301
301
|
|
|
302
|
+
// The same assertion, with a group present. It passed on an empty fixture
|
|
303
|
+
// while TimelineGroupRow called the THROWING context hook — one grouped clip
|
|
304
|
+
// and the whole timeline render died, not just the row.
|
|
305
|
+
it("renders without the provider even when a group row is on screen", () => {
|
|
306
|
+
const host = createSizedTimelineHost(640);
|
|
307
|
+
usePlayerStore.setState({
|
|
308
|
+
duration: 4,
|
|
309
|
+
timelineReady: true,
|
|
310
|
+
elements: [
|
|
311
|
+
{
|
|
312
|
+
id: "voice-1",
|
|
313
|
+
domId: "voice-1",
|
|
314
|
+
tag: "audio",
|
|
315
|
+
start: 0,
|
|
316
|
+
duration: 2,
|
|
317
|
+
track: 0,
|
|
318
|
+
audioGroup: "voiceover",
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
id: "voice-2",
|
|
322
|
+
domId: "voice-2",
|
|
323
|
+
tag: "audio",
|
|
324
|
+
start: 2,
|
|
325
|
+
duration: 2,
|
|
326
|
+
track: 1,
|
|
327
|
+
audioGroup: "voiceover",
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
});
|
|
331
|
+
const root = createRoot(host);
|
|
332
|
+
act(() => root.render(React.createElement(Timeline)));
|
|
333
|
+
|
|
334
|
+
expect(host.querySelector('[role="treegrid"]')).not.toBeNull();
|
|
335
|
+
act(() => root.unmount());
|
|
336
|
+
});
|
|
337
|
+
|
|
302
338
|
it("renders the complete track list while row virtualization is gated off", () => {
|
|
303
339
|
const host = createSizedTimelineHost(640);
|
|
304
340
|
usePlayerStore.setState({
|
|
@@ -389,7 +425,10 @@ describe("Timeline provider boundary", () => {
|
|
|
389
425
|
button.click();
|
|
390
426
|
});
|
|
391
427
|
|
|
392
|
-
|
|
428
|
+
// Up from the rowheader rather than counting parents: the header now lays
|
|
429
|
+
// its name and its controls out on two lines, so the button sits one level
|
|
430
|
+
// deeper than it used to.
|
|
431
|
+
const row = button.closest('[role="rowheader"]')?.parentElement;
|
|
393
432
|
// Row children: [TimelineTrackHeader (sticky column), time-mapped content].
|
|
394
433
|
const trackContent = row?.children.item(1);
|
|
395
434
|
expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false, 1);
|
|
@@ -33,7 +33,7 @@ import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemet
|
|
|
33
33
|
import {
|
|
34
34
|
getEffectiveTimelineDuration,
|
|
35
35
|
getTimelinePreviewElement,
|
|
36
|
-
|
|
36
|
+
timelineNeedsLabelColumn,
|
|
37
37
|
} from "./timelineViewModel";
|
|
38
38
|
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
|
|
39
39
|
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
|
|
@@ -115,7 +115,10 @@ export const Timeline = memo(function Timeline({
|
|
|
115
115
|
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
|
116
116
|
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
|
117
117
|
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
|
|
118
|
-
const labelMode = useMemo(
|
|
118
|
+
const labelMode = useMemo(
|
|
119
|
+
() => timelineNeedsLabelColumn(gsapAnimations, expandedElements),
|
|
120
|
+
[gsapAnimations, expandedElements],
|
|
121
|
+
);
|
|
119
122
|
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
|
|
120
123
|
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
|
|
121
124
|
const contentGutter = labelMode ? GUTTER : 0;
|