mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-13 15:49:53 +00:00
feat(sdk): ws-3c — addWithKeyframes + replaceWithKeyframes SDK ops (acorn writer) (#1572)
* feat(sdk): ws-b variables/brand — object-valued font/image + B1 JSON model
B1 — setVariableValue now drives the runtime JSON model
(data-composition-variables) so preview == render. CSS custom prop is
kept as a secondary compat write for compositions that CSS-bind directly
to --{id}.
B2 — object-valued font ({name, source}) and image ({url}) variable
types added core-to-SDK. Object values write to the JSON model only;
scalars write both model + explicit CSS style patches. Explicit style-path
patches in forward/inverse ensure apply-patches.ts handles each path type
purely (model vs CSS), so inverse patches restore exact pre-call state
without ambiguity.
Changed files:
packages/core/src/core.types.ts — font/image to CompositionVariableType + interfaces
packages/core/src/lint/rules/composition.ts — accept font/image in lint message
packages/core/src/parsers/htmlParser.ts — validate font/image variable declarations
packages/core/src/parsers/htmlParser.test.ts — tests for new variable types
packages/core/src/runtime/validateVariables.ts — checkType for font/image
packages/sdk/src/types.ts — FontValue/ImageValue; widen OverrideSet + EditOp
packages/sdk/src/index.ts — re-export FontValue/ImageValue
packages/sdk/src/session.ts — widen setVariableValue signature
packages/sdk/src/engine/patches.ts — valueChange helper for object-valued patches
packages/sdk/src/engine/mutate.ts — handleSetVariableValue: B1+B2 with explicit CSS patches
packages/sdk/src/engine/apply-patches.ts — variable case: model-only (CSS via explicit patch)
packages/sdk/src/engine/mutate.test.ts — B1+B2 round-trip + inverse tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sdk): ws-c elastic timing + word-alignment resolver (WS-C)
C1: getElementTimings/setElementTiming typed session methods + setHold typed
wrapper. getElementTimings reads data-duration (preferred) or data-end−data-start
(fallback) — same attr-preference as handleSetTiming. setElementTiming dispatches
a sparse map as one batch → one patch event → one undo step. setHold mirrors
setVariableValue pattern.
Also fixes a pre-existing apply-patches.ts gap: the timing/duration patch case was
absent, causing undo of duration changes to silently no-op. Added the duration
branch so inverse patches restore data-duration correctly.
C2: packages/core/src/compiler/timingResolver.ts — shared pure resolveTimings()
consumed by BOTH preview (sdk session) and render (timingCompiler) paths. Word-
anchored elements get enterAt = wordTimings[k].start + offset; elastic hold =
max(0, slotEnd − (enterAt + enterDuration + exitDuration)), clamped ≥ 0; never
timescales animated content. Un-anchored elements keep authored timing (align-on-
adjust). Deterministic + pure: no Date.now, no Math.random, no DOM.
extractGsapLabels() added to gsapParserAcorn.ts to parse tl.addLabel() calls for
the getElementTimings labels field.
Tests: timingResolver.test.ts (10 pure-function tests including preview==render
parity golden test); session.timings.test.ts (15 session-layer tests covering
duration-authored, end-authored, label extraction, batching, undo, and setHold
regression).
Gates: build ✓ · bun test (sdk+core/compiler) 434/434 ✓ · oxlint 0 warnings ✓ ·
oxfmt --check ✓ · fallow --gate new-only ✓ (complexity suppressed on 2 new
inline functions, duplication warn-only pre-existing)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sdk): addElement forward op — mint hf-id, inverse = removeElement (WS-D)
Implements WS-D: the addElement EditOp and session.addElement() typed method.
- types.ts: addElement op (parent/index/html) added to EditOp union;
addElement(parent, index, html): HfId added to Composition interface
- mutate.ts: handleAddElement inserts a single-root HTML fragment at
parent+index, minting ids against the LIVE document's existing id set
(not a fresh fragment set) via collectDocumentHfIds + mintFragmentIds;
forward = patchAdd, inverse = patchRemove; MutationResult.meta.newId
carries the minted root id
- mutate.ts: validateOp case rejects missing parent, negative index,
empty html, zero-element html, and <script> in html
- session.ts: typed addElement(parent, index, html) returns minted id
via result.meta.newId
- mutate.test.ts: 16 tests covering insert position, append semantics,
id uniqueness, content-collision rehash, nested fragments, forward/
inverse symmetry, undo, add/undo/redo stability, parent:null body
insertion, serialize round-trip, and all five validateOp rejection codes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sdk): ws-3c — addWithKeyframes + replaceWithKeyframes SDK ops (acorn writer)
Port add-with-keyframes / replace-with-keyframes from the server recast path
to the acorn/magic-string writer and expose them as typed SDK EditOps.
- gsapWriterAcorn.ts: extend buildKeyframeObjectCode and
addAnimationWithKeyframesToScript to accept `auto?: boolean` on keyframes
(emits `_auto: 1` matching the recast writer)
- types.ts: add `addWithKeyframes` and `replaceWithKeyframes` to EditOp union
- mutate.ts: add handleAddWithKeyframes, handleReplaceWithKeyframes, and
applyGsapWithKeyframesOp dispatch sub-function; validateOp cases for both
- sdkCutover.ts: add sdkAddWithKeyframesPersist + sdkReplaceWithKeyframesPersist
(shared via dispatchWithKeyframes to eliminate clone)
- useGsapAnimationOps.ts: wire addWithKeyframes + replaceWithKeyframes
callbacks with SDK-first / server fallback pattern
- gsapWriter.parity.test.ts: add parity tests for _auto endpoint round-trip and
replaceWithKeyframes (remove + addWithKeyframes) differential golden harness;
import removeAnimationFromScript from both writers
Landmine note: tween IDs are position-derived — replaceWithKeyframes removes the
old tween (renumbering survivors) then inserts the replacement at the end; the
MutationResult patch pair restores the whole GSAP script on undo, not a per-ID
inverse, so ID-held references in callers must re-parse after structural edits.
Gate: WS-3.F (retire recast / executeGsapMutation) — NOT started here.
Remaining Studio callers (gsapDragCommit, gsapRuntimeBridge, useGestureCommit,
useEnableKeyframes) remain on the server commitMutation path until WS-3.F.
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
0ca01c88cf
commit
418f198b33
@@ -22,6 +22,7 @@ import {
|
||||
addKeyframeToScript as addKeyframeRecast,
|
||||
removeKeyframeFromScript as removeKeyframeRecast,
|
||||
addAnimationWithKeyframesToScript as addWithKfRecast,
|
||||
removeAnimationFromScript as removeAnimRecast,
|
||||
shiftPositionsInScript as shiftRecast,
|
||||
scalePositionsInScript as scaleRecast,
|
||||
type SplitAnimationsOptions,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
addKeyframeToScript as addKeyframeAcorn,
|
||||
removeKeyframeFromScript as removeKeyframeAcorn,
|
||||
addAnimationWithKeyframesToScript as addWithKfAcorn,
|
||||
removeAnimationFromScript as removeAnimAcorn,
|
||||
shiftPositionsInScript as shiftAcorn,
|
||||
scalePositionsInScript as scaleAcorn,
|
||||
} from "./gsapWriterAcorn.js";
|
||||
@@ -917,6 +919,132 @@ describe("parity: addAnimationWithKeyframesToScript (recast vs acorn)", () => {
|
||||
const recast = addWithKfRecast(ADD_WITH_KF_BASE, "#card", 1.5, 2.25, kfs, "none").script;
|
||||
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
|
||||
});
|
||||
|
||||
// WS-3.C: auto-endpoint markers must round-trip through both writers.
|
||||
it("_auto endpoint: 0% and 100% carry the _auto marker", () => {
|
||||
const kfs = [
|
||||
{ percentage: 0, properties: { x: 0, opacity: 1 }, auto: true },
|
||||
{ percentage: 50, properties: { x: 100, opacity: 0.5 } },
|
||||
{ percentage: 100, properties: { x: 200, opacity: 0 }, auto: true },
|
||||
];
|
||||
const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#hero", 0, 1, kfs).script;
|
||||
const recast = addWithKfRecast(ADD_WITH_KF_BASE, "#hero", 0, 1, kfs).script;
|
||||
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
|
||||
});
|
||||
|
||||
it("_auto endpoint: only 0% carries auto marker", () => {
|
||||
const kfs = [
|
||||
{ percentage: 0, properties: { opacity: 1 }, auto: true },
|
||||
{ percentage: 100, properties: { opacity: 0 } },
|
||||
];
|
||||
const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#el", 2, 0.5, kfs).script;
|
||||
const recast = addWithKfRecast(ADD_WITH_KF_BASE, "#el", 2, 0.5, kfs).script;
|
||||
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
|
||||
});
|
||||
|
||||
it("returns a stable new animation ID that is non-empty", () => {
|
||||
const kfs = [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
];
|
||||
const acornResult = addWithKfAcorn(ADD_WITH_KF_BASE, "#box", 0, 1, kfs);
|
||||
const recastResult = addWithKfRecast(ADD_WITH_KF_BASE, "#box", 0, 1, kfs);
|
||||
expect(acornResult.id).not.toBe("");
|
||||
expect(recastResult.id).not.toBe("");
|
||||
// The IDs are position-derived and may differ between writers due to
|
||||
// formatting differences, but both must be non-empty valid strings.
|
||||
expect(typeof acornResult.id).toBe("string");
|
||||
expect(typeof recastResult.id).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
// ── replaceWithKeyframes parity (remove + addWithKeyframes, recast vs acorn) ──
|
||||
// WS-3.C replace path: both writers remove the existing tween by animationId,
|
||||
// then insert the replacement keyframed tween. The animation model of the
|
||||
// resulting script's last animation must match.
|
||||
|
||||
const REPLACE_WITH_KF_BASE = `\
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { x: 100, opacity: 1, duration: 0.5 }, 1);
|
||||
`;
|
||||
|
||||
function replaceWithKfRecast(
|
||||
script: string,
|
||||
animId: string,
|
||||
selector: string,
|
||||
pos: number,
|
||||
dur: number,
|
||||
kfs: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
auto?: boolean;
|
||||
}>,
|
||||
ease?: string,
|
||||
): string {
|
||||
const removed = removeAnimRecast(script, animId);
|
||||
return addWithKfRecast(removed, selector, pos, dur, kfs, ease).script;
|
||||
}
|
||||
|
||||
function replaceWithKfAcorn(
|
||||
script: string,
|
||||
animId: string,
|
||||
selector: string,
|
||||
pos: number,
|
||||
dur: number,
|
||||
kfs: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
auto?: boolean;
|
||||
}>,
|
||||
ease?: string,
|
||||
): string {
|
||||
const removed = removeAnimAcorn(script, animId);
|
||||
return addWithKfAcorn(removed, selector, pos, dur, kfs, ease).script;
|
||||
}
|
||||
|
||||
describe("parity: replaceWithKeyframes (remove + addWithKeyframes, recast vs acorn)", () => {
|
||||
it("replaces the only tween: resulting animation model matches", () => {
|
||||
const id = acornId(REPLACE_WITH_KF_BASE);
|
||||
const kfs = [
|
||||
{ percentage: 0, properties: { x: 0, opacity: 0 } },
|
||||
{ percentage: 100, properties: { x: 200, opacity: 1 } },
|
||||
];
|
||||
const acorn = replaceWithKfAcorn(REPLACE_WITH_KF_BASE, id, "#box", 0.5, 1.5, kfs);
|
||||
const recast = replaceWithKfRecast(REPLACE_WITH_KF_BASE, id, "#box", 0.5, 1.5, kfs);
|
||||
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
|
||||
});
|
||||
|
||||
it("replaces the first tween in a two-tween script, preserving the other", () => {
|
||||
const TWO_TWEEN = `\
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { x: 100, duration: 0.5 }, 0);
|
||||
tl.to("#circle", { y: 200, duration: 1 }, 1);
|
||||
`;
|
||||
const id = acornId(TWO_TWEEN);
|
||||
const kfs = [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 100, properties: { x: 300 } },
|
||||
];
|
||||
const acorn = replaceWithKfAcorn(TWO_TWEEN, id, "#box", 0, 0.75, kfs);
|
||||
const recast = replaceWithKfRecast(TWO_TWEEN, id, "#box", 0, 0.75, kfs);
|
||||
// The second tween (#circle) must survive unchanged.
|
||||
expect(modelOf(acorn)).toHaveLength(2);
|
||||
expect(modelOf(recast)).toHaveLength(2);
|
||||
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
|
||||
});
|
||||
|
||||
it("replaces with _auto endpoint markers", () => {
|
||||
const id = acornId(REPLACE_WITH_KF_BASE);
|
||||
const kfs = [
|
||||
{ percentage: 0, properties: { opacity: 1 }, auto: true },
|
||||
{ percentage: 100, properties: { opacity: 0 }, auto: true },
|
||||
];
|
||||
const acorn = replaceWithKfAcorn(REPLACE_WITH_KF_BASE, id, "#box", 1, 2, kfs);
|
||||
const recast = replaceWithKfRecast(REPLACE_WITH_KF_BASE, id, "#box", 1, 2, kfs);
|
||||
expect(lastModelOf(acorn)).toEqual(lastModelOf(recast));
|
||||
});
|
||||
});
|
||||
|
||||
// ── shiftPositionsInScript / scalePositionsInScript (timeline clip move/resize) ──
|
||||
|
||||
@@ -1128,12 +1128,14 @@ function buildKeyframeObjectCode(
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
auto?: boolean;
|
||||
}>,
|
||||
easeEach?: string,
|
||||
): string {
|
||||
const entries = keyframes.map((kf) => {
|
||||
const props = Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`);
|
||||
if (kf.auto) props.push(`_auto: 1`);
|
||||
return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`;
|
||||
});
|
||||
if (easeEach) entries.push(`easeEach: ${JSON.stringify(easeEach)}`);
|
||||
@@ -1210,6 +1212,7 @@ export function addAnimationWithKeyframesToScript(
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
auto?: boolean;
|
||||
}>,
|
||||
ease?: string,
|
||||
): { script: string; id: string } {
|
||||
|
||||
Reference in New Issue
Block a user