@hyperframes/studio 0.7.56 → 0.7.58

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 (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -0,0 +1,597 @@
1
+ // Soft-reload-first preview sync for timeline timing edits: server GSAP
2
+ // position mutations (shift / scale), folding those rewrites into the timing
3
+ // edit's undo history, and swapping the rewritten script into the live preview
4
+ // without a full iframe reload when possible.
5
+ import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
6
+ import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftReload";
7
+ import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
8
+ import type { RecordEditInput } from "../utils/studioFileHistory";
9
+ import { patchDocumentRootDuration } from "./timelineEditingGsap";
10
+
11
+ class GsapPreviewConvergenceError extends Error {}
12
+ class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
13
+
14
+ export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
15
+ if (targetPath.includes("\0") || targetPath.includes("..")) {
16
+ throw new Error(`Unsafe path: ${targetPath}`);
17
+ }
18
+ const response = await fetch(
19
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(targetPath)}`,
20
+ );
21
+ if (!response.ok) {
22
+ throw new Error(`Failed to read ${targetPath}`);
23
+ }
24
+ const data = (await response.json()) as { content?: string };
25
+ if (typeof data.content !== "string") {
26
+ throw new Error(`Missing file contents for ${targetPath}`);
27
+ }
28
+ return data.content;
29
+ }
30
+
31
+ /** Verify rollback ownership support before any GSAP mutation can land. */
32
+ async function requireGsapOwnershipProtocol(projectId: string): Promise<void> {
33
+ const response = await fetch(
34
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-capabilities`,
35
+ );
36
+ if (!response.ok) {
37
+ throw new GsapOwnershipProtocolError("Server does not support owned GSAP mutations");
38
+ }
39
+ const body = await response.json().catch(() => null);
40
+ if (!isRecord(body) || body.atomicOwnershipPairs !== true) {
41
+ throw new GsapOwnershipProtocolError("Invalid GSAP mutation capability response");
42
+ }
43
+ }
44
+
45
+ /** Atomically restore one GSAP mutation only while its exact output still owns
46
+ * the file. The server performs compare + write synchronously, eliminating the
47
+ * client GET→PUT window that could overwrite a successor edit. */
48
+ async function rollbackOwnedMutation(
49
+ projectId: string,
50
+ targetPath: string,
51
+ expected: string,
52
+ restore: string,
53
+ ): Promise<"restored" | "conflict"> {
54
+ if (targetPath.includes("\0") || targetPath.includes("..")) {
55
+ throw new Error(`Unsafe path: ${targetPath}`);
56
+ }
57
+ const response = await fetch(
58
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
59
+ {
60
+ method: "POST",
61
+ headers: { "Content-Type": "application/json" },
62
+ body: JSON.stringify({ expected, restore }),
63
+ },
64
+ );
65
+ if (!response.ok) {
66
+ throw new Error(`Failed to restore ${targetPath}`);
67
+ }
68
+ const result = (await response.json()) as { restored?: unknown; conflict?: unknown };
69
+ if (result.restored === true && result.conflict === false) return "restored";
70
+ if (result.restored === false && result.conflict === true) return "conflict";
71
+ throw new Error(`Invalid restore response for ${targetPath}`);
72
+ }
73
+
74
+ /** Best-effort live-iframe wrapper for patchDocumentRootDuration (see timelineEditingGsap). */
75
+ function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: number): void {
76
+ try {
77
+ patchDocumentRootDuration(iframe?.contentDocument ?? null, contentEnd);
78
+ } catch {
79
+ // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
80
+ }
81
+ }
82
+
83
+ /** Keep the duration readout and live root aligned with optimistically patched clips. */
84
+ export function syncPreviewContentDuration(iframe: HTMLIFrameElement | null): void {
85
+ const end = furthestClipEndFromDocument(iframe?.contentDocument ?? null);
86
+ if (end > 0) {
87
+ usePlayerStore.getState().setDuration(end);
88
+ patchIframeRootDuration(iframe, end);
89
+ }
90
+ }
91
+
92
+ /** Restore both store and live-root duration when a timing persist fails. */
93
+ export function captureDurationRollback(iframe: HTMLIFrameElement | null): () => void {
94
+ const previousDuration = usePlayerStore.getState().duration;
95
+ return () => {
96
+ if (usePlayerStore.getState().duration === previousDuration) return;
97
+ usePlayerStore.getState().setDuration(previousDuration);
98
+ patchIframeRootDuration(iframe, previousDuration);
99
+ };
100
+ }
101
+
102
+ /**
103
+ * The bits of the server GSAP-mutation response the timeline edit path needs.
104
+ * `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload`
105
+ * swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null
106
+ * when the endpoint didn't return one (older server, or a multi-script comp the
107
+ * soft path can't scope) — the caller then full-reloads when `mutated`, or
108
+ * rebinds the runtime timing in place when nothing was rewritten (see
109
+ * syncTimingEditPreview).
110
+ */
111
+ export type GsapMutationStatus = {
112
+ mutated: boolean;
113
+ scriptText: string | null;
114
+ /** Atomic whole-file ownership pair returned by the mutation endpoint. */
115
+ before?: string;
116
+ after?: string;
117
+ };
118
+
119
+ function isRecord(value: unknown): value is Record<string, unknown> {
120
+ return typeof value === "object" && value !== null;
121
+ }
122
+
123
+ function readMutationStatus(value: unknown): GsapMutationStatus {
124
+ if (
125
+ !isRecord(value) ||
126
+ typeof value.mutated !== "boolean" ||
127
+ typeof value.before !== "string" ||
128
+ typeof value.after !== "string" ||
129
+ value.mutated !== (value.before !== value.after) ||
130
+ ("changed" in value && value.changed !== value.mutated)
131
+ ) {
132
+ throw new GsapOwnershipProtocolError("Invalid owned GSAP mutation response");
133
+ }
134
+ return {
135
+ mutated: value.mutated,
136
+ scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
137
+ before: value.before,
138
+ after: value.after,
139
+ };
140
+ }
141
+
142
+ function readMutationError(value: unknown, fallback: string): string {
143
+ if (isRecord(value) && typeof value.error === "string") return value.error;
144
+ return fallback;
145
+ }
146
+
147
+ async function postGsapMutation(
148
+ projectId: string,
149
+ filePath: string,
150
+ mutation: Record<string, unknown>,
151
+ fallback: string,
152
+ ): Promise<GsapMutationStatus> {
153
+ let response: Response;
154
+ try {
155
+ response = await fetch(
156
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
157
+ {
158
+ method: "POST",
159
+ headers: { "Content-Type": "application/json" },
160
+ body: JSON.stringify(mutation),
161
+ },
162
+ );
163
+ } catch (error) {
164
+ throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
165
+ cause: error,
166
+ });
167
+ }
168
+ const body: unknown = await response.json().catch(() => null);
169
+ if (!response.ok) {
170
+ throw new GsapPreviewConvergenceError(readMutationError(body, fallback));
171
+ }
172
+ return readMutationStatus(body);
173
+ }
174
+
175
+ /** Re-derive live timing windows without re-executing composition scripts.
176
+ * Works for zero-GSAP compositions; false asks the caller to full-reload. */
177
+ function rebindPreviewTiming(iframe: HTMLIFrameElement | null, currentTime: number): boolean {
178
+ return applySoftReloadFinalization(iframe, currentTime);
179
+ }
180
+
181
+ /**
182
+ * Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a
183
+ * soft reload over the full iframe reload that flashes every clip.
184
+ *
185
+ * Why this is safe WITHOUT re-deriving timeline elements: a move/resize commit has
186
+ * already (a) patched the live DOM timing attributes, (b) updated the store's
187
+ * elements optimistically (the drag commit calls `updateElement` before the
188
+ * persist), and (c) had the server rewrite the GSAP tween positions — which is the
189
+ * `scriptText` we swap in here. `applySoftReload` re-runs that script in the LIVE
190
+ * document (no navigation), re-seeks to the current playhead, and rebinds the
191
+ * timeline, so the runtime matches the already-correct store. Nothing structural
192
+ * changed (no clip added/removed), so `processTimelineMessage` would re-derive the
193
+ * identical element set — skipping it just avoids the flash.
194
+ *
195
+ * Escalates to the full `reloadPreview()` only on the PERMANENT `cannot-soft-reload`
196
+ * result (no gsap runtime / rebind hook / scopable key / script element, or the
197
+ * re-run threw). The TRANSIENT `verify-failed` is NOT escalated — the live re-run
198
+ * already applied the shift; a remount would re-flash for nothing.
199
+ *
200
+ * `mutated` is the canonical decision, regardless of whether the server echoes
201
+ * the unchanged script text:
202
+ * - `mutated: false` — nothing was rewritten because there was NOTHING TO
203
+ * REWRITE (the clip has no domId so no id-addressed tweens, the delta was
204
+ * zero, or the server confirmed a no-op). Every script is unchanged and the
205
+ * timing attributes are already live-patched, so (when `rebindWhenUnmutated`
206
+ * allows it) `rebindPreviewTiming` re-seeks + rebinds and the runtime
207
+ * re-derives the clip windows from the live DOM — no script re-execution,
208
+ * no full-reload blink. This covers comps with zero GSAP scripts too; only
209
+ * a missing iframe/runtime hook falls back to the full reload.
210
+ * - `mutated: true` with no script — the file on disk WAS rewritten but the
211
+ * server returned no script (older server, multi-script comp): the live
212
+ * script is now stale, so a rebind against it would show wrong positions →
213
+ * full-reload.
214
+ */
215
+ function syncTimingEditPreview(
216
+ iframe: HTMLIFrameElement | null,
217
+ outcome: GsapMutationStatus,
218
+ currentTime: number,
219
+ reloadPreview: () => void,
220
+ rebindWhenUnmutated: boolean,
221
+ ): void {
222
+ if (!outcome.mutated && rebindWhenUnmutated) {
223
+ if (!rebindPreviewTiming(iframe, currentTime)) reloadPreview();
224
+ return;
225
+ }
226
+ if (!iframe || !outcome.scriptText) {
227
+ reloadPreview();
228
+ return;
229
+ }
230
+ const result = applySoftReload(iframe, outcome.scriptText, {
231
+ onAsyncFailure: reloadPreview,
232
+ currentTimeOverride: currentTime,
233
+ });
234
+ if (result === "cannot-soft-reload") reloadPreview();
235
+ }
236
+
237
+ async function finishTimelineTimingFallback(input: {
238
+ iframe: HTMLIFrameElement | null;
239
+ reloadPreview: () => void;
240
+ gsapMutation?: () => Promise<GsapMutationStatus>;
241
+ onGsapError: (error: unknown) => void;
242
+ /**
243
+ * When the mutation produced no rewrite (mutated:false, no scriptText),
244
+ * rebind the runtime timing in place (no script re-execution) instead of
245
+ * full-reloading (see syncTimingEditPreview). Callers pass false when a full
246
+ * reload is the only sync that reflects everything (e.g. a multi-file group
247
+ * edit).
248
+ */
249
+ rebindWhenUnmutated: boolean;
250
+ }): Promise<void> {
251
+ let outcome: GsapMutationStatus = { mutated: false, scriptText: null };
252
+ if (input.gsapMutation) {
253
+ try {
254
+ outcome = await input.gsapMutation();
255
+ } catch (error) {
256
+ input.onGsapError(error);
257
+ // Protocol and ownership conflicts mean the live iframe may no longer
258
+ // represent the bytes that won on disk. Converge explicitly by reloading.
259
+ if (error instanceof GsapPreviewConvergenceError) input.reloadPreview();
260
+ return;
261
+ }
262
+ }
263
+ syncTimingEditPreview(
264
+ input.iframe,
265
+ outcome,
266
+ usePlayerStore.getState().currentTime,
267
+ input.reloadPreview,
268
+ input.rebindWhenUnmutated,
269
+ );
270
+ }
271
+
272
+ // Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
273
+ // outlast one GSAP server round-trip, never a real second edit.
274
+ const GSAP_HISTORY_COALESCE_MS = 10_000;
275
+
276
+ type OwnedMutationStep = {
277
+ path: string;
278
+ before: string;
279
+ after: string;
280
+ };
281
+
282
+ type OwnedMutationRunner = (
283
+ path: string,
284
+ mutation: () => Promise<GsapMutationStatus> | null,
285
+ ) => Promise<GsapMutationStatus>;
286
+
287
+ /**
288
+ * Restore successful mutation steps in reverse order through the server's
289
+ * atomic compare-and-restore endpoint. A conflict means a successor owns the
290
+ * file and is deliberately preserved. Conflicts and restore errors require a
291
+ * preview reload; errors are also reported through onError.
292
+ */
293
+ async function rollbackMutatedFiles(
294
+ projectId: string,
295
+ ownedSteps: readonly OwnedMutationStep[],
296
+ onError: (error: unknown) => void,
297
+ ): Promise<"restored" | "convergence-required"> {
298
+ let convergenceRequired = false;
299
+ for (let index = ownedSteps.length - 1; index >= 0; index -= 1) {
300
+ const step = ownedSteps[index];
301
+ if (!step || step.before === step.after) continue;
302
+ try {
303
+ if (
304
+ (await rollbackOwnedMutation(projectId, step.path, step.after, step.before)) === "conflict"
305
+ ) {
306
+ convergenceRequired = true;
307
+ }
308
+ } catch (rollbackError) {
309
+ convergenceRequired = true;
310
+ onError(rollbackError);
311
+ }
312
+ }
313
+ return convergenceRequired ? "convergence-required" : "restored";
314
+ }
315
+
316
+ async function rollbackAfterFailure(
317
+ projectId: string,
318
+ ownedSteps: readonly OwnedMutationStep[],
319
+ onError: (error: unknown) => void,
320
+ originalError: unknown,
321
+ ): Promise<never> {
322
+ const outcome = await rollbackMutatedFiles(projectId, ownedSteps, onError);
323
+ if (outcome === "convergence-required") {
324
+ throw new GsapPreviewConvergenceError(
325
+ "GSAP rollback could not safely restore every owned write; preview reload required",
326
+ { cause: originalError },
327
+ );
328
+ }
329
+ throw originalError;
330
+ }
331
+
332
+ /**
333
+ * Fold server-owned GSAP rewrites into the preceding timing history entry.
334
+ * Every mutation contributes the atomic before/after pair returned by its
335
+ * endpoint; the first before and last after for each contiguous file chain are
336
+ * the only bytes this transaction may record or roll back.
337
+ */
338
+ // The ledger, reverse rollback, final ownership check, and history fold are one
339
+ // transaction; extracting phases would obscure which function owns convergence.
340
+ // fallow-ignore-next-line complexity
341
+ async function foldGsapMutationIntoHistory(input: {
342
+ projectId: string;
343
+ label: string;
344
+ coalesceKey?: string;
345
+ recordEdit: (edit: RecordEditInput) => Promise<void>;
346
+ gsapMutation: (runOwnedMutation: OwnedMutationRunner) => Promise<GsapMutationStatus>;
347
+ onRollbackError: (error: unknown) => void;
348
+ }): Promise<GsapMutationStatus> {
349
+ const ownedSteps: OwnedMutationStep[] = [];
350
+ const runOwnedMutation: OwnedMutationRunner = async (path, mutation) => {
351
+ const pending = mutation();
352
+ if (!pending) return { mutated: false, scriptText: null };
353
+ const status = await pending;
354
+ if (!status.mutated) return status;
355
+ if (status.before === undefined || status.after === undefined) {
356
+ throw new GsapOwnershipProtocolError(
357
+ `GSAP mutation returned no owned before/after pair for ${path}`,
358
+ );
359
+ }
360
+ const previous = [...ownedSteps].reverse().find((step) => step.path === path);
361
+ const step = { path, before: status.before, after: status.after };
362
+ ownedSteps.push(step);
363
+ // A foreign writer landed between two same-file mutation steps. The second
364
+ // step already wrote, so keep it in ownedSteps for reverse rollback, then
365
+ // fail rather than folding foreign bytes into this gesture's history.
366
+ if (previous && previous.after !== step.before) {
367
+ throw new Error(`GSAP mutation ownership chain broke for ${path}`);
368
+ }
369
+ return status;
370
+ };
371
+ let status: GsapMutationStatus;
372
+ try {
373
+ await requireGsapOwnershipProtocol(input.projectId);
374
+ status = await input.gsapMutation(runOwnedMutation);
375
+ } catch (error) {
376
+ return rollbackAfterFailure(input.projectId, ownedSteps, input.onRollbackError, error);
377
+ }
378
+ if (status.mutated) {
379
+ try {
380
+ const ownershipByPath = new Map<string, { before: string; after: string }>();
381
+ for (const step of ownedSteps) {
382
+ const owned = ownershipByPath.get(step.path);
383
+ if (owned) owned.after = step.after;
384
+ else ownershipByPath.set(step.path, { before: step.before, after: step.after });
385
+ }
386
+ const files: Record<string, { before: string; after: string }> = {};
387
+ for (const [path, owned] of ownershipByPath) {
388
+ const finalContent = await readFileContent(input.projectId, path);
389
+ if (finalContent !== owned.after) {
390
+ throw new Error(`GSAP mutation ownership lost for ${path}`);
391
+ }
392
+ if (owned.before !== owned.after) {
393
+ files[path] = owned;
394
+ }
395
+ }
396
+ if (Object.keys(files).length > 0) {
397
+ await input.recordEdit({
398
+ label: input.label,
399
+ kind: "timeline",
400
+ coalesceKey: input.coalesceKey,
401
+ coalesceMs: GSAP_HISTORY_COALESCE_MS,
402
+ files,
403
+ });
404
+ }
405
+ } catch (error) {
406
+ return rollbackAfterFailure(input.projectId, ownedSteps, input.onRollbackError, error);
407
+ }
408
+ }
409
+ return status;
410
+ }
411
+
412
+ /**
413
+ * Shift all GSAP animation positions targeting a given element by a time delta.
414
+ * Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
415
+ * Returns the rewritten script so the caller can soft-reload instead of full-reload.
416
+ */
417
+ export async function shiftGsapPositions(
418
+ projectId: string,
419
+ filePath: string,
420
+ elementId: string,
421
+ delta: number,
422
+ ): Promise<GsapMutationStatus> {
423
+ if (delta === 0 || !elementId) return { mutated: false, scriptText: null };
424
+ return postGsapMutation(
425
+ projectId,
426
+ filePath,
427
+ {
428
+ type: "shift-positions",
429
+ targetSelector: `#${elementId}`,
430
+ delta,
431
+ },
432
+ "shift-positions failed",
433
+ );
434
+ }
435
+
436
+ export async function scaleGsapPositions(
437
+ projectId: string,
438
+ filePath: string,
439
+ elementId: string,
440
+ oldStart: number,
441
+ oldDuration: number,
442
+ newStart: number,
443
+ newDuration: number,
444
+ ): Promise<GsapMutationStatus> {
445
+ if (!elementId || oldDuration <= 0 || newDuration <= 0)
446
+ return { mutated: false, scriptText: null };
447
+ if (oldStart === newStart && oldDuration === newDuration)
448
+ return { mutated: false, scriptText: null };
449
+ return postGsapMutation(
450
+ projectId,
451
+ filePath,
452
+ {
453
+ type: "scale-positions",
454
+ targetSelector: `#${elementId}`,
455
+ oldStart,
456
+ oldDuration,
457
+ newStart,
458
+ newDuration,
459
+ },
460
+ "scale-positions failed",
461
+ );
462
+ }
463
+
464
+ /** Timing delta a single-clip edit applies to its GSAP tweens. */
465
+ export type SingleClipGsapEdit =
466
+ | { kind: "shift"; delta: number }
467
+ | {
468
+ kind: "scale";
469
+ from: { start: number; duration: number };
470
+ to: { start: number; duration: number };
471
+ };
472
+
473
+ /**
474
+ * Post-persist GSAP sync for a SINGLE-clip timing edit (move / resize): runs the
475
+ * server shift/scale mutation, folds the rewrite into the timing edit's history
476
+ * entry (see foldGsapMutationIntoHistory), then soft-reloads the preview with
477
+ * the rewritten script. When there was nothing to rewrite (no domId — e.g. a
478
+ * selector-addressed caption clip — zero delta, or a server no-op) it rebinds
479
+ * the runtime timing in place instead of full-reloading; full reload remains
480
+ * for genuine rewrites without a returned script and for comps the soft path
481
+ * can't handle (see syncTimingEditPreview).
482
+ */
483
+ export function finishClipTimingFallback(input: {
484
+ iframe: HTMLIFrameElement | null;
485
+ reloadPreview: () => void;
486
+ projectId: string | null;
487
+ targetPath: string;
488
+ domId: string | undefined;
489
+ label: string;
490
+ coalesceKey?: string;
491
+ recordEdit: (edit: RecordEditInput) => Promise<void>;
492
+ edit: SingleClipGsapEdit;
493
+ }): Promise<void> {
494
+ const { projectId, targetPath, domId, edit } = input;
495
+ const timingChanged =
496
+ edit.kind === "shift"
497
+ ? edit.delta !== 0
498
+ : edit.from.start !== edit.to.start || edit.from.duration !== edit.to.duration;
499
+ const runMutation = (pid: string, id: string): Promise<GsapMutationStatus> =>
500
+ edit.kind === "shift"
501
+ ? shiftGsapPositions(pid, targetPath, id, edit.delta)
502
+ : scaleGsapPositions(
503
+ pid,
504
+ targetPath,
505
+ id,
506
+ edit.from.start,
507
+ edit.from.duration,
508
+ edit.to.start,
509
+ edit.to.duration,
510
+ );
511
+ const onGsapError = (err: unknown) =>
512
+ console.error(`[Timeline] Failed to ${edit.kind} GSAP positions`, err);
513
+ return finishTimelineTimingFallback({
514
+ iframe: input.iframe,
515
+ reloadPreview: input.reloadPreview,
516
+ gsapMutation:
517
+ timingChanged && domId && projectId
518
+ ? () =>
519
+ foldGsapMutationIntoHistory({
520
+ projectId,
521
+ label: input.label,
522
+ coalesceKey: input.coalesceKey,
523
+ recordEdit: input.recordEdit,
524
+ gsapMutation: (runOwnedMutation) =>
525
+ runOwnedMutation(targetPath, () => runMutation(projectId, domId)),
526
+ onRollbackError: onGsapError,
527
+ })
528
+ : undefined,
529
+ onGsapError,
530
+ rebindWhenUnmutated: true,
531
+ });
532
+ }
533
+
534
+ /**
535
+ * Shared post-persist GSAP sync for GROUP timing edits (move / resize): runs the
536
+ * per-change server mutation for every changed clip, folds the rewrites into the
537
+ * timing edit's history entry, and soft-reloads the preview when possible.
538
+ *
539
+ * The preview is a SINGLE shared iframe showing the ACTIVE composition, so only
540
+ * the active comp's rewritten script can be soft-reloaded (swapped in place, no
541
+ * all-clips flash). If any OTHER file changed too — e.g. a sub-comp group in a
542
+ * multi-file move — no scriptText is passed, so the fallback does ONE full
543
+ * reload that reflects every changed file.
544
+ */
545
+ export async function finishGroupTimingGsapFallback<C extends { element: TimelineElement }>(input: {
546
+ projectId: string;
547
+ iframe: HTMLIFrameElement | null;
548
+ reloadPreview: () => void;
549
+ label: string;
550
+ errorLabel: string;
551
+ coalesceKey?: string;
552
+ recordEdit: (edit: RecordEditInput) => Promise<void>;
553
+ activeCompPath: string | null;
554
+ changes: readonly C[];
555
+ resolveChangePath: (element: TimelineElement) => string;
556
+ /** Per-change GSAP mutation; return null to skip a change with no timing delta. */
557
+ mutateChange: (change: C, changePath: string) => Promise<GsapMutationStatus> | null;
558
+ }): Promise<void> {
559
+ const activePath = input.activeCompPath || "index.html";
560
+ const otherFileChanged = input.changes.some(
561
+ (change) => input.resolveChangePath(change.element) !== activePath,
562
+ );
563
+ const onGsapError = (err: unknown) => console.error(`[Timeline] ${input.errorLabel}`, err);
564
+ await finishTimelineTimingFallback({
565
+ iframe: input.iframe,
566
+ reloadPreview: input.reloadPreview,
567
+ gsapMutation: () =>
568
+ foldGsapMutationIntoHistory({
569
+ projectId: input.projectId,
570
+ label: input.label,
571
+ coalesceKey: input.coalesceKey,
572
+ recordEdit: input.recordEdit,
573
+ gsapMutation: async (runOwnedMutation) => {
574
+ let mutated = false;
575
+ let scriptText: GsapMutationStatus["scriptText"] = null;
576
+ for (const change of input.changes) {
577
+ const changePath = input.resolveChangePath(change.element);
578
+ const status = await runOwnedMutation(changePath, () =>
579
+ input.mutateChange(change, changePath),
580
+ );
581
+ mutated = mutated || status.mutated;
582
+ // The LAST mutation against the active comp carries the cumulative
583
+ // rewritten script for that file.
584
+ if (changePath === activePath) scriptText = status.scriptText;
585
+ }
586
+ return { mutated, scriptText: otherFileChanged ? null : scriptText };
587
+ },
588
+ onRollbackError: onGsapError,
589
+ }),
590
+ onGsapError,
591
+ // A batch where nothing needed rewriting (every change was zero-delta or
592
+ // no-domId, e.g. closing a gap over caption clips) still needs the runtime
593
+ // to re-derive clip windows — the in-place timing rebind covers that. But
594
+ // when another file changed, only a full reload reflects every file.
595
+ rebindWhenUnmutated: !otherFileChanged,
596
+ });
597
+ }
@@ -44,7 +44,9 @@ describe("toggleTimelineTrackHidden", () => {
44
44
  if (iframe.contentDocument) {
45
45
  iframe.contentDocument.body.innerHTML = `
46
46
  <div id="hero"></div>
47
- <div id="subtitle"></div>
47
+ <div data-composition-id="scene" data-composition-file="scene.html">
48
+ <div id="subtitle"></div>
49
+ </div>
48
50
  `;
49
51
  }
50
52
 
@@ -82,9 +82,10 @@ function patchLiveHiddenState(
82
82
  iframe: HTMLIFrameElement | null,
83
83
  elements: readonly TimelineElement[],
84
84
  hidden: boolean,
85
+ activeCompPath: string | null,
85
86
  ): void {
86
87
  for (const element of elements) {
87
- const target = findTimelineElementInIframe(iframe, element);
88
+ const target = findTimelineElementInIframe(iframe, element, activeCompPath);
88
89
  if (!target) continue;
89
90
  if (hidden) {
90
91
  target.setAttribute("data-hidden", "");
@@ -134,7 +135,7 @@ async function setElementsHidden({
134
135
  }: SetElementsHiddenInput): Promise<string[]> {
135
136
  if (elements.length === 0) return [];
136
137
 
137
- patchLiveHiddenState(previewIframe, elements, hidden);
138
+ patchLiveHiddenState(previewIframe, elements, hidden, activeCompPath);
138
139
  reseekPreviewRuntime(previewIframe);
139
140
 
140
141
  const hiddenOperation: PatchOperation = {
@@ -188,7 +189,7 @@ async function setElementsHidden({
188
189
  // The optimistic live patch already ran; a patch-target/save failure here would
189
190
  // otherwise leave the preview showing the wrong visibility until a reload. Revert
190
191
  // the live DOM to the prior state so what's on screen matches what persisted.
191
- patchLiveHiddenState(previewIframe, elements, !hidden);
192
+ patchLiveHiddenState(previewIframe, elements, !hidden, activeCompPath);
192
193
  reseekPreviewRuntime(previewIframe);
193
194
  throw error;
194
195
  }
@@ -1,5 +1,6 @@
1
1
  import { useEffect } from "react";
2
2
  import { useCaptionStore } from "../captions/store";
3
+ import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
3
4
  import { useCaptionSync } from "../captions/hooks/useCaptionSync";
4
5
  import { parseCaptionComposition } from "../captions/parser";
5
6
 
@@ -111,6 +112,7 @@ export function useCaptionDetection({
111
112
  const handleMessage = (e: MessageEvent) => {
112
113
  const data = e.data;
113
114
  if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
115
+ if (!acceptStudioRuntimeMessage(data)) return;
114
116
  tryActivateCaptions();
115
117
  }
116
118
  };