@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.6",
3
+ "version": "0.8.7",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.8.6",
51
- "@hyperframes/parsers": "0.8.6",
52
- "@hyperframes/player": "0.8.6",
53
- "@hyperframes/sdk": "0.8.6",
54
- "@hyperframes/studio-server": "0.8.6"
50
+ "@hyperframes/core": "0.8.7",
51
+ "@hyperframes/player": "0.8.7",
52
+ "@hyperframes/parsers": "0.8.7",
53
+ "@hyperframes/sdk": "0.8.7",
54
+ "@hyperframes/studio-server": "0.8.7"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -68,7 +68,7 @@
68
68
  "vite": "^6.4.2",
69
69
  "vitest": "^3.2.4",
70
70
  "zustand": "^5.0.0",
71
- "@hyperframes/producer": "0.8.6"
71
+ "@hyperframes/producer": "0.8.7"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "react": "19",
package/src/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react";
1
+ import { useState, useCallback, useRef, useMemo, useLayoutEffect } from "react";
2
2
  import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
3
3
  import { useRenderQueue } from "./components/renders/useRenderQueue";
4
4
  import { usePlayerStore } from "./player";
@@ -37,6 +37,8 @@ import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
37
37
  import { useToast } from "./hooks/useToast";
38
38
  import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
39
39
  import { useStudioUrlState } from "./hooks/useStudioUrlState";
