mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
* chore(producer): shim __filename/__dirname in the CJS banner Bundled CJS deps like wawoff2 call __dirname; without the shim they throw "__dirname is not defined in ES module" at render time. Also ignore .zed/. * chore(producer): use a template literal for the CJS banner (review nit) * feat(core): add GSAP keyframe + motion-path source mutations Array-form keyframe removal in both the recast and acorn writers, plus update/add/remove-motion-path-point and add-motion-path. Exclude _auto and data from tween property-group classification. * fix(core): address #1554 review — data-exclusion test, split-fix doc, motion-path sentinel, parity blocks - Regression test for the `data` GSAP-key exclusion (parallel to _auto). - splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests. - addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated. - Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug — documented as it.skip with a ready assertion (acorn cutover follow-up). * feat(core): route motion-path mutations through studio-api + fix clip stamping Wire the new mutations into the file save route. Only authored clips suppress descendant stamping, so auto-stamped animated scenes can inline-expand. Hide in-flow timed clips with `display:none` only when they are LEAF clips (no nested timed clips). `display:none` on a container removes its whole subtree, hiding descendants that are still inside their own visibility window — e.g. an in-flow composition root whose effective window clamps to the timeline end would black out a child video that should still show (the hdr-hlg regression). Containers keep `visibility:hidden`, which a visible descendant can override; only leaves leave the flow, which is all the split-overlap case needs. * feat(core): strip legacy path-offset/rotation + drop obsolete studio lint rule A position or rotation add/set mutation makes the GSAP timeline the single source of truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both channels, and the add-strip fires for the position AND rotation property groups. Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio cannot save drag/resize edits to elements in a registered timeline — the exact premise the single-source work inverts (the timeline is now the edit target). Removed the rule, its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests. * fix(core): address #1555 review — complete hold-sync, invalidate clip cache, strip rotation channel - HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta, shift-positions, scale-positions, split-animations. (add stays out: flat tweens only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.) - init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change; visible/hidden branches both go through isTimedClipInFlow (was .get() by accident). - keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set strips the stale --hf-studio-rotation channel. * feat(studio): GSAP runtime read layer + shared helpers * fix(studio): address #1607 review — cold-parse vs fetch-error budgets, isZeroDurationSet, array-ease tests - useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once. - Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent. - parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior ease is stripped without shifting the other keyframes' percentages. * feat(studio): GSAP drag/commit/bridge editing infra * fix(studio): address #1608 review — facade awaits commit, strict stale-parse guard, clearProps restore BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the server save, fixing both consumers (useEnableKeyframes + useGestureCommit's showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation return type widened void→Promise<void> (fire-and-forget consumers ignore it). - stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live). - commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek, so a failed commit leaves the dropped pose, not a cleared element. * feat(studio): motion-path geometry + commit helpers * docs(studio): address #1609 review — document occlusion fade-in invariant, donut limit, nearestPointOnPath t-semantics * feat(studio): on-canvas motion-path overlay * fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold * feat(studio): keyframes flag, gesture recording + timeline/selection refinements * fix(studio): address #1611 review — fetch-first keyframe path, gated hydration, dev-gated debug + gesture warn, per-group gesture tweens - useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's empty parse isn't overridden by a stale selectedGsapAnimations cache. - useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick). - useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent). - playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV). - useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per group, so a mixed gesture no longer yields an untagged legacy tween. * feat(studio): single-source manual offset + rotation via the GSAP timeline Dragging or rotating an element writes into the GSAP timeline (the single source of truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and the live preview moves via gsap.set so what you see equals what is written and renders. Removes the dual-channel CSS-var/transform reconciliation behind the fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/ keyframe commit, dropped the handleDom*Commit CSS fallbacks). Subcompositions edit the same single-source way, which surfaced and fixes: - resolve a subcomp element's source file via the composition-id map (the runtime drops the source linkage when inlining the subcomposition); - a selected element's selection box AND motion path use basic visibility, not the occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover); - soft reload rebuilds ONLY the committed composition's timeline, leaving other compositions' timelines intact (no cross-composition revert); - read keyframes from the element's OWN composition timeline (scan all timelines, not the first unstable key); - delete-all uses a soft reload too, so editing no longer hard-reloads the iframe. * fix(studio): address #1567 review — drop drag-intercept flag, harden softReload onerror, tighten runtime ladder, per-group gestures - DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only position/rotation channel; the false branch silently killed drag+rotate (and let GSAP elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests. - gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script. - gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops partial reads (no phantom identity) and re-applies the drag override in finally. - MotionPathOverlay: park-timer cleanup keyed on animId change. - useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review). * feat(studio): patchRuntimeTweenInPlace — update a tween's values in place Defensive runtime helper: locate the element's tween in window.__timelines via the shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek the playhead — without re-running the whole composition. Returns false (caller falls back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant, flicker-free manual edits. * fix(studio): address #1612 review — channel-aware set resolution + decline dynamic-expression patches - resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props. - patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch], instead of silently dropping the computed expression. * feat(studio): instantPatch fast path in runCommit A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on success the preview updates in place with NO reload (instant), on false it falls back to the existing soft reload. Extracts the preview-sync tail into a testable applyPreviewSync helper. No behavior change when instantPatch is absent. * feat(studio): route static position/rotation set drags through instantPatch Static-element position and rotation set commits now attach instantPatch{selector, change:{kind:set}} so the drag updates in place with no reload. Structural ops (new tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit it and keep the soft reload — keyframe instant-patch needs object-form keyframe support in patchRuntimeTweenInPlace (deferred). * fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure - commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated values flow into the patch; can't drift). - Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y}, so a second-POST failure still leaves the preview patched for what persisted. - applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload); per U4 the synchronous false still does NOT escalate. - (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.) * feat(studio): no full iframe remount for soft-reloadable edits A softReload edit (and the SDK single-script refresh) no longer escalates to a full reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set already shows the value, and a remount is the worst flash + re-inlines subcomps (reverting their keyframes). verifyTimelinesPopulated now checks the expected target keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty. Full reload stays only for the structural (no-softReload) and ambiguous-script paths. * feat(studio): pre-load MotionPathPlugin so motion-path edits don't async-flash ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the timeline. So when a user adds a motion path to a composition that didn't originally use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN async path (the flash). Idempotent + defensive; the existing async fallback stays for genuine cold-start/CDN-failure. * fix(studio): don't re-save + reload when source editor syncs externally The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged — including the programmatic dispatch that syncs external content (e.g. a manual-edit commit writing the source back into the open editor). That made the editor re-save the file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe edit — defeating the in-place instant patch and causing the flash. Annotate the programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save. * fix(core): inject MotionPathPlugin into preview when a composition uses motionPath A studio-created motion path writes a gsap motionPath tween into the single-source timeline, but the preview HTML only loaded gsap core — so the first render threw "Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject MotionPathPlugin right after the composition's gsap script, version-matched to it. * fix(studio): dedup __hfMotionPathPluginLoading type decl (restack artifact) * fix(studio): address #1605 review — distinguish soft-reload failure modes + observability, SourceEditor focus guard BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' | 'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script, or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome (origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted. - SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused). - Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append). * fix(core): align __clipTree and __clipManifest ids via stableClipId Timeline inline expansion was dead for nested children inside index.html: the tree keyed id-less elements by a synthetic __clip-N while the manifest keyed them null, so parent<->child never joined. Both now resolve identity through stableClipId (id || data-hf-id), which every generated element has. * fix(core): strip baked runtime + tag comp root in preview assembly Comps that ship a baked inline runtime were double-loaded (preview injects its own) and the baked copy failed to parse inline (Unexpected token '<'). Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also tag the comp root with data-composition-file so the studio resolves a comp's top-level elements to the right source file instead of defaulting to index.html (which made the GSAP panel parse the wrong, multi-timeline file). * feat(studio): set motion-path destination from a toolbar toggle Replaces the double-click-on-canvas UX (which painted text over the preview) with a 'Set motion destination' toggle next to Snap/Grid, shown only when the selected element can take a path. While armed, one canvas press places the destination. Also removes the dead TimelinePropertyRows component. * fix(studio): center timeline keyframe diamonds on their percentage Dropped clampDiamondLeft, which forced boundary keyframes fully inside the clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's midpoint now sits exactly on its % (the clip is overflow-visible). * fix(studio): resize static elements via tl.set, not a single-stop keyframes tween Resizing an element with no size animation wrote keyframes:{ <playhead%>: {width,height} } — one mid-point stop GSAP can't interpolate, so it rendered NaN/0 dimensions at every other frame and the element vanished (worst off 0%). Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize now writes tl.set({width,height}), held at all frames; re-resizing updates it in place. * fix(studio): negative-cache failed media probes Only successful probes were cached, so CORS/404 cross-origin media was re-probed every rAF-driven timeline re-derive, flooding the console. Remember failed URLs and skip them. * fix(studio): type window.setTimeout handle as number ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is present and clashes with the DOM number the call returns. Type it number. * fix(studio): drag/resize disappearance, stale-ID duplicates, soft-reload clearProps - Fix soft-reload clearProps destroying element inline styles — save cssText, clear, restore, strip only transform - Fix resize no-op on re-resize: delete+add instead of two update-property - Route set tweens through static resize path (convertToKeyframes skips sets) - Re-fetch animation ID before drag commit to prevent stale-ID duplicates - Guard editDebugLog for Node test environments - Fix NLELayout setState-during-render (move reset to useEffect) - Stop SnapToolbar pointer events propagating to canvas deselect handler - Enable click-to-add waypoints on cubic motion paths - Add whole-path drag offset (Alt+drag shifts all keyframes together) - Add Canvas shortcuts section to ShortcutsPanel - Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance) - Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports)
436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
import { describe, expect, it, beforeEach } from "vitest";
|
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
import {
|
|
commitGsapPositionFromDrag,
|
|
commitStaticGsapPosition,
|
|
commitStaticGsapRotation,
|
|
parkPlayheadOnKeyframe,
|
|
type GsapDragCommitCallbacks,
|
|
} from "./gsapDragCommit";
|
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
|
|
// Minimal selection whose element has no drag-baseline attributes (origX/Y = 0).
|
|
const selection = (): DomEditSelection =>
|
|
({
|
|
id: "puck-a",
|
|
selector: "#puck-a",
|
|
element: {
|
|
style: { getPropertyValue: () => "", setProperty: () => {} },
|
|
getAttribute: () => null,
|
|
removeAttribute: () => {},
|
|
getBoundingClientRect: () => ({ top: 0, left: 0 }),
|
|
},
|
|
}) as unknown as DomEditSelection;
|
|
|
|
const flatTween = (): GsapAnimation =>
|
|
({
|
|
id: "#puck-a-to",
|
|
targetSelector: "#puck-a",
|
|
method: "to",
|
|
resolvedStart: 1.2,
|
|
duration: 2.2,
|
|
properties: { x: -260 },
|
|
}) as unknown as GsapAnimation;
|
|
|
|
// What the flat tween becomes after convert-to-keyframes (returned by fetchAnimations).
|
|
const convertedTween = (): GsapAnimation =>
|
|
({
|
|
id: "#puck-a-converted",
|
|
targetSelector: "#puck-a",
|
|
method: "to",
|
|
resolvedStart: 1.2,
|
|
duration: 2.2,
|
|
keyframes: {
|
|
keyframes: [
|
|
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
{ percentage: 100, properties: { x: -260, y: 0 } },
|
|
],
|
|
},
|
|
}) as unknown as GsapAnimation;
|
|
|
|
function recordingCallbacks(): {
|
|
types: string[];
|
|
mutations: Array<Record<string, unknown>>;
|
|
callbacks: GsapDragCommitCallbacks;
|
|
} {
|
|
const types: string[] = [];
|
|
const mutations: Array<Record<string, unknown>> = [];
|
|
return {
|
|
types,
|
|
mutations,
|
|
callbacks: {
|
|
commitMutation: async (_sel, mutation) => {
|
|
types.push(mutation.type as string);
|
|
mutations.push(mutation);
|
|
},
|
|
fetchAnimations: async () => [convertedTween()],
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("commitGsapPositionFromDrag — flat tween", () => {
|
|
beforeEach(() => {
|
|
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
|
|
});
|
|
|
|
it("extends the existing tween (never spawns a parallel one) when dragged OUTSIDE its range", async () => {
|
|
usePlayerStore.setState({ currentTime: 6 }); // outside [1.2, 3.4]
|
|
const { types, callbacks } = recordingCallbacks();
|
|
|
|
await commitGsapPositionFromDrag(
|
|
selection(),
|
|
flatTween(),
|
|
{ x: -100, y: 0 },
|
|
{ x: 0, y: 0 },
|
|
null,
|
|
"#puck-a",
|
|
callbacks,
|
|
);
|
|
|
|
expect(types).toContain("convert-to-keyframes");
|
|
expect(types).toContain("replace-with-keyframes"); // the extend
|
|
expect(types).not.toContain("add-with-keyframes"); // regression: no parallel tween
|
|
});
|
|
|
|
it("adds a keyframe at the playhead when dragged INSIDE its range", async () => {
|
|
usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4]
|
|
const { types, callbacks } = recordingCallbacks();
|
|
|
|
await commitGsapPositionFromDrag(
|
|
selection(),
|
|
flatTween(),
|
|
{ x: -100, y: 0 },
|
|
{ x: 0, y: 0 },
|
|
null,
|
|
"#puck-a",
|
|
callbacks,
|
|
);
|
|
|
|
expect(types).toContain("add-keyframe");
|
|
expect(types).not.toContain("add-with-keyframes");
|
|
});
|
|
|
|
it("MODIFIES the selected keyframe (no extend) when one is selected, even past the tween end", async () => {
|
|
// User clicked the 100% diamond (activeKeyframePct=100), playhead drifted past
|
|
// the end. Expect: convert + add-keyframe AT 100% — not replace-with-keyframes.
|
|
usePlayerStore.setState({ currentTime: 6, activeKeyframePct: 100 }); // outside [1.2, 3.4]
|
|
const { types, mutations, callbacks } = recordingCallbacks();
|
|
|
|
await commitGsapPositionFromDrag(
|
|
selection(),
|
|
flatTween(),
|
|
{ x: -100, y: 0 },
|
|
{ x: 0, y: 0 },
|
|
null,
|
|
"#puck-a",
|
|
callbacks,
|
|
);
|
|
|
|
expect(types).toContain("add-keyframe");
|
|
expect(types).not.toContain("replace-with-keyframes"); // not extended
|
|
const addKf = mutations.find((m) => m.type === "add-keyframe");
|
|
expect(addKf?.percentage).toBe(100); // modified the selected endpoint
|
|
// consumed: cleared so the next free drag doesn't keep modifying
|
|
expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
|
|
// parked the playhead on the edited keyframe (1.2 start + 100% * 2.2 dur),
|
|
// so the edit is visible instead of rendering the base pose
|
|
expect(usePlayerStore.getState().requestedSeekTime).toBe(3.4);
|
|
});
|
|
});
|
|
|
|
describe("commitGsapPositionFromDrag — keyframed tween backfill", () => {
|
|
beforeEach(() => {
|
|
usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
|
|
});
|
|
|
|
const keyframedTween = (): GsapAnimation =>
|
|
({
|
|
id: "#puck-a-kf",
|
|
targetSelector: "#puck-a",
|
|
method: "to",
|
|
resolvedStart: 1.2,
|
|
duration: 2.2,
|
|
keyframes: {
|
|
keyframes: [
|
|
{ percentage: 0, properties: { x: 0 } },
|
|
{ percentage: 100, properties: { x: -260 } },
|
|
],
|
|
},
|
|
}) as unknown as GsapAnimation;
|
|
|
|
it("passes backfillDefaults so a newly-introduced prop doesn't move the other keyframes", async () => {
|
|
// Drag the 0% keyframe DOWN (introduces y on an x-only tween). The add-keyframe
|
|
// must carry backfillDefaults at the element's base so 100% gets y:0, not y:780.
|
|
usePlayerStore.setState({ currentTime: 1.2, activeKeyframePct: 0 });
|
|
const { mutations, callbacks } = recordingCallbacks();
|
|
|
|
await commitGsapPositionFromDrag(
|
|
selection(),
|
|
keyframedTween(),
|
|
{ x: 0, y: 780 }, // studioOffset: dragged straight down
|
|
{ x: 0, y: 0 }, // gsapPos → base falls back to {0,0} (selection has no base attrs)
|
|
null,
|
|
"#puck-a",
|
|
callbacks,
|
|
);
|
|
|
|
const addKf = mutations.find((m) => m.type === "add-keyframe");
|
|
expect(addKf).toBeDefined();
|
|
expect(addKf?.percentage).toBe(0); // edited the selected 0% keyframe
|
|
expect(addKf?.properties).toMatchObject({ y: 780 });
|
|
expect(addKf?.backfillDefaults).toEqual({ x: 0, y: 0 }); // base → 100% gets y:0
|
|
});
|
|
});
|
|
|
|
describe("commitGsapPositionFromDrag — from() tween dragged outside its range", () => {
|
|
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
|
|
|
|
const fromTween = (): GsapAnimation =>
|
|
({
|
|
id: "#title-from-400",
|
|
targetSelector: "#title",
|
|
method: "from",
|
|
resolvedStart: 0.4,
|
|
duration: 0.9,
|
|
properties: { y: 70 },
|
|
}) as unknown as GsapAnimation;
|
|
|
|
it("REPLACES the split position from() tween (no parallel tween → no drop jump)", async () => {
|
|
usePlayerStore.setState({ currentTime: 2.13 }); // outside [0.4, 1.3]
|
|
const types: string[] = [];
|
|
const mutations: Array<Record<string, unknown>> = [];
|
|
const callbacks: GsapDragCommitCallbacks = {
|
|
commitMutation: async (_s, m) => {
|
|
types.push(m.type as string);
|
|
mutations.push(m);
|
|
},
|
|
// After split-into-property-groups, the position group is a from() tween (no keyframes).
|
|
fetchAnimations: async () => [
|
|
{
|
|
id: "#title-from-400-position",
|
|
targetSelector: "#title",
|
|
method: "from",
|
|
propertyGroup: "position",
|
|
resolvedStart: 0.4,
|
|
duration: 0.9,
|
|
properties: { y: 70 },
|
|
} as unknown as GsapAnimation,
|
|
],
|
|
};
|
|
|
|
await commitGsapPositionFromDrag(
|
|
selection(),
|
|
fromTween(),
|
|
{ x: 0, y: -333 },
|
|
{ x: 0, y: 0 },
|
|
null,
|
|
"#title",
|
|
callbacks,
|
|
);
|
|
|
|
expect(types).toContain("split-into-property-groups");
|
|
expect(types).toContain("replace-with-keyframes");
|
|
expect(types).not.toContain("add-with-keyframes"); // regression: no parallel tween
|
|
const replace = mutations.find((m) => m.type === "replace-with-keyframes");
|
|
expect(replace?.animationId).toBe("#title-from-400-position"); // replaces the split from()
|
|
});
|
|
});
|
|
|
|
// Captures the OPTIONS each commit carries (not just the mutation) so we can
|
|
// assert which value-only commits attach the `instantPatch` fast path.
|
|
type RecordedCommit = { mutation: Record<string, unknown>; options: Record<string, unknown> };
|
|
function optionRecordingCallbacks(): {
|
|
commits: RecordedCommit[];
|
|
callbacks: GsapDragCommitCallbacks;
|
|
} {
|
|
const commits: RecordedCommit[] = [];
|
|
return {
|
|
commits,
|
|
callbacks: {
|
|
commitMutation: async (_sel, mutation, options) => {
|
|
commits.push({ mutation, options: options as Record<string, unknown> });
|
|
},
|
|
fetchAnimations: async () => [convertedTween()],
|
|
},
|
|
};
|
|
}
|
|
|
|
const existingPositionSet = (): GsapAnimation =>
|
|
({
|
|
id: "#puck-a-set",
|
|
targetSelector: "#puck-a",
|
|
method: "set",
|
|
properties: { x: 10, y: 20 },
|
|
}) as unknown as GsapAnimation;
|
|
|
|
const existingRotationSet = (): GsapAnimation =>
|
|
({
|
|
id: "#puck-a-rot-set",
|
|
targetSelector: "#puck-a",
|
|
method: "set",
|
|
properties: { rotation: 15 },
|
|
}) as unknown as GsapAnimation;
|
|
|
|
describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
|
|
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
|
|
|
|
it("attaches an instantPatch to BOTH coalesced commits, each derived from its own mutation", async () => {
|
|
const { commits, callbacks } = optionRecordingCallbacks();
|
|
|
|
await commitStaticGsapPosition(
|
|
selection(),
|
|
{ x: -50, y: 30 }, // studioOffset → newX/newY off a zero base
|
|
{ x: 0, y: 0 },
|
|
"#puck-a",
|
|
existingPositionSet(),
|
|
callbacks,
|
|
);
|
|
|
|
expect(commits).toHaveLength(2);
|
|
// First (x) commit is the intermediate skipReload one — it now carries an
|
|
// instantPatch for just {x}, so if the SECOND POST fails the preview still
|
|
// reflects the x that DID persist (no reload, instant feedback).
|
|
expect(commits[0].options.skipReload).toBe(true);
|
|
expect(commits[0].options.instantPatch).toEqual({
|
|
selector: "#puck-a",
|
|
change: { kind: "set", props: { x: -50 } },
|
|
});
|
|
// Final (y) commit triggers the reload and carries the full {x,y} patch.
|
|
expect(commits[1].options.softReload).toBe(true);
|
|
expect(commits[1].options.instantPatch).toEqual({
|
|
selector: "#puck-a",
|
|
change: { kind: "set", props: { x: -50, y: 30 } },
|
|
});
|
|
});
|
|
|
|
it("derives each instantPatch's props from the value in the SAME mutation that's POSTed", async () => {
|
|
const { commits, callbacks } = optionRecordingCallbacks();
|
|
|
|
await commitStaticGsapPosition(
|
|
selection(),
|
|
{ x: -50, y: 30 },
|
|
{ x: 0, y: 0 },
|
|
"#puck-a",
|
|
existingPositionSet(),
|
|
callbacks,
|
|
);
|
|
|
|
// The patch values must equal the mutation values — they're read out of the
|
|
// same object, so a clean mutation can't ship alongside a stale patch.
|
|
const xMutation = commits[0].mutation as { property: string; value: number };
|
|
const yMutation = commits[1].mutation as { property: string; value: number };
|
|
const xPatch = commits[0].options.instantPatch as {
|
|
change: { props: Record<string, number> };
|
|
};
|
|
const yPatch = commits[1].options.instantPatch as {
|
|
change: { props: Record<string, number> };
|
|
};
|
|
expect(xPatch.change.props[xMutation.property]).toBe(xMutation.value);
|
|
expect(yPatch.change.props[yMutation.property]).toBe(yMutation.value);
|
|
// The y commit's combined patch also carries the x mutation's value.
|
|
expect(yPatch.change.props[xMutation.property]).toBe(xMutation.value);
|
|
});
|
|
|
|
it("does NOT attach instantPatch when ADDING a new set (structural — new tween)", async () => {
|
|
const { commits, callbacks } = optionRecordingCallbacks();
|
|
|
|
await commitStaticGsapPosition(
|
|
selection(),
|
|
{ x: -50, y: 30 },
|
|
{ x: 0, y: 0 },
|
|
"#puck-a",
|
|
null, // no existing set → `add` a new tween
|
|
callbacks,
|
|
);
|
|
|
|
expect(commits).toHaveLength(1);
|
|
expect(commits[0].mutation.type).toBe("add");
|
|
expect(commits[0].options.instantPatch).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
|
|
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
|
|
|
|
it("attaches instantPatch {kind:set, props:{rotation}} when updating an existing rotation set", async () => {
|
|
const { commits, callbacks } = optionRecordingCallbacks();
|
|
|
|
await commitStaticGsapRotation(selection(), 42, "#puck-a", existingRotationSet(), callbacks);
|
|
|
|
expect(commits).toHaveLength(1);
|
|
expect(commits[0].mutation.type).toBe("update-property");
|
|
expect(commits[0].options.instantPatch).toEqual({
|
|
selector: "#puck-a",
|
|
change: { kind: "set", props: { rotation: 42 } },
|
|
});
|
|
// Patch value derived from the SAME mutation that's POSTed (one source).
|
|
const m = commits[0].mutation as { property: string; value: number };
|
|
const patch = commits[0].options.instantPatch as {
|
|
change: { props: Record<string, number> };
|
|
};
|
|
expect(patch.change.props[m.property]).toBe(m.value);
|
|
});
|
|
|
|
it("does NOT attach instantPatch when ADDING a new rotation set (structural)", async () => {
|
|
const { commits, callbacks } = optionRecordingCallbacks();
|
|
|
|
await commitStaticGsapRotation(selection(), 42, "#puck-a", null, callbacks);
|
|
|
|
expect(commits).toHaveLength(1);
|
|
expect(commits[0].mutation.type).toBe("add");
|
|
expect(commits[0].options.instantPatch).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("commitGsapPositionFromDrag — keyframe/structural commits omit instantPatch", () => {
|
|
beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
|
|
|
|
it("a structural keyframe drag (convert-to-keyframes → add-keyframe) sets no instantPatch", async () => {
|
|
usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4] → convert + add-keyframe
|
|
const { commits, callbacks } = optionRecordingCallbacks();
|
|
|
|
await commitGsapPositionFromDrag(
|
|
selection(),
|
|
flatTween(),
|
|
{ x: -100, y: 0 },
|
|
{ x: 0, y: 0 },
|
|
null,
|
|
"#puck-a",
|
|
callbacks,
|
|
);
|
|
|
|
// The keyframe path is structural here (convert + add-keyframe) and must rely
|
|
// on the soft reload — none of its commits opt into the instant patch.
|
|
expect(commits.length).toBeGreaterThan(0);
|
|
for (const c of commits) {
|
|
expect(c.options.instantPatch).toBeUndefined();
|
|
}
|
|
const types = commits.map((c) => c.mutation.type);
|
|
expect(types).toContain("convert-to-keyframes");
|
|
expect(types).toContain("add-keyframe");
|
|
});
|
|
});
|
|
|
|
describe("parkPlayheadOnKeyframe", () => {
|
|
beforeEach(() => usePlayerStore.setState({ requestedSeekTime: null }));
|
|
|
|
const tween = (): GsapAnimation =>
|
|
({
|
|
id: "#x",
|
|
targetSelector: "#x",
|
|
method: "to",
|
|
resolvedStart: 1.2,
|
|
duration: 2.2,
|
|
}) as unknown as GsapAnimation;
|
|
|
|
it("seeks to the keyframe's absolute time so the element previews AT it, not at base", () => {
|
|
parkPlayheadOnKeyframe(tween(), 0); // tween start
|
|
expect(usePlayerStore.getState().requestedSeekTime).toBe(1.2);
|
|
parkPlayheadOnKeyframe(tween(), 100); // tween end
|
|
expect(usePlayerStore.getState().requestedSeekTime).toBe(3.4);
|
|
parkPlayheadOnKeyframe(tween(), 50); // midpoint
|
|
expect(usePlayerStore.getState().requestedSeekTime).toBe(2.3);
|
|
});
|
|
});
|