@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.
@@ -0,0 +1,275 @@
1
+ import { memo, useCallback, useState } from "react";
2
+ import { useCaptionStore } from "../store";
3
+ import type { CaptionStyle } from "../types";
4
+ import { CaptionAnimationPanel } from "./CaptionAnimationPanel";
5
+ import { Section, Row, inputCls } from "./shared";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Main component
9
+ // ---------------------------------------------------------------------------
10
+
11
+ interface CaptionPropertyPanelProps {
12
+ iframeRef: React.RefObject<HTMLIFrameElement | null>;
13
+ }
14
+
15
+ export const CaptionPropertyPanel = memo(function CaptionPropertyPanel({
16
+ iframeRef,
17
+ }: CaptionPropertyPanelProps) {
18
+ const model = useCaptionStore((s) => s.model);
19
+ const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds);
20
+ const selectedGroupId = useCaptionStore((s) => s.selectedGroupId);
21
+ const updateSelectedStyle = useCaptionStore((s) => s.updateSelectedStyle);
22
+ const updateGroupStyle = useCaptionStore((s) => s.updateGroupStyle);
23
+
24
+ const [activeTab, setActiveTab] = useState<"style" | "animation">("style");
25
+
26
+ // Resolve effective style for the first selected segment
27
+ const firstSegmentId = selectedSegmentIds.size > 0 ? [...selectedSegmentIds][0] : undefined;
28
+ const firstSegment = model?.segments.get(firstSegmentId ?? "");
29
+
30
+ // Find the group that owns the first segment
31
+ let ownerGroupId: string | null = null;
32
+ if (model && firstSegmentId) {
33
+ for (const gid of model.groupOrder) {
34
+ const group = model.groups.get(gid);
35
+ if (group && group.segmentIds.includes(firstSegmentId)) {
36
+ ownerGroupId = gid;
37
+ break;
38
+ }
39
+ }
40
+ }
41
+
42
+ const groupStyle = ownerGroupId ? model?.groups.get(ownerGroupId)?.style : undefined;
43
+ const segmentOverrides = firstSegment?.style ?? {};
44
+
45
+ // Merge group style with segment overrides for display
46
+ const effectiveStyle: Partial<CaptionStyle> = {
47
+ ...groupStyle,
48
+ ...segmentOverrides,
49
+ };
50
+
51
+ /**
52
+ * Apply a CSS style change to selected word elements in the iframe DOM in real time.
53
+ * Maps CaptionStyle property names to CSS properties.
54
+ */
55
+ const applyToIframeDom = useCallback(
56
+ (updates: Partial<CaptionStyle>) => {
57
+ const iframe = iframeRef.current;
58
+ if (!iframe || !model) return;
59
+ let doc: Document | null = null;
60
+ try {
61
+ doc = iframe.contentDocument;
62
+ } catch {
63
+ return;
64
+ }
65
+ if (!doc) return;
66
+
67
+ const groupEls = doc.querySelectorAll<HTMLElement>(".caption-group");
68
+
69
+ // Build list of word elements to update
70
+ const targetEls: HTMLElement[] = [];
71
+ for (const segId of selectedSegmentIds) {
72
+ for (let gi = 0; gi < model.groupOrder.length; gi++) {
73
+ const group = model.groups.get(model.groupOrder[gi]);
74
+ if (!group) continue;
75
+ const wi = group.segmentIds.indexOf(segId);
76
+ if (wi < 0) continue;
77
+ const groupEl = groupEls[gi];
78
+ if (!groupEl) continue;
79
+ // Resolve word span, handling wrappers
80
+ const children = groupEl.children;
81
+ let idx = 0;
82
+ for (const child of children) {
83
+ const c = child as HTMLElement;
84
+ if (c.dataset.captionWrapper === "true") {
85
+ const inner = c.querySelector<HTMLElement>(":scope > span");
86
+ if (inner && idx === wi) {
87
+ targetEls.push(inner);
88
+ break;
89
+ }
90
+ } else if (c.tagName === "SPAN") {
91
+ if (idx === wi) {
92
+ targetEls.push(c);
93
+ break;
94
+ }
95
+ }
96
+ idx++;
97
+ }
98
+ break;
99
+ }
100
+ }
101
+
102
+ // Apply transform updates via gsap.set on the WRAPPER (not the word span)
103
+ const hasTransform =
104
+ updates.x !== undefined ||
105
+ updates.y !== undefined ||
106
+ updates.scaleX !== undefined ||
107
+ updates.scaleY !== undefined ||
108
+ updates.rotation !== undefined;
109
+
110
+ if (hasTransform) {
111
+ try {
112
+ const iframeGsap = (
113
+ iframeRef.current?.contentWindow as unknown as {
114
+ gsap?: {
115
+ set: (el: HTMLElement, props: Record<string, unknown>) => void;
116
+ getProperty: (el: HTMLElement, prop: string) => number;
117
+ };
118
+ }
119
+ )?.gsap;
120
+ if (iframeGsap) {
121
+ for (const el of targetEls) {
122
+ // Get or create wrapper
123
+ let wrapper = el.parentElement;
124
+ if (!wrapper || wrapper.dataset.captionWrapper !== "true") {
125
+ wrapper = doc.createElement("span") as HTMLElement;
126
+ wrapper.style.display = "inline-block";
127
+ wrapper.dataset.captionWrapper = "true";
128
+ el.parentNode?.insertBefore(wrapper, el);
129
+ wrapper.appendChild(el);
130
+ }
131
+ // Read current wrapper state and merge with updates
132
+ const curX = iframeGsap.getProperty(wrapper, "x") || 0;
133
+ const curY = iframeGsap.getProperty(wrapper, "y") || 0;
134
+ const curScale = iframeGsap.getProperty(wrapper, "scale") || 1;
135
+ const curRotation = iframeGsap.getProperty(wrapper, "rotation") || 0;
136
+ iframeGsap.set(wrapper, {
137
+ x: updates.x ?? curX,
138
+ y: updates.y ?? curY,
139
+ scale: updates.scaleX ?? curScale,
140
+ rotation: updates.rotation ?? curRotation,
141
+ });
142
+ }
143
+ }
144
+ } catch {
145
+ /* cross-origin */
146
+ }
147
+ }
148
+ },
149
+ [iframeRef, model, selectedSegmentIds],
150
+ );
151
+
152
+ // All hooks must be called before any early return
153
+ const handleStyleChange = useCallback(
154
+ (updates: Partial<CaptionStyle>) => {
155
+ if (selectedGroupId) {
156
+ updateGroupStyle(selectedGroupId, updates);
157
+ } else {
158
+ updateSelectedStyle(updates);
159
+ }
160
+ applyToIframeDom(updates);
161
+ },
162
+ [selectedGroupId, updateGroupStyle, updateSelectedStyle, applyToIframeDom],
163
+ );
164
+
165
+ // Empty state — after all hooks
166
+ if (selectedSegmentIds.size === 0) {
167
+ return (
168
+ <div className="flex items-center justify-center h-full px-4 text-center">
169
+ <p className="text-xs text-neutral-500">Select caption words to edit their style</p>
170
+ </div>
171
+ );
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Derived style values with fallbacks
176
+ // ---------------------------------------------------------------------------
177
+
178
+ const x = effectiveStyle.x ?? 0;
179
+ const y = effectiveStyle.y ?? 0;
180
+ const rotation = effectiveStyle.rotation ?? 0;
181
+ const scaleX = effectiveStyle.scaleX ?? 1;
182
+
183
+ // Count label
184
+ const countLabel = selectedSegmentIds.size === 1 ? "1 word" : `${selectedSegmentIds.size} words`;
185
+
186
+ return (
187
+ <div className="flex flex-col h-full min-h-0">
188
+ {/* Header */}
189
+ <div className="px-3 py-2 border-b border-neutral-800 flex-shrink-0">
190
+ <div className="flex items-center justify-between mb-1.5">
191
+ <span className="text-2xs text-neutral-500">{countLabel}</span>
192
+ </div>
193
+ {/* Tab switcher */}
194
+ <div className="flex gap-1">
195
+ <button
196
+ type="button"
197
+ onClick={() => setActiveTab("style")}
198
+ className={[
199
+ "flex-1 py-0.5 rounded text-2xs font-medium transition-colors",
200
+ activeTab === "style"
201
+ ? "bg-studio-accent/20 text-studio-accent border border-studio-accent/50"
202
+ : "text-neutral-500 border border-neutral-800 hover:text-neutral-300 hover:border-neutral-600",
203
+ ].join(" ")}
204
+ >
205
+ Style
206
+ </button>
207
+ <button
208
+ type="button"
209
+ onClick={() => setActiveTab("animation")}
210
+ className={[
211
+ "flex-1 py-0.5 rounded text-2xs font-medium transition-colors",
212
+ activeTab === "animation"
213
+ ? "bg-studio-accent/20 text-studio-accent border border-studio-accent/50"
214
+ : "text-neutral-500 border border-neutral-800 hover:text-neutral-300 hover:border-neutral-600",
215
+ ].join(" ")}
216
+ >
217
+ Animation
218
+ </button>
219
+ </div>
220
+ </div>
221
+
222
+ {/* Animation tab */}
223
+ {activeTab === "animation" && <CaptionAnimationPanel />}
224
+
225
+ {/* Style tab — Transform only */}
226
+ {activeTab === "style" && (
227
+ <div className="flex-1 overflow-y-auto px-3 py-2">
228
+ <Section label="Position">
229
+ <Row label="X">
230
+ <input
231
+ type="number"
232
+ value={x}
233
+ onChange={(e) => handleStyleChange({ x: Number(e.target.value) })}
234
+ className={inputCls}
235
+ />
236
+ </Row>
237
+ <Row label="Y">
238
+ <input
239
+ type="number"
240
+ value={y}
241
+ onChange={(e) => handleStyleChange({ y: Number(e.target.value) })}
242
+ className={inputCls}
243
+ />
244
+ </Row>
245
+ </Section>
246
+
247
+ <Section label="Transform">
248
+ <Row label="Scale">
249
+ <input
250
+ type="number"
251
+ value={scaleX}
252
+ step={0.1}
253
+ onChange={(e) =>
254
+ handleStyleChange({
255
+ scaleX: Number(e.target.value),
256
+ scaleY: Number(e.target.value),
257
+ })
258
+ }
259
+ className={inputCls}
260
+ />
261
+ </Row>
262
+ <Row label="Rotation">
263
+ <input
264
+ type="number"
265
+ value={rotation}
266
+ onChange={(e) => handleStyleChange({ rotation: Number(e.target.value) })}
267
+ className={inputCls}
268
+ />
269
+ </Row>
270
+ </Section>
271
+ </div>
272
+ )}
273
+ </div>
274
+ );
275
+ });
@@ -0,0 +1,187 @@
1
+ import { memo, useCallback, useRef } from "react";
2
+ import { useCaptionStore } from "../store";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Constants
6
+ // ---------------------------------------------------------------------------
7
+
8
+ const GROUP_COLORS = [
9
+ "#3CE6AC",
10
+ "#FF6B6B",
11
+ "#4ECDC4",
12
+ "#FFE66D",
13
+ "#A78BFA",
14
+ "#F472B6",
15
+ "#34D399",
16
+ "#FB923C",
17
+ "#60A5FA",
18
+ "#C084FC",
19
+ ];
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Types
23
+ // ---------------------------------------------------------------------------
24
+
25
+ interface CaptionTimelineProps {
26
+ pixelsPerSecond: number;
27
+ onSeek?: (time: number) => void;
28
+ }
29
+
30
+ interface DragState {
31
+ segId: string;
32
+ edge: "start" | "end";
33
+ originalStart: number;
34
+ originalEnd: number;
35
+ startX: number;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Component
40
+ // ---------------------------------------------------------------------------
41
+
42
+ export const CaptionTimeline = memo(function CaptionTimeline({
43
+ pixelsPerSecond,
44
+ onSeek,
45
+ }: CaptionTimelineProps) {
46
+ const model = useCaptionStore((s) => s.model);
47
+ const selectedSegmentIds = useCaptionStore((s) => s.selectedSegmentIds);
48
+ const selectSegment = useCaptionStore((s) => s.selectSegment);
49
+ const updateSegmentTiming = useCaptionStore((s) => s.updateSegmentTiming);
50
+ const splitGroup = useCaptionStore((s) => s.splitGroup);
51
+
52
+ const dragRef = useRef<DragState | null>(null);
53
+
54
+ const handleEdgePointerDown = useCallback(
55
+ (
56
+ e: React.PointerEvent<HTMLDivElement>,
57
+ segId: string,
58
+ edge: "start" | "end",
59
+ originalStart: number,
60
+ originalEnd: number,
61
+ ) => {
62
+ e.stopPropagation();
63
+ e.preventDefault();
64
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
65
+ dragRef.current = { segId, edge, originalStart, originalEnd, startX: e.clientX };
66
+ },
67
+ [],
68
+ );
69
+
70
+ const handlePointerMove = useCallback(
71
+ (e: React.PointerEvent<HTMLDivElement>) => {
72
+ const drag = dragRef.current;
73
+ if (!drag) return;
74
+
75
+ const delta = (e.clientX - drag.startX) / pixelsPerSecond;
76
+
77
+ if (drag.edge === "start") {
78
+ const newStart = Math.max(0, drag.originalStart + delta);
79
+ const clampedStart = Math.min(newStart, drag.originalEnd - 0.05);
80
+ updateSegmentTiming(drag.segId, clampedStart, drag.originalEnd);
81
+ } else {
82
+ const newEnd = Math.max(drag.originalStart + 0.05, drag.originalEnd + delta);
83
+ const clampedEnd = Math.max(0, newEnd);
84
+ updateSegmentTiming(drag.segId, drag.originalStart, clampedEnd);
85
+ }
86
+ },
87
+ [pixelsPerSecond, updateSegmentTiming],
88
+ );
89
+
90
+ const handlePointerUp = useCallback(() => {
91
+ dragRef.current = null;
92
+ }, []);
93
+
94
+ const handleBlockClick = useCallback(
95
+ (e: React.MouseEvent, segId: string) => {
96
+ e.stopPropagation();
97
+ selectSegment(segId, e.shiftKey);
98
+ },
99
+ [selectSegment],
100
+ );
101
+
102
+ const handleBlockDoubleClick = useCallback(
103
+ (e: React.MouseEvent, groupId: string, segId: string) => {
104
+ e.stopPropagation();
105
+ splitGroup(groupId, segId);
106
+ },
107
+ [splitGroup],
108
+ );
109
+
110
+ const handleTrackClick = useCallback(
111
+ (e: React.MouseEvent<HTMLDivElement>) => {
112
+ if (!onSeek) return;
113
+ const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
114
+ const x = e.clientX - rect.left - 32;
115
+ const time = Math.max(0, x / pixelsPerSecond);
116
+ onSeek(time);
117
+ },
118
+ [onSeek, pixelsPerSecond],
119
+ );
120
+
121
+ if (!model) return null;
122
+
123
+ return (
124
+ <div
125
+ className="relative select-none overflow-x-auto"
126
+ style={{ height: 40, minWidth: "100%" }}
127
+ onPointerMove={handlePointerMove}
128
+ onPointerUp={handlePointerUp}
129
+ onPointerLeave={handlePointerUp}
130
+ onClick={handleTrackClick}
131
+ >
132
+ {model.groupOrder.map((groupId, groupIdx) => {
133
+ const group = model.groups.get(groupId);
134
+ if (!group) return null;
135
+ const color = GROUP_COLORS[groupIdx % GROUP_COLORS.length];
136
+
137
+ return group.segmentIds.map((segId) => {
138
+ const seg = model.segments.get(segId);
139
+ if (!seg) return null;
140
+
141
+ const left = 32 + seg.start * pixelsPerSecond;
142
+ const width = Math.max((seg.end - seg.start) * pixelsPerSecond, 4);
143
+ const isSelected = selectedSegmentIds.has(segId);
144
+
145
+ return (
146
+ <div
147
+ key={segId}
148
+ className={`absolute top-1 bottom-1 rounded flex items-center overflow-hidden cursor-pointer${
149
+ isSelected ? " ring-1 ring-white/50 z-10" : ""
150
+ }`}
151
+ style={{
152
+ left,
153
+ width,
154
+ backgroundColor: color,
155
+ zIndex: isSelected ? 10 : 1,
156
+ }}
157
+ onClick={(e) => handleBlockClick(e, segId)}
158
+ onDoubleClick={(e) => handleBlockDoubleClick(e, groupId, segId)}
159
+ >
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
+ {/* Text label */}
168
+ <span
169
+ className="flex-1 truncate px-2 pointer-events-none"
170
+ style={{ fontSize: 9, color: "#000000", lineHeight: 1 }}
171
+ >
172
+ {seg.text}
173
+ </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
+ </div>
182
+ );
183
+ });
184
+ })}
185
+ </div>
186
+ );
187
+ });
@@ -0,0 +1,26 @@
1
+ import type React from "react";
2
+
3
+ export const inputCls =
4
+ "w-full bg-neutral-900 border border-neutral-800 rounded px-1.5 py-0.5 text-2xs text-neutral-200 font-mono outline-none focus:border-neutral-600";
5
+
6
+ export function Section({ label, children }: { label: string; children: React.ReactNode }) {
7
+ return (
8
+ <div className="mb-3">
9
+ <div className="flex items-center gap-1.5 mt-2 mb-1.5">
10
+ <span className="text-2xs font-medium text-neutral-500 uppercase tracking-wider">
11
+ {label}
12
+ </span>
13
+ </div>
14
+ <div className="space-y-1">{children}</div>
15
+ </div>
16
+ );
17
+ }
18
+
19
+ export function Row({ label, children }: { label: string; children: React.ReactNode }) {
20
+ return (
21
+ <div className="flex items-center gap-2">
22
+ <span className="text-2xs text-neutral-600 w-14 text-right flex-shrink-0">{label}</span>
23
+ <div className="flex-1 min-w-0">{children}</div>
24
+ </div>
25
+ );
26
+ }