@hyperframes/studio 0.7.36 → 0.7.38

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 (75) hide show
  1. package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
  2. package/dist/assets/index-BLRTwY5l.js +396 -0
  3. package/dist/assets/{index-BltxqwG6.js → index-ClUipc8i.js} +1 -1
  4. package/dist/assets/index-DJaiR8T2.css +1 -0
  5. package/dist/assets/{index-_bjyggFK.js → index-Ykq7ihge.js} +1 -1
  6. package/dist/index.d.ts +35 -10
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5147 -3322
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +19 -18
  12. package/src/components/AskAgentModal.tsx +23 -6
  13. package/src/components/LintModal.tsx +40 -10
  14. package/src/components/MediaPreview.tsx +36 -1
  15. package/src/components/SaveQueuePausedBanner.tsx +7 -6
  16. package/src/components/StudioErrorBoundary.tsx +19 -31
  17. package/src/components/StudioFeedbackBar.tsx +11 -2
  18. package/src/components/StudioGlobalDragOverlay.tsx +1 -1
  19. package/src/components/StudioHeader.tsx +184 -106
  20. package/src/components/StudioLeftSidebar.tsx +24 -3
  21. package/src/components/StudioOverlays.tsx +34 -8
  22. package/src/components/StudioRightPanel.tsx +154 -9
  23. package/src/components/StudioSplash.tsx +6 -3
  24. package/src/components/StudioToast.tsx +12 -11
  25. package/src/components/TimelineToolbar.test.tsx +9 -3
  26. package/src/components/TimelineToolbar.tsx +63 -29
  27. package/src/components/editor/PropertyPanel.tsx +18 -17
  28. package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
  29. package/src/components/editor/colorGradingScopePatch.ts +57 -0
  30. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  31. package/src/components/editor/manualEditingAvailability.ts +0 -6
  32. package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
  33. package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
  34. package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
  35. package/src/components/editor/propertyPanelHelpers.ts +22 -91
  36. package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
  37. package/src/components/editor/propertyPanelTypes.ts +111 -0
  38. package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
  39. package/src/components/nle/NLELayout.tsx +54 -51
  40. package/src/components/nle/NLEPreview.tsx +41 -5
  41. package/src/components/nle/TimelineResizeDivider.tsx +97 -0
  42. package/src/components/nle/usePreviewBlockDrop.ts +31 -7
  43. package/src/components/renders/RenderQueue.tsx +125 -24
  44. package/src/components/renders/RenderQueueItem.tsx +132 -64
  45. package/src/components/renders/useRenderQueue.ts +136 -24
  46. package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
  47. package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
  48. package/src/components/storyboard/StoryboardView.tsx +6 -0
  49. package/src/components/studioColorGradingScope.ts +124 -0
  50. package/src/components/studioMediaJobs.ts +123 -0
  51. package/src/components/ui/Button.tsx +20 -11
  52. package/src/components/ui/HyperframesLoader.tsx +9 -2
  53. package/src/components/ui/SearchInput.tsx +53 -0
  54. package/src/components/ui/Tooltip.tsx +52 -6
  55. package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
  56. package/src/components/ui/useDialogBehavior.ts +83 -0
  57. package/src/contexts/PanelLayoutContext.tsx +3 -0
  58. package/src/contexts/StudioContext.tsx +7 -0
  59. package/src/hooks/useCompositionContentLoader.ts +40 -0
  60. package/src/hooks/useEditorSave.ts +14 -0
  61. package/src/hooks/useFileManager.ts +39 -25
  62. package/src/hooks/useFrameCapture.ts +12 -1
  63. package/src/hooks/usePanelLayout.ts +1 -0
  64. package/src/hooks/usePreviewPersistence.ts +24 -15
  65. package/src/hooks/useStudioContextValue.ts +5 -0
  66. package/src/hooks/useToast.ts +66 -13
  67. package/src/styles/studio.css +61 -0
  68. package/src/utils/sdkResolverShadow.test.ts +70 -3
  69. package/src/utils/sdkResolverShadow.ts +47 -4
  70. package/src/utils/studioPendingEdits.test.ts +43 -0
  71. package/src/utils/studioPendingEdits.ts +45 -0
  72. package/src/utils/studioUiPreferences.ts +4 -0
  73. package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
  74. package/dist/assets/index-DfeE1_Rl.js +0 -396
  75. package/dist/assets/index-DmkOvZns.css +0 -1
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, type MouseEvent } from "react";
1
+ import { useState, useCallback, useRef, type MouseEvent } from "react";
2
2
  import { useMountEffect } from "./useMountEffect";
