@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,29 @@ import { memo, useCallback, useState } from "react";
2
2
  import { useCaptionStore } from "../store";
3
3
  import type { CaptionStyle } from "../types";
4
4
  import { CaptionAnimationPanel } from "./CaptionAnimationPanel";
5
- import { Section, Row, inputCls } from "./shared";
5
+ import { Section, Row, NumberField } from "./shared";
6
+
7
+ /** True when the given style key differs across the selected segments. */
8
+ function isMixedValue(
9
+ model: { segments: Map<string, { style: Partial<CaptionStyle> }> } | null,
10
+ selectedSegmentIds: Set<string>,
11
+ key: "x" | "y" | "scaleX" | "rotation",
12
+ fallback: number,
13
+ ): boolean {
14
+ if (!model || selectedSegmentIds.size < 2) return false;
15
+ let first: number | undefined;
16
+ let seen = false;
17
+ for (const segId of selectedSegmentIds) {
18
+ const v = model.segments.get(segId)?.style[key] ?? fallback;
19
+ if (!seen) {
20
+ first = v;
21
+ seen = true;
22
+ } else if (v !== first) {
23
+ return true;
24
+ }
25
+ }
26
+ return false;
27
+ }
6
28
 
7
29
  // ---------------------------------------------------------------------------
8
30
  // Main component
