@hyperframes/studio 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-hYc4aP7M.js +117 -0
  3. package/dist/index.html +1 -1
  4. package/package.json +4 -4
  5. package/src/App.tsx +2 -13
  6. package/src/captions/components/CaptionOverlay.tsx +13 -246
  7. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  8. package/src/components/StudioPreviewArea.tsx +6 -2
  9. package/src/components/editor/DomEditOverlay.tsx +88 -1007
  10. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  11. package/src/components/editor/FileTree.tsx +13 -621
  12. package/src/components/editor/FileTreeIcons.tsx +128 -0
  13. package/src/components/editor/FileTreeNodes.tsx +496 -0
  14. package/src/components/editor/MotionPanel.tsx +16 -390
  15. package/src/components/editor/MotionPanelFields.tsx +185 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  17. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  19. package/src/components/editor/domEditing.ts +44 -1150
  20. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  21. package/src/components/editor/domEditingDom.ts +266 -0
  22. package/src/components/editor/domEditingElement.ts +329 -0
  23. package/src/components/editor/domEditingLayers.ts +460 -0
  24. package/src/components/editor/domEditingTypes.ts +125 -0
  25. package/src/components/editor/manualEdits.ts +84 -1081
  26. package/src/components/editor/manualEditsDom.ts +436 -0
  27. package/src/components/editor/manualEditsParsing.ts +280 -0
  28. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  29. package/src/components/editor/manualEditsTypes.ts +141 -0
  30. package/src/components/editor/studioMotion.ts +47 -434
  31. package/src/components/editor/studioMotionOps.ts +299 -0
  32. package/src/components/editor/studioMotionTypes.ts +168 -0
  33. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  34. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  35. package/src/components/nle/NLELayout.tsx +60 -144
  36. package/src/components/nle/useCompositionStack.ts +126 -0
  37. package/src/hooks/useToast.ts +20 -0
  38. package/src/player/components/Timeline.tsx +189 -1418
  39. package/src/player/components/TimelineCanvas.tsx +434 -0
  40. package/src/player/components/TimelineEmptyState.tsx +102 -0
  41. package/src/player/components/TimelineRuler.tsx +90 -0
  42. package/src/player/components/timelineIcons.tsx +49 -0
  43. package/src/player/components/timelineLayout.ts +215 -0
  44. package/src/player/components/timelineUtils.ts +211 -0
  45. package/src/player/components/useTimelineClipDrag.ts +388 -0
  46. package/src/player/components/useTimelinePlayhead.ts +200 -0
  47. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  48. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  49. package/src/player/hooks/useTimelinePlayer.ts +69 -1372
  50. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  51. package/src/player/lib/playbackAdapter.ts +145 -0
  52. package/src/player/lib/playbackShortcuts.ts +68 -0
  53. package/src/player/lib/playbackTypes.ts +60 -0
  54. package/src/player/lib/timelineDOM.ts +373 -0
  55. package/src/player/lib/timelineElementHelpers.ts +303 -0
  56. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  57. package/dist/assets/hyperframes-player-DOFETgjy.js +0 -418
  58. package/dist/assets/index-DUqUmaoH.js +0 -117
package/dist/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-DUqUmaoH.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-hYc4aP7M.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-D1JDq7Gg.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,8 +32,8 @@
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "codemirror": "^6.0.1",
34
34
  "motion": "^12.38.0",
35
- "@hyperframes/core": "0.6.0",
36
- "@hyperframes/player": "0.6.0"
35
+ "@hyperframes/player": "0.6.1",
36
+ "@hyperframes/core": "0.6.1"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/react": "19",
@@ -47,7 +47,7 @@
47
47
  "vite": "^6.4.2",
48
48
  "vitest": "^3.2.4",
49
49
  "zustand": "^5.0.0",
50
- "@hyperframes/producer": "0.6.0"
50
+ "@hyperframes/producer": "0.6.1"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": "19",
package/src/App.tsx CHANGED
@@ -19,6 +19,7 @@ import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture";
19
19
  import { useFrameCapture } from "./hooks/useFrameCapture";
20
20
  import { useLintModal } from "./hooks/useLintModal";
21
21
  import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
22
+ import { useToast } from "./hooks/useToast";
22
23
  import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
