fix(sdk,studio): R5 cutover review fixes (on top of #1539) (#1545)

* fix(sdk,studio): R5 cutover review fixes — fromTo dest, timing sync, parity

Confirmed correctness findings from the R5 review of the SDK cutover stack,
applied on top of #1539:

- fromTo add via cutover dropped its destination: handleAddGsapTween read only
  `toProperties`; now falls back to `properties` like every other method.
- handleSetTiming GSAP sync: a clip with no data-start skipped the shift (now
  treats start as 0, matching the server path) and a blank/non-numeric
  data-start wrote position: NaN (now sanitized).
- handleSetTiming no longer appends an absolute position to an auto-sequenced
  (implicit-position) tween, which collapsed staggers.
- handleSetTiming keeps data-end in sync when a clip carries BOTH data-duration
  and data-end (a stale data-end inverted the clip).
- string/relative tween positions ("+=0.5", "<") documented as a known ceiling.
- opacity/autoAlpha property seed no longer falsy-zero (`|| 1`): an element at
  opacity 0 seeds 0, not 1.
- optimistic add-keyframe cache tolerance aligned to the writer's PCT_TOLERANCE
  (2%) so a near-neighbour keyframe no longer shows then vanishes on reload.
- DOM-patch finiteness validation runs before the SDK cutover path.
- attribute ops mapping to a reserved data-* name decline the cutover up front
  instead of throwing inside dispatch.

Regression tests added for each SDK-side fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): close two gaps in the reserved-attr cutover gate

- Lowercase the mapped attribute name before the reserved check, matching the
  SDK's validateSetAttribute (which lowercases), so a case-variant reserved
  name is declined up front instead of throwing inside dispatch.
- Also gate `html-attribute` ops (raw, non-prefixed names), not just bare
  `attribute` ops. Both the emitter and the gate now derive the name via one
  shared `sdkAttrName` helper so they can't drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): match keyframe remove-path tolerance to the writer (mirror of add)

The optimistic remove-keyframe cache filtered with `> 0.001`, dropping only a
near-exact match, while the writer removes within PCT_TOLERANCE (2). Removing
at e.g. 49% dropped a 50% keyframe on disk but left it in the cache — a phantom
that vanished on reload, the inverted twin of the add-path tolerance fix.
Now filters with `> 2` to match.

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:
Vance Ingalls
2026-06-17 17:15:16 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 8c981a451a
commit e57e75b9b4
7 changed files with 195 additions and 29 deletions
+47 -10
View File
@@ -14,6 +14,46 @@ const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
"html-attribute",
]);
// Mirrors the SDK's RESERVED_ATTRS (mutate.ts): a bare `attribute` op is
// force-prefixed `data-`, so e.g. property "end" → "data-end", which the SDK
// rejects with a throw. Detect that up front and decline the whole batch so it
// takes the server path cleanly, instead of throwing inside the dispatch and
// silently falling back per op.
// ponytail: small mirror of the SDK set; if the SDK adds a reserved attr, a new
// op for it just reverts to the (working) throw→fallback path until synced.
const RESERVED_CUTOVER_ATTRS = new Set<string>([
"data-hf-id",
"data-composition-id",
"data-width",
"data-height",
"data-start",
"data-end",
"data-track-index",
"data-hold-start",
"data-hold-end",
"data-hold-fill",
]);
// The attribute name the SDK setAttribute op carries for this patch op (or null
// if the op isn't an attribute). Shared by patchOpsToSdkEditOps and the reserved
// gate so the name they reason about can't drift: a bare `attribute` op is
// force-prefixed `data-`; an `html-attribute` op keeps its raw name.
function sdkAttrName(op: PatchOperation): string | null {
if (op.type === "attribute") {
return op.property.startsWith("data-") ? op.property : `data-${op.property}`;
}
if (op.type === "html-attribute") return op.property;
return null;
}
function mapsToReservedAttr(op: PatchOperation): boolean {
const name = sdkAttrName(op);
// Lowercase to match the SDK's validateSetAttribute (it lowercases before the
// reserved check), so "DATA-START" is declined up front too; covers both
// `attribute` (prefixed) and `html-attribute` (raw) ops.
return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase());
}
/**
* Map Studio PatchOperations for a given hf-id to SDK EditOps.
*
@@ -31,15 +71,11 @@ function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
hasStyles = true;
} else if (op.type === "text-content") {
result.push({ type: "setText", target: hfId, value: op.value ?? "" });
} else if (op.type === "attribute") {
result.push({
type: "setAttribute",
target: hfId,
name: op.property.startsWith("data-") ? op.property : `data-${op.property}`,
value: op.value,
});
} else if (op.type === "html-attribute") {
result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value });
} else if (op.type === "attribute" || op.type === "html-attribute") {
const name = sdkAttrName(op);
if (name !== null) {
result.push({ type: "setAttribute", target: hfId, name, value: op.value });
}
}
}
@@ -61,7 +97,8 @@ export function shouldUseSdkCutover(
hasSession &&
!!hfId &&
ops.length > 0 &&
ops.every((o) => CUTOVER_OP_TYPES.has(o.type))
ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
!ops.some(mapsToReservedAttr)
);
}