@hyperframes/studio 0.8.34 → 0.8.36
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-D4aSRr_M.js → hyperframes-player-BIpJmC69.js} +1 -1
- package/dist/assets/index-BmPeyP0k.js +441 -0
- package/dist/assets/{index-aNyQBoJv.js → index-Df3VgdgL.js} +1 -1
- package/dist/assets/{index-hch4ktcC.js → index-z2_QKypm.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +76 -209
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +0 -7
- package/src/components/StudioRightPanel.tsx +0 -7
- package/src/components/StudioRightPanel.types.ts +0 -2
- package/src/components/editor/SnapToolbar.test.tsx +0 -2
- package/src/components/editor/manualEdits.test.ts +3 -3
- package/src/components/editor/manualEdits.ts +1 -1
- package/src/components/editor/manualEditsParsing.ts +12 -21
- package/src/components/panels/VariablesOtherCompositions.tsx +1 -4
- package/src/components/panels/VariablesPanel.tsx +1 -5
- package/src/components/studioColorGradingScope.ts +0 -4
- package/src/hooks/elementLifecycleOpsTestUtils.ts +0 -1
- package/src/hooks/externalFileChangeOwnership.test.ts +40 -1
- package/src/hooks/gsapScriptCommitTypes.ts +0 -1
- package/src/hooks/sdkSelfWriteRegistry.ts +1 -1
- package/src/hooks/timelineAudioGroupCreate.ts +0 -7
- package/src/hooks/timelineAudioGroupVolume.test.ts +0 -1
- package/src/hooks/timelineAudioGroupVolume.ts +0 -6
- package/src/hooks/timelineEditingHelpers.test.ts +0 -3
- package/src/hooks/timelineEditingHelpers.ts +0 -10
- package/src/hooks/timelineElementFxAttribute.ts +0 -6
- package/src/hooks/timelineTrackVisibility.test.ts +0 -17
- package/src/hooks/timelineTrackVisibility.ts +0 -15
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -1
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -2
- package/src/hooks/useAppHotkeys.ts +1 -7
- package/src/hooks/useAudioGroupCarveAssignment.test.tsx +0 -1
- package/src/hooks/useClipboard.ts +1 -11
- package/src/hooks/useDomEditCommits.test.tsx +0 -2
- package/src/hooks/useDomEditCommits.ts +2 -21
- package/src/hooks/useDomEditSession.test.tsx +0 -1
- package/src/hooks/useDomEditSession.ts +0 -7
- package/src/hooks/useEditorSave.test.tsx +0 -1
- package/src/hooks/useEditorSave.ts +2 -6
- package/src/hooks/useElementLifecycleOps.ts +0 -3
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +54 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +38 -22
- package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
- package/src/hooks/useFileManager.ts +0 -3
- package/src/hooks/useGroupCommits.ts +0 -2
- package/src/hooks/useGsapScriptCommits.test.tsx +0 -2
- package/src/hooks/useGsapScriptCommits.ts +2 -5
- package/src/hooks/usePreviewInteraction.test.ts +14 -5
- package/src/hooks/usePreviewInteraction.ts +3 -3
- package/src/hooks/useProjectCompositionVariables.ts +3 -6
- package/src/hooks/useRazorSplit.history.test.tsx +0 -2
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useRazorSplit.ts +0 -6
- package/src/hooks/useSdkSession.ts +3 -11
- package/src/hooks/useSlideshowPersist.ts +1 -5
- package/src/hooks/useTimelineAssetDropOps.ts +0 -6
- package/src/hooks/useTimelineDeleteOps.ts +0 -4
- package/src/hooks/useTimelineEditing.test.tsx +0 -3
- package/src/hooks/useTimelineEditing.ts +0 -16
- package/src/hooks/useTimelineEditingTypes.ts +0 -1
- package/src/hooks/useTimelineGroupEditing.ts +0 -6
- package/src/hooks/useVariablesPersist.ts +0 -3
- package/src/player/components/Player.tsx +18 -5
- package/src/player/components/playerModuleRetry.test.ts +41 -0
- package/src/player/lib/playbackTypes.ts +7 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +30 -0
- package/src/player/lib/timelineIframeHelpers.ts +16 -0
- package/src/utils/sdkCutover.familyGate.test.ts +0 -1
- package/src/utils/sdkCutover.gate.test.ts +0 -1
- package/src/utils/sdkCutover.test.ts +0 -27
- package/src/utils/sdkEditTransaction.ts +0 -4
- package/src/utils/setSlideshowManifest.test.ts +0 -1
- package/src/utils/studioFileHistory.ts +0 -1
- package/src/webmcp/types.ts +5 -1
- package/src/webmcp/useStudioAgentTools.polyfill.test.tsx +219 -0
- package/src/webmcp/useStudioAgentTools.test.tsx +81 -67
- package/src/webmcp/useStudioAgentTools.ts +49 -28
- package/src/webmcp/webmcpTestUtils.ts +56 -0
- package/dist/assets/index-CkxPQxLJ.js +0 -441
- package/src/hooks/useSdkSession.test.ts +0 -32
|
@@ -322,4 +322,58 @@ describe("external file change coordinator", () => {
|
|
|
322
322
|
expect(reloadPreview).toHaveBeenCalledTimes(2);
|
|
323
323
|
expect(onAcceptedPersistedFileChange).toHaveBeenCalledTimes(2);
|
|
324
324
|
});
|
|
325
|
+
|
|
326
|
+
// `hyperframes preview` serves file-change over SSE, where the delivery is a
|
|
327
|
+
// MessageEvent whose `data` is a JSON STRING. Driven through the test adapter
|
|
328
|
+
// because vitest defines `import.meta.hot`, so the EventSource rung is
|
|
329
|
+
// unreachable here, which is exactly why decoding is shared by all rungs.
|
|
330
|
+
describe("SSE-shaped deliveries", () => {
|
|
331
|
+
const sseDelivery = (payload: unknown) =>
|
|
332
|
+
new MessageEvent("file-change", { data: JSON.stringify(payload) });
|
|
333
|
+
|
|
334
|
+
it("suppresses every reload for Studio's own write", async () => {
|
|
335
|
+
const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
|
|
336
|
+
const reloadPreview = vi.fn();
|
|
337
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
338
|
+
await mountCoordinator({ drainPendingChanges, reloadPreview, onAcceptedPersistedFileChange });
|
|
339
|
+
markStudioWriteToken("studio-write-1");
|
|
340
|
+
|
|
341
|
+
await act(async () =>
|
|
342
|
+
handler?.(sseDelivery({ path: "index.html", version: "v2", writeToken: "studio-write-1" })),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
expect(drainPendingChanges).not.toHaveBeenCalled();
|
|
346
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
347
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("reloads once when one watcher event reaches two subscribers", async () => {
|
|
351
|
+
const reloadPreview = vi.fn();
|
|
352
|
+
await mountCoordinator({ reloadPreview });
|
|
353
|
+
|
|
354
|
+
const external = { path: "index.html", version: "v2" };
|
|
355
|
+
await act(async () => handler?.(sseDelivery(external)));
|
|
356
|
+
await act(async () => handler?.(sseDelivery(external)));
|
|
357
|
+
|
|
358
|
+
expect(reloadPreview).toHaveBeenCalledOnce();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("still reloads for a genuinely external write", async () => {
|
|
362
|
+
const reloadPreview = vi.fn();
|
|
363
|
+
await mountCoordinator({ reloadPreview });
|
|
364
|
+
|
|
365
|
+
await act(async () => handler?.(sseDelivery({ path: "index.html", version: "v9" })));
|
|
366
|
+
|
|
367
|
+
expect(reloadPreview).toHaveBeenCalledOnce();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("drops an unparseable delivery instead of throwing", async () => {
|
|
371
|
+
const reloadPreview = vi.fn();
|
|
372
|
+
await mountCoordinator({ reloadPreview });
|
|
373
|
+
|
|
374
|
+
await act(async () => handler?.(new MessageEvent("file-change", { data: "not json" })));
|
|
375
|
+
|
|
376
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
325
379
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
|
2
|
-
import { readStudioFileChangePath } from "../components/editor/manualEdits";
|
|
2
|
+
import { readFileChangeField, readStudioFileChangePath } from "../components/editor/manualEdits";
|
|
3
3
|
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
4
4
|
import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage";
|
|
5
5
|
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
|
@@ -81,25 +81,39 @@ function testHotAdapter(): HotTestAdapter | null {
|
|
|
81
81
|
: null;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (typeof record.content === "string") return record.content;
|
|
88
|
-
return "data" in record ? readFileChangeContent(record.data) : null;
|
|
89
|
-
}
|
|
84
|
+
const readFileChangeContent = (payload: unknown) => readFileChangeField(payload, "content");
|
|
85
|
+
const readFileChangeVersion = (payload: unknown) => readFileChangeField(payload, "version");
|
|
86
|
+
const readFileChangeWriteToken = (payload: unknown) => readFileChangeField(payload, "writeToken");
|
|
90
87
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Decode one delivery into the payload shape every reader above assumes.
|
|
90
|
+
*
|
|
91
|
+
* SSE delivers a `MessageEvent` whose `data` is a JSON string; the hot-reload
|
|
92
|
+
* transports deliver the payload object. This is the only place that difference
|
|
93
|
+
* exists: a reader handed an undecoded envelope reports every field as absent,
|
|
94
|
+
* which is indistinguishable from a field that is genuinely unset.
|
|
95
|
+
*/
|
|
96
|
+
function decodeFileChange(delivery: unknown): unknown {
|
|
97
|
+
// Structural, not `instanceof`: a polyfilled or cross-realm event must still decode.
|
|
98
|
+
const data = (delivery as { data?: unknown } | null)?.data;
|
|
99
|
+
if (typeof data !== "string") return delivery;
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(data);
|
|
102
|
+
} catch {
|
|
103
|
+
logReload("file-change", { path: null, why: "unparseable payload" });
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
96
106
|
}
|
|
97
107
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
/**
|
|
109
|
+
* The production transport. vitest defines `import.meta.hot`, so the selection
|
|
110
|
+
* below never reaches this rung under test; the rung itself is exported so a test
|
|
111
|
+
* can drive a real `MessageEvent` through the listener it registers.
|
|
112
|
+
*/
|
|
113
|
+
export function sseFileChangeChannel(onDelivery: (delivery: unknown) => void): () => void {
|
|
114
|
+
const eventSource = new EventSource("/api/events");
|
|
115
|
+
eventSource.addEventListener("file-change", onDelivery);
|
|
116
|
+
return () => eventSource.close();
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
function eventIdentity(path: string, payload: unknown): string | null {
|
|
@@ -331,7 +345,10 @@ export function useExternalFileChangeCoordinator({
|
|
|
331
345
|
// fallow-ignore-next-line complexity
|
|
332
346
|
(payload: unknown) => {
|
|
333
347
|
const path = readStudioFileChangePath(payload);
|
|
334
|
-
if (!path || !projectId)
|
|
348
|
+
if (!path || !projectId) {
|
|
349
|
+
logReload("file-change", { path: null, why: path ? "no project" : "no path in payload" });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
335
352
|
pendingTimelineEditPathRef.current.delete(path);
|
|
336
353
|
|
|
337
354
|
const content = readFileChangeContent(payload);
|
|
@@ -362,7 +379,8 @@ export function useExternalFileChangeCoordinator({
|
|
|
362
379
|
);
|
|
363
380
|
|
|
364
381
|
useEffect(() => {
|
|
365
|
-
|
|
382
|
+
// One decoder for all three transports; the rungs only choose the channel.
|
|
383
|
+
const handler = (delivery?: unknown) => processChange(decodeFileChange(delivery));
|
|
366
384
|
const adapter = testHotAdapter();
|
|
367
385
|
if (adapter) {
|
|
368
386
|
adapter.on("hf:file-change", handler);
|
|
@@ -372,9 +390,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
372
390
|
import.meta.hot.on("hf:file-change", handler);
|
|
373
391
|
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
|
374
392
|
}
|
|
375
|
-
|
|
376
|
-
eventSource.addEventListener("file-change", handler);
|
|
377
|
-
return () => eventSource.close();
|
|
393
|
+
return sseFileChangeChannel(handler);
|
|
378
394
|
}, [processChange]);
|
|
379
395
|
|
|
380
396
|
const retry = useCallback(async () => {
|
|
@@ -27,7 +27,6 @@ interface UseFileManagerOptions {
|
|
|
27
27
|
projectId: string | null;
|
|
28
28
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
29
29
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
30
|
-
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
31
30
|
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
|
32
31
|
}
|
|
33
32
|
|
|
@@ -37,7 +36,6 @@ export function useFileManager({
|
|
|
37
36
|
projectId,
|
|
38
37
|
showToast,
|
|
39
38
|
recordEdit,
|
|
40
|
-
domEditSaveTimestampRef,
|
|
41
39
|
setRefreshKey,
|
|
42
40
|
}: UseFileManagerOptions) {
|
|
43
41
|
// ── Shared refs ──
|
|
@@ -198,7 +196,6 @@ export function useFileManager({
|
|
|
198
196
|
readProjectFile,
|
|
199
197
|
writeProjectFile,
|
|
200
198
|
recordEdit,
|
|
201
|
-
domEditSaveTimestampRef,
|
|
202
199
|
setRefreshKey,
|
|
203
200
|
showToast,
|
|
204
201
|
});
|
|
@@ -64,7 +64,6 @@ async function commitStructuralMutation(
|
|
|
64
64
|
UseGroupCommitsParams,
|
|
65
65
|
| "writeProjectFile"
|
|
66
66
|
| "editHistory"
|
|
67
|
-
| "domEditSaveTimestampRef"
|
|
68
67
|
| "clearDomSelection"
|
|
69
68
|
| "forceReloadSdkSession"
|
|
70
69
|
| "reloadPreview"
|
|
@@ -72,7 +71,6 @@ async function commitStructuralMutation(
|
|
|
72
71
|
): Promise<{ content?: string; groupId?: string }> {
|
|
73
72
|
const originalContent = await readProjectFileContent(pid, targetPath);
|
|
74
73
|
|
|
75
|
-
deps.domEditSaveTimestampRef.current = Date.now();
|
|
76
74
|
const mutateResponse = await fetch(
|
|
77
75
|
buildProjectApiPath(pid, `/file-mutations/${route}/${encodeURIComponent(targetPath)}`),
|
|
78
76
|
{
|
|
@@ -379,7 +379,6 @@ function renderCommitHook(
|
|
|
379
379
|
activeCompPath: "index.html",
|
|
380
380
|
previewIframeRef: { current: FAKE_IFRAME },
|
|
381
381
|
editHistory: { recordEdit },
|
|
382
|
-
domEditSaveTimestampRef: { current: 0 },
|
|
383
382
|
reloadPreview,
|
|
384
383
|
onCacheInvalidate,
|
|
385
384
|
onFileContentChanged,
|
|
@@ -669,7 +668,6 @@ describe("runCommit — instantPatch wiring", () => {
|
|
|
669
668
|
writeProjectFile,
|
|
670
669
|
readProjectFile: vi.fn(async () => "BEFORE"),
|
|
671
670
|
reloadPreview: deps.reloadPreview,
|
|
672
|
-
domEditSaveTimestampRef: { current: 0 },
|
|
673
671
|
});
|
|
674
672
|
await sdkWriteStarted;
|
|
675
673
|
|
|
@@ -313,7 +313,7 @@ function instantPatchesFor(
|
|
|
313
313
|
|
|
314
314
|
// oxfmt-ignore
|
|
315
315
|
// fallow-ignore-next-line complexity
|
|
316
|
-
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory,
|
|
316
|
+
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, publishSdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
|
|
317
317
|
const activeProjectId = projectIdRef.current;
|
|
318
318
|
const activeCompPathRef = useRef(activeCompPath);
|
|
319
319
|
activeCompPathRef.current = activeCompPath;
|
|
@@ -348,7 +348,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
348
348
|
}
|
|
349
349
|
return;
|
|
350
350
|
}
|
|
351
|
-
if (previewIsActive) domEditSaveTimestampRef.current = Date.now();
|
|
352
351
|
await recordMutationEdit(targetPath, result, options);
|
|
353
352
|
// The durable mutation belongs to the project captured when it was queued.
|
|
354
353
|
// A later project must never receive its file state or preview refresh.
|
|
@@ -365,7 +364,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
365
364
|
reloadPreview,
|
|
366
365
|
onCacheInvalidate,
|
|
367
366
|
});
|
|
368
|
-
}, [projectIdRef, previewIframeRef,
|
|
367
|
+
}, [projectIdRef, previewIframeRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
|
|
369
368
|
|
|
370
369
|
const runCommit = useCallback(async (pid: string, compositionPath: string | null, targetPath: string, selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
|
|
371
370
|
const result = await runMutationRequest([mutation], options, showToast, () =>
|
|
@@ -494,7 +493,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
494
493
|
editHistory: { recordEdit: editHistory.recordEdit },
|
|
495
494
|
writeProjectFile,
|
|
496
495
|
reloadPreview,
|
|
497
|
-
domEditSaveTimestampRef,
|
|
498
496
|
refresh: sdkRefresh,
|
|
499
497
|
compositionPath: activeCompPath,
|
|
500
498
|
readProjectFile: readProjectFileContent,
|
|
@@ -505,7 +503,6 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
505
503
|
editHistory.recordEdit,
|
|
506
504
|
writeProjectFile,
|
|
507
505
|
reloadPreview,
|
|
508
|
-
domEditSaveTimestampRef,
|
|
509
506
|
sdkRefresh,
|
|
510
507
|
activeCompPath,
|
|
511
508
|
readProjectFileContent,
|
|
@@ -224,12 +224,17 @@ describe("usePreviewInteraction", () => {
|
|
|
224
224
|
cleanup();
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
-
|
|
228
|
-
|
|
227
|
+
// The deselect resume asks the PLAYER to play; it must never move the store's
|
|
228
|
+
// flag on its own. The runtime really was paused (playerPause below), so a bare
|
|
229
|
+
// setIsPlaying(true) leaves the button reading "playing" over a stopped runtime
|
|
230
|
+
// with nothing able to reconcile the two.
|
|
231
|
+
it("requests a real resume when a click resolves to nothing (dead-zone / deselect)", async () => {
|
|
232
|
+
usePlayerStore.setState({ isPlaying: true, playbackRequest: null });
|
|
233
|
+
const playerPause = vi.fn();
|
|
229
234
|
const applyDomSelection = vi.fn();
|
|
230
235
|
const resolveDomSelectionFromPreviewPoint = vi.fn(async () => null);
|
|
231
236
|
const { canvas, cleanup } = renderHarness({
|
|
232
|
-
previewIframe: createPreviewIframe(
|
|
237
|
+
previewIframe: createPreviewIframe(playerPause),
|
|
233
238
|
resolveDomSelectionFromPreviewPoint,
|
|
234
239
|
applyDomSelection,
|
|
235
240
|
});
|
|
@@ -237,12 +242,15 @@ describe("usePreviewInteraction", () => {
|
|
|
237
242
|
await dispatchMouseDown(canvas, {});
|
|
238
243
|
|
|
239
244
|
expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false });
|
|
240
|
-
expect(
|
|
245
|
+
expect(playerPause).toHaveBeenCalled();
|
|
246
|
+
expect(usePlayerStore.getState().playbackRequest).toMatchObject({ playing: true });
|
|
247
|
+
// Only the player's own play() may raise this, once the runtime is running.
|
|
248
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
241
249
|
cleanup();
|
|
242
250
|
});
|
|
243
251
|
|
|
244
252
|
it("does not resume playback on deselect when it was already paused", async () => {
|
|
245
|
-
usePlayerStore.setState({ isPlaying: false });
|
|
253
|
+
usePlayerStore.setState({ isPlaying: false, playbackRequest: null });
|
|
246
254
|
const applyDomSelection = vi.fn();
|
|
247
255
|
const resolveDomSelectionFromPreviewPoint = vi.fn(async () => null);
|
|
248
256
|
const { canvas, cleanup } = renderHarness({
|
|
@@ -255,6 +263,7 @@ describe("usePreviewInteraction", () => {
|
|
|
255
263
|
|
|
256
264
|
expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false });
|
|
257
265
|
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
266
|
+
expect(usePlayerStore.getState().playbackRequest).toBeNull();
|
|
258
267
|
cleanup();
|
|
259
268
|
});
|
|
260
269
|
});
|
|
@@ -101,10 +101,10 @@ export function usePreviewInteraction({
|
|
|
101
101
|
const wasPlaying = usePlayerStore.getState().isPlaying;
|
|
102
102
|
pausePreviewPlayback();
|
|
103
103
|
// A click that resolves to nothing (dead-zone / deselect) shouldn't leave
|
|
104
|
-
// playback paused
|
|
105
|
-
//
|
|
104
|
+
// playback paused; the pause only keeps the hit target stable while resolving.
|
|
105
|
+
// Resume through requestPlayback so adapter, rAF loop and flag move together.
|
|
106
106
|
const resumeIfNothingSelected = () => {
|
|
107
|
-
if (wasPlaying) usePlayerStore.getState().
|
|
107
|
+
if (wasPlaying) usePlayerStore.getState().requestPlayback(true);
|
|
108
108
|
};
|
|
109
109
|
|
|
110
110
|
// Double-click a group → drill into it and select the child under the
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
2
|
import { openComposition, type Composition, type CompositionVariable } from "@hyperframes/sdk";
|
|
3
3
|
import { persistSdkSerialize } from "../utils/sdkCutover";
|
|
4
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
@@ -87,7 +87,6 @@ interface EditVariablesDeps {
|
|
|
87
87
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
88
88
|
recordEdit: RecordEditFn;
|
|
89
89
|
reloadPreview: () => void;
|
|
90
|
-
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
91
90
|
}
|
|
92
91
|
|
|
93
92
|
/**
|
|
@@ -98,8 +97,7 @@ interface EditVariablesDeps {
|
|
|
98
97
|
* but keyed on `path` rather than a live session.
|
|
99
98
|
*/
|
|
100
99
|
export function useEditVariablesInFile(deps: EditVariablesDeps) {
|
|
101
|
-
const { readProjectFile, writeProjectFile, recordEdit, reloadPreview
|
|
102
|
-
deps;
|
|
100
|
+
const { readProjectFile, writeProjectFile, recordEdit, reloadPreview } = deps;
|
|
103
101
|
return useCallback(
|
|
104
102
|
async (path: string, label: string, mutate: (session: Composition) => void): Promise<void> => {
|
|
105
103
|
const originalContent = await readProjectFile(path);
|
|
@@ -119,13 +117,12 @@ export function useEditVariablesInFile(deps: EditVariablesDeps) {
|
|
|
119
117
|
editHistory: { recordEdit },
|
|
120
118
|
writeProjectFile,
|
|
121
119
|
reloadPreview,
|
|
122
|
-
domEditSaveTimestampRef,
|
|
123
120
|
compositionPath: path,
|
|
124
121
|
readProjectFile,
|
|
125
122
|
},
|
|
126
123
|
{ label },
|
|
127
124
|
);
|
|
128
125
|
},
|
|
129
|
-
[readProjectFile, writeProjectFile, recordEdit, reloadPreview
|
|
126
|
+
[readProjectFile, writeProjectFile, recordEdit, reloadPreview],
|
|
130
127
|
);
|
|
131
128
|
}
|
|
@@ -117,7 +117,6 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
|
|
|
117
117
|
disk[path] = content;
|
|
118
118
|
},
|
|
119
119
|
recordEdit: store.recordEdit,
|
|
120
|
-
domEditSaveTimestampRef: { current: 0 },
|
|
121
120
|
reloadPreview: () => {
|
|
122
121
|
if (!opts.previewStamp) return;
|
|
123
122
|
const stamped = ensureHfIds(disk["index.html"]);
|
|
@@ -258,7 +257,6 @@ function mountRazorSplitAll(failOnSplit?: number): SplitAllHarness {
|
|
|
258
257
|
disk[path] = content;
|
|
259
258
|
},
|
|
260
259
|
recordEdit: store.recordEdit,
|
|
261
|
-
domEditSaveTimestampRef: { current: 0 },
|
|
262
260
|
reloadPreview: () => {},
|
|
263
261
|
});
|
|
264
262
|
splitAllRef.current = handleRazorSplitAll;
|
|
@@ -88,7 +88,6 @@ function mountRazorSplit(): Harness {
|
|
|
88
88
|
disk[path] = content;
|
|
89
89
|
},
|
|
90
90
|
recordEdit: async () => {},
|
|
91
|
-
domEditSaveTimestampRef: { current: 0 },
|
|
92
91
|
reloadPreview: () => {},
|
|
93
92
|
});
|
|
94
93
|
singleRef.current = handleRazorSplit;
|
|
@@ -211,7 +210,6 @@ function mountRazorSplitWithHistory(): UndoHarness {
|
|
|
211
210
|
disk[path] = content;
|
|
212
211
|
},
|
|
213
212
|
recordEdit: (input) => store.recordEdit(input),
|
|
214
|
-
domEditSaveTimestampRef: { current: 0 },
|
|
215
213
|
reloadPreview: () => {},
|
|
216
214
|
forceReloadSdkSession,
|
|
217
215
|
});
|
|
@@ -15,7 +15,6 @@ interface UseRazorSplitOptions {
|
|
|
15
15
|
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
16
16
|
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
|
17
17
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
18
|
-
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
19
18
|
reloadPreview: () => void;
|
|
20
19
|
forceReloadSdkSession?: () => void;
|
|
21
20
|
isRecordingRef?: React.RefObject<boolean>;
|
|
@@ -28,7 +27,6 @@ export function useRazorSplit({
|
|
|
28
27
|
writeProjectFile,
|
|
29
28
|
observeProjectFileVersion,
|
|
30
29
|
recordEdit,
|
|
31
|
-
domEditSaveTimestampRef,
|
|
32
30
|
reloadPreview,
|
|
33
31
|
forceReloadSdkSession,
|
|
34
32
|
isRecordingRef,
|
|
@@ -62,9 +60,6 @@ export function useRazorSplit({
|
|
|
62
60
|
? "Split timeline clip"
|
|
63
61
|
: `Split ${requestedCount} clips at ${splitTime.toFixed(2)}s`;
|
|
64
62
|
|
|
65
|
-
// Server writes arrive through the watcher before React can refresh. Keep
|
|
66
|
-
// the existing short self-write window active for this owned transaction.
|
|
67
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
68
63
|
const result = await runAtomicCutTransaction({
|
|
69
64
|
projectId: pid,
|
|
70
65
|
intents,
|
|
@@ -91,7 +86,6 @@ export function useRazorSplit({
|
|
|
91
86
|
},
|
|
92
87
|
[
|
|
93
88
|
activeCompPath,
|
|
94
|
-
domEditSaveTimestampRef,
|
|
95
89
|
observeProjectFileVersion,
|
|
96
90
|
recordEdit,
|
|
97
91
|
showToast,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import { openComposition } from "@hyperframes/sdk";
|
|
3
3
|
import type { Composition } from "@hyperframes/sdk";
|
|
4
|
-
import { readStudioFileChangePath } from "../components/editor/manualEdits";
|
|
5
4
|
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
|
6
5
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
7
6
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
@@ -29,15 +28,6 @@ async function readProjectFileOptional(
|
|
|
29
28
|
return typeof data.content === "string" ? data.content : undefined;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
/**
|
|
33
|
-
* True when an external file-change payload targets the active composition and
|
|
34
|
-
* the SDK session must be re-opened to pick up the new content.
|
|
35
|
-
*/
|
|
36
|
-
export function shouldReloadSdkSession(payload: unknown, activeCompPath: string | null): boolean {
|
|
37
|
-
if (!activeCompPath) return false;
|
|
38
|
-
return readStudioFileChangePath(payload) === activeCompPath;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
31
|
/**
|
|
42
32
|
* Stage 7 Step 3a — SDK session wired to the active composition.
|
|
43
33
|
*
|
|
@@ -73,7 +63,9 @@ export interface SdkSessionHandle {
|
|
|
73
63
|
/**
|
|
74
64
|
* Force a session reload immediately, bypassing the self-write suppress
|
|
75
65
|
* window. Call after undo/redo writes the active composition file so the
|
|
76
|
-
* SDK in-memory document reflects the reverted content.
|
|
66
|
+
* SDK in-memory document reflects the reverted content. Without it the
|
|
67
|
+
* window swallows the file-change and the session stays stale; the write
|
|
68
|
+
* side of that path is covered by usePersistentEditHistory.test.ts.
|
|
77
69
|
*/
|
|
78
70
|
forceReload: () => void;
|
|
79
71
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback
|
|
1
|
+
import { useCallback } from "react";
|
|
2
2
|
import type { Composition } from "@hyperframes/sdk";
|
|
3
3
|
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
|
|
4
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
@@ -16,7 +16,6 @@ export interface UseSlideshowPersistParams {
|
|
|
16
16
|
files: Record<string, { before: string; after: string }>;
|
|
17
17
|
}) => Promise<void>;
|
|
18
18
|
reloadPreview: () => void;
|
|
19
|
-
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
20
19
|
/** Publish a fully persisted candidate SDK session. */
|
|
21
20
|
publishSdkSession?: PublishSdkSession;
|
|
22
21
|
/**
|
|
@@ -35,7 +34,6 @@ export function useSlideshowPersist({
|
|
|
35
34
|
writeProjectFile,
|
|
36
35
|
recordEdit,
|
|
37
36
|
reloadPreview,
|
|
38
|
-
domEditSaveTimestampRef,
|
|
39
37
|
publishSdkSession,
|
|
40
38
|
coalesceKey,
|
|
41
39
|
}: UseSlideshowPersistParams): (manifest: SlideshowManifest) => Promise<void> {
|
|
@@ -52,7 +50,6 @@ export function useSlideshowPersist({
|
|
|
52
50
|
editHistory: { recordEdit },
|
|
53
51
|
writeProjectFile,
|
|
54
52
|
reloadPreview,
|
|
55
|
-
domEditSaveTimestampRef,
|
|
56
53
|
readProjectFile,
|
|
57
54
|
publishSession: publishSdkSession,
|
|
58
55
|
},
|
|
@@ -66,7 +63,6 @@ export function useSlideshowPersist({
|
|
|
66
63
|
writeProjectFile,
|
|
67
64
|
recordEdit,
|
|
68
65
|
reloadPreview,
|
|
69
|
-
domEditSaveTimestampRef,
|
|
70
66
|
publishSdkSession,
|
|
71
67
|
coalesceKey,
|
|
72
68
|
],
|
|
@@ -29,7 +29,6 @@ interface UseTimelineAssetDropOpsOptions {
|
|
|
29
29
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
30
30
|
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
31
31
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
32
|
-
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
33
32
|
reloadPreview: () => void;
|
|
34
33
|
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
|
35
34
|
isRecordingRef?: RefObject<boolean>;
|
|
@@ -44,7 +43,6 @@ export function useTimelineAssetDropOps({
|
|
|
44
43
|
showToast,
|
|
45
44
|
writeProjectFile,
|
|
46
45
|
recordEdit,
|
|
47
|
-
domEditSaveTimestampRef,
|
|
48
46
|
reloadPreview,
|
|
49
47
|
uploadProjectFiles,
|
|
50
48
|
isRecordingRef,
|
|
@@ -109,7 +107,6 @@ export function useTimelineAssetDropOps({
|
|
|
109
107
|
}),
|
|
110
108
|
);
|
|
111
109
|
|
|
112
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
113
110
|
await saveProjectFilesWithHistory({
|
|
114
111
|
projectId: pid,
|
|
115
112
|
label: "Add timeline asset",
|
|
@@ -135,7 +132,6 @@ export function useTimelineAssetDropOps({
|
|
|
135
132
|
showToast,
|
|
136
133
|
timelineElements,
|
|
137
134
|
writeProjectFile,
|
|
138
|
-
domEditSaveTimestampRef,
|
|
139
135
|
reloadPreview,
|
|
140
136
|
isRecordingRef,
|
|
141
137
|
forceReloadSdkSession,
|
|
@@ -198,7 +194,6 @@ export function useTimelineAssetDropOps({
|
|
|
198
194
|
resync: forceReloadSdkSession,
|
|
199
195
|
refresh: reloadPreview,
|
|
200
196
|
});
|
|
201
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
202
197
|
showToast("Composition added to the timeline.", "info");
|
|
203
198
|
} catch (error) {
|
|
204
199
|
showToast(
|
|
@@ -209,7 +204,6 @@ export function useTimelineAssetDropOps({
|
|
|
209
204
|
},
|
|
210
205
|
[
|
|
211
206
|
activeCompPath,
|
|
212
|
-
domEditSaveTimestampRef,
|
|
213
207
|
forceReloadSdkSession,
|
|
214
208
|
isRecordingRef,
|
|
215
209
|
observeProjectFileVersion,
|
|
@@ -20,7 +20,6 @@ interface UseTimelineDeleteOpsOptions {
|
|
|
20
20
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
21
21
|
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
22
22
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
23
|
-
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
24
23
|
reloadPreview: () => void;
|
|
25
24
|
isRecordingRef?: MutableRefObject<boolean>;
|
|
26
25
|
forceReloadSdkSession?: () => void;
|
|
@@ -34,7 +33,6 @@ export function useTimelineDeleteOps({
|
|
|
34
33
|
showToast,
|
|
35
34
|
writeProjectFile,
|
|
36
35
|
recordEdit,
|
|
37
|
-
domEditSaveTimestampRef,
|
|
38
36
|
reloadPreview,
|
|
39
37
|
isRecordingRef,
|
|
40
38
|
forceReloadSdkSession,
|
|
@@ -111,7 +109,6 @@ export function useTimelineDeleteOps({
|
|
|
111
109
|
usePlayerStore.getState().setDuration(deleteContentEnd);
|
|
112
110
|
}
|
|
113
111
|
|
|
114
|
-
domEditSaveTimestampRef.current = Date.now();
|
|
115
112
|
try {
|
|
116
113
|
await saveProjectFilesWithHistory({
|
|
117
114
|
projectId: pid,
|
|
@@ -154,7 +151,6 @@ export function useTimelineDeleteOps({
|
|
|
154
151
|
showToast,
|
|
155
152
|
timelineElements,
|
|
156
153
|
writeProjectFile,
|
|
157
|
-
domEditSaveTimestampRef,
|
|
158
154
|
reloadPreview,
|
|
159
155
|
isRecordingRef,
|
|
160
156
|
forceReloadSdkSession,
|
|
@@ -147,7 +147,6 @@ function renderTimelineEditingHook(input: {
|
|
|
147
147
|
showToast: input.showToast ?? vi.fn(),
|
|
148
148
|
writeProjectFile: input.writeProjectFile ?? vi.fn(),
|
|
149
149
|
recordEdit: input.recordEdit ?? vi.fn(),
|
|
150
|
-
domEditSaveTimestampRef: { current: 0 },
|
|
151
150
|
reloadPreview: input.reloadPreview ?? vi.fn(),
|
|
152
151
|
previewIframeRef: { current: input.iframe },
|
|
153
152
|
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
@@ -201,7 +200,6 @@ function renderTimelineEditingHookWithLifecycle(input: {
|
|
|
201
200
|
activeCompPath: "index.html",
|
|
202
201
|
showToast: vi.fn(),
|
|
203
202
|
writeProjectFile: vi.fn(),
|
|
204
|
-
domEditSaveTimestampRef: { current: 0 },
|
|
205
203
|
editHistory: { recordEdit: vi.fn() },
|
|
206
204
|
projectIdRef: { current: "p1" },
|
|
207
205
|
reloadPreview: vi.fn(),
|
|
@@ -217,7 +215,6 @@ function renderTimelineEditingHookWithLifecycle(input: {
|
|
|
217
215
|
showToast: vi.fn(),
|
|
218
216
|
writeProjectFile: vi.fn(),
|
|
219
217
|
recordEdit: vi.fn(),
|
|
220
|
-
domEditSaveTimestampRef: { current: 0 },
|
|
221
218
|
reloadPreview: vi.fn(),
|
|
222
219
|
previewIframeRef: { current: input.iframe },
|
|
223
220
|
pendingTimelineEditPathRef: { current: new Set<string>() },
|