@hyperframes/studio 0.7.56 → 0.7.57

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 (81) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
  4. package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/index.d.ts +26 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4345 -3812
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  12. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  13. package/src/components/editor/DomEditOverlay.tsx +31 -62
  14. package/src/components/editor/LayersPanel.tsx +10 -1
  15. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  16. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  18. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  19. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  20. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  21. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  22. package/src/components/nle/PreviewOverlays.tsx +36 -16
  23. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  24. package/src/components/sidebar/AssetCard.tsx +19 -2
  25. package/src/components/sidebar/AudioRow.tsx +19 -2
  26. package/src/hooks/domEditCommitTypes.ts +12 -1
  27. package/src/hooks/timelineEditingHelpers.test.ts +91 -0
  28. package/src/hooks/timelineEditingHelpers.ts +31 -244
  29. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  30. package/src/hooks/timelineMoveAdapter.ts +5 -3
  31. package/src/hooks/timelineTimingSync.test.ts +150 -0
  32. package/src/hooks/timelineTimingSync.ts +412 -0
  33. package/src/hooks/useCaptionDetection.ts +2 -0
  34. package/src/hooks/useCompositionDimensions.ts +32 -5
  35. package/src/hooks/useDomEditCommits.test.tsx +92 -1
  36. package/src/hooks/useDomEditCommits.ts +63 -1
  37. package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
  38. package/src/hooks/useElementLifecycleOps.ts +20 -7
  39. package/src/hooks/useElementPicker.ts +7 -8
  40. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  41. package/src/hooks/useTimelineEditing.test.tsx +378 -212
  42. package/src/hooks/useTimelineEditing.ts +181 -265
  43. package/src/hooks/useTimelineGroupEditing.ts +153 -132
  44. package/src/player/components/PlayerControls.tsx +5 -5
  45. package/src/player/components/PlayheadIndicator.tsx +7 -2
  46. package/src/player/components/Timeline.test.ts +85 -8
  47. package/src/player/components/Timeline.tsx +9 -9
  48. package/src/player/components/TimelineCanvas.tsx +11 -2
  49. package/src/player/components/TimelineRuler.tsx +58 -48
  50. package/src/player/components/timelineClipDragCommit.test.ts +91 -8
  51. package/src/player/components/timelineClipDragCommit.ts +106 -9
  52. package/src/player/components/timelineLayout.ts +99 -26
  53. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  54. package/src/player/components/timelineRevealScroll.ts +91 -0
  55. package/src/player/components/timelineStackingSync.test.ts +60 -0
  56. package/src/player/components/timelineStackingSync.ts +27 -2
  57. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  58. package/src/player/components/timelineZones.test.ts +122 -310
  59. package/src/player/components/timelineZones.ts +82 -135
  60. package/src/player/components/useTimelineRevealClip.ts +56 -0
  61. package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
  62. package/src/player/components/useTimelineStackingSync.ts +13 -2
  63. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  64. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  65. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  66. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  67. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  68. package/src/player/index.ts +3 -1
  69. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  70. package/src/player/lib/runtimeProtocol.ts +65 -0
  71. package/src/player/lib/timelineDOM.ts +9 -0
  72. package/src/player/lib/timelineElementHelpers.ts +19 -0
  73. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  74. package/src/player/store/playerStore.test.ts +38 -13
  75. package/src/player/store/playerStore.ts +37 -0
  76. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  77. package/src/utils/assetPreviewDismiss.ts +29 -0
  78. package/src/utils/timelineAssetDrop.test.ts +144 -52
  79. package/src/utils/timelineAssetDrop.ts +62 -86
  80. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  81. package/dist/assets/index-D-GyYi2d.css +0 -1