40
+ import { useEffectiveTimelineDuration } from "./hooks/useEffectiveTimelineDuration";
41
+ import { useAudioSoloBridge } from "./hooks/useAudioSoloBridge";
40
42
  import {
41
43
  buildStudioContextValue,
42
44
  useGlobalFileDrop,
@@ -60,11 +62,8 @@ import { StudioSplash } from "./components/StudioSplash";
60
62
  import { useServerConnection } from "./hooks/useServerConnection";
61
63
  import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
62
64
  import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
63
- import {
64
- normalizeStudioCompositionPath,
65
- readStudioUrlStateFromWindow,
66
- resolveMasterCompositionPath,
67
- } from "./utils/studioUrlState";
65
+ import { readStudioUrlStateFromWindow, resolveMasterCompositionPath } from "./utils/studioUrlState";
66
+ import { useHydrateActiveCompPathFromUrl } from "./hooks/useHydrateActiveCompPathFromUrl";
68
67
  const getTimelineSelectionSet = () => usePlayerStore.getState().selectedElementIds;
69
68
  // fallow-ignore-next-line complexity
70
69
  export function StudioApp() {
@@ -83,6 +82,7 @@ export function StudioApp() {
83
82
  const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
84
83
  const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
85
84
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
85
+ useAudioSoloBridge(previewIframeRef);
86
86
  const activeCompPathRef = useRef(activeCompPath);
87
87
  activeCompPathRef.current = activeCompPath;
88
88
  const leftSidebarRef = useRef<LeftSidebarHandle>(null);
@@ -95,13 +95,10 @@ export function StudioApp() {
95
95
  const setTimelineSelectionSet = usePlayerStore((s) => s.setSelectedElementIds);
96
96
  const timelineDuration = usePlayerStore((s) => s.duration);
97
97
  const isPlaying = usePlayerStore((s) => s.isPlaying);
98
- const effectiveTimelineDuration = useMemo(() => {
99
- const maxEnd =
100
- timelineElements.length > 0
101
- ? Math.max(...timelineElements.map((el) => el.start + el.duration))
102
- : 0;
103
- return Math.max(timelineDuration, maxEnd);
104
- }, [timelineDuration, timelineElements]);
98
+ const effectiveTimelineDuration = useEffectiveTimelineDuration(
99
+ timelineDuration,
100
+ timelineElements,
101
+ );
105
102
  const { toasts, showToast, dismissToast } = useToast();
106
103
  const panelLayout = usePanelLayout({
107
104
  rightCollapsed: initialUrlStateRef.current.rightCollapsed,
@@ -129,16 +126,14 @@ export function StudioApp() {
129
126
  activeCompPath,
130
127
  masterCompPath,
131
128
  );
132
- useEffect(() => {
133
- if (activeCompPathHydrated) return;
134
- if (!fileManager.fileTreeLoaded) return;
135
- const nextCompPath = normalizeStudioCompositionPath(
136
- initialUrlStateRef.current.activeCompPath,
137
- fileManager.fileTree,
138
- );
139
- setActiveCompPath((current) => (current === nextCompPath ? current : nextCompPath));
140
- setActiveCompPathHydrated(true);
141
- }, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
129
+ useHydrateActiveCompPathFromUrl({
130
+ hydrated: activeCompPathHydrated,
131
+ fileTreeLoaded: fileManager.fileTreeLoaded,
132
+ fileTree: fileManager.fileTree,
133
+ initialUrlStateRef,
134
+ setActiveCompPath,
135
+ setHydrated: setActiveCompPathHydrated,
136
+ });
142
137
  const previewPersistence = usePreviewPersistence({
143
138
  showToast,
144
139
  readOptionalProjectFile: fileManager.readOptionalProjectFile,
@@ -532,6 +527,7 @@ export function StudioApp() {
532
527
  domEditSaveTimestampRef={domEditSaveTimestampRef}
533
528
  recordEdit={editHistory.recordEdit}
534
529
  onToggleElementHidden={timelineEditing.handleToggleElementHidden}
530
+ onAutoGroupCarveSources={timelineEditing.handleAutoGroupCarveSources}
535
531
  onAddMediaOverlay={handleAddMediaOverlay}
536
532
  />
537
533
  )
@@ -549,6 +545,9 @@ export function StudioApp() {
549
545
  handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
550
546
  handleTimelineGroupResize={timelineEditing.handleTimelineGroupResize}
551
547
  handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
548
+ setAudioGroupAttribute={timelineEditing.setAudioGroupAttribute}
549
+ handleGroupClips={timelineEditing.handleAutoGroupCarveSources}
550
+ setElementFxAttribute={timelineEditing.setElementFxAttribute}
552
551
  handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
553
552
  handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
554
553
  handleRazorSplit={timelineEditing.handleRazorSplit}
@@ -61,7 +61,13 @@ const EASE_PRESETS = [
61
61
  "bounce.out",
62
62
  ];
63
63
 
64
- import { Section, Row, inputCls } from "./shared";
64
+ import { Section, Row, inputCls, NumberField } from "./shared";
65
+
66
+ // Animation edits currently mutate only the in-memory model: they are not
67
+ // applied to the preview and not serialized by buildOverrides, so tuning them
68
+ // would be editing blind. Controls stay visible (so the capability is
69
+ // discoverable) but disabled until the apply/persist pipeline exists.
70
+ const ANIMATION_PIPELINE_WIRED = false;
65
71
 
66
72
  // ---------------------------------------------------------------------------
67
73
  // Animation phase controls
@@ -72,6 +78,7 @@ interface AnimationPhaseProps {
72
78
  presets: string[];
73
79
  animation: CaptionAnimation | null;
74
80
  showIntensity?: boolean;
81
+ disabled?: boolean;
75
82
  onChange: (update: Partial<CaptionAnimation>) => void;
76
83
  }
77
84
 
@@ -80,6 +87,7 @@ function AnimationPhase({
80
87
  presets,
81
88
  animation,
82
89
  showIntensity,
90
+ disabled,
83
91
  onChange,
84
92
  }: AnimationPhaseProps) {
85
93
  const preset = animation?.preset ?? "none";
@@ -93,6 +101,8 @@ function AnimationPhase({
93
101
  <Row label="Preset">
94
102
  <select
95
103
  value={preset}
104
+ disabled={disabled}
105
+ aria-label={`${label} preset`}
96
106
  onChange={(e) => onChange({ preset: e.target.value })}
97
107
  className={inputCls}
98
108
  >
@@ -105,20 +115,22 @@ function AnimationPhase({
105
115
  </Row>
106
116
 
107
117
  <Row label="Duration">
108
- <input
109
- type="number"
118
+ <NumberField
110
119
  value={duration}
111
120
  step={0.05}
112
121
  min={0}
113
122
  max={2}
114
- onChange={(e) => onChange({ duration: Number(e.target.value) })}
115
- className={inputCls}
123
+ disabled={disabled}
124
+ ariaLabel={`${label} duration`}
125
+ onCommit={(v) => onChange({ duration: v })}
116
126
  />
117
127
  </Row>
118
128
 
119
129
  <Row label="Ease">
120
130
  <select
121
131
  value={ease}
132
+ disabled={disabled}
133
+ aria-label={`${label} ease`}
122
134
  onChange={(e) => onChange({ ease: e.target.value })}
123
135
  className={inputCls}
124
136
  >
@@ -131,14 +143,14 @@ function AnimationPhase({
131
143
  </Row>
132
144
 
133
145
  <Row label="Stagger">
134
- <input
135
- type="number"
146
+ <NumberField
136
147
  value={stagger}
137
148
  step={0.02}
138
149
  min={0}
139
150
  max={0.5}
140
- onChange={(e) => onChange({ stagger: Number(e.target.value) })}
141
- className={inputCls}
151
+ disabled={disabled}
152
+ ariaLabel={`${label} stagger`}
153
+ onCommit={(v) => onChange({ stagger: v })}
142
154
  />
143
155
  </Row>
144
156
 
@@ -151,8 +163,13 @@ function AnimationPhase({
151
163
  max={1}
152
164
  step={0.01}
153
165
  value={intensity}
154
- onChange={(e) => onChange({ intensity: Number(e.target.value) })}
155
- className="flex-1 accent-studio-accent"
166
+ disabled={disabled}
167
+ aria-label={`${label} intensity`}
168
+ onChange={(e) => {
169
+ const v = Number(e.target.value);
170
+ if (Number.isFinite(v)) onChange({ intensity: v });
171
+ }}
172
+ className="flex-1 accent-studio-accent disabled:opacity-40"
156
173
  />
157
174
  <span className="text-2xs text-neutral-400 font-mono w-8 text-right flex-shrink-0">
158
175
  {intensity.toFixed(2)}
@@ -222,19 +239,31 @@ export const CaptionAnimationPanel = memo(function CaptionAnimationPanel() {
222
239
  if (!group || !resolvedGroupId || !animation) {
223
240
  return (
224
241
  <div className="flex items-center justify-center h-full px-4 text-center">
225
- <p className="text-xs text-neutral-500">Select a caption group to edit animations</p>
242
+ <p className="text-xs text-neutral-500">Select a caption word to edit animations</p>
226
243
  </div>
227
244
  );
228
245
  }
229
246
 
247
+ const gated = !ANIMATION_PIPELINE_WIRED;
248
+
230
249
  return (
231
250
  <div className="flex flex-col h-full min-h-0">
251
+ {gated && (
252
+ <div className="flex-shrink-0 mx-3 mt-2 px-2 py-1.5 rounded border border-amber-500/30 bg-amber-500/10">
253
+ <p className="text-2xs text-amber-300/90 leading-snug">
254
+ Animation editing isn&apos;t applied to playback or saved yet, so these controls are
255
+ disabled.
256
+ </p>
257
+ </div>
258
+ )}
259
+
232
260
  {/* Scrollable content */}
233
261
  <div className="flex-1 overflow-y-auto px-3 py-2">
234
262
  <AnimationPhase
235
263
  label="Entrance"
236
264
  presets={ENTRANCE_PRESETS}
237
265
  animation={animation.entrance}
266
+ disabled={gated}
238
267
  onChange={handleEntranceChange}
239
268
  />
240
269
 
@@ -243,6 +272,7 @@ export const CaptionAnimationPanel = memo(function CaptionAnimationPanel() {
243
272
  presets={HIGHLIGHT_PRESETS}
244
273
  animation={animation.highlight}
245
274
  showIntensity
275
+ disabled={gated}
246
276
  onChange={handleHighlightChange}
247
277
  />
248
278
 
@@ -250,6 +280,7 @@ export const CaptionAnimationPanel = memo(function CaptionAnimationPanel() {
250
280
  label="Exit"
251
281
  presets={EXIT_PRESETS}
252
282
  animation={animation.exit}
283
+ disabled={gated}
253
284
  onChange={handleExitChange}
254
285
  />
255
286
  </div>
@@ -259,7 +290,9 @@ export const CaptionAnimationPanel = memo(function CaptionAnimationPanel() {
259
290
  <button
260
291
  type="button"
261
292
  onClick={handleApplyToAll}
262
- className="w-full py-1.5 rounded border border-neutral-700 text-2xs text-neutral-300 hover:border-studio-accent/50 hover:text-studio-accent transition-colors"
293
+ disabled={gated}
294
+ title={gated ? "Disabled until animation editing is applied to playback" : undefined}
295
+ className="w-full py-1.5 rounded border border-neutral-700 text-2xs text-neutral-300 hover:border-studio-accent/50 hover:text-studio-accent transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-neutral-700 disabled:hover:text-neutral-300"
263
296
  >
264
297
  Apply to all groups
265
298
  </button>
@@ -1,7 +1,8 @@
1
1
  import { memo, useState, useCallback, useRef } from "react";
2
2
  import { useCaptionStore } from "../store";
3
+ import { usePlayerStore } from "../../player";
3
4
  import { useMountEffect } from "../../hooks/useMountEffect";
4
- import { shouldHandleCaptionNudgeKey } from "../keyboard";
5
+ import { shouldHandleCaptionNudgeKey, isEditableEventTarget } from "../keyboard";
5
6
  import {
6
7
  readWordBoxes,
7
8
  getWordEl,
@@ -9,6 +10,7 @@ import {
9
10
  getOrCreateWrapper,
10
11
  writeTransform,
11
12
  computeTransformStyle,
13
+ registerCaptionIframe,
12
14
  type WordBox,
13
15
  } from "./CaptionOverlayUtils";
14
16
 
@@ -16,7 +18,8 @@ interface CaptionOverlayProps {
16
18
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
17
19
  }
18
20
 
19
- const HANDLE = 8;
21
+ const HANDLE = 8; // visual size of a handle dot
22
+ const HANDLE_HIT = 24; // pointer target size (invisible padding around the dot)
20
23
  const ROTATION_OFFSET = 20; // px above the selection box
21
24
 
22
25
  /** Sync canvas state back to the Zustand store so the property panel reflects it. */
@@ -38,6 +41,8 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
38
41
  const overlayRef = useRef<HTMLDivElement>(null);
39
42
  const modelRef = useRef(model);
40
43
  modelRef.current = model;
44
+ // Set by the mount effect; lets Escape handling cancel an in-flight drag.
45
+ const cancelDragRef = useRef<(() => boolean) | null>(null);
41
46
 
42
47
  // Interaction mode — only one active at a time
43
48
  const interactionRef = useRef<
@@ -78,8 +83,14 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
78
83
 
79
84
  useMountEffect(() => {
80
85
  if (!isEditMode) return;
86
+
87
+ // Let undo/redo (useAppHotkeys) reapply restored models to this iframe.
88
+ const unregisterIframe = registerCaptionIframe(iframeRef);
89
+
81
90
  let prevBoxes: WordBox[] = [];
91
+ let rafId: number | null = null;
82
92
  const tick = () => {
93
+ rafId = null;
83
94
  const iframe = iframeRef.current;
84
95
  const m = modelRef.current;
85
96
  const overlay = overlayRef.current;
@@ -95,15 +106,98 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
95
106
  prevBoxes = next;
96
107
  setWordBoxes(next);
97
108
  };
98
- const id = setInterval(tick, 66);
109
+ // Coalesce bursts of triggers (player store updates, resize) into one
110
+ // layout read per frame — replaces the old unconditional 66ms polling.
111
+ const scheduleTick = () => {
112
+ if (rafId === null) rafId = requestAnimationFrame(tick);
113
+ };
114
+
115
+ // Event sources: player state changes (seek/scrub/select), preview
116
+ // messages, element resize, window resize.
117
+ const unsubPlayer = usePlayerStore.subscribe(scheduleTick);
118
+ const handleMessage = (e: MessageEvent) => {
119
+ const data = e.data;
120
+ if (data?.source === "hf-preview") scheduleTick();
121
+ };
122
+ window.addEventListener("message", handleMessage);
123
+ window.addEventListener("resize", scheduleTick);
124
+ const resizeObserver = new ResizeObserver(scheduleTick);
125
+ if (iframeRef.current) resizeObserver.observe(iframeRef.current);
126
+ if (overlayRef.current) resizeObserver.observe(overlayRef.current);
127
+
128
+ // During playback caption groups animate continuously — a light interval
129
+ // is the only reliable driver, but it runs ONLY while playing.
130
+ let playInterval: ReturnType<typeof setInterval> | null = null;
131
+ const syncPlaybackInterval = () => {
132
+ const playing = usePlayerStore.getState().isPlaying;
133
+ if (playing && playInterval === null) {
134
+ playInterval = setInterval(scheduleTick, 66);
135
+ } else if (!playing && playInterval !== null) {
136
+ clearInterval(playInterval);
137
+ playInterval = null;
138
+ }
139
+ };
140
+ const unsubPlayback = usePlayerStore.subscribe(syncPlaybackInterval);
141
+ syncPlaybackInterval();
99
142
  tick();
100
143
 
101
- // Arrow key nudge for selected words
144
+ const getWin = (): Window | null => {
145
+ try {
146
+ return iframeRef.current?.contentWindow ?? null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ };
151
+
152
+ const cancelActiveDrag = (): boolean => {
153
+ const i = interactionRef.current;
154
+ if (!i) return false;
155
+ const win = getWin();
156
+ if (win) {
157
+ // Restore the pre-drag transform instead of committing the partial one.
158
+ writeTransform(i.wordEl, win, i.origTX, i.origTY, i.origScale, i.origRotation);
159
+ }
160
+ interactionRef.current = null;
161
+ return true;
162
+ };
163
+ cancelDragRef.current = cancelActiveDrag;
164
+
102
165
  const handleKeyDown = (e: KeyboardEvent) => {
103
- const { selectedSegmentIds: sel, model: m } = useCaptionStore.getState();
166
+ const store = useCaptionStore.getState();
167
+ const { selectedSegmentIds: sel, model: m } = store;
168
+
169
+ // Escape: cancel an in-flight drag first, else clear the selection.
170
+ if (e.key === "Escape") {
171
+ if (cancelActiveDrag()) {
172
+ e.preventDefault();
173
+ scheduleTick();
174
+ return;
175
+ }
176
+ if (sel.size > 0) {
177
+ e.preventDefault();
178
+ store.clearSelection();
179
+ }
180
+ return;
181
+ }
182
+
183
+ // ⌘A / Ctrl+A selects every caption word (unless typing in a field).
184
+ if (
185
+ (e.metaKey || e.ctrlKey) &&
186
+ e.key.toLowerCase() === "a" &&
187
+ !e.shiftKey &&
188
+ !e.altKey &&
189
+ !isEditableEventTarget(e.target)
190
+ ) {
191
+ e.preventDefault();
192
+ store.selectAll();
193
+ return;
194
+ }
195
+
104
196
  if (sel.size === 0 || !m) return;
105
197
  const arrow = e.key;
106
- if (!shouldHandleCaptionNudgeKey(e)) return;
198
+ // Pass the event target so arrows inside inputs keep their native
199
+ // behavior instead of nudging the canvas word.
200
+ if (!shouldHandleCaptionNudgeKey(e, e.target)) return;
107
201
 
108
202
  e.preventDefault();
109
203
  const step = e.shiftKey ? 10 : 1;
@@ -129,12 +223,21 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
129
223
  break;
130
224
  }
131
225
  }
226
+ scheduleTick();
132
227
  };
133
228
 
134
229
  window.addEventListener("keydown", handleKeyDown);
135
230
  return () => {
136
- clearInterval(id);
231
+ unregisterIframe();
232
+ unsubPlayer();
233
+ unsubPlayback();
234
+ if (playInterval !== null) clearInterval(playInterval);
235
+ if (rafId !== null) cancelAnimationFrame(rafId);
236
+ resizeObserver.disconnect();
237
+ window.removeEventListener("message", handleMessage);
238
+ window.removeEventListener("resize", scheduleTick);
137
239
  window.removeEventListener("keydown", handleKeyDown);
240
+ cancelDragRef.current = null;
138
241
  };
139
242
  });
140
243
 
@@ -297,6 +400,13 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
297
400
  onPointerUp={handlePointerUp}
298
401
  onLostPointerCapture={handlePointerUp}
299
402
  >
403
+ {wordBoxes.length === 0 && model && model.segments.size > 0 && (
404
+ <div className="absolute inset-x-0 top-3 flex justify-center pointer-events-none">
405
+ <span className="px-2.5 py-1 rounded-full bg-black/60 border border-neutral-700 text-2xs text-neutral-300">
406
+ No captions visible at this frame — scrub to a caption, or select one in the track below
407
+ </span>
408
+ </div>
409
+ )}
300
410
  {wordBoxes.map((box) => {
301
411
  const isSelected = selectedSegmentIds.has(box.segmentId);
302
412
  return (
@@ -325,23 +435,33 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
325
435
  >
326
436
  {isSelected && (
327
437
  <>
328
- {/* Rotation handle — circle above the box */}
438
+ {/* Rotation handle — 24px hit area around an 8px dot */}
329
439
  <div
330
440
  style={{
331
441
  position: "absolute",
332
442
  left: "50%",
333
- top: -ROTATION_OFFSET - HANDLE,
334
- marginLeft: -HANDLE / 2,
335
- width: HANDLE,
336
- height: HANDLE,
337
- borderRadius: "50%",
338
- backgroundColor: "var(--hf-accent, #3CE6AC)",
339
- border: "1px solid rgba(0,0,0,0.5)",
443
+ top: -ROTATION_OFFSET - HANDLE / 2 - HANDLE_HIT / 2,
444
+ marginLeft: -HANDLE_HIT / 2,
445
+ width: HANDLE_HIT,
446
+ height: HANDLE_HIT,
447
+ display: "flex",
448
+ alignItems: "center",
449
+ justifyContent: "center",
340
450
  cursor: "grab",
341
451
  touchAction: "none",
342
452
  }}
343
453
  onPointerDown={(e) => startRotate(box, e)}
344
- />
454
+ >
455
+ <div
456
+ style={{
457
+ width: HANDLE,
458
+ height: HANDLE,
459
+ borderRadius: "50%",
460
+ backgroundColor: "var(--hf-accent, #3CE6AC)",
461
+ border: "1px solid rgba(0,0,0,0.5)",
462
+ }}
463
+ />
464
+ </div>
345
465
  {/* Line from box to rotation handle */}
346
466
  <div
347
467
  style={{
@@ -356,29 +476,52 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
356
476
  pointerEvents: "none",
357
477
  }}
358
478
  />
359
- {/* Scale handles — four corners */}
479
+ {/* Scale handles — four corners, 24px hit areas around 8px dots */}
360
480
  {[
361
- { right: -HANDLE / 2, bottom: -HANDLE / 2, cursor: "nwse-resize" },
362
- { left: -HANDLE / 2, top: -HANDLE / 2, cursor: "nwse-resize" },
363
- { right: -HANDLE / 2, top: -HANDLE / 2, cursor: "nesw-resize" },
364
- { left: -HANDLE / 2, bottom: -HANDLE / 2, cursor: "nesw-resize" },
365
- ].map((pos, idx) => (
481
+ {
482
+ corner: { right: -HANDLE_HIT / 2, bottom: -HANDLE_HIT / 2 },
483
+ cursor: "nwse-resize",
484
+ },
485
+ {
486
+ corner: { left: -HANDLE_HIT / 2, top: -HANDLE_HIT / 2 },
487
+ cursor: "nwse-resize",
488
+ },
489
+ {
490
+ corner: { right: -HANDLE_HIT / 2, top: -HANDLE_HIT / 2 },
491
+ cursor: "nesw-resize",
492
+ },
493
+ {
494
+ corner: { left: -HANDLE_HIT / 2, bottom: -HANDLE_HIT / 2 },
495
+ cursor: "nesw-resize",
496
+ },
497
+ ].map(({ corner, cursor }, idx) => (
366
498
  <div
367
499
  key={idx}
368
500
  style={{
369
501
  position: "absolute",
370
- ...pos,
371
- width: HANDLE,
372
- height: HANDLE,
373
- backgroundColor: "var(--hf-accent, #3CE6AC)",
374
- border: "1px solid rgba(0,0,0,0.5)",
375
- borderRadius: 2,
502
+ ...corner,
503
+ width: HANDLE_HIT,
504
+ height: HANDLE_HIT,
505
+ display: "flex",
506
+ alignItems: "center",
507
+ justifyContent: "center",
508
+ cursor,
376
509
  touchAction: "none",
377
510
  }}
378
511
  onPointerDown={(e) =>
379
512
  startScale(box.groupIndex, box.wordIndex, box.segmentId, e)
380
513
  }
381
- />
514
+ >
515
+ <div
516
+ style={{
517
+ width: HANDLE,
518
+ height: HANDLE,
519
+ backgroundColor: "var(--hf-accent, #3CE6AC)",
520
+ border: "1px solid rgba(0,0,0,0.5)",
521
+ borderRadius: 2,
522
+ }}
523
+ />
524
+ </div>
382
525
  ))}
383
526
  </>
384
527
  )}
@@ -205,6 +205,60 @@ export function writeTransform(
205
205
  }
206
206
  }
207
207
 
208
+ // ---------------------------------------------------------------------------
209
+ // Iframe registry — lets non-component code (undo/redo in useAppHotkeys)
210
+ // reapply a restored model's transforms to the live preview.
211
+ // ---------------------------------------------------------------------------
212
+
213
+ let registeredIframe: React.RefObject<HTMLIFrameElement | null> | null = null;
214
+
215
+ export function registerCaptionIframe(ref: React.RefObject<HTMLIFrameElement | null>): () => void {
216
+ registeredIframe = ref;
217
+ return () => {
218
+ if (registeredIframe === ref) registeredIframe = null;
219
+ };
220
+ }
221
+
222
+ /**
223
+ * True when the caption preview iframe is mounted AND visible. The caption
224
+ * store's isEditMode stays true while the preview is merely hidden (e.g.
225
+ * storyboard view), so hotkeys must not route ⌘Z to the caption stack unless
226
+ * the user can actually see the captions the undo would change.
227
+ */
228
+ export function isCaptionPreviewVisible(): boolean {
229
+ const iframe = registeredIframe?.current;
230
+ return Boolean(iframe?.isConnected && iframe.offsetParent !== null);
231
+ }
232
+
233
+ /** Reapply every segment's transform from a (restored) model to the preview DOM. */
234
+ export function applyCaptionModelToIframe(model: {
235
+ groupOrder: string[];
236
+ groups: Map<string, { segmentIds: string[] }>;
237
+ segments: Map<string, { style: { x?: number; y?: number; scaleX?: number; rotation?: number } }>;
238
+ }): void {
239
+ const iframe = registeredIframe?.current;
240
+ if (!iframe) return;
241
+ let win: Window | null = null;
242
+ try {
243
+ win = iframe.contentWindow;
244
+ } catch {
245
+ return;
246
+ }
247
+ if (!win) return;
248
+ for (let gi = 0; gi < model.groupOrder.length; gi++) {
249
+ const group = model.groups.get(model.groupOrder[gi]);
250
+ if (!group) continue;
251
+ for (let wi = 0; wi < group.segmentIds.length; wi++) {
252
+ const seg = model.segments.get(group.segmentIds[wi]);
253
+ if (!seg) continue;
254
+ const wordEl = getWordEl(iframe, gi, wi);
255
+ if (!wordEl) continue;
256
+ const s = seg.style;
257
+ writeTransform(wordEl, win, s.x ?? 0, s.y ?? 0, s.scaleX ?? 1, s.rotation ?? 0);
258
+ }
259
+ }
260
+ }
261
+
208
262
  /** Compute style deltas from the current wrapper transform — used by syncToStore in the overlay. */
209
263
  export function computeTransformStyle(el: HTMLElement, iframeWin: Window): Record<string, number> {
210
264
  const wrapper = getOrCreateWrapper(el);