@hyperframes/studio 0.8.10 → 0.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/dist/assets/{hyperframes-player-DoTrWV8q.js → hyperframes-player-B243szNa.js} +1 -1
  2. package/dist/assets/{index-DV45YLqR.js → index-BTP86sD-.js} +1 -1
  3. package/dist/assets/index-BZfUJ2He.js +431 -0
  4. package/dist/assets/{index-BQs1fahB.js → index-DQ_lkuqY.js} +1 -1
  5. package/dist/assets/index-yGhfxxoL.css +1 -0
  6. package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
  7. package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
  8. package/dist/index.d.ts +84 -43
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +6440 -5514
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +0 -2
  14. package/src/components/StudioRightPanel.tsx +15 -57
  15. package/src/components/editor/PropertyPanel.test.tsx +101 -3
  16. package/src/components/editor/PropertyPanel.tsx +15 -1
  17. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  18. package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
  19. package/src/components/editor/PropertyPanelFlat.tsx +82 -21
  20. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
  21. package/src/components/editor/TimelineFxPopover.tsx +24 -4
  22. package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
  23. package/src/components/editor/audioFxRevealTarget.ts +113 -0
  24. package/src/components/editor/audioFxSignalPath.test.ts +50 -0
  25. package/src/components/editor/audioFxSignalPath.ts +71 -0
  26. package/src/components/editor/audioFxSummary.test.ts +8 -0
  27. package/src/components/editor/audioFxSummary.ts +28 -16
  28. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
  29. package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
  30. package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
  31. package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
  32. package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
  33. package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
  34. package/src/components/editor/propertyPanelFxControls.tsx +10 -2
  35. package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
  36. package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
  37. package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
  38. package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
  39. package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
  40. package/src/components/editor/propertyPanelFxSection.tsx +63 -4
  41. package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
  42. package/src/components/editor/useAudioFxRevealSection.ts +64 -0
  43. package/src/components/editor/useAuditionTransport.test.ts +36 -0
  44. package/src/components/editor/useAuditionTransport.ts +78 -0
  45. package/src/components/editor/useFxAudition.ts +12 -1
  46. package/src/components/editor/useFxCarve.ts +0 -0
  47. package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
  48. package/src/components/editor/useFxCarveGrouping.ts +139 -4
  49. package/src/components/editor/useFxCarveNodes.ts +153 -0
  50. package/src/components/editor/useFxLevelling.ts +5 -34
  51. package/src/components/nle/NLEContext.tsx +3 -10
  52. package/src/components/nle/PreviewPane.tsx +0 -1
  53. package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
  54. package/src/contexts/DesignPanelInputContext.tsx +6 -5
  55. package/src/contexts/DomEditContext.tsx +10 -3
  56. package/src/contexts/FileManagerContext.tsx +3 -2
  57. package/src/contexts/PanelLayoutContext.tsx +3 -2
  58. package/src/contexts/StudioContext.tsx +7 -3
  59. package/src/contexts/TimelineEditContext.tsx +6 -2
  60. package/src/contexts/VariablePromoteContext.tsx +6 -2
  61. package/src/contexts/ViewModeContext.tsx +2 -2
  62. package/src/hooks/domEditDeleteMembers.ts +34 -0
  63. package/src/hooks/timelineAudioGroupCreate.ts +345 -0
  64. package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
  65. package/src/hooks/timelineAudioGroupVolume.ts +135 -3
  66. package/src/hooks/timelineEditingHelpers.test.ts +86 -0
  67. package/src/hooks/timelineEditingHelpers.ts +19 -7
  68. package/src/hooks/timelineElementFxAttribute.ts +0 -3
  69. package/src/hooks/timelineTrackVisibility.test.ts +95 -13
  70. package/src/hooks/timelineTrackVisibility.ts +6 -182
  71. package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
  72. package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
  73. package/src/hooks/useDomEditAttributeCommits.ts +13 -0
  74. package/src/hooks/useDomEditSession.test.tsx +57 -2
  75. package/src/hooks/useDomEditSession.ts +23 -24
  76. package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
  77. package/src/hooks/useLivePlayheadTime.ts +3 -1
  78. package/src/hooks/useRemoveBackground.ts +70 -0
  79. package/src/hooks/useTimelineEditing.ts +5 -19
  80. package/src/hooks/useTimelineEditingTypes.ts +7 -0
  81. package/src/player/components/AutomationSelectionMenu.tsx +4 -0
  82. package/src/player/components/LayerDisclosureRow.tsx +59 -25
  83. package/src/player/components/PlayerControls.tsx +0 -38
  84. package/src/player/components/Timeline.test.ts +40 -1
  85. package/src/player/components/Timeline.tsx +5 -2
  86. package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
  87. package/src/player/components/TimelineAutomationLane.tsx +89 -12
  88. package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
  89. package/src/player/components/TimelineFxButton.test.tsx +184 -7
  90. package/src/player/components/TimelineFxButton.tsx +171 -23
  91. package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
  92. package/src/player/components/TimelineGroupHeader.tsx +100 -102
  93. package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
  94. package/src/player/components/TimelineGroupRow.test.tsx +146 -0
  95. package/src/player/components/TimelineGroupRow.tsx +123 -64
  96. package/src/player/components/TimelineLanes.test.tsx +3 -3
  97. package/src/player/components/TimelineLanes.tsx +25 -23
  98. package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
  99. package/src/player/components/TimelineTrackHeader.tsx +301 -381
  100. package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
  101. package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
  102. package/src/player/components/automationLaneData.test.ts +94 -0
  103. package/src/player/components/automationLaneData.ts +23 -0
  104. package/src/player/components/groupAutomationElement.test.ts +51 -0
  105. package/src/player/components/groupAutomationElement.ts +37 -0
  106. package/src/player/components/timelineCallbacks.ts +6 -2
  107. package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
  108. package/src/player/components/timelineKeyboardNavigation.ts +25 -8
  109. package/src/player/components/timelineLayout.ts +0 -1
  110. package/src/player/components/timelineViewModel.ts +30 -1
  111. package/src/player/components/trackHeaderLabelRows.tsx +328 -0
  112. package/src/player/components/useTimelineClipDisclosure.ts +41 -0
  113. package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
  114. package/src/player/components/useTimelineLogicalFocus.ts +2 -2
  115. package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
  116. package/src/player/components/useTimelineLogicalRows.ts +3 -3
  117. package/src/player/components/useTimelineTrackDerivations.ts +47 -8
  118. package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +12 -4
  120. package/src/player/hooks/previewMessageRouter.ts +4 -11
  121. package/src/player/hooks/timelineSyncHydration.ts +395 -0
  122. package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
  123. package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
  124. package/src/player/hooks/useTimelinePlayer.ts +2 -7
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
  126. package/src/player/lib/automationStoreSync.test.ts +26 -0
  127. package/src/player/lib/automationStoreSync.ts +28 -4
  128. package/src/player/lib/runtimeAudioId.test.ts +58 -0
  129. package/src/player/lib/timelineDOM.test.ts +115 -0
  130. package/src/player/lib/timelineDOM.ts +3 -32
  131. package/src/player/lib/timelineElementHelpers.ts +27 -0
  132. package/src/player/lib/timelineGroupInfo.ts +134 -0
  133. package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
  134. package/src/player/lib/timelineIframeHelpers.ts +17 -0
  135. package/src/player/store/keyframeSlice.ts +79 -5
  136. package/src/player/store/playerStore.ts +37 -47
  137. package/src/player/store/playerStoreDevHandle.ts +22 -0
  138. package/src/player/store/playerStoreSelection.ts +41 -0
  139. package/src/player/store/timelineElement.ts +32 -0
  140. package/src/utils/hmrStableContext.ts +64 -0
  141. package/src/utils/timelineInspector.test.ts +35 -1
  142. package/src/utils/timelineInspector.ts +38 -0
  143. package/dist/assets/index-Ba9zbpT9.css +0 -1
  144. package/dist/assets/index-C6m2nHiX.js +0 -428
  145. package/src/hooks/useAudioSoloBridge.ts +0 -61
  146. package/src/hooks/useGroupLevel.ts +0 -36
  147. package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
  148. package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
  149. package/src/player/components/TimelineSoloButton.tsx +0 -32
  150. package/src/player/store/audioSoloSlice.test.ts +0 -113
  151. package/src/player/store/audioSoloSlice.ts +0 -43
  152. package/src/player/store/groupLevels.ts +0 -33
  153. /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
  154. /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
- * The display row the clicked control announced, when the caller has one.
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: { current: [] },
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: vi.fn(),
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
- interface RecordEditInput {
26
- label: string;
27
- kind: EditHistoryKind;
28
- coalesceKey?: string;
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]);
@@ -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
- return useMemo(() => {
14
- const maxEnd =
15
- timelineElements.length > 0
16
- ? Math.max(...timelineElements.map((el) => el.start + el.duration))
17
- : 0;
18
- return Math.max(timelineDuration, maxEnd);
19
- }, [timelineDuration, timelineElements]);
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
- import { liveTime, usePlayerStore } from "../player";
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;