23
24
  import {
24
25
  STUDIO_INSPECTOR_PANELS_ENABLED,
@@ -27,7 +28,6 @@ import {
27
28
  import { getStudioMotionForSelection } from "./components/editor/studioMotion";
28
29
  import { getTimelineElementKey, isTimelineElementActiveAtTime } from "./utils/timelineInspector";
29
30
  import type { DomEditSelection } from "./components/editor/domEditing";
30
- import type { AppToast } from "./utils/studioHelpers";
31
31
  import { AskAgentModal } from "./components/AskAgentModal";
32
32
  import { StudioHeader } from "./components/StudioHeader";
33
33
  import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
@@ -102,18 +102,7 @@ export function StudioApp() {
102
102
 
103
103
  const [timelineVisible, setTimelineVisible] = useState(true);
104
104
  const toggleTimelineVisibility = useCallback(() => setTimelineVisible((v) => !v), []);
105
- const [appToast, setAppToast] = useState<AppToast | null>(null);
106
- const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
107
- const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
108
- if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
109
- setAppToast({ message, tone });
110
- toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
111
- }, []);
112
-
113
- useMountEffect(() => () => {
114
- if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
115
- });
116
-
105
+ const { appToast, showToast } = useToast();
117
106
  const panelLayout = usePanelLayout();
118
107
  const editHistory = usePersistentEditHistory({ projectId });
119
108
  const domEditSaveTimestampRef = useRef(0);
@@ -2,257 +2,31 @@ import { memo, useState, useCallback, useRef } from "react";
2
2
  import { useCaptionStore } from "../store";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { shouldHandleCaptionNudgeKey } from "../keyboard";
5
+ import {
6
+ readWordBoxes,
7
+ getWordEl,
8
+ readGsapTransform,
9
+ getOrCreateWrapper,
10
+ writeTransform,
11
+ computeTransformStyle,
12
+ type WordBox,
13
+ } from "./CaptionOverlayUtils";
5
14
 
6
15
  interface CaptionOverlayProps {
7
16
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
8
17
  }
9
18
 
10
- interface WordBox {
11
- segmentId: string;
12
- groupId: string;
13
- groupIndex: number;
14
- wordIndex: number;
15
- x: number;
16
- y: number;
17
- width: number;
18
- height: number;
19
- }
20
-
21
- function readWordBoxes(
22
- iframe: HTMLIFrameElement,
23
- model: {
24
- groupOrder: string[];
25
- groups: Map<string, { segmentIds: string[] }>;
26
- },
27
- overlayEl: HTMLElement,
28
- ): WordBox[] {
29
- let doc: Document | null = null;
30
- let win: Window | null = null;
31
- try {
32
- doc = iframe.contentDocument;
33
- win = iframe.contentWindow;
34
- } catch {
35
- return [];
36
- }
37
- if (!doc || !win) return [];
38
-
39
- const iframeDisplayRect = iframe.getBoundingClientRect();
40
- const overlayRect = overlayEl.getBoundingClientRect();
41
- // The iframe renders at native resolution (e.g. 1920x1080) but is
42
- // CSS-scaled to fit the viewport. getBoundingClientRect() on elements
43
- // inside the iframe returns coordinates in the iframe's native space.
44
- // Multiply by cssScale to convert to parent window coordinates.
45
- const nativeW = parseFloat(iframe.style.width) || iframeDisplayRect.width;
46
- const cssScale = iframeDisplayRect.width / nativeW;
47
- const offsetX = iframeDisplayRect.left - overlayRect.left;
48
- const offsetY = iframeDisplayRect.top - overlayRect.top;
49
-
50
- const groupEls = doc.querySelectorAll<HTMLElement>(".caption-group");
51
- const boxes: WordBox[] = [];
52
-
53
- for (let gi = 0; gi < model.groupOrder.length; gi++) {
54
- const groupId = model.groupOrder[gi];
55
- const group = model.groups.get(groupId);
56
- if (!group) continue;
57
- const groupEl = groupEls[gi] as HTMLElement | undefined;
58
- if (!groupEl) continue;
59
- const computed = win.getComputedStyle(groupEl);
60
- if (parseFloat(computed.opacity) <= 0.01 || computed.visibility === "hidden") continue;
61
- // Find word elements — handles both per-word spans (generator output)
62
- // and grouped text nodes (existing caption templates that use
63
- // el.textContent = line.text instead of individual word spans).
64
- const resolvedWordEls: HTMLElement[] = [];
65
- for (const child of groupEl.children) {
66
- const c = child as HTMLElement;
67
- if (c.dataset.captionWrapper === "true") {
68
- const inner = c.querySelector<HTMLElement>(":scope > span");
69
- if (inner) resolvedWordEls.push(inner);
70
- } else if (c.tagName === "SPAN") {
71
- resolvedWordEls.push(c);
72
- }
73
- }
74
- // Fallback: if no word spans found but group has text content,
75
- // the template uses grouped text. Wrap each word in a span so
76
- // the overlay can target them individually.
77
- if (resolvedWordEls.length === 0 && groupEl.textContent?.trim()) {
78
- const textNode = groupEl.childNodes[0];
79
- if (textNode && textNode.nodeType === Node.TEXT_NODE) {
80
- const words = (textNode.textContent || "").split(/\s+/).filter(Boolean);
81
- const frag = doc.createDocumentFragment();
82
- for (const word of words) {
83
- const span = doc.createElement("span");
84
- span.textContent = word + " ";
85
- span.style.display = "inline";
86
- frag.appendChild(span);
87
- resolvedWordEls.push(span);
88
- }
89
- groupEl.replaceChild(frag, textNode);
90
- } else {
91
- // Single span child with all text (e.g. vignelli template)
92
- const singleSpan = groupEl.querySelector<HTMLElement>(":scope > span");
93
- if (singleSpan && singleSpan.textContent?.trim()) {
94
- const words = singleSpan.textContent.split(/\s+/).filter(Boolean);
95
- const frag = doc.createDocumentFragment();
96
- for (const word of words) {
97
- const span = doc.createElement("span");
98
- span.textContent = word + " ";
99
- span.style.display = "inline";
100
- frag.appendChild(span);
101
- resolvedWordEls.push(span);
102
- }
103
- singleSpan.replaceWith(frag);
104
- }
105
- }
106
- }
107
- for (let wi = 0; wi < group.segmentIds.length; wi++) {
108
- const segId = group.segmentIds[wi];
109
- const wordEl = resolvedWordEls[wi] as HTMLElement | undefined;
110
- if (!wordEl) continue;
111
- const rect = wordEl.getBoundingClientRect();
112
- boxes.push({
113
- segmentId: segId,
114
- groupId,
115
- groupIndex: gi,
116
- wordIndex: wi,
117
- x: rect.left * cssScale + offsetX,
118
- y: rect.top * cssScale + offsetY,
119
- width: rect.width * cssScale,
120
- height: rect.height * cssScale,
121
- });
122
- }
123
- }
124
- return boxes;
125
- }
126
-
127
- function getWordEl(
128
- iframe: HTMLIFrameElement,
129
- groupIndex: number,
130
- wordIndex: number,
131
- ): HTMLElement | null {
132
- let doc: Document | null = null;
133
- try {
134
- doc = iframe.contentDocument;
135
- } catch {
136
- return null;
137
- }
138
- if (!doc) return null;
139
- const groupEl = doc.querySelectorAll<HTMLElement>(".caption-group")[groupIndex];
140
- if (!groupEl) return null;
141
- // Find word spans — they may be direct children or inside wrapper spans.
142
- // Word spans have class "word" or an id starting with "w".
143
- // Wrappers have data-caption-wrapper="true".
144
- const wordEls: HTMLElement[] = [];
145
- for (const child of groupEl.children) {
146
- const el = child as HTMLElement;
147
- if (el.dataset.captionWrapper === "true") {
148
- // Wrapped word — get the inner span
149
- const inner = el.querySelector<HTMLElement>(":scope > span");
150
- if (inner) wordEls.push(inner);
151
- } else if (el.tagName === "SPAN") {
152
- wordEls.push(el);
153
- }
154
- }
155
- return wordEls[wordIndex] ?? null;
156
- }
157
-
158
- /**
159
- * Read GSAP's internal transform state for an element.
160
- * GSAP stores transforms in its own cache, not in el.style.transform.
161
- */
162
- function readGsapTransform(
163
- el: HTMLElement,
164
- iframeWin: Window,
165
- ): { x: number; y: number; scale: number; rotation: number } {
166
- const gsap = (
167
- iframeWin as unknown as { gsap?: { getProperty?: (el: HTMLElement, prop: string) => number } }
168
- ).gsap;
169
- if (gsap && gsap.getProperty) {
170
- return {
171
- x: gsap.getProperty(el, "x") || 0,
172
- y: gsap.getProperty(el, "y") || 0,
173
- scale: gsap.getProperty(el, "scale") || 1,
174
- rotation: gsap.getProperty(el, "rotation") || 0,
175
- };
176
- }
177
- // Fallback: parse from style
178
- const t = el.style.transform || "";
179
- const scaleMatch = t.match(/scale\(([^)]+)\)/);
180
- const rotMatch = t.match(/rotate\(([^)]+)deg\)/);
181
- const txyMatch = t.match(/translate\(([^,]+)px,\s*([^)]+)px\)/);
182
- return {
183
- x: txyMatch ? parseFloat(txyMatch[1]) : 0,
184
- y: txyMatch ? parseFloat(txyMatch[2]) : 0,
185
- scale: scaleMatch ? parseFloat(scaleMatch[1]) : 1,
186
- rotation: rotMatch ? parseFloat(rotMatch[1]) : 0,
187
- };
188
- }
189
-
190
- /**
191
- * Get or create an inline-block wrapper span around a word element.
192
- * Transforms are applied to the wrapper so the word's GSAP animations are preserved.
193
- */
194
- function getOrCreateWrapper(el: HTMLElement): HTMLElement {
195
- // If el IS a wrapper, return it
196
- if (el.dataset.captionWrapper === "true") return el;
197
- // If el's parent is a wrapper, return the parent
198
- const parent = el.parentElement;
199
- if (parent && parent.dataset.captionWrapper === "true") return parent;
200
- // Create new wrapper
201
- const doc = el.ownerDocument;
202
- const wrapper = doc.createElement("span");
203
- wrapper.style.display = "inline-block";
204
- wrapper.dataset.captionWrapper = "true";
205
- el.parentNode?.insertBefore(wrapper, el);
206
- wrapper.appendChild(el);
207
- return wrapper;
208
- }
209
-
210
- /**
211
- * Write transform values to a wrapper span around the word element.
212
- * The word keeps its GSAP animations; the wrapper handles editor transforms.
213
- */
214
- function writeTransform(
215
- el: HTMLElement,
216
- iframeWin: Window,
217
- x: number,
218
- y: number,
219
- scale: number,
220
- rotation: number,
221
- ) {
222
- const wrapper = getOrCreateWrapper(el);
223
- const gsap = (
224
- iframeWin as unknown as {
225
- gsap?: { set?: (el: HTMLElement, props: Record<string, number>) => void };
226
- }
227
- ).gsap;
228
- if (gsap && gsap.set) {
229
- gsap.set(wrapper, { x, y, scale, rotation });
230
- } else {
231
- wrapper.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rotation.toFixed(1)}deg) scale(${scale.toFixed(3)})`;
232
- }
233
- }
19
+ const HANDLE = 8;
20
+ const ROTATION_OFFSET = 20; // px above the selection box
234
21
 
235
- /** Sync canvas state back to the Zustand store so the property panel reflects it.
236
- * Only writes non-default values to avoid creating spurious overrides. */
22
+ /** Sync canvas state back to the Zustand store so the property panel reflects it. */
237
23
  function syncToStore(segmentId: string, el: HTMLElement, iframeWin: Window) {
238
- const wrapper = getOrCreateWrapper(el);
239
- const { x, y, scale, rotation } = readGsapTransform(wrapper, iframeWin);
240
- const style: Record<string, number> = {};
241
- if (Math.abs(x) > 0.5) style.x = x;
242
- if (Math.abs(y) > 0.5) style.y = y;
243
- if (Math.abs(scale - 1) > 0.001) {
244
- style.scaleX = scale;
245
- style.scaleY = scale;
246
- }
247
- if (Math.abs(rotation) > 0.1) style.rotation = rotation;
24
+ const style = computeTransformStyle(el, iframeWin);
248
25
  if (Object.keys(style).length > 0) {
249
26
  useCaptionStore.getState().updateSegmentStyle(segmentId, style);
250
27
  }
251
28
  }
252
29
 
253
- const HANDLE = 8;
254
- const ROTATION_OFFSET = 20; // px above the selection box
255
-
256
30
  export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: CaptionOverlayProps) {
257
31
  const isEditMode = useCaptionStore((s) => s.isEditMode);
258
32
  const model = useCaptionStore((s) => s.model);
@@ -311,7 +85,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
311
85
  const overlay = overlayRef.current;
312
86
  if (!iframe || !m || !overlay) return;
313
87
  const next = readWordBoxes(iframe, m, overlay);
314
- // Skip state update if nothing changed (avoids re-render every 66ms)
315
88
  if (
316
89
  next.length === prevBoxes.length &&
317
90
  next.every(
@@ -342,7 +115,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
342
115
  if (!iframe || !win) return;
343
116
 
344
117
  for (const segId of sel) {
345
- // Find group/word index for this segment
346
118
  for (let gi = 0; gi < m.groupOrder.length; gi++) {
347
119
  const group = m.groups.get(m.groupOrder[gi]);
348
120
  if (!group) continue;
@@ -459,7 +231,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
459
231
  [iframeRef],
460
232
  );
461
233
 
462
- /** Get iframe contentWindow, needed for gsap calls */
463
234
  const getIframeWin = useCallback((): Window | null => {
464
235
  try {
465
236
  return iframeRef.current?.contentWindow ?? null;
@@ -482,8 +253,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
482
253
  const dy = (e.clientY - i.startMY) / cssScale;
483
254
  writeTransform(i.wordEl, win, i.origTX + dx, i.origTY + dy, i.origScale, i.origRotation);
484
255
  } else if (i.type === "scale") {
485
- // Use distance from box center so dragging outward from ANY corner
486
- // increases scale (not just right-side handles).
487
256
  const cx = i.startMX - i.startDxFromCenter;
488
257
  const startDist = Math.abs(i.startDxFromCenter);
489
258
  const currentDist = Math.abs(e.clientX - cx);
@@ -491,8 +260,6 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
491
260
  const newScale = Math.max(0.1, i.origScale * factor);
492
261
  writeTransform(i.wordEl, win, i.origTX, i.origTY, newScale, i.origRotation);
493
262
  } else if (i.type === "rotate") {
494
- // Horizontal drag maps to rotation: right = clockwise, left = counter-clockwise.
495
- // 200px of horizontal movement = 90 degrees.
496
263
  const dx = e.clientX - i.startMX;
497
264
  const delta = (dx / 200) * 90;
498
265
  writeTransform(i.wordEl, win, i.origTX, i.origTY, i.origScale, i.origRotation + delta);
@@ -0,0 +1,221 @@
1
+ // DOM helpers for CaptionOverlay — word box reading, transform I/O, wrapper management
2
+
3
+ export interface WordBox {
4
+ segmentId: string;
5
+ groupId: string;
6
+ groupIndex: number;
7
+ wordIndex: number;
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ }
13
+
14
+ export function readWordBoxes(
15
+ iframe: HTMLIFrameElement,
16
+ model: {
17
+ groupOrder: string[];
18
+ groups: Map<string, { segmentIds: string[] }>;
19
+ },
20
+ overlayEl: HTMLElement,
21
+ ): WordBox[] {
22
+ let doc: Document | null = null;
23
+ let win: Window | null = null;
24
+ try {
25
+ doc = iframe.contentDocument;
26
+ win = iframe.contentWindow;
27
+ } catch {
28
+ return [];
29
+ }
30
+ if (!doc || !win) return [];
31
+
32
+ const iframeDisplayRect = iframe.getBoundingClientRect();
33
+ const overlayRect = overlayEl.getBoundingClientRect();
34
+ const nativeW = parseFloat(iframe.style.width) || iframeDisplayRect.width;
35
+ const cssScale = iframeDisplayRect.width / nativeW;
36
+ const offsetX = iframeDisplayRect.left - overlayRect.left;
37
+ const offsetY = iframeDisplayRect.top - overlayRect.top;
38
+
39
+ const groupEls = doc.querySelectorAll<HTMLElement>(".caption-group");
40
+ const boxes: WordBox[] = [];
41
+
42
+ for (let gi = 0; gi < model.groupOrder.length; gi++) {
43
+ const groupId = model.groupOrder[gi];
44
+ const group = model.groups.get(groupId);
45
+ if (!group) continue;
46
+ const groupEl = groupEls[gi] as HTMLElement | undefined;
47
+ if (!groupEl) continue;
48
+ const computed = win.getComputedStyle(groupEl);
49
+ if (parseFloat(computed.opacity) <= 0.01 || computed.visibility === "hidden") continue;
50
+ const resolvedWordEls: HTMLElement[] = [];
51
+ for (const child of groupEl.children) {
52
+ const c = child as HTMLElement;
53
+ if (c.dataset.captionWrapper === "true") {
54
+ const inner = c.querySelector<HTMLElement>(":scope > span");
55
+ if (inner) resolvedWordEls.push(inner);
56
+ } else if (c.tagName === "SPAN") {
57
+ resolvedWordEls.push(c);
58
+ }
59
+ }
60
+ if (resolvedWordEls.length === 0 && groupEl.textContent?.trim()) {
61
+ const textNode = groupEl.childNodes[0];
62
+ if (textNode && textNode.nodeType === Node.TEXT_NODE) {
63
+ const words = (textNode.textContent || "").split(/\s+/).filter(Boolean);
64
+ const frag = doc.createDocumentFragment();
65
+ for (const word of words) {
66
+ const span = doc.createElement("span");
67
+ span.textContent = word + " ";
68
+ span.style.display = "inline";
69
+ frag.appendChild(span);
70
+ resolvedWordEls.push(span);
71
+ }
72
+ groupEl.replaceChild(frag, textNode);
73
+ } else {
74
+ const singleSpan = groupEl.querySelector<HTMLElement>(":scope > span");
75
+ if (singleSpan && singleSpan.textContent?.trim()) {
76
+ const words = singleSpan.textContent.split(/\s+/).filter(Boolean);
77
+ const frag = doc.createDocumentFragment();
78
+ for (const word of words) {
79
+ const span = doc.createElement("span");
80
+ span.textContent = word + " ";
81
+ span.style.display = "inline";
82
+ frag.appendChild(span);
83
+ resolvedWordEls.push(span);
84
+ }
85
+ singleSpan.replaceWith(frag);
86
+ }
87
+ }
88
+ }
89
+ for (let wi = 0; wi < group.segmentIds.length; wi++) {
90
+ const segId = group.segmentIds[wi];
91
+ const wordEl = resolvedWordEls[wi] as HTMLElement | undefined;
92
+ if (!wordEl) continue;
93
+ const rect = wordEl.getBoundingClientRect();
94
+ boxes.push({
95
+ segmentId: segId,
96
+ groupId,
97
+ groupIndex: gi,
98
+ wordIndex: wi,
99
+ x: rect.left * cssScale + offsetX,
100
+ y: rect.top * cssScale + offsetY,
101
+ width: rect.width * cssScale,
102
+ height: rect.height * cssScale,
103
+ });
104
+ }
105
+ }
106
+ return boxes;
107
+ }
108
+
109
+ export function getWordEl(
110
+ iframe: HTMLIFrameElement,
111
+ groupIndex: number,
112
+ wordIndex: number,
113
+ ): HTMLElement | null {
114
+ let doc: Document | null = null;
115
+ try {
116
+ doc = iframe.contentDocument;
117
+ } catch {
118
+ return null;
119
+ }
120
+ if (!doc) return null;
121
+ const groupEl = doc.querySelectorAll<HTMLElement>(".caption-group")[groupIndex];
122
+ if (!groupEl) return null;
123
+ const wordEls: HTMLElement[] = [];
124
+ for (const child of groupEl.children) {
125
+ const el = child as HTMLElement;
126
+ if (el.dataset.captionWrapper === "true") {
127
+ const inner = el.querySelector<HTMLElement>(":scope > span");
128
+ if (inner) wordEls.push(inner);
129
+ } else if (el.tagName === "SPAN") {
130
+ wordEls.push(el);
131
+ }
132
+ }
133
+ return wordEls[wordIndex] ?? null;
134
+ }
135
+
136
+ /**
137
+ * Read GSAP's internal transform state for an element.
138
+ * GSAP stores transforms in its own cache, not in el.style.transform.
139
+ */
140
+ export function readGsapTransform(
141
+ el: HTMLElement,
142
+ iframeWin: Window,
143
+ ): { x: number; y: number; scale: number; rotation: number } {
144
+ const gsap = (
145
+ iframeWin as unknown as { gsap?: { getProperty?: (el: HTMLElement, prop: string) => number } }
146
+ ).gsap;
147
+ if (gsap && gsap.getProperty) {
148
+ return {
149
+ x: gsap.getProperty(el, "x") || 0,
150
+ y: gsap.getProperty(el, "y") || 0,
151
+ scale: gsap.getProperty(el, "scale") || 1,
152
+ rotation: gsap.getProperty(el, "rotation") || 0,
153
+ };
154
+ }
155
+ const t = el.style.transform || "";
156
+ const scaleMatch = t.match(/scale\(([^)]+)\)/);
157
+ const rotMatch = t.match(/rotate\(([^)]+)deg\)/);
158
+ const txyMatch = t.match(/translate\(([^,]+)px,\s*([^)]+)px\)/);
159
+ return {
160
+ x: txyMatch ? parseFloat(txyMatch[1]) : 0,
161
+ y: txyMatch ? parseFloat(txyMatch[2]) : 0,
162
+ scale: scaleMatch ? parseFloat(scaleMatch[1]) : 1,
163
+ rotation: rotMatch ? parseFloat(rotMatch[1]) : 0,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Get or create an inline-block wrapper span around a word element.
169
+ * Transforms are applied to the wrapper so the word's GSAP animations are preserved.
170
+ */
171
+ export function getOrCreateWrapper(el: HTMLElement): HTMLElement {
172
+ if (el.dataset.captionWrapper === "true") return el;
173
+ const parent = el.parentElement;
174
+ if (parent && parent.dataset.captionWrapper === "true") return parent;
175
+ const doc = el.ownerDocument;
176
+ const wrapper = doc.createElement("span");
177
+ wrapper.style.display = "inline-block";
178
+ wrapper.dataset.captionWrapper = "true";
179
+ el.parentNode?.insertBefore(wrapper, el);
180
+ wrapper.appendChild(el);
181
+ return wrapper;
182
+ }
183
+
184
+ /**
185
+ * Write transform values to a wrapper span around the word element.
186
+ */
187
+ export function writeTransform(
188
+ el: HTMLElement,
189
+ iframeWin: Window,
190
+ x: number,
191
+ y: number,
192
+ scale: number,
193
+ rotation: number,
194
+ ) {
195
+ const wrapper = getOrCreateWrapper(el);
196
+ const gsap = (
197
+ iframeWin as unknown as {
198
+ gsap?: { set?: (el: HTMLElement, props: Record<string, number>) => void };
199
+ }
200
+ ).gsap;
201
+ if (gsap && gsap.set) {
202
+ gsap.set(wrapper, { x, y, scale, rotation });
203
+ } else {
204
+ wrapper.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rotation.toFixed(1)}deg) scale(${scale.toFixed(3)})`;
205
+ }
206
+ }
207
+
208
+ /** Compute style deltas from the current wrapper transform — used by syncToStore in the overlay. */
209
+ export function computeTransformStyle(el: HTMLElement, iframeWin: Window): Record<string, number> {
210
+ const wrapper = getOrCreateWrapper(el);
211
+ const { x, y, scale, rotation } = readGsapTransform(wrapper, iframeWin);
212
+ const style: Record<string, number> = {};
213
+ if (Math.abs(x) > 0.5) style.x = x;
214
+ if (Math.abs(y) > 0.5) style.y = y;
215
+ if (Math.abs(scale - 1) > 0.001) {
216
+ style.scaleX = scale;
217
+ style.scaleY = scale;
218
+ }
219
+ if (Math.abs(rotation) > 0.1) style.rotation = rotation;
220
+ return style;
221
+ }
@@ -108,8 +108,12 @@ export function StudioPreviewArea({
108
108
  onCompositionChange={(compPath) => {
109
109
  // Sync activeCompPath when user drills down via timeline double-click
110
110
  // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
111
- setActiveCompPath(compPath);
112
- refreshPreviewDocumentVersion();
111
+ // Guard against no-op updates to prevent circular refresh cascades
112
+ // between activeCompPath → compositionStack → onCompositionChange.
113
+ if (compPath !== activeCompPath) {
114
+ setActiveCompPath(compPath);
115
+ refreshPreviewDocumentVersion();
116
+ }
113
117
  }}
114
118
  onIframeRef={handlePreviewIframeRef}
115
119
  previewOverlay={