@@ -180,6 +202,11 @@ export const CaptionPropertyPanel = memo(function CaptionPropertyPanel({
180
202
  const rotation = effectiveStyle.rotation ?? 0;
181
203
  const scaleX = effectiveStyle.scaleX ?? 1;
182
204
 
205
+ const xMixed = isMixedValue(model, selectedSegmentIds, "x", 0);
206
+ const yMixed = isMixedValue(model, selectedSegmentIds, "y", 0);
207
+ const scaleMixed = isMixedValue(model, selectedSegmentIds, "scaleX", 1);
208
+ const rotationMixed = isMixedValue(model, selectedSegmentIds, "rotation", 0);
209
+
183
210
  // Count label
184
211
  const countLabel = selectedSegmentIds.size === 1 ? "1 word" : `${selectedSegmentIds.size} words`;
185
212
 
@@ -191,9 +218,11 @@ export const CaptionPropertyPanel = memo(function CaptionPropertyPanel({
191
218
  <span className="text-2xs text-neutral-500">{countLabel}</span>
192
219
  </div>
193
220
  {/* Tab switcher */}
194
- <div className="flex gap-1">
221
+ <div className="flex gap-1" role="tablist" aria-label="Caption editing tabs">
195
222
  <button
196
223
  type="button"
224
+ role="tab"
225
+ aria-selected={activeTab === "style"}
197
226
  onClick={() => setActiveTab("style")}
198
227
  className={[
199
228
  "flex-1 py-0.5 rounded text-2xs font-medium transition-colors",
@@ -206,6 +235,8 @@ export const CaptionPropertyPanel = memo(function CaptionPropertyPanel({
206
235
  </button>
207
236
  <button
208
237
  type="button"
238
+ role="tab"
239
+ aria-selected={activeTab === "animation"}
209
240
  onClick={() => setActiveTab("animation")}
210
241
  className={[
211
242
  "flex-1 py-0.5 rounded text-2xs font-medium transition-colors",
@@ -227,44 +258,39 @@ export const CaptionPropertyPanel = memo(function CaptionPropertyPanel({
227
258
  <div className="flex-1 overflow-y-auto px-3 py-2">
228
259
  <Section label="Position">
229
260
  <Row label="X">
230
- <input
231
- type="number"
261
+ <NumberField
232
262
  value={x}
233
- onChange={(e) => handleStyleChange({ x: Number(e.target.value) })}
234
- className={inputCls}
263
+ mixed={xMixed}
264
+ ariaLabel="X position"
265
+ onCommit={(v) => handleStyleChange({ x: v })}
235
266
  />
236
267
  </Row>
237
268
  <Row label="Y">
238
- <input
239
- type="number"
269
+ <NumberField
240
270
  value={y}
241
- onChange={(e) => handleStyleChange({ y: Number(e.target.value) })}
242
- className={inputCls}
271
+ mixed={yMixed}
272
+ ariaLabel="Y position"
273
+ onCommit={(v) => handleStyleChange({ y: v })}
243
274
  />
244
275
  </Row>
245
276
  </Section>
246
277
 
247
278
  <Section label="Transform">
248
279
  <Row label="Scale">
249
- <input
250
- type="number"
280
+ <NumberField
251
281
  value={scaleX}
282
+ mixed={scaleMixed}
252
283
  step={0.1}
253
- onChange={(e) =>
254
- handleStyleChange({
255
- scaleX: Number(e.target.value),
256
- scaleY: Number(e.target.value),
257
- })
258
- }
259
- className={inputCls}
284
+ ariaLabel="Scale"
285
+ onCommit={(v) => handleStyleChange({ scaleX: v, scaleY: v })}
260
286
  />
261
287
  </Row>
262
288
  <Row label="Rotation">
263
- <input
264
- type="number"
289
+ <NumberField
265
290
  value={rotation}
266
- onChange={(e) => handleStyleChange({ rotation: Number(e.target.value) })}
267
- className={inputCls}
291
+ mixed={rotationMixed}
292
+ ariaLabel="Rotation"
293
+ onCommit={(v) => handleStyleChange({ rotation: v })}
268
294
  />
269
295
  </Row>
270
296
  </Section>
@@ -1,5 +1,6 @@
1
- import { memo, useCallback, useRef } from "react";
1
+ import { memo, useCallback } from "react";
2
2
  import { useCaptionStore } from "../store";
3
+ import { usePlayerStore } from "../../player";
3
4
 
4
5
  // ---------------------------------------------------------------------------
5
6
  // Constants
@@ -18,6 +19,14 @@ const GROUP_COLORS = [
18
19
  "#C084FC",
19
20
  ];
20
21
 
22
+ const TRACK_LEFT_PAD = 32;
23
+
24
+ // Timing edge-drag and double-click group split were removed deliberately:
25
+ // segment timing and group structure only mutate the in-memory model — they
26
+ // are never applied to playback nor serialized to caption-overrides.json, so
27
+ // the UI was confirming edits that didn't exist. Restore them only alongside
28
+ // a real apply/persist pipeline.
29
+
21
30
  // ---------------------------------------------------------------------------
22
31
  // Types
23
32
  // ---------------------------------------------------------------------------
@@ -27,14 +36,6 @@ interface CaptionTimelineProps {
27
36
  onSeek?: (time: number) => void;
28
37
  }
29
38
 
30
- interface DragState {
31
- segId: string;
32
- edge: "start" | "end";
33
- originalStart: number;
34
- originalEnd: number;
35
- startX: number;
36
- }
37
-
38
39
  // ---------------------------------------------------------------------------
39
40
  // Component
40
41
  // ---------------------------------------------------------------------------
@@ -46,50 +47,7 @@ export const CaptionTimeline = memo(function CaptionTimeline({
46
47
  const model = useCaptionStore((s) => s.model);
47
48
  const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds);
48
49
  const selectSegment = useCaptionStore((s) => s.selectSegment);
49
- const updateSegmentTiming = useCaptionStore((s) => s.updateSegmentTiming);
50
- const splitGroup = useCaptionStore((s) => s.splitGroup);
51
-
52
- const dragRef = useRef<DragState | null>(null);
53
-
54
- const handleEdgePointerDown = useCallback(
55
- (
56
- e: React.PointerEvent<HTMLDivElement>,
57
- segId: string,
58
- edge: "start" | "end",
59
- originalStart: number,
60
- originalEnd: number,
61
- ) => {
62
- e.stopPropagation();
63
- e.preventDefault();
64
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
65
- dragRef.current = { segId, edge, originalStart, originalEnd, startX: e.clientX };
66
- },
67
- [],
68
- );
69
-
70
- const handlePointerMove = useCallback(
71
- (e: React.PointerEvent<HTMLDivElement>) => {
72
- const drag = dragRef.current;
73
- if (!drag) return;
74
-
75
- const delta = (e.clientX - drag.startX) / pixelsPerSecond;
76
-
77
- if (drag.edge === "start") {
78
- const newStart = Math.max(0, drag.originalStart + delta);
79
- const clampedStart = Math.min(newStart, drag.originalEnd - 0.05);
80
- updateSegmentTiming(drag.segId, clampedStart, drag.originalEnd);
81
- } else {
82
- const newEnd = Math.max(drag.originalStart + 0.05, drag.originalEnd + delta);
83
- const clampedEnd = Math.max(0, newEnd);
84
- updateSegmentTiming(drag.segId, drag.originalStart, clampedEnd);
85
- }
86
- },
87
- [pixelsPerSecond, updateSegmentTiming],
88
- );
89
-
90
- const handlePointerUp = useCallback(() => {
91
- dragRef.current = null;
92
- }, []);
50
+ const currentTime = usePlayerStore((s) => s.currentTime);
93
51
 
94
52
  const handleBlockClick = useCallback(
95
53
  (e: React.MouseEvent, segId: string) => {
@@ -99,19 +57,22 @@ export const CaptionTimeline = memo(function CaptionTimeline({
99
57
  [selectSegment],
100
58
  );
101
59
 
102
- const handleBlockDoubleClick = useCallback(
103
- (e: React.MouseEvent, groupId: string, segId: string) => {
104
- e.stopPropagation();
105
- splitGroup(groupId, segId);
60
+ const handleBlockKeyDown = useCallback(
61
+ (e: React.KeyboardEvent, segId: string) => {
62
+ if (e.key === "Enter" || e.key === " ") {
63
+ e.preventDefault();
64
+ e.stopPropagation();
65
+ selectSegment(segId, e.shiftKey);
66
+ }
106
67
  },
107
- [splitGroup],
68
+ [selectSegment],
108
69
  );
109
70
 
110
71
  const handleTrackClick = useCallback(
111
72
  (e: React.MouseEvent<HTMLDivElement>) => {
112
73
  if (!onSeek) return;
113
74
  const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
114
- const x = e.clientX - rect.left - 32;
75
+ const x = e.clientX - rect.left - TRACK_LEFT_PAD;
115
76
  const time = Math.max(0, x / pixelsPerSecond);
116
77
  onSeek(time);
117
78
  },
@@ -120,13 +81,12 @@ export const CaptionTimeline = memo(function CaptionTimeline({
120
81
 
121
82
  if (!model) return null;
122
83
 
84
+ const playheadLeft = TRACK_LEFT_PAD + currentTime * pixelsPerSecond;
85
+
123
86
  return (
124
87
  <div
125
88
  className="relative select-none overflow-x-auto"
126
89
  style={{ height: 40, minWidth: "100%" }}
127
- onPointerMove={handlePointerMove}
128
- onPointerUp={handlePointerUp}
129
- onPointerLeave={handlePointerUp}
130
90
  onClick={handleTrackClick}
131
91
  >
132
92
  {model.groupOrder.map((groupId, groupIdx) => {
@@ -138,14 +98,18 @@ export const CaptionTimeline = memo(function CaptionTimeline({
138
98
  const seg = model.segments.get(segId);
139
99
  if (!seg) return null;
140
100
 
141
- const left = 32 + seg.start * pixelsPerSecond;
101
+ const left = TRACK_LEFT_PAD + seg.start * pixelsPerSecond;
142
102
  const width = Math.max((seg.end - seg.start) * pixelsPerSecond, 4);
143
103
  const isSelected = selectedSegmentIds.has(segId);
144
104
 
145
105
  return (
146
106
  <div
147
107
  key={segId}
148
- className={`absolute top-1 bottom-1 rounded flex items-center overflow-hidden cursor-pointer${
108
+ role="button"
109
+ tabIndex={0}
110
+ aria-label={`Caption word "${seg.text}"`}
111
+ aria-pressed={isSelected}
112
+ className={`absolute top-1 bottom-1 rounded flex items-center overflow-hidden cursor-pointer focus-visible:ring-1 focus-visible:ring-white outline-none${
149
113
  isSelected ? " ring-1 ring-white/50 z-10" : ""
150
114
  }`}
151
115
  style={{
@@ -155,15 +119,8 @@ export const CaptionTimeline = memo(function CaptionTimeline({
155
119
  zIndex: isSelected ? 10 : 1,
156
120
  }}
157
121
  onClick={(e) => handleBlockClick(e, segId)}
158
- onDoubleClick={(e) => handleBlockDoubleClick(e, groupId, segId)}
122
+ onKeyDown={(e) => handleBlockKeyDown(e, segId)}
159
123
  >
160
- {/* Left edge drag handle */}
161
- <div
162
- className="absolute left-0 top-0 bottom-0 cursor-col-resize z-20"
163
- style={{ width: 6 }}
164
- onPointerDown={(e) => handleEdgePointerDown(e, segId, "start", seg.start, seg.end)}
165
- />
166
-
167
124
  {/* Text label */}
168
125
  <span
169
126
  className="flex-1 truncate px-2 pointer-events-none"
@@ -171,17 +128,17 @@ export const CaptionTimeline = memo(function CaptionTimeline({
171
128
  >
172
129
  {seg.text}
173
130
  </span>
174
-
175
- {/* Right edge drag handle */}
176
- <div
177
- className="absolute right-0 top-0 bottom-0 cursor-col-resize z-20"
178
- style={{ width: 6 }}
179
- onPointerDown={(e) => handleEdgePointerDown(e, segId, "end", seg.start, seg.end)}
180
- />
181
131
  </div>
182
132
  );
183
133
  });
184
134
  })}
135
+
136
+ {/* Playhead — correlates blocks with the current frame */}
137
+ <div
138
+ className="absolute top-0 bottom-0 w-px bg-white/70 pointer-events-none z-20"
139
+ style={{ left: playheadLeft }}
140
+ aria-hidden="true"
141
+ />
185
142
  </div>
186
143
  );
187
144
  });
@@ -1,7 +1,8 @@
1
1
  import type React from "react";
2
+ import { useEffect, useRef, useState } from "react";
2
3
 
3
4
  export const inputCls =
4
- "w-full bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-2xs text-neutral-200 font-mono outline-none focus:border-neutral-600";
5
+ "w-full bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-2xs text-neutral-200 font-mono outline-none focus:border-studio-accent disabled:opacity-40 disabled:cursor-not-allowed";
5
6
 
6
7
  export function Section({ label, children }: { label: string; children: React.ReactNode }) {
7
8
  return (
@@ -24,3 +25,77 @@ export function Row({ label, children }: { label: string; children: React.ReactN
24
25
  </div>
25
26
  );
26
27
  }
28
+
29
+ interface NumberFieldProps {
30
+ value: number | undefined;
31
+ /** True when a multi-selection has differing values — shows "Mixed" until edited. */
32
+ mixed?: boolean;
33
+ step?: number;
34
+ min?: number;
35
+ max?: number;
36
+ disabled?: boolean;
37
+ ariaLabel: string;
38
+ onCommit: (value: number) => void;
39
+ }
40
+
41
+ /**
42
+ * Numeric input that only commits finite parses. Typing "-" or clearing the
43
+ * field keeps a local draft instead of committing NaN/0 into live transforms
44
+ * and the persisted overrides file.
45
+ */
46
+ export function NumberField({
47
+ value,
48
+ mixed,
49
+ step,
50
+ min,
51
+ max,
52
+ disabled,
53
+ ariaLabel,
54
+ onCommit,
55
+ }: NumberFieldProps) {
56
+ const [draft, setDraft] = useState<string | null>(null);
57
+ const focusedRef = useRef(false);
58
+
59
+ // External value changed while not editing — drop any stale draft.
60
+ useEffect(() => {
61
+ if (!focusedRef.current) setDraft(null);
62
+ }, [value]);
63
+
64
+ const display = draft !== null ? draft : mixed ? "" : String(value ?? 0);
65
+
66
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
67
+ const raw = e.target.value;
68
+ setDraft(raw);
69
+ const parsed = Number(raw);
70
+ if (raw.trim() !== "" && Number.isFinite(parsed)) {
71
+ // Native min/max only constrain spinner steps — typed values bypass
72
+ // them, so clamp before committing to the model/overrides.
73
+ let clamped = parsed;
74
+ if (min !== undefined) clamped = Math.max(min, clamped);
75
+ if (max !== undefined) clamped = Math.min(max, clamped);
76
+ onCommit(clamped);
77
+ }
78
+ };
79
+
80
+ return (
81
+ <input
82
+ type="number"
83
+ className={inputCls}
84
+ value={display}
85
+ placeholder={mixed ? "Mixed" : undefined}
86
+ step={step}
87
+ min={min}
88
+ max={max}
89
+ disabled={disabled}
90
+ aria-label={ariaLabel}
91
+ onChange={handleChange}
92
+ onFocus={() => {
93
+ focusedRef.current = true;
94
+ }}
95
+ onBlur={() => {
96
+ focusedRef.current = false;
97
+ setDraft(null);
98
+ }}
99
+ />
100
+ );
101
+ }
@@ -68,23 +68,49 @@ export function useCaptionSync(projectId: string | null) {
68
68
  // Flag to suppress auto-save during loadOverrides
69
69
  const suppressSaveRef = useRef(false);
70
70
 
71
+ // True while an edit is debounced or a PUT is in flight — guards tab close.
72
+ const pendingRef = useRef(false);
73
+ // Bumped on every new edit: a PUT's success may only clear the pending flag
74
+ // when no newer edit re-armed the debounce while it was in flight.
75
+ const editSeqRef = useRef(0);
76
+
71
77
  const save = useCallback(() => {
72
78
  const state = useCaptionStore.getState();
73
- if (!state.model || !state.sourceFilePath || !state.isEditMode) return;
79
+ // Note: deliberately no isEditMode guard — exiting caption mode (or any
80
+ // path that fires the flush) must still write the last edits; the model
81
+ // survives exit, and after a store reset it is null anyway.
82
+ if (!state.model || !state.sourceFilePath) {
83
+ pendingRef.current = false;
84
+ return;
85
+ }
74
86
  const pid = projectIdRef.current;
75
- if (!pid) return;
87
+ if (!pid) {
88
+ pendingRef.current = false;
89
+ return;
90
+ }
76
91
 
92
+ const seqAtSave = editSeqRef.current;
77
93
  const overrides = buildOverrides(state.model);
78
94
 
79
95
  fetch(`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, {
80
96
  method: "PUT",
81
97
  headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
82
98
  body: JSON.stringify(overrides, null, 2),
83
- }).catch((error: unknown) => {
84
- // Caption auto-save is a data-loss path; surface failures via telemetry
85
- // so a silently-dropped edit isn't invisible (no console in studio).
86
- trackEvent("studio_caption_autosave_failed", { error: String(error) });
87
- });
99
+ })
100
+ .then((res) => {
101
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
102
+ // A newer edit may have re-armed the debounce while this PUT was in
103
+ // flight — its beforeunload/unmount flush still needs pending=true.
104
+ if (editSeqRef.current === seqAtSave) pendingRef.current = false;
105
+ const s = useCaptionStore.getState();
106
+ if (s.syncError) s.setSyncError(null);
107
+ })
108
+ .catch((error: unknown) => {
109
+ // Caption auto-save is a data-loss path: surface it to the user, not
110
+ // just telemetry. pendingRef stays true so beforeunload still warns.
111
+ trackEvent("studio_caption_autosave_failed", { error: String(error) });
112
+ useCaptionStore.getState().setSyncError("Caption changes couldn't be saved");
113
+ });
88
114
  }, []);
89
115
 
90
116
  // Auto-save on model changes with 800ms debounce
@@ -102,12 +128,37 @@ export function useCaptionSync(projectId: string | null) {
102
128
  }
103
129
 
104
130
  if (debounceRef.current) clearTimeout(debounceRef.current);
131
+ pendingRef.current = true;
132
+ editSeqRef.current++;
105
133
  debounceRef.current = setTimeout(save, 800);
106
134
  });
107
135
 
136
+ // Warn before the tab closes while an edit is unsaved.
137
+ const handleBeforeUnload = (e: BeforeUnloadEvent) => {
138
+ if (!pendingRef.current) return;
139
+ // Flush best-effort, then let the browser show its confirm.
140
+ if (debounceRef.current) {
141
+ clearTimeout(debounceRef.current);
142
+ debounceRef.current = null;
143
+ save();
144
+ }
145
+ e.preventDefault();
146
+ };
147
+ window.addEventListener("beforeunload", handleBeforeUnload);
148
+
149
+ // Let the caption error banner retry the save directly.
150
+ useCaptionStore.getState().setRetrySave(save);
151
+
108
152
  return () => {
109
153
  unsub();
110
- if (debounceRef.current) clearTimeout(debounceRef.current);
154
+ window.removeEventListener("beforeunload", handleBeforeUnload);
155
+ // Flush instead of discarding — clearTimeout alone drops the final edits.
156
+ if (debounceRef.current) {
157
+ clearTimeout(debounceRef.current);
158
+ debounceRef.current = null;
159
+ if (pendingRef.current) save();
160
+ }
161
+ useCaptionStore.getState().setRetrySave(null);
111
162
  };
112
163
  });
113
164
 
@@ -117,16 +168,21 @@ export function useCaptionSync(projectId: string | null) {
117
168
  const pid = projectIdRef.current;
118
169
  if (!pid) return;
119
170
 
171
+ let data: { content?: string };
120
172
  try {
121
173
  const res = await fetch(
122
174
  `/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`,
123
175
  );
124
- if (!res.ok) return;
125
- const data = await res.json();
126
- if (!data.content) return;
176
+ if (!res.ok) return; // no overrides file yet — normal
177
+ data = await res.json();
178
+ } catch {
179
+ return; // network failure fetching an optional file — nothing to restore
180
+ }
181
+ if (!data.content) return;
127
182
 
183
+ try {
128
184
  const overrides: CaptionOverrideEntry[] = JSON.parse(data.content);
129
- if (!Array.isArray(overrides)) return;
185
+ if (!Array.isArray(overrides)) throw new Error("not an array");
130
186
 
131
187
  const model = state.model;
132
188
  const allSegIds: string[] = [];
@@ -171,7 +227,10 @@ export function useCaptionSync(projectId: string | null) {
171
227
  suppressSaveRef.current = true;
172
228
  useCaptionStore.getState().setModel({ ...model, segments: newSegments });
173
229
  } catch {
174
- // No overrides file
230
+ // File exists but is unreadable — previous edits would silently not load.
231
+ useCaptionStore
232
+ .getState()
233
+ .setSyncError("caption-overrides.json is corrupt — earlier caption edits didn't load");
175
234
  }
176
235
  }, []);
177
236
 
@@ -1,3 +1,5 @@
1
+ // @vitest-environment happy-dom
2
+
1
3
  import { describe, expect, it } from "vitest";
2
4
  import { shouldHandleCaptionNudgeKey } from "./keyboard";
3
5
 
@@ -35,4 +37,14 @@ describe("shouldHandleCaptionNudgeKey", () => {
35
37
  it("ignores non-arrow keys", () => {
36
38
  expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("KeyL"))).toBe(false);
37
39
  });
40
+
41
+ it("ignores arrows when the event target is an editable element", () => {
42
+ const input = document.createElement("input");
43
+ const textarea = document.createElement("textarea");
44
+ const div = document.createElement("div");
45
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), input)).toBe(false);
46
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), textarea)).toBe(false);
47
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), div)).toBe(true);
48
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), null)).toBe(true);
49
+ });
38
50
  });
@@ -1,8 +1,19 @@
1
+ import { isEditableTarget } from "../utils/timelineDiscovery";
2
+
1
3
  const CAPTION_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
2
4
 
3
5
  type CaptionNudgeKeyEvent = Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key">;
4
6
 
5
- export function shouldHandleCaptionNudgeKey(event: CaptionNudgeKeyEvent): boolean {
7
+ export function shouldHandleCaptionNudgeKey(
8
+ event: CaptionNudgeKeyEvent,
9
+ target?: EventTarget | null,
10
+ ): boolean {
6
11
  if (event.metaKey || event.ctrlKey || event.altKey) return false;
12
+ if (target != null && isEditableTarget(target)) return false;
7
13
  return CAPTION_NUDGE_KEYS.has(event.key);
8
14
  }
15
+
16
+ // Single editable-target check for the caption surface — re-exported from the
17
+ // shared timeline helper so the two never diverge (it also covers ARIA
18
+ // textbox/searchbox/combobox roles and nested editors via closest()).
19
+ export { isEditableTarget as isEditableEventTarget };