@@ -0,0 +1,150 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { usePlayerStore } from "../player/store/playerStore";
4
+ import {
5
+ captureDurationRollback,
6
+ finishClipTimingFallback,
7
+ readFileContent,
8
+ shiftGsapPositions,
9
+ } from "./timelineTimingSync";
10
+
11
+ afterEach(() => {
12
+ usePlayerStore.getState().reset();
13
+ vi.restoreAllMocks();
14
+ vi.unstubAllGlobals();
15
+ });
16
+
17
+ function jsonResponse(body: unknown): Response {
18
+ return new Response(JSON.stringify(body), {
19
+ status: 200,
20
+ headers: { "content-type": "application/json" },
21
+ });
22
+ }
23
+
24
+ function requestUrl(input: Parameters<typeof fetch>[0]): string {
25
+ if (typeof input === "string") return input;
26
+ if (input instanceof URL) return input.toString();
27
+ return input.url;
28
+ }
29
+
30
+ /**
31
+ * Stub fetch: `/files/` reads return contents from the queue (repeating the
32
+ * last entry), the GSAP-mutation endpoint answers with `gsapBody` (a thrown
33
+ * Error rejects the call with a non-ok response).
34
+ */
35
+ function stubFetch(fileContents: string[], gsapBody: unknown | Error) {
36
+ let readIndex = 0;
37
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
38
+ const url = requestUrl(input);
39
+ if (url.includes("/files/")) {
40
+ const content = fileContents[Math.min(readIndex, fileContents.length - 1)];
41
+ readIndex += 1;
42
+ return jsonResponse({ content });
43
+ }
44
+ if (url.includes("/gsap-mutations/")) {
45
+ if (gsapBody instanceof Error) {
46
+ return new Response(JSON.stringify({ error: gsapBody.message }), { status: 500 });
47
+ }
48
+ return jsonResponse(gsapBody);
49
+ }
50
+ throw new Error(`Unexpected fetch: ${url}`);
51
+ });
52
+ vi.stubGlobal("fetch", fetchMock);
53
+ return fetchMock;
54
+ }
55
+
56
+ function clipFallbackInput(overrides: {
57
+ reloadPreview: () => void;
58
+ recordEdit: (edit: unknown) => Promise<void>;
59
+ }) {
60
+ return {
61
+ iframe: null,
62
+ reloadPreview: overrides.reloadPreview,
63
+ projectId: "p1",
64
+ targetPath: "index.html",
65
+ domId: "clip",
66
+ label: "Move timeline clip",
67
+ recordEdit: overrides.recordEdit as never,
68
+ edit: { kind: "shift", delta: 1 } as const,
69
+ };
70
+ }
71
+
72
+ describe("finishClipTimingFallback failure domains", () => {
73
+ it("still syncs the preview when the history-fold step fails after a successful mutation", async () => {
74
+ // Mutation succeeds (server rewrite already on disk), but recordEdit (the
75
+ // fold step) throws. The preview MUST still be synced — otherwise stale
76
+ // GSAP positions stay on screen. iframe=null makes the sync observable as
77
+ // one reloadPreview() call.
78
+ stubFetch(["<before>", "<after>"], { mutated: true, scriptText: "tl.to()" });
79
+ const reloadPreview = vi.fn();
80
+ const foldError = new Error("history fold failed");
81
+ const recordEdit = vi.fn(async () => {
82
+ throw foldError;
83
+ });
84
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
85
+
86
+ await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
87
+
88
+ expect(recordEdit).toHaveBeenCalledTimes(1);
89
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
90
+ // The fold error is surfaced, not swallowed silently.
91
+ expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("GSAP"), foldError);
92
+ });
93
+
94
+ it("skips the preview sync when the MUTATION itself fails", async () => {
95
+ stubFetch(["<before>"], new Error("mutation blew up"));
96
+ const reloadPreview = vi.fn();
97
+ const recordEdit = vi.fn(async () => {});
98
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
99
+
100
+ await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
101
+
102
+ expect(recordEdit).not.toHaveBeenCalled();
103
+ expect(reloadPreview).not.toHaveBeenCalled();
104
+ expect(consoleError).toHaveBeenCalledTimes(1);
105
+ });
106
+
107
+ it("records the fold and syncs on the happy path", async () => {
108
+ stubFetch(["<before>", "<after>"], { mutated: true, scriptText: "tl.to()" });
109
+ const reloadPreview = vi.fn();
110
+ const recordEdit = vi.fn(async () => {});
111
+
112
+ await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
113
+
114
+ expect(recordEdit).toHaveBeenCalledTimes(1);
115
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
116
+ });
117
+ });
118
+
119
+ describe("captureDurationRollback", () => {
120
+ it("restores the pre-sync duration only when it changed", () => {
121
+ usePlayerStore.getState().setDuration(4);
122
+ const rollback = captureDurationRollback(null);
123
+
124
+ // No change → rollback is a no-op (no spurious set).
125
+ rollback();
126
+ expect(usePlayerStore.getState().duration).toBe(4);
127
+
128
+ usePlayerStore.getState().setDuration(9);
129
+ rollback();
130
+ expect(usePlayerStore.getState().duration).toBe(4);
131
+ });
132
+ });
133
+
134
+ describe("fetch URL encoding (user-influenced segments)", () => {
135
+ it("URI-encodes the projectId in file reads", async () => {
136
+ const fetchMock = stubFetch(["<html>"], {});
137
+ await readFileContent("p/../evil", "index.html");
138
+ expect(requestUrl(fetchMock.mock.calls[0]![0])).toBe(
139
+ "/api/projects/p%2F..%2Fevil/files/index.html",
140
+ );
141
+ });
142
+
143
+ it("URI-encodes the projectId in GSAP mutation calls", async () => {
144
+ const fetchMock = stubFetch([], { mutated: false, scriptText: null });
145
+ await shiftGsapPositions("p one", "scenes/intro.html", "clip", 1);
146
+ expect(requestUrl(fetchMock.mock.calls[0]![0])).toBe(
147
+ "/api/projects/p%20one/gsap-mutations/scenes%2Fintro.html",
148
+ );
149
+ });
150
+ });
@@ -0,0 +1,412 @@
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 } from "../utils/gsapSoftReload";
7
+ import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
8
+ import type { RecordEditInput } from "../utils/studioFileHistory";
9
+ import { patchDocumentRootDuration } from "./timelineEditingGsap";
10
+
11
+ export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
12
+ if (targetPath.includes("\0") || targetPath.includes("..")) {
13
+ throw new Error(`Unsafe path: ${targetPath}`);
14
+ }
15
+ const response = await fetch(
16
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(targetPath)}`,
17
+ );
18
+ if (!response.ok) {
19
+ throw new Error(`Failed to read ${targetPath}`);
20
+ }
21
+ const data = (await response.json()) as { content?: string };
22
+ if (typeof data.content !== "string") {
23
+ throw new Error(`Missing file contents for ${targetPath}`);
24
+ }
25
+ return data.content;
26
+ }
27
+
28
+ /** Best-effort live-iframe wrapper for patchDocumentRootDuration (see timelineEditingGsap). */
29
+ function patchIframeRootDuration(iframe: HTMLIFrameElement | null, contentEnd: number): void {
30
+ try {
31
+ patchDocumentRootDuration(iframe?.contentDocument ?? null, contentEnd);
32
+ } catch {
33
+ // Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Optimistically push the composition's content-driven length into the player
39
+ * store right after the live DOM patch, so the duration readout + seek bar
40
+ * update immediately. The readout binds to store.duration (PlayerControls);
41
+ * edits only patched store.elements, so the number stayed frozen (esp. on
42
+ * shrink) until a manual refresh. Read from the just-patched preview DOM (raw
43
+ * data-duration) so it's immune to the runtime's truncated live durations.
44
+ *
45
+ * Also writes the content end into the live root's `data-duration`. Timing
46
+ * edits take the soft-reload path (no full iframe reload), which lets the
47
+ * runtime recompute the length from the root's declared duration and post it
48
+ * back — reading the STALE root would revert this optimistic set.
49
+ */
50
+ export function syncPreviewContentDuration(iframe: HTMLIFrameElement | null): void {
51
+ const end = furthestClipEndFromDocument(iframe?.contentDocument ?? null);
52
+ if (end > 0) {
53
+ usePlayerStore.getState().setDuration(end);
54
+ patchIframeRootDuration(iframe, end);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Snapshot the store duration BEFORE an optimistic duration update
60
+ * (extendRootDurationIfNeeded + syncPreviewContentDuration) and return a
61
+ * rollback closure for the persist-failure path. The rollback restores BOTH
62
+ * the store duration and the live root's `data-duration` — otherwise a failed
63
+ * write leaves the readout/seek bar and the live root advertising a duration
64
+ * the saved source never got. No-op when the duration never changed.
65
+ */
66
+ export function captureDurationRollback(iframe: HTMLIFrameElement | null): () => void {
67
+ const previousDuration = usePlayerStore.getState().duration;
68
+ return () => {
69
+ if (usePlayerStore.getState().duration === previousDuration) return;
70
+ usePlayerStore.getState().setDuration(previousDuration);
71
+ patchIframeRootDuration(iframe, previousDuration);
72
+ };
73
+ }
74
+
75
+ /**
76
+ * The bits of the server GSAP-mutation response the timeline edit path needs.
77
+ * `scriptText` is the rewritten root GSAP script — feeding it to `applySoftReload`
78
+ * swaps the runtime timeline in place (no iframe reload = no all-clips flash). Null
79
+ * when the endpoint didn't return one (older server, or a multi-script comp the
80
+ * soft path can't scope), in which case the caller full-reloads as before.
81
+ */
82
+ export type GsapMutationStatus = { mutated: boolean; scriptText: string | null };
83
+
84
+ function isRecord(value: unknown): value is Record<string, unknown> {
85
+ return typeof value === "object" && value !== null;
86
+ }
87
+
88
+ function readMutationStatus(value: unknown): GsapMutationStatus {
89
+ if (!isRecord(value)) return { mutated: false, scriptText: null };
90
+ return {
91
+ mutated: value.mutated === true || value.changed === true,
92
+ scriptText: typeof value.scriptText === "string" ? value.scriptText : null,
93
+ };
94
+ }
95
+
96
+ function readMutationError(value: unknown, fallback: string): string {
97
+ if (isRecord(value) && typeof value.error === "string") return value.error;
98
+ return fallback;
99
+ }
100
+
101
+ /**
102
+ * Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a
103
+ * soft reload over the full iframe reload that flashes every clip.
104
+ *
105
+ * Why this is safe WITHOUT re-deriving timeline elements: a move/resize commit has
106
+ * already (a) patched the live DOM timing attributes, (b) updated the store's
107
+ * elements optimistically (the drag commit calls `updateElement` before the
108
+ * persist), and (c) had the server rewrite the GSAP tween positions — which is the
109
+ * `scriptText` we swap in here. `applySoftReload` re-runs that script in the LIVE
110
+ * document (no navigation), re-seeks to the current playhead, and rebinds the
111
+ * timeline, so the runtime matches the already-correct store. Nothing structural
112
+ * changed (no clip added/removed), so `processTimelineMessage` would re-derive the
113
+ * identical element set — skipping it just avoids the flash.
114
+ *
115
+ * Escalates to the full `reloadPreview()` only on the PERMANENT `cannot-soft-reload`
116
+ * result (no gsap runtime / rebind hook / scopable key / script element, or the
117
+ * re-run threw). The TRANSIENT `verify-failed` is NOT escalated — the live re-run
118
+ * already applied the shift; a remount would re-flash for nothing. When the server
119
+ * returned no `scriptText` (older server, multi-script comp), we also full-reload.
120
+ */
121
+ function syncTimingEditPreview(
122
+ iframe: HTMLIFrameElement | null,
123
+ outcome: Pick<GsapMutationStatus, "scriptText">,
124
+ currentTime: number,
125
+ reloadPreview: () => void,
126
+ ): void {
127
+ if (!iframe || !outcome.scriptText) {
128
+ reloadPreview();
129
+ return;
130
+ }
131
+ const result = applySoftReload(iframe, outcome.scriptText, {
132
+ onAsyncFailure: reloadPreview,
133
+ currentTimeOverride: currentTime,
134
+ });
135
+ if (result === "cannot-soft-reload") reloadPreview();
136
+ }
137
+
138
+ async function finishTimelineTimingFallback(input: {
139
+ iframe: HTMLIFrameElement | null;
140
+ reloadPreview: () => void;
141
+ gsapMutation?: () => Promise<GsapMutationStatus>;
142
+ onGsapError: (error: unknown) => void;
143
+ }): Promise<void> {
144
+ let outcome: GsapMutationStatus = { mutated: false, scriptText: null };
145
+ if (input.gsapMutation) {
146
+ try {
147
+ outcome = await input.gsapMutation();
148
+ } catch (error) {
149
+ input.onGsapError(error);
150
+ return;
151
+ }
152
+ }
153
+ syncTimingEditPreview(
154
+ input.iframe,
155
+ outcome,
156
+ usePlayerStore.getState().currentTime,
157
+ input.reloadPreview,
158
+ );
159
+ }
160
+
161
+ // Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
162
+ // outlast one GSAP server round-trip, never a real second edit.
163
+ const GSAP_HISTORY_COALESCE_MS = 10_000;
164
+
165
+ /**
166
+ * A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
167
+ * timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
168
+ * conflict. This snapshots every touched file, runs the mutation, then records a follow-up
169
+ * edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
170
+ * folding both writes into one undo step. Returns the mutation status for caller reloads.
171
+ *
172
+ * Failure domains are separate: a MUTATION failure propagates (nothing was applied, so
173
+ * the caller must skip the preview sync), but a failure in the history-FOLD step
174
+ * (re-read / recordEdit) after a successful mutation is surfaced via `onFoldError` and
175
+ * the mutation status is still returned — the server rewrite already landed on disk, so
176
+ * the caller must still sync the preview or it shows stale GSAP positions.
177
+ */
178
+ async function foldGsapMutationIntoHistory(input: {
179
+ projectId: string;
180
+ paths: string[];
181
+ label: string;
182
+ coalesceKey?: string;
183
+ recordEdit: (edit: RecordEditInput) => Promise<void>;
184
+ gsapMutation: () => Promise<GsapMutationStatus>;
185
+ onFoldError: (error: unknown) => void;
186
+ }): Promise<GsapMutationStatus> {
187
+ const uniquePaths = [...new Set(input.paths)];
188
+ const before = new Map<string, string>();
189
+ // A `before`-snapshot failure propagates like a mutation failure: the mutation
190
+ // has not run yet, so nothing landed on disk and skipping the sync is correct.
191
+ for (const path of uniquePaths) {
192
+ before.set(path, await readFileContent(input.projectId, path));
193
+ }
194
+ const status = await input.gsapMutation();
195
+ if (status.mutated) {
196
+ try {
197
+ const files: Record<string, { before: string; after: string }> = {};
198
+ for (const path of uniquePaths) {
199
+ const priorContent = before.get(path);
200
+ const finalContent = await readFileContent(input.projectId, path);
201
+ if (priorContent !== undefined && finalContent !== priorContent) {
202
+ files[path] = { before: priorContent, after: finalContent };
203
+ }
204
+ }
205
+ if (Object.keys(files).length > 0) {
206
+ await input.recordEdit({
207
+ label: input.label,
208
+ kind: "timeline",
209
+ coalesceKey: input.coalesceKey,
210
+ coalesceMs: GSAP_HISTORY_COALESCE_MS,
211
+ files,
212
+ });
213
+ }
214
+ } catch (error) {
215
+ input.onFoldError(error);
216
+ }
217
+ }
218
+ return status;
219
+ }
220
+
221
+ /**
222
+ * Shift all GSAP animation positions targeting a given element by a time delta.
223
+ * Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
224
+ * Returns the rewritten script so the caller can soft-reload instead of full-reload.
225
+ */
226
+ export async function shiftGsapPositions(
227
+ projectId: string,
228
+ filePath: string,
229
+ elementId: string,
230
+ delta: number,
231
+ ): Promise<GsapMutationStatus> {
232
+ if (delta === 0 || !elementId) return { mutated: false, scriptText: null };
233
+ const res = await fetch(
234
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
235
+ {
236
+ method: "POST",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify({
239
+ type: "shift-positions",
240
+ targetSelector: `#${elementId}`,
241
+ delta,
242
+ }),
243
+ },
244
+ );
245
+ if (!res.ok) {
246
+ const err = await res.json().catch(() => null);
247
+ throw new Error(readMutationError(err, "shift-positions failed"));
248
+ }
249
+ return readMutationStatus(await res.json().catch(() => null));
250
+ }
251
+
252
+ export async function scaleGsapPositions(
253
+ projectId: string,
254
+ filePath: string,
255
+ elementId: string,
256
+ oldStart: number,
257
+ oldDuration: number,
258
+ newStart: number,
259
+ newDuration: number,
260
+ ): Promise<GsapMutationStatus> {
261
+ if (!elementId || oldDuration <= 0 || newDuration <= 0)
262
+ return { mutated: false, scriptText: null };
263
+ if (oldStart === newStart && oldDuration === newDuration)
264
+ return { mutated: false, scriptText: null };
265
+ const res = await fetch(
266
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
267
+ {
268
+ method: "POST",
269
+ headers: { "Content-Type": "application/json" },
270
+ body: JSON.stringify({
271
+ type: "scale-positions",
272
+ targetSelector: `#${elementId}`,
273
+ oldStart,
274
+ oldDuration,
275
+ newStart,
276
+ newDuration,
277
+ }),
278
+ },
279
+ );
280
+ if (!res.ok) {
281
+ const err = await res.json().catch(() => null);
282
+ throw new Error(readMutationError(err, "scale-positions failed"));
283
+ }
284
+ return readMutationStatus(await res.json().catch(() => null));
285
+ }
286
+
287
+ /** Timing delta a single-clip edit applies to its GSAP tweens. */
288
+ export type SingleClipGsapEdit =
289
+ | { kind: "shift"; delta: number }
290
+ | {
291
+ kind: "scale";
292
+ from: { start: number; duration: number };
293
+ to: { start: number; duration: number };
294
+ };
295
+
296
+ /**
297
+ * Post-persist GSAP sync for a SINGLE-clip timing edit (move / resize): runs the
298
+ * server shift/scale mutation, folds the rewrite into the timing edit's history
299
+ * entry (see foldGsapMutationIntoHistory), then soft-reloads the preview with
300
+ * the rewritten script — full reload when the mutation is skipped, failed, or
301
+ * returned no script.
302
+ */
303
+ export function finishClipTimingFallback(input: {
304
+ iframe: HTMLIFrameElement | null;
305
+ reloadPreview: () => void;
306
+ projectId: string | null;
307
+ targetPath: string;
308
+ domId: string | undefined;
309
+ label: string;
310
+ coalesceKey?: string;
311
+ recordEdit: (edit: RecordEditInput) => Promise<void>;
312
+ edit: SingleClipGsapEdit;
313
+ }): Promise<void> {
314
+ const { projectId, targetPath, domId, edit } = input;
315
+ const timingChanged =
316
+ edit.kind === "shift"
317
+ ? edit.delta !== 0
318
+ : edit.from.start !== edit.to.start || edit.from.duration !== edit.to.duration;
319
+ const runMutation = (pid: string, id: string): Promise<GsapMutationStatus> =>
320
+ edit.kind === "shift"
321
+ ? shiftGsapPositions(pid, targetPath, id, edit.delta)
322
+ : scaleGsapPositions(
323
+ pid,
324
+ targetPath,
325
+ id,
326
+ edit.from.start,
327
+ edit.from.duration,
328
+ edit.to.start,
329
+ edit.to.duration,
330
+ );
331
+ const onGsapError = (err: unknown) =>
332
+ console.error(`[Timeline] Failed to ${edit.kind} GSAP positions`, err);
333
+ return finishTimelineTimingFallback({
334
+ iframe: input.iframe,
335
+ reloadPreview: input.reloadPreview,
336
+ gsapMutation:
337
+ timingChanged && domId && projectId
338
+ ? () =>
339
+ foldGsapMutationIntoHistory({
340
+ projectId,
341
+ paths: [targetPath],
342
+ label: input.label,
343
+ coalesceKey: input.coalesceKey,
344
+ recordEdit: input.recordEdit,
345
+ gsapMutation: () => runMutation(projectId, domId),
346
+ onFoldError: onGsapError,
347
+ })
348
+ : undefined,
349
+ onGsapError,
350
+ });
351
+ }
352
+
353
+ /**
354
+ * Shared post-persist GSAP sync for GROUP timing edits (move / resize): runs the
355
+ * per-change server mutation for every changed clip, folds the rewrites into the
356
+ * timing edit's history entry, and soft-reloads the preview when possible.
357
+ *
358
+ * The preview is a SINGLE shared iframe showing the ACTIVE composition, so only
359
+ * the active comp's rewritten script can be soft-reloaded (swapped in place, no
360
+ * all-clips flash). If any OTHER file changed too — e.g. a sub-comp group in a
361
+ * multi-file move — no scriptText is passed, so the fallback does ONE full
362
+ * reload that reflects every changed file.
363
+ */
364
+ export async function finishGroupTimingGsapFallback<C extends { element: TimelineElement }>(input: {
365
+ projectId: string;
366
+ iframe: HTMLIFrameElement | null;
367
+ reloadPreview: () => void;
368
+ label: string;
369
+ errorLabel: string;
370
+ coalesceKey?: string;
371
+ recordEdit: (edit: RecordEditInput) => Promise<void>;
372
+ activeCompPath: string | null;
373
+ changes: readonly C[];
374
+ resolveChangePath: (element: TimelineElement) => string;
375
+ /** Per-change GSAP mutation; return null to skip a change with no timing delta. */
376
+ mutateChange: (change: C, changePath: string) => Promise<GsapMutationStatus> | null;
377
+ }): Promise<void> {
378
+ const activePath = input.activeCompPath || "index.html";
379
+ const otherFileChanged = input.changes.some(
380
+ (change) => input.resolveChangePath(change.element) !== activePath,
381
+ );
382
+ const onGsapError = (err: unknown) => console.error(`[Timeline] ${input.errorLabel}`, err);
383
+ await finishTimelineTimingFallback({
384
+ iframe: input.iframe,
385
+ reloadPreview: input.reloadPreview,
386
+ gsapMutation: () =>
387
+ foldGsapMutationIntoHistory({
388
+ projectId: input.projectId,
389
+ paths: input.changes.map((change) => input.resolveChangePath(change.element)),
390
+ label: input.label,
391
+ coalesceKey: input.coalesceKey,
392
+ recordEdit: input.recordEdit,
393
+ gsapMutation: async () => {
394
+ let mutated = false;
395
+ let scriptText: GsapMutationStatus["scriptText"] = null;
396
+ for (const change of input.changes) {
397
+ const changePath = input.resolveChangePath(change.element);
398
+ const pending = input.mutateChange(change, changePath);
399
+ if (!pending) continue;
400
+ const status = await pending;
401
+ mutated = mutated || status.mutated;
402
+ // The LAST mutation against the active comp carries the cumulative
403
+ // rewritten script for that file.
404
+ if (changePath === activePath) scriptText = status.scriptText;
405
+ }
406
+ return { mutated, scriptText: otherFileChanged ? null : scriptText };
407
+ },
408
+ onFoldError: onGsapError,
409
+ }),
410
+ onGsapError,
411
+ });
412
+ }
@@ -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
  };
