@hyperframes/studio 0.8.11 → 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-C6J6fsAD.js → hyperframes-player-B243szNa.js} +1 -1
  2. package/dist/assets/{index-BX0piiWc.js → index-BTP86sD-.js} +1 -1
  3. package/dist/assets/index-BZfUJ2He.js +431 -0
  4. package/dist/assets/{index-1vMlv05L.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 +6431 -5515
  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 +167 -21
  90. package/src/player/components/TimelineFxButton.tsx +156 -31
  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-DoW5v5eS.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
@@ -1,4 +1,9 @@
1
1
  import { useCallback } from "react";
2
+ import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
3
+ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
4
+ import { usePlayerStore } from "../player";
5
+ import type { TimelineElementPatch } from "../player/store/timelineElement";
6
+ import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
2
7
  import {
3
8
  buildPatchTarget,
4
9
  persistElementAttribute,
@@ -21,6 +26,111 @@ function patchLiveGroupAttribute(
21
26
  if (!target) return;
22
27
  if (value === null) target.removeAttribute(attr);
23
28
  else target.setAttribute(attr, value);
29
+ invalidateGroupInfoCache(iframe?.contentDocument);
30
+ }
31
+
32
+ /**
33
+ * `data-volume` exactly as core reads it (`readAudioGroupVolume`): an absent or
34
+ * empty attribute is UNITY, not zero.
35
+ *
36
+ * `Number(null)` and `Number("")` are both 0 and both finite, so the obvious
37
+ * `Number.isFinite(Number(value))` mirrored "silent" into the store for a
38
+ * removed attribute while the DOM, the preview bus and the render all read 1 —
39
+ * exactly the parse divergence this mirror exists to eliminate.
40
+ */
41
+ function mirroredGroupVolume(value: string | null): number {
42
+ if (!value) return 1;
43
+ const parsed = parseFloat(value);
44
+ return Number.isFinite(parsed) ? parsed : 1;
45
+ }
46
+
47
+ /** Which store field each writable group attribute mirrors into. */
48
+ const GROUP_ATTR_TO_MIRROR: Record<
49
+ string,
50
+ (value: string | null, groupId: string) => TimelineElementPatch
51
+ > = {
52
+ "data-hidden": (value) => ({ audioGroupHidden: value !== null }),
53
+ "data-volume": (value) => ({ audioGroupVolume: mirroredGroupVolume(value) }),
54
+ "data-label": (value, groupId) => ({ audioGroupLabel: value ?? groupId }),
55
+ [HF_AUDIO_FX_ATTR]: (value) => ({ audioGroupFxChain: value ?? undefined }),
56
+ [HF_AUDIO_AUTOMATION_ATTR]: (value) => ({ audioGroupAutomation: value ?? undefined }),
57
+ };
58
+
59
+ /**
60
+ * Mirror a group attribute onto the store copy every member carries.
61
+ *
62
+ * The timeline derives a group's label / volume / mute / chain from these
63
+ * mirrored `audioGroup*` fields on its MEMBERS, not from the group element —
64
+ * and a group write only ever touched the file and the live preview DOM.
65
+ * Nothing re-parsed, so the header went on reading the old value: the observed
66
+ * symptom was a muted group whose button stayed "Mute group", re-writing
67
+ * `data-hidden` on every click and never offering to unmute.
68
+ *
69
+ * Invalidating the parse cache is necessary but not sufficient — it only
70
+ * ensures the NEXT parse is honest, and a live attribute patch does not cause
71
+ * one. Same reason `commitDataAttribute` carries `syncStoredAutomationFromPreview`.
72
+ */
73
+ function syncStoredGroupAttribute(groupId: string, attr: string, value: string | null): void {
74
+ const toPatch = GROUP_ATTR_TO_MIRROR[attr];
75
+ if (!toPatch) return;
76
+ const patch = toPatch(value, groupId);
77
+ // ONE pass and one notification, rather than `updateElement` per member.
78
+ // That helper maps the whole `elements` array per call, so a 3-member group on
79
+ // a 500-clip composition was 1500 object spreads and 3 store notifications per
80
+ // drag frame — at ~60/s, with every `elements`-keyed memo downstream
81
+ // recomputing each time.
82
+ // BOTH stores, because a group declared inside a sub-composition has no flat
83
+ // member to mirror onto: `childGroupState` keeps those members out of
84
+ // `elements` entirely, so their `audioGroup*` fields come from the
85
+ // `DomClipChild` record instead. Mirroring only `elements` made this whole
86
+ // function a no-op for such a group — the header kept the pre-write chain, its
87
+ // FX button showed the old count, and `laneCount` stayed 0 so the lane
88
+ // disclosure never appeared for automation that now existed. Verbatim the
89
+ // symptom this docblock claims to have fixed, fixed only for flat members.
90
+ //
91
+ // `setDomClipChildren` has one other writer, inside `processTimelineMessage`,
92
+ // which a live attribute patch does not trigger.
93
+ usePlayerStore.setState((state) => {
94
+ const next: Partial<typeof state> = {
95
+ elements: state.elements.map((el) => (el.audioGroup === groupId ? { ...el, ...patch } : el)),
96
+ };
97
+ if (state.domClipChildren.some((child) => child.audioGroup === groupId)) {
98
+ next.domClipChildren = state.domClipChildren.map((child) =>
99
+ child.audioGroup === groupId ? { ...child, ...patch } : child,
100
+ );
101
+ }
102
+ return next;
103
+ });
104
+ }
105
+
106
+ /**
107
+ * The composition FILE that contains a group element, walking up through
108
+ * composition ancestors until one names a file.
109
+ *
110
+ * `getTimelineElementSourceFile` stops at the nearest `[data-composition-id]`,
111
+ * which for an inlined sub-composition is its own ROOT element — that carries
112
+ * the composition id but not the file. The file is on the HOST one level above
113
+ * it. Measured on a live preview:
114
+ *
115
+ * hf-audio-group#voiceover (no composition attrs)
116
+ * section#voices-root data-composition-id="voices" <- stops here
117
+ * div#voices-host data-composition-file="…/voices.html" <- file is here
118
+ * body data-composition-id="<root>"
119
+ *
120
+ * Returns undefined for a group in the root composition (body names an id but
121
+ * no file), which is exactly when the caller should fall back to activeCompPath.
122
+ */
123
+ export function resolveGroupSourceFile(groupEl: Element | null): string | undefined {
124
+ let node: Element | null = groupEl?.parentElement ?? null;
125
+ while (node) {
126
+ const owner: Element | null = node.closest("[data-composition-id]");
127
+ if (!owner) return undefined;
128
+ const file =
129
+ owner.getAttribute("data-composition-file") ?? owner.getAttribute("data-composition-src");
130
+ if (file) return file;
131
+ node = owner.parentElement;
132
+ }
133
+ return undefined;
24
134
  }
25
135
 
26
136
  interface SetAudioGroupAttributeInput {
@@ -56,7 +166,16 @@ async function setAudioGroupAttribute({
56
166
  domEditSaveTimestampRef,
57
167
  pendingTimelineEditPathRef,
58
168
  }: SetAudioGroupAttributeInput): Promise<string[]> {
59
- const targetPath = activeCompPath || "index.html";
169
+ // The file that actually CONTAINS the group element, not just the active
170
+ // composition. A hand-authored sub-composition can declare both the members
171
+ // and their `<hf-audio-group>`, and until sub-comp children inherited
172
+ // `audioGroup*` no group row existed for that case so nothing could reach
173
+ // here. Now the row appears, and routing its writes at `activeCompPath`
174
+ // means `readTagSnippetByTarget` finds nothing and every mute, fader move and
175
+ // FX preset throws "Unable to patch element in index.html". Every sibling
176
+ // timeline writer already routes `element.sourceFile || activeCompPath`.
177
+ const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null;
178
+ const targetPath = resolveGroupSourceFile(groupEl) || activeCompPath || "index.html";
60
179
  const patchTarget = buildPatchTarget({ domId: groupId });
61
180
  if (!patchTarget) return [];
62
181
 
@@ -72,8 +191,6 @@ async function setAudioGroupAttribute({
72
191
  domEditSaveTimestampRef,
73
192
  pendingTimelineEditPathRef,
74
193
  patchLive: (v) => patchLiveGroupAttribute(previewIframe, groupId, attr, v),
75
- readLive: () =>
76
- previewIframe?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ?? null,
77
194
  });
78
195
  }
79
196
 
@@ -101,6 +218,10 @@ export function useSetAudioGroupAttribute({
101
218
  const setLive = useCallback(
102
219
  (groupId: string, attr: string, value: string | null) => {
103
220
  patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value);
221
+ // Live too, not just on commit: a fader drag is `setLive` per frame and
222
+ // `setQuiet` once on release, so without this the strip's own readout
223
+ // fights the drag.
224
+ syncStoredGroupAttribute(groupId, attr, value);
104
225
  },
105
226
  [previewIframeRef],
106
227
  );
@@ -126,7 +247,18 @@ export function useSetAudioGroupAttribute({
126
247
  domEditSaveTimestampRef,
127
248
  pendingTimelineEditPathRef,
128
249
  });
250
+ syncStoredGroupAttribute(groupId, attr, value);
129
251
  } catch (error) {
252
+ // `persistElementAttribute` leaves the live DOM at the previous value
253
+ // however it failed — it unwinds a failed save, and an unresolvable
254
+ // target now throws before patching at all. But `setLive` mirrored the
255
+ // in-progress value into the store on every drag frame — so without
256
+ // this the fader reads 0.4 while
257
+ // the preview and the file are both back at 1.0, and nothing re-parses
258
+ // to correct it (a live patch causing no parse is this mirror's whole
259
+ // premise). Re-mirror from the DOM, which is now authoritative again.
260
+ const live = previewIframeRef.current?.contentDocument?.getElementById(groupId);
261
+ syncStoredGroupAttribute(groupId, attr, live?.getAttribute(attr) ?? null);
130
262
  console.error("[Timeline] Failed to set group attribute", error);
131
263
  const message = error instanceof Error ? error.message : "Failed to update group";
132
264
  showToast(message);
@@ -6,6 +6,7 @@ import {
6
6
  deleteSelectedKeyframes,
7
7
  extendRootDurationIfNeeded,
8
8
  patchIframeDomTiming,
9
+ persistElementAttribute,
9
10
  persistTimelineBatchEdit,
10
11
  type PersistTimelineBatchChange,
11
12
  } from "./timelineEditingHelpers";
@@ -423,3 +424,88 @@ describe("deleteSelectedKeyframes", () => {
423
424
  expect(handleGsapRemoveKeyframe.mock.calls[0]?.[1]).toBe(20);
424
425
  });
425
426
  });
427
+
428
+ describe("persistElementAttribute", () => {
429
+ /**
430
+ * The optimistic live patch used to run BEFORE the target was resolved, and
431
+ * only the save was wrapped in the unwind. So an unresolvable target threw
432
+ * with the preview holding a value that never reached disk — and the group
433
+ * writer's catch mirrors the live DOM into the store, so the UI reported the
434
+ * write as applied until a reload dropped it.
435
+ */
436
+ it("does not patch the live DOM when the target resolves to nothing", async () => {
437
+ const fetchSpy = vi
438
+ .spyOn(globalThis, "fetch")
439
+ .mockResolvedValue(
440
+ new Response(JSON.stringify({ content: '<body><audio id="other"></audio></body>' })),
441
+ );
442
+ const patchLive = vi.fn();
443
+ const writeProjectFile = vi.fn();
444
+
445
+ await expect(
446
+ persistElementAttribute({
447
+ projectId: "p",
448
+ targetPath: "index.html",
449
+ patchTarget: { id: "missing" },
450
+ attr: "data-volume",
451
+ value: "0.4",
452
+ label: "Set volume",
453
+ writeProjectFile,
454
+ recordEdit: vi.fn(),
455
+ domEditSaveTimestampRef: { current: 0 },
456
+ pendingTimelineEditPathRef: { current: new Set() },
457
+ patchLive,
458
+ }),
459
+ ).rejects.toThrow("Unable to patch element in index.html");
460
+
461
+ expect(patchLive).not.toHaveBeenCalled();
462
+ expect(writeProjectFile).not.toHaveBeenCalled();
463
+ fetchSpy.mockRestore();
464
+ });
465
+ });
466
+
467
+ describe("persistElementAttribute — unwind value", () => {
468
+ /**
469
+ * The unwind has to restore the value on DISK, not the one in the preview.
470
+ *
471
+ * Every live-write caller patches the DOM before committing (a fader drag is
472
+ * `setLive` per frame; hovering a preset auditions the whole chain), so by
473
+ * commit time the live DOM already holds the in-progress value. Reading it as
474
+ * `previousValue` made the unwind a no-op, and the group writer's catch —
475
+ * which deliberately re-mirrors the store off the live DOM — then mirrored the
476
+ * never-saved value: the panel agreed with the preview, and a reload dropped it.
477
+ */
478
+ it("restores the file's value, not the audition already in the live DOM", async () => {
479
+ const fetchSpy = vi
480
+ .spyOn(globalThis, "fetch")
481
+ .mockResolvedValue(
482
+ new Response(
483
+ JSON.stringify({ content: `<body><audio id="bgm" data-volume="0.25"></audio></body>` }),
484
+ ),
485
+ );
486
+ const patched: Array<string | null> = [];
487
+ const writeProjectFile = vi.fn(() => Promise.reject(new Error("save failed")));
488
+
489
+ await expect(
490
+ persistElementAttribute({
491
+ projectId: "p",
492
+ targetPath: "index.html",
493
+ patchTarget: { id: "bgm" },
494
+ attr: "data-volume",
495
+ value: "0.9",
496
+ label: "Set volume",
497
+ writeProjectFile,
498
+ recordEdit: vi.fn(),
499
+ domEditSaveTimestampRef: { current: 0 },
500
+ pendingTimelineEditPathRef: { current: new Set() },
501
+ // The live DOM is ALREADY at the new value when the commit runs — that
502
+ // is what `setLive` does on every drag frame.
503
+ patchLive: (v) => patched.push(v),
504
+ }),
505
+ ).rejects.toThrow("save failed");
506
+
507
+ // First the optimistic write, then the unwind — back to what the FILE said.
508
+ expect(patched).toEqual(["0.9", "0.25"]);
509
+ fetchSpy.mockRestore();
510
+ });
511
+ });
@@ -410,8 +410,6 @@ export interface PersistElementAttributeInput {
410
410
  pendingTimelineEditPathRef: { current: Set<string> };
411
411
  /** Write the attribute directly on the live preview DOM node. */
412
412
  patchLive: (value: string | null) => void;
413
- /** Read the attribute's current value off the live preview DOM node. */
414
- readLive: () => string | null;
415
413
  }
416
414
 
417
415
  /**
@@ -420,7 +418,7 @@ export interface PersistElementAttributeInput {
420
418
  * `setAudioGroupAttribute` (a group id addressed by its own DOM id) and
421
419
  * `useSetElementAttribute` (an arbitrary timeline clip) — same shape, only
422
420
  * how the live node is found and where the patch target resolves to differs,
423
- * which is exactly what `patchLive`/`readLive`/`patchTarget` parameterize.
421
+ * which is exactly what `patchLive`/`patchTarget` parameterize.
424
422
  */
425
423
  export async function persistElementAttribute({
426
424
  projectId,
@@ -434,15 +432,29 @@ export async function persistElementAttribute({
434
432
  domEditSaveTimestampRef,
435
433
  pendingTimelineEditPathRef,
436
434
  patchLive,
437
- readLive,
438
435
  }: PersistElementAttributeInput): Promise<string[]> {
439
- const previousValue = readLive();
440
- patchLive(value);
441
-
436
+ // Resolve the target BEFORE patching the live DOM. The optimistic patch used
437
+ // to run first, and only the save was wrapped in the unwind — so an
438
+ // unresolvable target threw with the live preview (and, through the callers'
439
+ // catch, the store mirrored off it) holding a value that never reached disk.
440
+ // The write then read as successful until a reload dropped it.
442
441
  const before = await readFileContent(projectId, targetPath);
443
442
  if (readTagSnippetByTarget(before, patchTarget) === undefined) {
444
443
  throw new Error(`Unable to patch element in ${targetPath}`);
445
444
  }
445
+ // The unwind value comes from the FILE, not from `readLive()`.
446
+ //
447
+ // Every live-write caller patches the DOM before committing — a fader drag is
448
+ // `setLive` per frame, hovering a preset auditions the whole chain — so by the
449
+ // time this runs the live DOM already holds the in-progress value. Reading it
450
+ // here made `previousValue === value`, so the unwind below was a no-op, and
451
+ // `setQuiet`'s catch (which deliberately re-mirrors the store from the live
452
+ // DOM) then mirrored that same never-saved value. The group audibly had the
453
+ // preset, the panel agreed, and a reload dropped it — the failure class the
454
+ // target check above was added to close, still open on the live-write path.
455
+ const previousValue = readAttributeByTarget(before, patchTarget, attr) ?? null;
456
+ patchLive(value);
457
+
446
458
  const operation: PatchOperation = { type: "attribute", property: attr, value };
447
459
  const patched = applyPatchByTarget(before, patchTarget, operation);
448
460
 
@@ -75,9 +75,6 @@ async function setElementAttribute({
75
75
  domEditSaveTimestampRef,
76
76
  pendingTimelineEditPathRef,
77
77
  patchLive: (v) => patchLiveElementAttribute(previewIframe, element, attr, v, activeCompPath),
78
- readLive: () =>
79
- findTimelineElementInIframe(previewIframe, element, activeCompPath)?.getAttribute(attr) ??
80
- null,
81
78
  });
82
79
  }
83
80
 
@@ -2,11 +2,10 @@
2
2
 
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import { usePlayerStore, type TimelineElement } from "../player";
5
- import {
6
- createAudioGroupAndAssignMembers,
7
- toggleTimelineElementHidden,
8
- toggleTimelineTrackHidden,
9
- } from "./timelineTrackVisibility";
5
+ import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
6
+ import { readTagSnippetByTarget } from "../utils/sourcePatcher";
7
+ import { createAudioGroupAndAssignMembers } from "./timelineAudioGroupCreate";
8
+ import { toggleTimelineElementHidden, toggleTimelineTrackHidden } from "./timelineTrackVisibility";
10
9
 
11
10
  afterEach(() => {
12
11
  document.body.innerHTML = "";
@@ -513,23 +512,106 @@ describe("createAudioGroupAndAssignMembers", () => {
513
512
  ).toBe("voiceover");
514
513
  });
515
514
 
516
- it("does nothing for fewer than two elements grouping is a plural concept", async () => {
517
- const recordEdit = vi.fn();
518
- const changedPaths = await createAudioGroupAndAssignMembers({
515
+ // The group element is what every LATER group write addresses mute, the bus
516
+ // fader's data-volume, an FX preset all go through
517
+ // `buildPatchTarget({ domId: groupId })`. Membership alone parses, but leaves
518
+ // a group nothing can edit.
519
+ it("emits the group's own <hf-audio-group> element, patchable by its DOM id", async () => {
520
+ const iframe = document.createElement("iframe");
521
+ document.body.append(iframe);
522
+ if (iframe.contentDocument) {
523
+ iframe.contentDocument.body.innerHTML = `
524
+ <audio id="narration"></audio>
525
+ <audio id="interview-guest"></audio>
526
+ `;
527
+ }
528
+ const files = new Map([
529
+ [
530
+ "index.html",
531
+ `<html><body>
532
+ <audio id="narration" data-start="0" data-duration="5"></audio>
533
+ <audio id="interview-guest" data-start="10" data-duration="5"></audio>
534
+ </body></html>`,
535
+ ],
536
+ ]);
537
+ stubProjectFiles(files);
538
+
539
+ const narration = element({ id: "narration", domId: "narration", track: 0 });
540
+ const guest = element({ id: "interview-guest", domId: "interview-guest", track: 1 });
541
+ usePlayerStore.getState().setElements([narration, guest]);
542
+
543
+ const writes = new Map<string, string>();
544
+ await createAudioGroupAndAssignMembers({
519
545
  projectId: "project-1",
520
546
  activeCompPath: "index.html",
521
- elements: [element({ id: "narration", domId: "narration" })],
547
+ elements: [narration, guest],
522
548
  groupId: "voiceover",
523
- previewIframe: null,
524
- writeProjectFile: async () => {},
525
- recordEdit,
549
+ previewIframe: iframe,
550
+ writeProjectFile: async (path, content) => {
551
+ writes.set(path, content);
552
+ },
553
+ recordEdit: vi.fn(),
526
554
  domEditSaveTimestampRef: { current: 0 },
527
555
  pendingTimelineEditPathRef: { current: new Set() },
528
556
  });
529
- expect(changedPaths).toEqual([]);
557
+
558
+ const written = writes.get("index.html") ?? "";
559
+ expect(written).toContain('<hf-audio-group id="voiceover"></hf-audio-group>');
560
+ // The actual contract: the group-attribute writer can now find a target.
561
+ // This is the read that threw "Unable to patch element in index.html".
562
+ expect(readTagSnippetByTarget(written, { id: "voiceover" })).toBeDefined();
563
+ // ...and in the live preview, which is what patchLiveGroupAttribute reads
564
+ // before the next reload.
565
+ expect(iframe.contentDocument?.getElementById("voiceover")?.tagName.toLowerCase()).toBe(
566
+ "hf-audio-group",
567
+ );
568
+ // Both members still resolve into it.
569
+ expect(resolveAudioGroups(iframe.contentDocument as Document)[0]).toMatchObject({
570
+ id: "voiceover",
571
+ memberIds: ["narration", "interview-guest"],
572
+ });
573
+ });
574
+
575
+ // Rejects rather than resolving empty: the carve's auto-group persists
576
+ // `sources: [groupId]` once this resolves, so a silent no-op leaves the carve
577
+ // pointing at a group that was never written — and stops ducking.
578
+ it("rejects for fewer than two elements — grouping is a plural concept", async () => {
579
+ const recordEdit = vi.fn();
580
+ await expect(
581
+ createAudioGroupAndAssignMembers({
582
+ projectId: "project-1",
583
+ activeCompPath: "index.html",
584
+ elements: [element({ id: "narration", domId: "narration" })],
585
+ groupId: "voiceover",
586
+ previewIframe: null,
587
+ writeProjectFile: async () => {},
588
+ recordEdit,
589
+ domEditSaveTimestampRef: { current: 0 },
590
+ pendingTimelineEditPathRef: { current: new Set() },
591
+ }),
592
+ ).rejects.toThrow("a group needs at least two");
530
593
  expect(recordEdit).not.toHaveBeenCalled();
531
594
  });
532
595
 
596
+ it("rejects a group id that is not safe to interpolate into markup or a path", async () => {
597
+ await expect(
598
+ createAudioGroupAndAssignMembers({
599
+ projectId: "project-1",
600
+ activeCompPath: "index.html",
601
+ elements: [
602
+ element({ id: "narration", domId: "narration" }),
603
+ element({ id: "guest", domId: "guest" }),
604
+ ],
605
+ groupId: '../x"><script>',
606
+ previewIframe: null,
607
+ writeProjectFile: async () => {},
608
+ recordEdit: vi.fn(),
609
+ domEditSaveTimestampRef: { current: 0 },
610
+ pendingTimelineEditPathRef: { current: new Set() },
611
+ }),
612
+ ).rejects.toThrow("Invalid audio group id");
613
+ });
614
+
533
615
  it("reverts the optimistic live patch when the save fails", async () => {
534
616
  const iframe = document.createElement("iframe");
535
617
  document.body.append(iframe);