@hyperframes/studio 0.2.1 → 0.2.2-alpha.3
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/index-BT9D8I7B.css +1 -0
- package/dist/assets/index-DA_l-VKo.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/App.tsx +213 -8
- package/src/captions/components/CaptionAnimationPanel.tsx +269 -0
- package/src/captions/components/CaptionOverlay.tsx +622 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +275 -0
- package/src/captions/components/CaptionTimeline.tsx +187 -0
- package/src/captions/components/shared.tsx +26 -0
- package/src/captions/generator.test.ts +279 -0
- package/src/captions/generator.ts +376 -0
- package/src/captions/hooks/useCaptionSync.ts +168 -0
- package/src/captions/index.ts +10 -0
- package/src/captions/parser.test.ts +377 -0
- package/src/captions/parser.ts +314 -0
- package/src/captions/store.ts +272 -0
- package/src/captions/types.ts +207 -0
- package/src/components/nle/NLELayout.tsx +1 -1
- package/dist/assets/index-Bkp9HQbo.css +0 -1
- package/dist/assets/index-DfhSlTti.js +0 -93
package/dist/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>HyperFrames Studio</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-DA_l-VKo.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BT9D8I7B.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2-alpha.3",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"codemirror": "^6.0.1",
|
|
34
34
|
"motion": "^12.38.0",
|
|
35
|
-
"@hyperframes/core": "0.2.
|
|
35
|
+
"@hyperframes/core": "0.2.2-alpha.3"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/react": "^19.0.0",
|
package/src/App.tsx
CHANGED
|
@@ -12,6 +12,12 @@ import { LintModal } from "./components/LintModal";
|
|
|
12
12
|
import type { LintFinding } from "./components/LintModal";
|
|
13
13
|
import { MediaPreview } from "./components/MediaPreview";
|
|
14
14
|
import { isMediaFile } from "./utils/mediaTypes";
|
|
15
|
+
import { CaptionOverlay } from "./captions/components/CaptionOverlay";
|
|
16
|
+
import { CaptionPropertyPanel } from "./captions/components/CaptionPropertyPanel";
|
|
17
|
+
import { CaptionTimeline } from "./captions/components/CaptionTimeline";
|
|
18
|
+
import { useCaptionStore } from "./captions/store";
|
|
19
|
+
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
20
|
+
import { parseCaptionComposition } from "./captions/parser";
|
|
15
21
|
|
|
16
22
|
interface EditingFile {
|
|
17
23
|
path: string;
|
|
@@ -50,12 +56,134 @@ export function StudioApp() {
|
|
|
50
56
|
const [fileTree, setFileTree] = useState<string[]>([]);
|
|
51
57
|
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
|
|
52
58
|
const renderQueue = useRenderQueue(projectId);
|
|
59
|
+
const captionEditMode = useCaptionStore((s) => s.isEditMode);
|
|
60
|
+
const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
|
|
61
|
+
const captionSync = useCaptionSync(projectId);
|
|
53
62
|
|
|
54
63
|
// Resizable and collapsible panel widths
|
|
55
64
|
const [leftWidth, setLeftWidth] = useState(240);
|
|
56
65
|
const [rightWidth, setRightWidth] = useState(400);
|
|
57
66
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
58
67
|
const [rightCollapsed, setRightCollapsed] = useState(true);
|
|
68
|
+
// Auto-enter caption edit mode when the iframe contains .caption-group elements.
|
|
69
|
+
// This is a subscription to external events (postMessage from runtime) — useEffect
|
|
70
|
+
// is appropriate here. The runtime fires "state"/"timeline" messages after all
|
|
71
|
+
// compositions load, which triggers caption detection.
|
|
72
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!projectId) return;
|
|
75
|
+
|
|
76
|
+
let activating = false;
|
|
77
|
+
|
|
78
|
+
const tryActivateCaptions = () => {
|
|
79
|
+
if (useCaptionStore.getState().isEditMode || activating) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const iframe = previewIframeRef.current;
|
|
84
|
+
let doc: Document | null = null;
|
|
85
|
+
let win: Window | null = null;
|
|
86
|
+
try {
|
|
87
|
+
doc = iframe?.contentDocument ?? null;
|
|
88
|
+
win = iframe?.contentWindow ?? null;
|
|
89
|
+
} catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!doc || !win) return;
|
|
93
|
+
|
|
94
|
+
const groups = doc.querySelectorAll(".caption-group");
|
|
95
|
+
if (groups.length === 0) return;
|
|
96
|
+
|
|
97
|
+
// Find the captions composition source path.
|
|
98
|
+
// The runtime strips data-composition-src after loading, so also check
|
|
99
|
+
// data-composition-file (set by the bundler) and the compIdToSrc map.
|
|
100
|
+
let captionSrcPath: string | null = null;
|
|
101
|
+
|
|
102
|
+
// Strategy 1: data-composition-src or data-composition-file attributes
|
|
103
|
+
const compHosts = doc.querySelectorAll("[data-composition-src], [data-composition-file]");
|
|
104
|
+
for (const host of compHosts) {
|
|
105
|
+
const src =
|
|
106
|
+
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
|
|
107
|
+
if (src && src.includes("captions")) {
|
|
108
|
+
captionSrcPath = src;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Strategy 2: compIdToSrc map (built from raw index.html before runtime strips attrs)
|
|
114
|
+
if (!captionSrcPath) {
|
|
115
|
+
for (const [id, src] of compIdToSrc) {
|
|
116
|
+
if (id.includes("caption") || src.includes("caption")) {
|
|
117
|
+
captionSrcPath = src;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Strategy 3: activeCompPath if viewing captions directly
|
|
124
|
+
if (!captionSrcPath && activeCompPath?.includes("captions")) {
|
|
125
|
+
captionSrcPath = activeCompPath;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Strategy 4: find composition element with "caption" in its ID
|
|
129
|
+
if (!captionSrcPath) {
|
|
130
|
+
const captionComp = doc.querySelector('[data-composition-id*="caption"]');
|
|
131
|
+
if (captionComp) {
|
|
132
|
+
const compId = captionComp.getAttribute("data-composition-id") || "";
|
|
133
|
+
captionSrcPath = compIdToSrc.get(compId) || null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!captionSrcPath) return;
|
|
138
|
+
|
|
139
|
+
activating = true;
|
|
140
|
+
const srcPath = captionSrcPath;
|
|
141
|
+
fetch(`/api/projects/${projectId}/files/${encodeURIComponent(srcPath)}`)
|
|
142
|
+
.then((r) => r.json())
|
|
143
|
+
.then((data: { content?: string }) => {
|
|
144
|
+
if (!data.content || !doc || !win || useCaptionStore.getState().isEditMode) return;
|
|
145
|
+
const root = doc.querySelector("[data-composition-id]");
|
|
146
|
+
const w = parseInt(root?.getAttribute("data-width") ?? "1920", 10);
|
|
147
|
+
const h = parseInt(root?.getAttribute("data-height") ?? "1080", 10);
|
|
148
|
+
const dur = parseFloat(root?.getAttribute("data-duration") ?? "0");
|
|
149
|
+
const model = parseCaptionComposition(doc, win, data.content, w, h, dur);
|
|
150
|
+
if (!model) return;
|
|
151
|
+
const store = useCaptionStore.getState();
|
|
152
|
+
store.setModel(model);
|
|
153
|
+
store.setSourceFilePath(srcPath);
|
|
154
|
+
store.setEditMode(true);
|
|
155
|
+
captionSync.loadOverrides();
|
|
156
|
+
})
|
|
157
|
+
.catch(() => {})
|
|
158
|
+
.finally(() => {
|
|
159
|
+
activating = false;
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Listen for runtime messages that signal composition loading is complete
|
|
164
|
+
const handleMessage = (e: MessageEvent) => {
|
|
165
|
+
const data = e.data;
|
|
166
|
+
if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
|
|
167
|
+
tryActivateCaptions();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
window.addEventListener("message", handleMessage);
|
|
172
|
+
// Try immediately in case compositions are already loaded
|
|
173
|
+
tryActivateCaptions();
|
|
174
|
+
|
|
175
|
+
return () => {
|
|
176
|
+
window.removeEventListener("message", handleMessage);
|
|
177
|
+
};
|
|
178
|
+
}, [activeCompPath, projectId, compIdToSrc, captionSync]);
|
|
179
|
+
|
|
180
|
+
// Auto-expand right panel when a caption word is selected
|
|
181
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (captionEditMode) {
|
|
184
|
+
setRightCollapsed(!captionHasSelection);
|
|
185
|
+
}
|
|
186
|
+
}, [captionHasSelection, captionEditMode]);
|
|
59
187
|
const [globalDragOver, setGlobalDragOver] = useState(false);
|
|
60
188
|
const [uploadToast, setUploadToast] = useState<string | null>(null);
|
|
61
189
|
const [timelineVisible, setTimelineVisible] = useState(false);
|
|
@@ -159,12 +287,14 @@ export function StudioApp() {
|
|
|
159
287
|
[compIdToSrc, activePreviewUrl],
|
|
160
288
|
);
|
|
161
289
|
const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
|
|
290
|
+
const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
|
|
162
291
|
const [linting, setLinting] = useState(false);
|
|
163
292
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
164
293
|
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
165
294
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
166
295
|
const projectIdRef = useRef(projectId);
|
|
167
296
|
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
297
|
+
const consoleErrorsRef = useRef<LintFinding[]>([]);
|
|
168
298
|
|
|
169
299
|
// Listen for external file changes (user editing HTML outside the editor).
|
|
170
300
|
// In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
|
|
@@ -673,7 +803,69 @@ export function StudioApp() {
|
|
|
673
803
|
}}
|
|
674
804
|
onIframeRef={(iframe) => {
|
|
675
805
|
previewIframeRef.current = iframe;
|
|
806
|
+
consoleErrorsRef.current = [];
|
|
807
|
+
setConsoleErrors(null);
|
|
808
|
+
if (!iframe) return;
|
|
809
|
+
|
|
810
|
+
// Attach error capture after each iframe load (content resets on navigation)
|
|
811
|
+
const attachErrorCapture = () => {
|
|
812
|
+
try {
|
|
813
|
+
const win = iframe.contentWindow as (Window & typeof globalThis) | null;
|
|
814
|
+
if (!win) return;
|
|
815
|
+
// Guard against double-patching
|
|
816
|
+
if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
|
|
817
|
+
(win as unknown as Record<string, unknown>).__hfErrorCapture = true;
|
|
818
|
+
const origError = win.console.error.bind(win.console);
|
|
819
|
+
win.console.error = function (...args: unknown[]) {
|
|
820
|
+
origError(...args);
|
|
821
|
+
const text = args
|
|
822
|
+
.map((a) => (a instanceof Error ? a.message : String(a)))
|
|
823
|
+
.join(" ");
|
|
824
|
+
if (text.includes("favicon")) return;
|
|
825
|
+
consoleErrorsRef.current = [
|
|
826
|
+
...consoleErrorsRef.current,
|
|
827
|
+
{ severity: "error", message: text },
|
|
828
|
+
];
|
|
829
|
+
setConsoleErrors([...consoleErrorsRef.current]);
|
|
830
|
+
};
|
|
831
|
+
win.addEventListener("error", (e: ErrorEvent) => {
|
|
832
|
+
const text = e.message || String(e);
|
|
833
|
+
consoleErrorsRef.current = [
|
|
834
|
+
...consoleErrorsRef.current,
|
|
835
|
+
{ severity: "error", message: text },
|
|
836
|
+
];
|
|
837
|
+
setConsoleErrors([...consoleErrorsRef.current]);
|
|
838
|
+
});
|
|
839
|
+
} catch {
|
|
840
|
+
// cross-origin — can't attach
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
// Attach now (iframe may already be loaded) and on future loads
|
|
844
|
+
attachErrorCapture();
|
|
845
|
+
iframe.addEventListener("load", () => {
|
|
846
|
+
consoleErrorsRef.current = [];
|
|
847
|
+
setConsoleErrors(null);
|
|
848
|
+
attachErrorCapture();
|
|
849
|
+
});
|
|
676
850
|
}}
|
|
851
|
+
previewOverlay={
|
|
852
|
+
captionEditMode ? <CaptionOverlay iframeRef={previewIframeRef} /> : undefined
|
|
853
|
+
}
|
|
854
|
+
timelineFooter={
|
|
855
|
+
captionEditMode ? (
|
|
856
|
+
<div
|
|
857
|
+
className="border-t border-neutral-800/30 flex-shrink-0"
|
|
858
|
+
style={{ height: 60 }}
|
|
859
|
+
>
|
|
860
|
+
<div className="flex items-center gap-1.5 px-2 py-0.5">
|
|
861
|
+
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
|
|
862
|
+
Captions
|
|
863
|
+
</span>
|
|
864
|
+
</div>
|
|
865
|
+
<CaptionTimeline pixelsPerSecond={100} />
|
|
866
|
+
</div>
|
|
867
|
+
) : undefined
|
|
868
|
+
}
|
|
677
869
|
timelineVisible={timelineVisible}
|
|
678
870
|
onToggleTimeline={() => setTimelineVisible((v) => !v)}
|
|
679
871
|
/>
|
|
@@ -693,14 +885,18 @@ export function StudioApp() {
|
|
|
693
885
|
className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
|
|
694
886
|
style={{ width: rightWidth }}
|
|
695
887
|
>
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
888
|
+
{captionEditMode ? (
|
|
889
|
+
<CaptionPropertyPanel iframeRef={previewIframeRef} />
|
|
890
|
+
) : (
|
|
891
|
+
<RenderQueue
|
|
892
|
+
jobs={renderQueue.jobs}
|
|
893
|
+
projectId={projectId}
|
|
894
|
+
onDelete={renderQueue.deleteRender}
|
|
895
|
+
onClearCompleted={renderQueue.clearCompleted}
|
|
896
|
+
onStartRender={(format) => renderQueue.startRender(30, "standard", format)}
|
|
897
|
+
isRendering={renderQueue.isRendering}
|
|
898
|
+
/>
|
|
899
|
+
)}
|
|
704
900
|
</div>
|
|
705
901
|
</>
|
|
706
902
|
)}
|
|
@@ -711,6 +907,15 @@ export function StudioApp() {
|
|
|
711
907
|
<LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
|
|
712
908
|
)}
|
|
713
909
|
|
|
910
|
+
{/* Console errors modal — auto-shows when composition has runtime errors */}
|
|
911
|
+
{consoleErrors !== null && consoleErrors.length > 0 && projectId && (
|
|
912
|
+
<LintModal
|
|
913
|
+
findings={consoleErrors}
|
|
914
|
+
projectId={projectId}
|
|
915
|
+
onClose={() => setConsoleErrors(null)}
|
|
916
|
+
/>
|
|
917
|
+
)}
|
|
918
|
+
|
|
714
919
|
{/* Global drag-drop overlay */}
|
|
715
920
|
{globalDragOver && (
|
|
716
921
|
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { memo, useCallback } from "react";
|
|
2
|
+
import { useCaptionStore } from "../store";
|
|
3
|
+
import type { CaptionAnimation } from "../types";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Constants
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const ENTRANCE_PRESETS = [
|
|
10
|
+
"none",
|
|
11
|
+
"fade",
|
|
12
|
+
"slide-up",
|
|
13
|
+
"slide-down",
|
|
14
|
+
"slide-left",
|
|
15
|
+
"slide-right",
|
|
16
|
+
"pop",
|
|
17
|
+
"slam",
|
|
18
|
+
"bounce",
|
|
19
|
+
"typewriter",
|
|
20
|
+
"blur-in",
|
|
21
|
+
"flip",
|
|
22
|
+
"drop",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const HIGHLIGHT_PRESETS = [
|
|
26
|
+
"none",
|
|
27
|
+
"color-change",
|
|
28
|
+
"scale-pop",
|
|
29
|
+
"glow-pulse",
|
|
30
|
+
"underline-sweep",
|
|
31
|
+
"background-fill",
|
|
32
|
+
"bounce",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const EXIT_PRESETS = [
|
|
36
|
+
"none",
|
|
37
|
+
"fade",
|
|
38
|
+
"slide-up",
|
|
39
|
+
"slide-down",
|
|
40
|
+
"slide-left",
|
|
41
|
+
"slide-right",
|
|
42
|
+
"scatter",
|
|
43
|
+
"drop",
|
|
44
|
+
"collapse",
|
|
45
|
+
"blur-out",
|
|
46
|
+
"shrink",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const EASE_PRESETS = [
|
|
50
|
+
"power1.out",
|
|
51
|
+
"power2.out",
|
|
52
|
+
"power3.out",
|
|
53
|
+
"power4.out",
|
|
54
|
+
"power1.in",
|
|
55
|
+
"power2.in",
|
|
56
|
+
"power3.in",
|
|
57
|
+
"power1.inOut",
|
|
58
|
+
"power2.inOut",
|
|
59
|
+
"back.out(1.7)",
|
|
60
|
+
"elastic.out(1,0.3)",
|
|
61
|
+
"bounce.out",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
import { Section, Row, inputCls } from "./shared";
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Animation phase controls
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
interface AnimationPhaseProps {
|
|
71
|
+
label: string;
|
|
72
|
+
presets: string[];
|
|
73
|
+
animation: CaptionAnimation | null;
|
|
74
|
+
showIntensity?: boolean;
|
|
75
|
+
onChange: (update: Partial<CaptionAnimation>) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function AnimationPhase({
|
|
79
|
+
label,
|
|
80
|
+
presets,
|
|
81
|
+
animation,
|
|
82
|
+
showIntensity,
|
|
83
|
+
onChange,
|
|
84
|
+
}: AnimationPhaseProps) {
|
|
85
|
+
const preset = animation?.preset ?? "none";
|
|
86
|
+
const duration = animation?.duration ?? 0.2;
|
|
87
|
+
const ease = animation?.ease ?? "power2.out";
|
|
88
|
+
const stagger = animation?.stagger ?? 0;
|
|
89
|
+
const intensity = animation?.intensity ?? 1;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Section label={label}>
|
|
93
|
+
<Row label="Preset">
|
|
94
|
+
<select
|
|
95
|
+
value={preset}
|
|
96
|
+
onChange={(e) => onChange({ preset: e.target.value })}
|
|
97
|
+
className={inputCls}
|
|
98
|
+
>
|
|
99
|
+
{presets.map((p) => (
|
|
100
|
+
<option key={p} value={p}>
|
|
101
|
+
{p}
|
|
102
|
+
</option>
|
|
103
|
+
))}
|
|
104
|
+
</select>
|
|
105
|
+
</Row>
|
|
106
|
+
|
|
107
|
+
<Row label="Duration">
|
|
108
|
+
<input
|
|
109
|
+
type="number"
|
|
110
|
+
value={duration}
|
|
111
|
+
step={0.05}
|
|
112
|
+
min={0}
|
|
113
|
+
max={2}
|
|
114
|
+
onChange={(e) => onChange({ duration: Number(e.target.value) })}
|
|
115
|
+
className={inputCls}
|
|
116
|
+
/>
|
|
117
|
+
</Row>
|
|
118
|
+
|
|
119
|
+
<Row label="Ease">
|
|
120
|
+
<select
|
|
121
|
+
value={ease}
|
|
122
|
+
onChange={(e) => onChange({ ease: e.target.value })}
|
|
123
|
+
className={inputCls}
|
|
124
|
+
>
|
|
125
|
+
{EASE_PRESETS.map((e) => (
|
|
126
|
+
<option key={e} value={e}>
|
|
127
|
+
{e}
|
|
128
|
+
</option>
|
|
129
|
+
))}
|
|
130
|
+
</select>
|
|
131
|
+
</Row>
|
|
132
|
+
|
|
133
|
+
<Row label="Stagger">
|
|
134
|
+
<input
|
|
135
|
+
type="number"
|
|
136
|
+
value={stagger}
|
|
137
|
+
step={0.02}
|
|
138
|
+
min={0}
|
|
139
|
+
max={0.5}
|
|
140
|
+
onChange={(e) => onChange({ stagger: Number(e.target.value) })}
|
|
141
|
+
className={inputCls}
|
|
142
|
+
/>
|
|
143
|
+
</Row>
|
|
144
|
+
|
|
145
|
+
{showIntensity && (
|
|
146
|
+
<Row label="Intensity">
|
|
147
|
+
<div className="flex items-center gap-2">
|
|
148
|
+
<input
|
|
149
|
+
type="range"
|
|
150
|
+
min={0}
|
|
151
|
+
max={1}
|
|
152
|
+
step={0.01}
|
|
153
|
+
value={intensity}
|
|
154
|
+
onChange={(e) => onChange({ intensity: Number(e.target.value) })}
|
|
155
|
+
className="flex-1 accent-studio-accent"
|
|
156
|
+
/>
|
|
157
|
+
<span className="text-2xs text-neutral-400 font-mono w-8 text-right flex-shrink-0">
|
|
158
|
+
{intensity.toFixed(2)}
|
|
159
|
+
</span>
|
|
160
|
+
</div>
|
|
161
|
+
</Row>
|
|
162
|
+
)}
|
|
163
|
+
</Section>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Main component
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
export const CaptionAnimationPanel = memo(function CaptionAnimationPanel() {
|
|
172
|
+
const model = useCaptionStore((s) => s.model);
|
|
173
|
+
const selectedGroupId = useCaptionStore((s) => s.selectedGroupId);
|
|
174
|
+
const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds);
|
|
175
|
+
const updateGroupAnimation = useCaptionStore((s) => s.updateGroupAnimation);
|
|
176
|
+
const applyAnimationToAll = useCaptionStore((s) => s.applyAnimationToAll);
|
|
177
|
+
|
|
178
|
+
// Resolve which group to edit
|
|
179
|
+
let resolvedGroupId: string | null = selectedGroupId;
|
|
180
|
+
if (!resolvedGroupId && model && selectedSegmentIds.size > 0) {
|
|
181
|
+
const firstSegmentId = [...selectedSegmentIds][0];
|
|
182
|
+
if (firstSegmentId) {
|
|
183
|
+
for (const [gid, group] of model.groups) {
|
|
184
|
+
if (group.segmentIds.includes(firstSegmentId)) {
|
|
185
|
+
resolvedGroupId = gid;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const group = resolvedGroupId ? model?.groups.get(resolvedGroupId) : undefined;
|
|
193
|
+
const animation = group?.animation;
|
|
194
|
+
|
|
195
|
+
// All hooks must be called before any early return
|
|
196
|
+
const handleEntranceChange = useCallback(
|
|
197
|
+
(update: Partial<CaptionAnimation>) => {
|
|
198
|
+
if (resolvedGroupId) updateGroupAnimation(resolvedGroupId, "entrance", update);
|
|
199
|
+
},
|
|
200
|
+
[resolvedGroupId, updateGroupAnimation],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const handleHighlightChange = useCallback(
|
|
204
|
+
(update: Partial<CaptionAnimation>) => {
|
|
205
|
+
if (resolvedGroupId) updateGroupAnimation(resolvedGroupId, "highlight", update);
|
|
206
|
+
},
|
|
207
|
+
[resolvedGroupId, updateGroupAnimation],
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const handleExitChange = useCallback(
|
|
211
|
+
(update: Partial<CaptionAnimation>) => {
|
|
212
|
+
if (resolvedGroupId) updateGroupAnimation(resolvedGroupId, "exit", update);
|
|
213
|
+
},
|
|
214
|
+
[resolvedGroupId, updateGroupAnimation],
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const handleApplyToAll = useCallback(() => {
|
|
218
|
+
if (animation) applyAnimationToAll(animation);
|
|
219
|
+
}, [animation, applyAnimationToAll]);
|
|
220
|
+
|
|
221
|
+
// Empty state — after all hooks
|
|
222
|
+
if (!group || !resolvedGroupId || !animation) {
|
|
223
|
+
return (
|
|
224
|
+
<div className="flex items-center justify-center h-full px-4 text-center">
|
|
225
|
+
<p className="text-xs text-neutral-500">Select a caption group to edit animations</p>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<div className="flex flex-col h-full min-h-0">
|
|
232
|
+
{/* Scrollable content */}
|
|
233
|
+
<div className="flex-1 overflow-y-auto px-3 py-2">
|
|
234
|
+
<AnimationPhase
|
|
235
|
+
label="Entrance"
|
|
236
|
+
presets={ENTRANCE_PRESETS}
|
|
237
|
+
animation={animation.entrance}
|
|
238
|
+
onChange={handleEntranceChange}
|
|
239
|
+
/>
|
|
240
|
+
|
|
241
|
+
<AnimationPhase
|
|
242
|
+
label="Highlight"
|
|
243
|
+
presets={HIGHLIGHT_PRESETS}
|
|
244
|
+
animation={animation.highlight}
|
|
245
|
+
showIntensity
|
|
246
|
+
onChange={handleHighlightChange}
|
|
247
|
+
/>
|
|
248
|
+
|
|
249
|
+
<AnimationPhase
|
|
250
|
+
label="Exit"
|
|
251
|
+
presets={EXIT_PRESETS}
|
|
252
|
+
animation={animation.exit}
|
|
253
|
+
onChange={handleExitChange}
|
|
254
|
+
/>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
{/* Footer */}
|
|
258
|
+
<div className="flex-shrink-0 px-3 py-2 border-t border-neutral-800">
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
onClick={handleApplyToAll}
|
|
262
|
+
className="w-full py-1.5 rounded border border-neutral-700 text-2xs text-neutral-300 hover:border-studio-accent/50 hover:text-studio-accent transition-colors"
|
|
263
|
+
>
|
|
264
|
+
Apply to all groups
|
|
265
|
+
</button>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
});
|