@@ -1,6 +1,33 @@
1
1
  import { useState } from "react";
2
2
  import { useMountEffect } from "./useMountEffect";
3
3
  import type { CompositionDimensions } from "../components/renders/RenderQueue";
4
+ import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
5
+
6
+ function readCompositionSizeMessage(data: unknown): CompositionDimensions | null {
7
+ if (!isStageSizeMessage(data)) return null;
8
+ const message = data;
9
+ if (!acceptStudioRuntimeMessage(message)) return null;
10
+ return readPositiveDimensions(message.width, message.height);
11
+ }
12
+
13
+ function isStageSizeMessage(value: unknown): value is Record<string, unknown> {
14
+ if (typeof value !== "object") return false;
15
+ if (value === null) return false;
16
+ const message = value as Record<string, unknown>;
17
+ return message.source === "hf-preview" && message.type === "stage-size";
18
+ }
19
+
20
+ function readPositiveNumber(value: unknown): number | null {
21
+ if (typeof value !== "number") return null;
22
+ return Number.isFinite(value) && value > 0 ? value : null;
23
+ }
24
+
25
+ function readPositiveDimensions(width: unknown, height: unknown): CompositionDimensions | null {
26
+ const parsedWidth = readPositiveNumber(width);
27
+ const parsedHeight = readPositiveNumber(height);
28
+ if (parsedWidth === null || parsedHeight === null) return null;
29
+ return { width: parsedWidth, height: parsedHeight };
30
+ }
4
31
 
5
32
  export function useCompositionDimensions() {
6
33
  const [compositionDimensions, setCompositionDimensions] = useState<CompositionDimensions | null>(
@@ -9,12 +36,12 @@ export function useCompositionDimensions() {
9
36
 
10
37
  useMountEffect(() => {
11
38
  const handleMessage = (e: MessageEvent) => {
12
- const data = e.data;
13
- if (data?.source !== "hf-preview" || data?.type !== "stage-size") return;
14
- const { width, height } = data as { width: number; height: number };
15
- if (!(width > 0) || !(height > 0)) return;
39
+ const dimensions = readCompositionSizeMessage(e.data);
40
+ if (!dimensions) return;
16
41
  setCompositionDimensions((prev) =>
17
- prev && prev.width === width && prev.height === height ? prev : { width, height },
42
+ prev && prev.width === dimensions.width && prev.height === dimensions.height
43
+ ? prev
44
+ : dimensions,
18
45
  );
19
46
  };
20
47
  window.addEventListener("message", handleMessage);