@hyperframes/studio 0.7.37 → 0.7.39

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 (109) hide show
  1. package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
  2. package/dist/assets/index-0P10SwC_.css +1 -0
  3. package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
  4. package/dist/assets/index-B9YvRJz1.js +396 -0
  5. package/dist/assets/{index-Ct-X2wlf.js → index-CQHiecE7.js} +1 -1
  6. package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
  7. package/dist/chunk-RCLGSZ7C.js.map +1 -0
  8. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
  9. package/dist/index.d.ts +48 -10
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6045 -3507
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +26 -24
  15. package/src/components/AskAgentModal.tsx +23 -6
  16. package/src/components/LintModal.tsx +40 -10
  17. package/src/components/MediaPreview.tsx +36 -1
  18. package/src/components/SaveQueuePausedBanner.tsx +7 -6
  19. package/src/components/StudioErrorBoundary.tsx +19 -31
  20. package/src/components/StudioFeedbackBar.tsx +11 -2
  21. package/src/components/StudioGlobalDragOverlay.tsx +1 -1
  22. package/src/components/StudioHeader.tsx +184 -106
  23. package/src/components/StudioLeftSidebar.tsx +24 -3
  24. package/src/components/StudioOverlays.tsx +34 -8
  25. package/src/components/StudioPreviewArea.tsx +8 -0
  26. package/src/components/StudioRightPanel.tsx +160 -9
  27. package/src/components/StudioSplash.tsx +6 -3
  28. package/src/components/StudioToast.tsx +12 -11
  29. package/src/components/TimelineToolbar.test.tsx +9 -3
  30. package/src/components/TimelineToolbar.tsx +63 -29
  31. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  32. package/src/components/editor/DomEditOverlay.tsx +111 -108
  33. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  34. package/src/components/editor/PropertyPanel.test.ts +48 -0
  35. package/src/components/editor/PropertyPanel.tsx +27 -47
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  37. package/src/components/editor/SnapToolbar.tsx +20 -1
  38. package/src/components/editor/clipPathHelpers.ts +113 -0
  39. package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
  40. package/src/components/editor/colorGradingScopePatch.ts +57 -0
  41. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  42. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  43. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  44. package/src/components/editor/domEditOverlayShape.ts +39 -0
  45. package/src/components/editor/domEditing.test.ts +2 -0
  46. package/src/components/editor/domEditingTypes.ts +3 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  48. package/src/components/editor/manualEditingAvailability.ts +0 -6
  49. package/src/components/editor/marqueeCommit.ts +3 -2
  50. package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
  51. package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
  52. package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
  53. package/src/components/editor/propertyPanelHelpers.ts +32 -125
  54. package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
  55. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  56. package/src/components/editor/propertyPanelTypes.ts +113 -0
  57. package/src/components/editor/snapTargetCollection.ts +2 -3
  58. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  59. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  60. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  61. package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
  62. package/src/components/nle/NLELayout.tsx +54 -51
  63. package/src/components/nle/NLEPreview.tsx +41 -5
  64. package/src/components/nle/TimelineResizeDivider.tsx +97 -0
  65. package/src/components/nle/usePreviewBlockDrop.ts +31 -7
  66. package/src/components/renders/RenderQueue.tsx +125 -24
  67. package/src/components/renders/RenderQueueItem.tsx +132 -64
  68. package/src/components/renders/useRenderQueue.ts +136 -24
  69. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  70. package/src/components/sidebar/AssetsTab.tsx +113 -15
  71. package/src/components/sidebar/BlocksTab.tsx +2 -1
  72. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  73. package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
  74. package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
  75. package/src/components/storyboard/StoryboardView.tsx +6 -0
  76. package/src/components/studioColorGradingScope.ts +124 -0
  77. package/src/components/studioMediaJobs.ts +123 -0
  78. package/src/components/ui/Button.tsx +20 -11
  79. package/src/components/ui/HyperframesLoader.tsx +9 -2
  80. package/src/components/ui/SearchInput.tsx +53 -0
  81. package/src/components/ui/Tooltip.tsx +52 -6
  82. package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
  83. package/src/components/ui/useDialogBehavior.ts +83 -0
  84. package/src/contexts/PanelLayoutContext.tsx +3 -0
  85. package/src/contexts/StudioContext.tsx +7 -0
  86. package/src/hooks/domSelectionTestHarness.ts +1 -0
  87. package/src/hooks/useCompositionContentLoader.ts +40 -0
  88. package/src/hooks/useCropMode.ts +91 -0
  89. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  90. package/src/hooks/useEditorSave.ts +14 -0
  91. package/src/hooks/useFileManager.ts +39 -25
  92. package/src/hooks/useFrameCapture.ts +12 -1
  93. package/src/hooks/usePanelLayout.ts +1 -0
  94. package/src/hooks/usePreviewPersistence.ts +24 -15
  95. package/src/hooks/useStudioContextValue.ts +5 -0
  96. package/src/hooks/useToast.ts +66 -13
  97. package/src/player/components/ShortcutsPanel.tsx +9 -0
  98. package/src/player/store/playerStore.ts +14 -0
  99. package/src/styles/studio.css +61 -0
  100. package/src/utils/sdkResolverShadow.test.ts +53 -3
  101. package/src/utils/sdkResolverShadow.ts +41 -1
  102. package/src/utils/studioPendingEdits.test.ts +43 -0
  103. package/src/utils/studioPendingEdits.ts +45 -0
  104. package/src/utils/studioUiPreferences.ts +4 -0
  105. package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
  106. package/dist/assets/index-DmkOvZns.css +0 -1
  107. package/dist/assets/index-SYSbQyK4.js +0 -396
  108. package/dist/chunk-JND3XUJL.js.map +0 -1
  109. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
