@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
@@ -74,6 +74,8 @@ export function MetricField({
74
74
  onPointerDown: handleScrubPointerDown,
75
75
  onPointerMove: handleScrubPointerMove,
76
76
  onPointerUp: handleScrubPointerUp,
77
+ onPointerCancel: handleScrubPointerUp,
78
+ onLostPointerCapture: handleScrubPointerUp,
77
79
  } as const)
78
80
  : ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500" } as const);
79
81
 
@@ -180,6 +182,7 @@ export function SliderControl({
180
182
  step={step}
181
183
  value={draft}
182
184
  disabled={disabled}
185
+ aria-label={trackName}
183
186
  onChange={(e) => {
184
187
  const n = Number(e.target.value);
185
188
  setDraft(n);
@@ -228,6 +231,7 @@ export function SegmentedControl({
228
231
  if (option.value !== value) track("segmented", trackName);
229
232
  onChange(option.value);
230
233
  }}
234
+ aria-pressed={option.value === value}
231
235
  className={`min-w-0 truncate rounded px-2 py-[5px] text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
232
236
  option.value === value
233
237
  ? "bg-panel-hover text-white"
@@ -292,23 +296,15 @@ export function Section({
292
296
  defaultCollapsed?: boolean;
293
297
  }) {
294
298
  const [collapsed, setCollapsed] = useState(defaultCollapsed);
295
- const collapseIcon = collapsed ? (
296
- <svg
297
- width="12"
298
- height="12"
299
- viewBox="0 0 12 12"
300
- fill="none"
301
- className="flex-shrink-0 text-panel-text-5"
302
- >
303
- <path d="M6 2.5v7M2.5 6h7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
304
- </svg>
305
- ) : (
299
+ const collapseIcon = (
306
300
  <svg
307
301
  width="10"
308
302
  height="10"
309
303
  viewBox="0 0 10 10"
310
304
  fill="currentColor"
311
- className="flex-shrink-0 text-panel-text-5"
305
+ className={`flex-shrink-0 text-panel-text-5 transition-transform duration-150 ${
306
+ collapsed ? "-rotate-90" : ""
307
+ }`}
312
308
  >
313
309
  <path d="M2 3l3 4 3-4z" />
314
310
  </svg>
@@ -322,6 +318,7 @@ export function Section({
322
318
  <button
323
319
  type="button"
324
320
  onClick={() => setCollapsed((v) => !v)}
321
+ aria-expanded={!collapsed}
325
322
  className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"
326
323
  >
327
324
  <h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
  import { Eye, Layers, Palette, Settings, Square, Zap } from "../../icons/SystemIcons";
3
3
  import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
4
4
  import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
@@ -116,21 +116,40 @@ export function StyleSections({
116
116
  : "Solid";
117
117
  const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
118
118
  const imageUrl = extractBackgroundImageUrl(backgroundImage);
119
+ // Remember the last authored gradient so an exploratory Solid↔Gradient
120
+ // toggle restores it instead of destroying it with the default gradient.
121
+ const lastGradientRef = useRef<string | null>(
122
+ backgroundImage.includes("gradient") ? backgroundImage : null,
123
+ );
124
+
125
+ // The remembered gradient is per-element: reset on selection change so
126
+ // element A's gradient can't leak onto element B via the fill-mode toggle.
127
+ // (The effect below re-stores it when the new element has its own gradient.)
128
+ useEffect(() => {
129
+ lastGradientRef.current = null;
130
+ }, [element.id, element.selector]);
119
131
 
120
132
  useEffect(() => {
121
133
  setPreferredFillMode(fillMode);
134
+ if (backgroundImage.includes("gradient")) {
135
+ lastGradientRef.current = backgroundImage;
136
+ }
122
137
  }, [fillMode, element.id, element.selector, backgroundImage]);
123
138
 
124
139
  const handleFillModeChange = (nextMode: string) => {
125
140
  setPreferredFillMode(nextMode);
126
141
  if (nextMode === "Solid") {
142
+ if (backgroundImage.includes("gradient")) {
143
+ lastGradientRef.current = backgroundImage;
144
+ }
127
145
  onSetStyle("background-image", "none");
128
146
  return;
129
147
  }
130
148
  if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
131
149
  onSetStyle(
132
150
  "background-image",
133
- serializeGradient(buildDefaultGradientModel(styles["background-color"])),
151
+ lastGradientRef.current ??
152
+ serializeGradient(buildDefaultGradientModel(styles["background-color"])),
134
153
  );
135
154
  }
136
155
  };
@@ -69,7 +69,7 @@ export function createTransformCommitHandlers({
69
69
  await commitAnimatedTransformValue(
70
70
  axis,
71
71
  parsed,
72
- "Cannot edit position — animation callbacks not available",
72
+ "This element's position can't be edited here yet it is driven by its animation",
73
73
  )
74
74
  )
75
75
  return;
@@ -91,7 +91,7 @@ export function createTransformCommitHandlers({
91
91
  return;
92
92
  }
93
93
  if (hasGsapAnimation) {
94
- showToast?.("Cannot edit size — animation callbacks not available");
94
+ showToast?.("This element's size can't be edited here yet it is driven by its animation");
95
95
  return;
96
96
  }
97
97
  const current = readStudioBoxSize(element.element);
@@ -118,7 +118,7 @@ export function createTransformCommitHandlers({
118
118
  await commitAnimatedTransformValue(
119
119
  "rotation",
120
120
  parsed,
121
- "Cannot edit rotation — animation callbacks not available",
121
+ "This element's rotation can't be edited here yet it is driven by its animation",
122
122
  )
123
123
  )
124
124
  return;
@@ -92,6 +92,8 @@ export interface PropertyPanelProps {
92
92
  onRemoveTextField: (fieldKey: string) => void;
93
93
  onAskAgent: () => void;
94
94
  onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
95
+ /** B6: group two or more picked voice clips, atomically, one undo entry. */
96
+ onAutoGroupCarveSources?: (clipIds: readonly string[], groupId: string) => Promise<void>;
95
97
  onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
96
98
  onAddMediaOverlay?: AddMediaOverlayHandler;
97
99
  fontAssets?: ImportedFontAsset[];
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Append (or, on reapply, replace in place) a preset's nodes onto a chain,
3
+ * with the telemetry that decision needs — the write path the property
4
+ * panel's FX section and the timeline FX popover (C1) both use, so a preset
5
+ * applied from either surface counts the same way.
6
+ */
7
+
8
+ import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
9
+ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
10
+ import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
11
+ import { trackPresetApplied } from "./audioFxTelemetry.js";
12
+
13
+ export function applyPresetToChain(
14
+ chain: HfAudioFxChain,
15
+ presetId: string,
16
+ trackKind: HfAudioNameKind | undefined,
17
+ ): HfAudioFxChain | null {
18
+ const preset = getAudioFxPreset(presetId);
19
+ if (!preset) return null;
20
+ // Appends. Stacking a character preset onto an already-cleaned voice is a
21
+ // real thing to want, and replacing silently would throw work away — so
22
+ // the destructive option is a separate gesture, not the default one.
23
+ const next = applyAudioFxPreset(chain, preset);
24
+ // Re-applying replaces this preset's own nodes in place rather than
25
+ // appending a second copy, and the two are different decisions — worth
26
+ // telling apart in the numbers.
27
+ const reapply = chain.nodes.some((n) => n.fromPreset === preset.id);
28
+ trackPresetApplied(
29
+ preset.id,
30
+ preset.family,
31
+ preset.nodes.length,
32
+ reapply ? "reapply" : "append",
33
+ { trackKind },
34
+ );
35
+ return next;
36
+ }
Binary file
@@ -0,0 +1,132 @@
1
+ /**
2
+ * B6's normative rule: plural voiceover carve always targets a group.
3
+ *
4
+ * Split out of `useFxCarve.ts`, which owned all of this before the file grew
5
+ * past a size where "carve targets a group" was still one thing to read —
6
+ * same reason `useFxCarve.ts` itself was split out of
7
+ * `propertyPanelAudioFxGroup.tsx`.
8
+ */
9
+
10
+ import { classifyAudioName, type HfCarveSettings } from "@hyperframes/core/audio-carve";
11
+ import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
12
+
13
+ /**
14
+ * An id for a new voiceover group, de-duped against every id already in the
15
+ * document — group ids and plain element ids share one namespace, so both
16
+ * have to be checked.
17
+ */
18
+ export function mintGroupId(doc: Document): string {
19
+ const taken = new Set([
20
+ ...resolveAudioGroups(doc).map((g) => g.id),
21
+ ...Array.from(doc.querySelectorAll("[id]")).map((el) => el.id),
22
+ ]);
23
+ if (!taken.has("voiceover")) return "voiceover";
24
+ let n = 2;
25
+ while (taken.has(`voiceover-${n}`)) n += 1;
26
+ return `voiceover-${n}`;
27
+ }
28
+
29
+ export function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
30
+ return typeof (value as { then?: unknown })?.then === "function";
31
+ }
32
+
33
+ /**
34
+ * Plural voiceover carve, always against a group — normative, not a
35
+ * suggestion (groups doc §1.6). Picking a second ungrouped voice clip mints a
36
+ * group behind the two of them and points the carve at it instead, the same
37
+ * way a hand-authored composition is expected to work; a source list already
38
+ * naming a group is left alone; this only fires on a run of plain clip ids.
39
+ *
40
+ * Returns the settings unchanged, synchronously, when there is nothing to do —
41
+ * NOT wrapped in a promise even though the caller may `await` the result. An
42
+ * `async` function always yields a microtask, which would push every
43
+ * `setCarve` call (grouped or not) one tick later than before this existed;
44
+ * several tests assert on `onSetAttributeQuiet` synchronously after mount and
45
+ * would miss that write.
46
+ */
47
+ function withAutoGroupedSources(
48
+ doc: Document,
49
+ next: HfCarveSettings,
50
+ assignGroup: ((clipIds: readonly string[], groupId: string) => Promise<void>) | undefined,
51
+ ): HfCarveSettings | Promise<HfCarveSettings> {
52
+ if (!assignGroup || next.sources.length < 2) return next;
53
+ const groupIds = new Set(resolveAudioGroups(doc).map((g) => g.id));
54
+ if (next.sources.some((id) => groupIds.has(id))) return next;
55
+ const groupId = mintGroupId(doc);
56
+ return assignGroup(next.sources, groupId).then(() => ({ ...next, sources: [groupId] }));
57
+ }
58
+
59
+ /**
60
+ * `setCarve`'s first step: apply the auto-group rule, if a document and a
61
+ * write-back are both available.
62
+ *
63
+ * Deliberately NOT an `async function`: wrapping this in one would make every
64
+ * call return a promise-wrapped value, forcing the caller's `await` to yield
65
+ * a microtask even on the synchronous branch — the exact bug
66
+ * `withAutoGroupedSources`'s own sync-when-possible contract exists to avoid.
67
+ * The caller does the `isPromiseLike` check (re-exported from here) and only
68
+ * awaits the branch that is genuinely async.
69
+ */
70
+ export function resolveNextCarveSettings(
71
+ nextRaw: HfCarveSettings | null,
72
+ doc: Document | undefined,
73
+ assignGroup: ((clipIds: readonly string[], groupId: string) => Promise<void>) | undefined,
74
+ ): HfCarveSettings | Promise<HfCarveSettings> | null {
75
+ return nextRaw && doc ? withAutoGroupedSources(doc, nextRaw, assignGroup) : nextRaw;
76
+ }
77
+
78
+ export interface CarveCandidate {
79
+ id: string;
80
+ label: string;
81
+ kind: ReturnType<typeof classifyAudioName>;
82
+ }
83
+
84
+ /**
85
+ * One row per ungrouped clip that overlaps the bed, one row per group that has
86
+ * ANY overlapping member (union, not per-clip — a narration group spanning
87
+ * the whole timeline is relevant even if each of its segments only overlaps
88
+ * part of the bed). Grouped members never appear individually.
89
+ */
90
+ export function collectCarveCandidates(
91
+ doc: Document,
92
+ others: readonly HTMLAudioElement[],
93
+ overlapsBed: (a: Element) => boolean,
94
+ ): CarveCandidate[] {
95
+ const groupByMember = new Map(
96
+ resolveAudioGroups(doc).flatMap((group) => group.memberIds.map((id) => [id, group] as const)),
97
+ );
98
+ const offeredGroupIds = new Set<string>();
99
+ const described: CarveCandidate[] = [];
100
+ for (const a of others) {
101
+ const group = groupByMember.get(a.id);
102
+ if (!group) {
103
+ if (overlapsBed(a)) {
104
+ described.push({
105
+ id: a.id,
106
+ label: a.id,
107
+ kind: classifyAudioName(a.id, a.getAttribute("src")),
108
+ });
109
+ }
110
+ continue;
111
+ }
112
+ if (offeredGroupIds.has(group.id)) continue;
113
+ const members = group.memberIds
114
+ .map((id) => doc.getElementById(id))
115
+ // Not `instanceof HTMLAudioElement`: these belong to the composition's
116
+ // iframe document, so the constructor is a different realm's and the
117
+ // instanceof is false for every one (mirrors resolveCarveVoices in
118
+ // useFxCarve.ts).
119
+ .filter((el): el is HTMLElement => el?.tagName === "AUDIO");
120
+ if (!members.some(overlapsBed)) continue;
121
+ offeredGroupIds.add(group.id);
122
+ described.push({
123
+ id: group.id,
124
+ label: `${group.label} (${members.length})`,
125
+ kind: classifyAudioName(
126
+ group.label,
127
+ ...members.flatMap((m) => [m.id, m.getAttribute("src")]),
128
+ ),
129
+ });
130
+ }
131
+ return described;
132
+ }
@@ -1,5 +1,6 @@
1
- import { useState } from "react";
1
+ import { useCallback, useState } from "react";
2
2
  import { CaptionOverlay } from "../../captions/components/CaptionOverlay";
3
+ import { useCaptionStore } from "../../captions/store";
3
4
  import { DomEditOverlay } from "../editor/DomEditOverlay";
4
5
  import { MotionPathOverlay } from "../editor/MotionPathOverlay";
5
6
  import { SnapToolbar } from "../editor/SnapToolbar";
@@ -139,6 +140,24 @@ export function PreviewOverlays({
139
140
  const { captionEditMode, compositionLoading, isPlaying } = useStudioPlaybackContext();
140
141
  const compositionDimensions = useCompositionDimensions();
141
142
 
143
+ // Caption edit mode is entered automatically when captions are detected;
144
+ // these give the author an explicit way OUT (and back in). Without them the
145
+ // caption overlay permanently replaces normal element editing.
146
+ const captionModelPresent = useCaptionStore((state) => state.model !== null);
147
+ const captionDismissed = useCaptionStore((state) => state.dismissed);
148
+ const captionSyncError = useCaptionStore((state) => state.syncError);
149
+ const exitCaptionMode = useCallback(() => {
150
+ const store = useCaptionStore.getState();
151
+ store.clearSelection();
152
+ store.setDismissed(true);
153
+ store.setEditMode(false);
154
+ }, []);
155
+ const enterCaptionMode = useCallback(() => {
156
+ const store = useCaptionStore.getState();
157
+ store.setDismissed(false);
158
+ store.setEditMode(true);
159
+ }, []);
160
+
142
161
  const { domEditHoverSelection, domEditSelection, domEditGroupSelections } =
143
162
  useDomEditSelectionContext();
144
163
  const {
@@ -194,7 +213,46 @@ export function PreviewOverlays({
194
213
  }
195
214
 
196
215
  if (captionEditMode) {
197
- return <CaptionOverlay iframeRef={previewIframeRef} />;
216
+ return (
217
+ <>
218
+ <CaptionOverlay iframeRef={previewIframeRef} />
219
+ {/* Mode indicator + explicit exit */}
220
+ <div className="absolute top-2 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-2 rounded-full border border-studio-accent/40 bg-black/70 px-2.5 py-1">
221
+ <span className="h-1.5 w-1.5 rounded-full bg-studio-accent" aria-hidden="true" />
222
+ <span className="text-2xs text-neutral-200">Editing captions</span>
223
+ <button
224
+ type="button"
225
+ onClick={exitCaptionMode}
226
+ className="rounded text-2xs text-neutral-400 underline underline-offset-2 hover:text-neutral-100 focus-visible:outline focus-visible:outline-1 focus-visible:outline-studio-accent"
227
+ >
228
+ Exit
229
+ </button>
230
+ </div>
231
+ {captionSyncError && (
232
+ <div
233
+ role="alert"
234
+ className="absolute top-10 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-2 rounded-full border border-red-500/50 bg-red-950/90 px-2.5 py-1"
235
+ >
236
+ <span className="text-2xs text-red-200">{captionSyncError}</span>
237
+ <button
238
+ type="button"
239
+ onClick={() => useCaptionStore.getState().retrySave?.()}
240
+ className="rounded text-2xs text-red-100 underline underline-offset-2 hover:text-white"
241
+ >
242
+ Retry
243
+ </button>
244
+ <button
245
+ type="button"
246
+ onClick={() => useCaptionStore.getState().setSyncError(null)}
247
+ aria-label="Dismiss"
248
+ className="rounded px-0.5 text-2xs text-red-300/70 hover:text-red-100"
249
+ >
250
+
251
+ </button>
252
+ </div>
253
+ )}
254
+ </>
255
+ );
198
256
  }
199
257
 
200
258
  return (
@@ -270,6 +328,15 @@ export function PreviewOverlays({
270
328
  isPlaying={isPlaying}
271
329
  />
272
330
  {gestureOverlay}
331
+ {captionModelPresent && captionDismissed && (
332
+ <button
333
+ type="button"
334
+ onClick={enterCaptionMode}
335
+ className="absolute top-2 left-1/2 -translate-x-1/2 z-[60] rounded-full border border-neutral-700 bg-black/60 px-2.5 py-1 text-2xs text-neutral-300 transition-colors hover:border-studio-accent/50 hover:text-studio-accent focus-visible:outline focus-visible:outline-1 focus-visible:outline-studio-accent"
336
+ >
337
+ Edit captions
338
+ </button>
339
+ )}
273
340
  </>
274
341
  );
275
342
  }
@@ -156,6 +156,7 @@ export function PreviewPane({
156
156
  disabled={timelineDisabled}
157
157
  isFullscreen={isFullscreen}
158
158
  onToggleFullscreen={toggleFullscreen}
159
+ previewIframeRef={iframeRef}
159
160
  />
160
161
  </div>
161
162
  </div>
@@ -104,6 +104,7 @@ function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => v
104
104
  handleTimelineElementResize: vi.fn(),
105
105
  handleTimelineGroupResize: vi.fn(),
106
106
  handleToggleTrackHidden: vi.fn(),
107
+ setAudioGroupAttribute: { setLive: vi.fn(), setQuiet: vi.fn() },
107
108
  handleBlockedTimelineEdit: vi.fn(),
108
109
  handleTimelineElementSplit: vi.fn(),
109
110
  handleRazorSplit: vi.fn(),
@@ -38,10 +38,26 @@ export interface TimelineEditCallbackDeps {
38
38
  ) => Promise<void> | void;
39
39
  handleTimelineGroupResize: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
40
40
  handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
41
+ setAudioGroupAttribute: {
42
+ setLive: (groupId: string, attr: string, value: string | null) => void;
43
+ setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
44
+ };
41
45
  handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
42
46
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
43
47
  handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
44
48
  handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
49
+ /** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
50
+ handleGroupClips?: (clipIds: readonly string[], groupId: string) => Promise<void>;
51
+ /** C1's single-clip FX write, addressed by the clip itself. */
52
+ setElementFxAttribute?: {
53
+ setLive: (element: TimelineElement, attr: string, value: string | null) => void;
54
+ setQuiet: (
55
+ element: TimelineElement,
56
+ attr: string,
57
+ value: string | null,
58
+ label: string,
59
+ ) => Promise<void>;
60
+ };
45
61
  }
46
62
 
47
63
  interface TimelineKeyframeTargetAnimation {
@@ -99,10 +115,13 @@ export function useTimelineEditCallbacks({
99
115
  handleTimelineElementResize,
100
116
  handleTimelineGroupResize,
101
117
  handleToggleTrackHidden,
118
+ setAudioGroupAttribute,
102
119
  handleBlockedTimelineEdit,
103
120
  handleTimelineElementSplit,
104
121
  handleRazorSplit,
105
122
  handleRazorSplitAll,
123
+ handleGroupClips,
124
+ setElementFxAttribute,
106
125
  }: TimelineEditCallbackDeps): TimelineEditCallbacks {
107
126
  const { projectId, activeCompPath } = useStudioShellContext();
108
127
  const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext();
@@ -185,6 +204,11 @@ export function useTimelineEditCallbacks({
185
204
  onResizeElement: handleTimelineElementResize,
186
205
  onResizeElements: handleTimelineGroupResize,
187
206
  onToggleTrackHidden: handleToggleTrackHidden,
207
+ onSetAudioGroupAttributeLive: setAudioGroupAttribute.setLive,
208
+ onSetAudioGroupAttributeQuiet: setAudioGroupAttribute.setQuiet,
209
+ onGroupClips: handleGroupClips,
210
+ onSetElementAttributeLive: setElementFxAttribute?.setLive,
211
+ onSetElementAttributeQuiet: setElementFxAttribute?.setQuiet,
188
212
  onBlockedEditAttempt: handleBlockedTimelineEdit,
189
213
  onSplitElement: handleTimelineElementSplit,
190
214
  onRazorSplit: handleRazorSplit,
@@ -378,6 +402,9 @@ export function useTimelineEditCallbacks({
378
402
  handleTimelineElementResize,
379
403
  handleTimelineGroupResize,
380
404
  handleToggleTrackHidden,
405
+ setAudioGroupAttribute,
406
+ handleGroupClips,
407
+ setElementFxAttribute,
381
408
  handleBlockedTimelineEdit,
382
409
  handleTimelineElementSplit,
383
410
  handleRazorSplit,
@@ -183,6 +183,13 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
183
183
  const [expandedSections, setExpandedSections] = useState<Set<SectionKey>>(
184
184
  () => new Set<SectionKey>(["slides", "inspector"]),
185
185
  );
186
+ // Persist failure surfacing: edits keep working in memory, but the user must
187
+ // know the file write failed (and be able to retry) — never silent data loss.
188
+ const [persistError, setPersistError] = useState(false);
189
+ const [retrying, setRetrying] = useState(false);
190
+ // In-panel undo history of manifest snapshots (discrete edits only).
191
+ const undoStackRef = useRef<SlideshowManifest[]>([]);
192
+ const [undoDepth, setUndoDepth] = useState(0);
186
193
 
187
194
  const currentTime = usePlayerStore((s) => s.currentTime);
188
195
  const { domEditSelection } = useDomEditSelectionContext();
@@ -209,26 +216,56 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
209
216
  setManifest(parsed);
210
217
  manifestRef.current = parsed;
211
218
  setSelectedSequenceId(null);
219
+ // History belongs to one composition — a switch starts fresh.
220
+ undoStackRef.current = [];
221
+ setUndoDepth(0);
222
+ setPersistError(false);
212
223
  }, [compHtml]);
213
224
 
214
225
  /** Discrete actions (toggle, reorder, add/delete, hotspot): persist immediately. */
215
226
  const applyManifest = useCallback(
216
- async (next: SlideshowManifest) => {
227
+ async (next: SlideshowManifest, opts?: { skipUndo?: boolean }) => {
217
228
  // Fold any in-flight typed notes into the discrete manifest so they are
218
229
  // not silently dropped when the debounce timer would have fired later.
219
230
  const merged = notesCtrlRef.current.mergeIntoDiscrete(next);
231
+ if (!opts?.skipUndo) {
232
+ undoStackRef.current.push(manifestRef.current);
233
+ if (undoStackRef.current.length > 50) undoStackRef.current.shift();
234
+ setUndoDepth(undoStackRef.current.length);
235
+ }
220
236
  setManifest(merged);
221
237
  manifestRef.current = merged;
222
238
  // Surface persist failures instead of swallowing them at each call site.
223
239
  try {
224
240
  await onPersist(merged);
241
+ setPersistError(false);
225
242
  } catch (err) {
226
243
  console.error("[slideshow] failed to persist manifest edit:", err);
244
+ setPersistError(true);
227
245
  }
228
246
  },
229
247
  [onPersist],
230
248
  );
231
249
 
250
+ const handleUndo = useCallback(() => {
251
+ const prev = undoStackRef.current.pop();
252
+ if (!prev) return;
253
+ setUndoDepth(undoStackRef.current.length);
254
+ applyManifest(prev, { skipUndo: true }).catch(() => {});
255
+ }, [applyManifest]);
256
+
257
+ const handleRetryPersist = useCallback(async () => {
258
+ setRetrying(true);
259
+ try {
260
+ await onPersist(manifestRef.current);
261
+ setPersistError(false);
262
+ } catch (err) {
263
+ console.error("[slideshow] retry persist failed:", err);
264
+ } finally {
265
+ setRetrying(false);
266
+ }
267
+ }, [onPersist]);
268
+
232
269
  /**
233
270
  * Notes path: update in-memory state immediately for a responsive UI, but
234
271
  * debounce the disk persist to ~450 ms after the last keystroke. The pending
@@ -338,18 +375,10 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
338
375
  [applyManifest],
339
376
  );
340
377
 
378
+ // Confirmation is inline in BranchItem (consistent with the FileTree/assets
379
+ // delete pattern); by the time this fires the user has already confirmed.
341
380
  const handleDeleteSequence = useCallback(
342
381
  (id: string) => {
343
- // Deleting a branch removes its slides and orphans any hotspot targeting it —
344
- // confirm first to prevent accidental data loss.
345
- const seq = (manifestRef.current.slideSequences ?? []).find((s) => s.id === id);
346
- const count = seq?.slides.length ?? 0;
347
- const label = seq?.label ?? id;
348
- const ok = window.confirm(
349
- `Delete branch "${label}"${count ? ` and its ${count} slide${count === 1 ? "" : "s"}` : ""}? ` +
350
- `Hotspots pointing to it will no longer resolve.`,
351
- );
352
- if (!ok) return;
353
382
  applyManifest(deleteSequence(manifestRef.current, id)).catch(() => {});
354
383
  },
355
384
  [applyManifest],
@@ -383,7 +412,47 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
383
412
  );
384
413
 
385
414
  return (
386
- <div className="flex flex-col h-full overflow-y-auto text-white">
415
+ <div
416
+ className="flex flex-col h-full overflow-y-auto text-white"
417
+ onKeyDown={(e) => {
418
+ // In-panel undo — scoped so it never fights the app-level file undo.
419
+ if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "z") {
420
+ const target = e.target instanceof HTMLElement ? e.target.tagName : "";
421
+ if (target === "TEXTAREA" || target === "INPUT") return;
422
+ e.preventDefault();
423
+ e.stopPropagation();
424
+ handleUndo();
425
+ }
426
+ }}
427
+ >
428
+ {persistError && (
429
+ <div
430
+ role="alert"
431
+ className="flex items-center justify-between gap-2 px-3 py-2 bg-red-950/40 border-b border-red-500/40"
432
+ >
433
+ <span className="text-[11px] text-red-300">Changes not saved</span>
434
+ <button
435
+ type="button"
436
+ disabled={retrying}
437
+ onClick={handleRetryPersist}
438
+ className="px-2 py-0.5 text-[10px] rounded bg-red-600 text-white enabled:hover:bg-red-500 enabled:active:scale-[0.97] disabled:opacity-50 transition-colors"
439
+ >
440
+ {retrying ? "Retrying…" : "Retry"}
441
+ </button>
442
+ </div>
443
+ )}
444
+ {undoDepth > 0 && (
445
+ <div className="flex items-center justify-end px-3 py-1 border-b border-neutral-800/60">
446
+ <button
447
+ type="button"
448
+ onClick={handleUndo}
449
+ title="Undo last slideshow edit (⌘Z)"
450
+ className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 active:scale-[0.97] transition-colors"
451
+ >
452
+ Undo ({undoDepth})
453
+ </button>
454
+ </div>
455
+ )}
387
456
  <SectionHeader
388
457
  expanded={expandedSections.has("slides")}
389
458
  onToggle={() => toggleSection("slides")}