@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
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
} from "../player/components/timelineTrackDisplay";
|
|
9
9
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
10
10
|
import { isAudioTimelineElement } from "../utils/timelineInspector";
|
|
11
|
-
import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
|
|
12
11
|
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
|
13
12
|
import {
|
|
14
13
|
applyPatchByTarget,
|
|
@@ -32,12 +31,8 @@ interface ToggleTimelineTrackHiddenInput {
|
|
|
32
31
|
timelineElements: readonly TimelineElement[];
|
|
33
32
|
track: number;
|
|
34
33
|
hidden: boolean;
|
|
35
|
-
/**
|
|
36
|
-
*
|
|
37
|
-
* Absent (a keyboard path or a programmatic call), the row is derived here
|
|
38
|
-
* from ascending element-bearing keys — correct whenever no group has
|
|
39
|
-
* reordered the header.
|
|
40
|
-
*/
|
|
34
|
+
/** The row the CLICKED control announced. Absent when the caller has no
|
|
35
|
+
* rendered number to hand over, which falls back to deriving one. */
|
|
41
36
|
displayNumber?: number | null;
|
|
42
37
|
previewIframe: HTMLIFrameElement | null;
|
|
43
38
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
@@ -110,7 +105,7 @@ function patchLiveHiddenState(
|
|
|
110
105
|
}
|
|
111
106
|
}
|
|
112
107
|
|
|
113
|
-
function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void {
|
|
108
|
+
export function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void {
|
|
114
109
|
try {
|
|
115
110
|
const win: (Window & { __player?: { seek?: (time: number) => void } }) | null =
|
|
116
111
|
iframe?.contentWindow ?? null;
|
|
@@ -118,7 +113,7 @@ function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void {
|
|
|
118
113
|
} catch {}
|
|
119
114
|
}
|
|
120
115
|
|
|
121
|
-
function groupElementsByTargetPath(
|
|
116
|
+
export function groupElementsByTargetPath(
|
|
122
117
|
elements: readonly TimelineElement[],
|
|
123
118
|
activeCompPath: string | null,
|
|
124
119
|
): Map<string, TimelineElement[]> {
|
|
@@ -227,7 +222,8 @@ export async function toggleTimelineTrackHidden({
|
|
|
227
222
|
// read by a human, so it gets the display row instead — the one the clicked
|
|
228
223
|
// control announced, when the caller passed it. Deriving it again here would
|
|
229
224
|
// use ascending element-bearing keys, which stop matching the header as soon
|
|
230
|
-
// as an audio group reorders the rows and inserts an anchor
|
|
225
|
+
// as an audio group reorders the rows and inserts an anchor: the same click
|
|
226
|
+
// then said "Mute track 2" and recorded "Mute track 1".
|
|
231
227
|
const suffix = trackDisplaySuffix(
|
|
232
228
|
displayNumber ?? trackDisplayNumber(timelineTrackOrder(timelineElements), track),
|
|
233
229
|
);
|
|
@@ -289,114 +285,6 @@ export async function toggleTimelineElementHidden({
|
|
|
289
285
|
});
|
|
290
286
|
}
|
|
291
287
|
|
|
292
|
-
function patchLiveAudioGroupState(
|
|
293
|
-
iframe: HTMLIFrameElement | null,
|
|
294
|
-
elements: readonly TimelineElement[],
|
|
295
|
-
groupId: string | null,
|
|
296
|
-
activeCompPath: string | null,
|
|
297
|
-
): void {
|
|
298
|
-
for (const element of elements) {
|
|
299
|
-
const target = findTimelineElementInIframe(iframe, element, activeCompPath);
|
|
300
|
-
if (!target) continue;
|
|
301
|
-
if (groupId) target.setAttribute(HF_AUDIO_GROUP_ATTR, groupId);
|
|
302
|
-
else target.removeAttribute(HF_AUDIO_GROUP_ATTR);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
interface CreateAudioGroupAndAssignMembersInput {
|
|
307
|
-
projectId: string;
|
|
308
|
-
activeCompPath: string | null;
|
|
309
|
-
elements: readonly TimelineElement[];
|
|
310
|
-
groupId: string;
|
|
311
|
-
previewIframe: HTMLIFrameElement | null;
|
|
312
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
313
|
-
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
314
|
-
domEditSaveTimestampRef: MutableRef<number>;
|
|
315
|
-
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Group two or more voice clips: write `data-audio-group="<groupId>"` on
|
|
320
|
-
* every one of them, atomically, one undo entry — the same multi-target shape
|
|
321
|
-
* `setElementsHidden` uses for mute. The group needs no `<hf-audio-group>`
|
|
322
|
-
* element of its own to exist: `resolveAudioGroups` already degrades
|
|
323
|
-
* gracefully to label = id when one is absent, and a naming dialog is out of
|
|
324
|
-
* scope here — the id itself is the default name.
|
|
325
|
-
*/
|
|
326
|
-
// fallow-ignore-next-line complexity
|
|
327
|
-
export async function createAudioGroupAndAssignMembers({
|
|
328
|
-
projectId,
|
|
329
|
-
activeCompPath,
|
|
330
|
-
elements,
|
|
331
|
-
groupId,
|
|
332
|
-
previewIframe,
|
|
333
|
-
writeProjectFile,
|
|
334
|
-
recordEdit,
|
|
335
|
-
domEditSaveTimestampRef,
|
|
336
|
-
pendingTimelineEditPathRef,
|
|
337
|
-
}: CreateAudioGroupAndAssignMembersInput): Promise<string[]> {
|
|
338
|
-
if (elements.length < 2) return [];
|
|
339
|
-
|
|
340
|
-
patchLiveAudioGroupState(previewIframe, elements, groupId, activeCompPath);
|
|
341
|
-
reseekPreviewRuntime(previewIframe);
|
|
342
|
-
|
|
343
|
-
const groupOperation: PatchOperation = {
|
|
344
|
-
type: "attribute",
|
|
345
|
-
property: HF_AUDIO_GROUP_ATTR,
|
|
346
|
-
value: groupId,
|
|
347
|
-
};
|
|
348
|
-
const originalByPath = new Map<string, string>();
|
|
349
|
-
const files: Record<string, string> = {};
|
|
350
|
-
|
|
351
|
-
try {
|
|
352
|
-
for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
|
|
353
|
-
let patchedContent = await readFileContent(projectId, targetPath);
|
|
354
|
-
originalByPath.set(targetPath, patchedContent);
|
|
355
|
-
|
|
356
|
-
for (const element of fileElements) {
|
|
357
|
-
const patchTarget = buildPatchTarget(element);
|
|
358
|
-
if (!patchTarget) {
|
|
359
|
-
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
360
|
-
}
|
|
361
|
-
if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
|
|
362
|
-
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
363
|
-
}
|
|
364
|
-
patchedContent = applyPatchByTarget(patchedContent, patchTarget, groupOperation);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
files[targetPath] = patchedContent;
|
|
368
|
-
pendingTimelineEditPathRef.current.add(targetPath);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
372
|
-
const changedPaths = await saveProjectFilesWithHistory({
|
|
373
|
-
projectId,
|
|
374
|
-
label: `Group ${elements.length} voice clips`,
|
|
375
|
-
kind: "timeline",
|
|
376
|
-
files,
|
|
377
|
-
readFile: async (path) => {
|
|
378
|
-
const original = originalByPath.get(path);
|
|
379
|
-
if (original !== undefined) return original;
|
|
380
|
-
return readFileContent(projectId, path);
|
|
381
|
-
},
|
|
382
|
-
writeFile: writeProjectFile,
|
|
383
|
-
recordEdit,
|
|
384
|
-
});
|
|
385
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
386
|
-
for (const element of elements) {
|
|
387
|
-
usePlayerStore.getState().updateElement(element.key ?? element.id, { audioGroup: groupId });
|
|
388
|
-
}
|
|
389
|
-
return changedPaths;
|
|
390
|
-
} catch (error) {
|
|
391
|
-
// Mirrors setElementsHidden's failure path: the optimistic live patch
|
|
392
|
-
// already ran, so a save failure has to be unwound or the preview shows a
|
|
393
|
-
// grouping that never made it to disk.
|
|
394
|
-
patchLiveAudioGroupState(previewIframe, elements, null, activeCompPath);
|
|
395
|
-
reseekPreviewRuntime(previewIframe);
|
|
396
|
-
throw error;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
288
|
export function useTimelineTrackVisibilityEditing({
|
|
401
289
|
projectIdRef,
|
|
402
290
|
activeCompPath,
|
|
@@ -532,67 +420,3 @@ export function useTimelineElementVisibilityEditing({
|
|
|
532
420
|
],
|
|
533
421
|
);
|
|
534
422
|
}
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* The write behind B6's auto-group: pick two or more voice clips in the carve
|
|
538
|
-
* picker and they land in a group instead of naming each other by id. Same
|
|
539
|
-
* expanded-rows resolution as element-visibility, for the same reason — a
|
|
540
|
-
* nested sub-composition child has no entry in the raw store list.
|
|
541
|
-
*/
|
|
542
|
-
export function useAudioGroupCarveAssignment({
|
|
543
|
-
projectIdRef,
|
|
544
|
-
activeCompPath,
|
|
545
|
-
showToast,
|
|
546
|
-
writeProjectFile,
|
|
547
|
-
recordEdit,
|
|
548
|
-
domEditSaveTimestampRef,
|
|
549
|
-
previewIframeRef,
|
|
550
|
-
pendingTimelineEditPathRef,
|
|
551
|
-
isRecordingRef,
|
|
552
|
-
}: UseTimelineElementVisibilityEditingInput): (
|
|
553
|
-
clipIds: readonly string[],
|
|
554
|
-
groupId: string,
|
|
555
|
-
) => Promise<void> {
|
|
556
|
-
const expandedElements = useExpandedTimelineElements();
|
|
557
|
-
return useCallback(
|
|
558
|
-
async (clipIds: readonly string[], groupId: string) => {
|
|
559
|
-
if (isRecordingRef?.current) {
|
|
560
|
-
showToast("Cannot edit timeline while recording", "error");
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
563
|
-
const pid = projectIdRef.current;
|
|
564
|
-
if (!pid) return;
|
|
565
|
-
const keys = new Set(clipIds);
|
|
566
|
-
const elements = expandedElements.filter((item) => keys.has(item.key ?? item.id));
|
|
567
|
-
try {
|
|
568
|
-
await createAudioGroupAndAssignMembers({
|
|
569
|
-
projectId: pid,
|
|
570
|
-
activeCompPath,
|
|
571
|
-
elements,
|
|
572
|
-
groupId,
|
|
573
|
-
previewIframe: previewIframeRef.current,
|
|
574
|
-
writeProjectFile,
|
|
575
|
-
recordEdit,
|
|
576
|
-
domEditSaveTimestampRef,
|
|
577
|
-
pendingTimelineEditPathRef,
|
|
578
|
-
});
|
|
579
|
-
} catch (error) {
|
|
580
|
-
console.error("[Timeline] Failed to group voice clips", error);
|
|
581
|
-
const message = error instanceof Error ? error.message : "Failed to group voice clips";
|
|
582
|
-
showToast(message);
|
|
583
|
-
}
|
|
584
|
-
},
|
|
585
|
-
[
|
|
586
|
-
activeCompPath,
|
|
587
|
-
expandedElements,
|
|
588
|
-
previewIframeRef,
|
|
589
|
-
writeProjectFile,
|
|
590
|
-
recordEdit,
|
|
591
|
-
domEditSaveTimestampRef,
|
|
592
|
-
pendingTimelineEditPathRef,
|
|
593
|
-
isRecordingRef,
|
|
594
|
-
showToast,
|
|
595
|
-
projectIdRef,
|
|
596
|
-
],
|
|
597
|
-
);
|
|
598
|
-
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The carve's auto-group write path, end to end from the ids its picker hands
|
|
5
|
+
* over. Both callers — the carve picker (`withAutoGroupedSources`) and the
|
|
6
|
+
* timeline's group-pointer button — name clips by DOM id, because that is the
|
|
7
|
+
* space `collectCarveCandidates` reads them out of and the space
|
|
8
|
+
* `resolveAudioGroups` reads them back in. Resolving against store keys here
|
|
9
|
+
* matched nothing, wrote nothing, threw nothing, and let the carve persist
|
|
10
|
+
* `sources: [<group>]` for a group that was never created — a carve silently
|
|
11
|
+
* not ducking.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { act } from "react";
|
|
15
|
+
import { createRoot } from "react-dom/client";
|
|
16
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
18
|
+
import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
|
|
19
|
+
|
|
20
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
document.body.innerHTML = "";
|
|
24
|
+
vi.unstubAllGlobals();
|
|
25
|
+
usePlayerStore.getState().reset();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const FILE = `<html><body>
|
|
29
|
+
<audio id="voice-1" data-start="0" data-duration="5"></audio>
|
|
30
|
+
<audio id="voice-2" data-start="5" data-duration="5"></audio>
|
|
31
|
+
</body></html>`;
|
|
32
|
+
|
|
33
|
+
function stubProjectFiles(files: Map<string, string>) {
|
|
34
|
+
vi.stubGlobal(
|
|
35
|
+
"fetch",
|
|
36
|
+
vi.fn(async (input: RequestInfo | URL) => {
|
|
37
|
+
const url = String(input);
|
|
38
|
+
const path = decodeURIComponent(url.slice(url.lastIndexOf("/") + 1));
|
|
39
|
+
const content = files.get(path);
|
|
40
|
+
return new Response(JSON.stringify({ content }), {
|
|
41
|
+
status: content === undefined ? 404 : 200,
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
});
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function audio(overrides: Partial<TimelineElement>): TimelineElement {
|
|
49
|
+
return {
|
|
50
|
+
id: overrides.domId ?? "clip",
|
|
51
|
+
// A store key that is NOT the DOM id — the shape every real row has.
|
|
52
|
+
key: `index.html#${overrides.domId ?? "clip"}`,
|
|
53
|
+
tag: "audio",
|
|
54
|
+
start: 0,
|
|
55
|
+
duration: 5,
|
|
56
|
+
track: 0,
|
|
57
|
+
...overrides,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Assign = (clipIds: readonly string[], groupId: string) => Promise<void>;
|
|
62
|
+
|
|
63
|
+
function renderAssign(writeProjectFile: (path: string, content: string) => Promise<void>) {
|
|
64
|
+
const showToast = vi.fn();
|
|
65
|
+
// A holder, not a bare `let`: TS narrows a variable only assigned inside a
|
|
66
|
+
// component body to `never` at the call site.
|
|
67
|
+
const held: { assign: Assign | null } = { assign: null };
|
|
68
|
+
function Probe() {
|
|
69
|
+
held.assign = useAudioGroupCarveAssignment({
|
|
70
|
+
projectIdRef: { current: "project-1" },
|
|
71
|
+
activeCompPath: "index.html",
|
|
72
|
+
showToast,
|
|
73
|
+
writeProjectFile,
|
|
74
|
+
recordEdit: async () => {},
|
|
75
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
76
|
+
pendingTimelineEditPathRef: { current: new Set() },
|
|
77
|
+
previewIframeRef: { current: null },
|
|
78
|
+
});
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const host = document.createElement("div");
|
|
82
|
+
document.body.append(host);
|
|
83
|
+
const root = createRoot(host);
|
|
84
|
+
act(() => root.render(<Probe />));
|
|
85
|
+
const assign = held.assign;
|
|
86
|
+
expect(assign).not.toBeNull();
|
|
87
|
+
return { assign: assign as Assign, showToast, root };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("useAudioGroupCarveAssignment", () => {
|
|
91
|
+
it("resolves the picker's DOM ids and writes the group", async () => {
|
|
92
|
+
stubProjectFiles(new Map([["index.html", FILE]]));
|
|
93
|
+
usePlayerStore
|
|
94
|
+
.getState()
|
|
95
|
+
.setElements([audio({ domId: "voice-1" }), audio({ domId: "voice-2", track: 1 })]);
|
|
96
|
+
|
|
97
|
+
const writes = new Map<string, string>();
|
|
98
|
+
const { assign, showToast, root } = renderAssign(async (path, content) => {
|
|
99
|
+
writes.set(path, content);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await act(async () => {
|
|
103
|
+
await assign(["voice-1", "voice-2"], "voiceover");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const written = writes.get("index.html") ?? "";
|
|
107
|
+
expect(written).toContain(
|
|
108
|
+
'id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"',
|
|
109
|
+
);
|
|
110
|
+
expect(written).toContain(
|
|
111
|
+
'id="voice-2" data-start="5" data-duration="5" data-audio-group="voiceover"',
|
|
112
|
+
);
|
|
113
|
+
expect(written).toContain('<hf-audio-group id="voiceover"></hf-audio-group>');
|
|
114
|
+
expect(showToast).not.toHaveBeenCalled();
|
|
115
|
+
act(() => root.unmount());
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Loud AND rejecting: the carve chains `.then(() => ({...next, sources:
|
|
119
|
+
// [groupId]}))` off this promise, so a resolved-but-failed call let it
|
|
120
|
+
// persist a carve aimed at a group that was never written. Toasting alone
|
|
121
|
+
// was not enough — the promise has to carry the failure too.
|
|
122
|
+
it("rejects, and toasts, when an id resolves to no clip", async () => {
|
|
123
|
+
stubProjectFiles(new Map([["index.html", FILE]]));
|
|
124
|
+
usePlayerStore.getState().setElements([audio({ domId: "voice-1" })]);
|
|
125
|
+
|
|
126
|
+
const writes = new Map<string, string>();
|
|
127
|
+
const { assign, showToast, root } = renderAssign(async (path, content) => {
|
|
128
|
+
writes.set(path, content);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await act(async () => {
|
|
132
|
+
await expect(assign(["voice-1", "voice-gone"], "voiceover")).rejects.toThrow("voice-gone");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(writes.size).toBe(0);
|
|
136
|
+
expect(showToast).toHaveBeenCalledWith(expect.stringContaining("voice-gone"));
|
|
137
|
+
act(() => root.unmount());
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "can't be moved from the timeline yet" toast, rate-limited.
|
|
3
|
+
*
|
|
4
|
+
* Its own hook so `useTimelineEditing.ts` stays under the studio's 600-line cap.
|
|
5
|
+
* The 1.5s gate matters: a blocked drag fires this per pointermove, and without
|
|
6
|
+
* it one gesture stacked dozens of identical toasts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useRef } from "react";
|
|
10
|
+
import type { TimelineElement } from "../player";
|
|
11
|
+
|
|
12
|
+
const BLOCKED_TOAST_INTERVAL_MS = 1500;
|
|
13
|
+
|
|
14
|
+
export function useBlockedTimelineEditToast(
|
|
15
|
+
showToast: (message: string, tone?: "info" | "error") => void,
|
|
16
|
+
): (element: TimelineElement) => void {
|
|
17
|
+
const lastAtRef = useRef(0);
|
|
18
|
+
return useCallback(
|
|
19
|
+
(_element: TimelineElement) => {
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
if (now - lastAtRef.current < BLOCKED_TOAST_INTERVAL_MS) return;
|
|
22
|
+
lastAtRef.current = now;
|
|
23
|
+
showToast("This clip can't be moved or resized from the timeline yet.", "info");
|
|
24
|
+
},
|
|
25
|
+
[showToast],
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -9,6 +9,8 @@ import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
|
9
9
|
import { reportDomEditPersistFailure } from "./domEditPersistFailure";
|
|
10
10
|
import { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner";
|
|
11
11
|
import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
|
|
12
|
+
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
13
|
+
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
|
|
12
14
|
|
|
13
15
|
// ── Types ──
|
|
14
16
|
|
|
@@ -63,6 +65,17 @@ function setOrRemovePreviewAttribute(
|
|
|
63
65
|
} else {
|
|
64
66
|
el.setAttribute(fullAttr, value);
|
|
65
67
|
}
|
|
68
|
+
// Every DOM-edit attribute write funnels through here, which is the only
|
|
69
|
+
// place that can catch a group edit made from the rack rather than from the
|
|
70
|
+
// group header — `openGroupFxRack` hands the `<hf-audio-group>` to the DOM
|
|
71
|
+
// editor, and that path never went near the timeline's own writers.
|
|
72
|
+
//
|
|
73
|
+
// The group element itself OR a member's membership attribute: writing
|
|
74
|
+
// `data-audio-group` onto an `<audio>` moves it between groups, which changes
|
|
75
|
+
// the answer just as much as editing the bus does.
|
|
76
|
+
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG || fullAttr === HF_AUDIO_GROUP_ATTR) {
|
|
77
|
+
invalidateGroupInfoCache(el.ownerDocument);
|
|
78
|
+
}
|
|
66
79
|
}
|
|
67
80
|
|
|
68
81
|
function findPreviewAttributeElement(
|
|
@@ -58,6 +58,8 @@ const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined }
|
|
|
58
58
|
fn: undefined,
|
|
59
59
|
};
|
|
60
60
|
const domEditSelectionRef: { current: DomEditSelection | null } = { current: null };
|
|
61
|
+
const domEditGroupSelectionsRef: { current: DomEditSelection[] } = { current: [] };
|
|
62
|
+
const groupSelectionSpy = vi.fn();
|
|
61
63
|
const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
|
|
62
64
|
|
|
63
65
|
function createSessionParams(
|
|
@@ -131,7 +133,7 @@ vi.mock("./useDomSelection", () => ({
|
|
|
131
133
|
domEditHoverSelection: null,
|
|
132
134
|
activeGroupElement: null,
|
|
133
135
|
domEditSelectionRef,
|
|
134
|
-
domEditGroupSelectionsRef
|
|
136
|
+
domEditGroupSelectionsRef,
|
|
135
137
|
setActiveGroupElement: vi.fn(),
|
|
136
138
|
applyDomSelection: vi.fn(),
|
|
137
139
|
clearDomSelection: vi.fn(),
|
|
@@ -190,7 +192,7 @@ vi.mock("./useGsapScriptCommits", () => ({
|
|
|
190
192
|
}));
|
|
191
193
|
vi.mock("./useGroupCommits", () => ({
|
|
192
194
|
useGroupCommits: () => ({
|
|
193
|
-
groupSelection:
|
|
195
|
+
groupSelection: (...args: unknown[]) => groupSelectionSpy(...args),
|
|
194
196
|
ungroupSelection: vi.fn(),
|
|
195
197
|
}),
|
|
196
198
|
}));
|
|
@@ -407,3 +409,56 @@ describe("bulk segment ease commits", () => {
|
|
|
407
409
|
}
|
|
408
410
|
});
|
|
409
411
|
});
|
|
412
|
+
|
|
413
|
+
// ── Grouping refuses audio ───────────────────────────────────────────────────
|
|
414
|
+
//
|
|
415
|
+
// A layout group is a positioned wrapper: it takes the members' bounding box,
|
|
416
|
+
// rebases each child's left/top against it and adopts the topmost z-index. An
|
|
417
|
+
// <audio> clip has no box — offsetWidth/Height are 0 — so this produced a 0x0
|
|
418
|
+
// div with inline left/top on elements that are never laid out. Enforced here
|
|
419
|
+
// rather than only by hiding the button, because the G shortcut routes through
|
|
420
|
+
// the same handler and no hidden button can gate a keystroke.
|
|
421
|
+
|
|
422
|
+
describe("handleGroupSelection with audio in the selection", () => {
|
|
423
|
+
const sel = (tag: string): DomEditSelection =>
|
|
424
|
+
({
|
|
425
|
+
id: tag,
|
|
426
|
+
element: document.createElement(tag),
|
|
427
|
+
sourceFile: "index.html",
|
|
428
|
+
}) as unknown as DomEditSelection;
|
|
429
|
+
|
|
430
|
+
async function group(members: DomEditSelection[]) {
|
|
431
|
+
const { useDomEditSession } = await import("./useDomEditSession");
|
|
432
|
+
groupSelectionSpy.mockClear();
|
|
433
|
+
domEditGroupSelectionsRef.current = members;
|
|
434
|
+
const showToast = vi.fn();
|
|
435
|
+
const captured: { fn?: () => void } = {};
|
|
436
|
+
function Probe() {
|
|
437
|
+
captured.fn = useDomEditSession(createSessionParams({ showToast })).handleGroupSelection;
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
const root = createRoot(document.createElement("div"));
|
|
441
|
+
act(() => root.render(<Probe />));
|
|
442
|
+
act(() => captured.fn?.());
|
|
443
|
+
act(() => root.unmount());
|
|
444
|
+
domEditGroupSelectionsRef.current = [];
|
|
445
|
+
return { showToast };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
it("refuses a selection of audio clips, and says where grouping audio lives", async () => {
|
|
449
|
+
const { showToast } = await group([sel("audio"), sel("audio")]);
|
|
450
|
+
expect(groupSelectionSpy).not.toHaveBeenCalled();
|
|
451
|
+
expect(String(showToast.mock.calls[0]?.[0])).toContain("bus");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("refuses a mixed selection, since the wrapper would take the audio in too", async () => {
|
|
455
|
+
const { showToast } = await group([sel("div"), sel("audio")]);
|
|
456
|
+
expect(groupSelectionSpy).not.toHaveBeenCalled();
|
|
457
|
+
expect(String(showToast.mock.calls[0]?.[0])).toContain("layout");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("still groups a selection of layout elements", async () => {
|
|
461
|
+
await group([sel("div"), sel("span")]);
|
|
462
|
+
expect(groupSelectionSpy).toHaveBeenCalledTimes(1);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
3
|
+
import { isAudioDomElement } from "../utils/timelineInspector";
|
|
3
4
|
import type { SelectElementOptions, TimelineElement } from "../player";
|
|
4
5
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
5
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
6
6
|
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
7
7
|
import type { PatchTarget } from "../utils/sourcePatcher";
|
|
8
8
|
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
|
|
@@ -21,13 +21,11 @@ import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
|
|
21
21
|
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
|
|
22
22
|
import { useKeyframeEaseCommits } from "./useKeyframeEaseCommits";
|
|
23
23
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
files: Record<string, { before: string; after: string }>;
|
|
30
|
-
}
|
|
24
|
+
import { membersForDelete } from "./domEditDeleteMembers";
|
|
25
|
+
import type { RecordEditInput } from "./domEditDeleteMembers";
|
|
26
|
+
// Re-exported: the delete rule lives in its own module now, and callers (and its
|
|
27
|
+
// own test) have always imported it from here.
|
|
28
|
+
export { membersForDelete };
|
|
31
29
|
|
|
32
30
|
export interface UseDomEditSessionParams {
|
|
33
31
|
projectId: string | null;
|
|
@@ -73,22 +71,6 @@ export interface UseDomEditSessionParams {
|
|
|
73
71
|
forceReloadSdkSession?: () => void;
|
|
74
72
|
}
|
|
75
73
|
|
|
76
|
-
/**
|
|
77
|
-
* Which elements a delete acts on. `expandGroup` widens the primary to the
|
|
78
|
-
* whole marquee group, which is what the Delete key means.
|
|
79
|
-
*
|
|
80
|
-
* The caller chooses rather than the delete deciding for everyone: Cut copies
|
|
81
|
-
* the primary alone, so expanding for it put one element on the clipboard and
|
|
82
|
-
* removed every other member of the group with it.
|
|
83
|
-
*/
|
|
84
|
-
export function membersForDelete(
|
|
85
|
-
selection: DomEditSelection,
|
|
86
|
-
group: DomEditSelection[],
|
|
87
|
-
options?: { expandGroup?: boolean },
|
|
88
|
-
): DomEditSelection[] {
|
|
89
|
-
return options?.expandGroup && group.length > 0 ? group : [selection];
|
|
90
|
-
}
|
|
91
|
-
|
|
92
74
|
export function useDomEditSession({
|
|
93
75
|
projectId,
|
|
94
76
|
activeCompPath,
|
|
@@ -374,6 +356,23 @@ export function useDomEditSession({
|
|
|
374
356
|
showToast("Select at least 2 elements to group", "info");
|
|
375
357
|
return;
|
|
376
358
|
}
|
|
359
|
+
// A layout group is a positioned wrapper: it takes the members' bounding
|
|
360
|
+
// box, rebases each child's left/top against it, and adopts the topmost
|
|
361
|
+
// z-index. An <audio> clip has no box — offsetWidth/Height are 0 — so
|
|
362
|
+
// grouping audio produced a 0x0 div with inline left/top written onto
|
|
363
|
+
// elements that have never been laid out, and the timeline gained a
|
|
364
|
+
// wrapper standing for nothing audible. The audio answer to "these clips
|
|
365
|
+
// belong together" is an <hf-audio-group> bus, which the timeline's own FX
|
|
366
|
+
// pointer creates, so the refusal names it rather than just declining.
|
|
367
|
+
if (members.some((m) => isAudioDomElement(m.element))) {
|
|
368
|
+
showToast(
|
|
369
|
+
members.every((m) => isAudioDomElement(m.element))
|
|
370
|
+
? "Audio clips group into a bus — use FX on the track header"
|
|
371
|
+
: "Can't group audio clips with layout elements",
|
|
372
|
+
"info",
|
|
373
|
+
);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
377
376
|
trackStudioEvent("group", { action: "create", count: members.length });
|
|
378
377
|
void groupSelection(members);
|
|
379
378
|
}, [domEditGroupSelectionsRef, domEditSelectionRef, groupSelection, showToast]);
|