@hyperframes/studio 0.8.6 → 0.8.8

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 (155) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
  2. package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
  3. package/dist/assets/index-Ba9zbpT9.css +1 -0
  4. package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
  5. package/dist/assets/index-ng4tq6YE.js +428 -0
  6. package/dist/index.d.ts +108 -18
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10335 -7201
  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 +3 -5
  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 +253 -1
  89. package/src/hooks/timelineTrackVisibility.ts +207 -8
  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 +8 -8
  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 +8 -1
  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.test.tsx +2 -1
  124. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  125. package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
  126. package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
  127. package/src/player/components/VideoThumbnail.tsx +6 -1
  128. package/src/player/components/menuKeyboardNav.ts +47 -0
  129. package/src/player/components/timelineCallbacks.ts +40 -1
  130. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  131. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  132. package/src/player/components/timelineLaneProps.ts +3 -0
  133. package/src/player/components/timelineLayout.ts +1 -1
  134. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  135. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  136. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  137. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  138. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  139. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  140. package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
  141. package/src/player/components/useTimelineTrackDerivations.ts +143 -6
  142. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  143. package/src/player/hooks/previewMessageRouter.ts +127 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  145. package/src/player/lib/timelineDOM.ts +51 -0
  146. package/src/player/store/audioSoloSlice.test.ts +113 -0
  147. package/src/player/store/audioSoloSlice.ts +43 -0
  148. package/src/player/store/editingModeSlice.ts +45 -0
  149. package/src/player/store/groupLevels.ts +33 -0
  150. package/src/player/store/keyframeSlice.ts +26 -0
  151. package/src/player/store/playerStore.ts +21 -22
  152. package/src/player/store/timelineElement.ts +10 -0
  153. package/src/telemetry/events.ts +12 -0
  154. package/dist/assets/index-Cy6fGN1u.js +0 -428
  155. 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,118 @@ 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
+ // The header derives its row from the GROUP-AWARE order (a synthetic anchor
227
+ // row, members pulled contiguous); this function's own fallback derives it
228
+ // from ascending element-bearing keys. Once a group exists those disagree, so
229
+ // the same click announced "Mute track 2" and recorded "Mute track 1". The
230
+ // clicked control passes the row it showed, and that is what gets recorded.
231
+ it("records the display row the clicked control announced, not its own derivation", async () => {
232
+ const files = new Map([
233
+ ["index.html", `<div id="voiceover" data-start="0" data-duration="2"></div>`],
234
+ ]);
235
+ stubProjectFiles(files);
236
+
237
+ const recordEdit = vi.fn();
238
+
239
+ await toggleTimelineTrackHidden({
240
+ projectId: "project-1",
241
+ activeCompPath: "index.html",
242
+ timelineElements: [element({ id: "voiceover", domId: "voiceover", track: 0, tag: "audio" })],
243
+ track: 0,
244
+ hidden: true,
245
+ // Row 2 in the header: the group's anchor row sits above this member.
246
+ displayNumber: 2,
247
+ previewIframe: null,
248
+ writeProjectFile: async () => {},
249
+ recordEdit,
250
+ domEditSaveTimestampRef: { current: 0 },
251
+ pendingTimelineEditPathRef: { current: new Set() },
252
+ });
253
+
254
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Mute track 2");
255
+ });
256
+
257
+ it("labels unmuting an audio-only track back on", async () => {
258
+ const files = new Map([
259
+ ["index.html", `<div id="voiceover" data-start="0" data-duration="2" data-hidden=""></div>`],
260
+ ]);
261
+ stubProjectFiles(files);
262
+
263
+ const recordEdit = vi.fn();
264
+
265
+ await toggleTimelineTrackHidden({
266
+ projectId: "project-1",
267
+ activeCompPath: "index.html",
268
+ timelineElements: [
269
+ element({ id: "voiceover", domId: "voiceover", track: 0, tag: "audio", hidden: true }),
270
+ ],
271
+ track: 0,
272
+ hidden: false,
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("Unmute track 1");
281
+ });
282
+
283
+ it("keeps Hide/Show wording for a mixed (audio + visual) track", async () => {
284
+ const files = new Map([
285
+ [
286
+ "index.html",
287
+ `<div id="voiceover" data-start="0" data-duration="2"></div>
288
+ <div id="caption" data-start="0" data-duration="2"></div>`,
289
+ ],
290
+ ]);
291
+ stubProjectFiles(files);
292
+
293
+ const recordEdit = vi.fn();
294
+
295
+ await toggleTimelineTrackHidden({
296
+ projectId: "project-1",
297
+ activeCompPath: "index.html",
298
+ timelineElements: [
299
+ element({ id: "voiceover", domId: "voiceover", track: 0, tag: "audio" }),
300
+ element({ id: "caption", domId: "caption", track: 0, tag: "div" }),
301
+ ],
302
+ track: 0,
303
+ hidden: true,
304
+ previewIframe: null,
305
+ writeProjectFile: async () => {},
306
+ recordEdit,
307
+ domEditSaveTimestampRef: { current: 0 },
308
+ pendingTimelineEditPathRef: { current: new Set() },
309
+ });
310
+
311
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 1");
312
+ });
197
313
  });
