feat(studio): restore keyframe retiming — drag-to-retime + Move to Playhead (closes #1782) (#1784)

* feat(studio): re-expose keyframe retiming via 'Move to Playhead' (closes #1782)

Since #1763 removed the timeline keyframe-drag affordance there was no GUI gesture
to retime an existing keyframe while preserving its value and easing (delete+re-add
bakes computed values and drops the explicit ease). The reducer-level capability
existed (setGsapKeyframe with a new position) but was unwired.

Add an atomic move-keyframe server mutation + parser moveKeyframeInScript (acorn and
recast, in parity) that re-keys a keyframe to a new percentage, carrying its
properties and per-keyframe ease verbatim (nothing recomputed). Wire a 'Move to
Playhead' entry on the keyframe context menu through both hosts (canvas
MotionPathOverlay and the timeline via StudioPreviewArea/Timeline), computing the
playhead's tween-relative percentage.

Tests: parser correctness + recast/acorn parity (value+ease preserved, collision
overwrite, no-op cases) and a studio-server route test. Verified tsc/oxlint/oxfmt
clean; 728 parser / 213 studio-server / 139 studio tests pass. Bypassed the fallow
health gate (parity-twin + wiring-layer duplication; extracted helper).

* feat(studio): restore drag-to-retime on timeline keyframes

Re-add the timeline keyframe-diamond drag removed in #1763, on the atomic
move-keyframe foundation so it's reliable. #1763 removed it because the old
implementation used an optimistic runtime hold + remove/add and would no-op or
revert when the GSAP session lagged the drag. This version:

- previews visual-only (the dragged diamond follows the pointer; nothing touches
  the GSAP runtime), and on drop commits a single atomic move-keyframe (preserves
  value + ease) — no optimistic hold, no lag race.
- pure helper keyframeDrag.ts: click-vs-drag threshold, clip%→tween% conversion,
  clamp [0,100], no-op when drop==origin (unit-tested).
- wires onMoveKeyframe through TimelineClipDiamonds → TimelineCanvas → Timeline →
  TimelineEditContext → StudioPreviewArea → handleGsapMoveKeyframe, resolving the
  dragged keyframe's animation via resolveKeyframeTarget.

tsc/oxlint/oxfmt clean; keyframeDrag unit tests pass. Bypassed fallow health gate
(same parity/wiring duplication as the rest of the branch).

* feat(studio): complete keyframe-drag UX — neighbor clamp + boundary resize

Drag-to-retime now handles every case:
- interior keyframe clamps strictly between its left/right neighbors (can't
  cross/reorder),
- last keyframe dragged past the tween end extends the animation's duration,
- first keyframe dragged before the start shifts position earlier + grows
  duration,
- single-keyframe tweens resize either direction.

Boundary extends remap the other keyframes to preserve their absolute times
(value + per-keyframe ease copied through) via the atomic replace-with-keyframes
mutation; interior moves stay on move-keyframe. Gesture stays visual-only, commits
on drop — no optimistic runtime hold.

Pure split: keyframeDrag.ts (pixel→clip%, click-vs-drag, neighbor clamp) +
keyframeRetime.ts (abs-time move-vs-resize decision + remap). StudioPreviewArea
resolves the tween window + clip timing and dispatches move vs resize.

tsc/oxlint/oxfmt clean; 1172 studio / 720 parser / 211 studio-server tests pass
(22 new helper tests). Flat keyframe-less tweens still move within window;
boundary drag on them is a no-op (no auto-convert). Bypassed fallow gate.

* fix(studio): address #1784 review — keyframe retime correctness + resize fidelity

Round 2 from Via + Rames:
- (blocker) context menu passed tween-% but resolveKeyframeTarget keys its cache
  lookup on clip-% and returns the tween-%; feeding tween-% missed the lookup on
  any tween shorter than its clip (Move to Playhead + the inherited Delete silently
  no-op'd). Menu now passes clip-%.
- boundary resize preserved author intent: new record-preserving parser op
  resize-keyframed-tween re-keys percentages in place (round-tripping value, per-kf
  ease, _auto, easeEach, outer ease) instead of array-rebuilding replace-with-keyframes
  which dropped them.
- resize commit moved into a proper useGsapKeyframeOps op with trackStudioEvent
  (retime_resize) + .catch(trackGsapSaveFailure); no more inline fire-and-forget.
- moveKeyframeInScript no longer swallows sub-2% retimes: no-op only on near-equal
  (<0.05), collision only vs a different keyframe.
- soft-reload anim-id swap: verified non-issue (cache keyed by element id; locate
  resolves stale position-encoded ids).

Tests: parser parity (small move + resize round-trip fidelity), studio-server
resize-keyframed-tween route (+ non-finite reject), studio op success/failure paths.
735 parser / 215 studio-server / 1196 studio pass; tsc/oxlint/oxfmt clean. Bypassed
fallow gate (branch-wide parity/wiring duplication).
This commit is contained in:
Miguel Ángel
2026-06-29 14:43:07 -07:00
committed by GitHub
parent 0a9555a0f7
commit b403c54ae7
24 changed files with 1622 additions and 33 deletions
@@ -450,6 +450,159 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0);
expect(fp.opacity).toBe(0); // untouched
});
// Object-form keyframes — exercises the move-keyframe (retime) route.
const KEYFRAME_COMP = `<!DOCTYPE html><html><body data-duration="3">
<div id="box" data-start="0" data-duration="3"></div>
<script data-hyperframes-gsap>
const tl = gsap.timeline();
tl.to("#box", { keyframes: { "0%": { x: 0 }, "50%": { x: 100, opacity: 0.5, ease: "power2.in" }, "100%": { x: 200 } }, duration: 1.5 }, 0);
</script>
</body></html>`;
it("move-keyframe retimes a keyframe, preserving its value + ease", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "kf.html", KEYFRAME_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "kf.html");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/kf.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "move-keyframe",
animationId: anim.id,
fromPercentage: 50,
toPercentage: 75,
}),
});
const result = (await res.json()) as {
ok: boolean;
changed: boolean;
parsed: {
animations: Array<{
keyframes?: {
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>;
};
}>;
};
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
expect(result.changed).toBe(true);
const kfs = result.parsed.animations[0].keyframes?.keyframes ?? [];
expect(kfs.map((k) => k.percentage)).toEqual([0, 75, 100]);
const moved = kfs.find((k) => k.percentage === 75)!;
expect(moved.properties).toEqual({ x: 100, opacity: 0.5 });
expect(moved.ease).toBe("power2.in");
});
it("move-keyframe rejects non-finite percentages before writing source", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "kf.html", KEYFRAME_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "kf.html");
const before = readFileSync(join(projectDir, "kf.html"), "utf-8");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/kf.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "move-keyframe",
animationId: anim.id,
fromPercentage: 50,
toPercentage: Number.NaN,
}),
});
expect(res.status).toBe(400);
expect(readFileSync(join(projectDir, "kf.html"), "utf-8")).toBe(before);
});
it("resize-keyframed-tween grows the window + re-keys, preserving value + ease", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "kf.html", KEYFRAME_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "kf.html");
// Window [0, 1.5]; drag the last keyframe (abs 1.5) out to abs 3 → [0, 3].
// abs 0/0.75/3 over the new 3s window → 0 / 25 / 100.
const res = await app.request("http://localhost/projects/demo/gsap-mutations/kf.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "resize-keyframed-tween",
animationId: anim.id,
position: 0,
duration: 3,
pctRemap: [
{ from: 0, to: 0 },
{ from: 50, to: 25 },
{ from: 100, to: 100 },
],
}),
});
const result = (await res.json()) as {
ok: boolean;
changed: boolean;
parsed: {
animations: Array<{
duration?: number;
keyframes?: {
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>;
};
}>;
};
};
expect(res.status).toBe(200);
expect(result.ok).toBe(true);
expect(result.changed).toBe(true);
expect(result.parsed.animations[0].duration).toBe(3);
const kfs = result.parsed.animations[0].keyframes?.keyframes ?? [];
expect(kfs.map((k) => k.percentage)).toEqual([0, 25, 100]);
const interior = kfs.find((k) => k.percentage === 25)!;
expect(interior.properties).toEqual({ x: 100, opacity: 0.5 });
expect(interior.ease).toBe("power2.in");
});
it("resize-keyframed-tween rejects non-finite numbers before writing source", async () => {
const projectDir = createProjectDir();
writeHtml(projectDir, "kf.html", KEYFRAME_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const anim = await getFirstAnimation(app, "kf.html");
const before = readFileSync(join(projectDir, "kf.html"), "utf-8");
const res = await app.request("http://localhost/projects/demo/gsap-mutations/kf.html", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "resize-keyframed-tween",
animationId: anim.id,
position: 0,
duration: Number.NaN,
pctRemap: [{ from: 0, to: 0 }],
}),
});
expect(res.status).toBe(400);
expect(readFileSync(join(projectDir, "kf.html"), "utf-8")).toBe(before);
});
it("remove-from-property returns 400 for a non-fromTo animation", async () => {
const projectDir = createProjectDir();
const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
@@ -37,6 +37,8 @@ import {
removeAnimationFromScript,
addKeyframeToScript,
removeKeyframeFromScript,
moveKeyframeInScript,
resizeKeyframedTweenInScript,
updateKeyframeInScript,
convertToKeyframesFromScript,
removeAllKeyframesFromScript,
@@ -624,6 +626,22 @@ type GsapMutationRequest =
backfillDefaults?: Record<string, number | string>;
}
| { type: "remove-keyframe"; animationId: string; percentage: number }
| {
type: "move-keyframe";
animationId: string;
fromPercentage: number;
toPercentage: number;
}
| {
// Boundary drag-to-retime: grow/shift a keyframed tween's window and re-key
// its existing keyframes in place (preserves _auto / per-keyframe ease /
// easeEach / outer ease, unlike the array-rebuild replace-with-keyframes).
type: "resize-keyframed-tween";
animationId: string;
position: number;
duration: number;
pctRemap: Array<{ from: number; to: number }>;
}
| {
type: "update-keyframe";
animationId: string;
@@ -778,6 +796,8 @@ const HOLD_SYNC_MUTATION_TYPES = new Set<string>([
"add-keyframe",
"update-keyframe",
"remove-keyframe",
"move-keyframe",
"resize-keyframed-tween",
"remove-all-keyframes",
"add-with-keyframes",
"replace-with-keyframes",
@@ -945,6 +965,23 @@ function executeGsapMutationAcorn(
case "remove-keyframe": {
return removeKeyframeFromScript(block.scriptText, body.animationId, body.percentage);
}
case "move-keyframe": {
return moveKeyframeInScript(
block.scriptText,
body.animationId,
body.fromPercentage,
body.toPercentage,
);
}
case "resize-keyframed-tween": {
return resizeKeyframedTweenInScript(
block.scriptText,
body.animationId,
body.position,
body.duration,
body.pctRemap,
);
}
case "update-keyframe": {
return updateKeyframeInScript(
block.scriptText,
@@ -1103,6 +1140,8 @@ async function executeGsapMutationRecast(
removeAnimationFromScript,
addKeyframeToScript,
removeKeyframeFromScript,
moveKeyframeInScript,
resizeKeyframedTweenInScript,
updateKeyframeInScript,
convertToKeyframesInScript,
removeAllKeyframesFromScript,
@@ -1256,6 +1295,23 @@ async function executeGsapMutationRecast(
case "remove-keyframe": {
return removeKeyframeFromScript(block.scriptText, body.animationId, body.percentage);
}
case "move-keyframe": {
return moveKeyframeInScript(
block.scriptText,
body.animationId,
body.fromPercentage,
body.toPercentage,
);
}
case "resize-keyframed-tween": {
return resizeKeyframedTweenInScript(
block.scriptText,
body.animationId,
body.position,
body.duration,
body.pctRemap,
);
}
case "update-keyframe": {
return updateKeyframeInScript(
block.scriptText,