@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.
- package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
- package/dist/assets/index-0P10SwC_.css +1 -0
- package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
- package/dist/assets/index-B9YvRJz1.js +396 -0
- package/dist/assets/{index-Ct-X2wlf.js → index-CQHiecE7.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
- package/dist/chunk-RCLGSZ7C.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
- package/dist/index.d.ts +48 -10
- package/dist/index.html +2 -2
- package/dist/index.js +6045 -3507
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -24
- package/src/components/AskAgentModal.tsx +23 -6
- package/src/components/LintModal.tsx +40 -10
- package/src/components/MediaPreview.tsx +36 -1
- package/src/components/SaveQueuePausedBanner.tsx +7 -6
- package/src/components/StudioErrorBoundary.tsx +19 -31
- package/src/components/StudioFeedbackBar.tsx +11 -2
- package/src/components/StudioGlobalDragOverlay.tsx +1 -1
- package/src/components/StudioHeader.tsx +184 -106
- package/src/components/StudioLeftSidebar.tsx +24 -3
- package/src/components/StudioOverlays.tsx +34 -8
- package/src/components/StudioPreviewArea.tsx +8 -0
- package/src/components/StudioRightPanel.tsx +160 -9
- package/src/components/StudioSplash.tsx +6 -3
- package/src/components/StudioToast.tsx +12 -11
- package/src/components/TimelineToolbar.test.tsx +9 -3
- package/src/components/TimelineToolbar.tsx +63 -29
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +111 -108
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +48 -0
- package/src/components/editor/PropertyPanel.tsx +27 -47
- package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
- package/src/components/editor/SnapToolbar.tsx +20 -1
- package/src/components/editor/clipPathHelpers.ts +113 -0
- package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
- package/src/components/editor/colorGradingScopePatch.ts +57 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
- package/src/components/editor/domEditOverlayCrop.ts +115 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayShape.ts +39 -0
- package/src/components/editor/domEditing.test.ts +2 -0
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +0 -6
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
- package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
- package/src/components/editor/propertyPanelHelpers.ts +32 -125
- package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +113 -0
- package/src/components/editor/snapTargetCollection.ts +2 -3
- package/src/components/editor/useDomEditCompositionRect.ts +68 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
- package/src/components/editor/useDomEditOverlayRects.ts +5 -3
- package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
- package/src/components/nle/NLELayout.tsx +54 -51
- package/src/components/nle/NLEPreview.tsx +41 -5
- package/src/components/nle/TimelineResizeDivider.tsx +97 -0
- package/src/components/nle/usePreviewBlockDrop.ts +31 -7
- package/src/components/renders/RenderQueue.tsx +125 -24
- package/src/components/renders/RenderQueueItem.tsx +132 -64
- package/src/components/renders/useRenderQueue.ts +136 -24
- package/src/components/sidebar/AssetsTab.test.ts +87 -0
- package/src/components/sidebar/AssetsTab.tsx +113 -15
- package/src/components/sidebar/BlocksTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
- package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
- package/src/components/storyboard/StoryboardView.tsx +6 -0
- package/src/components/studioColorGradingScope.ts +124 -0
- package/src/components/studioMediaJobs.ts +123 -0
- package/src/components/ui/Button.tsx +20 -11
- package/src/components/ui/HyperframesLoader.tsx +9 -2
- package/src/components/ui/SearchInput.tsx +53 -0
- package/src/components/ui/Tooltip.tsx +52 -6
- package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
- package/src/components/ui/useDialogBehavior.ts +83 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/StudioContext.tsx +7 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/useCompositionContentLoader.ts +40 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/hooks/useEditorSave.ts +14 -0
- package/src/hooks/useFileManager.ts +39 -25
- package/src/hooks/useFrameCapture.ts +12 -1
- package/src/hooks/usePanelLayout.ts +1 -0
- package/src/hooks/usePreviewPersistence.ts +24 -15
- package/src/hooks/useStudioContextValue.ts +5 -0
- package/src/hooks/useToast.ts +66 -13
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/store/playerStore.ts +14 -0
- package/src/styles/studio.css +61 -0
- package/src/utils/sdkResolverShadow.test.ts +53 -3
- package/src/utils/sdkResolverShadow.ts +41 -1
- package/src/utils/studioPendingEdits.test.ts +43 -0
- package/src/utils/studioPendingEdits.ts +45 -0
- package/src/utils/studioUiPreferences.ts +4 -0
- package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
- package/dist/assets/index-DmkOvZns.css +0 -1
- package/dist/assets/index-SYSbQyK4.js +0 -396
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
package/src/hooks/useToast.ts
CHANGED
|
@@ -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 [
|
|
7
|
-
const
|
|
22
|
+
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
23
|
+
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
|
8
24
|
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
73
|
+
for (const timer of timersRef.current.values()) clearTimeout(timer);
|
|
74
|
+
timersRef.current.clear();
|
|
17
75
|
});
|
|
18
76
|
|
|
19
|
-
|
|
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) =>
|
package/src/styles/studio.css
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
|
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:
|
|
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
|
}
|