198
314
 
199
315
  describe("toggleTimelineElementHidden", () => {
@@ -314,3 +430,139 @@ describe("toggleTimelineElementHidden", () => {
314
430
  expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide 2 elements");
315
431
  });
316
432
  });
433
+
434
+ describe("createAudioGroupAndAssignMembers", () => {
435
+ it("writes data-audio-group on every member in ONE atomic edit and updates the player store", async () => {
436
+ const iframe = document.createElement("iframe");
437
+ document.body.append(iframe);
438
+ if (iframe.contentDocument) {
439
+ iframe.contentDocument.body.innerHTML = `
440
+ <audio id="narration"></audio>
441
+ <audio id="interview-guest"></audio>
442
+ `;
443
+ }
444
+
445
+ const files = new Map([
446
+ [
447
+ "index.html",
448
+ `<audio id="narration" data-start="0" data-duration="5"></audio>
449
+ <audio id="interview-guest" data-start="10" data-duration="5"></audio>
450
+ <audio id="sfx-boom" data-start="0" data-duration="1"></audio>`,
451
+ ],
452
+ ]);
453
+ stubProjectFiles(files);
454
+
455
+ const narration = element({
456
+ id: "narration",
457
+ key: "index.html:#narration",
458
+ domId: "narration",
459
+ track: 0,
460
+ });
461
+ const guest = element({
462
+ id: "interview-guest",
463
+ key: "index.html:#interview-guest",
464
+ domId: "interview-guest",
465
+ track: 1,
466
+ });
467
+ usePlayerStore.getState().setElements([narration, guest]);
468
+
469
+ const writes = new Map<string, string>();
470
+ const recordEdit = vi.fn();
471
+
472
+ const changedPaths = await createAudioGroupAndAssignMembers({
473
+ projectId: "project-1",
474
+ activeCompPath: "index.html",
475
+ elements: [narration, guest],
476
+ groupId: "voiceover",
477
+ previewIframe: iframe,
478
+ writeProjectFile: async (path, content) => {
479
+ writes.set(path, content);
480
+ },
481
+ recordEdit,
482
+ domEditSaveTimestampRef: { current: 0 },
483
+ pendingTimelineEditPathRef: { current: new Set() },
484
+ });
485
+
486
+ expect(changedPaths).toEqual(["index.html"]);
487
+ expect(
488
+ iframe.contentDocument?.getElementById("narration")?.getAttribute("data-audio-group"),
489
+ ).toBe("voiceover");
490
+ expect(
491
+ iframe.contentDocument?.getElementById("interview-guest")?.getAttribute("data-audio-group"),
492
+ ).toBe("voiceover");
493
+ // One write carrying BOTH members — per-element writes would clobber each
494
+ // other (each starts from the original file content).
495
+ expect(writes.get("index.html")).toContain(
496
+ 'id="narration" data-start="0" data-duration="5" data-audio-group="voiceover"',
497
+ );
498
+ expect(writes.get("index.html")).toContain(
499
+ 'id="interview-guest" data-start="10" data-duration="5" data-audio-group="voiceover"',
500
+ );
501
+ expect(writes.get("index.html")).toContain(
502
+ 'id="sfx-boom" data-start="0" data-duration="1"></audio>',
503
+ );
504
+ expect(recordEdit).toHaveBeenCalledTimes(1);
505
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Group 2 voice clips");
506
+ expect(
507
+ usePlayerStore.getState().elements.find((el) => el.key === "index.html:#narration")
508
+ ?.audioGroup,
509
+ ).toBe("voiceover");
510
+ expect(
511
+ usePlayerStore.getState().elements.find((el) => el.key === "index.html:#interview-guest")
512
+ ?.audioGroup,
513
+ ).toBe("voiceover");
514
+ });
515
+
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({
519
+ projectId: "project-1",
520
+ activeCompPath: "index.html",
521
+ elements: [element({ id: "narration", domId: "narration" })],
522
+ groupId: "voiceover",
523
+ previewIframe: null,
524
+ writeProjectFile: async () => {},
525
+ recordEdit,
526
+ domEditSaveTimestampRef: { current: 0 },
527
+ pendingTimelineEditPathRef: { current: new Set() },
528
+ });
529
+ expect(changedPaths).toEqual([]);
530
+ expect(recordEdit).not.toHaveBeenCalled();
531
+ });
532
+
533
+ it("reverts the optimistic live patch when the save fails", async () => {
534
+ const iframe = document.createElement("iframe");
535
+ document.body.append(iframe);
536
+ if (iframe.contentDocument) {
537
+ iframe.contentDocument.body.innerHTML = `
538
+ <audio id="narration"></audio>
539
+ <audio id="interview-guest"></audio>
540
+ `;
541
+ }
542
+ // No stubbed fetch: readFileContent's request will fail, forcing the
543
+ // catch path.
544
+ const narration = element({ id: "narration", domId: "narration" });
545
+ const guest = element({ id: "interview-guest", domId: "interview-guest" });
546
+
547
+ await expect(
548
+ createAudioGroupAndAssignMembers({
549
+ projectId: "project-1",
550
+ activeCompPath: "index.html",
551
+ elements: [narration, guest],
552
+ groupId: "voiceover",
553
+ previewIframe: iframe,
554
+ writeProjectFile: async () => {},
555
+ recordEdit: vi.fn(),
556
+ domEditSaveTimestampRef: { current: 0 },
557
+ pendingTimelineEditPathRef: { current: new Set() },
558
+ }),
559
+ ).rejects.toThrow();
560
+
561
+ expect(
562
+ iframe.contentDocument?.getElementById("narration")?.hasAttribute("data-audio-group"),
563
+ ).toBe(false);
564
+ expect(
565
+ iframe.contentDocument?.getElementById("interview-guest")?.hasAttribute("data-audio-group"),
566
+ ).toBe(false);
567
+ });
568
+ });
@@ -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
 
