@hyperframes/studio 0.6.110 → 0.6.111
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-B7S86vK1.js +370 -0
- package/dist/assets/index-DP8pPIk2.css +1 -0
- package/dist/assets/{index-CfiyaR7G.js → index-_IV-vm9l.js} +1 -1
- package/dist/assets/{index-CXy_mWnP.js → index-x0c2-zQN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -5
- package/src/App.tsx +133 -141
- package/src/components/StudioHeader.tsx +44 -0
- package/src/components/StudioOverlays.tsx +70 -0
- package/src/components/editor/SourceEditor.tsx +19 -16
- package/src/components/editor/manualEditingAvailability.ts +25 -7
- package/src/components/storyboard/FramePoster.tsx +56 -0
- package/src/components/storyboard/StoryboardDirection.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
- package/src/components/storyboard/StoryboardGrid.tsx +33 -0
- package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
- package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
- package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
- package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
- package/src/components/storyboard/StoryboardView.tsx +78 -0
- package/src/components/storyboard/frameStatus.ts +36 -0
- package/src/contexts/ViewModeContext.tsx +98 -0
- package/src/hooks/gsapScriptCommitTypes.ts +4 -7
- package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
- package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -0
- package/src/hooks/useAppHotkeys.ts +20 -0
- package/src/hooks/useDomEditCommits.ts +43 -14
- package/src/hooks/useDomEditSession.test.ts +41 -0
- package/src/hooks/useDomEditSession.ts +38 -6
- package/src/hooks/useDomGeometryCommits.ts +5 -9
- package/src/hooks/useElementLifecycleOps.ts +30 -0
- package/src/hooks/useGsapAnimationOps.ts +78 -51
- package/src/hooks/useGsapKeyframeOps.ts +127 -43
- package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
- package/src/hooks/useGsapPropertyDebounce.ts +201 -23
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
- package/src/hooks/useGsapScriptCommits.ts +95 -40
- package/src/hooks/useSdkSession.test.ts +12 -0
- package/src/hooks/useSdkSession.ts +91 -25
- package/src/hooks/useStoryboard.ts +80 -0
- package/src/hooks/useTimelineEditing.ts +163 -68
- package/src/utils/gsapSoftReload.ts +14 -0
- package/src/utils/sdkCutover.gate.test.ts +61 -0
- package/src/utils/sdkCutover.test.ts +839 -0
- package/src/utils/sdkCutover.ts +465 -0
- package/src/utils/sdkOpMapping.ts +43 -0
- package/src/utils/sdkResolverShadow.test.ts +366 -0
- package/src/utils/sdkResolverShadow.ts +313 -0
- package/dist/assets/index-1C8oFiPi.css +0 -1
- package/dist/assets/index-D-3sGz65.js +0 -296
- package/src/utils/sdkShadow.test.ts +0 -606
- package/src/utils/sdkShadow.ts +0 -517
- package/src/utils/sdkShadowGsapFidelity.ts +0 -296
- package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
- package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
- package/src/utils/sdkShadowNumeric.ts +0 -11
package/src/utils/sdkShadow.ts
DELETED
|
@@ -1,517 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SDK shadow dispatch utilities for Stage 7 Step 3b.
|
|
3
|
-
*
|
|
4
|
-
* Shadow mode keeps the server patch path authoritative while also dispatching
|
|
5
|
-
* the equivalent op to the SDK session, then compares the result to detect
|
|
6
|
-
* addressing gaps (blocker E: no-hf-id elements) and serialization drift
|
|
7
|
-
* (blocker B: linkedom whole-doc serialize). Results are reported as structured
|
|
8
|
-
* mismatches for telemetry — no user-visible change.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type { Composition } from "@hyperframes/sdk";
|
|
12
|
-
import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk";
|
|
13
|
-
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
14
|
-
import { trackStudioEvent } from "./studioTelemetry";
|
|
15
|
-
import { relEqual } from "./sdkShadowNumeric";
|
|
16
|
-
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
17
|
-
import type { PatchOperation } from "./sourcePatcher";
|
|
18
|
-
|
|
19
|
-
// ─── Op mapping ──────────────────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Map Studio PatchOperations for a given hf-id to SDK EditOps.
|
|
23
|
-
*
|
|
24
|
-
* Multiple inline-style ops are coalesced into a single setStyle (SDK batches
|
|
25
|
-
* style changes naturally). One SDK op is emitted per non-style op.
|
|
26
|
-
*/
|
|
27
|
-
// "attribute" PatchOperations carry the data- attribute NAME. Studio passes
|
|
28
|
-
// some already prefixed (e.g. "data-hf-studio-path-offset") and some bare
|
|
29
|
-
// (e.g. "name"); prefix only when needed, never double-prefix.
|
|
30
|
-
function attrName(property: string): string {
|
|
31
|
-
return property.startsWith("data-") ? property : `data-${property}`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// The SDK element model excludes data-hf-* attributes (document.ts skips them),
|
|
35
|
-
// so shadowing studio-internal markers (data-hf-studio-path-offset, etc.) can
|
|
36
|
-
// never match — drop those ops from the shadow instead of false-mismatching.
|
|
37
|
-
function isShadowableOp(op: PatchOperation): boolean {
|
|
38
|
-
if (op.type === "attribute") return !attrName(op.property).startsWith("data-hf-");
|
|
39
|
-
if (op.type === "html-attribute") return !op.property.startsWith("data-hf-");
|
|
40
|
-
return true;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// PatchOperation types patchOpsToSdkEditOps knows how to map. Used by
|
|
44
|
-
// runShadowDispatch to flag any unmapped type as visible telemetry rather than
|
|
45
|
-
// silently dropping it (see the unmapped_type guard there).
|
|
46
|
-
const MAPPED_PATCH_OP_TYPES: ReadonlySet<string> = new Set([
|
|
47
|
-
"inline-style",
|
|
48
|
-
"text-content",
|
|
49
|
-
"attribute",
|
|
50
|
-
"html-attribute",
|
|
51
|
-
]);
|
|
52
|
-
|
|
53
|
-
export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
|
|
54
|
-
const result: EditOp[] = [];
|
|
55
|
-
const styles: Record<string, string | null> = {};
|
|
56
|
-
let hasStyles = false;
|
|
57
|
-
|
|
58
|
-
for (const op of ops) {
|
|
59
|
-
if (op.type === "inline-style") {
|
|
60
|
-
styles[op.property] = op.value;
|
|
61
|
-
hasStyles = true;
|
|
62
|
-
} else if (op.type === "text-content") {
|
|
63
|
-
result.push({ type: "setText", target: hfId, value: op.value ?? "" });
|
|
64
|
-
} else if (op.type === "attribute") {
|
|
65
|
-
result.push({
|
|
66
|
-
type: "setAttribute",
|
|
67
|
-
target: hfId,
|
|
68
|
-
name: attrName(op.property),
|
|
69
|
-
value: op.value,
|
|
70
|
-
});
|
|
71
|
-
} else if (op.type === "html-attribute") {
|
|
72
|
-
result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value });
|
|
73
|
-
}
|
|
74
|
-
// unknown op types produce no SDK op
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (hasStyles) {
|
|
78
|
-
result.unshift({ type: "setStyle", target: hfId, styles });
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return result;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ─── Shadow result types ──────────────────────────────────────────────────────
|
|
85
|
-
|
|
86
|
-
export interface SdkShadowMismatch {
|
|
87
|
-
kind: "element_not_found" | "value_mismatch" | "dispatch_error";
|
|
88
|
-
hfId: string;
|
|
89
|
-
property?: string;
|
|
90
|
-
expected?: string | null;
|
|
91
|
-
actual?: string | null | undefined;
|
|
92
|
-
error?: string;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export interface SdkShadowResult {
|
|
96
|
-
/** False if the element was not found in the SDK session. */
|
|
97
|
-
dispatched: boolean;
|
|
98
|
-
mismatches: SdkShadowMismatch[];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// ─── Shadow dispatch ──────────────────────────────────────────────────────────
|
|
102
|
-
|
|
103
|
-
type ElementSnapshot = ReturnType<Composition["getElement"]>;
|
|
104
|
-
type OpFields = {
|
|
105
|
-
property: string;
|
|
106
|
-
expected: string | null | undefined;
|
|
107
|
-
actual: string | null | undefined;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
type FlatSnapshot = {
|
|
111
|
-
styles: Record<string, string | null>;
|
|
112
|
-
attrs: Record<string, string | null>;
|
|
113
|
-
text: string | null;
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
function flattenSnapshot(snap: ElementSnapshot): FlatSnapshot {
|
|
117
|
-
return {
|
|
118
|
-
styles: snap?.inlineStyles ?? {},
|
|
119
|
-
attrs: Object.fromEntries(
|
|
120
|
-
Object.entries(snap?.attributes ?? {}).map(([k, v]) => [k, v ?? null]),
|
|
121
|
-
),
|
|
122
|
-
text: snap?.text ?? null,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
type OpFieldResolver = (op: PatchOperation, flat: FlatSnapshot) => OpFields;
|
|
127
|
-
|
|
128
|
-
// Snapshot inlineStyles are camelCase (CSSStyleDeclaration convention); PatchOperation
|
|
129
|
-
// style properties are kebab-case ("background-color"). Convert for read-back, else
|
|
130
|
-
// every hyphenated property false-mismatches against a null actual.
|
|
131
|
-
function kebabToCamel(prop: string): string {
|
|
132
|
-
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Text parity: the SDK snapshot.text is trimmed, so trim the op value too.
|
|
136
|
-
// An empty string and absent text (null) are treated as equivalent (collapsed
|
|
137
|
-
// to null) so "" vs null does not flag — both mean "no text content".
|
|
138
|
-
function normalizeText(value: string | null | undefined): string | null {
|
|
139
|
-
if (value == null) return null;
|
|
140
|
-
const trimmed = value.trim();
|
|
141
|
-
return trimmed === "" ? null : trimmed;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const OP_FIELD_RESOLVERS: Record<string, OpFieldResolver> = {
|
|
145
|
-
"inline-style": (op, flat) => ({
|
|
146
|
-
property: op.property,
|
|
147
|
-
expected: op.value,
|
|
148
|
-
actual: flat.styles[kebabToCamel(op.property)] ?? flat.styles[op.property] ?? null,
|
|
149
|
-
}),
|
|
150
|
-
// snapshot.text is already TRIMMED; trim the expected op value to match, so
|
|
151
|
-
// trailing-whitespace differences don't flag. Empty-vs-absent ("" vs null) is
|
|
152
|
-
// collapsed in checkOpParity. A genuinely different text value still flags.
|
|
153
|
-
"text-content": (op, flat) => ({
|
|
154
|
-
property: "text",
|
|
155
|
-
expected: normalizeText(op.value),
|
|
156
|
-
actual: normalizeText(flat.text),
|
|
157
|
-
}),
|
|
158
|
-
attribute: (op, flat) => ({
|
|
159
|
-
property: attrName(op.property),
|
|
160
|
-
expected: op.value ?? null,
|
|
161
|
-
actual: flat.attrs[attrName(op.property)] ?? null,
|
|
162
|
-
}),
|
|
163
|
-
"html-attribute": (op, flat) => ({
|
|
164
|
-
property: op.property,
|
|
165
|
-
expected: op.value ?? null,
|
|
166
|
-
actual: flat.attrs[op.property] ?? null,
|
|
167
|
-
}),
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
function resolveOpFields(op: PatchOperation, flat: FlatSnapshot): OpFields | null {
|
|
171
|
-
return OP_FIELD_RESOLVERS[op.type]?.(op, flat) ?? null;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function checkOpParity(
|
|
175
|
-
op: PatchOperation,
|
|
176
|
-
flat: FlatSnapshot,
|
|
177
|
-
hfId: string,
|
|
178
|
-
): SdkShadowMismatch | null {
|
|
179
|
-
const fields = resolveOpFields(op, flat);
|
|
180
|
-
if (!fields || fields.actual === fields.expected) return null;
|
|
181
|
-
return { kind: "value_mismatch", hfId, ...fields };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Dispatch PatchOperations to the SDK session and return a parity report.
|
|
186
|
-
*
|
|
187
|
-
* If the element is not found by hfId, returns dispatched:false with a
|
|
188
|
-
* element_not_found mismatch (signals blocker E — element has no hf-id or
|
|
189
|
-
* SDK can't address it).
|
|
190
|
-
*
|
|
191
|
-
* On success, verifies that the SDK element snapshot reflects the applied
|
|
192
|
-
* values. Value mismatches indicate serialization or normalization drift.
|
|
193
|
-
*
|
|
194
|
-
* **persist:error drift risk**: the HTTP adapter fires persist:error on
|
|
195
|
-
* network failure but the SDK session is already mutated at that point. If
|
|
196
|
-
* the server file was not updated (e.g. 503), subsequent shadow parity
|
|
197
|
-
* comparisons here will see a diverged SDK session and produce false
|
|
198
|
-
* positives. Before flipping STUDIO_SDK_DISPATCH_ENABLED, verify the shadow
|
|
199
|
-
* window is clear of persist:error events.
|
|
200
|
-
*/
|
|
201
|
-
|
|
202
|
-
export function sdkShadowDispatch(
|
|
203
|
-
session: Composition,
|
|
204
|
-
hfId: string,
|
|
205
|
-
ops: PatchOperation[],
|
|
206
|
-
): SdkShadowResult {
|
|
207
|
-
if (!session.getElement(hfId)) {
|
|
208
|
-
return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] };
|
|
209
|
-
}
|
|
210
|
-
// Drop studio-internal markers the SDK model can't represent (data-hf-*), so
|
|
211
|
-
// canvas-drag/path-offset edits don't false-mismatch on bookkeeping attrs.
|
|
212
|
-
const shadowable = ops.filter(isShadowableOp);
|
|
213
|
-
try {
|
|
214
|
-
const sdkOps = patchOpsToSdkEditOps(hfId, shadowable);
|
|
215
|
-
session.batch(() => {
|
|
216
|
-
for (const op of sdkOps) session.dispatch(op);
|
|
217
|
-
});
|
|
218
|
-
} catch (err) {
|
|
219
|
-
return {
|
|
220
|
-
dispatched: false,
|
|
221
|
-
mismatches: [{ kind: "dispatch_error", hfId, error: String(err) }],
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
const flat = flattenSnapshot(session.getElement(hfId));
|
|
225
|
-
const mismatches = shadowable
|
|
226
|
-
.map((op) => checkOpParity(op, flat, hfId))
|
|
227
|
-
.filter((m): m is SdkShadowMismatch => m !== null);
|
|
228
|
-
return { dispatched: true, mismatches };
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// ─── Telemetry reporting ──────────────────────────────────────────────────────
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Shadow-dispatch ops to the SDK session and emit sdk_shadow_dispatch telemetry.
|
|
235
|
-
* Despite the telemetry focus, this function does mutate the SDK session — it
|
|
236
|
-
* is not read-only. No-op when STUDIO_SDK_SHADOW_ENABLED is false.
|
|
237
|
-
*/
|
|
238
|
-
// Property-path mismatches carry user content (inline-style values, edited
|
|
239
|
-
// text) in expected/actual. Scrub before telemetry: fully redact text-content
|
|
240
|
-
// values, length-cap the rest. The in-memory parity result keeps raw values.
|
|
241
|
-
function redactValueForTelemetry(
|
|
242
|
-
property: string | undefined,
|
|
243
|
-
value: string | null | undefined,
|
|
244
|
-
): string | null | undefined {
|
|
245
|
-
if (value == null) return value;
|
|
246
|
-
if (property === "text") return `[redacted len=${value.length}]`;
|
|
247
|
-
return value.length > 64 ? `${value.slice(0, 64)}…` : value;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function redactMismatchesForTelemetry(mismatches: SdkShadowMismatch[]): SdkShadowMismatch[] {
|
|
251
|
-
return mismatches.map((m) => ({
|
|
252
|
-
...m,
|
|
253
|
-
expected: redactValueForTelemetry(m.property, m.expected),
|
|
254
|
-
actual: redactValueForTelemetry(m.property, m.actual),
|
|
255
|
-
}));
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
export function runShadowDispatch(
|
|
259
|
-
session: Composition,
|
|
260
|
-
selection: DomEditSelection,
|
|
261
|
-
ops: PatchOperation[],
|
|
262
|
-
): void {
|
|
263
|
-
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
|
264
|
-
const hfId = selection.hfId;
|
|
265
|
-
if (!hfId) {
|
|
266
|
-
trackStudioEvent("sdk_shadow_dispatch", {
|
|
267
|
-
op: "property",
|
|
268
|
-
dispatched: false,
|
|
269
|
-
reason: "no_hf_id",
|
|
270
|
-
mismatchCount: 0,
|
|
271
|
-
});
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
// Defensive: patchOpsToSdkEditOps silently drops PatchOperation types it
|
|
275
|
-
// doesn't map. PatchOperation.type is a closed union today, but emit a visible
|
|
276
|
-
// unmapped_type event if a future type ever slips through, so the gap surfaces
|
|
277
|
-
// in telemetry instead of vanishing.
|
|
278
|
-
// Map to the type string before find, so a future unmapped type is read as a
|
|
279
|
-
// plain string (no object cast; find on the closed union narrows to never).
|
|
280
|
-
const unmappedType = ops.map((op) => op.type).find((t) => !MAPPED_PATCH_OP_TYPES.has(t));
|
|
281
|
-
if (unmappedType !== undefined) {
|
|
282
|
-
trackStudioEvent("sdk_shadow_dispatch", {
|
|
283
|
-
op: "property",
|
|
284
|
-
dispatched: false,
|
|
285
|
-
reason: "unmapped_type",
|
|
286
|
-
type: unmappedType,
|
|
287
|
-
mismatchCount: 0,
|
|
288
|
-
});
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
const result = sdkShadowDispatch(session, hfId, ops);
|
|
292
|
-
trackStudioEvent("sdk_shadow_dispatch", {
|
|
293
|
-
op: "property",
|
|
294
|
-
dispatched: result.dispatched,
|
|
295
|
-
mismatchCount: result.mismatches.length,
|
|
296
|
-
mismatches: JSON.stringify(redactMismatchesForTelemetry(result.mismatches)),
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// ─── Shadow for non-PatchOperation ops (delete / timing / GSAP) ───────────────
|
|
301
|
-
//
|
|
302
|
-
// These ops never flow through persistDomEditOperations, so the property-path
|
|
303
|
-
// shadow above never sees them. Each runner keeps the server authoritative and
|
|
304
|
-
// only observes the SDK: can() pre-checks addressing/validity (pure, no
|
|
305
|
-
// mutation — works even for GSAP, which has no element-snapshot value), then a
|
|
306
|
-
// dispatch into the live session with a snapshot-based parity check.
|
|
307
|
-
//
|
|
308
|
-
// Parity coverage by op:
|
|
309
|
-
// delete → getElement(id) === null (full)
|
|
310
|
-
// timing → snapshot.start/duration/trackIndex (full)
|
|
311
|
-
// gsap → tween id present/absent in animationIds (existence only — the
|
|
312
|
-
// tween's property values are script-level, not in the snapshot)
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* can()-gated shadow dispatch. Emits sdk_shadow_dispatch tagged with `opLabel`.
|
|
316
|
-
* Mutates the SDK session (not read-only); server stays authoritative.
|
|
317
|
-
* No-op when STUDIO_SDK_SHADOW_ENABLED is false.
|
|
318
|
-
*/
|
|
319
|
-
function runShadowEditOp(
|
|
320
|
-
session: Composition,
|
|
321
|
-
op: EditOp,
|
|
322
|
-
opLabel: string,
|
|
323
|
-
dispatchAndCheck: () => SdkShadowMismatch[],
|
|
324
|
-
): void {
|
|
325
|
-
const verdict = session.can(op);
|
|
326
|
-
if (!verdict.ok) {
|
|
327
|
-
trackStudioEvent("sdk_shadow_dispatch", {
|
|
328
|
-
op: opLabel,
|
|
329
|
-
dispatched: false,
|
|
330
|
-
reason: "cannot_dispatch",
|
|
331
|
-
code: verdict.code,
|
|
332
|
-
mismatchCount: 0,
|
|
333
|
-
});
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
let mismatches: SdkShadowMismatch[];
|
|
337
|
-
try {
|
|
338
|
-
mismatches = dispatchAndCheck();
|
|
339
|
-
} catch (err) {
|
|
340
|
-
trackStudioEvent("sdk_shadow_dispatch", {
|
|
341
|
-
op: opLabel,
|
|
342
|
-
dispatched: false,
|
|
343
|
-
reason: "dispatch_error",
|
|
344
|
-
error: String(err),
|
|
345
|
-
mismatchCount: 0,
|
|
346
|
-
});
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
349
|
-
trackStudioEvent("sdk_shadow_dispatch", {
|
|
350
|
-
op: opLabel,
|
|
351
|
-
dispatched: true,
|
|
352
|
-
mismatchCount: mismatches.length,
|
|
353
|
-
mismatches: JSON.stringify(mismatches),
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** Shadow an element delete. Parity: the element is gone from the SDK session. */
|
|
358
|
-
export function runShadowDelete(session: Composition, hfId: string | null | undefined): void {
|
|
359
|
-
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
|
360
|
-
if (!hfId) {
|
|
361
|
-
trackStudioEvent("sdk_shadow_dispatch", {
|
|
362
|
-
op: "delete",
|
|
363
|
-
dispatched: false,
|
|
364
|
-
reason: "no_hf_id",
|
|
365
|
-
mismatchCount: 0,
|
|
366
|
-
});
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
const op: EditOp = { type: "removeElement", target: hfId };
|
|
370
|
-
runShadowEditOp(session, op, "delete", () => {
|
|
371
|
-
session.batch(() => session.dispatch(op));
|
|
372
|
-
return session.getElement(hfId)
|
|
373
|
-
? [
|
|
374
|
-
{
|
|
375
|
-
kind: "value_mismatch",
|
|
376
|
-
hfId,
|
|
377
|
-
property: "exists",
|
|
378
|
-
expected: "removed",
|
|
379
|
-
actual: "present",
|
|
380
|
-
},
|
|
381
|
-
]
|
|
382
|
-
: [];
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
export interface ShadowTiming {
|
|
387
|
-
start?: number;
|
|
388
|
-
duration?: number;
|
|
389
|
-
trackIndex?: number;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// start/duration tolerate float-precision drift (SDK computes them
|
|
393
|
-
// arithmetically, server stores a rounded literal) via the shared relative
|
|
394
|
-
// epsilon; trackIndex (integer track slot) is compared exactly.
|
|
395
|
-
function timingFieldEqual(
|
|
396
|
-
key: keyof ShadowTiming,
|
|
397
|
-
actual: number | null | undefined,
|
|
398
|
-
expected: number,
|
|
399
|
-
): boolean {
|
|
400
|
-
if (typeof actual === "number" && key !== "trackIndex") {
|
|
401
|
-
return relEqual(actual, expected);
|
|
402
|
-
}
|
|
403
|
-
return actual === expected;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/** Shadow a timing edit. Parity: snapshot start/duration/trackIndex match. */
|
|
407
|
-
export function runShadowTiming(
|
|
408
|
-
session: Composition,
|
|
409
|
-
hfId: string | null | undefined,
|
|
410
|
-
timing: ShadowTiming,
|
|
411
|
-
): void {
|
|
412
|
-
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
|
413
|
-
if (!hfId) {
|
|
414
|
-
trackStudioEvent("sdk_shadow_dispatch", {
|
|
415
|
-
op: "timing",
|
|
416
|
-
dispatched: false,
|
|
417
|
-
reason: "no_hf_id",
|
|
418
|
-
mismatchCount: 0,
|
|
419
|
-
});
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
const op: EditOp = { type: "setTiming", target: hfId, ...timing };
|
|
423
|
-
runShadowEditOp(session, op, "timing", () => {
|
|
424
|
-
session.batch(() => session.dispatch(op));
|
|
425
|
-
const el = session.getElement(hfId);
|
|
426
|
-
const mismatches: SdkShadowMismatch[] = [];
|
|
427
|
-
const fields: Array<[keyof ShadowTiming, number | null | undefined]> = [
|
|
428
|
-
["start", el?.start],
|
|
429
|
-
["duration", el?.duration],
|
|
430
|
-
["trackIndex", el?.trackIndex],
|
|
431
|
-
];
|
|
432
|
-
for (const [key, actual] of fields) {
|
|
433
|
-
const expected = timing[key];
|
|
434
|
-
if (expected === undefined || timingFieldEqual(key, actual, expected)) continue;
|
|
435
|
-
mismatches.push({
|
|
436
|
-
kind: "value_mismatch",
|
|
437
|
-
hfId,
|
|
438
|
-
property: key,
|
|
439
|
-
expected: String(expected),
|
|
440
|
-
actual: actual == null ? null : String(actual),
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
return mismatches;
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
export type ShadowGsapOp =
|
|
448
|
-
| { kind: "add"; target: string; tween: GsapTweenSpec }
|
|
449
|
-
| { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
|
|
450
|
-
| { kind: "remove"; animationId: string };
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* Shadow a GSAP tween mutation (add / set / remove). The server's animationId
|
|
454
|
-
* shares the SDK's id-space (both derive `targetSelector-method-position` from
|
|
455
|
-
* the same acorn parser — see sdk assignStableIds), so it is dispatchable as-is.
|
|
456
|
-
*
|
|
457
|
-
* Parity via the now-populated ElementSnapshot.animationIds:
|
|
458
|
-
* add → the returned tween id is present on the target element
|
|
459
|
-
* remove → the id is gone from every element
|
|
460
|
-
* set → existence only (the SDK exposes no per-tween property reader; value
|
|
461
|
-
* fidelity would need serialize()-script round-trip diffing).
|
|
462
|
-
*/
|
|
463
|
-
export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void {
|
|
464
|
-
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
|
465
|
-
const op: EditOp =
|
|
466
|
-
gsapOp.kind === "add"
|
|
467
|
-
? { type: "addGsapTween", target: gsapOp.target, tween: gsapOp.tween }
|
|
468
|
-
: gsapOp.kind === "set"
|
|
469
|
-
? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties }
|
|
470
|
-
: { type: "removeGsapTween", animationId: gsapOp.animationId };
|
|
471
|
-
// fallow-ignore-next-line complexity
|
|
472
|
-
runShadowEditOp(session, op, "gsap", () => {
|
|
473
|
-
let newId: string | undefined;
|
|
474
|
-
session.batch(() => {
|
|
475
|
-
if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween);
|
|
476
|
-
else session.dispatch(op);
|
|
477
|
-
});
|
|
478
|
-
if (gsapOp.kind === "add") {
|
|
479
|
-
const onTarget = session.getElement(gsapOp.target)?.animationIds ?? [];
|
|
480
|
-
if (!newId || !onTarget.includes(newId)) {
|
|
481
|
-
return [
|
|
482
|
-
{
|
|
483
|
-
kind: "value_mismatch",
|
|
484
|
-
hfId: gsapOp.target,
|
|
485
|
-
property: "animationIds",
|
|
486
|
-
expected: newId ?? "non-empty",
|
|
487
|
-
actual: onTarget.join(",") || null,
|
|
488
|
-
},
|
|
489
|
-
];
|
|
490
|
-
}
|
|
491
|
-
} else if (gsapOp.kind === "remove") {
|
|
492
|
-
const stillPresent = session
|
|
493
|
-
.getElements()
|
|
494
|
-
.some((el) => el.animationIds.includes(gsapOp.animationId));
|
|
495
|
-
if (stillPresent) {
|
|
496
|
-
return [
|
|
497
|
-
{
|
|
498
|
-
kind: "value_mismatch",
|
|
499
|
-
hfId: gsapOp.animationId,
|
|
500
|
-
property: "animationIds",
|
|
501
|
-
expected: "removed",
|
|
502
|
-
actual: "present",
|
|
503
|
-
},
|
|
504
|
-
];
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
return [];
|
|
508
|
-
});
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// GSAP value-fidelity diff lives in its own module to keep this file under the
|
|
512
|
-
// 600-line studio cap; re-exported here so the shadow surface stays in one place.
|
|
513
|
-
export {
|
|
514
|
-
gsapFidelityMismatches,
|
|
515
|
-
resolveGsapFidelityArgs,
|
|
516
|
-
runShadowGsapFidelity,
|
|
517
|
-
} from "./sdkShadowGsapFidelity";
|