@hyperframes/studio 0.8.24 → 0.8.26
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-CTQCs_KG.js → hyperframes-player-C-CArn13.js} +1 -1
- package/dist/assets/{index-okdE01ky.js → index-BIXyJgK5.js} +1 -1
- package/dist/assets/{index-Bq3M0sjr.js → index-D5yni1t6.js} +218 -218
- package/dist/assets/index-DnRfAiK2.css +1 -0
- package/dist/assets/{index-9WJFcXTP.js → index-pfeP2Y63.js} +1 -1
- package/dist/{chunk-6BT6DTB4.js → chunk-A7S5SGNA.js} +2 -2
- package/dist/chunk-A7S5SGNA.js.map +1 -0
- package/dist/{domEditingLayers-URA7BLWE.js → domEditingLayers-JMMVOGTO.js} +2 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.html +2 -2
- package/dist/index.js +6230 -5019
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/editor/TopologyLens.test.tsx +294 -0
- package/src/components/editor/TopologyLens.tsx +206 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayGeometryBatch.test.ts +18 -0
- package/src/components/editor/domEditingLayers.test.ts +9 -0
- package/src/components/editor/domEditingLayers.ts +8 -8
- package/src/components/editor/topologyLensGeometry.test.ts +44 -0
- package/src/components/editor/topologyLensGeometry.ts +119 -0
- package/src/components/editor/topologyLensState.test.ts +116 -0
- package/src/components/editor/topologyLensState.ts +72 -0
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +126 -0
- package/src/components/nle/PreviewOverlays.tsx +24 -18
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +16 -0
- package/src/components/sidebar/CompositionsTab.tsx +10 -3
- package/src/components/ui/HyperframesLoader.tsx +3 -48
- package/src/components/ui/HyperframesMark.tsx +53 -0
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/hooks/domEditCommitRunner.test.ts +59 -0
- package/src/hooks/domEditCommitRunner.ts +55 -20
- package/src/hooks/domEditCommitTypes.ts +8 -1
- package/src/hooks/domEditTextCommitPlan.ts +47 -0
- package/src/hooks/timelineTrackVisibility.test.ts +7 -1
- package/src/hooks/timelineTrackVisibility.ts +5 -5
- package/src/hooks/useDomEditAttributeCommits.ts +4 -1
- package/src/hooks/useDomEditCommits.test.tsx +243 -8
- package/src/hooks/useDomEditCommits.ts +69 -14
- package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
- package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
- package/src/hooks/useDomEditSession.ts +6 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
- package/src/hooks/useDomEditTextCommits.ts +64 -86
- package/src/hooks/useDomEditWiring.ts +1 -1
- package/src/hooks/useDomSelection.ts +2 -0
- package/src/hooks/useDomSelectionTypes.ts +2 -0
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
- package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
- package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
- package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
- package/src/hooks/useGsapAnimationOps.ts +4 -4
- package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
- package/src/hooks/useGsapAwareEditing.ts +31 -10
- package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
- package/src/hooks/useGsapSelectionHandlers.ts +21 -11
- package/src/hooks/useRenderClipContent.test.ts +27 -0
- package/src/hooks/useRenderClipContent.ts +5 -0
- package/src/hooks/useStudioExternalFileChanges.ts +5 -0
- package/src/player/components/CompositionThumbnail.test.ts +66 -1
- package/src/player/components/CompositionThumbnail.tsx +6 -0
- package/src/player/hooks/timelineSyncHydration.ts +10 -2
- package/src/player/store/playerStore.test.ts +24 -0
- package/src/player/store/thumbnailSlice.ts +6 -0
- package/src/styles/studio.css +179 -0
- package/src/utils/domEditSaveQueue.test.ts +5 -3
- package/src/utils/domEditSaveQueue.ts +6 -1
- package/src/webmcp/StudioAgentTools.test.ts +70 -0
- package/src/webmcp/StudioAgentTools.tsx +89 -21
- package/src/webmcp/handles.test.ts +172 -15
- package/src/webmcp/handles.ts +152 -35
- package/src/webmcp/tools/animationTools.test.ts +241 -60
- package/src/webmcp/tools/animationTools.ts +191 -104
- package/src/webmcp/tools/contentTools.test.ts +103 -41
- package/src/webmcp/tools/contentTools.ts +149 -105
- package/src/webmcp/tools/inspectTools.test.ts +47 -5
- package/src/webmcp/tools/inspectTools.ts +59 -19
- package/src/webmcp/tools/lookTools.test.ts +185 -38
- package/src/webmcp/tools/lookTools.ts +134 -21
- package/src/webmcp/tools/selectionTools.test.ts +105 -9
- package/src/webmcp/tools/selectionTools.ts +34 -19
- package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
- package/src/webmcp/tools/transformTools.test.ts +140 -54
- package/src/webmcp/tools/transformTools.ts +215 -56
- package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
- package/src/webmcp/useStudioAgentTools.ts +27 -15
- package/src/webmcp/webmcpTestUtils.ts +37 -0
- package/src/webmcp/writeCoordinator.test.ts +375 -0
- package/src/webmcp/writeCoordinator.ts +473 -0
- package/dist/assets/index-yGhfxxoL.css +0 -1
- package/dist/chunk-6BT6DTB4.js.map +0 -1
- /package/dist/{domEditingLayers-URA7BLWE.js.map → domEditingLayers-JMMVOGTO.js.map} +0 -0
|
@@ -200,8 +200,13 @@ export function useGsapSelectionHandlers({
|
|
|
200
200
|
const handleGsapDeleteAnimation = useCallback(
|
|
201
201
|
(animId: string, selectionOverride?: DomEditSelection | null) => {
|
|
202
202
|
const sel = resolveWriteSelection(selectionOverride);
|
|
203
|
-
if (!sel) return;
|
|
204
|
-
observeGsapMutation(
|
|
203
|
+
if (!sel) return Promise.resolve(false);
|
|
204
|
+
return observeGsapMutation(
|
|
205
|
+
deleteGsapAnimation(sel, animId),
|
|
206
|
+
sel,
|
|
207
|
+
"delete",
|
|
208
|
+
"Delete GSAP animation",
|
|
209
|
+
);
|
|
205
210
|
},
|
|
206
211
|
[resolveWriteSelection, deleteGsapAnimation, observeGsapMutation],
|
|
207
212
|
);
|
|
@@ -222,20 +227,25 @@ export function useGsapSelectionHandlers({
|
|
|
222
227
|
);
|
|
223
228
|
|
|
224
229
|
const handleGsapAddAnimation = useCallback(
|
|
225
|
-
(method: "to" | "from" | "set" | "fromTo") => {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
230
|
+
(method: "to" | "from" | "set" | "fromTo", selectionOverride?: DomEditSelection | null) => {
|
|
231
|
+
const selection = resolveWriteSelection(selectionOverride);
|
|
232
|
+
if (!selection) return Promise.resolve(false);
|
|
233
|
+
const landed = observeGsapMutation(
|
|
234
|
+
addGsapAnimation(selection, method, usePlayerStore.getState().currentTime),
|
|
235
|
+
selection,
|
|
236
|
+
"add",
|
|
237
|
+
`Add GSAP ${method} animation`,
|
|
231
238
|
);
|
|
232
|
-
if (
|
|
239
|
+
if (selection.element.hasAttribute("data-hf-studio-path-offset")) {
|
|
233
240
|
// The reset owns rollback and the position commit already owns user and
|
|
234
241
|
// telemetry reporting. This is only the fire-and-forget UI boundary.
|
|
235
|
-
void
|
|
242
|
+
void landed.then((didLand) => {
|
|
243
|
+
if (didLand) void handleDomManualEditsReset(selection).catch(() => undefined);
|
|
244
|
+
});
|
|
236
245
|
}
|
|
246
|
+
return landed;
|
|
237
247
|
},
|
|
238
|
-
[
|
|
248
|
+
[resolveWriteSelection, addGsapAnimation, handleDomManualEditsReset, observeGsapMutation],
|
|
239
249
|
);
|
|
240
250
|
|
|
241
251
|
const handleGsapAddProperty = useCallback(
|
|
@@ -241,4 +241,31 @@ describe("useRenderClipContent", () => {
|
|
|
241
241
|
});
|
|
242
242
|
}
|
|
243
243
|
});
|
|
244
|
+
|
|
245
|
+
it("forwards persisted content revision to mounted composition thumbnails", () => {
|
|
246
|
+
usePlayerStore.setState({
|
|
247
|
+
thumbnailMode: "adaptive",
|
|
248
|
+
timelineSessionEpoch: 7,
|
|
249
|
+
thumbnailContentRevision: 11,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const content = renderClipContent({
|
|
253
|
+
id: "nested",
|
|
254
|
+
tag: "div",
|
|
255
|
+
start: 0,
|
|
256
|
+
duration: 4,
|
|
257
|
+
track: 0,
|
|
258
|
+
compositionSrc: "compositions/nested.html",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(isValidElement(content)).toBe(true);
|
|
262
|
+
if (isValidElement(content)) {
|
|
263
|
+
expect(content.type).toBe(CompositionThumbnail);
|
|
264
|
+
expect(content.props).toMatchObject({
|
|
265
|
+
projectId: "my-project",
|
|
266
|
+
sessionEpoch: 7,
|
|
267
|
+
contentRevision: 11,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
});
|
|
244
271
|
});
|
|
@@ -110,6 +110,7 @@ export function useRenderClipContent({
|
|
|
110
110
|
const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
|
|
111
111
|
const effectiveMode = effectiveThumbnailMode(thumbnailMode);
|
|
112
112
|
const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
|
|
113
|
+
const contentRevision = usePlayerStore((s) => s.thumbnailContentRevision);
|
|
113
114
|
return useCallback(
|
|
114
115
|
// Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
|
|
115
116
|
// fallow-ignore-next-line complexity
|
|
@@ -153,6 +154,7 @@ export function useRenderClipContent({
|
|
|
153
154
|
duration: el.duration,
|
|
154
155
|
projectId: pid,
|
|
155
156
|
sessionEpoch,
|
|
157
|
+
contentRevision,
|
|
156
158
|
priority: context.priority,
|
|
157
159
|
rich: context.rich,
|
|
158
160
|
});
|
|
@@ -179,6 +181,7 @@ export function useRenderClipContent({
|
|
|
179
181
|
duration: el.duration,
|
|
180
182
|
projectId: pid,
|
|
181
183
|
sessionEpoch,
|
|
184
|
+
contentRevision,
|
|
182
185
|
priority: context.priority,
|
|
183
186
|
rich: context.rich,
|
|
184
187
|
});
|
|
@@ -232,6 +235,7 @@ export function useRenderClipContent({
|
|
|
232
235
|
duration: el.duration,
|
|
233
236
|
projectId: pid,
|
|
234
237
|
sessionEpoch,
|
|
238
|
+
contentRevision,
|
|
235
239
|
priority: context.priority,
|
|
236
240
|
rich: context.rich,
|
|
237
241
|
});
|
|
@@ -246,6 +250,7 @@ export function useRenderClipContent({
|
|
|
246
250
|
effectiveTimelineDuration,
|
|
247
251
|
effectiveMode,
|
|
248
252
|
sessionEpoch,
|
|
253
|
+
contentRevision,
|
|
249
254
|
],
|
|
250
255
|
);
|
|
251
256
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, type MutableRefObject } from "react";
|
|
2
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
2
3
|
import {
|
|
3
4
|
deleteExternalConflictSnapshot,
|
|
4
5
|
loadExternalConflictSnapshot,
|
|
@@ -48,6 +49,9 @@ export function useStudioExternalFileChanges({
|
|
|
48
49
|
}: UseStudioExternalFileChangesOptions) {
|
|
49
50
|
const { flushPendingSourceSave, discardPendingSourceSave } = fileManager;
|
|
50
51
|
const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence;
|
|
52
|
+
const bumpThumbnailContentRevision = usePlayerStore(
|
|
53
|
+
(state) => state.bumpThumbnailContentRevision,
|
|
54
|
+
);
|
|
51
55
|
const drainPendingChanges = useCallback(async () => {
|
|
52
56
|
const source = await flushPendingSourceSave();
|
|
53
57
|
if (source.status !== "clean") return source;
|
|
@@ -77,5 +81,6 @@ export function useStudioExternalFileChanges({
|
|
|
77
81
|
readProjectFile: fileManager.readProjectFile,
|
|
78
82
|
onUseExternalFile: fileManager.updateEditingFileContent,
|
|
79
83
|
resetSaveQueues: resetDomEditSaveQueueBreaker,
|
|
84
|
+
onAcceptedPersistedFileChange: bumpThumbnailContentRevision,
|
|
80
85
|
});
|
|
81
86
|
}
|
|
@@ -73,7 +73,7 @@ describe("buildCompositionThumbnailUrl", () => {
|
|
|
73
73
|
origin: "http://localhost:3000",
|
|
74
74
|
}),
|
|
75
75
|
).toBe(
|
|
76
|
-
"http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v3&selector=.card&selectorIndex=2",
|
|
76
|
+
"http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v3&revision=0&selector=.card&selectorIndex=2",
|
|
77
77
|
);
|
|
78
78
|
});
|
|
79
79
|
|
|
@@ -91,6 +91,16 @@ describe("buildCompositionThumbnailUrl", () => {
|
|
|
91
91
|
"output=storyboard",
|
|
92
92
|
);
|
|
93
93
|
});
|
|
94
|
+
|
|
95
|
+
it("includes the persisted content revision in the cache identity", () => {
|
|
96
|
+
const url = buildCompositionThumbnailUrl({
|
|
97
|
+
previewUrl: "/api/projects/demo/preview",
|
|
98
|
+
origin: "http://localhost:3000",
|
|
99
|
+
contentRevision: 7,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(new URL(url).searchParams.get("revision")).toBe("7");
|
|
103
|
+
});
|
|
94
104
|
});
|
|
95
105
|
|
|
96
106
|
describe("CompositionThumbnail", () => {
|
|
@@ -148,4 +158,59 @@ describe("CompositionThumbnail", () => {
|
|
|
148
158
|
expect(probe.src).toBe("");
|
|
149
159
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:composition-thumbnail");
|
|
150
160
|
});
|
|
161
|
+
|
|
162
|
+
it("releases the old request and ignores its late result when persisted content changes", async () => {
|
|
163
|
+
const signals: AbortSignal[] = [];
|
|
164
|
+
const resolveFetches: Array<(response: Response) => void> = [];
|
|
165
|
+
globalThis.fetch = vi.fn((_url, init) => {
|
|
166
|
+
signals.push(init?.signal as AbortSignal);
|
|
167
|
+
return new Promise<Response>((resolve) => resolveFetches.push(resolve));
|
|
168
|
+
});
|
|
169
|
+
root = createRoot(host);
|
|
170
|
+
|
|
171
|
+
await act(async () => {
|
|
172
|
+
root!.render(
|
|
173
|
+
React.createElement(CompositionThumbnail, {
|
|
174
|
+
previewUrl: "/api/projects/demo/preview",
|
|
175
|
+
label: "",
|
|
176
|
+
labelColor: "#fff",
|
|
177
|
+
projectId: "demo",
|
|
178
|
+
contentRevision: 0,
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
182
|
+
});
|
|
183
|
+
await act(async () => {
|
|
184
|
+
root!.render(
|
|
185
|
+
React.createElement(CompositionThumbnail, {
|
|
186
|
+
previewUrl: "/api/projects/demo/preview",
|
|
187
|
+
label: "",
|
|
188
|
+
labelColor: "#fff",
|
|
189
|
+
projectId: "demo",
|
|
190
|
+
contentRevision: 1,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
|
197
|
+
expect(signals[0]?.aborted).toBe(true);
|
|
198
|
+
expect(signals[1]?.aborted).toBe(false);
|
|
199
|
+
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[1]?.[0]).toContain(
|
|
200
|
+
"revision=1",
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
await act(async () => {
|
|
204
|
+
resolveFetches[0]?.(new Response(new Blob(["stale"]), { status: 200 }));
|
|
205
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
206
|
+
});
|
|
207
|
+
expect(MockImage.instances).toHaveLength(0);
|
|
208
|
+
|
|
209
|
+
await act(async () => {
|
|
210
|
+
resolveFetches[1]?.(new Response(new Blob(["fresh"]), { status: 200 }));
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
212
|
+
});
|
|
213
|
+
expect(MockImage.instances).toHaveLength(1);
|
|
214
|
+
expect(MockImage.instances[0]?.src).toBe("blob:composition-thumbnail");
|
|
215
|
+
});
|
|
151
216
|
});
|
|
@@ -17,6 +17,7 @@ interface CompositionThumbnailProps {
|
|
|
17
17
|
height?: number;
|
|
18
18
|
projectId?: string;
|
|
19
19
|
sessionEpoch?: number;
|
|
20
|
+
contentRevision?: number;
|
|
20
21
|
priority?: ThumbnailPriority;
|
|
21
22
|
rich?: boolean;
|
|
22
23
|
}
|
|
@@ -32,6 +33,7 @@ export function buildCompositionThumbnailUrl({
|
|
|
32
33
|
selectorIndex,
|
|
33
34
|
origin,
|
|
34
35
|
output,
|
|
36
|
+
contentRevision = 0,
|
|
35
37
|
}: {
|
|
36
38
|
previewUrl: string;
|
|
37
39
|
seekTime?: number;
|
|
@@ -46,6 +48,7 @@ export function buildCompositionThumbnailUrl({
|
|
|
46
48
|
* high-density review size; `"source"` uses the composition's own dimensions.
|
|
47
49
|
*/
|
|
48
50
|
output?: "source" | "storyboard";
|
|
51
|
+
contentRevision?: number;
|
|
49
52
|
}): string {
|
|
50
53
|
const thumbnailBase = previewUrl
|
|
51
54
|
.replace("/preview/comp/", "/thumbnail/")
|
|
@@ -53,6 +56,7 @@ export function buildCompositionThumbnailUrl({
|
|
|
53
56
|
const thumbnailUrl = new URL(thumbnailBase, origin);
|
|
54
57
|
thumbnailUrl.searchParams.set("t", (seekTime + duration / 2).toFixed(2));
|
|
55
58
|
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
|
59
|
+
thumbnailUrl.searchParams.set("revision", String(contentRevision));
|
|
56
60
|
if (output) thumbnailUrl.searchParams.set("output", output);
|
|
57
61
|
if (selector) {
|
|
58
62
|
thumbnailUrl.searchParams.set("selector", selector);
|
|
@@ -96,6 +100,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
96
100
|
duration = 5,
|
|
97
101
|
projectId = previewUrl,
|
|
98
102
|
sessionEpoch = 0,
|
|
103
|
+
contentRevision = 0,
|
|
99
104
|
priority = "visible",
|
|
100
105
|
}: CompositionThumbnailProps) {
|
|
101
106
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
@@ -107,6 +112,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
107
112
|
selector,
|
|
108
113
|
selectorIndex,
|
|
109
114
|
origin: window.location.origin,
|
|
115
|
+
contentRevision,
|
|
110
116
|
});
|
|
111
117
|
const request = useMemo(
|
|
112
118
|
() => ({
|
|
@@ -290,6 +290,15 @@ export function resolveReloadSeekTime(input: {
|
|
|
290
290
|
return Math.min(target, input.duration);
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
type SeekablePreview = Pick<PlaybackAdapter, "seek">;
|
|
294
|
+
|
|
295
|
+
/** Re-run the current frame even when the runtime already reports that time. */
|
|
296
|
+
export function reseekPreviewAtTime(preview: SeekablePreview, time: number): void {
|
|
297
|
+
const target = Number.isFinite(time) && time > 0 ? time : 0;
|
|
298
|
+
preview.seek(target > 0.001 ? Math.max(0, target - 0.001) : 0.001);
|
|
299
|
+
preview.seek(target);
|
|
300
|
+
}
|
|
301
|
+
|
|
293
302
|
export function seekAdapterToRestorePoint(
|
|
294
303
|
adapter: PlaybackAdapter,
|
|
295
304
|
pendingSeekRef: { current: number | null },
|
|
@@ -303,8 +312,7 @@ export function seekAdapterToRestorePoint(
|
|
|
303
312
|
});
|
|
304
313
|
pendingSeekRef.current = null;
|
|
305
314
|
if (storeSeek != null) usePlayerStore.getState().clearSeekRequest();
|
|
306
|
-
adapter
|
|
307
|
-
adapter.seek(startTime);
|
|
315
|
+
reseekPreviewAtTime(adapter, startTime);
|
|
308
316
|
return startTime;
|
|
309
317
|
}
|
|
310
318
|
|
|
@@ -30,6 +30,30 @@ describe("usePlayerStore", () => {
|
|
|
30
30
|
});
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
describe("thumbnail content revision", () => {
|
|
34
|
+
it("advances once per accepted persisted file event", () => {
|
|
35
|
+
const before = usePlayerStore.getState().thumbnailContentRevision;
|
|
36
|
+
|
|
37
|
+
usePlayerStore.getState().bumpThumbnailContentRevision();
|
|
38
|
+
|
|
39
|
+
expect(usePlayerStore.getState().thumbnailContentRevision).toBe(before + 1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("remains monotonic across soft resets while project identity stays with the session epoch", () => {
|
|
43
|
+
const store = usePlayerStore.getState();
|
|
44
|
+
store.beginTimelineSession("project-a");
|
|
45
|
+
store.bumpThumbnailContentRevision();
|
|
46
|
+
const revision = usePlayerStore.getState().thumbnailContentRevision;
|
|
47
|
+
const firstEpoch = usePlayerStore.getState().timelineSessionEpoch;
|
|
48
|
+
|
|
49
|
+
store.reset();
|
|
50
|
+
expect(usePlayerStore.getState().thumbnailContentRevision).toBe(revision);
|
|
51
|
+
store.beginTimelineSession("project-b");
|
|
52
|
+
expect(usePlayerStore.getState().thumbnailContentRevision).toBe(revision);
|
|
53
|
+
expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch + 1);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
33
57
|
describe("expandedClipIds", () => {
|
|
34
58
|
it("toggles clip membership", () => {
|
|
35
59
|
const store = usePlayerStore.getState();
|
|
@@ -4,15 +4,21 @@ import { defaultThumbnailMode, type ThumbnailMode } from "../lib/thumbnailPolicy
|
|
|
4
4
|
|
|
5
5
|
export interface ThumbnailSlice {
|
|
6
6
|
thumbnailMode: ThumbnailMode;
|
|
7
|
+
/** Monotonic identity for persisted project content shown by mounted thumbnails. */
|
|
8
|
+
thumbnailContentRevision: number;
|
|
7
9
|
setThumbnailMode: (mode: ThumbnailMode) => void;
|
|
10
|
+
bumpThumbnailContentRevision: () => void;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
export function createThumbnailSlice(set: StoreApi<ThumbnailSlice>["setState"]): ThumbnailSlice {
|
|
11
14
|
return {
|
|
12
15
|
thumbnailMode: defaultThumbnailMode(readStudioUiPreferences().thumbnailMode),
|
|
16
|
+
thumbnailContentRevision: 0,
|
|
13
17
|
setThumbnailMode: (mode) => {
|
|
14
18
|
writeStudioUiPreferences({ thumbnailMode: mode });
|
|
15
19
|
set({ thumbnailMode: mode });
|
|
16
20
|
},
|
|
21
|
+
bumpThumbnailContentRevision: () =>
|
|
22
|
+
set((state) => ({ thumbnailContentRevision: state.thumbnailContentRevision + 1 })),
|
|
17
23
|
};
|
|
18
24
|
}
|
package/src/styles/studio.css
CHANGED
|
@@ -618,6 +618,185 @@ body {
|
|
|
618
618
|
}
|
|
619
619
|
}
|
|
620
620
|
|
|
621
|
+
/* Receipt-driven WebMCP Topology Lens. Every animation is one-shot Studio
|
|
622
|
+
chrome; none of these nodes enter the composition iframe or rendered output. */
|
|
623
|
+
.hf-topology-contour {
|
|
624
|
+
border: 1px solid rgba(60, 230, 172, 0.62);
|
|
625
|
+
box-shadow:
|
|
626
|
+
0 0 0 1px rgba(6, 227, 250, 0.08),
|
|
627
|
+
inset 0 0 18px rgba(60, 230, 172, 0.035);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
.hf-topology-field::before,
|
|
631
|
+
.hf-topology-contour::before,
|
|
632
|
+
.hf-topology-target::before {
|
|
633
|
+
content: attr(data-topology-node);
|
|
634
|
+
position: absolute;
|
|
635
|
+
top: 2px;
|
|
636
|
+
left: 3px;
|
|
637
|
+
z-index: 2;
|
|
638
|
+
padding: 1px 3px;
|
|
639
|
+
border-radius: 2px;
|
|
640
|
+
background: rgba(5, 15, 12, 0.86);
|
|
641
|
+
color: rgb(90, 247, 193);
|
|
642
|
+
font:
|
|
643
|
+
600 7px/1.3 ui-monospace,
|
|
644
|
+
SFMono-Regular,
|
|
645
|
+
Menlo,
|
|
646
|
+
Monaco,
|
|
647
|
+
Consolas,
|
|
648
|
+
monospace;
|
|
649
|
+
letter-spacing: 0.04em;
|
|
650
|
+
text-transform: lowercase;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.hf-topology-field {
|
|
654
|
+
z-index: 0;
|
|
655
|
+
border: 1px solid rgba(60, 230, 172, 0.28);
|
|
656
|
+
background: linear-gradient(
|
|
657
|
+
90deg,
|
|
658
|
+
rgba(60, 230, 172, 0.025),
|
|
659
|
+
rgba(60, 230, 172, 0.1) 68%,
|
|
660
|
+
rgba(6, 227, 250, 0.06)
|
|
661
|
+
);
|
|
662
|
+
box-shadow: inset 0 0 28px rgba(60, 230, 172, 0.05);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
.hf-topology-target {
|
|
666
|
+
border: 1px solid rgba(60, 230, 172, 0.82);
|
|
667
|
+
box-shadow:
|
|
668
|
+
0 0 0 1px rgba(60, 230, 172, 0.14),
|
|
669
|
+
0 0 18px rgba(6, 227, 250, 0.09);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.hf-topology-scan {
|
|
673
|
+
background: linear-gradient(
|
|
674
|
+
90deg,
|
|
675
|
+
transparent,
|
|
676
|
+
rgba(60, 230, 172, 0.16) 55%,
|
|
677
|
+
rgba(230, 255, 247, 0.9)
|
|
678
|
+
);
|
|
679
|
+
clip-path: polygon(0 0, 100% 0, 72% 100%, 0 100%);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
.hf-topology-seal {
|
|
683
|
+
filter: drop-shadow(0 0 6px rgba(60, 230, 172, 0.36));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
687
|
+
.hf-topology-contour {
|
|
688
|
+
animation: hf-topology-reveal 176ms cubic-bezier(0.77, 0, 0.175, 1) both;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.hf-topology-field[data-topology-phase="acquiring"] {
|
|
692
|
+
animation: hf-topology-reveal 240ms cubic-bezier(0.77, 0, 0.175, 1) both;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
.hf-topology-scan {
|
|
696
|
+
animation: hf-topology-scan 224ms cubic-bezier(0.77, 0, 0.175, 1) both;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
.hf-topology-target[data-topology-phase="acquiring"] {
|
|
700
|
+
animation: hf-topology-localize 224ms cubic-bezier(0.23, 1, 0.32, 1) 72ms both;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.hf-topology-target[data-topology-phase="localizing"] {
|
|
704
|
+
animation: hf-topology-localize 176ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.hf-topology-target[data-topology-phase="sealing"] {
|
|
708
|
+
animation: hf-topology-localize 144ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.hf-topology-seal {
|
|
712
|
+
animation: hf-topology-seal 224ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.hf-topology-seal path:first-of-type {
|
|
716
|
+
animation: hf-topology-seal-left 224ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
.hf-topology-seal path:nth-of-type(2) {
|
|
720
|
+
animation: hf-topology-seal-right 224ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
@keyframes hf-topology-reveal {
|
|
725
|
+
from {
|
|
726
|
+
opacity: 0;
|
|
727
|
+
clip-path: inset(0 100% 0 0);
|
|
728
|
+
}
|
|
729
|
+
to {
|
|
730
|
+
opacity: 1;
|
|
731
|
+
clip-path: inset(0);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
@keyframes hf-topology-scan {
|
|
736
|
+
from {
|
|
737
|
+
opacity: 0;
|
|
738
|
+
transform: translate3d(-100%, 0, 0);
|
|
739
|
+
}
|
|
740
|
+
16% {
|
|
741
|
+
opacity: 1;
|
|
742
|
+
}
|
|
743
|
+
to {
|
|
744
|
+
opacity: 0;
|
|
745
|
+
transform: translate3d(400%, 0, 0);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
@keyframes hf-topology-localize {
|
|
750
|
+
from {
|
|
751
|
+
opacity: 0;
|
|
752
|
+
transform: scaleX(0.76);
|
|
753
|
+
}
|
|
754
|
+
to {
|
|
755
|
+
opacity: 1;
|
|
756
|
+
transform: scaleX(1);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
@keyframes hf-topology-seal {
|
|
761
|
+
from {
|
|
762
|
+
opacity: 0;
|
|
763
|
+
transform: scale(0.88);
|
|
764
|
+
}
|
|
765
|
+
to {
|
|
766
|
+
opacity: 1;
|
|
767
|
+
transform: scale(1);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
@keyframes hf-topology-seal-left {
|
|
772
|
+
from {
|
|
773
|
+
transform: translate3d(-9px, 0, 0);
|
|
774
|
+
}
|
|
775
|
+
to {
|
|
776
|
+
transform: translate3d(0, 0, 0);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
@keyframes hf-topology-seal-right {
|
|
781
|
+
from {
|
|
782
|
+
transform: translate3d(9px, 0, 0);
|
|
783
|
+
}
|
|
784
|
+
to {
|
|
785
|
+
transform: translate3d(0, 0, 0);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
@media (prefers-reduced-motion: reduce) {
|
|
790
|
+
.hf-topology-contour,
|
|
791
|
+
.hf-topology-field,
|
|
792
|
+
.hf-topology-target,
|
|
793
|
+
.hf-topology-seal,
|
|
794
|
+
.hf-topology-seal path {
|
|
795
|
+
animation: none;
|
|
796
|
+
transform: none;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
621
800
|
@keyframes hf-fx-preset-wave {
|
|
622
801
|
0%,
|
|
623
802
|
100% {
|
|
@@ -51,7 +51,7 @@ describe("dom edit save queue", () => {
|
|
|
51
51
|
queue.destroy();
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
it("
|
|
54
|
+
it("stops already queued work when the preceding save opens the breaker", async () => {
|
|
55
55
|
const onOpen = vi.fn();
|
|
56
56
|
const onReset = vi.fn();
|
|
57
57
|
const queue = createDomEditSaveQueue({
|
|
@@ -72,13 +72,15 @@ describe("dom edit save queue", () => {
|
|
|
72
72
|
resolveFirstStarted?.();
|
|
73
73
|
}),
|
|
74
74
|
);
|
|
75
|
-
const
|
|
75
|
+
const secondSave = vi.fn(async () => undefined);
|
|
76
|
+
const second = queue.enqueue(secondSave);
|
|
76
77
|
|
|
77
78
|
await firstStarted;
|
|
78
79
|
expect(rejectFirst).toBeTypeOf("function");
|
|
79
80
|
rejectFirst?.(new StudioSaveHttpError("Server down", 503));
|
|
80
81
|
await expect(first).rejects.toThrow("Server down");
|
|
81
|
-
await expect(second).
|
|
82
|
+
await expect(second).rejects.toThrow("Auto-save is paused");
|
|
83
|
+
expect(secondSave).not.toHaveBeenCalled();
|
|
82
84
|
|
|
83
85
|
expect(onOpen).toHaveBeenCalledOnce();
|
|
84
86
|
expect(onReset).not.toHaveBeenCalled();
|
|
@@ -81,7 +81,12 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
|
|
|
81
81
|
return {
|
|
82
82
|
enqueue(save) {
|
|
83
83
|
if (breakerOpen) return Promise.reject(new DomEditSaveQueueOpenError());
|
|
84
|
-
const queued = tail
|
|
84
|
+
const queued = tail
|
|
85
|
+
.catch(() => undefined)
|
|
86
|
+
.then(() => {
|
|
87
|
+
if (breakerOpen) throw new DomEditSaveQueueOpenError();
|
|
88
|
+
return run(save);
|
|
89
|
+
});
|
|
85
90
|
tail = queued.then(
|
|
86
91
|
() => undefined,
|
|
87
92
|
() => undefined,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
|
+
import { vi } from "vitest";
|
|
5
|
+
import { readLiveSelectionBox, resizeSelectionFromAgent } from "./StudioAgentTools";
|
|
6
|
+
|
|
7
|
+
function rect(width: number, height: number): DOMRect {
|
|
8
|
+
return { x: 10, y: 20, width, height } as DOMRect;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("readLiveSelectionBox", () => {
|
|
12
|
+
it("measures the replacement preview node after a commit reload", () => {
|
|
13
|
+
const oldElement = document.createElement("h1");
|
|
14
|
+
oldElement.id = "headline";
|
|
15
|
+
oldElement.getBoundingClientRect = () => rect(300, 50);
|
|
16
|
+
const selection = {
|
|
17
|
+
id: "headline",
|
|
18
|
+
sourceFile: "index.html",
|
|
19
|
+
element: oldElement,
|
|
20
|
+
} as DomEditSelection;
|
|
21
|
+
|
|
22
|
+
const liveDocument = document.implementation.createHTMLDocument();
|
|
23
|
+
const replacement = liveDocument.createElement("h1");
|
|
24
|
+
replacement.id = "headline";
|
|
25
|
+
replacement.getBoundingClientRect = () => rect(280, 60);
|
|
26
|
+
liveDocument.body.append(replacement);
|
|
27
|
+
|
|
28
|
+
expect(readLiveSelectionBox(liveDocument, selection, "index.html")).toEqual({
|
|
29
|
+
x: 10,
|
|
30
|
+
y: 20,
|
|
31
|
+
width: 280,
|
|
32
|
+
height: 60,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not hide a stale target behind its detached old node", () => {
|
|
37
|
+
const oldElement = document.createElement("h1");
|
|
38
|
+
oldElement.id = "headline";
|
|
39
|
+
const selection = {
|
|
40
|
+
id: "headline",
|
|
41
|
+
sourceFile: "index.html",
|
|
42
|
+
element: oldElement,
|
|
43
|
+
} as DomEditSelection;
|
|
44
|
+
const liveDocument = document.implementation.createHTMLDocument();
|
|
45
|
+
|
|
46
|
+
expect(() => readLiveSelectionBox(liveDocument, selection, "index.html")).toThrow(
|
|
47
|
+
"target is missing",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("resizeSelectionFromAgent", () => {
|
|
53
|
+
it("applies the same live draft a pointer gesture shows before persistence", async () => {
|
|
54
|
+
const element = document.createElement("div");
|
|
55
|
+
element.style.width = "300px";
|
|
56
|
+
element.style.height = "50px";
|
|
57
|
+
const selection = { element } as DomEditSelection;
|
|
58
|
+
const commit = vi.fn(async (_selection, _next, _offset, restore: () => void) => {
|
|
59
|
+
expect(element.style.width).toBe("280px");
|
|
60
|
+
expect(element.style.height).toBe("60px");
|
|
61
|
+
restore();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await resizeSelectionFromAgent(selection, { width: 280, height: 60 }, commit);
|
|
65
|
+
|
|
66
|
+
expect(commit).toHaveBeenCalledOnce();
|
|
67
|
+
expect(element.style.width).toBe("300px");
|
|
68
|
+
expect(element.style.height).toBe("50px");
|
|
69
|
+
});
|
|
70
|
+
});
|