@hyperframes/studio 0.6.109 → 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 (66) hide show
  1. package/dist/assets/hyperframes-player-BRjQ5a5_.js +418 -0
  2. package/dist/assets/index-B7S86vK1.js +370 -0
  3. package/dist/assets/index-DP8pPIk2.css +1 -0
  4. package/dist/assets/{index-SlMJloLR.js → index-_IV-vm9l.js} +1 -1
  5. package/dist/assets/{index-CFCe0Xvn.js → index-x0c2-zQN.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/package.json +8 -5
  8. package/src/App.tsx +133 -141
  9. package/src/components/StudioHeader.tsx +44 -0
  10. package/src/components/StudioOverlays.tsx +70 -0
  11. package/src/components/editor/SourceEditor.tsx +19 -16
  12. package/src/components/editor/gsapAnimationConstants.ts +24 -0
  13. package/src/components/editor/gsapAnimationHelpers.test.ts +29 -0
  14. package/src/components/editor/manualEditingAvailability.ts +25 -7
  15. package/src/components/storyboard/FramePoster.tsx +56 -0
  16. package/src/components/storyboard/StoryboardDirection.tsx +45 -0
  17. package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
  18. package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
  19. package/src/components/storyboard/StoryboardGrid.tsx +33 -0
  20. package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
  21. package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
  22. package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
  23. package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
  24. package/src/components/storyboard/StoryboardView.tsx +78 -0
  25. package/src/components/storyboard/frameStatus.ts +36 -0
  26. package/src/contexts/ViewModeContext.tsx +98 -0
  27. package/src/hooks/gsapScriptCommitTypes.ts +4 -7
  28. package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
  29. package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
  30. package/src/hooks/timelineEditingHelpers.ts +2 -0
  31. package/src/hooks/useAppHotkeys.ts +20 -0
  32. package/src/hooks/useDomEditCommits.ts +43 -14
  33. package/src/hooks/useDomEditSession.test.ts +41 -0
  34. package/src/hooks/useDomEditSession.ts +38 -6
  35. package/src/hooks/useDomGeometryCommits.ts +5 -9
  36. package/src/hooks/useElementLifecycleOps.ts +30 -0
  37. package/src/hooks/useGsapAnimationOps.ts +78 -51
  38. package/src/hooks/useGsapKeyframeOps.ts +127 -43
  39. package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
  40. package/src/hooks/useGsapPropertyDebounce.ts +201 -23
  41. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
  42. package/src/hooks/useGsapScriptCommits.ts +95 -40
  43. package/src/hooks/useGsapTweenCache.ts +0 -27
  44. package/src/hooks/useRazorSplit.ts +4 -10
  45. package/src/hooks/useSdkSession.test.ts +12 -0
  46. package/src/hooks/useSdkSession.ts +91 -25
  47. package/src/hooks/useStoryboard.ts +80 -0
  48. package/src/hooks/useTimelineEditing.ts +163 -68
  49. package/src/utils/gsapSoftReload.ts +14 -0
  50. package/src/utils/sdkCutover.gate.test.ts +61 -0
  51. package/src/utils/sdkCutover.test.ts +839 -0
  52. package/src/utils/sdkCutover.ts +465 -0
  53. package/src/utils/sdkOpMapping.ts +43 -0
  54. package/src/utils/sdkResolverShadow.test.ts +366 -0
  55. package/src/utils/sdkResolverShadow.ts +313 -0
  56. package/src/utils/timelineElementSplit.test.ts +61 -1
  57. package/src/utils/timelineElementSplit.ts +18 -0
  58. package/dist/assets/hyperframes-player-67pq7USK.js +0 -418
  59. package/dist/assets/index-BVqybwMG.css +0 -1
  60. package/dist/assets/index-ho_f4axK.js +0 -296
  61. package/src/utils/sdkShadow.test.ts +0 -606
  62. package/src/utils/sdkShadow.ts +0 -517
  63. package/src/utils/sdkShadowGsapFidelity.ts +0 -296
  64. package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
  65. package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
  66. package/src/utils/sdkShadowNumeric.ts +0 -11
@@ -0,0 +1,85 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act, useState } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { useGsapPropertyDebounce } from "./useGsapPropertyDebounce";
6
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
+
8
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ // The SDK path is gated on STUDIO_SDK_CUTOVER_ENABLED; keep it OFF so the flush
11
+ // routes through commitMutationSafely (the spy we count), keeping the test about
12
+ // flush TIMING, not the SDK write path.
13
+ vi.mock("../components/editor/manualEditingAvailability", () => ({
14
+ STUDIO_SDK_CUTOVER_ENABLED: false,
15
+ }));
16
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
17
+
18
+ const selection = { sourceFile: "index.html" } as unknown as DomEditSelection;
19
+
20
+ describe("useGsapPropertyDebounce flush stability (finding #7)", () => {
21
+ let container: HTMLDivElement;
22
+ beforeEach(() => {
23
+ vi.useFakeTimers();
24
+ container = document.createElement("div");
25
+ document.body.appendChild(container);
26
+ });
27
+ afterEach(() => {
28
+ vi.useRealTimers();
29
+ container.remove();
30
+ });
31
+
32
+ it("re-rendering the parent while an edit is pending does NOT flush early or duplicate commits", () => {
33
+ const commitMutationSafely = vi.fn();
34
+ let queueEdit: (() => void) | null = null;
35
+ let forceRerender: (() => void) | null = null;
36
+
37
+ function Harness() {
38
+ const [tick, setTick] = useState(0);
39
+ forceRerender = () => setTick((t) => t + 1);
40
+ // A FRESH sdk wrapper literal every render — the exact churn that, before
41
+ // the ref-stabilization fix, re-fired the unmount-flush cleanup effect.
42
+ const ops = useGsapPropertyDebounce(commitMutationSafely, {
43
+ sdkSession: null,
44
+ sdkDeps: null,
45
+ activeCompPath: "index.html",
46
+ });
47
+ queueEdit = () => ops.updateGsapProperty(selection, "tw-1", "x", tick + 1);
48
+ return React.createElement("div", null, String(tick));
49
+ }
50
+
51
+ const root = createRoot(container);
52
+ act(() => {
53
+ root.render(React.createElement(Harness));
54
+ });
55
+
56
+ // Queue one pending edit.
57
+ act(() => {
58
+ queueEdit?.();
59
+ });
60
+ expect(commitMutationSafely).not.toHaveBeenCalled();
61
+
62
+ // Re-render the parent several times BEFORE the debounce elapses. The bug
63
+ // flushed (and recorded a commit) on every re-render via the cleanup effect.
64
+ act(() => {
65
+ forceRerender?.();
66
+ });
67
+ act(() => {
68
+ forceRerender?.();
69
+ });
70
+ act(() => {
71
+ forceRerender?.();
72
+ });
73
+ expect(commitMutationSafely).not.toHaveBeenCalled();
74
+
75
+ // The debounce fires exactly once.
76
+ act(() => {
77
+ vi.advanceTimersByTime(200);
78
+ });
79
+ expect(commitMutationSafely).toHaveBeenCalledTimes(1);
80
+
81
+ act(() => {
82
+ root.unmount();
83
+ });
84
+ });
85
+ });
@@ -1,9 +1,8 @@
1
- import { useCallback, useRef } from "react";
1
+ import { useCallback, useMemo, useRef } from "react";
2
2
  import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
