@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
|
@@ -1,296 +0,0 @@
|
|
|
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 { relEqual } from "./sdkShadowNumeric";
|
|
20
|
-
import type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow";
|
|
21
|
-
|
|
22
|
-
// Marker set must match document.ts extractGsapScript so both pick the same
|
|
23
|
-
// <script> from any given composition.
|
|
24
|
-
function isGsapScriptBody(body: string): boolean {
|
|
25
|
-
return body.includes("gsap") || body.includes("__timelines") || body.includes("ScrollTrigger");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function extractGsapScript(html: string): string | null {
|
|
29
|
-
// Close tag is `</script[^>]*>` (not just `</script>`) — HTML5 ignores junk
|
|
30
|
-
// before the `>`, e.g. `</script >` or `</script foo>` (CodeQL js/bad-tag-filter).
|
|
31
|
-
const scripts = html.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi);
|
|
32
|
-
if (!scripts) return null;
|
|
33
|
-
for (const block of scripts) {
|
|
34
|
-
const body = block.replace(/^<script\b[^>]*>/i, "").replace(/<\/script[^>]*>$/i, "");
|
|
35
|
-
if (isGsapScriptBody(body)) return body;
|
|
36
|
-
}
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function posKey(position: unknown): string {
|
|
41
|
-
if (typeof position === "number") return String(position);
|
|
42
|
-
const n = Number(position);
|
|
43
|
-
return Number.isNaN(n) ? String(position) : String(n);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Key a tween by its RESOLVED target element (not raw selector) + method +
|
|
47
|
-
// position. The SDK writer emits [data-hf-id="X"] selectors while the server
|
|
48
|
-
// emits class/other selectors for the SAME element; keying by resolved element
|
|
49
|
-
// matches them so the diff compares values instead of flagging present/absent.
|
|
50
|
-
//
|
|
51
|
-
// ponytail: one-tween-per-(element, method, position) assumption — coincident
|
|
52
|
-
// tweens (same element+method+position, different props) collapse, last wins,
|
|
53
|
-
// so the diff under-reports them. Props can't go in the key (a matched pair
|
|
54
|
-
// must share a key for the field-diff to run; raw props would split real value
|
|
55
|
-
// drift into present/absent). Not seen in studio-emitted templates; add a
|
|
56
|
-
// property-NAME hash to the key if coincident tweens show up in the wild.
|
|
57
|
-
function tweenKey(anim: GsapAnimation, resolveSelector?: (sel: string) => string): string {
|
|
58
|
-
const sel = resolveSelector ? resolveSelector(anim.targetSelector) : anim.targetSelector;
|
|
59
|
-
return `${sel}|${anim.method}|${posKey(anim.position)}`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function animByKey(
|
|
63
|
-
script: string,
|
|
64
|
-
resolveSelector?: (sel: string) => string,
|
|
65
|
-
): Map<string, GsapAnimation> {
|
|
66
|
-
const map = new Map<string, GsapAnimation>();
|
|
67
|
-
const parsed = parseGsapScriptAcorn(script);
|
|
68
|
-
for (const anim of parsed.animations) map.set(tweenKey(anim, resolveSelector), anim);
|
|
69
|
-
return map;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// The server (addAnimationToScript) and SDK (gsapWriterAcorn) are DIFFERENT
|
|
73
|
-
// writers, so the same tween can serialize with different property key order or
|
|
74
|
-
// number-vs-string forms. Compare canonically — sort keys, coerce numeric
|
|
75
|
-
// strings — so only real value drift registers, not formatting differences.
|
|
76
|
-
|
|
77
|
-
// Coerce string operands to numbers, then compare with the shared relative
|
|
78
|
-
// epsilon (relEqual) so float-formatting noise (3.1 vs 3.0999999999999996)
|
|
79
|
-
// isn't flagged as drift while a real 2 vs 1 still is.
|
|
80
|
-
function numericEqual(a: unknown, b: unknown): boolean {
|
|
81
|
-
if (a === b) return true;
|
|
82
|
-
const na = typeof a === "string" ? Number(a) : a;
|
|
83
|
-
const nb = typeof b === "string" ? Number(b) : b;
|
|
84
|
-
if (typeof na !== "number" || typeof nb !== "number" || Number.isNaN(na) || Number.isNaN(nb)) {
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
return relEqual(na, nb);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function canonicalProps(obj: Record<string, unknown> | undefined): string {
|
|
91
|
-
if (!obj) return "{}";
|
|
92
|
-
const out: Record<string, unknown> = {};
|
|
93
|
-
for (const key of Object.keys(obj).sort()) {
|
|
94
|
-
const v = obj[key];
|
|
95
|
-
// normalize "0.5" → 0.5 so a number/string writer difference isn't drift
|
|
96
|
-
out[key] = typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v;
|
|
97
|
-
}
|
|
98
|
-
return JSON.stringify(out);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Structurally diff two GSAP scripts. Tweens are matched by resolved target
|
|
103
|
-
* element + method + position (see tweenKey), so the SDK's [data-hf-id]
|
|
104
|
-
* selectors and the server's class selectors for the same element don't
|
|
105
|
-
* false-flag present/absent. Reports a tween present in one but not the other,
|
|
106
|
-
* and per-field value drift (duration, ease, properties, fromProperties).
|
|
107
|
-
* Comparison is canonical so writer formatting differences don't register.
|
|
108
|
-
*
|
|
109
|
-
* Pass resolveSelector (selector → canonical element id) to enable the
|
|
110
|
-
* element-based matching; without it, matching falls back to raw selector.
|
|
111
|
-
*/
|
|
112
|
-
// fallow-ignore-next-line complexity
|
|
113
|
-
export function gsapFidelityMismatches(
|
|
114
|
-
sdkScript: string,
|
|
115
|
-
serverScript: string,
|
|
116
|
-
resolveSelector?: (sel: string) => string,
|
|
117
|
-
): SdkShadowMismatch[] {
|
|
118
|
-
const sdk = animByKey(sdkScript, resolveSelector);
|
|
119
|
-
const server = animByKey(serverScript, resolveSelector);
|
|
120
|
-
const mismatches: SdkShadowMismatch[] = [];
|
|
121
|
-
const keys = new Set([...sdk.keys(), ...server.keys()]);
|
|
122
|
-
for (const key of keys) {
|
|
123
|
-
const a = sdk.get(key);
|
|
124
|
-
const b = server.get(key);
|
|
125
|
-
if (!a || !b) {
|
|
126
|
-
mismatches.push({
|
|
127
|
-
kind: "value_mismatch",
|
|
128
|
-
hfId: key,
|
|
129
|
-
property: "tween",
|
|
130
|
-
expected: b ? "present" : "absent",
|
|
131
|
-
actual: a ? "present" : "absent",
|
|
132
|
-
});
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
// method + position are part of the key (already equal); compare values.
|
|
136
|
-
const fields: Array<[string, unknown, unknown, boolean]> = [
|
|
137
|
-
["duration", a.duration, b.duration, numericEqual(a.duration, b.duration)],
|
|
138
|
-
["ease", a.ease, b.ease, a.ease === b.ease],
|
|
139
|
-
[
|
|
140
|
-
"properties",
|
|
141
|
-
a.properties,
|
|
142
|
-
b.properties,
|
|
143
|
-
canonicalProps(a.properties) === canonicalProps(b.properties),
|
|
144
|
-
],
|
|
145
|
-
[
|
|
146
|
-
"fromProperties",
|
|
147
|
-
a.fromProperties,
|
|
148
|
-
b.fromProperties,
|
|
149
|
-
canonicalProps(a.fromProperties) === canonicalProps(b.fromProperties),
|
|
150
|
-
],
|
|
151
|
-
];
|
|
152
|
-
for (const [property, av, bv, equal] of fields) {
|
|
153
|
-
if (!equal) {
|
|
154
|
-
mismatches.push({
|
|
155
|
-
kind: "value_mismatch",
|
|
156
|
-
hfId: key,
|
|
157
|
-
property,
|
|
158
|
-
expected: bv == null ? null : JSON.stringify(bv),
|
|
159
|
-
actual: av == null ? null : JSON.stringify(av),
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return mismatches;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export interface GsapFidelityArgs {
|
|
168
|
-
before: string;
|
|
169
|
-
op: ShadowGsapOp;
|
|
170
|
-
serverScript: string;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Wiring gate for the commitMutation chokepoint: return the narrowed fidelity
|
|
175
|
-
* args only when there is a live session, a typed shadow op, and both the
|
|
176
|
-
* pre-op file and the server's resulting script to diff against (scriptText is
|
|
177
|
-
* null when the composition has no GSAP script). Returns null otherwise. Pure +
|
|
178
|
-
* narrowing so the wiring decision is unit-testable without rendering the hook
|
|
179
|
-
* and the caller needs no non-null assertions.
|
|
180
|
-
*/
|
|
181
|
-
export function resolveGsapFidelityArgs(
|
|
182
|
-
sdkSession: unknown,
|
|
183
|
-
shadowGsapOp: ShadowGsapOp | undefined,
|
|
184
|
-
before: string | null | undefined,
|
|
185
|
-
serverScript: string | null | undefined,
|
|
186
|
-
): GsapFidelityArgs | null {
|
|
187
|
-
if (!sdkSession || !shadowGsapOp || before == null || serverScript == null) return null;
|
|
188
|
-
return { before, op: shadowGsapOp, serverScript };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Resolve a CSS selector to a canonical element key using the pre-op document,
|
|
192
|
-
// so tweens that target the same element via different selectors
|
|
193
|
-
// ([data-hf-id="X"] vs .X vs #X) collapse to one key in the fidelity diff.
|
|
194
|
-
//
|
|
195
|
-
// The SDK writer emits [data-hf-id="X"] while the server may emit a class/id
|
|
196
|
-
// selector for the SAME element. Keying both forms to the same node prevents a
|
|
197
|
-
// false present/absent mismatch. Resolution order, for whatever element the
|
|
198
|
-
// selector matches:
|
|
199
|
-
// 1. data-hf-id present → "hfid:<id>" (the common, stable case)
|
|
200
|
-
// 2. no data-hf-id → "node:<n>" (per-document node index; identical
|
|
201
|
-
// regardless of which selector form found the node, so .x and [data-hf-id]
|
|
202
|
-
// pointing at the same attribute-less node still collapse)
|
|
203
|
-
// 3. selector resolves to no node / parse error / no DOM → the raw selector
|
|
204
|
-
// (last resort; only diverges when the two writers genuinely target
|
|
205
|
-
// different — or unresolvable — nodes, which is real drift to surface)
|
|
206
|
-
// The "hfid:"/"node:" prefixes are namespaced so a canonical key can never
|
|
207
|
-
// collide with a raw-selector fallback.
|
|
208
|
-
//
|
|
209
|
-
// ponytail: first-match heuristic — querySelector returns the FIRST match, so an
|
|
210
|
-
// ambiguous selector (e.g. .x shared by two elements) may map to a different
|
|
211
|
-
// node than the SDK side's [data-hf-id] target and still flag present/absent.
|
|
212
|
-
// Safe for studio templates (one tween per element); upgrade to querySelectorAll
|
|
213
|
-
// + uniqueness check if ambiguous selectors appear.
|
|
214
|
-
export function makeSelectorResolver(html: string): (sel: string) => string {
|
|
215
|
-
let doc: Document | null = null;
|
|
216
|
-
try {
|
|
217
|
-
doc = new DOMParser().parseFromString(html, "text/html");
|
|
218
|
-
} catch {
|
|
219
|
-
doc = null;
|
|
220
|
-
}
|
|
221
|
-
// Stable per-node index so an attribute-less element keys identically no
|
|
222
|
-
// matter which selector form (class vs id vs [data-hf-id]) resolved it.
|
|
223
|
-
const nodeKeys = new WeakMap<Element, string>();
|
|
224
|
-
let nextNode = 0;
|
|
225
|
-
const keyForNode = (el: Element): string => {
|
|
226
|
-
const hfId = el.getAttribute("data-hf-id");
|
|
227
|
-
if (hfId != null && hfId !== "") return `hfid:${hfId}`;
|
|
228
|
-
const existing = nodeKeys.get(el);
|
|
229
|
-
if (existing != null) return existing;
|
|
230
|
-
const key = `node:${nextNode++}`;
|
|
231
|
-
nodeKeys.set(el, key);
|
|
232
|
-
return key;
|
|
233
|
-
};
|
|
234
|
-
return (sel) => {
|
|
235
|
-
if (!doc) return sel;
|
|
236
|
-
try {
|
|
237
|
-
const el = doc.querySelector(sel);
|
|
238
|
-
return el ? keyForNode(el) : sel;
|
|
239
|
-
} catch {
|
|
240
|
-
return sel;
|
|
241
|
-
}
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Shadow GSAP value fidelity: open a fresh SDK doc from the server's pre-op
|
|
247
|
-
* file, apply the same tween op, serialize, and diff the SDK's GSAP script
|
|
248
|
-
* against the server's resulting script. Emits sdk_shadow_dispatch op:
|
|
249
|
-
* "gsap_fidelity". Async, fire-and-forget; server stays authoritative.
|
|
250
|
-
*/
|
|
251
|
-
export async function runShadowGsapFidelity(
|
|
252
|
-
beforeHtml: string,
|
|
253
|
-
gsapOp: ShadowGsapOp,
|
|
254
|
-
serverScript: string,
|
|
255
|
-
): Promise<void> {
|
|
256
|
-
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
|
257
|
-
// No server script to diff against → skip the (costly) openComposition.
|
|
258
|
-
if (!serverScript || !beforeHtml) return;
|
|
259
|
-
try {
|
|
260
|
-
const session = await openComposition(beforeHtml);
|
|
261
|
-
session.batch(() => {
|
|
262
|
-
if (gsapOp.kind === "add") session.addGsapTween(gsapOp.target, gsapOp.tween);
|
|
263
|
-
else if (gsapOp.kind === "set") session.setGsapTween(gsapOp.animationId, gsapOp.properties);
|
|
264
|
-
else session.removeGsapTween(gsapOp.animationId);
|
|
265
|
-
});
|
|
266
|
-
const sdkScript = extractGsapScript(session.serialize());
|
|
267
|
-
if (sdkScript == null) {
|
|
268
|
-
trackStudioEvent("sdk_shadow_dispatch", {
|
|
269
|
-
op: "gsap_fidelity",
|
|
270
|
-
dispatched: false,
|
|
271
|
-
reason: "no_sdk_script",
|
|
272
|
-
mismatchCount: 0,
|
|
273
|
-
});
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
const mismatches = gsapFidelityMismatches(
|
|
277
|
-
sdkScript,
|
|
278
|
-
serverScript,
|
|
279
|
-
makeSelectorResolver(beforeHtml),
|
|
280
|
-
);
|
|
281
|
-
trackStudioEvent("sdk_shadow_dispatch", {
|
|
282
|
-
op: "gsap_fidelity",
|
|
283
|
-
dispatched: true,
|
|
284
|
-
mismatchCount: mismatches.length,
|
|
285
|
-
mismatches: JSON.stringify(mismatches),
|
|
286
|
-
});
|
|
287
|
-
} catch (err) {
|
|
288
|
-
trackStudioEvent("sdk_shadow_dispatch", {
|
|
289
|
-
op: "gsap_fidelity",
|
|
290
|
-
dispatched: false,
|
|
291
|
-
reason: "fidelity_error",
|
|
292
|
-
error: String(err),
|
|
293
|
-
mismatchCount: 0,
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
}
|
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
-
import { openComposition } from "@hyperframes/sdk";
|
|
3
|
-
import {
|
|
4
|
-
resolveKeyframeIndexByPercentage,
|
|
5
|
-
keyframeOpToEditOp,
|
|
6
|
-
gsapKeyframeFidelityMismatches,
|
|
7
|
-
runShadowGsapKeyframeFidelity,
|
|
8
|
-
type ShadowKeyframeOp,
|
|
9
|
-
} from "./sdkShadowGsapKeyframe";
|
|
10
|
-
import { runShadowDispatch } from "./sdkShadow";
|
|
11
|
-
import type { PatchOperation } from "./sourcePatcher";
|
|
12
|
-
|
|
13
|
-
// Capture sdk_shadow_dispatch telemetry.
|
|
14
|
-
const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
|
|
15
|
-
vi.mock("./studioTelemetry", () => ({
|
|
16
|
-
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
|
|
17
|
-
trackedEvents.push({ event, props }),
|
|
18
|
-
}));
|
|
19
|
-
// STUDIO_SDK_SHADOW_ENABLED defaults true (no env override in test), so the
|
|
20
|
-
// runners are active here without mocking the availability module.
|
|
21
|
-
|
|
22
|
-
beforeEach(() => {
|
|
23
|
-
trackedEvents.length = 0;
|
|
24
|
-
});
|
|
25
|
-
const lastShadow = () =>
|
|
26
|
-
trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props;
|
|
27
|
-
|
|
28
|
-
const ANIM_ID = "#hero-to-0-position";
|
|
29
|
-
|
|
30
|
-
function gsapHtml(scriptBody: string): string {
|
|
31
|
-
return /* html */ `<!DOCTYPE html><html><body>
|
|
32
|
-
<div data-hf-id="hf-hero" id="hero" class="clip">x</div>
|
|
33
|
-
<script>
|
|
34
|
-
${scriptBody}
|
|
35
|
-
window.__timelines = [tl];
|
|
36
|
-
</script>
|
|
37
|
-
</body></html>`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const KF_SCRIPT = `
|
|
41
|
-
const tl = gsap.timeline({ paused: true });
|
|
42
|
-
tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
|
|
43
|
-
|
|
44
|
-
// A script body string (not full HTML) for the index-resolution helpers.
|
|
45
|
-
const KF_SCRIPT_BODY = KF_SCRIPT;
|
|
46
|
-
const DUP_SCRIPT_BODY = `
|
|
47
|
-
const tl = gsap.timeline({ paused: true });
|
|
48
|
-
tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "50%": { x: 150 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
|
|
49
|
-
|
|
50
|
-
describe("resolveKeyframeIndexByPercentage", () => {
|
|
51
|
-
it("resolves a unique percentage to its 0-based index", () => {
|
|
52
|
-
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50)).toEqual({
|
|
53
|
-
keyframeIndex: 1,
|
|
54
|
-
});
|
|
55
|
-
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 100)).toEqual({
|
|
56
|
-
keyframeIndex: 2,
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("matches within ~0.001 tolerance", () => {
|
|
61
|
-
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50.0005).keyframeIndex).toBe(
|
|
62
|
-
1,
|
|
63
|
-
);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("returns null with not_found when no percentage matches", () => {
|
|
67
|
-
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 33)).toEqual({
|
|
68
|
-
keyframeIndex: null,
|
|
69
|
-
reason: "not_found",
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("returns null with no_keyframes for an unknown animation", () => {
|
|
74
|
-
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, "#nope-to-0", 50)).toEqual({
|
|
75
|
-
keyframeIndex: null,
|
|
76
|
-
reason: "no_keyframes",
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("returns null with no_keyframes when script is empty", () => {
|
|
81
|
-
expect(resolveKeyframeIndexByPercentage(null, ANIM_ID, 50).reason).toBe("no_keyframes");
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("no-ops on ambiguity (duplicate-percentage keyframes — PR #1498 landmine)", () => {
|
|
85
|
-
expect(resolveKeyframeIndexByPercentage(DUP_SCRIPT_BODY, ANIM_ID, 50)).toEqual({
|
|
86
|
-
keyframeIndex: null,
|
|
87
|
-
reason: "ambiguous",
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
// Regression: a from/fromTo tween's id may normalize to "-to-" on write, so a
|
|
92
|
-
// "-from-"/"-fromTo-" animationId must fall back to the converted id (matching
|
|
93
|
-
// the writer's locateAnimationWithFallback) — else the keyframe diff goes blind.
|
|
94
|
-
it("falls back from a -from- id to the -to- tween", () => {
|
|
95
|
-
const fromId = ANIM_ID.replace("-to-", "-from-");
|
|
96
|
-
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, fromId, 50)).toEqual({
|
|
97
|
-
keyframeIndex: 1,
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
describe("keyframeOpToEditOp", () => {
|
|
103
|
-
it("maps add → addGsapKeyframe with position = percentage", () => {
|
|
104
|
-
const op: ShadowKeyframeOp = {
|
|
105
|
-
kind: "add",
|
|
106
|
-
animationId: ANIM_ID,
|
|
107
|
-
percentage: 25,
|
|
108
|
-
properties: { x: 50 },
|
|
109
|
-
};
|
|
110
|
-
expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({
|
|
111
|
-
op: { type: "addGsapKeyframe", animationId: ANIM_ID, position: 25, value: { x: 50 } },
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("maps remove → removeGsapKeyframe with resolved index", () => {
|
|
116
|
-
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
|
|
117
|
-
expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({
|
|
118
|
-
op: { type: "removeGsapKeyframe", animationId: ANIM_ID, keyframeIndex: 1 },
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("returns null op + reason when remove percentage is ambiguous", () => {
|
|
123
|
-
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
|
|
124
|
-
expect(keyframeOpToEditOp(op, DUP_SCRIPT_BODY)).toEqual({ op: null, reason: "ambiguous" });
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
describe("gsapKeyframeFidelityMismatches", () => {
|
|
129
|
-
it("reports no mismatches when keyframe arrays match", () => {
|
|
130
|
-
expect(gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, KF_SCRIPT_BODY, ANIM_ID)).toEqual([]);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it("reports a keyframes mismatch when arrays diverge", () => {
|
|
134
|
-
const other = `
|
|
135
|
-
const tl = gsap.timeline({ paused: true });
|
|
136
|
-
tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 999 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
|
|
137
|
-
const mismatches = gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, other, ANIM_ID);
|
|
138
|
-
expect(mismatches.some((m) => m.property === "keyframes")).toBe(true);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe("runShadowGsapKeyframeFidelity (add)", () => {
|
|
143
|
-
it("emits gsap_keyframe with a keyframes mismatch when SDK adds but server didn't", async () => {
|
|
144
|
-
const beforeHtml = gsapHtml(KF_SCRIPT);
|
|
145
|
-
// server script unchanged (server "failed" to add the 25% keyframe) → drift
|
|
146
|
-
const session = await openComposition(beforeHtml);
|
|
147
|
-
const serverScript = session
|
|
148
|
-
.serialize()
|
|
149
|
-
.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
|
|
150
|
-
expect(serverScript).toBeTruthy();
|
|
151
|
-
const op: ShadowKeyframeOp = {
|
|
152
|
-
kind: "add",
|
|
153
|
-
animationId: ANIM_ID,
|
|
154
|
-
percentage: 25,
|
|
155
|
-
properties: { x: 50 },
|
|
156
|
-
};
|
|
157
|
-
await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
|
|
158
|
-
const props = lastShadow();
|
|
159
|
-
expect(props?.op).toBe("gsap_keyframe");
|
|
160
|
-
expect(props?.dispatched).toBe(true);
|
|
161
|
-
expect(props?.mismatchCount).toBe(1);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("emits dispatched:true mismatchCount:0 when SDK and server agree", async () => {
|
|
165
|
-
const beforeHtml = gsapHtml(KF_SCRIPT);
|
|
166
|
-
// Build the server's resulting script by applying the same op via the SDK.
|
|
167
|
-
const serverSession = await openComposition(beforeHtml);
|
|
168
|
-
serverSession.batch(() =>
|
|
169
|
-
serverSession.dispatch({
|
|
170
|
-
type: "addGsapKeyframe",
|
|
171
|
-
animationId: ANIM_ID,
|
|
172
|
-
position: 25,
|
|
173
|
-
value: { x: 50 },
|
|
174
|
-
}),
|
|
175
|
-
);
|
|
176
|
-
const serverScript = serverSession
|
|
177
|
-
.serialize()
|
|
178
|
-
.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
|
|
179
|
-
const op: ShadowKeyframeOp = {
|
|
180
|
-
kind: "add",
|
|
181
|
-
animationId: ANIM_ID,
|
|
182
|
-
percentage: 25,
|
|
183
|
-
properties: { x: 50 },
|
|
184
|
-
};
|
|
185
|
-
await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
|
|
186
|
-
const props = lastShadow();
|
|
187
|
-
expect(props?.op).toBe("gsap_keyframe");
|
|
188
|
-
expect(props?.dispatched).toBe(true);
|
|
189
|
-
expect(props?.mismatchCount).toBe(0);
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
describe("runShadowGsapKeyframeFidelity (remove)", () => {
|
|
194
|
-
it("no-ops with reason when remove percentage is ambiguous", async () => {
|
|
195
|
-
const beforeHtml = gsapHtml(DUP_SCRIPT_BODY);
|
|
196
|
-
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
|
|
197
|
-
await runShadowGsapKeyframeFidelity(beforeHtml, op, "non-empty-server-script gsap");
|
|
198
|
-
const props = lastShadow();
|
|
199
|
-
expect(props?.op).toBe("gsap_keyframe");
|
|
200
|
-
expect(props?.dispatched).toBe(false);
|
|
201
|
-
expect(props?.reason).toBe("ambiguous");
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it("dispatches a resolved remove and diffs", async () => {
|
|
205
|
-
const beforeHtml = gsapHtml(KF_SCRIPT);
|
|
206
|
-
const serverSession = await openComposition(beforeHtml);
|
|
207
|
-
serverSession.batch(() =>
|
|
208
|
-
serverSession.dispatch({
|
|
209
|
-
type: "removeGsapKeyframe",
|
|
210
|
-
animationId: ANIM_ID,
|
|
211
|
-
keyframeIndex: 1,
|
|
212
|
-
}),
|
|
213
|
-
);
|
|
214
|
-
const serverScript = serverSession
|
|
215
|
-
.serialize()
|
|
216
|
-
.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
|
|
217
|
-
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
|
|
218
|
-
await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
|
|
219
|
-
const props = lastShadow();
|
|
220
|
-
expect(props?.op).toBe("gsap_keyframe");
|
|
221
|
-
expect(props?.dispatched).toBe(true);
|
|
222
|
-
expect(props?.mismatchCount).toBe(0);
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
describe("runShadowGsapKeyframeFidelity (guards)", () => {
|
|
227
|
-
it("skips when there is no server script", async () => {
|
|
228
|
-
const op: ShadowKeyframeOp = {
|
|
229
|
-
kind: "add",
|
|
230
|
-
animationId: ANIM_ID,
|
|
231
|
-
percentage: 25,
|
|
232
|
-
properties: { x: 50 },
|
|
233
|
-
};
|
|
234
|
-
await runShadowGsapKeyframeFidelity(gsapHtml(KF_SCRIPT), op, null);
|
|
235
|
-
expect(lastShadow()).toBeUndefined();
|
|
236
|
-
});
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
describe("runShadowDispatch unmapped-type guard", () => {
|
|
240
|
-
const ELEMENT_HTML = /* html */ `<!DOCTYPE html><html><body>
|
|
241
|
-
<div data-hf-id="hf-box" style="color: red;">Hi</div>
|
|
242
|
-
</body></html>`;
|
|
243
|
-
|
|
244
|
-
it("emits unmapped_type when a PatchOperation type isn't mapped", async () => {
|
|
245
|
-
const session = await openComposition(ELEMENT_HTML);
|
|
246
|
-
// PatchOperation.type is a closed union today; cast to exercise the defensive
|
|
247
|
-
// guard for a future unmapped type.
|
|
248
|
-
const ops = [{ type: "future-op", property: "x", value: "1" } as unknown as PatchOperation];
|
|
249
|
-
runShadowDispatch(session, { hfId: "hf-box" } as never, ops);
|
|
250
|
-
const props = lastShadow();
|
|
251
|
-
expect(props?.op).toBe("property");
|
|
252
|
-
expect(props?.dispatched).toBe(false);
|
|
253
|
-
expect(props?.reason).toBe("unmapped_type");
|
|
254
|
-
expect(props?.type).toBe("future-op");
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it("dispatches normally for known PatchOperation types", async () => {
|
|
258
|
-
const session = await openComposition(ELEMENT_HTML);
|
|
259
|
-
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
|
|
260
|
-
runShadowDispatch(session, { hfId: "hf-box" } as never, ops);
|
|
261
|
-
const props = lastShadow();
|
|
262
|
-
expect(props?.dispatched).toBe(true);
|
|
263
|
-
expect(props?.reason).toBeUndefined();
|
|
264
|
-
});
|
|
265
|
-
});
|