mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
* 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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user