- import { applySoftReload } from "../utils/gsapSoftReload";
5
- import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity";
6
- import { runShadowGsapKeyframeFidelity } from "../utils/sdkShadowGsapKeyframe";
4
+ import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
5
+ import type { CutoverDeps } from "../utils/sdkCutover";
7
6
  import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
8
7
  import { createKeyedSerializer } from "./serializeByKey";
9
8
  import {
@@ -46,15 +45,12 @@ async function mutateGsapScript(
46
45
 
47
46
  // oxfmt-ignore
48
47
  // fallow-ignore-next-line complexity
49
- export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) {
48
+ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
50
49
  // Serializer for per-key commits (options.serializeKey). Keyed by
51
50
  // `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for
52
- // the same animationId so their POSTs can't interleave which is what made
53
- // the shadow fidelity diff pair an op with a stale server result and report
54
- // false ease mismatches. Held in a ref so the chain survives re-renders.
51
+ // the same animationId so their POSTs can't interleave. Held in a ref so the
52
+ // chain survives re-renders.
55
53
  const serializerRef = useRef(createKeyedSerializer());
56
- // Pre-existing complexity (server mutate + history + reload branches); this PR
57
- // adds only a guarded shadow-fidelity dispatch.
58
54
  // fallow-ignore-next-line complexity
59
55
  const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
60
56
  const pid = projectIdRef.current;
@@ -76,32 +72,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
76
72
  }
77
73
  if (result.changed === false) return;
78
74
  domEditSaveTimestampRef.current = Date.now();
79
- // Shadow value fidelity: diff the SDK's GSAP writer output against the
80
- // server's, from the same pre-op file. Fire-and-forget; server authoritative.
81
- // Meta-level ops carry shadowGsapOp (add / update-meta / delete via
82
- // useGsapAnimationOps); keyframe ops carry shadowKeyframeOp (add/remove via
83
- // useGsapKeyframeOps, handled by the gsap_keyframe block below). Per-property
84
- // handlers (useGsapPropertyDebounce) don't synthesize one yet — deferred follow-up.
85
- // scriptText is null when the composition has no GSAP script; nothing to diff.
86
- const fidelityArgs = resolveGsapFidelityArgs(
87
- sdkSession,
88
- options.shadowGsapOp,
89
- result.before,
90
- result.scriptText,
91
- );
92
- if (fidelityArgs) {
93
- void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript);
94
- }
95
- // Keyframe value fidelity (gsap_keyframe): same serialize-diff approach, but
96
- // the SDK has no keyframe reader so there is no live-existence path — the diff
97
- // is the only signal. Guarded on a live session + both scripts to diff.
98
- if (sdkSession && options.shadowKeyframeOp && result.before != null && result.scriptText != null) {
99
- void runShadowGsapKeyframeFidelity(result.before, options.shadowKeyframeOp, result.scriptText);
100
- }
101
75
  if (result.before != null && result.after != null) {
102
76
  await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
103
77
  }
