@hyperframes/studio 0.6.110 → 0.6.111

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/index-B7S86vK1.js +370 -0
  2. package/dist/assets/index-DP8pPIk2.css +1 -0
  3. package/dist/assets/{index-CfiyaR7G.js → index-_IV-vm9l.js} +1 -1
  4. package/dist/assets/{index-CXy_mWnP.js → index-x0c2-zQN.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +8 -5
  7. package/src/App.tsx +133 -141
  8. package/src/components/StudioHeader.tsx +44 -0
  9. package/src/components/StudioOverlays.tsx +70 -0
  10. package/src/components/editor/SourceEditor.tsx +19 -16
  11. package/src/components/editor/manualEditingAvailability.ts +25 -7
  12. package/src/components/storyboard/FramePoster.tsx +56 -0
  13. package/src/components/storyboard/StoryboardDirection.tsx +45 -0
  14. package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
  15. package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
  16. package/src/components/storyboard/StoryboardGrid.tsx +33 -0
  17. package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
  18. package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
  19. package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
  20. package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
  21. package/src/components/storyboard/StoryboardView.tsx +78 -0
  22. package/src/components/storyboard/frameStatus.ts +36 -0
  23. package/src/contexts/ViewModeContext.tsx +98 -0
  24. package/src/hooks/gsapScriptCommitTypes.ts +4 -7
  25. package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
  26. package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
  27. package/src/hooks/timelineEditingHelpers.ts +2 -0
  28. package/src/hooks/useAppHotkeys.ts +20 -0
  29. package/src/hooks/useDomEditCommits.ts +43 -14
  30. package/src/hooks/useDomEditSession.test.ts +41 -0
  31. package/src/hooks/useDomEditSession.ts +38 -6
  32. package/src/hooks/useDomGeometryCommits.ts +5 -9
  33. package/src/hooks/useElementLifecycleOps.ts +30 -0
  34. package/src/hooks/useGsapAnimationOps.ts +78 -51
  35. package/src/hooks/useGsapKeyframeOps.ts +127 -43
  36. package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
  37. package/src/hooks/useGsapPropertyDebounce.ts +201 -23
  38. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
  39. package/src/hooks/useGsapScriptCommits.ts +95 -40
  40. package/src/hooks/useSdkSession.test.ts +12 -0
  41. package/src/hooks/useSdkSession.ts +91 -25
  42. package/src/hooks/useStoryboard.ts +80 -0
  43. package/src/hooks/useTimelineEditing.ts +163 -68
  44. package/src/utils/gsapSoftReload.ts +14 -0
  45. package/src/utils/sdkCutover.gate.test.ts +61 -0
  46. package/src/utils/sdkCutover.test.ts +839 -0
  47. package/src/utils/sdkCutover.ts +465 -0
  48. package/src/utils/sdkOpMapping.ts +43 -0
  49. package/src/utils/sdkResolverShadow.test.ts +366 -0
  50. package/src/utils/sdkResolverShadow.ts +313 -0
  51. package/dist/assets/index-1C8oFiPi.css +0 -1
  52. package/dist/assets/index-D-3sGz65.js +0 -296
  53. package/src/utils/sdkShadow.test.ts +0 -606
  54. package/src/utils/sdkShadow.ts +0 -517
  55. package/src/utils/sdkShadowGsapFidelity.ts +0 -296
  56. package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
  57. package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
  58. package/src/utils/sdkShadowNumeric.ts +0 -11
@@ -1,8 +1,10 @@
1
- import { useState, useEffect } from "react";
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import type { MutableRefObject } from "react";
2
3
  import { openComposition } from "@hyperframes/sdk";
3
4
  import { createHttpAdapter } from "@hyperframes/sdk/adapters/http";
4
5
  import type { Composition } from "@hyperframes/sdk";
5
6
  import { readStudioFileChangePath } from "../components/editor/manualEdits";
7
+ import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
6
8
 
7
9
  /**
8
10
  * True when an external file-change payload targets the active composition and
@@ -20,33 +22,88 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
20
22
  * (projectId, activeCompPath) change, disposes the old one on cleanup, and
21
23
  * re-opens it when the active composition file changes on disk (code editor,
22
24
  * agent, or server-side patch) so the in-memory linkedom document never goes
23
- * stale.
24
- *
25
- * Opened WITHOUT a persist queue: this session is shadow-telemetry +
26
- * selection-sync only it reads from the server but must NEVER write back.
27
- * Shadow dispatch ops mutate the in-memory model and are discarded on the next
28
- * reload-on-change (the studio's own authoritative write triggers it). Routing
29
- * authoritative writes through this session (cutover, Step 3c+) must re-add
30
- * persist TOGETHER WITH self-write suppression — without it, the SDK's
31
- * serialize() output races and clobbers the studio's authoritative write.
25
+ * stale. The session has NO persist queue — Studio is the sole file writer; see
26
+ * the open effect below.
27
+ */
28
+ // Reload-suppression baseline: a file-change within this window of our own SDK
29
+ // cutover write is a CANDIDATE echo, but the decision is content-identity based
30
+ // (isSelfWriteEcho) not time-only so an undo write that lands inside the window
31
+ // still reloads (its reverted bytes were never registered as a self-write). The
32
+ // window only bounds how long a registered self-write stays suppressible.
33
+ const SELF_WRITE_SUPPRESS_MS = 2000;
34
+
35
+ /** Best-effort read of the changed file's content from a file-change payload. */
36
+ function readFileChangeContent(payload: unknown): string | null {
37
+ if (!payload || typeof payload !== "object") return null;
38
+ const record = payload as Record<string, unknown>;
39
+ if (typeof record.content === "string") return record.content;
40
+ if ("data" in record) return readFileChangeContent(record.data);
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Decide whether a file-change for the active composition should reload the SDK
46
+ * session. `content` is the new on-disk bytes (from the payload or a re-read);
47
+ * pass null when unavailable. Content-identity wins: a change whose bytes match a
48
+ * registered self-write is our own echo (suppress). Without content we can't prove
49
+ * identity, so we fall back to the time window ONLY to suppress an echo — an undo
50
+ * write outside the window (or any non-self-write) still reloads. Exported for test.
32
51
  */
52
+ export function shouldReloadOnFileChange(
53
+ activeCompPath: string,
54
+ content: string | null,
55
+ withinSuppressWindow: boolean,
56
+ ): boolean {
57
+ if (content != null) return !isSelfWriteEcho(activeCompPath, content);
58
+ // No content to compare — preserve the old time-window echo suppression.
59
+ return !withinSuppressWindow;
60
+ }
61
+
62
+ export interface SdkSessionHandle {
63
+ session: Composition | null;
64
+ /**
65
+ * Force a session reload immediately, bypassing the self-write suppress
66
+ * window. Call after undo/redo writes the active composition file so the
67
+ * SDK in-memory document reflects the reverted content.
68
+ */
69
+ forceReload: () => void;
70
+ }
71
+
33
72
  export function useSdkSession(
34
73
  projectId: string | null,
35
74
  activeCompPath: string | null,
36
- ): Composition | null {
75
+ domEditSaveTimestampRef?: MutableRefObject<number>,
76
+ ): SdkSessionHandle {
37
77
  const [session, setSession] = useState<Composition | null>(null);
38
78
  const [reloadToken, setReloadToken] = useState(0);
39
79
 
40
80
  // ── Re-open on external change to the active composition ──
41
81
  useEffect(() => {
42
82
  if (!activeCompPath) return;
43
- // Pre-existing clone of the file-change reload handler (usePreviewPersistence);
44
- // surfaced by this PR's adjacent edits, not introduced by it.
45
- // fallow-ignore-next-line code-duplication
83
+ const compPath = activeCompPath;
84
+ const readAdapter =
85
+ projectId != null
86
+ ? createHttpAdapter({ projectFilesUrl: `/api/projects/${projectId}` })
87
+ : null;
46
88
  const handler = (payload?: unknown) => {
47
- if (shouldReloadSdkSession(payload, activeCompPath)) {
48
- setReloadToken((t) => t + 1);
89
+ if (!shouldReloadSdkSession(payload, compPath)) return;
90
+ const withinWindow =
91
+ !!domEditSaveTimestampRef &&
92
+ Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS;
93
+ const decide = (content: string | null) => {
94
+ if (shouldReloadOnFileChange(compPath, content, withinWindow)) setReloadToken((t) => t + 1);
95
+ };
96
+ const payloadContent = readFileChangeContent(payload);
97
+ // Prefer payload content; otherwise re-read so the decision is by IDENTITY
98
+ // (an undo's reverted bytes won't match a registered self-write → reload).
99
+ if (payloadContent != null || !readAdapter) {
100
+ decide(payloadContent);
101
+ return;
49
102
  }
103
+ readAdapter
104
+ .read(compPath)
105
+ .then((c) => decide(typeof c === "string" ? c : null))
106
+ .catch(() => decide(null));
50
107
  };
51
108
  if (import.meta.hot) {
52
109
  import.meta.hot.on("hf:file-change", handler);
@@ -56,7 +113,8 @@ export function useSdkSession(
56
113
  const es = new EventSource("/api/events");
57
114
  es.addEventListener("file-change", handler);
58
115
  return () => es.close();
59
- }, [activeCompPath]);
116
+ // eslint-disable-next-line react-hooks/exhaustive-deps
117
+ }, [activeCompPath, projectId]);
60
118
 
61
119
  // ── Open / re-open the session ──
62
120
  useEffect(() => {
@@ -66,7 +124,7 @@ export function useSdkSession(
66
124
  }
67
125
 
68
126
  let cancelled = false;
69
- let comp: Composition | null = null;
127
+ const compRef = { current: null as Composition | null };
70
128
 
71
129
  const adapter = createHttpAdapter({
72
130
  projectFilesUrl: `/api/projects/${projectId}`,
@@ -75,15 +133,21 @@ export function useSdkSession(
75
133
  .read(activeCompPath)
76
134
  .then(async (content) => {
77
135
  if (cancelled || typeof content !== "string") return;
78
- // No persist shadow/selection only; see the hook docstring. The SDK
79
- // must not write back to the server while it shadows the authoritative
80
- // studio path.
81
- comp = await openComposition(content);
136
+ // No persist queue: Studio's writeProjectFile (via sdkCutover's
137
+ // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
138
+ // queue too would double-write the file (queue auto-writes on every
139
+ // 'change' AND Studio writes explicitly) and race on disk; it would
140
+ // also write the full active-composition serialization to the fixed
141
+ // persistPath even when an edit targeted a sub-composition file.
142
+ // Studio's editHistory is the authoritative undo stack — SDK history
143
+ // is unused dead weight here (forceReloadSdkSession discards it on undo).
144
+ const comp = await openComposition(content, { history: false });
82
145
  // Cleanup may have fired while openComposition was awaited; dispose immediately.
83
146
  if (cancelled) {
84
147
  comp.dispose();
85
148
  return;
86
149
  }
150
+ compRef.current = comp;
87
151
  setSession(comp);
88
152
  })
89
153
  .catch(() => {
@@ -92,10 +156,12 @@ export function useSdkSession(
92
156
 
93
157
  return () => {
94
158
  cancelled = true;
95
- const c = comp;
96
- if (c) void c.flush().finally(() => c.dispose());
159
+ // No queue to flush; dispose only. (Flushing here would serialize the
160
+ // pre-undo in-memory doc and race the revert write on undo/redo reload.)
161
+ compRef.current?.dispose();
97
162
  };
98
163
  }, [projectId, activeCompPath, reloadToken]);
99
164
 
100
- return session;
165
+ const forceReload = useCallback(() => setReloadToken((t) => t + 1), []);
166
+ return { session, forceReload };
101
167
  }
@@ -0,0 +1,80 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import type {
3
+ StoryboardFrame,
4
+ StoryboardGlobals,
5
+ StoryboardWarning,
6
+ } from "@hyperframes/core/storyboard";
7
+ import { buildProjectApiPath } from "../utils/projectRouting";
8
+
9
+ /** A frame as returned by the API: parsed frame + disk-resolution info. */
10
+ export interface StoryboardFrameView extends StoryboardFrame {
11
+ /** Whether `src` resolves to an existing file inside the project. */
12
+ srcExists: boolean;
13
+ }
14
+
15
+ /** The companion narration script (SCRIPT.md), when present alongside the storyboard. */
16
+ export interface StoryboardScript {
17
+ exists: boolean;
18
+ path: string;
19
+ content: string;
20
+ }
21
+
22
+ /** Shape of `GET /api/projects/:id/storyboard`. */
23
+ export interface StoryboardResponse {
24
+ exists: boolean;
25
+ path: string;
26
+ globals: StoryboardGlobals;
27
+ frames: StoryboardFrameView[];
28
+ warnings: StoryboardWarning[];
29
+ script?: StoryboardScript;
30
+ }
31
+
32
+ export interface UseStoryboardResult {
33
+ data: StoryboardResponse | null;
34
+ loading: boolean;
35
+ error: string | null;
36
+ reload: () => void;
37
+ }
38
+
39
+ /**
40
+ * Load the parsed storyboard manifest for a project. Markdown stays canonical on
41
+ * disk; this fetches the server-derived JSON the storyboard view renders.
42
+ */
43
+ export function useStoryboard(projectId: string | null): UseStoryboardResult {
44
+ const [data, setData] = useState<StoryboardResponse | null>(null);
45
+ const [loading, setLoading] = useState(true);
46
+ const [error, setError] = useState<string | null>(null);
47
+ const [reloadKey, setReloadKey] = useState(0);
48
+
49
+ const reload = useCallback(() => setReloadKey((k) => k + 1), []);
50
+
51
+ useEffect(() => {
52
+ if (!projectId) return;
53
+ let cancelled = false;
54
+ setLoading(true);
55
+ setError(null);
56
+
57
+ // Route through buildProjectApiPath so the (URL-derived) projectId is encoded
58
+ // into the path rather than interpolated raw (CodeQL js/client-side-request-forgery).
59
+ fetch(buildProjectApiPath(projectId, "/storyboard"))
60
+ .then((res) => {
61
+ if (!res.ok) throw new Error(`storyboard request failed: ${res.status}`);
62
+ return res.json() as Promise<StoryboardResponse>;
63
+ })
64
+ .then((json) => {
65
+ if (!cancelled) setData(json);
66
+ })
67
+ .catch((err: unknown) => {
68
+ if (!cancelled) setError(err instanceof Error ? err.message : "failed to load storyboard");
69
+ })
70
+ .finally(() => {
71
+ if (!cancelled) setLoading(false);
72
+ });
73
+
74
+ return () => {
75
+ cancelled = true;
76
+ };
77
+ }, [projectId, reloadKey]);
78
+
79
+ return { data, loading, error, reload };
80
+ }
@@ -1,11 +1,7 @@
1
1
  // Pre-existing-complex timeline hook (DOM patch + GSAP position shift/scale +
2
- // playback-start resolution); this PR adds guarded shadow-timing dispatches in
3
- // the move/resize .then() chains, which nudges several callbacks over the CC
4
- // threshold. The added branches are telemetry-only.
2
+ // playback-start resolution).
5
3
  // fallow-ignore-file complexity
6
4
  import { useCallback, useRef } from "react";
7
- import type { Composition } from "@hyperframes/sdk";
8
- import { runShadowDelete, runShadowTiming } from "../utils/sdkShadow";
9
5
  import type { TimelineElement } from "../player";
10
6
  import { usePlayerStore } from "../player";
11
7
  import { useRazorSplit } from "./useRazorSplit";
@@ -37,6 +33,8 @@ import {
37
33
  scaleGsapPositions,
38
34
  } from "./timelineEditingHelpers";
39
35
  import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
36
+ import { sdkTimingPersist } from "../utils/sdkCutover";
37
+ import type { Composition } from "@hyperframes/sdk";
40
38
 
41
39
  // ── Types ──
42
40
 
@@ -60,8 +58,10 @@ interface UseTimelineEditingOptions {
60
58
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
61
59
  uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
62
60
  isRecordingRef?: React.RefObject<boolean>;
63
- /** Stage 7 Step 3b: SDK session for shadow timing dispatch (server stays authoritative). */
61
+ /** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
64
62
  sdkSession?: Composition | null;
63
+ /** Resync the SDK session after a server-authoritative timeline write. */
64
+ forceReloadSdkSession?: () => void;
65
65
  }
66
66
 
67
67
  // ── Hook ──
@@ -80,6 +80,7 @@ export function useTimelineEditing({
80
80
  uploadProjectFiles,
81
81
  isRecordingRef,
82
82
  sdkSession,
83
+ forceReloadSdkSession,
83
84
  }: UseTimelineEditingOptions) {
84
85
  const projectIdRef = useRef(projectId);
85
86
  projectIdRef.current = projectId;
@@ -92,6 +93,7 @@ export function useTimelineEditing({
92
93
  element: TimelineElement,
93
94
  label: string,
94
95
  buildPatches: PersistTimelineEditInput["buildPatches"],
96
+ coalesceKey?: string,
95
97
  ): Promise<void> => {
96
98
  if (isRecordingRef?.current) {
97
99
  showToast("Cannot edit timeline while recording", "error");
@@ -99,19 +101,25 @@ export function useTimelineEditing({
99
101
  }
100
102
  const pid = projectIdRef.current;
101
103
  if (!pid) return Promise.resolve();
102
- const queued = editQueueRef.current.then(() =>
103
- persistTimelineEdit({
104
- projectId: pid,
105
- element,
106
- activeCompPath,
107
- label,
108
- buildPatches,
109
- writeProjectFile,
110
- recordEdit,
111
- domEditSaveTimestampRef,
112
- pendingTimelineEditPathRef,
113
- }),
114
- );
104
+ const queued = editQueueRef.current
105
+ .then(() =>
106
+ persistTimelineEdit({
107
+ projectId: pid,
108
+ element,
109
+ activeCompPath,
110
+ label,
111
+ buildPatches,
112
+ writeProjectFile,
113
+ recordEdit,
114
+ domEditSaveTimestampRef,
115
+ pendingTimelineEditPathRef,
116
+ coalesceKey,
117
+ }),
118
+ )
119
+ .then(() => {
120
+ // Server wrote the file; resync the stale in-memory SDK doc.
121
+ forceReloadSdkSession?.();
122
+ });
115
123
  editQueueRef.current = queued.catch((error) => {
116
124
  console.error(`[Timeline] Failed to persist: ${label}`, error);
117
125
  });
@@ -125,18 +133,20 @@ export function useTimelineEditing({
125
133
  pendingTimelineEditPathRef,
126
134
  showToast,
127
135
  isRecordingRef,
136
+ forceReloadSdkSession,
128
137
  ],
129
138
  );
130
139
 
140
+ // fallow-ignore-next-line complexity
131
141
  const handleTimelineElementMove = useCallback(
142
+ // fallow-ignore-next-line complexity
132
143
  (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
133
144
  patchIframeDomTiming(previewIframeRef.current, element, [
134
145
  ["data-start", formatTimelineAttributeNumber(updates.start)],
135
146
  ["data-track-index", String(updates.track)],
136
147
  ]);
137
- const delta = updates.start - element.start;
138
- const filePath = element.sourceFile || activeCompPath || "index.html";
139
- return enqueueEdit(element, "Move timeline clip", (original, target) => {
148
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
149
+ const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
140
150
  let patched = applyPatchByTarget(original, target, {
141
151
  type: "attribute",
142
152
  property: "start",
@@ -147,24 +157,63 @@ export function useTimelineEditing({
147
157
  property: "track-index",
148
158
  value: String(updates.track),
149
159
  });
150
- }).then(() => {
151
- if (sdkSession)
152
- runShadowTiming(sdkSession, element.hfId, {
153
- start: updates.start,
154
- trackIndex: updates.track,
155
- });
156
- const pid = projectIdRef.current;
157
- if (delta !== 0 && element.domId && pid) {
158
- return shiftGsapPositions(pid, filePath, element.domId, delta)
159
- .then(() => reloadPreview())
160
- .catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
161
- }
162
- });
160
+ };
161
+ // Server-path fallback (no SDK session): persist the attr patch, then
162
+ // shift GSAP tween positions on the server and reload the preview — the
163
+ // SDK path folds both into setTiming, but the fallback must do them
164
+ // explicitly or the clip moves while its GSAP tweens stay put + the
165
+ // preview never refreshes. coalesceKey mirrors the SDK branch so undo
166
+ // granularity is identical on either path.
167
+ const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
168
+ const moveFallback = () =>
169
+ enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => {
170
+ const pid = projectIdRef.current;
171
+ const delta = updates.start - element.start;
172
+ if (delta !== 0 && element.domId && pid) {
173
+ return shiftGsapPositions(pid, targetPath, element.domId, delta)
174
+ .then(() => reloadPreview())
175
+ .catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
176
+ }
177
+ return reloadPreview();
178
+ });
179
+ if (sdkSession && element.hfId) {
180
+ return sdkTimingPersist(
181
+ element.hfId,
182
+ targetPath,
183
+ { start: updates.start, trackIndex: updates.track },
184
+ sdkSession,
185
+ {
186
+ editHistory: { recordEdit },
187
+ writeProjectFile,
188
+ reloadPreview,
189
+ domEditSaveTimestampRef,
190
+ compositionPath: activeCompPath,
191
+ // Capture on-disk bytes as the undo `before` so undoing a timing move
192
+ // restores the file verbatim, not a normalized full-DOM re-emit.
193
+ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
194
+ },
195
+ { label: "Move timeline clip", coalesceKey },
196
+ ).then((handled) => {
197
+ if (!handled) return moveFallback();
198
+ });
199
+ }
200
+ return moveFallback();
163
201
  },
164
- [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession],
202
+ [
203
+ previewIframeRef,
204
+ enqueueEdit,
205
+ activeCompPath,
206
+ sdkSession,
207
+ recordEdit,
208
+ writeProjectFile,
209
+ reloadPreview,
210
+ domEditSaveTimestampRef,
211
+ ],
165
212
  );
166
213
 
214
+ // fallow-ignore-next-line complexity
167
215
  const handleTimelineElementResize = useCallback(
216
+ // fallow-ignore-next-line complexity
168
217
  (
169
218
  element: TimelineElement,
170
219
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
@@ -173,6 +222,9 @@ export function useTimelineEditing({
173
222
  ["data-start", formatTimelineAttributeNumber(updates.start)],
174
223
  ["data-duration", formatTimelineAttributeNumber(updates.duration)],
175
224
  ];
225
+ // Patch the live playback-start/media-start attr too, or a resize that
226
+ // trims the playback start leaves the preview showing the old in-point
227
+ // until the next reload (the persisted patch handles it via pbs below).
176
228
  if (updates.playbackStart != null) {
177
229
  const liveAttr =
178
230
  element.playbackStartAttr === "playback-start"
@@ -181,10 +233,8 @@ export function useTimelineEditing({
181
233
  liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
182
234
  }
183
235
  patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
184
- const filePath = element.sourceFile || activeCompPath || "index.html";
185
- const timingChanged =
186
- updates.start !== element.start || updates.duration !== element.duration;
187
- return enqueueEdit(element, "Resize timeline clip", (original, target) => {
236
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
237
+ const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
188
238
  const pbs = resolveResizePlaybackStart(original, target, element, updates);
189
239
  let patched = applyPatchByTarget(original, target, {
190
240
  type: "attribute",
@@ -204,34 +254,77 @@ export function useTimelineEditing({
204
254
  });
205
255
  }
206
256
  return patched;
207
- }).then(() => {
208
- if (sdkSession)
209
- runShadowTiming(sdkSession, element.hfId, {
210
- start: updates.start,
211
- duration: updates.duration,
212
- });
213
- const pid = projectIdRef.current;
214
- if (timingChanged && element.domId && pid) {
215
- return scaleGsapPositions(
216
- pid,
217
- filePath,
218
- element.domId,
219
- element.start,
220
- element.duration,
221
- updates.start,
222
- updates.duration,
223
- )
224
- .then(() => reloadPreview())
225
- .catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err));
226
- }
227
- return reloadPreview();
228
- });
257
+ };
258
+ // SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field).
259
+ // The second clause fires because trimming the start of a clip that has a
260
+ // playback-start attribute implicitly shifts that in-point — which the SDK
261
+ // setTiming op can't express — so those resizes must take the server path.
262
+ const hasPbsAdjustment =
263
+ updates.playbackStart != null ||
264
+ (updates.start !== element.start && element.playbackStart != null);
265
+ // Server-path fallback: after persisting the attr patch, scale GSAP tween
266
+ // positions/durations on the server and reload the preview. The SDK path
267
+ // folds both into setTiming; the fallback must do them explicitly or the
268
+ // clip resizes while its GSAP tweens keep their old timing + the preview
269
+ // never refreshes. coalesceKey mirrors the SDK branch for undo parity.
270
+ const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
271
+ const timingChanged =
272
+ updates.start !== element.start || updates.duration !== element.duration;
273
+ const resizeFallback = () =>
274
+ enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => {
275
+ const pid = projectIdRef.current;
276
+ if (timingChanged && element.domId && pid) {
277
+ return scaleGsapPositions(
278
+ pid,
279
+ targetPath,
280
+ element.domId,
281
+ element.start,
282
+ element.duration,
283
+ updates.start,
284
+ updates.duration,
285
+ )
286
+ .then(() => reloadPreview())
287
+ .catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err));
288
+ }
289
+ return reloadPreview();
290
+ });
291
+ if (sdkSession && element.hfId && !hasPbsAdjustment) {
292
+ return sdkTimingPersist(
293
+ element.hfId,
294
+ targetPath,
295
+ { start: updates.start, duration: updates.duration },
296
+ sdkSession,
297
+ {
298
+ editHistory: { recordEdit },
299
+ writeProjectFile,
300
+ reloadPreview,
301
+ domEditSaveTimestampRef,
302
+ compositionPath: activeCompPath,
303
+ // Capture on-disk bytes as the undo `before` so undoing a timing
304
+ // resize restores the file verbatim, not a normalized full-DOM re-emit.
305
+ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
306
+ },
307
+ { label: "Resize timeline clip", coalesceKey },
308
+ ).then((handled) => {
309
+ if (!handled) return resizeFallback();
310
+ });
311
+ }
312
+ return resizeFallback();
229
313
  },
230
- [previewIframeRef, enqueueEdit, activeCompPath, reloadPreview, sdkSession],
314
+ [
315
+ previewIframeRef,
316
+ enqueueEdit,
317
+ activeCompPath,
318
+ sdkSession,
319
+ recordEdit,
320
+ writeProjectFile,
321
+ reloadPreview,
322
+ domEditSaveTimestampRef,
323
+ ],
231
324
  );
232
325
 
326
+ // fallow-ignore-next-line complexity
233
327
  const handleTimelineElementDelete = useCallback(
234
- // Pre-existing handler complexity, unchanged by this PR.
235
328
  // fallow-ignore-next-line complexity
236
329
  async (element: TimelineElement) => {
237
330
  if (isRecordingRef?.current) {
@@ -287,8 +380,8 @@ export function useTimelineEditing({
287
380
  timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)),
288
381
  );
289
382
  usePlayerStore.getState().setSelectedElementId(null);
383
+ forceReloadSdkSession?.();
290
384
  reloadPreview();
291
- if (sdkSession) runShadowDelete(sdkSession, element.hfId);
292
385
  showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
293
386
  } catch (error) {
294
387
  const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
@@ -304,12 +397,12 @@ export function useTimelineEditing({
304
397
  domEditSaveTimestampRef,
305
398
  reloadPreview,
306
399
  isRecordingRef,
307
- sdkSession,
400
+ forceReloadSdkSession,
308
401
  ],
309
402
  );
310
403
 
404
+ // fallow-ignore-next-line complexity
311
405
  const handleTimelineAssetDrop = useCallback(
312
- // Pre-existing handler complexity, unchanged by this PR.
313
406
  // fallow-ignore-next-line complexity
314
407
  async (
315
408
  assetPath: string,
@@ -373,6 +466,7 @@ export function useTimelineEditing({
373
466
  recordEdit,
374
467
  });
375
468
 
469
+ forceReloadSdkSession?.();
376
470
  reloadPreview();
377
471
  } catch (error) {
378
472
  const message =
@@ -389,11 +483,12 @@ export function useTimelineEditing({
389
483
  domEditSaveTimestampRef,
390
484
  reloadPreview,
391
485
  isRecordingRef,
486
+ forceReloadSdkSession,
392
487
  ],
393
488
  );
394
489
 
490
+ // fallow-ignore-next-line complexity
395
491
  const handleTimelineFileDrop = useCallback(
396
- // Pre-existing handler complexity, unchanged by this PR.
397
492
  // fallow-ignore-next-line complexity
398
493
  async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
399
494
  if (isRecordingRef?.current) {
@@ -31,6 +31,19 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
31
31
  return results;
32
32
  }
33
33
 
34
+ /**
35
+ * Extract the GSAP timeline script text from a serialized HTML document, for
36
+ * feeding into applySoftReload. Returns null when zero or multiple GSAP scripts
37
+ * are present (ambiguous — caller should fall back to a full reload), matching
38
+ * applySoftReload's own single-script requirement.
39
+ */
40
+ export function extractGsapScriptText(html: string): string | null {
41
+ const doc = new DOMParser().parseFromString(html, "text/html");
42
+ const scripts = findGsapScriptElements(doc);
43
+ if (scripts.length !== 1) return null;
44
+ return scripts[0].textContent || null;
45
+ }
46
+
34
47
  /** Check that the new script repopulated __timelines with at least one entry. */
35
48
  function verifyTimelinesPopulated(win: IframeWindow): boolean {
36
49
  const tlKeys = win.__timelines
@@ -73,6 +86,7 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
73
86
  // full iframe reload that destroys the very WebGL context we're preserving.
74
87
  let deferredToAsync = false;
75
88
 
89
+ // fallow-ignore-next-line complexity
76
90
  const doReload = () => {
77
91
  const timelines = win.__timelines;
78
92
  const allTargets: Element[] = [];