@@ -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
  }
@@ -60,6 +60,15 @@ const SHORTCUT_SECTIONS = [
60
60
  { key: "⇧ Drag", label: "Uniform resize" },
61
61
  ],
62
62
  },
63
+ {
64
+ title: "Crop",
65
+ hints: [
66
+ { key: "DblClick", label: "Crop selected element" },
67
+ { key: "Drag edge", label: "Adjust crop side" },
68
+ { key: "Drag frame", label: "Move crop window" },
69
+ { key: "Esc", label: "Exit crop (or click outside)" },
70
+ ],
71
+ },
63
72
  {
64
73
  title: "Panels",
65
74
  hints: [
@@ -111,6 +111,15 @@ interface PlayerState {
111
111
  autoKeyframeEnabled: boolean;
112
112
  setAutoKeyframeEnabled: (enabled: boolean) => void;
113
113
 
114
+ /** Crop mode. Armed from the preview toolbar, the Clip panel, or a
115
+ * double-click on a croppable selection; while armed, edge handles on the
116
+ * selection adjust a non-destructive clip-path inset. `available` is
117
+ * published by DomEditOverlay when the selection can be cropped. */
118
+ cropMode: boolean;
119
+ setCropMode: (active: boolean) => void;
120
+ cropAvailable: boolean;
121
+ setCropAvailable: (available: boolean) => void;
122
+
114
123
  /** Multi-select: additional selected elements beyond selectedElementId. */
115
124
  selectedElementIds: Set<string>;
116
125
  toggleSelectedElementId: (id: string) => void;
@@ -246,6 +255,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
246
255
  setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }),
247
256
  autoKeyframeEnabled: true,
248
257
  setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
258
+ cropMode: false,
259
+ setCropMode: (active) => set({ cropMode: active }),
260
+ cropAvailable: false,
261
+ setCropAvailable: (available) =>
262
+ set(available ? { cropAvailable: true } : { cropAvailable: false, cropMode: false }),
249
263
 
250
264
  selectedElementIds: new Set<string>(),
251
265
  toggleSelectedElementId: (id: string) =>
@@ -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 () => {
@@ -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,
@@ -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
  }