@@ -30,6 +32,13 @@ interface ToggleTimelineTrackHiddenInput {
30
32
  timelineElements: readonly TimelineElement[];
31
33
  track: number;
32
34
  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
+ */
41
+ displayNumber?: number | null;
33
42
  previewIframe: HTMLIFrameElement | null;
34
43
  writeProjectFile: (path: string, content: string) => Promise<void>;
35
44
  recordEdit: (input: RecordEditInput) => Promise<void>;
@@ -66,7 +75,7 @@ interface UseTimelineTrackVisibilityEditingInput extends Omit<
66
75
  forceReloadSdkSession?: () => void;
67
76
  }
68
77
 
69
- interface UseTimelineElementVisibilityEditingInput extends Omit<
78
+ export interface UseTimelineElementVisibilityEditingInput extends Omit<
70
79
  ToggleTimelineElementHiddenInput,
71
80
  "projectId" | "elementKey" | "hidden" | "previewIframe" | "timelineElements"
72
81
  > {
@@ -207,6 +216,7 @@ export async function toggleTimelineTrackHidden({
207
216
  timelineElements,
208
217
  track,
209
218
  hidden,
219
+ displayNumber,
210
220
  previewIframe,
211
221
  writeProjectFile,
212
222
  recordEdit,
@@ -214,16 +224,28 @@ export async function toggleTimelineTrackHidden({
214
224
  pendingTimelineEditPathRef,
215
225
  }: ToggleTimelineTrackHiddenInput): Promise<string[]> {
216
226
  // `track` is the fractional sort key the callback needs; the history entry is
217
- // read by a human, so it gets the display row instead.
227
+ // read by a human, so it gets the display row instead — the one the clicked
228
+ // control announced, when the caller passed it. Deriving it again here would
229
+ // use ascending element-bearing keys, which stop matching the header as soon
230
+ // as an audio group reorders the rows and inserts an anchor.
218
231
  const suffix = trackDisplaySuffix(
219
- trackDisplayNumber(timelineTrackOrder(timelineElements), track),
232
+ displayNumber ?? trackDisplayNumber(timelineTrackOrder(timelineElements), track),
220
233
  );
234
+ const trackElements = timelineElements.filter((element) => element.track === track);
235
+ const isAudioOnlyTrack = trackElements.length > 0 && trackElements.every(isAudioTimelineElement);
236
+ const label = isAudioOnlyTrack
237
+ ? hidden
238
+ ? `Mute track${suffix}`
239
+ : `Unmute track${suffix}`
240
+ : hidden
241
+ ? `Hide track${suffix}`
242
+ : `Show track${suffix}`;
221
243
  return setElementsHidden({
222
244
  projectId,
223
245
  activeCompPath,
224
- elements: timelineElements.filter((element) => element.track === track),
246
+ elements: trackElements,
225
247
  hidden,
226
- label: hidden ? `Hide track${suffix}` : `Show track${suffix}`,
248
+ label,
227
249
  previewIframe,
228
250
  writeProjectFile,
229
251
  recordEdit,
@@ -267,6 +289,114 @@ export async function toggleTimelineElementHidden({
267
289
  });
268
290
  }
269
291
 
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
+
270
400
  export function useTimelineTrackVisibilityEditing({
271
401
  projectIdRef,
272
402
  activeCompPath,
@@ -278,14 +408,18 @@ export function useTimelineTrackVisibilityEditing({
278
408
  pendingTimelineEditPathRef,
279
409
  isRecordingRef,
280
410
  forceReloadSdkSession,
281
- }: UseTimelineTrackVisibilityEditingInput): (track: number, hidden: boolean) => Promise<void> {
411
+ }: UseTimelineTrackVisibilityEditingInput): (
412
+ track: number,
413
+ hidden: boolean,
414
+ displayNumber?: number | null,
415
+ ) => Promise<void> {
282
416
  // Resolve the eye toggle against the EXPANDED rows the canvas actually renders:
283
417
  // virtual sub-comp children carry their own (display.track + idx) track numbers,
284
418
  // so filtering the raw store list by a virtual track number would hide the wrong
285
419
  // outer-scene sibling sharing that index.
286
420
  const expandedElements = useExpandedTimelineElements();
287
421
  return useCallback(
288
- async (track: number, hidden: boolean) => {
422
+ async (track: number, hidden: boolean, displayNumber?: number | null) => {
289
423
  if (isRecordingRef?.current) {
290
424
  showToast("Cannot edit timeline while recording", "error");
291
425
  return;
@@ -299,6 +433,7 @@ export function useTimelineTrackVisibilityEditing({
299
433
  timelineElements: expandedElements,
300
434
  track,
301
435
  hidden,
436
+ displayNumber,
302
437
  previewIframe: previewIframeRef.current,
303
438
  writeProjectFile,
304
439
  recordEdit,
@@ -397,3 +532,67 @@ export function useTimelineElementVisibilityEditing({
397
532
  ],
398
533
  );
399
534
  }
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
+ }
@@ -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
+ }