@hyperframes/studio 0.8.6 → 0.8.7

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 (152) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-bDcU464i.js} +1 -1
  2. package/dist/assets/index-Ba9zbpT9.css +1 -0
  3. package/dist/assets/index-CUXnyoIa.js +428 -0
  4. package/dist/assets/{index-bv8b5eiQ.js → index-FkAs3zRu.js} +1 -1
  5. package/dist/assets/{index-Ci7Uy8fR.js → index-MSOSP1-J.js} +1 -1
  6. package/dist/index.d.ts +98 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10336 -7195
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +22 -23
  12. package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
  13. package/src/captions/components/CaptionOverlay.tsx +172 -29
  14. package/src/captions/components/CaptionOverlayUtils.ts +54 -0
  15. package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
  16. package/src/captions/components/CaptionTimeline.tsx +36 -79
  17. package/src/captions/components/shared.tsx +76 -1
  18. package/src/captions/hooks/useCaptionSync.ts +72 -13
  19. package/src/captions/keyboard.test.ts +12 -0
  20. package/src/captions/keyboard.ts +12 -1
  21. package/src/captions/store.ts +120 -14
  22. package/src/components/EditorShell.selectionSync.test.tsx +1 -1
  23. package/src/components/EditorShell.tsx +13 -1
  24. package/src/components/StudioRightPanel.tsx +2 -0
  25. package/src/components/StudioRightPanel.types.ts +1 -0
  26. package/src/components/editor/AnimationCard.tsx +2 -1
  27. package/src/components/editor/AnimationCardParts.tsx +6 -3
  28. package/src/components/editor/ArcPathControls.tsx +10 -4
  29. package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
  30. package/src/components/editor/BlockParamsPanel.tsx +153 -11
  31. package/src/components/editor/BorderRadiusEditor.tsx +3 -22
  32. package/src/components/editor/FileTree.tsx +41 -12
  33. package/src/components/editor/FileTreeNodes.tsx +90 -20
  34. package/src/components/editor/GsapAnimationList.tsx +5 -0
  35. package/src/components/editor/KeyframeDiamond.tsx +3 -1
  36. package/src/components/editor/KeyframeEaseList.tsx +3 -2
  37. package/src/components/editor/KeyframeNavigation.tsx +6 -2
  38. package/src/components/editor/LayersPanel.tsx +11 -2
  39. package/src/components/editor/MotionPathNode.tsx +1 -0
  40. package/src/components/editor/PropertyPanel.test.tsx +7 -6
  41. package/src/components/editor/PropertyPanel.tsx +13 -5
  42. package/src/components/editor/PropertyPanelFlat.tsx +2 -0
  43. package/src/components/editor/SnapToolbar.tsx +20 -4
  44. package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
  45. package/src/components/editor/TimelineFxPopover.tsx +138 -0
  46. package/src/components/editor/Transform3DCube.tsx +22 -1
  47. package/src/components/editor/manualOffsetDrag.test.ts +49 -0
  48. package/src/components/editor/manualOffsetDrag.ts +13 -1
  49. package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
  51. package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
  52. package/src/components/editor/propertyPanelColor.tsx +9 -1
  53. package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
  54. package/src/components/editor/propertyPanelCommitField.tsx +9 -1
  55. package/src/components/editor/propertyPanelFill.tsx +50 -2
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelFont.tsx +45 -9
  58. package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
  59. package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
  60. package/src/components/editor/propertyPanelFxSection.tsx +8 -23
  61. package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
  62. package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
  63. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  64. package/src/components/editor/propertyPanelTypes.ts +2 -0
  65. package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
  66. package/src/components/editor/useFxCarve.ts +0 -0
  67. package/src/components/editor/useFxCarveGrouping.ts +132 -0
  68. package/src/components/nle/PreviewOverlays.tsx +69 -2
  69. package/src/components/nle/PreviewPane.tsx +1 -0
  70. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  71. package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
  72. package/src/components/panels/SlideshowPanel.tsx +81 -12
  73. package/src/components/panels/SlideshowSubPanels.tsx +61 -7
  74. package/src/components/sidebar/AssetCard.test.tsx +2 -2
  75. package/src/components/sidebar/AssetCard.tsx +94 -46
  76. package/src/components/sidebar/AssetContextMenu.tsx +196 -42
  77. package/src/components/sidebar/AssetsTab.tsx +156 -96
  78. package/src/components/sidebar/AudioRow.tsx +95 -40
  79. package/src/components/sidebar/BlocksTab.tsx +40 -128
  80. package/src/components/sidebar/CompositionsTab.tsx +46 -34
  81. package/src/components/sidebar/LeftSidebar.tsx +87 -75
  82. package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
  83. package/src/components/sidebar/assetHelpers.ts +8 -0
  84. package/src/contexts/TimelineEditContext.tsx +5 -0
  85. package/src/hooks/timelineAudioGroupVolume.ts +148 -0
  86. package/src/hooks/timelineEditingHelpers.ts +78 -1
  87. package/src/hooks/timelineElementFxAttribute.ts +150 -0
  88. package/src/hooks/timelineTrackVisibility.test.ts +222 -1
  89. package/src/hooks/timelineTrackVisibility.ts +187 -4
  90. package/src/hooks/useAppHotkeys.ts +22 -0
  91. package/src/hooks/useAudioSoloBridge.ts +61 -0
  92. package/src/hooks/useCaptionDetection.ts +22 -2
  93. package/src/hooks/useDomEditCommits.test.tsx +24 -0
  94. package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
  95. package/src/hooks/useGroupLevel.ts +36 -0
  96. package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
  97. package/src/hooks/useMusicBeatAnalysis.ts +13 -0
  98. package/src/hooks/useTimelineDeleteOps.ts +170 -0
  99. package/src/hooks/useTimelineEditing.ts +53 -129
  100. package/src/player/components/AudioWaveform.tsx +29 -26
  101. package/src/player/components/BeatStrip.test.tsx +5 -9
  102. package/src/player/components/BeatStrip.tsx +12 -5
  103. package/src/player/components/ClipContextMenu.tsx +8 -2
  104. package/src/player/components/EditModal.tsx +48 -7
  105. package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
  106. package/src/player/components/LayerDisclosureRow.tsx +12 -13
  107. package/src/player/components/Player.tsx +43 -8
  108. package/src/player/components/PlayerControls.tsx +107 -63
  109. package/src/player/components/ShortcutsPanel.tsx +23 -4
  110. package/src/player/components/SpeedMenu.tsx +34 -42
  111. package/src/player/components/Timeline.test.ts +7 -7
  112. package/src/player/components/Timeline.tsx +7 -10
  113. package/src/player/components/TimelineFxButton.test.tsx +81 -0
  114. package/src/player/components/TimelineFxButton.tsx +143 -0
  115. package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
  116. package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
  117. package/src/player/components/TimelineGroupHeader.tsx +176 -0
  118. package/src/player/components/TimelineGroupRow.tsx +146 -0
  119. package/src/player/components/TimelineLanes.test.tsx +5 -0
  120. package/src/player/components/TimelineLanes.tsx +39 -33
  121. package/src/player/components/TimelineOverlays.tsx +34 -0
  122. package/src/player/components/TimelineSoloButton.tsx +32 -0
  123. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  124. package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
  125. package/src/player/components/VideoThumbnail.tsx +6 -1
  126. package/src/player/components/menuKeyboardNav.ts +47 -0
  127. package/src/player/components/timelineCallbacks.ts +26 -0
  128. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  129. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  130. package/src/player/components/timelineLaneProps.ts +3 -0
  131. package/src/player/components/timelineLayout.ts +1 -1
  132. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  133. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  134. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  135. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  136. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  137. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  138. package/src/player/components/useTimelineTrackDerivations.ts +150 -6
  139. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  140. package/src/player/hooks/previewMessageRouter.ts +127 -0
  141. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  142. package/src/player/lib/timelineDOM.ts +51 -0
  143. package/src/player/store/audioSoloSlice.test.ts +113 -0
  144. package/src/player/store/audioSoloSlice.ts +43 -0
  145. package/src/player/store/editingModeSlice.ts +45 -0
  146. package/src/player/store/groupLevels.ts +33 -0
  147. package/src/player/store/keyframeSlice.ts +26 -0
  148. package/src/player/store/playerStore.ts +21 -22
  149. package/src/player/store/timelineElement.ts +10 -0
  150. package/src/telemetry/events.ts +12 -0
  151. package/dist/assets/index-Cy6fGN1u.js +0 -428
  152. package/dist/assets/index-DcF4s1PG.css +0 -1
