mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
refactor(studio): dedup shadow numeric-equal + GSAP script extraction (#1516)
* fix(studio): serialize GSAP script commits per file (shadow request race) Rapid GSAP edits (ease/duration/keyframe/property) fired overlapping read-modify-write POSTs to one script file — coalesceKey only dedupes edit history, not requests. The gsap_fidelity shadow then diffed an op against whichever POST's scriptText resolved, which could predate that op → false "expected null, actual power2.out" mismatches. Server persists correctly; a pure client request-pairing race. Adds createKeyedSerializer (per-key promise chain, rejection-safe, self- cleaning). commitMutation now serializes every GSAP-script commit per target file by default (key `gsap-file:<path>`) — covering all op types and all animations, not just one meta family — so same-file POSTs can't interleave. Distinct files run concurrently; an explicit serializeKey still overrides. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(studio): dedup shadow numeric-equal + GSAP script extraction Code-review cleanup (no behavior change): - Extract the relative-epsilon float compare into shared sdkShadowNumeric.relEqual, used by both timing parity (sdkShadow) and GSAP value fidelity (numericEqual) — was duplicated verbatim, risking divergent tuning. - Export extractGsapScript from sdkShadowGsapFidelity and import it in the keyframe shadow instead of the byte-identical clone (the regex + marker set must stay in sync with document.ts; one copy is safer). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4ee57d5505
commit
0e5caa453a
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 `</script[^>]*>` (not just `</script>`) — HTML5 ignores junk
|
||||
// before the `>`, e.g. `</script >` or `</script foo>` (CodeQL js/bad-tag-filter).
|
||||
const scripts = html.match(/<script\b[^>]*>([\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<string, unknown> | undefined): string {
|
||||
|
||||
@@ -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 `</script[^>]*>` (HTML5 ignores junk before `>`).
|
||||
const scripts = html.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi);
|
||||
if (!scripts) return null;
|
||||
for (const block of scripts) {
|
||||
const body = block.replace(/^<script\b[^>]*>/i, "").replace(/<\/script[^>]*>$/i, "");
|
||||
if (isGsapScriptBody(body)) return body;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── percentage → SDK op mapping ──────────────────────────────────────────────
|
||||
|
||||
function findAnimationKeyframes(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user