mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-02 19:57:13 +00:00
fix(studio): an edit at the playhead rewrites its tween in place, so later tweens keep their times
This commit is contained in:
@@ -1903,7 +1903,7 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor
|
||||
tweenCallToAnimation(call, scope, script, identifierBindings),
|
||||
);
|
||||
applyTimelineDefaults(rawAnims, detection.defaults);
|
||||
resolveTimelinePositions(rawAnims);
|
||||
resolveTimelinePositions(rawAnims, collectAddLabelDefs(ast, ref, scope, calls));
|
||||
const animations = assignStableIds(rawAnims);
|
||||
const located = calls.map((call, i) => ({
|
||||
id: animations[i]!.id,
|
||||
|
||||
@@ -1790,6 +1790,54 @@ export function materializeKeyframesFromScript(
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a tween as `to()` with these keyframes where it stands: the call keeps its place in the
|
||||
* script and its position argument, so a tween placed after it ('>', '<', '+=', none) stays put.
|
||||
*/
|
||||
export function replaceTweenWithKeyframesInScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
edit: {
|
||||
targetSelector: string;
|
||||
position: number;
|
||||
duration: number;
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
auto?: boolean;
|
||||
}>;
|
||||
ease?: string;
|
||||
easeEach?: string;
|
||||
},
|
||||
): string | null {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
const target = parsed?.located.find((l) => l.id === animationId);
|
||||
const call = target?.call;
|
||||
if (!target || call?.varsArg?.type !== "ObjectExpression") return null;
|
||||
const { animation } = target;
|
||||
if (animation.method !== "to" && animation.method !== "from" && animation.method !== "fromTo")
|
||||
return null;
|
||||
const ms = new MagicString(script);
|
||||
const start = animation.resolvedStart ?? animation.position;
|
||||
const moved = typeof start !== "number" || Math.abs(start - edit.position) > 5e-4;
|
||||
const kept = preservedVarsEntries(call.varsArg, script).filter(
|
||||
(e) => !/^\s*duration\s*:/.test(e) && !(moved && /^\s*delay\s*:/.test(e)),
|
||||
);
|
||||
const sorted = [...edit.keyframes].sort((a, b) => a.percentage - b.percentage);
|
||||
const parts = [`keyframes: ${buildKeyframeObjectCode(sorted, edit.easeEach)}`, ...kept];
|
||||
parts.push(`duration: ${valueToCode(edit.duration)}`);
|
||||
if (edit.ease) parts.push(`ease: ${JSON.stringify(edit.ease)}`);
|
||||
if (animation.hasUnresolvedSelector || animation.targetSelector !== edit.targetSelector) {
|
||||
const selectorArg = call.node.arguments[0];
|
||||
ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(edit.targetSelector));
|
||||
}
|
||||
convertMethodToTo(ms, animation, call, call.varsArg);
|
||||
overwriteVarsArg(ms, call, `{ ${parts.join(", ")} }`);
|
||||
if (moved) overwritePosition(ms, call, edit.position);
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
// ── Add animation with keyframes ──────────────────────────────────────────────
|
||||
|
||||
/** Insert a new keyframed `to()` call and return the new animation ID. */
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { registerFileRoutes } from "./files";
|
||||
import type { StudioApiAdapter } from "../types";
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
interface Anim {
|
||||
id: string;
|
||||
targetSelector: string;
|
||||
resolvedStart?: number;
|
||||
method: string;
|
||||
}
|
||||
|
||||
const notHold = (a: Anim) => a.method !== "set";
|
||||
|
||||
function app(script: string): Hono {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-replace-in-place-"));
|
||||
dirs.push(dir);
|
||||
const html = `<!DOCTYPE html><html><body data-duration="10">
|
||||
<div id="a"></div><div id="b"></div><div id="c"></div>
|
||||
<script data-hyperframes-gsap>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
${script}
|
||||
window.__timelines = { main: tl };
|
||||
</script>
|
||||
</body></html>`;
|
||||
writeFileSync(join(dir, "comp.html"), html);
|
||||
const hono = new Hono();
|
||||
registerFileRoutes(hono, {
|
||||
listProjects: () => [],
|
||||
resolveProject: async (id: string) => ({ id, dir }),
|
||||
bundle: async () => null,
|
||||
lint: async () => ({ findings: [] }),
|
||||
runtimeUrl: "/api/runtime.js",
|
||||
rendersDir: () => "/tmp/renders",
|
||||
startRender: () => ({
|
||||
id: "job-1",
|
||||
status: "rendering",
|
||||
progress: 0,
|
||||
outputPath: "/tmp/o.mp4",
|
||||
}),
|
||||
} as StudioApiAdapter);
|
||||
return hono;
|
||||
}
|
||||
|
||||
async function animations(hono: Hono): Promise<Anim[]> {
|
||||
const res = await hono.request("http://localhost/projects/demo/gsap-animations/comp.html");
|
||||
return ((await res.json()) as { animations: Anim[] }).animations;
|
||||
}
|
||||
|
||||
// The script's calls, without the hold sets the server keeps before a first position keyframe.
|
||||
const calls = (code: string) =>
|
||||
code.split("\n").filter((line) => line.startsWith("tl.") && !line.includes("hf-hold"));
|
||||
|
||||
/** A move on `selector` at the playhead, sent the way the playhead writer sends it. */
|
||||
async function moveAtPlayhead(script: string, selector: string) {
|
||||
const hono = app(script);
|
||||
const before = await animations(hono);
|
||||
const edited = before.find((a) => a.targetSelector === selector)!;
|
||||
const res = await hono.request("http://localhost/projects/demo/gsap-mutations/comp.html", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "replace-with-keyframes",
|
||||
animationId: edited.id,
|
||||
targetSelector: selector,
|
||||
position: edited.resolvedStart,
|
||||
duration: 1,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 40 } },
|
||||
{ percentage: 100, properties: { x: 100 } },
|
||||
],
|
||||
easeEach: "none",
|
||||
}),
|
||||
});
|
||||
const { after: html } = (await res.json()) as { after: string };
|
||||
const after = await animations(hono);
|
||||
const starts = (list: Anim[]) => list.map((a) => [a.targetSelector, a.resolvedStart]);
|
||||
return { html, starts: starts(after.filter(notHold)), was: starts(before) };
|
||||
}
|
||||
|
||||
describe("an edit at the playhead rewrites its tween where it stands", () => {
|
||||
it.each([
|
||||
[
|
||||
"no position argument",
|
||||
`tl.to("#a", { x: 100, duration: 1 });\ntl.to("#b", { y: 50, duration: 1 });`,
|
||||
],
|
||||
[
|
||||
"'<' and '+='",
|
||||
`tl.to("#a", { x: 100, duration: 1 }, 1);\ntl.to("#b", { y: 50, duration: 1 }, "<");\ntl.to("#c", { y: 9, duration: 1 }, "+=0.5");`,
|
||||
],
|
||||
[
|
||||
"'>'",
|
||||
`tl.to("#a", { x: 100, duration: 1 }, 0.5);\ntl.to("#b", { y: 50, duration: 1 }, ">");`,
|
||||
],
|
||||
[
|
||||
"a label",
|
||||
`tl.addLabel("intro", 2);\ntl.to("#a", { x: 100, duration: 1 }, "intro");\ntl.to("#b", { y: 50, duration: 1 }, "intro+=0.5");`,
|
||||
],
|
||||
[
|
||||
"a from() entrance",
|
||||
`tl.from("#a", { x: -60, duration: 1 });\ntl.to("#b", { y: 50, duration: 1 });`,
|
||||
],
|
||||
])("leaves every other tween's source and start alone after %s", async (_, script) => {
|
||||
const { html, starts, was } = await moveAtPlayhead(script, "#a");
|
||||
|
||||
expect(starts).toEqual(was);
|
||||
const others = calls(script).filter((line) => !line.includes('"#a"'));
|
||||
expect(calls(html).filter((line) => !line.includes('"#a"'))).toEqual(others);
|
||||
expect(calls(html).findIndex((line) => line.includes('"#a"'))).toBe(
|
||||
calls(script).findIndex((line) => line.includes('"#a"')),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a chained call's next link in place", async () => {
|
||||
const script = `tl.to("#a", { x: 100, duration: 1 }).to("#b", { y: 50, duration: 1 });`;
|
||||
const { html, starts, was } = await moveAtPlayhead(script, "#a");
|
||||
|
||||
expect(starts).toEqual(was);
|
||||
expect(calls(html)).toHaveLength(1);
|
||||
expect(calls(html)[0]).toMatch(
|
||||
/^tl\.to\("#a", \{ keyframes: .*\}\)\.to\("#b", \{ y: 50, duration: 1 \}\);$/,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a relative one",
|
||||
`tl.to("#b", { y: 50, duration: 1 }, 0);\ntl.to("#a", { x: 100, duration: 1 }, "<");`,
|
||||
`"<");`,
|
||||
],
|
||||
[
|
||||
"a label",
|
||||
`tl.addLabel("intro", 2);\ntl.to("#a", { x: 100, duration: 1 }, "intro");`,
|
||||
`"intro");`,
|
||||
],
|
||||
[
|
||||
"the last tween's number",
|
||||
`tl.to("#b", { y: 50, duration: 1 }, 0);\ntl.to("#a", { x: 100, duration: 1 }, 1);`,
|
||||
`}, 1);`,
|
||||
],
|
||||
])("keeps the edited tween's own position argument when it is %s", async (_, script, tail) => {
|
||||
const { html, starts, was } = await moveAtPlayhead(script, "#a");
|
||||
|
||||
expect(starts).toEqual(was);
|
||||
const edited = calls(html).find((line) => line.includes('"#a"'))!;
|
||||
expect(edited.endsWith(tail)).toBe(true);
|
||||
expect(calls(html).indexOf(edited)).toBe(calls(script).findIndex((l) => l.includes('"#a"')));
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
addMotionPathToScript,
|
||||
removeArcPathFromScript,
|
||||
addAnimationWithKeyframesToScript,
|
||||
replaceTweenWithKeyframesInScript,
|
||||
splitAnimationsInScript,
|
||||
splitIntoPropertyGroupsFromScript,
|
||||
shiftPositionsInScript,
|
||||
@@ -1203,6 +1204,25 @@ export type GsapMutationRequest =
|
||||
|
||||
type GsapMutationResult = string | { script: string; skippedSelectors: string[] };
|
||||
|
||||
function replaceInPlaceUnlessSetOrUnknown(
|
||||
scriptText: string,
|
||||
body: Extract<GsapMutationRequest, { type: "replace-with-keyframes" }>,
|
||||
): string {
|
||||
const edit = { ...body, easeEach: resolveReplacementEaseEach(scriptText, body) };
|
||||
const inPlace = replaceTweenWithKeyframesInScript(scriptText, body.animationId, edit);
|
||||
if (inPlace !== null) return inPlace;
|
||||
const script = removeAnimationFromScript(scriptText, body.animationId);
|
||||
return addAnimationWithKeyframesToScript(
|
||||
script,
|
||||
body.targetSelector,
|
||||
body.position,
|
||||
body.duration,
|
||||
body.keyframes,
|
||||
body.ease,
|
||||
edit.easeEach,
|
||||
).script;
|
||||
}
|
||||
|
||||
function resolveReplacementEaseEach(
|
||||
scriptText: string,
|
||||
request: { animationId: string; easeEach?: string },
|
||||
@@ -1679,17 +1699,7 @@ function executeGsapMutationAcorn(
|
||||
if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) {
|
||||
stripStudioEditsFromTarget(block.document, body.targetSelector);
|
||||
}
|
||||
const script = removeAnimationFromScript(block.scriptText, body.animationId);
|
||||
const added = addAnimationWithKeyframesToScript(
|
||||
script,
|
||||
body.targetSelector,
|
||||
body.position,
|
||||
body.duration,
|
||||
body.keyframes,
|
||||
body.ease,
|
||||
resolveReplacementEaseEach(block.scriptText, body),
|
||||
);
|
||||
return added.script;
|
||||
return replaceInPlaceUnlessSetOrUnknown(block.scriptText, body);
|
||||
}
|
||||
case "split-animations": {
|
||||
if (
|
||||
@@ -2051,17 +2061,7 @@ async function executeGsapMutationRecast(
|
||||
if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) {
|
||||
stripStudioEditsFromTarget(block.document, body.targetSelector);
|
||||
}
|
||||
const script = removeAnimationFromScript(block.scriptText, body.animationId);
|
||||
const added = addAnimationWithKeyframesToScript(
|
||||
script,
|
||||
body.targetSelector,
|
||||
body.position,
|
||||
body.duration,
|
||||
body.keyframes,
|
||||
body.ease,
|
||||
resolveReplacementEaseEach(block.scriptText, body),
|
||||
);
|
||||
return added.script;
|
||||
return replaceInPlaceUnlessSetOrUnknown(block.scriptText, body);
|
||||
}
|
||||
case "split-animations": {
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user