@@ -2,7 +2,11 @@
2
2
 
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import { usePlayerStore, type TimelineElement } from "../player";
5
- import { toggleTimelineElementHidden, toggleTimelineTrackHidden } from "./timelineTrackVisibility";
5
+ import {
6
+ createAudioGroupAndAssignMembers,
7
+ toggleTimelineElementHidden,
8
+ toggleTimelineTrackHidden,
9
+ } from "./timelineTrackVisibility";
6
10
 
7
11
  afterEach(() => {
8
12
  document.body.innerHTML = "";
@@ -194,6 +198,87 @@ describe("toggleTimelineTrackHidden", () => {
194
198
 
195
199
  expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Show track 2");
196
200
  });
201
+
202
+ it("labels an audio-only track Mute/Unmute instead of Hide/Show", async () => {
203
+ const files = new Map([
204
+ ["index.html", `<div id="voiceover" data-start="0" data-duration="2"></div>`],
205
+ ]);
206
+ stubProjectFiles(files);
207
+
208
+ const recordEdit = vi.fn();
209
+
210
+ await toggleTimelineTrackHidden({
211
+ projectId: "project-1",
212
+ activeCompPath: "index.html",
213
+ timelineElements: [element({ id: "voiceover", domId: "voiceover", track: 0, tag: "audio" })],
214
+ track: 0,
215
+ hidden: true,
216
+ previewIframe: null,
217
+ writeProjectFile: async () => {},
218
+ recordEdit,
219
+ domEditSaveTimestampRef: { current: 0 },
220
+ pendingTimelineEditPathRef: { current: new Set() },
221
+ });
222
+
223
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Mute track 1");
224
+ });
225
+
226
+ it("labels unmuting an audio-only track back on", async () => {
227
+ const files = new Map([
228
+ ["index.html", `<div id="voiceover" data-start="0" data-duration="2" data-hidden=""></div>`],
229
+ ]);
230
+ stubProjectFiles(files);
231
+
232
+ const recordEdit = vi.fn();
233
+
234
+ await toggleTimelineTrackHidden({
235
+ projectId: "project-1",
236
+ activeCompPath: "index.html",
237
+ timelineElements: [
238
+ element({ id: "voiceover", domId: "voiceover", track: 0, tag: "audio", hidden: true }),
239
+ ],
240
+ track: 0,
241
+ hidden: false,
242
+ previewIframe: null,
243
+ writeProjectFile: async () => {},
244
+ recordEdit,
245
+ domEditSaveTimestampRef: { current: 0 },
246
+ pendingTimelineEditPathRef: { current: new Set() },
247
+ });
248
+
249
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Unmute track 1");
250
+ });
251
+
252
+ it("keeps Hide/Show wording for a mixed (audio + visual) track", async () => {
253
+ const files = new Map([
254
+ [
255
+ "index.html",
256
+ `<div id="voiceover" data-start="0" data-duration="2"></div>
257
+ <div id="caption" data-start="0" data-duration="2"></div>`,
258
+ ],
259
+ ]);
260
+ stubProjectFiles(files);
261
+
262
+ const recordEdit = vi.fn();
263
+
264
+ await toggleTimelineTrackHidden({
265
+ projectId: "project-1",
266
+ activeCompPath: "index.html",
267
+ timelineElements: [
268
+ element({ id: "voiceover", domId: "voiceover", track: 0, tag: "audio" }),
269
+ element({ id: "caption", domId: "caption", track: 0, tag: "div" }),
270
+ ],
271
+ track: 0,
272
+ hidden: true,
273
+ previewIframe: null,
274
+ writeProjectFile: async () => {},
275
+ recordEdit,
276
+ domEditSaveTimestampRef: { current: 0 },
277
+ pendingTimelineEditPathRef: { current: new Set() },
278
+ });
279
+
280
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 1");
281
+ });
197
282
  });
