@hyperframes/studio 0.6.101 → 0.6.103
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-DvttAtOD.js → index-CdjhNZK1.js} +1 -1
- package/dist/assets/{index-CQ3n6Y9q.js → index-nCLBVKzI.js} +1 -1
- package/dist/assets/{index-CTiqZ7XQ.js → index-woy2HyV8.js} +98 -98
- package/dist/index.html +1 -1
- package/package.json +5 -5
- package/src/components/renders/useRenderQueue.ts +6 -0
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +121 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +48 -2
- package/src/hooks/gsapScriptCommitTypes.ts +3 -0
- package/src/hooks/useGsapAnimationOps.ts +38 -26
- package/src/hooks/useGsapScriptCommits.ts +17 -1
- package/src/hooks/useGsapTweenCache.ts +10 -12
- package/src/hooks/useSafeGsapCommitMutation.ts +1 -14
- package/src/utils/sdkShadow.test.ts +131 -1
- package/src/utils/sdkShadow.ts +46 -16
- package/src/utils/sdkShadowGsapFidelity.ts +208 -0
package/src/utils/sdkShadow.ts
CHANGED
|
@@ -367,12 +367,15 @@ export type ShadowGsapOp =
|
|
|
367
367
|
| { kind: "remove"; animationId: string };
|
|
368
368
|
|
|
369
369
|
/**
|
|
370
|
-
* Shadow a GSAP tween mutation
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
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).
|
|
376
379
|
*/
|
|
377
380
|
export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void {
|
|
378
381
|
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
|
@@ -382,23 +385,50 @@ export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp):
|
|
|
382
385
|
: gsapOp.kind === "set"
|
|
383
386
|
? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties }
|
|
384
387
|
: { type: "removeGsapTween", animationId: gsapOp.animationId };
|
|
388
|
+
// fallow-ignore-next-line complexity
|
|
385
389
|
runShadowEditOp(session, op, "gsap", () => {
|
|
386
390
|
let newId: string | undefined;
|
|
387
391
|
session.batch(() => {
|
|
388
392
|
if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween);
|
|
389
393
|
else session.dispatch(op);
|
|
390
394
|
});
|
|
391
|
-
if (gsapOp.kind === "add"
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
+
}
|
|
401
423
|
}
|
|
402
424
|
return [];
|
|
403
425
|
});
|
|
404
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";
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSAP value-fidelity shadow (serialize round-trip diff). Split out of
|
|
3
|
+
* sdkShadow.ts to keep that file under the 600-line studio cap.
|
|
4
|
+
*
|
|
5
|
+
* Existence parity (sdkShadow.ts) confirms a tween was created/removed, but not
|
|
6
|
+
* that its VALUES (duration / ease / position / properties) match the server.
|
|
7
|
+
* The SDK exposes no per-tween property reader, so we compare the two writers'
|
|
8
|
+
* output: apply the same op to a fresh SDK doc opened from the server's pre-op
|
|
9
|
+
* file, then structurally diff the SDK's GSAP script against the server's
|
|
10
|
+
* resulting script. Both are re-parsed, so formatting/whitespace differences
|
|
11
|
+
* never produce false positives — only real value drift does.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { openComposition } from "@hyperframes/sdk";
|
|
15
|
+
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
|
|
16
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
17
|
+
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
18
|
+
import { trackStudioEvent } from "./studioTelemetry";
|
|
19
|
+
import type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow";
|
|
20
|
+
|
|
21
|
+
// Marker set must match document.ts extractGsapScript so both pick the same
|
|
22
|
+
// <script> from any given composition.
|
|
23
|
+
function isGsapScriptBody(body: string): boolean {
|
|
24
|
+
return body.includes("gsap") || body.includes("__timelines") || body.includes("ScrollTrigger");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractGsapScript(html: string): string | null {
|
|
28
|
+
// Close tag is `</script[^>]*>` (not just `</script>`) — HTML5 ignores junk
|
|
29
|
+
// before the `>`, e.g. `</script >` or `</script foo>` (CodeQL js/bad-tag-filter).
|
|
30
|
+
const scripts = html.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi);
|
|
31
|
+
if (!scripts) return null;
|
|
32
|
+
for (const block of scripts) {
|
|
33
|
+
const body = block.replace(/^<script\b[^>]*>/i, "").replace(/<\/script[^>]*>$/i, "");
|
|
34
|
+
if (isGsapScriptBody(body)) return body;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function animById(script: string): Map<string, GsapAnimation> {
|
|
40
|
+
const map = new Map<string, GsapAnimation>();
|
|
41
|
+
const parsed = parseGsapScriptAcorn(script);
|
|
42
|
+
for (const anim of parsed.animations) map.set(anim.id, anim);
|
|
43
|
+
return map;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The server (addAnimationToScript) and SDK (gsapWriterAcorn) are DIFFERENT
|
|
47
|
+
// writers, so the same tween can serialize with different property key order or
|
|
48
|
+
// number-vs-string forms. Compare canonically — sort keys, coerce numeric
|
|
49
|
+
// strings — so only real value drift registers, not formatting differences.
|
|
50
|
+
|
|
51
|
+
function numericEqual(a: unknown, b: unknown): boolean {
|
|
52
|
+
if (a === b) return true;
|
|
53
|
+
const na = typeof a === "string" ? Number(a) : a;
|
|
54
|
+
const nb = typeof b === "string" ? Number(b) : b;
|
|
55
|
+
return (
|
|
56
|
+
typeof na === "number" &&
|
|
57
|
+
typeof nb === "number" &&
|
|
58
|
+
!Number.isNaN(na) &&
|
|
59
|
+
!Number.isNaN(nb) &&
|
|
60
|
+
na === nb
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function canonicalProps(obj: Record<string, unknown> | undefined): string {
|
|
65
|
+
if (!obj) return "{}";
|
|
66
|
+
const out: Record<string, unknown> = {};
|
|
67
|
+
for (const key of Object.keys(obj).sort()) {
|
|
68
|
+
const v = obj[key];
|
|
69
|
+
// normalize "0.5" → 0.5 so a number/string writer difference isn't drift
|
|
70
|
+
out[key] = typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v;
|
|
71
|
+
}
|
|
72
|
+
return JSON.stringify(out);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Structurally diff two GSAP scripts by tween id. Reports a tween present in
|
|
77
|
+
* one but not the other, and per-field value drift (method, position, duration,
|
|
78
|
+
* ease, properties, fromProperties). Comparison is canonical (see above) so
|
|
79
|
+
* writer formatting differences do not produce false mismatches.
|
|
80
|
+
*/
|
|
81
|
+
// fallow-ignore-next-line complexity
|
|
82
|
+
export function gsapFidelityMismatches(
|
|
83
|
+
sdkScript: string,
|
|
84
|
+
serverScript: string,
|
|
85
|
+
): SdkShadowMismatch[] {
|
|
86
|
+
const sdk = animById(sdkScript);
|
|
87
|
+
const server = animById(serverScript);
|
|
88
|
+
const mismatches: SdkShadowMismatch[] = [];
|
|
89
|
+
const ids = new Set([...sdk.keys(), ...server.keys()]);
|
|
90
|
+
for (const id of ids) {
|
|
91
|
+
const a = sdk.get(id);
|
|
92
|
+
const b = server.get(id);
|
|
93
|
+
if (!a || !b) {
|
|
94
|
+
mismatches.push({
|
|
95
|
+
kind: "value_mismatch",
|
|
96
|
+
hfId: id,
|
|
97
|
+
property: "tween",
|
|
98
|
+
expected: b ? "present" : "absent",
|
|
99
|
+
actual: a ? "present" : "absent",
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// [property, sdk-value, server-value, equal?]
|
|
104
|
+
const fields: Array<[string, unknown, unknown, boolean]> = [
|
|
105
|
+
["method", a.method, b.method, a.method === b.method],
|
|
106
|
+
["position", a.position, b.position, numericEqual(a.position, b.position)],
|
|
107
|
+
["duration", a.duration, b.duration, numericEqual(a.duration, b.duration)],
|
|
108
|
+
["ease", a.ease, b.ease, a.ease === b.ease],
|
|
109
|
+
[
|
|
110
|
+
"properties",
|
|
111
|
+
a.properties,
|
|
112
|
+
b.properties,
|
|
113
|
+
canonicalProps(a.properties) === canonicalProps(b.properties),
|
|
114
|
+
],
|
|
115
|
+
[
|
|
116
|
+
"fromProperties",
|
|
117
|
+
a.fromProperties,
|
|
118
|
+
b.fromProperties,
|
|
119
|
+
canonicalProps(a.fromProperties) === canonicalProps(b.fromProperties),
|
|
120
|
+
],
|
|
121
|
+
];
|
|
122
|
+
for (const [property, av, bv, equal] of fields) {
|
|
123
|
+
if (!equal) {
|
|
124
|
+
mismatches.push({
|
|
125
|
+
kind: "value_mismatch",
|
|
126
|
+
hfId: id,
|
|
127
|
+
property,
|
|
128
|
+
expected: bv == null ? null : JSON.stringify(bv),
|
|
129
|
+
actual: av == null ? null : JSON.stringify(av),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return mismatches;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface GsapFidelityArgs {
|
|
138
|
+
before: string;
|
|
139
|
+
op: ShadowGsapOp;
|
|
140
|
+
serverScript: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Wiring gate for the commitMutation chokepoint: return the narrowed fidelity
|
|
145
|
+
* args only when there is a live session, a typed shadow op, and both the
|
|
146
|
+
* pre-op file and the server's resulting script to diff against (scriptText is
|
|
147
|
+
* null when the composition has no GSAP script). Returns null otherwise. Pure +
|
|
148
|
+
* narrowing so the wiring decision is unit-testable without rendering the hook
|
|
149
|
+
* and the caller needs no non-null assertions.
|
|
150
|
+
*/
|
|
151
|
+
export function resolveGsapFidelityArgs(
|
|
152
|
+
sdkSession: unknown,
|
|
153
|
+
shadowGsapOp: ShadowGsapOp | undefined,
|
|
154
|
+
before: string | null | undefined,
|
|
155
|
+
serverScript: string | null | undefined,
|
|
156
|
+
): GsapFidelityArgs | null {
|
|
157
|
+
if (!sdkSession || !shadowGsapOp || before == null || serverScript == null) return null;
|
|
158
|
+
return { before, op: shadowGsapOp, serverScript };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Shadow GSAP value fidelity: open a fresh SDK doc from the server's pre-op
|
|
163
|
+
* file, apply the same tween op, serialize, and diff the SDK's GSAP script
|
|
164
|
+
* against the server's resulting script. Emits sdk_shadow_dispatch op:
|
|
165
|
+
* "gsap_fidelity". Async, fire-and-forget; server stays authoritative.
|
|
166
|
+
*/
|
|
167
|
+
export async function runShadowGsapFidelity(
|
|
168
|
+
beforeHtml: string,
|
|
169
|
+
gsapOp: ShadowGsapOp,
|
|
170
|
+
serverScript: string,
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
|
173
|
+
// No server script to diff against → skip the (costly) openComposition.
|
|
174
|
+
if (!serverScript || !beforeHtml) return;
|
|
175
|
+
try {
|
|
176
|
+
const session = await openComposition(beforeHtml);
|
|
177
|
+
session.batch(() => {
|
|
178
|
+
if (gsapOp.kind === "add") session.addGsapTween(gsapOp.target, gsapOp.tween);
|
|
179
|
+
else if (gsapOp.kind === "set") session.setGsapTween(gsapOp.animationId, gsapOp.properties);
|
|
180
|
+
else session.removeGsapTween(gsapOp.animationId);
|
|
181
|
+
});
|
|
182
|
+
const sdkScript = extractGsapScript(session.serialize());
|
|
183
|
+
if (sdkScript == null) {
|
|
184
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
185
|
+
op: "gsap_fidelity",
|
|
186
|
+
dispatched: false,
|
|
187
|
+
reason: "no_sdk_script",
|
|
188
|
+
mismatchCount: 0,
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const mismatches = gsapFidelityMismatches(sdkScript, serverScript);
|
|
193
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
194
|
+
op: "gsap_fidelity",
|
|
195
|
+
dispatched: true,
|
|
196
|
+
mismatchCount: mismatches.length,
|
|
197
|
+
mismatches: JSON.stringify(mismatches),
|
|
198
|
+
});
|
|
199
|
+
} catch (err) {
|
|
200
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
201
|
+
op: "gsap_fidelity",
|
|
202
|
+
dispatched: false,
|
|
203
|
+
reason: "fidelity_error",
|
|
204
|
+
error: String(err),
|
|
205
|
+
mismatchCount: 0,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|