3
3
  import { liveTime, usePlayerStore } from "../player";
4
4
  import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
@@ -17,6 +17,8 @@ export function useFrameCapture({
17
17
  waitForPendingDomEditSaves,
18
18
  }: UseFrameCaptureParams) {
19
19
  const [captureFrameTime, setCaptureFrameTime] = useState(0);
20
+ const [capturing, setCapturing] = useState(false);
21
+ const capturingRef = useRef(false);
20
22
 
21
23
  useMountEffect(() => {
22
24
  setCaptureFrameTime(usePlayerStore.getState().currentTime);
@@ -31,6 +33,11 @@ export function useFrameCapture({
31
33
  async (event: MouseEvent<HTMLAnchorElement>) => {
32
34
  if (!projectId) return;
33
35
  event.preventDefault();
36
+ // A capture can take up to ~35s (save drain + server render) — ignore
37
+ // re-entrant clicks instead of firing parallel captures.
38
+ if (capturingRef.current) return;
39
+ capturingRef.current = true;
40
+ setCapturing(true);
34
41
  try {
35
42
  const time = usePlayerStore.getState().currentTime;
36
43
  setCaptureFrameTime(time);
@@ -79,6 +86,9 @@ export function useFrameCapture({
79
86
  }
80
87
  } catch (err) {
81
88
  showToast(err instanceof Error ? err.message : "Capture failed", "error");
89
+ } finally {
90
+ capturingRef.current = false;
91
+ setCapturing(false);
82
92
  }
83
93
  },
84
94
  [activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
@@ -98,5 +108,6 @@ export function useFrameCapture({
98
108
  captureFrameFilename,
99
109
  handleCaptureFrameClick,
100
110
  refreshCaptureFrameTime,
111
+ capturing,
101
112
  };
102
113
  }
@@ -100,6 +100,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
100
100
  leftWidth,
101
101
  setLeftWidth,
102
102
  rightWidth,
103
+ setRightWidth,
103
104
  leftCollapsed,
104
105
  setLeftCollapsed,
105
106
  rightCollapsed,
@@ -8,6 +8,7 @@ import {
8
8
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
9
9
  import type { EditHistoryKind } from "../utils/editHistory";
10
10
  import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
11
+ import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
11
12
  import { trackStudioEvent } from "../utils/studioTelemetry";
12
13
 
13
14
  // ── Types ──
@@ -77,6 +78,24 @@ function shouldReloadForStudioFileChange(
77
78
  return Date.now() - domEditSaveTimestampRef.current >= 4000;
78
79
  }
79
80
 
81
+ // fallow-ignore-next-line complexity
82
+ async function clearLegacyStudioMotionFile(
83
+ readOptionalProjectFile: (path: string) => Promise<string>,
84
+ writeProjectFile: (path: string, content: string) => Promise<void>,
85
+ ): Promise<void> {
86
+ const content = await readOptionalProjectFile(STUDIO_MOTION_PATH).catch(() => null);
87
+ if (!content) return;
88
+ try {
89
+ const parsed = JSON.parse(content) as { motions?: unknown[] };
90
+ if (!Array.isArray(parsed.motions) || parsed.motions.length === 0) return;
91
+ } catch {
92
+ return;
93
+ }
94
+ await writeProjectFile(STUDIO_MOTION_PATH, JSON.stringify({ version: 1, motions: [] })).catch(
95
+ () => {},
96
+ );
97
+ }
98
+
80
99
  // ── Hook ──
81
100
 
82
101
  export function usePreviewPersistence({
@@ -135,6 +154,7 @@ export function usePreviewPersistence({
135
154
  }, []);
136
155
 
137
156
  const waitForPendingDomEditSaves = useCallback(async () => {
157
+ await flushStudioPendingEdits();
138
158
  await domEditSaveQueueRef.current?.waitForIdle();
139
159
  }, []);
140
160
 
@@ -185,20 +205,7 @@ export function usePreviewPersistence({
185
205
  // could still fire alongside the new seek-reapply runtime. Empty the file so
186
206
  // the legacy codepath no-ops.
187
207
  useMountEffect(() => {
188
- _readOptionalProjectFile(STUDIO_MOTION_PATH)
189
- .then((content) => {
190
- if (!content) return;
191
- try {
192
- const parsed = JSON.parse(content) as { motions?: unknown[] };
193
- if (!Array.isArray(parsed.motions) || parsed.motions.length === 0) return;
194
- } catch {
195
- return;
196
- }
197
- return _writeProjectFile(STUDIO_MOTION_PATH, JSON.stringify({ version: 1, motions: [] }));
198
- })
199
- .catch(() => {
200
- /* best-effort migration — ignore failures */
201
- });
208
+ void clearLegacyStudioMotionFile(_readOptionalProjectFile, _writeProjectFile);
202
209
  });
203
210
 
204
211
  // ── Listen for external file changes (HMR / SSE) ──
@@ -210,8 +217,10 @@ export function usePreviewPersistence({
210
217
  pendingTimelineEditPathRef,
211
218
  domEditSaveTimestampRef,
212
219
  )
213
- )
220
+ ) {
221
+ // fallow-ignore-next-line code-duplication
214
222
  reloadPreview();
223
+ }
215
224
  };
216
225
  if (import.meta.hot) {
217
226
  import.meta.hot.on("hf:file-change", handler);
@@ -21,7 +21,12 @@ interface StudioContextInput {
21
21
  renderQueue: {
22
22
  jobs: unknown[];
23
23
  isRendering: boolean;
24
+ loadError: string | null;
25
+ actionError: string | null;
26
+ dismissActionError: () => void;
27
+ reloadRenders: () => void;
24
28
  deleteRender: (id: string) => void;
29
+ cancelRender: (id: string) => void;
25
30
  clearCompleted: () => void;
26
31
  startRender: (options: unknown) => Promise<void>;
27
32
  };
@@ -2,24 +2,77 @@ import { useState, useCallback, useRef } from "react";
2
2
  import { useMountEffect } from "./useMountEffect";
3
3
  import type { AppToast } from "../utils/studioHelpers";
4
4
 
5
+ interface ToastItem extends AppToast {
6
+ id: number;
7
+ /** True while the exit animation plays, just before removal. */
8
+ leaving?: boolean;
9
+ }
10
+
11
+ const AUTO_DISMISS_MS = 4000;
12
+ const EXIT_MS = 160;
13
+ const MAX_TOASTS = 3;
14
+
15
+ let nextToastId = 1;
16
+
17
+ /**
18
+ * Stacked toasts (max 3). Info toasts auto-dismiss after 4s; error toasts
19
+ * persist until explicitly dismissed so failures can't silently vanish.
20
+ */
5
21
  export function useToast() {
6
- const [appToast, setAppToast] = useState<AppToast | null>(null);
7
- const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
22
+ const [toasts, setToasts] = useState<ToastItem[]>([]);
23
+ const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
8
24
 
9
- const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
10
- if (timerRef.current) clearTimeout(timerRef.current);
11
- setAppToast({ message, tone });
12
- timerRef.current = setTimeout(() => setAppToast(null), 4000);
25
+ const clearTimer = useCallback((id: number) => {
26
+ const timer = timersRef.current.get(id);
27
+ if (timer) {
28
+ clearTimeout(timer);
29
+ timersRef.current.delete(id);
30
+ }
13
31
  }, []);
14
32
 
33
+ const removeToast = useCallback(
34
+ (id: number) => {
35
+ clearTimer(id);
36
+ setToasts((prev) => prev.filter((t) => t.id !== id));
37
+ },
38
+ [clearTimer],
39
+ );
40
+
41
+ const dismissToast = useCallback(
42
+ (id: number) => {
43
+ clearTimer(id);
44
+ // Mark leaving so the exit animation plays, then remove.
45
+ setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, leaving: true } : t)));
46
+ const timer = setTimeout(() => removeToast(id), EXIT_MS);
47
+ timersRef.current.set(id, timer);
48
+ },
49
+ [clearTimer, removeToast],
50
+ );
51
+
52
+ const showToast = useCallback(
53
+ (message: string, tone: AppToast["tone"] = "error") => {
54
+ const id = nextToastId++;
55
+ setToasts((prev) => {
56
+ const next = [...prev, { id, message, tone }];
57
+ // Cap the stack; drop the oldest (and its pending timer).
58
+ while (next.length > MAX_TOASTS) {
59
+ const dropped = next.shift();
60
+ if (dropped) clearTimer(dropped.id);
61
+ }
62
+ return next;
63
+ });
64
+ if (tone !== "error") {
65
+ const timer = setTimeout(() => dismissToast(id), AUTO_DISMISS_MS);
66
+ timersRef.current.set(id, timer);
67
+ }
68
+ },
69
+ [clearTimer, dismissToast],
70
+ );
71
+
15
72
  useMountEffect(() => () => {
16
- if (timerRef.current) clearTimeout(timerRef.current);
73
+ for (const timer of timersRef.current.values()) clearTimeout(timer);
74
+ timersRef.current.clear();
17
75
  });
18
76
 
19
- const dismissToast = useCallback(() => {
20
- if (timerRef.current) clearTimeout(timerRef.current);
21
- setAppToast(null);
22
- }, []);
23
-
24
- return { appToast, showToast, dismissToast };
77
+ return { toasts, showToast, dismissToast };
25
78
  }
@@ -251,3 +251,64 @@ body {
251
251
  background: linear-gradient(90deg, #06e3fa, #4fdb5e);
252
252
  transition: transform 160ms ease;
253
253
  }
254
+
255
+ /* Toast enter/exit (StudioToast) */
256
+ @keyframes hf-toast-in {
257
+ from {
258
+ opacity: 0;
259
+ transform: translateY(8px);
260
+ }
261
+ to {
262
+ opacity: 1;
263
+ transform: translateY(0);
264
+ }
265
+ }
266
+
267
+ @keyframes hf-toast-out {
268
+ from {
269
+ opacity: 1;
270
+ transform: translateY(0);
271
+ }
272
+ to {
273
+ opacity: 0;
274
+ transform: translateY(8px);
275
+ }
276
+ }
277
+
278
+ .hf-toast-enter {
279
+ animation: hf-toast-in 160ms ease-out;
280
+ }
281
+
282
+ .hf-toast-exit {
283
+ animation: hf-toast-out 160ms ease-in forwards;
284
+ }
285
+
286
+ @media (prefers-reduced-motion: reduce) {
287
+ .hf-toast-enter,
288
+ .hf-toast-exit {
289
+ animation: none;
290
+ }
291
+ .hf-toast-exit {
292
+ opacity: 0;
293
+ }
294
+ }
295
+
296
+ /* Overlay/backdrop entrance — the heaviest visual changes shouldn't pop in */
297
+ @keyframes hf-backdrop-in {
298
+ from {
299
+ opacity: 0;
300
+ }
301
+ to {
302
+ opacity: 1;
303
+ }
304
+ }
305
+
306
+ .hf-backdrop-in {
307
+ animation: hf-backdrop-in 150ms ease-out;
308
+ }
309
+
310
+ @media (prefers-reduced-motion: reduce) {
311
+ .hf-backdrop-in {
312
+ animation: none;
313
+ }
314
+ }
@@ -98,6 +98,23 @@ describe("A. Flag gating", () => {
98
98
  expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
99
99
  });
100
100
 
101
+ it("A2c: empty session → ONE tagged session_empty event per session, no attempt", async () => {
102
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
103
+ flushAttemptCounts(); // drain counts left by earlier tests
104
+ const session = await openComposition("<!DOCTYPE html><html><body></body></html>");
105
+ const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
106
+ runResolverShadow(session, "hf-anything", ops);
107
+ runResolverShadow(session, "hf-other", ops); // repeat edits do not re-emit
108
+ const events = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow");
109
+ // The modeling gap stays VISIBLE (silence would blind the tripwire to the
110
+ // exact class that exposed the template-comp bug) but is distinguishable
111
+ // and rate-limited to once per session instance.
112
+ expect(events).toHaveLength(1);
113
+ expect(events[0]?.props.sessionEmpty).toBe(true);
114
+ expect(JSON.stringify(events[0]?.props.mismatches)).toContain("session_empty");
115
+ expect(flushAttemptCounts()).toBeNull(); // can't cut over → not in the denominator
116
+ });
117
+
101
118
  it("A3: shadow depends ONLY on shadow flag, not on STUDIO_SDK_CUTOVER_ENABLED", async () => {
102
119
  // The mock always returns STUDIO_SDK_CUTOVER_ENABLED=false. Use a divergence
103
120
  // (poisoned session) so the flag-on case emits; flag-off must stay silent.
@@ -268,7 +285,12 @@ describe("C. Resolver-parity detection", () => {
268
285
 
269
286
  it("C8 sourceHfIdCount: emitted element_not_found carries source occurrence count", async () => {
270
287
  mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
271
- const session = { getElement: () => null, getElements: () => [] } as unknown as Composition;
288
+ // One unrelated element so the session isn't "empty" (empty sessions are
289
+ // skipped as structural modeling gaps) — it just can't resolve hf-dup.
290
+ const session = {
291
+ getElement: () => null,
292
+ getElements: () => [{ id: "hf-other" }],
293
+ } as unknown as Composition;
272
294
  // id present twice in source (duplicate-id ambiguity) but absent from session
273
295
  const source = `<div data-hf-id="hf-dup">a</div><div data-hf-id="hf-dup">b</div>`;
274
296
  runResolverShadow(
@@ -282,7 +304,10 @@ describe("C. Resolver-parity detection", () => {
282
304
 
283
305
  it("C8 sourceLooseMatchOnly: hfId matches source only as plain text, not a data-hf-id attribute", async () => {
284
306
  mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
285
- const session = { getElement: () => null, getElements: () => [] } as unknown as Composition;
307
+ const session = {
308
+ getElement: () => null,
309
+ getElements: () => [{ id: "hf-other" }],
310
+ } as unknown as Composition;
286
311
  // "hf-widget" appears only inside a class name, never as data-hf-id="hf-widget".
287
312
  const source = `<div class="hf-widget-container">no attribute match here</div>`;
288
313
  runResolverShadow(
@@ -436,7 +461,7 @@ describe("F. recordResolverParity", () => {
436
461
  expect(ev?.sourceHfIdCount).toBeUndefined();
437
462
  });
438
463
 
439
- it("fails open: a readSource error still emits (no suppression)", async () => {
464
+ it("fails open: a readSource error still emits (no suppression), tagged sourceReadFailed", async () => {
440
465
  mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
441
466
  const session = await openComposition(BASE_HTML);
442
467
  await recordResolverParity(session, "hf-missing", "setTiming", () =>
@@ -445,6 +470,31 @@ describe("F. recordResolverParity", () => {
445
470
  const ev = lastShadow();
446
471
  expect(ev?.mismatchCount).toBe(1);
447
472
  expect(ev?.sourceHfIdCount).toBeUndefined();
473
+ // Distinguishes "reader threw" from "no reader wired" — every wild emission
474
+ // of the setTiming class had an absent sourceHfIdCount and the two cases
475
+ // were indistinguishable in telemetry.
476
+ expect(ev?.sourceReadFailed).toBe(true);
477
+ });
478
+
479
+ it("does not tag sourceReadFailed when no reader is supplied", async () => {
480
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
481
+ const session = await openComposition(BASE_HTML);
482
+ await recordResolverParity(session, "hf-missing", "setTiming");
483
+ expect(lastShadow()?.sourceReadFailed).toBeUndefined();
484
+ });
485
+
486
+ it("empty session → ONE tagged session_empty event, no attempt, no element_not_found", async () => {
487
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
488
+ flushAttemptCounts(); // drain any counts left by earlier tests
489
+ const session = await openComposition("<!DOCTYPE html><html><body></body></html>");
490
+ await recordResolverParity(session, "hf-anything", "setTiming");
491
+ await recordResolverParity(session, "hf-other", "setTiming"); // no re-emit
492
+ const events = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow");
493
+ expect(events).toHaveLength(1);
494
+ expect(events[0]?.props.sessionEmpty).toBe(true);
495
+ expect(JSON.stringify(events[0]?.props.mismatches)).toContain("session_empty");
496
+ expect(JSON.stringify(events[0]?.props.mismatches)).not.toContain("element_not_found");
497
+ expect(flushAttemptCounts()).toBeNull(); // can't cut over → not in the denominator
448
498
  });
449
499
 
450
500
  it("tags sourceLooseMatchOnly when hfId matches source only as plain text, not a data-hf-id attribute", async () => {
@@ -469,6 +519,12 @@ const GSAP_HTML = /* html */ `<!DOCTYPE html>
469
519
  <script>var tl = gsap.timeline({ paused: true }); tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 0);</script>
470
520
  </body></html>`;
471
521
 
522
+ const GSAP_UNMATCHED_SELECTOR_HTML = /* html */ `<!DOCTYPE html>
523
+ <html><body>
524
+ <div data-hf-id="hf-box" style="color: red">Hello</div>
525
+ <script>var tl = gsap.timeline({ paused: true }); tl.to("#coral-band", { x: 100, duration: 1 }, 3);</script>
526
+ </body></html>`;
527
+
472
528
  describe("G. recordAnimationResolverParity", () => {
473
529
  it("emits animation_not_found when the SDK cannot resolve the animationId", async () => {
474
530
  mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
@@ -495,6 +551,17 @@ describe("G. recordAnimationResolverParity", () => {
495
551
  recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
496
552
  expect(trackedEvents).toHaveLength(0);
497
553
  });
554
+
555
+ it("emits nothing when the animationId only resolves via getAllAnimationIds (no live DOM match) — repro of the v0.7.31 false-positive", async () => {
556
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
557
+ const session = await openComposition(GSAP_UNMATCHED_SELECTOR_HTML);
558
+ const unmatchedId = [...session.getAllAnimationIds()][0] ?? "";
559
+ expect(unmatchedId).not.toBe("");
560
+ // Confirms the bug this fixes: the id is NOT attached to any element.
561
+ expect(session.getElements().some((el) => el.animationIds.includes(unmatchedId))).toBe(false);
562
+ recordAnimationResolverParity(session, unmatchedId, "removeAllKeyframes");
563
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
564
+ });
498
565
  });
499
566
 
500
567
  // ─── H. Inlined sub-composition: bare leaf id resolves (regression) ───────────
@@ -24,7 +24,12 @@ import { trackStudioEvent, flushViaBeacon } from "./studioTelemetry";
24
24
  // ─── Types ────────────────────────────────────────────────────────────────────
25
25
 
26
26
  export interface SdkResolverMismatch {
27
- kind: "element_not_found" | "value_mismatch" | "dispatch_error" | "animation_not_found";
27
+ kind:
28
+ | "element_not_found"
29
+ | "value_mismatch"
30
+ | "dispatch_error"
31
+ | "animation_not_found"
32
+ | "session_empty";
28
33
  hfId?: string;
29
34
  animationId?: string;
30
35
  property?: string;
@@ -347,6 +352,34 @@ function redactMismatches(mismatches: SdkResolverMismatch[]): SdkResolverMismatc
347
352
  * (see below). The session is shared with the cutover path, so it MUST end the
348
353
  * call exactly as it started.
349
354
  */
355
+ // Sessions whose empty-session modeling gap has already been reported — one
356
+ // event per session instance, not one per edit (the per-edit storm is noise;
357
+ // the EXISTENCE of the gap is the signal).
358
+ const emptySessionReported = new WeakSet<Composition>();
359
+
360
+ /**
361
+ * An empty session structurally cannot resolve ANY id — a modeling gap (empty
362
+ * file, comp shape the SDK can't parse into elements), not a resolver
363
+ * divergence, and it can't cut over either, so it stays out of the attempt
364
+ * denominator. But silence would blind the tripwire to exactly the class that
365
+ * exposed the template-comp bug — so emit ONE distinguishable `session_empty`
366
+ * event per session, then skip. Returns true when the caller should skip.
367
+ */
368
+ function reportEmptySession(session: Composition, opLabel: string): boolean {
369
+ if (session.getElements().length !== 0) return false;
370
+ if (!emptySessionReported.has(session)) {
371
+ emptySessionReported.add(session);
372
+ trackStudioEvent("sdk_resolver_shadow", {
373
+ opLabel,
374
+ sessionEmpty: true,
375
+ sessionElementCount: 0,
376
+ mismatchCount: 1,
377
+ mismatches: JSON.stringify([{ kind: "session_empty" } satisfies SdkResolverMismatch]),
378
+ });
379
+ }
380
+ return true;
381
+ }
382
+
350
383
  export function runResolverShadow(
351
384
  session: Composition,
352
385
  hfId: string | null | undefined,
@@ -356,6 +389,7 @@ export function runResolverShadow(
356
389
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
357
390
  if (!hfId) return;
358
391
  try {
392
+ if (reportEmptySession(session, "dom-edit")) return;
359
393
  recordAttempt("dom-edit");
360
394
  const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent);
361
395
  // Emit only on divergence — parity is silent, matching recordResolverParity
@@ -409,6 +443,7 @@ export async function recordResolverParity(
409
443
  if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
410
444
  if (!session || !hfId) return;
411
445
  try {
446
+ if (reportEmptySession(session, opLabel)) return;
412
447
  recordAttempt(opLabel);
413
448
  if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
414
449
  // Capture BEFORE any await: this call is fire-and-forget (`void recordResolverParity(...)`)
@@ -419,11 +454,13 @@ export async function recordResolverParity(
419
454
  const sessionElementCount = session.getElements().length;
420
455
  // Cheap check passed above, so the source read only runs on a real divergence.
421
456
  let source: string | undefined;
457
+ let sourceReadFailed = false;
422
458
  if (readSource) {
423
459
  try {
424
460
  source = await readSource();
425
461
  } catch {
426
462
  source = undefined; // fail-open: a read error must not drop a real divergence
463
+ sourceReadFailed = true;
427
464
  }
428
465
  }
429
466
  // Runtime-generated node the static parse can't model — suppress (mirrors the dom-edit path).
@@ -444,6 +481,9 @@ export async function recordResolverParity(
444
481
  // Lets telemetry consumers filter this cohort without parsing the
445
482
  // sourceHfIdCount comment above.
446
483
  ...(strictCount === 0 ? { sourceLooseMatchOnly: true } : {}),
484
+ // The reader was wired but threw — distinguishes "read failed, emitted
485
+ // fail-open without the suppression/count checks" from "no reader wired".
486
+ ...(sourceReadFailed ? { sourceReadFailed: true } : {}),
447
487
  mismatchCount: 1,
448
488
  mismatches: JSON.stringify([
449
489
  { kind: "element_not_found", hfId } satisfies SdkResolverMismatch,
@@ -459,8 +499,9 @@ export async function recordResolverParity(
459
499
  * dispatching. Read-only: emits `animation_not_found` when the SDK can't resolve
460
500
  * the animationId the server GSAP path is addressing — the GSAP-edit-surface
461
501
  * analogue of element_not_found. The SDK's resolvable animation ids are the
462
- * located ids attached to elements (buildAnimationIdMap), so a target absent
463
- * from every element's animationIds is a resolver divergence.
502
+ * located ids attached to elements (buildAnimationIdMap) OR any id parsed from
503
+ * the script regardless of DOM match (getAllAnimationIds) — a target absent
504
+ * from both is a resolver divergence.
464
505
  *
465
506
  * No-op when the shadow flag is off; never throws; never mutates the session.
466
507
  */
@@ -474,7 +515,9 @@ export function recordAnimationResolverParity(
474
515
  try {
475
516
  recordAttempt(opLabel);
476
517
  const elements = session.getElements();
477
- const resolves = elements.some((el) => el.animationIds.includes(animationId));
518
+ const resolves =
519
+ elements.some((el) => el.animationIds.includes(animationId)) ||
520
+ session.getAllAnimationIds().has(animationId);
478
521
  if (resolves) return; // SDK locates the animation — parity
479
522
  trackStudioEvent("sdk_resolver_shadow", {
480
523
  animationId,
@@ -0,0 +1,43 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ addStudioPendingEditFlushListener,
5
+ flushStudioPendingEdits,
6
+ trackStudioPendingEdit,
7
+ } from "./studioPendingEdits";
8
+
9
+ describe("studio pending edit flush", () => {
10
+ it("waits for mounted panels to persist pending local edits", async () => {
11
+ const persist = vi.fn(async () => undefined);
12
+ const remove = addStudioPendingEditFlushListener(persist);
13
+
14
+ try {
15
+ await flushStudioPendingEdits();
16
+ expect(persist).toHaveBeenCalledTimes(1);
17
+ } finally {
18
+ remove();
19
+ }
20
+ });
21
+
22
+ it("waits for edits already started by unmounted panels", async () => {
23
+ const steps: string[] = [];
24
+ let resolvePersist!: () => void;
25
+ trackStudioPendingEdit(
26
+ new Promise<void>((resolve) => {
27
+ resolvePersist = resolve;
28
+ }).then(() => {
29
+ steps.push("persisted");
30
+ }),
31
+ );
32
+
33
+ const flushed = flushStudioPendingEdits().then(() => {
34
+ steps.push("flushed");
35
+ });
36
+ await Promise.resolve();
37
+ expect(steps).toEqual([]);
38
+
39
+ resolvePersist();
40
+ await flushed;
41
+ expect(steps).toEqual(["persisted", "flushed"]);
42
+ });
43
+ });
@@ -0,0 +1,45 @@
1
+ const STUDIO_FLUSH_PENDING_EDITS_EVENT = "hf-studio-flush-pending-edits";
2
+
3
+ interface StudioFlushPendingEditsDetail {
4
+ promises: Array<Promise<unknown>>;
5
+ }
6
+
7
+ const pendingEditPromises = new Set<Promise<unknown>>();
8
+
9
+ export function trackStudioPendingEdit(
10
+ result: Promise<unknown> | unknown,
11
+ ): Promise<unknown> | undefined {
12
+ if (!result) return undefined;
13
+ const promise = Promise.resolve(result);
14
+ pendingEditPromises.add(promise);
15
+ promise.then(
16
+ () => pendingEditPromises.delete(promise),
17
+ () => pendingEditPromises.delete(promise),
18
+ );
19
+ return promise;
20
+ }
21
+
22
+ export async function flushStudioPendingEdits(): Promise<void> {
23
+ const detail: StudioFlushPendingEditsDetail = { promises: [] };
24
+ window.dispatchEvent(
25
+ new CustomEvent<StudioFlushPendingEditsDetail>(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }),
26
+ );
27
+ while (detail.promises.length > 0 || pendingEditPromises.size > 0) {
28
+ const promises = [...detail.promises, ...pendingEditPromises];
29
+ detail.promises = [];
30
+ await Promise.allSettled(promises);
31
+ }
32
+ }
33
+
34
+ export function addStudioPendingEditFlushListener(
35
+ handler: () => Promise<unknown> | unknown,
36
+ ): () => void {
37
+ const listener = (event: Event) => {
38
+ const detail = (event as CustomEvent<StudioFlushPendingEditsDetail>).detail;
39
+ if (!detail?.promises) return;
40
+ const promise = trackStudioPendingEdit(handler());
41
+ if (promise) detail.promises.push(promise);
42
+ };
43
+ window.addEventListener(STUDIO_FLUSH_PENDING_EDITS_EVENT, listener);
44
+ return () => window.removeEventListener(STUDIO_FLUSH_PENDING_EDITS_EVENT, listener);
45
+ }
@@ -7,6 +7,7 @@ export interface StoredPreviewZoomState {
7
7
  export interface StudioUiPreferences {
8
8
  leftCollapsed?: boolean;
9
9
  timelineVisible?: boolean;
10
+ timelineHeight?: number;
10
11
  playbackRate?: number;
11
12
  audioMuted?: boolean;
12
13
  previewZoom?: StoredPreviewZoomState;
@@ -48,6 +49,9 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
48
49
  if (typeof parsed.timelineVisible === "boolean") {
49
50
  preferences.timelineVisible = parsed.timelineVisible;
50
51
  }
52
+ if (typeof parsed.timelineHeight === "number" && Number.isFinite(parsed.timelineHeight)) {
53
+ preferences.timelineHeight = parsed.timelineHeight;
54
+ }
51
55
  if (typeof parsed.playbackRate === "number" && Number.isFinite(parsed.playbackRate)) {
52
56
  preferences.playbackRate = parsed.playbackRate;
53
57
  }