104
78
  if (result.after != null) onFileContentChanged?.(targetPath, result.after);
79
+ // Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
80
+ // later SDK-routed edit doesn't serialize the pre-write doc and revert this.
81
+ forceReloadSdkSession?.();
105
82
  if (options.skipReload) return;
106
83
  if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation);
107
84
  options.beforeReload?.();
@@ -111,12 +88,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
111
88
  reloadPreview();
112
89
  }
113
90
  onCacheInvalidate();
114
- }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]);
91
+ }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]);
115
92
  // Every GSAP-script commit is a read-modify-write of one file. Overlapping
116
- // commits to the SAME file (any op type, any animation) interleave server-side
117
- // and make the shadow fidelity diff pair an op with a stale server result —
118
- // the false ease/value mismatches this serializer exists to prevent. So
119
- // serialize per target file by default; an explicit serializeKey overrides.
93
+ // commits to the SAME file (any op type, any animation) interleave server-side,
94
+ // so serialize per target file by default; an explicit serializeKey overrides.
120
95
  const commitMutation = useCallback(
121
96
  (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
122
97
  const file = selection.sourceFile || activeCompPath || "index.html";
@@ -127,9 +102,89 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
127
102
  );
128
103
  const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
129
104
  const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
130
- const propertyOps = useGsapPropertyDebounce(commitMutationSafely);
131
- const animationOps = useGsapAnimationOps({ projectIdRef, activeCompPath, commitMutation, commitMutationSafely, showToast, sdkSession });
132
- const keyframeOps = useGsapKeyframeOps({ activeCompPath, commitMutation, commitMutationSafely, trackGsapSaveFailure });
105
+
106
+ // One stable SDK-deps object shared by all GSAP child hooks. Memoized so the
107
+ // hooks' callbacks keep a stable identity (an inline literal here re-fired the
108
+ // property-debounce flush on every render). refresh() soft-reloads (preserving
109
+ // the playhead) and invalidates the panel cache, matching the server path.
110
+ const sdkRefresh = useCallback(
111
+ (after: string) => {
112
+ const script = extractGsapScriptText(after);
113
+ if (!(script && applySoftReload(previewIframeRef.current, script))) reloadPreview();
114
+ onCacheInvalidate();
115
+ },
116
+ [previewIframeRef, reloadPreview, onCacheInvalidate],
117
+ );
118
+ // Reuse the SAME per-file serializer the legacy commitMutation path uses, so
119
+ // SDK gsap-write flushes serialize against legacy commits AND each other —
120
+ // overlapping same-file read-modify-writes can't interleave and lose an edit.
121
+ const serializeByFile = useCallback(
122
+ <T>(key: string, task: () => Promise<T>): Promise<T> => serializerRef.current(key, task),
123
+ [],
124
+ );
125
+ // Read the on-disk bytes of targetPath so the SDK GSAP persist captures the
126
+ // exact prior content as its undo `before` (matching the style/delete paths),
127
+ // instead of a normalized full-DOM re-emit that would reformat the whole file.
128
+ const readProjectFileContent = useCallback(
129
+ async (path: string): Promise<string> => {
130
+ const pid = projectIdRef.current;
131
+ if (!pid) throw new Error("No active project");
132
+ const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
133
+ if (!res.ok) throw new Error(`Failed to read ${path}`);
134
+ const data = (await res.json()) as { content?: string };
135
+ if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
136
+ return data.content;
137
+ },
138
+ [projectIdRef],
139
+ );
140
+ const sdkDeps = useMemo<CutoverDeps | null>(
141
+ () =>
142
+ writeProjectFile
143
+ ? {
144
+ editHistory: { recordEdit: editHistory.recordEdit },
145
+ writeProjectFile,
146
+ reloadPreview,
147
+ domEditSaveTimestampRef,
148
+ refresh: sdkRefresh,
149
+ compositionPath: activeCompPath,
150
+ serialize: serializeByFile,
151
+ readProjectFile: readProjectFileContent,
152
+ }
153
+ : null,
154
+ [
155
+ editHistory.recordEdit,
156
+ writeProjectFile,
157
+ reloadPreview,
158
+ domEditSaveTimestampRef,
159
+ sdkRefresh,
160
+ activeCompPath,
161
+ serializeByFile,
162
+ readProjectFileContent,
163
+ ],
164
+ );
165
+
166
+ const propertyOps = useGsapPropertyDebounce(commitMutationSafely, {
167
+ sdkSession,
168
+ sdkDeps,
169
+ activeCompPath,
170
+ });
171
+ const animationOps = useGsapAnimationOps({
172
+ projectIdRef,
173
+ activeCompPath,
174
+ commitMutation,
175
+ commitMutationSafely,
176
+ showToast,
177
+ sdkSession,
178
+ sdkDeps,
179
+ });
180
+ const keyframeOps = useGsapKeyframeOps({
181
+ activeCompPath,
182
+ commitMutation,
183
+ commitMutationSafely,
184
+ trackGsapSaveFailure,
185
+ sdkSession,
186
+ sdkDeps,
187
+ });
133
188
  const arcPathOps = useGsapArcPathOps(commitMutationSafely);
134
189
  return { commitMutation, ...propertyOps, ...animationOps, ...keyframeOps, ...arcPathOps };
135
190
  }
