@hyperframes/studio 0.6.100 → 0.6.102
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/index-BITwbxi-.css +1 -0
- package/dist/assets/{index-CKWBqyRd.js → index-BZKngETE.js} +1 -1
- package/dist/assets/index-BzjItfjX.js +296 -0
- package/dist/assets/{index-gpSohHUn.js → index-C0vMHtMH.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -5
- package/src/App.tsx +2 -1
- package/src/components/editor/PropertyPanel.tsx +24 -16
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- package/src/components/nle/NLELayout.tsx +89 -1
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +121 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +48 -2
- package/src/hooks/gsapScriptCommitHelpers.ts +8 -5
- package/src/hooks/gsapScriptCommitTypes.ts +6 -0
- package/src/hooks/gsapTargetCache.ts +65 -0
- package/src/hooks/useAppHotkeys.ts +10 -0
- package/src/hooks/useDomEditCommits.ts +6 -5
- package/src/hooks/useDomEditSession.ts +6 -1
- package/src/hooks/useDomGeometryCommits.ts +1 -36
- package/src/hooks/useElementLifecycleOps.ts +5 -0
- package/src/hooks/useGsapAnimationOps.ts +46 -9
- package/src/hooks/useGsapScriptCommits.ts +22 -3
- package/src/hooks/useGsapTweenCache.ts +10 -12
- package/src/hooks/useRazorSplit.ts +3 -0
- package/src/hooks/useSafeGsapCommitMutation.ts +1 -14
- package/src/hooks/useSdkSession.ts +15 -12
- package/src/hooks/useTimelineEditing.ts +23 -3
- package/src/player/components/Timeline.tsx +31 -18
- package/src/player/components/TimelineClip.tsx +3 -3
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +30 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +91 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +153 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +22 -0
- package/src/player/store/playerStore.ts +22 -8
- package/src/telemetry/events.test.ts +16 -1
- package/src/telemetry/events.ts +15 -0
- package/src/utils/blockCategories.ts +2 -2
- package/src/utils/sdkShadow.test.ts +232 -2
- package/src/utils/sdkShadow.ts +230 -2
- package/src/utils/sdkShadowGsapFidelity.ts +208 -0
- package/src/utils/studioHelpers.test.ts +25 -1
- package/src/utils/studioHelpers.ts +54 -28
- package/dist/assets/index-B62bDCQv.css +0 -1
- package/dist/assets/index-BkT9VKwz.js +0 -296
|
@@ -1,8 +1,30 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
patchOpsToSdkEditOps,
|
|
4
|
+
runShadowDelete,
|
|
5
|
+
runShadowTiming,
|
|
6
|
+
runShadowGsapTween,
|
|
7
|
+
runShadowGsapFidelity,
|
|
8
|
+
gsapFidelityMismatches,
|
|
9
|
+
resolveGsapFidelityArgs,
|
|
10
|
+
SdkShadowMismatch,
|
|
11
|
+
} from "./sdkShadow";
|
|
12
|
+
import type { ShadowGsapOp } from "./sdkShadow";
|
|
3
13
|
import type { PatchOperation } from "./sourcePatcher";
|
|
4
14
|
import { openComposition } from "@hyperframes/sdk";
|
|
5
15
|
|
|
16
|
+
// Capture sdk_shadow_dispatch telemetry for the non-PatchOperation runners.
|
|
17
|
+
const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
|
|
18
|
+
vi.mock("./studioTelemetry", () => ({
|
|
19
|
+
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
|
|
20
|
+
trackedEvents.push({ event, props }),
|
|
21
|
+
}));
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
trackedEvents.length = 0;
|
|
24
|
+
});
|
|
25
|
+
const lastShadow = () =>
|
|
26
|
+
trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props;
|
|
27
|
+
|
|
6
28
|
const BASE_HTML = /* html */ `<!DOCTYPE html>
|
|
7
29
|
<html><body>
|
|
8
30
|
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
|
|
@@ -144,3 +166,211 @@ describe("sdkShadowDispatch (integration)", () => {
|
|
|
144
166
|
});
|
|
145
167
|
});
|
|
146
168
|
});
|
|
169
|
+
|
|
170
|
+
const TIMING_HTML = /* html */ `<!DOCTYPE html>
|
|
171
|
+
<html><body>
|
|
172
|
+
<div data-hf-id="hf-clip" data-start="0" data-duration="1" data-track="0">clip</div>
|
|
173
|
+
</body></html>`;
|
|
174
|
+
|
|
175
|
+
const GSAP_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
|
176
|
+
<div data-hf-id="hf-box" style="opacity:0"></div>
|
|
177
|
+
<script>var tl = gsap.timeline({ paused: true });
|
|
178
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
|
|
179
|
+
window.__timelines["t"] = tl;</script>
|
|
180
|
+
</div>`;
|
|
181
|
+
|
|
182
|
+
const NO_TIMELINE_HTML = `<div data-hf-id="hf-stage" data-hf-root>
|
|
183
|
+
<div data-hf-id="hf-box"></div>
|
|
184
|
+
<script>gsap.defaults({ ease: "power1.out" });
|
|
185
|
+
window.__timelines = {};</script>
|
|
186
|
+
</div>`;
|
|
187
|
+
|
|
188
|
+
describe("runShadowDelete", () => {
|
|
189
|
+
it("removes the element from the SDK session and reports parity", async () => {
|
|
190
|
+
const session = await openComposition(BASE_HTML);
|
|
191
|
+
runShadowDelete(session, "hf-box");
|
|
192
|
+
expect(session.getElement("hf-box")).toBeNull();
|
|
193
|
+
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("reports no_hf_id when selection has no hf-id", async () => {
|
|
197
|
+
const session = await openComposition(BASE_HTML);
|
|
198
|
+
runShadowDelete(session, null);
|
|
199
|
+
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: false, reason: "no_hf_id" });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("reports cannot_dispatch when the element is not addressable", async () => {
|
|
203
|
+
const session = await openComposition(BASE_HTML);
|
|
204
|
+
runShadowDelete(session, "hf-missing");
|
|
205
|
+
expect(lastShadow()).toMatchObject({
|
|
206
|
+
op: "delete",
|
|
207
|
+
dispatched: false,
|
|
208
|
+
reason: "cannot_dispatch",
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("runShadowTiming", () => {
|
|
214
|
+
it("applies timing and reports parity against the snapshot", async () => {
|
|
215
|
+
const session = await openComposition(TIMING_HTML);
|
|
216
|
+
runShadowTiming(session, "hf-clip", { start: 2, duration: 3, trackIndex: 1 });
|
|
217
|
+
const el = session.getElement("hf-clip");
|
|
218
|
+
expect(el?.start).toBe(2);
|
|
219
|
+
expect(el?.duration).toBe(3);
|
|
220
|
+
expect(el?.trackIndex).toBe(1);
|
|
221
|
+
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("runShadowGsapTween", () => {
|
|
226
|
+
it("add reports success and the new tween lands on the target's animationIds", async () => {
|
|
227
|
+
const session = await openComposition(GSAP_HTML);
|
|
228
|
+
const before = session.getElement("hf-box")?.animationIds.length ?? 0;
|
|
229
|
+
runShadowGsapTween(session, {
|
|
230
|
+
kind: "add",
|
|
231
|
+
target: "hf-box",
|
|
232
|
+
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
|
233
|
+
});
|
|
234
|
+
expect(session.getElement("hf-box")!.animationIds.length).toBe(before + 1);
|
|
235
|
+
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("remove drops the tween from animationIds and reports parity", async () => {
|
|
239
|
+
const session = await openComposition(GSAP_HTML);
|
|
240
|
+
const animationId = session.getElement("hf-box")?.animationIds[0];
|
|
241
|
+
expect(animationId).toBeDefined();
|
|
242
|
+
runShadowGsapTween(session, { kind: "remove", animationId: animationId! });
|
|
243
|
+
expect(session.getElement("hf-box")?.animationIds ?? []).not.toContain(animationId);
|
|
244
|
+
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("reports cannot_dispatch (E_NO_GSAP_TIMELINE) when the script has no timeline", async () => {
|
|
248
|
+
const session = await openComposition(NO_TIMELINE_HTML);
|
|
249
|
+
runShadowGsapTween(session, {
|
|
250
|
+
kind: "add",
|
|
251
|
+
target: "hf-box",
|
|
252
|
+
tween: { method: "to", properties: { x: 100 } },
|
|
253
|
+
});
|
|
254
|
+
expect(lastShadow()).toMatchObject({
|
|
255
|
+
op: "gsap",
|
|
256
|
+
dispatched: false,
|
|
257
|
+
reason: "cannot_dispatch",
|
|
258
|
+
code: "E_NO_GSAP_TIMELINE",
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const SCRIPT_A = `var tl = gsap.timeline({ paused: true });
|
|
264
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
|
|
265
|
+
window.__timelines["t"] = tl;`;
|
|
266
|
+
|
|
267
|
+
describe("gsapFidelityMismatches", () => {
|
|
268
|
+
it("returns no mismatches for identical scripts", () => {
|
|
269
|
+
expect(gsapFidelityMismatches(SCRIPT_A, SCRIPT_A)).toEqual([]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("flags a per-field value drift (duration)", () => {
|
|
273
|
+
const drifted = SCRIPT_A.replace("duration: 0.5", "duration: 0.9");
|
|
274
|
+
const mismatches = gsapFidelityMismatches(drifted, SCRIPT_A);
|
|
275
|
+
expect(mismatches.some((m) => m.property === "duration")).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("flags a tween present in one script but not the other", () => {
|
|
279
|
+
const empty = `var tl = gsap.timeline({ paused: true });
|
|
280
|
+
window.__timelines["t"] = tl;`;
|
|
281
|
+
const mismatches = gsapFidelityMismatches(empty, SCRIPT_A);
|
|
282
|
+
expect(mismatches.some((m) => m.property === "tween")).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("does NOT flag property key-order differences (canonical compare)", () => {
|
|
286
|
+
const ab = `var tl = gsap.timeline({ paused: true });
|
|
287
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 10, y: 20, duration: 0.5 }, 0);
|
|
288
|
+
window.__timelines["t"] = tl;`;
|
|
289
|
+
const ba = `var tl = gsap.timeline({ paused: true });
|
|
290
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { y: 20, x: 10, duration: 0.5 }, 0);
|
|
291
|
+
window.__timelines["t"] = tl;`;
|
|
292
|
+
expect(gsapFidelityMismatches(ab, ba)).toEqual([]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("does NOT flag number-vs-string-equivalent property values", () => {
|
|
296
|
+
const numeric = `var tl = gsap.timeline({ paused: true });
|
|
297
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
|
|
298
|
+
window.__timelines["t"] = tl;`;
|
|
299
|
+
const stringy = `var tl = gsap.timeline({ paused: true });
|
|
300
|
+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: "1", duration: 0.5 }, 0);
|
|
301
|
+
window.__timelines["t"] = tl;`;
|
|
302
|
+
expect(gsapFidelityMismatches(numeric, stringy)).toEqual([]);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("runShadowGsapFidelity", () => {
|
|
307
|
+
const BEFORE_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
|
308
|
+
<div data-hf-id="hf-box" style="opacity:0"></div>
|
|
309
|
+
<script>var tl = gsap.timeline({ paused: true });
|
|
310
|
+
window.__timelines["t"] = tl;</script>
|
|
311
|
+
</div>`;
|
|
312
|
+
|
|
313
|
+
it("reports zero mismatches when the SDK output matches the server script", async () => {
|
|
314
|
+
// Produce the "server" script by applying the same op via the SDK, so a
|
|
315
|
+
// faithful SDK writer must reproduce it exactly.
|
|
316
|
+
const ref = await openComposition(BEFORE_HTML);
|
|
317
|
+
const op = {
|
|
318
|
+
kind: "add",
|
|
319
|
+
target: "hf-box",
|
|
320
|
+
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
|
321
|
+
} as const;
|
|
322
|
+
ref.addGsapTween(op.target, op.tween);
|
|
323
|
+
const serverScript =
|
|
324
|
+
ref.serialize().match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? "";
|
|
325
|
+
|
|
326
|
+
await runShadowGsapFidelity(BEFORE_HTML, op, serverScript);
|
|
327
|
+
expect(lastShadow()).toMatchObject({ op: "gsap_fidelity", dispatched: true, mismatchCount: 0 });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("reports mismatches when the server script diverges", async () => {
|
|
331
|
+
const op = {
|
|
332
|
+
kind: "add",
|
|
333
|
+
target: "hf-box",
|
|
334
|
+
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
|
335
|
+
} as const;
|
|
336
|
+
const ref = await openComposition(BEFORE_HTML);
|
|
337
|
+
ref.addGsapTween(op.target, op.tween);
|
|
338
|
+
const serverScript = (
|
|
339
|
+
ref.serialize().match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? ""
|
|
340
|
+
).replace("100", "999");
|
|
341
|
+
|
|
342
|
+
await runShadowGsapFidelity(BEFORE_HTML, op, serverScript);
|
|
343
|
+
const ev = lastShadow();
|
|
344
|
+
expect(ev).toMatchObject({ op: "gsap_fidelity", dispatched: true });
|
|
345
|
+
expect(ev?.mismatchCount as number).toBeGreaterThan(0);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe("resolveGsapFidelityArgs (chokepoint wiring)", () => {
|
|
350
|
+
const op: ShadowGsapOp = { kind: "remove", animationId: "a-1" };
|
|
351
|
+
const session = {} as object;
|
|
352
|
+
|
|
353
|
+
it("returns narrowed args when session, op, before, and serverScript are all present", () => {
|
|
354
|
+
expect(resolveGsapFidelityArgs(session, op, "<html>before</html>", "tl.to(...)")).toEqual({
|
|
355
|
+
before: "<html>before</html>",
|
|
356
|
+
op,
|
|
357
|
+
serverScript: "tl.to(...)",
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("returns null when no session (shadow not wired)", () => {
|
|
362
|
+
expect(resolveGsapFidelityArgs(null, op, "before", "script")).toBeNull();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("returns null when no shadowGsapOp (non-meta edit, e.g. property/keyframe)", () => {
|
|
366
|
+
expect(resolveGsapFidelityArgs(session, undefined, "before", "script")).toBeNull();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("returns null when serverScript is null (composition has no GSAP script)", () => {
|
|
370
|
+
expect(resolveGsapFidelityArgs(session, op, "before", null)).toBeNull();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("returns null when before is null", () => {
|
|
374
|
+
expect(resolveGsapFidelityArgs(session, op, null, "script")).toBeNull();
|
|
375
|
+
});
|
|
376
|
+
});
|
package/src/utils/sdkShadow.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { Composition } from "@hyperframes/sdk";
|
|
12
|
-
import type { EditOp } from "@hyperframes/sdk";
|
|
12
|
+
import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk";
|
|
13
13
|
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
14
14
|
import { trackStudioEvent } from "./studioTelemetry";
|
|
15
15
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
@@ -182,6 +182,26 @@ export function sdkShadowDispatch(
|
|
|
182
182
|
* Despite the telemetry focus, this function does mutate the SDK session — it
|
|
183
183
|
* is not read-only. No-op when STUDIO_SDK_SHADOW_ENABLED is false.
|
|
184
184
|
*/
|
|
185
|
+
// Property-path mismatches carry user content (inline-style values, edited
|
|
186
|
+
// text) in expected/actual. Scrub before telemetry: fully redact text-content
|
|
187
|
+
// values, length-cap the rest. The in-memory parity result keeps raw values.
|
|
188
|
+
function redactValueForTelemetry(
|
|
189
|
+
property: string | undefined,
|
|
190
|
+
value: string | null | undefined,
|
|
191
|
+
): string | null | undefined {
|
|
192
|
+
if (value == null) return value;
|
|
193
|
+
if (property === "text") return `[redacted len=${value.length}]`;
|
|
194
|
+
return value.length > 64 ? `${value.slice(0, 64)}…` : value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function redactMismatchesForTelemetry(mismatches: SdkShadowMismatch[]): SdkShadowMismatch[] {
|
|
198
|
+
return mismatches.map((m) => ({
|
|
199
|
+
...m,
|
|
200
|
+
expected: redactValueForTelemetry(m.property, m.expected),
|
|
201
|
+
actual: redactValueForTelemetry(m.property, m.actual),
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
|
|
185
205
|
export function runShadowDispatch(
|
|
186
206
|
session: Composition,
|
|
187
207
|
selection: DomEditSelection,
|
|
@@ -191,6 +211,7 @@ export function runShadowDispatch(
|
|
|
191
211
|
const hfId = selection.hfId;
|
|
192
212
|
if (!hfId) {
|
|
193
213
|
trackStudioEvent("sdk_shadow_dispatch", {
|
|
214
|
+
op: "property",
|
|
194
215
|
dispatched: false,
|
|
195
216
|
reason: "no_hf_id",
|
|
196
217
|
mismatchCount: 0,
|
|
@@ -199,8 +220,215 @@ export function runShadowDispatch(
|
|
|
199
220
|
}
|
|
200
221
|
const result = sdkShadowDispatch(session, hfId, ops);
|
|
201
222
|
trackStudioEvent("sdk_shadow_dispatch", {
|
|
223
|
+
op: "property",
|
|
202
224
|
dispatched: result.dispatched,
|
|
203
225
|
mismatchCount: result.mismatches.length,
|
|
204
|
-
mismatches: JSON.stringify(result.mismatches),
|
|
226
|
+
mismatches: JSON.stringify(redactMismatchesForTelemetry(result.mismatches)),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── Shadow for non-PatchOperation ops (delete / timing / GSAP) ───────────────
|
|
231
|
+
//
|
|
232
|
+
// These ops never flow through persistDomEditOperations, so the property-path
|
|
233
|
+
// shadow above never sees them. Each runner keeps the server authoritative and
|
|
234
|
+
// only observes the SDK: can() pre-checks addressing/validity (pure, no
|
|
235
|
+
// mutation — works even for GSAP, which has no element-snapshot value), then a
|
|
236
|
+
// dispatch into the live session with a snapshot-based parity check.
|
|
237
|
+
//
|
|
238
|
+
// Parity coverage by op:
|
|
239
|
+
// delete → getElement(id) === null (full)
|
|
240
|
+
// timing → snapshot.start/duration/trackIndex (full)
|
|
241
|
+
// gsap → tween id present/absent in animationIds (existence only — the
|
|
242
|
+
// tween's property values are script-level, not in the snapshot)
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* can()-gated shadow dispatch. Emits sdk_shadow_dispatch tagged with `opLabel`.
|
|
246
|
+
* Mutates the SDK session (not read-only); server stays authoritative.
|
|
247
|
+
* No-op when STUDIO_SDK_SHADOW_ENABLED is false.
|
|
248
|
+
*/
|
|
249
|
+
function runShadowEditOp(
|
|
250
|
+
session: Composition,
|
|
251
|
+
op: EditOp,
|
|
252
|
+
opLabel: string,
|
|
253
|
+
dispatchAndCheck: () => SdkShadowMismatch[],
|
|
254
|
+
): void {
|
|
255
|
+
const verdict = session.can(op);
|
|
256
|
+
if (!verdict.ok) {
|
|
257
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
258
|
+
op: opLabel,
|
|
259
|
+
dispatched: false,
|
|
260
|
+
reason: "cannot_dispatch",
|
|
261
|
+
code: verdict.code,
|
|
262
|
+
mismatchCount: 0,
|
|
263
|
+
});
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
let mismatches: SdkShadowMismatch[];
|
|
267
|
+
try {
|
|
268
|
+
mismatches = dispatchAndCheck();
|
|
269
|
+
} catch (err) {
|
|
270
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
271
|
+
op: opLabel,
|
|
272
|
+
dispatched: false,
|
|
273
|
+
reason: "dispatch_error",
|
|
274
|
+
error: String(err),
|
|
275
|
+
mismatchCount: 0,
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
280
|
+
op: opLabel,
|
|
281
|
+
dispatched: true,
|
|
282
|
+
mismatchCount: mismatches.length,
|
|
283
|
+
mismatches: JSON.stringify(mismatches),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Shadow an element delete. Parity: the element is gone from the SDK session. */
|
|
288
|
+
export function runShadowDelete(session: Composition, hfId: string | null | undefined): void {
|
|
289
|
+
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
|
290
|
+
if (!hfId) {
|
|
291
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
292
|
+
op: "delete",
|
|
293
|
+
dispatched: false,
|
|
294
|
+
reason: "no_hf_id",
|
|
295
|
+
mismatchCount: 0,
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const op: EditOp = { type: "removeElement", target: hfId };
|
|
300
|
+
runShadowEditOp(session, op, "delete", () => {
|
|
301
|
+
session.batch(() => session.dispatch(op));
|
|
302
|
+
return session.getElement(hfId)
|
|
303
|
+
? [
|
|
304
|
+
{
|
|
305
|
+
kind: "value_mismatch",
|
|
306
|
+
hfId,
|
|
307
|
+
property: "exists",
|
|
308
|
+
expected: "removed",
|
|
309
|
+
actual: "present",
|
|
310
|
+
},
|
|
311
|
+
]
|
|
312
|
+
: [];
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface ShadowTiming {
|
|
317
|
+
start?: number;
|
|
318
|
+
duration?: number;
|
|
319
|
+
trackIndex?: number;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Shadow a timing edit. Parity: snapshot start/duration/trackIndex match. */
|
|
323
|
+
export function runShadowTiming(
|
|
324
|
+
session: Composition,
|
|
325
|
+
hfId: string | null | undefined,
|
|
326
|
+
timing: ShadowTiming,
|
|
327
|
+
): void {
|
|
328
|
+
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
|
329
|
+
if (!hfId) {
|
|
330
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
331
|
+
op: "timing",
|
|
332
|
+
dispatched: false,
|
|
333
|
+
reason: "no_hf_id",
|
|
334
|
+
mismatchCount: 0,
|
|
335
|
+
});
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const op: EditOp = { type: "setTiming", target: hfId, ...timing };
|
|
339
|
+
runShadowEditOp(session, op, "timing", () => {
|
|
340
|
+
session.batch(() => session.dispatch(op));
|
|
341
|
+
const el = session.getElement(hfId);
|
|
342
|
+
const mismatches: SdkShadowMismatch[] = [];
|
|
343
|
+
const fields: Array<[keyof ShadowTiming, number | null | undefined]> = [
|
|
344
|
+
["start", el?.start],
|
|
345
|
+
["duration", el?.duration],
|
|
346
|
+
["trackIndex", el?.trackIndex],
|
|
347
|
+
];
|
|
348
|
+
for (const [key, actual] of fields) {
|
|
349
|
+
const expected = timing[key];
|
|
350
|
+
if (expected !== undefined && actual !== expected) {
|
|
351
|
+
mismatches.push({
|
|
352
|
+
kind: "value_mismatch",
|
|
353
|
+
hfId,
|
|
354
|
+
property: key,
|
|
355
|
+
expected: String(expected),
|
|
356
|
+
actual: actual == null ? null : String(actual),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return mismatches;
|
|
205
361
|
});
|
|
206
362
|
}
|
|
363
|
+
|
|
364
|
+
export type ShadowGsapOp =
|
|
365
|
+
| { kind: "add"; target: string; tween: GsapTweenSpec }
|
|
366
|
+
| { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
|
|
367
|
+
| { kind: "remove"; animationId: string };
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Shadow a GSAP tween mutation (add / set / remove). The server's animationId
|
|
371
|
+
* shares the SDK's id-space (both derive `targetSelector-method-position` from
|
|
372
|
+
* the same acorn parser — see sdk assignStableIds), so it is dispatchable as-is.
|
|
373
|
+
*
|
|
374
|
+
* Parity via the now-populated ElementSnapshot.animationIds:
|
|
375
|
+
* add → the returned tween id is present on the target element
|
|
376
|
+
* remove → the id is gone from every element
|
|
377
|
+
* set → existence only (the SDK exposes no per-tween property reader; value
|
|
378
|
+
* fidelity would need serialize()-script round-trip diffing).
|
|
379
|
+
*/
|
|
380
|
+
export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void {
|
|
381
|
+
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
|
382
|
+
const op: EditOp =
|
|
383
|
+
gsapOp.kind === "add"
|
|
384
|
+
? { type: "addGsapTween", target: gsapOp.target, tween: gsapOp.tween }
|
|
385
|
+
: gsapOp.kind === "set"
|
|
386
|
+
? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties }
|
|
387
|
+
: { type: "removeGsapTween", animationId: gsapOp.animationId };
|
|
388
|
+
// fallow-ignore-next-line complexity
|
|
389
|
+
runShadowEditOp(session, op, "gsap", () => {
|
|
390
|
+
let newId: string | undefined;
|
|
391
|
+
session.batch(() => {
|
|
392
|
+
if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween);
|
|
393
|
+
else session.dispatch(op);
|
|
394
|
+
});
|
|
395
|
+
if (gsapOp.kind === "add") {
|
|
396
|
+
const onTarget = session.getElement(gsapOp.target)?.animationIds ?? [];
|
|
397
|
+
if (!newId || !onTarget.includes(newId)) {
|
|
398
|
+
return [
|
|
399
|
+
{
|
|
400
|
+
kind: "value_mismatch",
|
|
401
|
+
hfId: gsapOp.target,
|
|
402
|
+
property: "animationIds",
|
|
403
|
+
expected: newId ?? "non-empty",
|
|
404
|
+
actual: onTarget.join(",") || null,
|
|
405
|
+
},
|
|
406
|
+
];
|
|
407
|
+
}
|
|
408
|
+
} else if (gsapOp.kind === "remove") {
|
|
409
|
+
const stillPresent = session
|
|
410
|
+
.getElements()
|
|
411
|
+
.some((el) => el.animationIds.includes(gsapOp.animationId));
|
|
412
|
+
if (stillPresent) {
|
|
413
|
+
return [
|
|
414
|
+
{
|
|
415
|
+
kind: "value_mismatch",
|
|
416
|
+
hfId: gsapOp.animationId,
|
|
417
|
+
property: "animationIds",
|
|
418
|
+
expected: "removed",
|
|
419
|
+
actual: "present",
|
|
420
|
+
},
|
|
421
|
+
];
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return [];
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// GSAP value-fidelity diff lives in its own module to keep this file under the
|
|
429
|
+
// 600-line studio cap; re-exported here so the shadow surface stays in one place.
|
|
430
|
+
export {
|
|
431
|
+
gsapFidelityMismatches,
|
|
432
|
+
resolveGsapFidelityArgs,
|
|
433
|
+
runShadowGsapFidelity,
|
|
434
|
+
} from "./sdkShadowGsapFidelity";
|