@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.
- package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-bDcU464i.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/index-CUXnyoIa.js +428 -0
- package/dist/assets/{index-bv8b5eiQ.js → index-FkAs3zRu.js} +1 -1
- package/dist/assets/{index-Ci7Uy8fR.js → index-MSOSP1-J.js} +1 -1
- package/dist/index.d.ts +98 -17
- package/dist/index.html +2 -2
- package/dist/index.js +10336 -7195
- 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 +2 -0
- 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 +222 -1
- package/src/hooks/timelineTrackVisibility.ts +187 -4
- 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 +7 -7
- 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 +5 -0
- 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.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +26 -0
- 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.ts +150 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/sdk": "0.8.
|
|
54
|
-
"@hyperframes/studio-server": "0.8.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
<
|
|
109
|
-
type="number"
|
|
118
|
+
<NumberField
|
|
110
119
|
value={duration}
|
|
111
120
|
step={0.05}
|
|
112
121
|
min={0}
|
|
113
122
|
max={2}
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
<
|
|
135
|
-
type="number"
|
|
146
|
+
<NumberField
|
|
136
147
|
value={stagger}
|
|
137
148
|
step={0.02}
|
|
138
149
|
min={0}
|
|
139
150
|
max={0.5}
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
|
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'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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
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: -
|
|
335
|
-
width:
|
|
336
|
-
height:
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
{
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
...
|
|
371
|
-
width:
|
|
372
|
-
height:
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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);
|