@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.
- package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
- package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
- package/dist/assets/index-ng4tq6YE.js +428 -0
- package/dist/index.d.ts +108 -18
- package/dist/index.html +2 -2
- package/dist/index.js +10335 -7201
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +22 -23
- package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
- package/src/captions/components/CaptionOverlay.tsx +172 -29
- package/src/captions/components/CaptionOverlayUtils.ts +54 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
- package/src/captions/components/CaptionTimeline.tsx +36 -79
- package/src/captions/components/shared.tsx +76 -1
- package/src/captions/hooks/useCaptionSync.ts +72 -13
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +12 -1
- package/src/captions/store.ts +120 -14
- package/src/components/EditorShell.selectionSync.test.tsx +1 -1
- package/src/components/EditorShell.tsx +13 -1
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/StudioRightPanel.types.ts +1 -0
- package/src/components/editor/AnimationCard.tsx +2 -1
- package/src/components/editor/AnimationCardParts.tsx +6 -3
- package/src/components/editor/ArcPathControls.tsx +10 -4
- package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
- package/src/components/editor/BlockParamsPanel.tsx +153 -11
- package/src/components/editor/BorderRadiusEditor.tsx +3 -22
- package/src/components/editor/FileTree.tsx +41 -12
- package/src/components/editor/FileTreeNodes.tsx +90 -20
- package/src/components/editor/GsapAnimationList.tsx +5 -0
- package/src/components/editor/KeyframeDiamond.tsx +3 -1
- package/src/components/editor/KeyframeEaseList.tsx +3 -2
- package/src/components/editor/KeyframeNavigation.tsx +6 -2
- package/src/components/editor/LayersPanel.tsx +11 -2
- package/src/components/editor/MotionPathNode.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +7 -6
- package/src/components/editor/PropertyPanel.tsx +13 -5
- package/src/components/editor/PropertyPanelFlat.tsx +3 -5
- package/src/components/editor/SnapToolbar.tsx +20 -4
- package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
- package/src/components/editor/TimelineFxPopover.tsx +138 -0
- package/src/components/editor/Transform3DCube.tsx +22 -1
- package/src/components/editor/manualOffsetDrag.test.ts +49 -0
- package/src/components/editor/manualOffsetDrag.ts +13 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
- package/src/components/editor/propertyPanelColor.tsx +9 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
- package/src/components/editor/propertyPanelCommitField.tsx +9 -1
- package/src/components/editor/propertyPanelFill.tsx +50 -2
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.tsx +45 -9
- package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
- package/src/components/editor/propertyPanelFxSection.tsx +8 -23
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
- package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.ts +132 -0
- package/src/components/nle/PreviewOverlays.tsx +69 -2
- package/src/components/nle/PreviewPane.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +81 -12
- package/src/components/panels/SlideshowSubPanels.tsx +61 -7
- package/src/components/sidebar/AssetCard.test.tsx +2 -2
- package/src/components/sidebar/AssetCard.tsx +94 -46
- package/src/components/sidebar/AssetContextMenu.tsx +196 -42
- package/src/components/sidebar/AssetsTab.tsx +156 -96
- package/src/components/sidebar/AudioRow.tsx +95 -40
- package/src/components/sidebar/BlocksTab.tsx +40 -128
- package/src/components/sidebar/CompositionsTab.tsx +46 -34
- package/src/components/sidebar/LeftSidebar.tsx +87 -75
- package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
- package/src/components/sidebar/assetHelpers.ts +8 -0
- package/src/contexts/TimelineEditContext.tsx +5 -0
- package/src/hooks/timelineAudioGroupVolume.ts +148 -0
- package/src/hooks/timelineEditingHelpers.ts +78 -1
- package/src/hooks/timelineElementFxAttribute.ts +150 -0
- package/src/hooks/timelineTrackVisibility.test.ts +253 -1
- package/src/hooks/timelineTrackVisibility.ts +207 -8
- package/src/hooks/useAppHotkeys.ts +22 -0
- package/src/hooks/useAudioSoloBridge.ts +61 -0
- package/src/hooks/useCaptionDetection.ts +22 -2
- package/src/hooks/useDomEditCommits.test.tsx +24 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
- package/src/hooks/useGroupLevel.ts +36 -0
- package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
- package/src/hooks/useMusicBeatAnalysis.ts +13 -0
- package/src/hooks/useTimelineDeleteOps.ts +170 -0
- package/src/hooks/useTimelineEditing.ts +53 -129
- package/src/player/components/AudioWaveform.tsx +29 -26
- package/src/player/components/BeatStrip.test.tsx +5 -9
- package/src/player/components/BeatStrip.tsx +12 -5
- package/src/player/components/ClipContextMenu.tsx +8 -2
- package/src/player/components/EditModal.tsx +48 -7
- package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
- package/src/player/components/LayerDisclosureRow.tsx +12 -13
- package/src/player/components/Player.tsx +43 -8
- package/src/player/components/PlayerControls.tsx +107 -63
- package/src/player/components/ShortcutsPanel.tsx +23 -4
- package/src/player/components/SpeedMenu.tsx +34 -42
- package/src/player/components/Timeline.test.ts +8 -8
- package/src/player/components/Timeline.tsx +7 -10
- package/src/player/components/TimelineFxButton.test.tsx +81 -0
- package/src/player/components/TimelineFxButton.tsx +143 -0
- package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
- package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
- package/src/player/components/TimelineGroupHeader.tsx +176 -0
- package/src/player/components/TimelineGroupRow.tsx +146 -0
- package/src/player/components/TimelineLanes.test.tsx +8 -1
- package/src/player/components/TimelineLanes.tsx +39 -33
- package/src/player/components/TimelineOverlays.tsx +34 -0
- package/src/player/components/TimelineSoloButton.tsx +32 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
- package/src/player/components/TimelineTrackHeader.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +40 -1
- package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
- package/src/player/components/timelineKeyboardNavigation.ts +147 -30
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/components/timelineNavigationIdentity.ts +4 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
- package/src/player/components/useTimelineLogicalFocus.ts +9 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
- package/src/player/components/useTimelineLogicalRows.ts +17 -12
- package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
- package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
- package/src/player/components/useTimelineTrackDerivations.ts +143 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -47
- package/src/player/lib/timelineDOM.ts +51 -0
- package/src/player/store/audioSoloSlice.test.ts +113 -0
- package/src/player/store/audioSoloSlice.ts +43 -0
- package/src/player/store/editingModeSlice.ts +45 -0
- package/src/player/store/groupLevels.ts +33 -0
- package/src/player/store/keyframeSlice.ts +26 -0
- package/src/player/store/playerStore.ts +21 -22
- package/src/player/store/timelineElement.ts +10 -0
- package/src/telemetry/events.ts +12 -0
- package/dist/assets/index-Cy6fGN1u.js +0 -428
- 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,
|
|
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
|
-
<
|
|
231
|
-
type="number"
|
|
261
|
+
<NumberField
|
|
232
262
|
value={x}
|
|
233
|
-
|
|
234
|
-
|
|
263
|
+
mixed={xMixed}
|
|
264
|
+
ariaLabel="X position"
|
|
265
|
+
onCommit={(v) => handleStyleChange({ x: v })}
|
|
235
266
|
/>
|
|
236
267
|
</Row>
|
|
237
268
|
<Row label="Y">
|
|
238
|
-
<
|
|
239
|
-
type="number"
|
|
269
|
+
<NumberField
|
|
240
270
|
value={y}
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
<
|
|
250
|
-
type="number"
|
|
280
|
+
<NumberField
|
|
251
281
|
value={scaleX}
|
|
282
|
+
mixed={scaleMixed}
|
|
252
283
|
step={0.1}
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
<
|
|
264
|
-
type="number"
|
|
289
|
+
<NumberField
|
|
265
290
|
value={rotation}
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
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
|
|
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
|
|
103
|
-
(e: React.
|
|
104
|
-
e.
|
|
105
|
-
|
|
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
|
-
[
|
|
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 -
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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)
|
|
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
|
-
})
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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))
|
|
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
|
-
//
|
|
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
|
});
|
package/src/captions/keyboard.ts
CHANGED
|
@@ -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(
|
|
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 };
|