@hyperframes/studio 0.6.102 → 0.6.104
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-BzjItfjX.js → index-CtVcyRD2.js} +73 -73
- package/dist/assets/{index-BZKngETE.js → index-JOvcn0L_.js} +1 -1
- package/dist/assets/{index-C0vMHtMH.js → index-_elwW-31.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +5 -5
- package/src/App.tsx +1 -1
- package/src/components/renders/useRenderQueue.ts +6 -0
- package/src/hooks/useTimelineEditing.ts +3 -1
- package/src/utils/sdkShadow.test.ts +44 -0
- package/src/utils/sdkShadow.ts +32 -6
- package/src/utils/sdkShadowGsapFidelity.ts +78 -18
|
@@ -1 +1 @@
|
|
|
1
|
-
import{g as P}from"./index-
|
|
1
|
+
import{g as P}from"./index-CtVcyRD2.js";function j(c,d){for(var s=0;s<d.length;s++){const a=d[s];if(typeof a!="string"&&!Array.isArray(a)){for(const i in a)if(i!=="default"&&!(i in c)){const l=Object.getOwnPropertyDescriptor(a,i);l&&Object.defineProperty(c,i,l.get?l:{enumerable:!0,get:()=>a[i]})}}}return Object.freeze(Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}))}var v={},w;function k(){if(w)return v;w=1,Object.defineProperty(v,"__esModule",{value:!0}),v.default=d;var c=window.OfflineAudioContext||window.webkitOfflineAudioContext;function d(e){var r=a(e);return r.start(0),[i,y,O(e.sampleRate),s].reduce(function(t,o){return o(t)},r.buffer.getChannelData(0))}function s(e){return e.sort(function(r,t){return t.count-r.count}).splice(0,5)[0].tempo}function a(e){var r=e.length,t=e.numberOfChannels,o=e.sampleRate,n=new c(t,r,o),u=n.createBufferSource();u.buffer=e;var f=n.createBiquadFilter();return f.type="lowpass",u.connect(f),f.connect(n.destination),u}function i(e){for(var r=[],t=.9,o=.3,n=15;r.length<n&&t>=o;)r=l(e,t),t-=.05;if(r.length<n)throw new Error("Could not find enough samples for a reliable detection.");return r}function l(e,r){for(var t=[],o=0,n=e.length;o<n;o+=1)e[o]>r&&(t.push(o),o+=1e4);return t}function y(e){var r=[];return e.forEach(function(t,o){for(var n=function(x){var g=e[o+x]-t,_=r.some(function(h){if(h.interval===g)return h.count+=1});_||r.push({interval:g,count:1})},u=0;u<10;u+=1)n(u)}),r}function O(e){return function(r){var t=[];return r.forEach(function(o){if(o.interval!==0){for(var n=60/(o.interval/e);n<90;)n*=2;for(;n>180;)n/=2;n=Math.round(n);var u=t.some(function(f){if(f.tempo===n)return f.count+=o.count});u||t.push({tempo:n,count:o.count})}}),t}}return v}var p,b;function q(){return b||(b=1,p=k().default),p}var m=q();const A=P(m),D=j({__proto__:null,default:A},[m]);export{D as i};
|
package/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CtVcyRD2.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-BITwbxi-.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.104",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,9 +33,9 @@
|
|
|
33
33
|
"@phosphor-icons/react": "^2.1.10",
|
|
34
34
|
"bpm-detective": "^2.0.5",
|
|
35
35
|
"mediabunny": "^1.45.3",
|
|
36
|
-
"@hyperframes/core": "0.6.
|
|
37
|
-
"@hyperframes/player": "0.6.
|
|
38
|
-
"@hyperframes/sdk": "0.6.
|
|
36
|
+
"@hyperframes/core": "0.6.104",
|
|
37
|
+
"@hyperframes/player": "0.6.104",
|
|
38
|
+
"@hyperframes/sdk": "0.6.104"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/react": "19",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"vite": "^6.4.2",
|
|
50
50
|
"vitest": "^3.2.4",
|
|
51
51
|
"zustand": "^5.0.0",
|
|
52
|
-
"@hyperframes/producer": "0.6.
|
|
52
|
+
"@hyperframes/producer": "0.6.104"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -175,7 +175,7 @@ export function StudioApp() {
|
|
|
175
175
|
reloadPreview: () => setRefreshKey((k) => k + 1),
|
|
176
176
|
pendingTimelineEditPathRef,
|
|
177
177
|
});
|
|
178
|
-
const sdkSession = useSdkSession(projectId, activeCompPath);
|
|
178
|
+
const sdkSession = useSdkSession(projectId, activeCompPath ?? "index.html");
|
|
179
179
|
const timelineEditing = useTimelineEditing({
|
|
180
180
|
projectId,
|
|
181
181
|
activeCompPath,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
2
2
|
import { trackStudioRenderStart } from "../../telemetry/events";
|
|
3
|
+
import { getAnonymousId } from "../../telemetry/config";
|
|
3
4
|
|
|
4
5
|
export interface RenderJob {
|
|
5
6
|
id: string;
|
|
@@ -109,10 +110,15 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
109
110
|
format: string;
|
|
110
111
|
resolution?: string;
|
|
111
112
|
composition?: string;
|
|
113
|
+
telemetryDistinctId: string;
|
|
112
114
|
} = {
|
|
113
115
|
fps,
|
|
114
116
|
quality,
|
|
115
117
|
format,
|
|
118
|
+
// So the server-emitted render_complete/render_error is attributed to
|
|
119
|
+
// this browser user (same id studio_* events use), making the render
|
|
120
|
+
// funnel joinable. Matches studio_render_start fired just above.
|
|
121
|
+
telemetryDistinctId: getAnonymousId(),
|
|
116
122
|
};
|
|
117
123
|
if (resolution && resolution !== "auto") body.resolution = resolution;
|
|
118
124
|
if (composition) body.composition = composition;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// fallow-ignore-file complexity
|
|
6
6
|
import { useCallback, useRef } from "react";
|
|
7
7
|
import type { Composition } from "@hyperframes/sdk";
|
|
8
|
-
import { runShadowTiming } from "../utils/sdkShadow";
|
|
8
|
+
import { runShadowDelete, runShadowTiming } from "../utils/sdkShadow";
|
|
9
9
|
import type { TimelineElement } from "../player";
|
|
10
10
|
import { usePlayerStore } from "../player";
|
|
11
11
|
import { useRazorSplit } from "./useRazorSplit";
|
|
@@ -288,6 +288,7 @@ export function useTimelineEditing({
|
|
|
288
288
|
);
|
|
289
289
|
usePlayerStore.getState().setSelectedElementId(null);
|
|
290
290
|
reloadPreview();
|
|
291
|
+
if (sdkSession) runShadowDelete(sdkSession, element.hfId);
|
|
291
292
|
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
|
|
292
293
|
} catch (error) {
|
|
293
294
|
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
|
@@ -303,6 +304,7 @@ export function useTimelineEditing({
|
|
|
303
304
|
domEditSaveTimestampRef,
|
|
304
305
|
reloadPreview,
|
|
305
306
|
isRecordingRef,
|
|
307
|
+
sdkSession,
|
|
306
308
|
],
|
|
307
309
|
);
|
|
308
310
|
|
|
@@ -108,6 +108,20 @@ describe("sdkShadowDispatch (integration)", () => {
|
|
|
108
108
|
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f");
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
it("does NOT false-mismatch a hyphenated style property (kebab op vs camelCase snapshot)", async () => {
|
|
112
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
113
|
+
const session = await openComposition(BASE_HTML);
|
|
114
|
+
|
|
115
|
+
const ops: PatchOperation[] = [
|
|
116
|
+
{ type: "inline-style", property: "background-color", value: "rgb(255, 79, 88)" },
|
|
117
|
+
];
|
|
118
|
+
const result = sdkShadowDispatch(session, "hf-box", ops);
|
|
119
|
+
|
|
120
|
+
expect(result.dispatched).toBe(true);
|
|
121
|
+
expect(result.mismatches).toHaveLength(0); // was 1 before the kebab→camel read-back fix
|
|
122
|
+
expect(session.getElement("hf-box")?.inlineStyles.backgroundColor).toBe("rgb(255, 79, 88)");
|
|
123
|
+
});
|
|
124
|
+
|
|
111
125
|
it("returns dispatched:false when hfId not found in session", async () => {
|
|
112
126
|
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
113
127
|
const session = await openComposition(BASE_HTML);
|
|
@@ -143,6 +157,21 @@ describe("sdkShadowDispatch (integration)", () => {
|
|
|
143
157
|
expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero");
|
|
144
158
|
});
|
|
145
159
|
|
|
160
|
+
// fallow-ignore-next-line code-duplication
|
|
161
|
+
it("does NOT false-mismatch studio-internal data-hf-* marker attributes", async () => {
|
|
162
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
163
|
+
const session = await openComposition(BASE_HTML);
|
|
164
|
+
|
|
165
|
+
// path-offset drags emit these already-data-prefixed, SDK-excluded markers.
|
|
166
|
+
const ops: PatchOperation[] = [
|
|
167
|
+
{ type: "attribute", property: "data-hf-studio-path-offset", value: "true" },
|
|
168
|
+
];
|
|
169
|
+
const result = sdkShadowDispatch(session, "hf-box", ops);
|
|
170
|
+
|
|
171
|
+
expect(result.dispatched).toBe(true);
|
|
172
|
+
expect(result.mismatches).toHaveLength(0); // filtered, not double-prefixed + flagged
|
|
173
|
+
});
|
|
174
|
+
|
|
146
175
|
it("returns dispatch_error when dispatch throws — does not propagate", async () => {
|
|
147
176
|
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
148
177
|
const session = await openComposition(BASE_HTML);
|
|
@@ -301,6 +330,21 @@ tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: "1", duration: 0.5 }, 0);
|
|
|
301
330
|
window.__timelines["t"] = tl;`;
|
|
302
331
|
expect(gsapFidelityMismatches(numeric, stringy)).toEqual([]);
|
|
303
332
|
});
|
|
333
|
+
|
|
334
|
+
it("matches the same element across different selector forms when a resolver is given", () => {
|
|
335
|
+
// SDK writes [data-hf-id="hf-x"], server writes .x — same element, same tween.
|
|
336
|
+
const sdk = `var tl = gsap.timeline({ paused: true });
|
|
337
|
+
tl.to("[data-hf-id=\\"hf-x\\"]", { x: 200, duration: 0.8 }, 0.5);
|
|
338
|
+
window.__timelines["t"] = tl;`;
|
|
339
|
+
const server = `var tl = gsap.timeline({ paused: true });
|
|
340
|
+
tl.to(".x", { x: 200, duration: 0.8 }, 0.5);
|
|
341
|
+
window.__timelines["t"] = tl;`;
|
|
342
|
+
const resolve = (sel: string) => (/hf-x|\.x/.test(sel) ? "hf-x" : sel);
|
|
343
|
+
// Without a resolver: selector-form divergence → present/absent mismatch.
|
|
344
|
+
expect(gsapFidelityMismatches(sdk, server).length).toBeGreaterThan(0);
|
|
345
|
+
// With a resolver: matched by element → no mismatch.
|
|
346
|
+
expect(gsapFidelityMismatches(sdk, server, resolve)).toEqual([]);
|
|
347
|
+
});
|
|
304
348
|
});
|
|
305
349
|
|
|
306
350
|
describe("runShadowGsapFidelity", () => {
|
package/src/utils/sdkShadow.ts
CHANGED
|
@@ -23,6 +23,22 @@ import type { PatchOperation } from "./sourcePatcher";
|
|
|
23
23
|
* Multiple inline-style ops are coalesced into a single setStyle (SDK batches
|
|
24
24
|
* style changes naturally). One SDK op is emitted per non-style op.
|
|
25
25
|
*/
|
|
26
|
+
// "attribute" PatchOperations carry the data- attribute NAME. Studio passes
|
|
27
|
+
// some already prefixed (e.g. "data-hf-studio-path-offset") and some bare
|
|
28
|
+
// (e.g. "name"); prefix only when needed, never double-prefix.
|
|
29
|
+
function attrName(property: string): string {
|
|
30
|
+
return property.startsWith("data-") ? property : `data-${property}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The SDK element model excludes data-hf-* attributes (document.ts skips them),
|
|
34
|
+
// so shadowing studio-internal markers (data-hf-studio-path-offset, etc.) can
|
|
35
|
+
// never match — drop those ops from the shadow instead of false-mismatching.
|
|
36
|
+
function isShadowableOp(op: PatchOperation): boolean {
|
|
37
|
+
if (op.type === "attribute") return !attrName(op.property).startsWith("data-hf-");
|
|
38
|
+
if (op.type === "html-attribute") return !op.property.startsWith("data-hf-");
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
|
|
27
43
|
const result: EditOp[] = [];
|
|
28
44
|
const styles: Record<string, string | null> = {};
|
|
@@ -38,7 +54,7 @@ export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditO
|
|
|
38
54
|
result.push({
|
|
39
55
|
type: "setAttribute",
|
|
40
56
|
target: hfId,
|
|
41
|
-
name:
|
|
57
|
+
name: attrName(op.property),
|
|
42
58
|
value: op.value,
|
|
43
59
|
});
|
|
44
60
|
} else if (op.type === "html-attribute") {
|
|
@@ -98,17 +114,24 @@ function flattenSnapshot(snap: ElementSnapshot): FlatSnapshot {
|
|
|
98
114
|
|
|
99
115
|
type OpFieldResolver = (op: PatchOperation, flat: FlatSnapshot) => OpFields;
|
|
100
116
|
|
|
117
|
+
// Snapshot inlineStyles are camelCase (CSSStyleDeclaration convention); PatchOperation
|
|
118
|
+
// style properties are kebab-case ("background-color"). Convert for read-back, else
|
|
119
|
+
// every hyphenated property false-mismatches against a null actual.
|
|
120
|
+
function kebabToCamel(prop: string): string {
|
|
121
|
+
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
122
|
+
}
|
|
123
|
+
|
|
101
124
|
const OP_FIELD_RESOLVERS: Record<string, OpFieldResolver> = {
|
|
102
125
|
"inline-style": (op, flat) => ({
|
|
103
126
|
property: op.property,
|
|
104
127
|
expected: op.value,
|
|
105
|
-
actual: flat.styles[op.property] ?? null,
|
|
128
|
+
actual: flat.styles[kebabToCamel(op.property)] ?? flat.styles[op.property] ?? null,
|
|
106
129
|
}),
|
|
107
130
|
"text-content": (op, flat) => ({ property: "text", expected: op.value ?? "", actual: flat.text }),
|
|
108
131
|
attribute: (op, flat) => ({
|
|
109
|
-
property:
|
|
132
|
+
property: attrName(op.property),
|
|
110
133
|
expected: op.value ?? null,
|
|
111
|
-
actual: flat.attrs[
|
|
134
|
+
actual: flat.attrs[attrName(op.property)] ?? null,
|
|
112
135
|
}),
|
|
113
136
|
"html-attribute": (op, flat) => ({
|
|
114
137
|
property: op.property,
|
|
@@ -157,8 +180,11 @@ export function sdkShadowDispatch(
|
|
|
157
180
|
if (!session.getElement(hfId)) {
|
|
158
181
|
return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] };
|
|
159
182
|
}
|
|
183
|
+
// Drop studio-internal markers the SDK model can't represent (data-hf-*), so
|
|
184
|
+
// canvas-drag/path-offset edits don't false-mismatch on bookkeeping attrs.
|
|
185
|
+
const shadowable = ops.filter(isShadowableOp);
|
|
160
186
|
try {
|
|
161
|
-
const sdkOps = patchOpsToSdkEditOps(hfId,
|
|
187
|
+
const sdkOps = patchOpsToSdkEditOps(hfId, shadowable);
|
|
162
188
|
session.batch(() => {
|
|
163
189
|
for (const op of sdkOps) session.dispatch(op);
|
|
164
190
|
});
|
|
@@ -169,7 +195,7 @@ export function sdkShadowDispatch(
|
|
|
169
195
|
};
|
|
170
196
|
}
|
|
171
197
|
const flat = flattenSnapshot(session.getElement(hfId));
|
|
172
|
-
const mismatches =
|
|
198
|
+
const mismatches = shadowable
|
|
173
199
|
.map((op) => checkOpParity(op, flat, hfId))
|
|
174
200
|
.filter((m): m is SdkShadowMismatch => m !== null);
|
|
175
201
|
return { dispatched: true, mismatches };
|
|
@@ -36,10 +36,35 @@ function extractGsapScript(html: string): string | null {
|
|
|
36
36
|
return null;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function
|
|
39
|
+
function posKey(position: unknown): string {
|
|
40
|
+
if (typeof position === "number") return String(position);
|
|
41
|
+
const n = Number(position);
|
|
42
|
+
return Number.isNaN(n) ? String(position) : String(n);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Key a tween by its RESOLVED target element (not raw selector) + method +
|
|
46
|
+
// position. The SDK writer emits [data-hf-id="X"] selectors while the server
|
|
47
|
+
// emits class/other selectors for the SAME element; keying by resolved element
|
|
48
|
+
// matches them so the diff compares values instead of flagging present/absent.
|
|
49
|
+
//
|
|
50
|
+
// ponytail: one-tween-per-(element, method, position) assumption — coincident
|
|
51
|
+
// tweens (same element+method+position, different props) collapse, last wins,
|
|
52
|
+
// so the diff under-reports them. Props can't go in the key (a matched pair
|
|
53
|
+
// must share a key for the field-diff to run; raw props would split real value
|
|
54
|
+
// drift into present/absent). Not seen in studio-emitted templates; add a
|
|
55
|
+
// property-NAME hash to the key if coincident tweens show up in the wild.
|
|
56
|
+
function tweenKey(anim: GsapAnimation, resolveSelector?: (sel: string) => string): string {
|
|
57
|
+
const sel = resolveSelector ? resolveSelector(anim.targetSelector) : anim.targetSelector;
|
|
58
|
+
return `${sel}|${anim.method}|${posKey(anim.position)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function animByKey(
|
|
62
|
+
script: string,
|
|
63
|
+
resolveSelector?: (sel: string) => string,
|
|
64
|
+
): Map<string, GsapAnimation> {
|
|
40
65
|
const map = new Map<string, GsapAnimation>();
|
|
41
66
|
const parsed = parseGsapScriptAcorn(script);
|
|
42
|
-
for (const anim of parsed.animations) map.set(anim
|
|
67
|
+
for (const anim of parsed.animations) map.set(tweenKey(anim, resolveSelector), anim);
|
|
43
68
|
return map;
|
|
44
69
|
}
|
|
45
70
|
|
|
@@ -73,37 +98,41 @@ function canonicalProps(obj: Record<string, unknown> | undefined): string {
|
|
|
73
98
|
}
|
|
74
99
|
|
|
75
100
|
/**
|
|
76
|
-
* Structurally diff two GSAP scripts
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
101
|
+
* Structurally diff two GSAP scripts. Tweens are matched by resolved target
|
|
102
|
+
* element + method + position (see tweenKey), so the SDK's [data-hf-id]
|
|
103
|
+
* selectors and the server's class selectors for the same element don't
|
|
104
|
+
* false-flag present/absent. Reports a tween present in one but not the other,
|
|
105
|
+
* and per-field value drift (duration, ease, properties, fromProperties).
|
|
106
|
+
* Comparison is canonical so writer formatting differences don't register.
|
|
107
|
+
*
|
|
108
|
+
* Pass resolveSelector (selector → canonical element id) to enable the
|
|
109
|
+
* element-based matching; without it, matching falls back to raw selector.
|
|
80
110
|
*/
|
|
81
111
|
// fallow-ignore-next-line complexity
|
|
82
112
|
export function gsapFidelityMismatches(
|
|
83
113
|
sdkScript: string,
|
|
84
114
|
serverScript: string,
|
|
115
|
+
resolveSelector?: (sel: string) => string,
|
|
85
116
|
): SdkShadowMismatch[] {
|
|
86
|
-
const sdk =
|
|
87
|
-
const server =
|
|
117
|
+
const sdk = animByKey(sdkScript, resolveSelector);
|
|
118
|
+
const server = animByKey(serverScript, resolveSelector);
|
|
88
119
|
const mismatches: SdkShadowMismatch[] = [];
|
|
89
|
-
const
|
|
90
|
-
for (const
|
|
91
|
-
const a = sdk.get(
|
|
92
|
-
const b = server.get(
|
|
120
|
+
const keys = new Set([...sdk.keys(), ...server.keys()]);
|
|
121
|
+
for (const key of keys) {
|
|
122
|
+
const a = sdk.get(key);
|
|
123
|
+
const b = server.get(key);
|
|
93
124
|
if (!a || !b) {
|
|
94
125
|
mismatches.push({
|
|
95
126
|
kind: "value_mismatch",
|
|
96
|
-
hfId:
|
|
127
|
+
hfId: key,
|
|
97
128
|
property: "tween",
|
|
98
129
|
expected: b ? "present" : "absent",
|
|
99
130
|
actual: a ? "present" : "absent",
|
|
100
131
|
});
|
|
101
132
|
continue;
|
|
102
133
|
}
|
|
103
|
-
//
|
|
134
|
+
// method + position are part of the key (already equal); compare values.
|
|
104
135
|
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
136
|
["duration", a.duration, b.duration, numericEqual(a.duration, b.duration)],
|
|
108
137
|
["ease", a.ease, b.ease, a.ease === b.ease],
|
|
109
138
|
[
|
|
@@ -123,7 +152,7 @@ export function gsapFidelityMismatches(
|
|
|
123
152
|
if (!equal) {
|
|
124
153
|
mismatches.push({
|
|
125
154
|
kind: "value_mismatch",
|
|
126
|
-
hfId:
|
|
155
|
+
hfId: key,
|
|
127
156
|
property,
|
|
128
157
|
expected: bv == null ? null : JSON.stringify(bv),
|
|
129
158
|
actual: av == null ? null : JSON.stringify(av),
|
|
@@ -158,6 +187,33 @@ export function resolveGsapFidelityArgs(
|
|
|
158
187
|
return { before, op: shadowGsapOp, serverScript };
|
|
159
188
|
}
|
|
160
189
|
|
|
190
|
+
// Resolve a CSS selector to a canonical element id (data-hf-id) using the pre-op
|
|
191
|
+
// document, so tweens that target the same element via different selectors
|
|
192
|
+
// ([data-hf-id="X"] vs .X) match in the fidelity diff. Falls back to the raw
|
|
193
|
+
// selector when it can't resolve (DOMParser unavailable, no match, bad selector).
|
|
194
|
+
//
|
|
195
|
+
// ponytail: first-match heuristic — querySelector returns the FIRST match, so an
|
|
196
|
+
// ambiguous selector (e.g. .x shared by two elements) may map to a different id
|
|
197
|
+
// than the SDK side's [data-hf-id] target and still flag present/absent. Safe
|
|
198
|
+
// for studio templates (one tween per data-hf-id); upgrade to querySelectorAll +
|
|
199
|
+
// uniqueness check if ambiguous selectors appear.
|
|
200
|
+
function makeSelectorResolver(html: string): (sel: string) => string {
|
|
201
|
+
let doc: Document | null = null;
|
|
202
|
+
try {
|
|
203
|
+
doc = new DOMParser().parseFromString(html, "text/html");
|
|
204
|
+
} catch {
|
|
205
|
+
doc = null;
|
|
206
|
+
}
|
|
207
|
+
return (sel) => {
|
|
208
|
+
if (!doc) return sel;
|
|
209
|
+
try {
|
|
210
|
+
return doc.querySelector(sel)?.getAttribute("data-hf-id") ?? sel;
|
|
211
|
+
} catch {
|
|
212
|
+
return sel;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
161
217
|
/**
|
|
162
218
|
* Shadow GSAP value fidelity: open a fresh SDK doc from the server's pre-op
|
|
163
219
|
* file, apply the same tween op, serialize, and diff the SDK's GSAP script
|
|
@@ -189,7 +245,11 @@ export async function runShadowGsapFidelity(
|
|
|
189
245
|
});
|
|
190
246
|
return;
|
|
191
247
|
}
|
|
192
|
-
const mismatches = gsapFidelityMismatches(
|
|
248
|
+
const mismatches = gsapFidelityMismatches(
|
|
249
|
+
sdkScript,
|
|
250
|
+
serverScript,
|
|
251
|
+
makeSelectorResolver(beforeHtml),
|
|
252
|
+
);
|
|
193
253
|
trackStudioEvent("sdk_shadow_dispatch", {
|
|
194
254
|
op: "gsap_fidelity",
|
|
195
255
|
dispatched: true,
|