198
283
 
199
284
  describe("toggleTimelineElementHidden", () => {
@@ -314,3 +399,139 @@ describe("toggleTimelineElementHidden", () => {
314
399
  expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide 2 elements");
315
400
  });
316
401
  });
402
+
403
+ describe("createAudioGroupAndAssignMembers", () => {
404
+ it("writes data-audio-group on every member in ONE atomic edit and updates the player store", async () => {
405
+ const iframe = document.createElement("iframe");
406
+ document.body.append(iframe);
407
+ if (iframe.contentDocument) {
408
+ iframe.contentDocument.body.innerHTML = `
409
+ <audio id="narration"></audio>
410
+ <audio id="interview-guest"></audio>
411
+ `;
412
+ }
413
+
414
+ const files = new Map([
415
+ [
416
+ "index.html",
417
+ `<audio id="narration" data-start="0" data-duration="5"></audio>
418
+ <audio id="interview-guest" data-start="10" data-duration="5"></audio>
419
+ <audio id="sfx-boom" data-start="0" data-duration="1"></audio>`,
420
+ ],
421
+ ]);
422
+ stubProjectFiles(files);
423
+
424
+ const narration = element({
425
+ id: "narration",
426
+ key: "index.html:#narration",
427
+ domId: "narration",
428
+ track: 0,
429
+ });
430
+ const guest = element({
431
+ id: "interview-guest",
432
+ key: "index.html:#interview-guest",
433
+ domId: "interview-guest",
434
+ track: 1,
435
+ });
436
+ usePlayerStore.getState().setElements([narration, guest]);
437
+
438
+ const writes = new Map<string, string>();
439
+ const recordEdit = vi.fn();
440
+
441
+ const changedPaths = await createAudioGroupAndAssignMembers({
442
+ projectId: "project-1",
443
+ activeCompPath: "index.html",
444
+ elements: [narration, guest],
445
+ groupId: "voiceover",
446
+ previewIframe: iframe,
447
+ writeProjectFile: async (path, content) => {
448
+ writes.set(path, content);
449
+ },
450
+ recordEdit,
451
+ domEditSaveTimestampRef: { current: 0 },
452
+ pendingTimelineEditPathRef: { current: new Set() },
453
+ });
454
+
455
+ expect(changedPaths).toEqual(["index.html"]);
456
+ expect(
457
+ iframe.contentDocument?.getElementById("narration")?.getAttribute("data-audio-group"),
458
+ ).toBe("voiceover");
459
+ expect(
460
+ iframe.contentDocument?.getElementById("interview-guest")?.getAttribute("data-audio-group"),
461
+ ).toBe("voiceover");
462
+ // One write carrying BOTH members — per-element writes would clobber each
463
+ // other (each starts from the original file content).
464
+ expect(writes.get("index.html")).toContain(
465
+ 'id="narration" data-start="0" data-duration="5" data-audio-group="voiceover"',
466
+ );
467
+ expect(writes.get("index.html")).toContain(
468
+ 'id="interview-guest" data-start="10" data-duration="5" data-audio-group="voiceover"',
469
+ );
470
+ expect(writes.get("index.html")).toContain(
471
+ 'id="sfx-boom" data-start="0" data-duration="1"></audio>',
472
+ );
473
+ expect(recordEdit).toHaveBeenCalledTimes(1);
474
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Group 2 voice clips");
475
+ expect(
476
+ usePlayerStore.getState().elements.find((el) => el.key === "index.html:#narration")
477
+ ?.audioGroup,
478
+ ).toBe("voiceover");
479
+ expect(
480
+ usePlayerStore.getState().elements.find((el) => el.key === "index.html:#interview-guest")
481
+ ?.audioGroup,
482
+ ).toBe("voiceover");
483
+ });
484
+
485
+ it("does nothing for fewer than two elements — grouping is a plural concept", async () => {
486
+ const recordEdit = vi.fn();
487
+ const changedPaths = await createAudioGroupAndAssignMembers({
488
+ projectId: "project-1",
489
+ activeCompPath: "index.html",
490
+ elements: [element({ id: "narration", domId: "narration" })],
491
+ groupId: "voiceover",
492
+ previewIframe: null,
493
+ writeProjectFile: async () => {},
494
+ recordEdit,
495
+ domEditSaveTimestampRef: { current: 0 },
496
+ pendingTimelineEditPathRef: { current: new Set() },
497
+ });
498
+ expect(changedPaths).toEqual([]);
499
+ expect(recordEdit).not.toHaveBeenCalled();
500
+ });
501
+
502
+ it("reverts the optimistic live patch when the save fails", async () => {
503
+ const iframe = document.createElement("iframe");
504
+ document.body.append(iframe);
505
+ if (iframe.contentDocument) {
506
+ iframe.contentDocument.body.innerHTML = `
507
+ <audio id="narration"></audio>
508
+ <audio id="interview-guest"></audio>
509
+ `;
510
+ }
511
+ // No stubbed fetch: readFileContent's request will fail, forcing the
512
+ // catch path.
513
+ const narration = element({ id: "narration", domId: "narration" });
514
+ const guest = element({ id: "interview-guest", domId: "interview-guest" });
515
+
516
+ await expect(
517
+ createAudioGroupAndAssignMembers({
518
+ projectId: "project-1",
519
+ activeCompPath: "index.html",
520
+ elements: [narration, guest],
521
+ groupId: "voiceover",
522
+ previewIframe: iframe,
523
+ writeProjectFile: async () => {},
524
+ recordEdit: vi.fn(),
525
+ domEditSaveTimestampRef: { current: 0 },
526
+ pendingTimelineEditPathRef: { current: new Set() },
527
+ }),
528
+ ).rejects.toThrow();
529
+
530
+ expect(
531
+ iframe.contentDocument?.getElementById("narration")?.hasAttribute("data-audio-group"),
532
+ ).toBe(false);
533
+ expect(
534
+ iframe.contentDocument?.getElementById("interview-guest")?.hasAttribute("data-audio-group"),
535
+ ).toBe(false);
536
+ });
537
+ });
@@ -7,6 +7,8 @@ import {
7
7
  trackDisplaySuffix,
8
8
  } from "../player/components/timelineTrackDisplay";
