diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts index 631eba20b..08ed05b08 100644 --- a/packages/studio/src/utils/sdkShadow.ts +++ b/packages/studio/src/utils/sdkShadow.ts @@ -12,6 +12,7 @@ import type { Composition } from "@hyperframes/sdk"; import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk"; import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; import { trackStudioEvent } from "./studioTelemetry"; +import { relEqual } from "./sdkShadowNumeric"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { PatchOperation } from "./sourcePatcher"; @@ -388,24 +389,16 @@ export interface ShadowTiming { trackIndex?: number; } -// Timing start/duration are computed arithmetically by the SDK (e.g. 21.36 - -// 0 + drag delta) but stored as a rounded literal server-side, so exact compare -// flags float-precision noise like 3.1 vs 3.0999999999999996 (~1e-16). Compare -// with a relative epsilon; a genuinely different value (3.1 vs 3.5) still flags. -// trackIndex is an integer track slot — compared exactly by the caller. -function timingValuesEqual(a: number, b: number): boolean { - if (a === b) return true; - return Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(a), Math.abs(b)); -} - -// start/duration tolerate float-precision drift; trackIndex (integer slot) is exact. +// start/duration tolerate float-precision drift (SDK computes them +// arithmetically, server stores a rounded literal) via the shared relative +// epsilon; trackIndex (integer track slot) is compared exactly. function timingFieldEqual( key: keyof ShadowTiming, actual: number | null | undefined, expected: number, ): boolean { if (typeof actual === "number" && key !== "trackIndex") { - return timingValuesEqual(actual, expected); + return relEqual(actual, expected); } return actual === expected; } diff --git a/packages/studio/src/utils/sdkShadowGsapFidelity.ts b/packages/studio/src/utils/sdkShadowGsapFidelity.ts index cad5f08e0..45f800d7f 100644 --- a/packages/studio/src/utils/sdkShadowGsapFidelity.ts +++ b/packages/studio/src/utils/sdkShadowGsapFidelity.ts @@ -16,6 +16,7 @@ import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; import { trackStudioEvent } from "./studioTelemetry"; +import { relEqual } from "./sdkShadowNumeric"; import type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow"; // Marker set must match document.ts extractGsapScript so both pick the same @@ -24,7 +25,7 @@ function isGsapScriptBody(body: string): boolean { return body.includes("gsap") || body.includes("__timelines") || body.includes("ScrollTrigger"); } -function extractGsapScript(html: string): string | null { +export function extractGsapScript(html: string): string | null { // Close tag is `]*>` (not just ``) — HTML5 ignores junk // before the `>`, e.g. `` or `` (CodeQL js/bad-tag-filter). const scripts = html.match(/]*>([\s\S]*?)<\/script[^>]*>/gi); @@ -73,12 +74,9 @@ function animByKey( // number-vs-string forms. Compare canonically — sort keys, coerce numeric // strings — so only real value drift registers, not formatting differences. -// Relative-epsilon compare: the two writers round-trip durations through JS -// number formatting, so a value like 3.1 can come back as 3.0999999999999996. -// An exact `===` flags that sub-ULP delta as drift. Treat values as equal when -// they're within 1e-6 * max(1, |a|, |b|) of each other — tight enough that a -// real 2 vs 1 (or 0.5 vs 0.49) drift still flags, loose enough to absorb -// float-formatting noise. +// Coerce string operands to numbers, then compare with the shared relative +// epsilon (relEqual) so float-formatting noise (3.1 vs 3.0999999999999996) +// isn't flagged as drift while a real 2 vs 1 still is. function numericEqual(a: unknown, b: unknown): boolean { if (a === b) return true; const na = typeof a === "string" ? Number(a) : a; @@ -86,9 +84,7 @@ function numericEqual(a: unknown, b: unknown): boolean { if (typeof na !== "number" || typeof nb !== "number" || Number.isNaN(na) || Number.isNaN(nb)) { return false; } - if (na === nb) return true; - const tolerance = 1e-6 * Math.max(1, Math.abs(na), Math.abs(nb)); - return Math.abs(na - nb) <= tolerance; + return relEqual(na, nb); } function canonicalProps(obj: Record | undefined): string { diff --git a/packages/studio/src/utils/sdkShadowGsapKeyframe.ts b/packages/studio/src/utils/sdkShadowGsapKeyframe.ts index 7a657c23d..38a633162 100644 --- a/packages/studio/src/utils/sdkShadowGsapKeyframe.ts +++ b/packages/studio/src/utils/sdkShadowGsapKeyframe.ts @@ -31,7 +31,11 @@ import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; import { trackStudioEvent } from "./studioTelemetry"; import type { SdkShadowMismatch } from "./sdkShadow"; -import { gsapFidelityMismatches, makeSelectorResolver } from "./sdkShadowGsapFidelity"; +import { + extractGsapScript, + gsapFidelityMismatches, + makeSelectorResolver, +} from "./sdkShadowGsapFidelity"; // Match the GSAP writer's percentage equality tolerance so a remove resolves to // the same keyframe the server would pick (writer rounds to ~3 decimals). @@ -46,23 +50,6 @@ export type ShadowKeyframeOp = } | { kind: "remove"; animationId: string; percentage: number }; -// ─── Script helpers (mirror sdkShadowGsapFidelity's extraction) ─────────────── - -function isGsapScriptBody(body: string): boolean { - return body.includes("gsap") || body.includes("__timelines") || body.includes("ScrollTrigger"); -} - -function extractGsapScript(html: string): string | null { - // Close tag is `]*>` (HTML5 ignores junk before `>`). - const scripts = html.match(/]*>([\s\S]*?)<\/script[^>]*>/gi); - if (!scripts) return null; - for (const block of scripts) { - const body = block.replace(/^]*>/i, "").replace(/<\/script[^>]*>$/i, ""); - if (isGsapScriptBody(body)) return body; - } - return null; -} - // ─── percentage → SDK op mapping ────────────────────────────────────────────── function findAnimationKeyframes( diff --git a/packages/studio/src/utils/sdkShadowNumeric.ts b/packages/studio/src/utils/sdkShadowNumeric.ts new file mode 100644 index 000000000..bf8ecd136 --- /dev/null +++ b/packages/studio/src/utils/sdkShadowNumeric.ts @@ -0,0 +1,11 @@ +/** + * Relative-epsilon numeric equality shared by the shadow diffs (timing parity + + * GSAP value fidelity). Both writers round-trip durations/positions through JS + * number formatting, so a value like 3.1 can read back as 3.0999999999999996. + * Treat values within 1e-6 * max(1, |a|, |b|) as equal — tight enough that a + * real 2 vs 1 (or 0.5 vs 0.49) still flags, loose enough to absorb float noise. + */ +export function relEqual(a: number, b: number): boolean { + if (a === b) return true; + return Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(a), Math.abs(b)); +}