@@ -366,13 +366,6 @@ export function usePopulateKeyframeCacheForFile(
366
366
  const { setKeyframeCache } = usePlayerStore.getState();
367
367
  clearKeyframeCacheForFile(sf);
368
368
  const { elements } = usePlayerStore.getState();
369
- console.log(
370
- "[kf:static] elements in store:",
371
- elements
372
- .map((e) => e.domId)
373
- .filter(Boolean)
374
- .join(", "),
375
- );
376
369
  const mergedByElement = new Map<string, GsapKeyframesData>();
377
370
  for (const anim of parsed.animations) {
378
371
  const id = extractIdFromSelector(anim.targetSelector);
@@ -415,12 +408,6 @@ export function usePopulateKeyframeCacheForFile(
415
408
  mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
416
409
  }
417
410
  }
418
- console.log(
419
- "[kf:static] merged elements:",
420
- [...mergedByElement.keys()].join(", "),
421
- "kf counts:",
422
- [...mergedByElement.entries()].map(([k, v]) => `${k}:${v.keyframes.length}`).join(", "),
423
- );
424
411
  for (const [id, kfData] of mergedByElement) {
425
412
  setKeyframeCache(`${sf}#${id}`, kfData);
426
413
  setKeyframeCache(id, kfData);
@@ -454,12 +441,6 @@ export function usePopulateKeyframeCacheForFile(
454
441
  if (el.domId) clipById.set(el.domId, { start: el.start, duration: el.duration });
455
442
  }
456
443
  const scanned = scanAllRuntimeKeyframes(iframe, clipById);
457
- console.log(
458
- "[kf:runtime] scanned",
459
- scanned.size,
460
- "elements:",
461
- [...scanned.keys()].join(", "),
462
- );
463
444
  if (scanned.size === 0) return false;
464
445
  const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
465
446
  for (const [id, data] of scanned) {
@@ -470,14 +451,6 @@ export function usePopulateKeyframeCacheForFile(
470
451
  if (alreadyCached) {
471
452
  continue;
472
453
  }
473
- console.log(
474
- "[kf:runtime] adding runtime entry:",
475
- id,
476
- "kfs:",
477
- data.keyframes.length,
478
- "arc:",
479
- !!data.arcPath,
480
- );
481
454
  const entry = {
482
455
  format: "percentage" as const,
483
456
  keyframes: data.keyframes,
@@ -5,10 +5,10 @@ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
5
5
  import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers";
6
6
  import { trackStudioRazorSplit } from "../telemetry/events";
7
7
  import {
8
- canSplitElement,
8
+ canSplitElementAt,
9
+ selectSplittableElements,
9
10
  buildPatchTarget,
10
11
  readFileContent,
11
- isSplitTimeWithinBounds,
12
12
  } from "../utils/timelineElementSplit";
13
13
  import type { RecordEditInput } from "./timelineEditingHelpers";
14
14
 
@@ -170,11 +170,7 @@ export function useRazorSplit({
170
170
  }
171
171
 
172
172
  const pid = projectIdRef.current;
173
- if (!pid || !canSplitElement(element)) return;
174
-
175
- if (!isSplitTimeWithinBounds(splitTime, element.start, element.duration)) {
176
- return;
177
- }
173
+ if (!pid || !canSplitElementAt(element, splitTime)) return;
178
174
 
179
175
  try {
180
176
  const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
@@ -232,9 +228,7 @@ export function useRazorSplit({
232
228
  const pid = projectIdRef.current;
233
229
  if (!pid) return;
234
230
  const { elements } = usePlayerStore.getState();
235
- const splittable = elements.filter(
236
- (el) => canSplitElement(el) && splitTime > el.start && splitTime < el.start + el.duration,
237
- );
231
+ const splittable = selectSplittableElements(elements, splitTime);
238
232
  if (splittable.length === 0) return;
239
233
 
240
234
  try {
@@ -1,6 +1,18 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { shouldReloadSdkSession } from "./useSdkSession";
3
3
 
4
+ // ── undo-sync contract ────────────────────────────────────────────────────────
5
+ // useSdkSession exposes forceReload() so callers can bypass the 2 s self-write
6
+ // suppress window. useAppHotkeys calls forceReload() after a successful
7
+ // undo/redo that wrote the active composition path. Without it, the suppress
8
+ // window swallows the file-change event and the SDK session stays stale.
9
+ //
10
+ // The React hook internals (useState / useEffect) cannot be unit-tested without
11
+ // a full render environment; the correctness of the suppress-bypass path is
12
+ // covered by the integration tests in usePersistentEditHistory.test.ts
13
+ // (which verify undo writes the correct before-content to disk).
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+
4
16
  describe("shouldReloadSdkSession", () => {
5
17
  it("reloads when the changed file is the active composition", () => {
6
18
  expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, "scenes/intro.html")).toBe(true);
@@ -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
+ }