9
9
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
10
+ import { isAudioTimelineElement } from "../utils/timelineInspector";
11
+ import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
10
12
  import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
11
13
  import {
12
14
  applyPatchByTarget,
@@ -16,7 +18,7 @@ import {
16
18
  type RecordEditInput,
17
19
  } from "./timelineEditingHelpers";
18
20
 
19
- interface MutableRef<T> {
21
+ export interface MutableRef<T> {
20
22
  current: T;
21
23
  }
22
24
 
@@ -66,7 +68,7 @@ interface UseTimelineTrackVisibilityEditingInput extends Omit<
66
68
  forceReloadSdkSession?: () => void;
67
69
  }
68
70
 
69
- interface UseTimelineElementVisibilityEditingInput extends Omit<
71
+ export interface UseTimelineElementVisibilityEditingInput extends Omit<
70
72
  ToggleTimelineElementHiddenInput,
71
73
  "projectId" | "elementKey" | "hidden" | "previewIframe" | "timelineElements"
72
74
  > {
@@ -218,12 +220,21 @@ export async function toggleTimelineTrackHidden({
218
220
  const suffix = trackDisplaySuffix(
219
221
  trackDisplayNumber(timelineTrackOrder(timelineElements), track),
220
222
  );
223
+ const trackElements = timelineElements.filter((element) => element.track === track);
224
+ const isAudioOnlyTrack = trackElements.length > 0 && trackElements.every(isAudioTimelineElement);
225
+ const label = isAudioOnlyTrack
226
+ ? hidden
227
+ ? `Mute track${suffix}`
228
+ : `Unmute track${suffix}`
229
+ : hidden
230
+ ? `Hide track${suffix}`
231
+ : `Show track${suffix}`;
221
232
  return setElementsHidden({
222
233
  projectId,
223
234
  activeCompPath,
224
- elements: timelineElements.filter((element) => element.track === track),
235
+ elements: trackElements,
225
236
  hidden,
226
- label: hidden ? `Hide track${suffix}` : `Show track${suffix}`,
237
+ label,
227
238
  previewIframe,
228
239
  writeProjectFile,
229
240
  recordEdit,
@@ -267,6 +278,114 @@ export async function toggleTimelineElementHidden({
267
278
  });
268
279
  }
269
280
 
281
+ function patchLiveAudioGroupState(
282
+ iframe: HTMLIFrameElement | null,
283
+ elements: readonly TimelineElement[],
284
+ groupId: string | null,
285
+ activeCompPath: string | null,
286
+ ): void {
287
+ for (const element of elements) {
288
+ const target = findTimelineElementInIframe(iframe, element, activeCompPath);
289
+ if (!target) continue;
290
+ if (groupId) target.setAttribute(HF_AUDIO_GROUP_ATTR, groupId);
291
+ else target.removeAttribute(HF_AUDIO_GROUP_ATTR);
292
+ }
293
+ }
294
+
295
+ interface CreateAudioGroupAndAssignMembersInput {
296
+ projectId: string;
297
+ activeCompPath: string | null;
298
+ elements: readonly TimelineElement[];
299
+ groupId: string;
300
+ previewIframe: HTMLIFrameElement | null;
301
+ writeProjectFile: (path: string, content: string) => Promise<void>;
302
+ recordEdit: (input: RecordEditInput) => Promise<void>;
303
+ domEditSaveTimestampRef: MutableRef<number>;
304
+ pendingTimelineEditPathRef: MutableRef<Set<string>>;
305
+ }
306
+
307
+ /**
308
+ * Group two or more voice clips: write `data-audio-group="<groupId>"` on
309
+ * every one of them, atomically, one undo entry — the same multi-target shape
310
+ * `setElementsHidden` uses for mute. The group needs no `<hf-audio-group>`
311
+ * element of its own to exist: `resolveAudioGroups` already degrades
312
+ * gracefully to label = id when one is absent, and a naming dialog is out of
313
+ * scope here — the id itself is the default name.
314
+ */
315
+ // fallow-ignore-next-line complexity
316
+ export async function createAudioGroupAndAssignMembers({
317
+ projectId,
318
+ activeCompPath,
319
+ elements,
320
+ groupId,
321
+ previewIframe,
322
+ writeProjectFile,
323
+ recordEdit,
324
+ domEditSaveTimestampRef,
325
+ pendingTimelineEditPathRef,
326
+ }: CreateAudioGroupAndAssignMembersInput): Promise<string[]> {
327
+ if (elements.length < 2) return [];
328
+
329
+ patchLiveAudioGroupState(previewIframe, elements, groupId, activeCompPath);
330
+ reseekPreviewRuntime(previewIframe);
331
+
332
+ const groupOperation: PatchOperation = {
333
+ type: "attribute",
334
+ property: HF_AUDIO_GROUP_ATTR,
335
+ value: groupId,
336
+ };
337
+ const originalByPath = new Map<string, string>();
338
+ const files: Record<string, string> = {};
339
+
340
+ try {
341
+ for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
342
+ let patchedContent = await readFileContent(projectId, targetPath);
343
+ originalByPath.set(targetPath, patchedContent);
344
+
345
+ for (const element of fileElements) {
346
+ const patchTarget = buildPatchTarget(element);
347
+ if (!patchTarget) {
348
+ throw new Error(`Timeline element ${element.id} is missing a patchable target`);
349
+ }
350
+ if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
351
+ throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
352
+ }
353
+ patchedContent = applyPatchByTarget(patchedContent, patchTarget, groupOperation);
354
+ }
355
+
356
+ files[targetPath] = patchedContent;
357
+ pendingTimelineEditPathRef.current.add(targetPath);
358
+ }
359
+
360
+ domEditSaveTimestampRef.current = Date.now();
361
+ const changedPaths = await saveProjectFilesWithHistory({
362
+ projectId,
363
+ label: `Group ${elements.length} voice clips`,
364
+ kind: "timeline",
365
+ files,
366
+ readFile: async (path) => {
367
+ const original = originalByPath.get(path);
368
+ if (original !== undefined) return original;
369
+ return readFileContent(projectId, path);
370
+ },
371
+ writeFile: writeProjectFile,
372
+ recordEdit,
373
+ });
374
+ domEditSaveTimestampRef.current = Date.now();
375
+ for (const element of elements) {
376
+ usePlayerStore.getState().updateElement(element.key ?? element.id, { audioGroup: groupId });
377
+ }
378
+ return changedPaths;
379
+ } catch (error) {
380
+ // Mirrors setElementsHidden's failure path: the optimistic live patch
381
+ // already ran, so a save failure has to be unwound or the preview shows a
382
+ // grouping that never made it to disk.
383
+ patchLiveAudioGroupState(previewIframe, elements, null, activeCompPath);
384
+ reseekPreviewRuntime(previewIframe);
385
+ throw error;
386
+ }
387
+ }
388
+
270
389
  export function useTimelineTrackVisibilityEditing({
271
390
  projectIdRef,
272
391
  activeCompPath,
@@ -397,3 +516,67 @@ export function useTimelineElementVisibilityEditing({
397
516
  ],
398
517
  );
399
518
  }
519
+
520
+ /**
521
+ * The write behind B6's auto-group: pick two or more voice clips in the carve
522
+ * picker and they land in a group instead of naming each other by id. Same
523
+ * expanded-rows resolution as element-visibility, for the same reason — a
524
+ * nested sub-composition child has no entry in the raw store list.
525
+ */
526
+ export function useAudioGroupCarveAssignment({
527
+ projectIdRef,
528
+ activeCompPath,
529
+ showToast,
530
+ writeProjectFile,
531
+ recordEdit,
532
+ domEditSaveTimestampRef,
533
+ previewIframeRef,
534
+ pendingTimelineEditPathRef,
535
+ isRecordingRef,
536
+ }: UseTimelineElementVisibilityEditingInput): (
537
+ clipIds: readonly string[],
538
+ groupId: string,
539
+ ) => Promise<void> {
540
+ const expandedElements = useExpandedTimelineElements();
541
+ return useCallback(
542
+ async (clipIds: readonly string[], groupId: string) => {
543
+ if (isRecordingRef?.current) {
544
+ showToast("Cannot edit timeline while recording", "error");
545
+ return;
546
+ }
547
+ const pid = projectIdRef.current;
548
+ if (!pid) return;
549
+ const keys = new Set(clipIds);
550
+ const elements = expandedElements.filter((item) => keys.has(item.key ?? item.id));
551
+ try {
552
+ await createAudioGroupAndAssignMembers({
553
+ projectId: pid,
554
+ activeCompPath,
555
+ elements,
556
+ groupId,
557
+ previewIframe: previewIframeRef.current,
558
+ writeProjectFile,
559
+ recordEdit,
560
+ domEditSaveTimestampRef,
561
+ pendingTimelineEditPathRef,
562
+ });
563
+ } catch (error) {
564
+ console.error("[Timeline] Failed to group voice clips", error);
565
+ const message = error instanceof Error ? error.message : "Failed to group voice clips";
566
+ showToast(message);
567
+ }
568
+ },
569
+ [
570
+ activeCompPath,
571
+ expandedElements,
572
+ previewIframeRef,
573
+ writeProjectFile,
574
+ recordEdit,
575
+ domEditSaveTimestampRef,
576
+ pendingTimelineEditPathRef,
577
+ isRecordingRef,
578
+ showToast,
579
+ projectIdRef,
580
+ ],
581
+ );
582
+ }
@@ -7,6 +7,11 @@ import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
7
7
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
8
8
  import { isTypingTarget } from "../utils/typingTarget";
9
9
  import { isEditableTarget } from "../utils/timelineDiscovery";
10
+ import { useCaptionStore } from "../captions/store";
11
+ import {
12
+ applyCaptionModelToIframe,
13
+ isCaptionPreviewVisible,
14
+ } from "../captions/components/CaptionOverlayUtils";
10
15
  import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
11
16
  import { canSplitElement } from "../utils/timelineElementSplit";
12
17
  import { trackStudioEvent } from "../utils/studioTelemetry";
@@ -413,6 +418,23 @@ export function useAppHotkeys({
413
418
 
414
419
  const applyHistory = useCallback(
415
420
  async (direction: "undo" | "redo") => {
421
+ // Caption edits live in their own in-memory stack. While caption edit
422
+ // mode is active, ⌘Z must revert the caption edit — not an unrelated
423
+ // earlier file edit (which would ALSO leave the caption change intact).
424
+ const captionState = useCaptionStore.getState();
425
+ // Only when the caption preview is actually visible: isEditMode stays
426
+ // true while the preview is hidden (storyboard view), and eating ⌘Z
427
+ // there would pop invisible caption edits instead of file history.
428
+ if (captionState.isEditMode && isCaptionPreviewVisible()) {
429
+ const restored = direction === "undo" ? captionState.undo() : captionState.redo();
430
+ if (restored) {
431
+ applyCaptionModelToIframe(restored);
432
+ showToast(`${direction === "undo" ? "Undid" : "Redid"} caption edit`, "info");
433
+ return;
434
+ }
435
+ // Empty caption stack: fall through to beat/file history as usual.
436
+ }
437
+
416
438
  // Beat edits interleave with file history by timestamp; handle them first.
417
439
  if (tryApplyBeatHistory(direction, editHistory.state, showToast)) return;
418
440
 
@@ -0,0 +1,61 @@
1
+ import { useEffect, useMemo } from "react";
2
+ import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
3
+ import { usePlayerStore } from "../player/store/playerStore";
4
+ import { getTimelineElementDisplayLabel } from "../player/lib/timelineElementHelpers";
5
+
6
+ interface IframeWindow extends Window {
7
+ __hf?: { setAudioSolo?: (ids: readonly string[]) => void };
8
+ }
9
+
10
+ /**
11
+ * Pushes the studio's "Hear only this" set into the preview runtime whenever
12
+ * it changes. A dedicated push, not a DOM write: solo is session-only and
13
+ * must never touch an attribute (design doc §2.2 / the export-safety
14
+ * guarantee), so it can't ride `syncTimedElementVisibility`'s attribute-diff
15
+ * the way group mute does — see `window.__hf.setAudioSolo`.
16
+ */
17
+ export function useAudioSoloBridge(previewIframeRef: { current: HTMLIFrameElement | null }): void {
18
+ const soloed = usePlayerStore((s) => s.soloed);
19
+ useEffect(() => {
20
+ const win = previewIframeRef.current?.contentWindow as IframeWindow | null;
21
+ win?.__hf?.setAudioSolo?.([...soloed]);
22
+ }, [soloed, previewIframeRef]);
23
+ }
24
+
25
+ /** One soloed id's display label — reads the live preview DOM directly (same
26
+ * approach as `patchLiveGroupAttribute`), since solo ids are never anywhere
27
+ * but the document's own element ids. A group carries its label on
28
+ * `data-label`; anything else falls back to the same label rule the
29
+ * timeline itself uses. */
30
+ function resolveSoloLabel(doc: Document | null | undefined, id: string): string {
31
+ const el = doc?.getElementById(id);
32
+ if (!el) return id;
33
+ if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) {
34
+ return getTimelineElementDisplayLabel({ id, label: el.getAttribute("data-label") });
35
+ }
36
+ return getTimelineElementDisplayLabel({
37
+ id,
38
+ label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
39
+ tag: el.tagName,
40
+ });
41
+ }
42
+
43
+ /**
44
+ * The transport bar's "Hear only this" banner text — `null` while nothing is
45
+ * soloed. One name when exactly one thing is soloed, `"N tracks"` otherwise
46
+ * (design doc §2.2's banner rule); "your export is not affected" is fixed
47
+ * copy the caller owns, this only resolves the variable half.
48
+ */
49
+ export function useSoloBannerText(previewIframeRef: {
50
+ current: HTMLIFrameElement | null;
51
+ }): string | null {
52
+ const soloed = usePlayerStore((s) => s.soloed);
53
+ return useMemo(() => {
54
+ if (soloed.size === 0) return null;
55
+ if (soloed.size === 1) {
56
+ const doc = previewIframeRef.current?.contentDocument;
57
+ return resolveSoloLabel(doc, [...soloed][0]);
58
+ }
59
+ return `${soloed.size} tracks`;
60
+ }, [soloed, previewIframeRef]);
61
+ }
@@ -1,4 +1,4 @@
1
- import { useEffect } from "react";
1
+ import { useEffect, useRef } from "react";
2
2
  import { useCaptionStore } from "../captions/store";
3
3
  import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
4
4
  import { useCaptionSync } from "../captions/hooks/useCaptionSync";
@@ -25,6 +25,24 @@ export function useCaptionDetection({
25
25
  captionSync,
26
26
  setRightCollapsed,
27
27
  }: UseCaptionDetectionParams) {
28
+ // Switching compositions must drop the previous comp's caption state — a
29
+ // stale model + full-canvas overlay otherwise blocks normal element editing
30
+ // on the new composition (and edit mode could never be exited).
31
+ const prevCompPathRef = useRef<string | null>(activeCompPath);
32
+ // eslint-disable-next-line no-restricted-syntax
33
+ useEffect(() => {
34
+ if (prevCompPathRef.current !== activeCompPath) {
35
+ prevCompPathRef.current = activeCompPath;
36
+ const store = useCaptionStore.getState();
37
+ if (store.model || store.isEditMode) {
38
+ // Flush the last debounced caption edit before the reset destroys the
39
+ // model — otherwise a save landing after the switch writes nothing.
40
+ store.retrySave?.();
41
+ store.reset();
42
+ }
43
+ }
44
+ }, [activeCompPath]);
45
+
28
46
  // eslint-disable-next-line no-restricted-syntax
29
47
  useEffect(() => {
30
48
  if (!projectId) return;
@@ -32,7 +50,9 @@ export function useCaptionDetection({
32
50
  let activating = false;
33
51
 
34
52
  const tryActivateCaptions = () => {
35
- if (useCaptionStore.getState().isEditMode || activating) {
53
+ const captionState = useCaptionStore.getState();
54
+ // `dismissed` = user explicitly exited caption editing; don't re-trap them.
55
+ if (captionState.isEditMode || captionState.dismissed || activating) {
36
56
  return;
37
57
  }
38
58
 
@@ -1376,6 +1376,30 @@ describe("useDomEditCommits attribute persist handling", () => {
1376
1376
  }
1377
1377
  });
1378
1378
 
1379
+ it("sets and removes data-audio-group like any other data attribute", async () => {
1380
+ stubPatchFetch({ ok: true, changed: true, matched: true });
1381
+ const { iframe, element } = createPreviewElement();
1382
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
1383
+
1384
+ try {
1385
+ await act(async () => {
1386
+ await rendered.hook.handleDomAttributeLiveCommit("audio-group", "voiceover", undefined, {
1387
+ previewOnly: true,
1388
+ });
1389
+ });
1390
+ expect(element.getAttribute("data-audio-group")).toBe("voiceover");
1391
+
1392
+ await act(async () => {
1393
+ await rendered.hook.handleDomAttributeLiveCommit("audio-group", "", undefined, {
1394
+ previewOnly: true,
1395
+ });
1396
+ });
1397
+ expect(element.getAttribute("data-audio-group")).toBeNull();
1398
+ } finally {
1399
+ rendered.cleanup();
1400
+ }
1401
+ });
1402
+
1379
1403
  it("keeps a data-attribute commit on success", async () => {
1380
1404
  stubPatchFetch({
1381
1405
  ok: true,
@@ -0,0 +1,20 @@
1
+ import { useMemo } from "react";
2
+ import type { TimelineElement } from "../player/store/timelineElement";
3
+
4
+ /**
5
+ * The stored `duration` lags a moment behind an edit that pushes an element
6
+ * past it (drag, trim, paste) — this is the actual end of the timeline, the
7
+ * later of the stored duration and the furthest element's end.
8
+ */
9
+ export function useEffectiveTimelineDuration(
10
+ timelineDuration: number,
11
+ timelineElements: readonly TimelineElement[],
12
+ ): 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]);
20
+ }