mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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).
781 lines
28 KiB
TypeScript
781 lines
28 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import { Hono } from "hono";
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { registerFileRoutes } from "./files";
|
|
import type { StudioApiAdapter } from "../types";
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function createProjectDir(): string {
|
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-files-test-"));
|
|
tempDirs.push(projectDir);
|
|
writeFileSync(join(projectDir, "index.html"), "<html><body>Preview</body></html>");
|
|
return projectDir;
|
|
}
|
|
|
|
function createAdapter(projectDir: string): StudioApiAdapter {
|
|
return {
|
|
listProjects: () => [],
|
|
resolveProject: async (id: string) => ({ id, dir: projectDir }),
|
|
bundle: async () => null,
|
|
lint: async () => ({ findings: [] }),
|
|
runtimeUrl: "/api/runtime.js",
|
|
rendersDir: () => "/tmp/renders",
|
|
startRender: () => ({
|
|
id: "job-1",
|
|
status: "rendering",
|
|
progress: 0,
|
|
outputPath: "/tmp/out.mp4",
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe("registerFileRoutes", () => {
|
|
it("returns empty content for missing files when caller marks the read optional", async () => {
|
|
const projectDir = createProjectDir();
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const response = await app.request(
|
|
"http://localhost/projects/demo/files/missing-file.txt?optional=1",
|
|
);
|
|
const payload = (await response.json()) as { filename?: string; content?: string };
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(payload.filename).toBe("missing-file.txt");
|
|
expect(payload.content).toBe("");
|
|
});
|
|
|
|
it("still returns 404 for other missing files", async () => {
|
|
const projectDir = createProjectDir();
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const response = await app.request("http://localhost/projects/demo/files/missing-file.txt");
|
|
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
it("backs up the previous file content before PUT overwrite", async () => {
|
|
const projectDir = createProjectDir();
|
|
writeFileSync(join(projectDir, "index.html"), "before");
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const response = await app.request("http://localhost/projects/demo/files/index.html", {
|
|
method: "PUT",
|
|
body: "after",
|
|
});
|
|
const payload = (await response.json()) as { path?: string; backupPath?: string };
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(payload.path).toBe("index.html");
|
|
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
|
|
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before");
|
|
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe("after");
|
|
});
|
|
|
|
it("backs up the previous file content before delete", async () => {
|
|
const projectDir = createProjectDir();
|
|
writeFileSync(join(projectDir, "index.html"), "before delete");
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const response = await app.request("http://localhost/projects/demo/files/index.html", {
|
|
method: "DELETE",
|
|
});
|
|
const payload = (await response.json()) as { backupPath?: string };
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
|
|
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before delete");
|
|
});
|
|
|
|
it("backs up the previous file content before structured DOM mutations", async () => {
|
|
const projectDir = createProjectDir();
|
|
writeFileSync(projectDir + "/index.html", '<div id="title">Before</div>');
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const response = await app.request(
|
|
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
target: { id: "title" },
|
|
operations: [{ type: "text-content", property: "textContent", value: "After" }],
|
|
}),
|
|
},
|
|
);
|
|
const payload = (await response.json()) as {
|
|
changed?: boolean;
|
|
path?: string;
|
|
backupPath?: string;
|
|
};
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(payload.changed).toBe(true);
|
|
expect(payload.path).toBe("index.html");
|
|
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
|
|
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe(
|
|
'<div id="title">Before</div>',
|
|
);
|
|
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After");
|
|
});
|
|
|
|
// A realistic sub-composition: markup + GSAP wrapped in a <template>, tweens
|
|
// targeting element variables resolved from querySelector, with interleaved
|
|
// gsap.set() calls. This is the shape every scaffolded composition uses.
|
|
const TEMPLATE_COMP = `<template id="scene-template">
|
|
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080" data-start="0" data-duration="3">
|
|
<div class="kicker">HELLO</div>
|
|
</div>
|
|
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
|
<script>
|
|
(function () {
|
|
window.__timelines = window.__timelines || {};
|
|
const tl = gsap.timeline({ paused: true });
|
|
const root = document.querySelector('#scene');
|
|
const kicker = root.querySelector(".kicker");
|
|
gsap.set(kicker, { y: 16, opacity: 0 });
|
|
tl.to(kicker, { y: 0, opacity: 1, duration: 0.45, ease: "expo.out" }, 0.3);
|
|
window.__timelines["scene"] = tl;
|
|
})();
|
|
</script>
|
|
</template>`;
|
|
|
|
function writeComp(projectDir: string, name: string, html: string): void {
|
|
const dir = join(projectDir, "compositions");
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(join(dir, name), html);
|
|
}
|
|
|
|
it("parses GSAP tweens from a <template>-wrapped sub-composition with variable targets", async () => {
|
|
const projectDir = createProjectDir();
|
|
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const response = await app.request(
|
|
"http://localhost/projects/demo/gsap-animations/compositions/scene.html",
|
|
);
|
|
const payload = (await response.json()) as {
|
|
animations: Array<{ id: string; targetSelector: string; properties: Record<string, number> }>;
|
|
};
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(payload.animations).toHaveLength(1);
|
|
expect(payload.animations[0].targetSelector).toBe(".kicker");
|
|
});
|
|
|
|
// A composition with a fromTo tween — used by the fromProperties mutation tests.
|
|
const FROMTO_COMP = `<!DOCTYPE html><html><body data-duration="3">
|
|
<div id="box" data-start="0" data-duration="3" style="opacity:0"></div>
|
|
<script data-hyperframes-gsap>
|
|
const tl = gsap.timeline();
|
|
tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, ease: "power2.out" }, 0);
|
|
</script>
|
|
</body></html>`;
|
|
|
|
function writeHtml(projectDir: string, name: string, html: string): void {
|
|
writeFileSync(join(projectDir, name), html);
|
|
}
|
|
|
|
async function getFirstAnimation(
|
|
app: Hono,
|
|
file: string,
|
|
): Promise<{ id: string; method: string; fromProperties?: Record<string, number | string> }> {
|
|
const res = await app.request(`http://localhost/projects/demo/gsap-animations/${file}`);
|
|
const payload = (await res.json()) as {
|
|
animations: Array<{
|
|
id: string;
|
|
method: string;
|
|
fromProperties?: Record<string, number | string>;
|
|
}>;
|
|
};
|
|
return payload.animations[0];
|
|
}
|
|
|
|
it("update-from-property updates a fromTo start value in place", async () => {
|
|
const projectDir = createProjectDir();
|
|
writeHtml(projectDir, "comp.html", FROMTO_COMP);
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const anim = await getFirstAnimation(app, "comp.html");
|
|
expect(anim.method).toBe("fromTo");
|
|
expect(anim.fromProperties?.opacity).toBe(0);
|
|
|
|
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
type: "update-from-property",
|
|
animationId: anim.id,
|
|
property: "opacity",
|
|
value: 0.2,
|
|
}),
|
|
});
|
|
const result = (await res.json()) as {
|
|
ok: boolean;
|
|
after: string;
|
|
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
|
|
};
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(result.ok).toBe(true);
|
|
expect(result.after).toContain("opacity: 0.2");
|
|
expect(result.parsed.animations[0].fromProperties?.opacity).toBe(0.2);
|
|
// x unchanged
|
|
expect(result.parsed.animations[0].fromProperties?.x).toBe(-50);
|
|
});
|
|
|
|
it("consolidate-position-writes leaves exactly one position write per selector", async () => {
|
|
const projectDir = createProjectDir();
|
|
const CORRUPTED = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
|
|
const tl = gsap.timeline({ paused: true });
|
|
tl.to("#box", { duration: 0, x: -766, y: 314, immediateRender: true }, 1.333);
|
|
gsap.set("#box", { x: -520, y: 170 });
|
|
gsap.set("#box", { rotation: 45 });
|
|
</script></body></html>`;
|
|
writeHtml(projectDir, "dup.html", CORRUPTED);
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const res = await app.request("http://localhost/projects/demo/gsap-mutations/dup.html", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ type: "consolidate-position-writes", targetSelector: "#box" }),
|
|
});
|
|
const result = (await res.json()) as {
|
|
ok: boolean;
|
|
parsed: {
|
|
animations: Array<{
|
|
targetSelector: string;
|
|
propertyGroup?: string;
|
|
properties: Record<string, unknown>;
|
|
}>;
|
|
};
|
|
};
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(result.ok).toBe(true);
|
|
const posWrites = result.parsed.animations.filter(
|
|
(a) => a.targetSelector === "#box" && a.propertyGroup === "position",
|
|
);
|
|
expect(posWrites).toHaveLength(1);
|
|
// The non-position rotation set is untouched.
|
|
expect(
|
|
result.parsed.animations.some(
|
|
(a) => a.targetSelector === "#box" && "rotation" in a.properties,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("rejects serialized non-finite mutation values before writing source", async () => {
|
|
const projectDir = createProjectDir();
|
|
writeHtml(projectDir, "comp.html", FROMTO_COMP);
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const anim = await getFirstAnimation(app, "comp.html");
|
|
const before = readFileSync(join(projectDir, "comp.html"), "utf-8");
|
|
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
type: "update-property",
|
|
animationId: anim.id,
|
|
property: "x",
|
|
value: Number.NaN,
|
|
}),
|
|
});
|
|
const payload = (await res.json()) as { error?: string; fields?: string[] };
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(payload.error).toContain("unsafe values");
|
|
expect(payload.fields).toContain("body.value");
|
|
expect(readFileSync(join(projectDir, "comp.html"), "utf-8")).toBe(before);
|
|
});
|
|
|
|
it("rejects unsafe DOM patch metadata before writing source", async () => {
|
|
const projectDir = createProjectDir();
|
|
writeFileSync(join(projectDir, "index.html"), '<div id="title">Before</div>');
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const response = await app.request(
|
|
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
target: { id: "title", selectorIndex: Number.NaN },
|
|
operations: [{ type: "text-content", property: "textContent", value: "After" }],
|
|
}),
|
|
},
|
|
);
|
|
const payload = (await response.json()) as { error?: string; fields?: string[] };
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(payload.error).toContain("unsafe values");
|
|
expect(payload.fields).toContain("body.target.selectorIndex");
|
|
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(
|
|
'<div id="title">Before</div>',
|
|
);
|
|
});
|
|
|
|
it("allows DOM patch null values used for explicit style removals", async () => {
|
|
const projectDir = createProjectDir();
|
|
writeFileSync(
|
|
join(projectDir, "index.html"),
|
|
'<div id="title" style="opacity: 1">Before</div>',
|
|
);
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const response = await app.request(
|
|
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
target: { id: "title" },
|
|
operations: [{ type: "inline-style", property: "opacity", value: null }],
|
|
}),
|
|
},
|
|
);
|
|
const payload = (await response.json()) as { changed?: boolean; content?: string };
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(payload.changed).toBe(true);
|
|
expect(payload.content).not.toContain("opacity");
|
|
});
|
|
|
|
it("update-from-property returns 400 for a non-fromTo animation", async () => {
|
|
const projectDir = createProjectDir();
|
|
const TO_COMP = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
|
|
const tl = gsap.timeline();
|
|
tl.to("#box", { opacity: 1, duration: 1 }, 0);
|
|
</script></body></html>`;
|
|
writeHtml(projectDir, "to.html", TO_COMP);
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const anim = await getFirstAnimation(app, "to.html");
|
|
expect(anim.method).toBe("to");
|
|
|
|
const res = await app.request("http://localhost/projects/demo/gsap-mutations/to.html", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
type: "update-from-property",
|
|
animationId: anim.id,
|
|
property: "opacity",
|
|
value: 0,
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("add-from-property merges a new key into existing fromProperties", async () => {
|
|
const projectDir = createProjectDir();
|
|
writeHtml(projectDir, "comp.html", FROMTO_COMP);
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const anim = await getFirstAnimation(app, "comp.html");
|
|
|
|
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
type: "add-from-property",
|
|
animationId: anim.id,
|
|
property: "scale",
|
|
defaultValue: 0.5,
|
|
}),
|
|
});
|
|
const result = (await res.json()) as {
|
|
ok: boolean;
|
|
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
|
|
};
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(result.ok).toBe(true);
|
|
// Existing keys preserved, new key added
|
|
const fp = result.parsed.animations[0].fromProperties ?? {};
|
|
expect(fp.opacity).toBe(0);
|
|
expect(fp.x).toBe(-50);
|
|
expect(fp.scale).toBe(0.5);
|
|
});
|
|
|
|
it("remove-from-property deletes one key, leaving others intact", async () => {
|
|
const projectDir = createProjectDir();
|
|
writeHtml(projectDir, "comp.html", FROMTO_COMP);
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const anim = await getFirstAnimation(app, "comp.html");
|
|
|
|
const res = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
type: "remove-from-property",
|
|
animationId: anim.id,
|
|
property: "x",
|
|
}),
|
|
});
|
|
const result = (await res.json()) as {
|
|
ok: boolean;
|
|
after: string;
|
|
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
|
|
};
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(result.ok).toBe(true);
|
|
const fp = result.parsed.animations[0].fromProperties ?? {};
|
|
expect(fp.x).toBeUndefined();
|
|
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>
|
|
const tl = gsap.timeline();
|
|
tl.to("#box", { opacity: 1, duration: 1 }, 0);
|
|
</script></body></html>`;
|
|
writeHtml(projectDir, "to.html", TO_COMP);
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const anim = await getFirstAnimation(app, "to.html");
|
|
|
|
const res = await app.request("http://localhost/projects/demo/gsap-mutations/to.html", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
type: "remove-from-property",
|
|
animationId: anim.id,
|
|
property: "opacity",
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("add mutation with fromTo method creates a fromTo tween with fromProperties", async () => {
|
|
const projectDir = createProjectDir();
|
|
const EMPTY_COMP = `<!DOCTYPE html><html><body><div id="el"></div><script data-hyperframes-gsap>
|
|
const tl = gsap.timeline();
|
|
</script></body></html>`;
|
|
writeHtml(projectDir, "empty.html", EMPTY_COMP);
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const res = await app.request("http://localhost/projects/demo/gsap-mutations/empty.html", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
type: "add",
|
|
targetSelector: "#el",
|
|
method: "fromTo",
|
|
position: 0,
|
|
duration: 0.5,
|
|
ease: "power2.out",
|
|
properties: { opacity: 1 },
|
|
fromProperties: { opacity: 0 },
|
|
}),
|
|
});
|
|
const result = (await res.json()) as {
|
|
ok: boolean;
|
|
parsed: {
|
|
animations: Array<{
|
|
method: string;
|
|
fromProperties?: Record<string, number | string>;
|
|
properties: Record<string, number | string>;
|
|
}>;
|
|
};
|
|
};
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(result.ok).toBe(true);
|
|
const anim = result.parsed.animations[0];
|
|
expect(anim.method).toBe("fromTo");
|
|
expect(anim.fromProperties?.opacity).toBe(0);
|
|
expect(anim.properties.opacity).toBe(1);
|
|
});
|
|
|
|
it("add mutation returns 400 when fromProperties provided for non-fromTo method", async () => {
|
|
const projectDir = createProjectDir();
|
|
const EMPTY_COMP = `<!DOCTYPE html><html><body><div id="el"></div><script data-hyperframes-gsap>
|
|
const tl = gsap.timeline();
|
|
</script></body></html>`;
|
|
writeHtml(projectDir, "empty.html", EMPTY_COMP);
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const res = await app.request("http://localhost/projects/demo/gsap-mutations/empty.html", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
type: "add",
|
|
targetSelector: "#el",
|
|
method: "to",
|
|
position: 0,
|
|
duration: 0.5,
|
|
ease: "power2.out",
|
|
properties: { opacity: 1 },
|
|
fromProperties: { opacity: 0 },
|
|
}),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const body = (await res.json()) as { error: string };
|
|
expect(body.error).toContain("fromProperties");
|
|
});
|
|
|
|
// A rotation-only keyframe set must strip the legacy studio rotation channel just
|
|
// as a position keyframe set strips the offset channel — otherwise --hf-studio-rotation
|
|
// double-applies on top of the new GSAP rotation tween.
|
|
it("replace-with-keyframes strips studio rotation edits for a rotation-only keyframe set", async () => {
|
|
const projectDir = createProjectDir();
|
|
const ROT_COMP = `<!DOCTYPE html><html><body data-duration="3">
|
|
<div id="box" data-start="0" data-duration="3" data-hf-studio-rotation="30" style="--hf-studio-rotation:30deg;rotate:30deg"></div>
|
|
<script data-hyperframes-gsap>
|
|
const tl = gsap.timeline();
|
|
tl.to("#box", { opacity: 1, duration: 1 }, 0);
|
|
</script>
|
|
</body></html>`;
|
|
writeHtml(projectDir, "rot.html", ROT_COMP);
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const anim = await getFirstAnimation(app, "rot.html");
|
|
const res = await app.request("http://localhost/projects/demo/gsap-mutations/rot.html", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
type: "replace-with-keyframes",
|
|
animationId: anim.id,
|
|
targetSelector: "#box",
|
|
position: 0,
|
|
duration: 1,
|
|
keyframes: [
|
|
{ percentage: 0, properties: { rotation: 0 } },
|
|
{ percentage: 100, properties: { rotation: 90 } },
|
|
],
|
|
}),
|
|
});
|
|
const result = (await res.json()) as { ok: boolean; after: string };
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(result.ok).toBe(true);
|
|
expect(result.after).not.toContain("--hf-studio-rotation");
|
|
expect(result.after).not.toContain("data-hf-studio-rotation");
|
|
});
|
|
|
|
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
|
|
const projectDir = createProjectDir();
|
|
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
|
|
const app = new Hono();
|
|
registerFileRoutes(app, createAdapter(projectDir));
|
|
|
|
const parseRes = await app.request(
|
|
"http://localhost/projects/demo/gsap-animations/compositions/scene.html",
|
|
);
|
|
const { animations } = (await parseRes.json()) as { animations: Array<{ id: string }> };
|
|
const animationId = animations[0].id;
|
|
|
|
const mutateRes = await app.request(
|
|
"http://localhost/projects/demo/gsap-mutations/compositions/scene.html",
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
type: "update-property",
|
|
animationId,
|
|
property: "opacity",
|
|
value: 0.5,
|
|
}),
|
|
},
|
|
);
|
|
const result = (await mutateRes.json()) as { ok: boolean; after: string };
|
|
|
|
expect(mutateRes.status).toBe(200);
|
|
expect(result.ok).toBe(true);
|
|
// Edit landed
|
|
expect(result.after).toContain("opacity: 0.5");
|
|
// Surrounding code preserved verbatim — the in-place AST edit didn't rewrite the block
|
|
expect(result.after).toContain("gsap.set(kicker, { y: 16, opacity: 0 })");
|
|
expect(result.after).toContain('const kicker = root.querySelector(".kicker")');
|
|
expect(result.after).toContain('window.__timelines["scene"] = tl;');
|
|
expect(result.after).toContain("(function () {");
|
|
// The variable target was not flattened to a string-literal selector
|
|
expect(result.after).toContain("tl.to(kicker,");
|
|
});
|
|
});
|