@hyperframes/studio 0.8.46 → 0.8.47
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-B7fsB8C2.js +464 -0
- package/dist/assets/{index-BywY2OM3.js → index-B50xQfSG.js} +1 -1
- package/dist/assets/{index-DCblmTX3.js → index-j-4hCrZU.js} +130 -130
- package/dist/assets/{index-CbchRscN.js → index-ryL7q1QG.js} +1 -1
- package/dist/index.d.ts +17 -3
- package/dist/index.html +1 -1
- package/dist/index.js +103 -40
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/manualEditingAvailability.test.ts +19 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -5
- package/src/hooks/useSdkSession.lifecycle.test.tsx +76 -0
- package/src/hooks/useSdkSession.ts +21 -3
- package/src/player/hooks/useTimelinePlayer.ts +9 -4
- package/src/player/hooks/useTimelineSyncCallbacks.ts +7 -4
- package/src/player/store/playerStore.test.ts +42 -0
- package/src/player/store/playerStore.ts +9 -7
- package/src/player/store/readinessSlice.ts +42 -0
- package/src/utils/sdkCutover.gate.test.ts +5 -4
- package/src/utils/sdkCutover.test.ts +42 -0
- package/src/utils/sdkCutover.ts +46 -25
- package/src/utils/sdkCutoverEligibility.ts +21 -0
- package/src/utils/sdkCutoverPolicy.test.ts +11 -2
- package/src/utils/sdkCutoverPolicy.ts +5 -3
- package/src/utils/sdkEditTransaction.decline.test.ts +46 -0
- package/src/utils/sdkEditTransaction.ts +22 -1
- package/src/utils/sdkResolverShadow.ts +10 -1
- package/dist/assets/hyperframes-player-CsV2rywT.js +0 -464
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.47",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
"gsap": "^3.13.0",
|
|
49
49
|
"marked": "^14.1.4",
|
|
50
50
|
"mediabunny": "^1.45.3",
|
|
51
|
-
"@hyperframes/core": "0.8.
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/studio-server": "0.8.
|
|
55
|
-
"@hyperframes/
|
|
51
|
+
"@hyperframes/core": "0.8.47",
|
|
52
|
+
"@hyperframes/sdk": "0.8.47",
|
|
53
|
+
"@hyperframes/parsers": "0.8.47",
|
|
54
|
+
"@hyperframes/studio-server": "0.8.47",
|
|
55
|
+
"@hyperframes/player": "0.8.47"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/react": "19",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"vite": "^6.4.2",
|
|
70
70
|
"vitest": "^4.1.11",
|
|
71
71
|
"zustand": "^5.0.0",
|
|
72
|
-
"@hyperframes/producer": "0.8.
|
|
72
|
+
"@hyperframes/producer": "0.8.47"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"react": "19",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { resolveStudioBooleanEnvFlag } from "./manualEditingAvailability";
|
|
3
|
+
import { STUDIO_SDK_OPERATION_FAMILIES } from "../../utils/sdkCutoverPolicy";
|
|
3
4
|
|
|
4
5
|
async function loadAvailabilityWithEnv(env: Record<string, string | undefined>) {
|
|
5
6
|
vi.resetModules();
|
|
@@ -88,4 +89,22 @@ describe("manual editing availability", () => {
|
|
|
88
89
|
});
|
|
89
90
|
expect(off.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(false);
|
|
90
91
|
});
|
|
92
|
+
|
|
93
|
+
it("defaults SDK cutover on for every family", async () => {
|
|
94
|
+
const all = await loadAvailabilityWithEnv({});
|
|
95
|
+
expect(all.STUDIO_SDK_CUTOVER_ENABLED).toBe(true);
|
|
96
|
+
expect(all.STUDIO_SDK_CUTOVER_FAMILIES).toEqual(new Set(STUDIO_SDK_OPERATION_FAMILIES));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("honors the SDK cutover kill switches", async () => {
|
|
100
|
+
const off = await loadAvailabilityWithEnv({ VITE_STUDIO_SDK_CUTOVER_ENABLED: "false" });
|
|
101
|
+
expect(off.STUDIO_SDK_CUTOVER_ENABLED).toBe(false);
|
|
102
|
+
expect(off.STUDIO_SDK_CUTOVER_FAMILIES.size).toBe(0);
|
|
103
|
+
|
|
104
|
+
const subset = await loadAvailabilityWithEnv({
|
|
105
|
+
VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom,timing",
|
|
106
|
+
});
|
|
107
|
+
expect(subset.STUDIO_SDK_CUTOVER_ENABLED).toBe(true);
|
|
108
|
+
expect(subset.STUDIO_SDK_CUTOVER_FAMILIES).toEqual(new Set(["dom", "timing"]));
|
|
109
|
+
});
|
|
91
110
|
});
|
|
@@ -38,16 +38,20 @@ const runtimeEnv =
|
|
|
38
38
|
: {};
|
|
39
39
|
const env = { ...(import.meta.env ?? {}), ...runtimeEnv } as StudioFeatureFlagEnv;
|
|
40
40
|
|
|
41
|
-
// Stage 7
|
|
42
|
-
//
|
|
43
|
-
//
|
|
41
|
+
// Stage 7 SDK cutover — routes Studio edits through SDK dispatch instead of the
|
|
42
|
+
// server patch/mutation routes. Default TRUE as of the flip release (spec:
|
|
43
|
+
// 2026-09-17-studio-sdk-cutover-flip-design.md). Kill switch:
|
|
44
|
+
// VITE_STUDIO_SDK_CUTOVER_ENABLED=false (works at runtime via __HF_STUDIO_ENV__).
|
|
44
45
|
export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
|
|
45
46
|
env,
|
|
46
47
|
["VITE_STUDIO_SDK_CUTOVER_ENABLED"],
|
|
47
|
-
|
|
48
|
+
true,
|
|
48
49
|
);
|
|
49
50
|
|
|
50
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Per-family selection. Unset = every family (the master alone enables all).
|
|
53
|
+
* Set `VITE_STUDIO_SDK_CUTOVER_FAMILIES=dom,timing` to RESTRICT to a subset.
|
|
54
|
+
*/
|
|
51
55
|
export const STUDIO_SDK_CUTOVER_FAMILIES = resolveEnabledSdkFamilies(
|
|
52
56
|
env,
|
|
53
57
|
STUDIO_SDK_CUTOVER_ENABLED,
|
|
@@ -12,6 +12,17 @@ vi.mock("@hyperframes/sdk", () => ({
|
|
|
12
12
|
import type { Composition } from "@hyperframes/sdk";
|
|
13
13
|
import { useSdkSession, type SdkSessionHandle } from "./useSdkSession";
|
|
14
14
|
|
|
15
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
16
|
+
|
|
17
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
18
|
+
|
|
19
|
+
const trackMock = vi.mocked(trackStudioEvent);
|
|
20
|
+
|
|
21
|
+
function Probe({ projectId }: { projectId: string }) {
|
|
22
|
+
useSdkSession(projectId, "index.html");
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
27
|
|
|
17
28
|
function fakeSession(): Composition {
|
|
@@ -135,3 +146,68 @@ describe("useSdkSession ownership", () => {
|
|
|
135
146
|
expect(published.dispose).toHaveBeenCalledOnce();
|
|
136
147
|
});
|
|
137
148
|
});
|
|
149
|
+
|
|
150
|
+
describe("useSdkSession unavailable telemetry", () => {
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
openComposition.mockReset();
|
|
153
|
+
trackMock.mockClear();
|
|
154
|
+
class FakeEventSource {
|
|
155
|
+
addEventListener(): void {}
|
|
156
|
+
close(): void {}
|
|
157
|
+
}
|
|
158
|
+
vi.stubGlobal("EventSource", FakeEventSource);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
afterEach(() => {
|
|
162
|
+
vi.unstubAllGlobals();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Every cutover chokepoint silently takes the server path when there is no
|
|
166
|
+
// session, and the shadow never runs either — so a missing session is a total,
|
|
167
|
+
// otherwise-invisible SDK bypass. These three exits are its only origin.
|
|
168
|
+
it("reports an unreadable composition", async () => {
|
|
169
|
+
vi.stubGlobal(
|
|
170
|
+
"fetch",
|
|
171
|
+
vi.fn(async () => ({ ok: false, json: async () => ({}) }) as Response),
|
|
172
|
+
);
|
|
173
|
+
const root = createRoot(document.createElement("div"));
|
|
174
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
175
|
+
await flushAsyncEffects();
|
|
176
|
+
|
|
177
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", { stage: "read" });
|
|
178
|
+
await act(async () => root.unmount());
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("reports a parse failure with its message", async () => {
|
|
182
|
+
vi.stubGlobal(
|
|
183
|
+
"fetch",
|
|
184
|
+
vi.fn(async () => response("PROJECT_A")),
|
|
185
|
+
);
|
|
186
|
+
openComposition.mockRejectedValue(new Error("unparseable composition"));
|
|
187
|
+
|
|
188
|
+
const root = createRoot(document.createElement("div"));
|
|
189
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
190
|
+
await flushAsyncEffects();
|
|
191
|
+
|
|
192
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
193
|
+
stage: "open",
|
|
194
|
+
error: "unparseable composition",
|
|
195
|
+
});
|
|
196
|
+
await act(async () => root.unmount());
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("stays silent on the happy path", async () => {
|
|
200
|
+
vi.stubGlobal(
|
|
201
|
+
"fetch",
|
|
202
|
+
vi.fn(async () => response("PROJECT_A")),
|
|
203
|
+
);
|
|
204
|
+
openComposition.mockResolvedValue(fakeSession());
|
|
205
|
+
|
|
206
|
+
const root = createRoot(document.createElement("div"));
|
|
207
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
208
|
+
await flushAsyncEffects();
|
|
209
|
+
|
|
210
|
+
expect(trackMock).not.toHaveBeenCalledWith("sdk_session_unavailable", expect.anything());
|
|
211
|
+
await act(async () => root.unmount());
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -181,7 +181,15 @@ export function useSdkSession(
|
|
|
181
181
|
|
|
182
182
|
readProjectFileOptional(projectId, activeCompPath)
|
|
183
183
|
.then(async (content) => {
|
|
184
|
-
if (cancelled
|
|
184
|
+
if (cancelled) return;
|
|
185
|
+
if (typeof content !== "string") {
|
|
186
|
+
// No SDK session follows, so EVERY cutover chokepoint below takes the
|
|
187
|
+
// server path and emits nothing — the shadow never runs either. This
|
|
188
|
+
// is the only place a missing session can originate, so a broken read
|
|
189
|
+
// would otherwise be a silent, total SDK bypass.
|
|
190
|
+
trackStudioEvent("sdk_session_unavailable", { stage: "read" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
185
193
|
// No persist queue: Studio's writeProjectFile (via sdkCutover's
|
|
186
194
|
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
|
|
187
195
|
// queue too would double-write the file (queue auto-writes on every
|
|
@@ -206,6 +214,7 @@ export function useSdkSession(
|
|
|
206
214
|
)
|
|
207
215
|
) {
|
|
208
216
|
disposeSdkSession(comp);
|
|
217
|
+
trackStudioEvent("sdk_session_unavailable", { stage: "ownership" });
|
|
209
218
|
return;
|
|
210
219
|
}
|
|
211
220
|
const displaced = ownedSessionRef.current;
|
|
@@ -215,8 +224,17 @@ export function useSdkSession(
|
|
|
215
224
|
setOwnedSession(installed);
|
|
216
225
|
if (displaced && displaced.session !== comp) disposeSdkSession(displaced.session);
|
|
217
226
|
})
|
|
218
|
-
.catch(() => {
|
|
219
|
-
if (!cancelled && generationRef.current === generation)
|
|
227
|
+
.catch((error: unknown) => {
|
|
228
|
+
if (!cancelled && generationRef.current === generation) {
|
|
229
|
+
setOwnedSession(null);
|
|
230
|
+
// openComposition threw (unparseable composition, OOM) — same total
|
|
231
|
+
// bypass as the read failure above, but this one is a real defect
|
|
232
|
+
// rather than a missing file. Carry the message; it is the only clue.
|
|
233
|
+
trackStudioEvent("sdk_session_unavailable", {
|
|
234
|
+
stage: "open",
|
|
235
|
+
error: error instanceof Error ? error.message : String(error),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
220
238
|
});
|
|
221
239
|
|
|
222
240
|
return () => {
|
|
@@ -45,6 +45,7 @@ import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../li
|
|
|
45
45
|
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
|
46
46
|
import { createPreviewMessageHandler } from "./previewMessageRouter";
|
|
47
47
|
import { timelineElementsChanged } from "./timelinePlayerSync";
|
|
48
|
+
import { safeContentDocument } from "./timelineSyncHydration";
|
|
48
49
|
|
|
49
50
|
export function useTimelinePlayer() {
|
|
50
51
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
@@ -60,7 +61,7 @@ export function useTimelinePlayer() {
|
|
|
60
61
|
const staticSeekAdapterRef = useRef<StaticSeekCacheEntry | null>(null);
|
|
61
62
|
const staticSeekWarnedRef = useRef(false);
|
|
62
63
|
|
|
63
|
-
const { setIsPlaying, setCurrentTime, setDuration,
|
|
64
|
+
const { setIsPlaying, setCurrentTime, setDuration, requestTimelineReady, setElements } =
|
|
64
65
|
usePlayerStore.getState();
|
|
65
66
|
|
|
66
67
|
// The fixture lease belongs at this shared synchronization boundary so every
|
|
@@ -101,7 +102,11 @@ export function useTimelinePlayer() {
|
|
|
101
102
|
setDuration(nextDuration ?? 0);
|
|
102
103
|
}
|
|
103
104
|
if (!state.timelineReady) {
|
|
104
|
-
|
|
105
|
+
// Same gate as initializeAdapter's own readiness wait: this is the
|
|
106
|
+
// message-based fallback for compositions that report their duration
|
|
107
|
+
// via clip manifest rather than the direct adapter, and it must not
|
|
108
|
+
// enable Play any earlier than that path does.
|
|
109
|
+
requestTimelineReady(safeContentDocument(iframeRef.current));
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
// Asynchronously enrich media elements still missing sourceDuration
|
|
@@ -120,7 +125,7 @@ export function useTimelinePlayer() {
|
|
|
120
125
|
},
|
|
121
126
|
);
|
|
122
127
|
},
|
|
123
|
-
[setElements,
|
|
128
|
+
[setElements, requestTimelineReady, setDuration],
|
|
124
129
|
);
|
|
125
130
|
|
|
126
131
|
// Pre-existing dispatcher complexity — surfaced by this PR's line shifts, not new logic.
|
|
@@ -425,7 +430,7 @@ export function useTimelinePlayer() {
|
|
|
425
430
|
syncTimelineElements,
|
|
426
431
|
setDuration,
|
|
427
432
|
setCurrentTime,
|
|
428
|
-
|
|
433
|
+
requestTimelineReady,
|
|
429
434
|
setIsPlaying,
|
|
430
435
|
attachIframeShortcutListeners,
|
|
431
436
|
applyPreviewAudioState,
|
|
@@ -42,7 +42,7 @@ interface UseTimelineSyncCallbacksParams {
|
|
|
42
42
|
syncTimelineElements: (elements: TimelineElement[], nextDuration?: number) => void;
|
|
43
43
|
setDuration: (v: number) => void;
|
|
44
44
|
setCurrentTime: (v: number) => void;
|
|
45
|
-
|
|
45
|
+
requestTimelineReady: (doc: Document | null) => void;
|
|
46
46
|
setIsPlaying: (v: boolean) => void;
|
|
47
47
|
attachIframeShortcutListeners: () => void;
|
|
48
48
|
applyPreviewAudioState: () => void;
|
|
@@ -106,7 +106,7 @@ export function useTimelineSyncCallbacks({
|
|
|
106
106
|
syncTimelineElements,
|
|
107
107
|
setDuration,
|
|
108
108
|
setCurrentTime,
|
|
109
|
-
|
|
109
|
+
requestTimelineReady,
|
|
110
110
|
setIsPlaying,
|
|
111
111
|
attachIframeShortcutListeners,
|
|
112
112
|
applyPreviewAudioState,
|
|
@@ -204,7 +204,10 @@ export function useTimelineSyncCallbacks({
|
|
|
204
204
|
syncAdapterDuration(adapter, setDuration);
|
|
205
205
|
setCurrentTime(startTime);
|
|
206
206
|
if (!isRefreshingRef.current) {
|
|
207
|
-
|
|
207
|
+
// Enables Play from actual play-readiness, not just a known duration —
|
|
208
|
+
// a click before this resolves used to start the timeline with media,
|
|
209
|
+
// images or fonts still loading and never recover.
|
|
210
|
+
requestTimelineReady(safeContentDocument(iframeRef.current));
|
|
208
211
|
}
|
|
209
212
|
isRefreshingRef.current = false;
|
|
210
213
|
setIsPlaying(false);
|
|
@@ -223,7 +226,7 @@ export function useTimelineSyncCallbacks({
|
|
|
223
226
|
getAdapter,
|
|
224
227
|
setDuration,
|
|
225
228
|
setCurrentTime,
|
|
226
|
-
|
|
229
|
+
requestTimelineReady,
|
|
227
230
|
setIsPlaying,
|
|
228
231
|
processTimelineMessage,
|
|
229
232
|
enrichMissingCompositions,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
1
2
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
3
|
import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
|
|
3
4
|
|
|
@@ -322,6 +323,47 @@ describe("usePlayerStore", () => {
|
|
|
322
323
|
});
|
|
323
324
|
});
|
|
324
325
|
|
|
326
|
+
describe("requestTimelineReady", () => {
|
|
327
|
+
function stalledVideoDoc(): { doc: Document; video: HTMLVideoElement } {
|
|
328
|
+
const doc = document.implementation.createHTMLDocument("composition");
|
|
329
|
+
const video = doc.createElement("video");
|
|
330
|
+
Object.defineProperty(video, "readyState", { value: 0, configurable: true });
|
|
331
|
+
doc.body.appendChild(video);
|
|
332
|
+
return { doc, video };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
it("resolves timelineReady once the doc's media settles", async () => {
|
|
336
|
+
const { doc, video } = stalledVideoDoc();
|
|
337
|
+
usePlayerStore.getState().requestTimelineReady(doc);
|
|
338
|
+
expect(usePlayerStore.getState().timelineReady).toBe(false);
|
|
339
|
+
|
|
340
|
+
video.dispatchEvent(new Event("canplay"));
|
|
341
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
342
|
+
|
|
343
|
+
expect(usePlayerStore.getState().timelineReady).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("a stale wait from a project switched away from never marks the new project ready", async () => {
|
|
347
|
+
// beginTimelineSession no-ops when projectId is unchanged, so pin a
|
|
348
|
+
// known starting project first rather than relying on reset()'s
|
|
349
|
+
// leftover timelineProjectId from whatever test ran before this one.
|
|
350
|
+
usePlayerStore.getState().beginTimelineSession("readiness-test-project-a");
|
|
351
|
+
const { doc: docA, video: videoA } = stalledVideoDoc();
|
|
352
|
+
usePlayerStore.getState().requestTimelineReady(docA);
|
|
353
|
+
|
|
354
|
+
// Switches projects mid-wait — beginTimelineSession resets timelineReady
|
|
355
|
+
// to false for project B, whose own media has not been checked at all.
|
|
356
|
+
usePlayerStore.getState().beginTimelineSession("readiness-test-project-b");
|
|
357
|
+
expect(usePlayerStore.getState().timelineReady).toBe(false);
|
|
358
|
+
|
|
359
|
+
// A's wait settles late; it must not resolve into B's readiness.
|
|
360
|
+
videoA.dispatchEvent(new Event("canplay"));
|
|
361
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
362
|
+
|
|
363
|
+
expect(usePlayerStore.getState().timelineReady).toBe(false);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
325
367
|
describe("setElements", () => {
|
|
326
368
|
it("sets the elements array", () => {
|
|
327
369
|
const elements: TimelineElement[] = [
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { createEditingModeSlice, type EditingModeSlice } from "./editingModeSlice";
|
|
20
20
|
import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
|
|
21
21
|
import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
|
|
22
|
+
import { createPlaybackReadinessSlice, resetPlaybackReadinessState } from "./readinessSlice";
|
|
22
23
|
|
|
23
24
|
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
|
24
25
|
export { liveTime } from "./liveTime";
|
|
@@ -54,12 +55,15 @@ function resolveElementSelection(
|
|
|
54
55
|
};
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
type PlayerStoreSlices = KeyframeSlice &
|
|
59
|
+
AutomationSelectionSlice &
|
|
60
|
+
ThumbnailSlice &
|
|
61
|
+
EditingModeSlice &
|
|
62
|
+
ReturnType<typeof createPlaybackReadinessSlice>;
|
|
63
|
+
interface PlayerState extends PlayerStoreSlices {
|
|
59
64
|
isPlaying: boolean;
|
|
60
65
|
currentTime: number;
|
|
61
66
|
duration: number;
|
|
62
|
-
timelineReady: boolean;
|
|
63
67
|
/** Increments exactly once when the Studio switches to a different project. */
|
|
64
68
|
timelineSessionEpoch: number;
|
|
65
69
|
/** Project owning the current timeline session; null outside a project-scoped reset. */
|
|
@@ -129,7 +133,6 @@ interface PlayerState
|
|
|
129
133
|
setAudioMuted: (muted: boolean) => void;
|
|
130
134
|
setAudioVolume: (volume: number) => void;
|
|
131
135
|
setLoopEnabled: (enabled: boolean) => void;
|
|
132
|
-
setTimelineReady: (ready: boolean) => void;
|
|
133
136
|
setBeatDragging: (dragging: boolean) => void;
|
|
134
137
|
setElements: (elements: TimelineElement[]) => void;
|
|
135
138
|
setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void;
|
|
@@ -260,7 +263,7 @@ export function createTimelineResetState() {
|
|
|
260
263
|
isPlaying: false,
|
|
261
264
|
currentTime: 0,
|
|
262
265
|
duration: 0,
|
|
263
|
-
|
|
266
|
+
...resetPlaybackReadinessState(),
|
|
264
267
|
beatDragging: false,
|
|
265
268
|
elements: [],
|
|
266
269
|
selectedElementId: null,
|
|
@@ -304,7 +307,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
304
307
|
isPlaying: false,
|
|
305
308
|
currentTime: 0,
|
|
306
309
|
duration: 0,
|
|
307
|
-
timelineReady: false,
|
|
308
310
|
timelineSessionEpoch: 0,
|
|
309
311
|
timelineProjectId: null,
|
|
310
312
|
beatDragging: false,
|
|
@@ -333,6 +335,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
333
335
|
|
|
334
336
|
...createAutomationSelectionSlice(set),
|
|
335
337
|
...createEditingModeSlice(set),
|
|
338
|
+
...createPlaybackReadinessSlice(set),
|
|
336
339
|
|
|
337
340
|
activeKeyframePct: null,
|
|
338
341
|
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
|
|
@@ -521,7 +524,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
521
524
|
bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })),
|
|
522
525
|
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
|
|
523
526
|
setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
|
|
524
|
-
setTimelineReady: (ready) => set({ timelineReady: ready }),
|
|
525
527
|
setBeatDragging: (dragging) => set({ beatDragging: dragging }),
|
|
526
528
|
setElements: (elements) => set({ elements }),
|
|
527
529
|
// A genuine single selection: always collapse the set to just this element. User
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Gates `timelineReady` on the composition's declared readiness inputs
|
|
2
|
+
* (media, for now) instead of just a known duration. The generation counter
|
|
3
|
+
* is module-scope, not store state: it guards an in-flight settlement, not
|
|
4
|
+
* something a component reads, so bumping it shouldn't trigger a render. */
|
|
5
|
+
import type { StoreApi } from "zustand";
|
|
6
|
+
import { settleCompositionReadiness } from "@hyperframes/core/composition-readiness";
|
|
7
|
+
|
|
8
|
+
export interface PlaybackReadinessSlice {
|
|
9
|
+
timelineReady: boolean;
|
|
10
|
+
setTimelineReady: (ready: boolean) => void;
|
|
11
|
+
/** Sets timelineReady once doc's readiness inputs settle, or immediately
|
|
12
|
+
* if doc is null. A wait a later call supersedes never wins the race. */
|
|
13
|
+
requestTimelineReady: (doc: Document | null) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let timelineReadyGeneration = 0;
|
|
17
|
+
|
|
18
|
+
/** For a full timeline reset: bumps the generation so any requestTimelineReady
|
|
19
|
+
* wait in flight can never resolve into what replaced it. */
|
|
20
|
+
export function resetPlaybackReadinessState(): Pick<PlaybackReadinessSlice, "timelineReady"> {
|
|
21
|
+
timelineReadyGeneration++;
|
|
22
|
+
return { timelineReady: false };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createPlaybackReadinessSlice(
|
|
26
|
+
set: StoreApi<PlaybackReadinessSlice>["setState"],
|
|
27
|
+
): PlaybackReadinessSlice {
|
|
28
|
+
return {
|
|
29
|
+
timelineReady: false,
|
|
30
|
+
setTimelineReady: (ready) => {
|
|
31
|
+
timelineReadyGeneration++;
|
|
32
|
+
set({ timelineReady: ready });
|
|
33
|
+
},
|
|
34
|
+
requestTimelineReady: (doc) => {
|
|
35
|
+
const generation = ++timelineReadyGeneration;
|
|
36
|
+
if (!doc) return set({ timelineReady: true });
|
|
37
|
+
settleCompositionReadiness(doc, () => {
|
|
38
|
+
if (generation === timelineReadyGeneration) set({ timelineReady: true });
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// Kill-switch contract: with VITE_STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover
|
|
4
4
|
// persist chokepoint must explicitly decline so the caller takes the legacy server
|
|
5
5
|
// path — even when a valid SDK session exists (one always does, for
|
|
6
|
-
// shadow/selection).
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// shadow/selection). Default is ON (manualEditingAvailability.test.ts pins that);
|
|
7
|
+
// this file pins that the env override still turns it fully off. A future
|
|
8
|
+
// refactor of the gate guards that ignores the flag turns these red.
|
|
9
|
+
// (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.)
|
|
9
10
|
vi.mock("../components/editor/manualEditingAvailability", () => ({
|
|
10
11
|
STUDIO_SDK_CUTOVER_ENABLED: false,
|
|
11
12
|
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
|
|
@@ -23,6 +23,8 @@ vi.mock("./studioTelemetry", () => ({
|
|
|
23
23
|
trackStudioEvent: vi.fn(),
|
|
24
24
|
}));
|
|
25
25
|
|
|
26
|
+
import { trackStudioEvent } from "./studioTelemetry";
|
|
27
|
+
|
|
26
28
|
const styleOp = (property: string, value: string): PatchOperation => ({
|
|
27
29
|
type: "inline-style",
|
|
28
30
|
property,
|
|
@@ -202,6 +204,46 @@ describe("sdkCutoverPersist", () => {
|
|
|
202
204
|
expect(result.status).toBe("declined");
|
|
203
205
|
});
|
|
204
206
|
|
|
207
|
+
it("tags target_not_found with resolverDisagreement when dispatch could have resolved it", async () => {
|
|
208
|
+
// getElement is canonical-only for a bare id; resolveSnapshot mirrors what
|
|
209
|
+
// dispatch resolves (bare ids anywhere). An element present under a scoped
|
|
210
|
+
// id is dispatchable, so getElement refusing it is a resolver disagreement
|
|
211
|
+
// — and one the shadow event stays silent about.
|
|
212
|
+
const deps = makeDeps();
|
|
213
|
+
const session = makeSession(false);
|
|
214
|
+
(session as unknown as { getElements: () => unknown[] }).getElements = () => [
|
|
215
|
+
{ id: "hf-abc", scopedId: "host/hf-abc" },
|
|
216
|
+
];
|
|
217
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
218
|
+
|
|
219
|
+
await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/path.html", session, deps);
|
|
220
|
+
|
|
221
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
222
|
+
"sdk_cutover_declined",
|
|
223
|
+
expect.objectContaining({ reason: "target_not_found", resolverDisagreement: true }),
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("does not tag resolverDisagreement when the element is genuinely absent", async () => {
|
|
228
|
+
const deps = makeDeps();
|
|
229
|
+
const session = makeSession(false);
|
|
230
|
+
(session as unknown as { getElements: () => unknown[] }).getElements = () => [
|
|
231
|
+
{ id: "hf-other", scopedId: "hf-other" },
|
|
232
|
+
];
|
|
233
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
234
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
235
|
+
|
|
236
|
+
await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/path.html", session, deps);
|
|
237
|
+
|
|
238
|
+
expect(trackStudioEvent).toHaveBeenLastCalledWith(
|
|
239
|
+
"sdk_cutover_declined",
|
|
240
|
+
expect.objectContaining({ reason: "target_not_found" }),
|
|
241
|
+
);
|
|
242
|
+
expect(vi.mocked(trackStudioEvent).mock.lastCall?.[1]).not.toHaveProperty(
|
|
243
|
+
"resolverDisagreement",
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
205
247
|
it("dispatches setStyle for inline-style ops", async () => {
|
|
206
248
|
const deps = makeDeps();
|
|
207
249
|
const session = makeSession(true);
|