diff --git a/packages/core/src/lint/rules/gsap.test.ts b/packages/core/src/lint/rules/gsap.test.ts
index af649ed614..89a70b5247 100644
--- a/packages/core/src/lint/rules/gsap.test.ts
+++ b/packages/core/src/lint/rules/gsap.test.ts
@@ -978,106 +978,6 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
- // gsap_studio_edit_blocked
- it("warns when script registers timeline AND has GSAP tweens targeting #id selectors", async () => {
- const html = `
-
-
-
-
-`;
- const result = await lintHyperframeHtml(html);
- const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
- expect(finding).toBeDefined();
- expect(finding?.severity).toBe("warning");
- expect(finding?.message).toContain('"#headline"');
- });
-
- it("warns when script registers timeline AND has GSAP tweens targeting .class selectors", async () => {
- const html = `
-
-
-
-
-`;
- const result = await lintHyperframeHtml(html);
- const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
- expect(finding).toBeDefined();
- expect(finding?.message).toContain('".box"');
- });
-
- it("does NOT warn when timeline is registered but no GSAP element selectors are called", async () => {
- const html = `
-
-
-
-
-`;
- const result = await lintHyperframeHtml(html);
- const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
- expect(finding).toBeUndefined();
- });
-
- it("does NOT warn when script has GSAP calls but does not register on window.__timelines", async () => {
- const html = `
-
-
-
-
-`;
- const result = await lintHyperframeHtml(html);
- const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
- expect(finding).toBeUndefined();
- });
-
- it("lists all unique targeted selectors in the warning message", async () => {
- const html = `
-
-
-
-
-`;
- const result = await lintHyperframeHtml(html);
- const finding = result.findings.find((f) => f.code === "gsap_studio_edit_blocked");
- expect(finding).toBeDefined();
- expect(finding?.message).toContain('"#title"');
- expect(finding?.message).toContain('"#sub"');
- });
-
it("scene_layer_missing_visibility_kill: fires when multi-scene exit lacks hard kill", async () => {
const html = `
diff --git a/packages/core/src/lint/rules/gsap.ts b/packages/core/src/lint/rules/gsap.ts
index 4e2d67b743..257d01af69 100644
--- a/packages/core/src/lint/rules/gsap.ts
+++ b/packages/core/src/lint/rules/gsap.ts
@@ -27,7 +27,6 @@ import {
truncateSnippet,
stripJsComments,
WINDOW_TIMELINE_ASSIGN_PATTERN,
- TIMELINE_REGISTRY_ASSIGN_PATTERN,
} from "../utils";
// ── GSAP-specific types ────────────────────────────────────────────────────
@@ -855,39 +854,4 @@ export const gsapRules: LintRule[] = [
}
return findings;
},
-
- // gsap_studio_edit_blocked
- // When a script both registers a timeline on window.__timelines AND contains
- // GSAP mutation calls targeting element selectors, Studio's isElementGsapTargeted
- // check returns true for those elements and silently skips saving drag/resize
- // position changes back to source HTML.
- ({ scripts }) => {
- const findings: HyperframeLintFinding[] = [];
- const GSAP_MUTATION_SELECTOR_RE = /\.\s*(?:set|to|from|fromTo)\s*\(\s*["']([#.][^"']+)["']/g;
-
- for (const script of scripts) {
- const content = stripJsComments(script.content);
- if (!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(content)) continue;
-
- const targets = new Set();
- let match: RegExpExecArray | null;
- const re = new RegExp(GSAP_MUTATION_SELECTOR_RE.source, "g");
- while ((match = re.exec(content)) !== null) {
- if (match[1]) targets.add(match[1]);
- }
- if (targets.size === 0) continue;
-
- const selList = [...targets].map((s) => `"${s}"`).join(", ");
- findings.push({
- code: "gsap_studio_edit_blocked",
- severity: "warning",
- message: `GSAP tweens target ${selList} in a registered timeline. Studio cannot save drag/resize edits to these elements — the runtime skips write-back for any element that appears in a registered window.__timelines timeline.`,
- fixHint:
- "The hyperframes runtime registers timelines automatically. Do not add a manual window.__timelines script unless GSAP intentionally controls element positions. " +
- "For initial visibility states, use CSS (e.g. opacity:0) instead of gsap.set(). " +
- "If GSAP must own these elements' positions, avoid drag-editing them in Studio.",
- });
- }
- return findings;
- },
];
diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts
index 47eac2c40d..3d785c6426 100644
--- a/packages/core/src/parsers/gsapParser.test.ts
+++ b/packages/core/src/parsers/gsapParser.test.ts
@@ -2002,6 +2002,31 @@ describe("keyframe mutations", () => {
expect(updated).toBe("const x = 1;");
});
+ it("addMotionPathToScript + hold-sync — holds (0,0) at t=0 when authored past t=0", () => {
+ // A motionPath authored at position > 0 parses with a first keyframe of (0,0).
+ // Without a pre-tween hold the element would snap to its CSS home at frame 0 and
+ // jump when the tween starts — this is why `add-motion-path` is hold-synced.
+ const script = `const tl = gsap.timeline({ paused: true });`;
+ const { script: withPath } = addMotionPathToScript(script, "#el", 2.0, 1.5, {
+ x: 300,
+ y: -100,
+ });
+ const synced = syncPositionHoldsBeforeKeyframes(withPath);
+ const hold = parseGsapScript(synced).animations.find((a) => a.method === "set");
+ expect(hold).toBeDefined();
+ expect(hold!.position).toBe(0);
+ expect(hold!.properties).toMatchObject({ x: 0, y: 0 });
+ });
+
+ it("addMotionPathToScript + hold-sync — adds no hold when authored at t=0", () => {
+ const script = `const tl = gsap.timeline({ paused: true });`;
+ const { script: withPath } = addMotionPathToScript(script, "#el", 0, 1.5, {
+ x: 300,
+ y: -100,
+ });
+ expect(syncPositionHoldsBeforeKeyframes(withPath)).not.toContain("hf-hold");
+ });
+
// ── convertToKeyframesInScript ──────────────────────────────────────────
it("convertToKeyframesInScript — converts flat to() tween", () => {
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts
index bdc0e2c3a2..f3962140c5 100644
--- a/packages/core/src/runtime/init.ts
+++ b/packages/core/src/runtime/init.ts
@@ -410,23 +410,6 @@ export function initSandboxRuntimeModular(): void {
return resolveStartForElement(element, fallback);
};
- const findTimedClipAncestor = (
- element: HTMLElement,
- rootComp: HTMLElement | null,
- ): HTMLElement | null => {
- let node = element.parentElement;
- while (node) {
- // rootComp may be null when no composition is mounted; the walk still
- // terminates via `while (node)` — node === null is never true here.
- if (node === rootComp) break;
- if (node.hasAttribute("data-start")) {
- return node;
- }
- node = node.parentElement;
- }
- return null;
- };
-
const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => {
const tag = rawNode.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") {
@@ -1073,6 +1056,21 @@ export function initSandboxRuntimeModular(): void {
const dur = String(rootDuration > 0 ? rootDuration : 1);
const seen = new Set();
+ // Only an AUTHORED clip (data-start already in the source, captured before
+ // we stamp anything) should suppress stamping its descendants. An animated
+ // scene container we auto-stamp below (e.g. an opacity-crossfaded scene)
+ // must NOT suppress its own animated children — otherwise those children
+ // never become timeline clips and that scene can't inline-expand.
+ const authoredTimed = new Set(document.querySelectorAll("[data-start]"));
+ const hasAuthoredTimedAncestor = (element: HTMLElement): boolean => {
+ let node = element.parentElement;
+ while (node && node !== rootComp) {
+ if (authoredTimed.has(node)) return true;
+ node = node.parentElement;
+ }
+ return false;
+ };
+
// Stamp GSAP-targeted elements
if (state.capturedTimeline.getChildren) {
try {
@@ -1082,7 +1080,7 @@ export function initSandboxRuntimeModular(): void {
if (!(target instanceof HTMLElement)) continue;
if (target === rootComp) continue;
if (target.hasAttribute("data-start")) continue;
- if (findTimedClipAncestor(target, rootComp)) continue;
+ if (hasAuthoredTimedAncestor(target)) continue;
if (seen.has(target)) continue;
seen.add(target);
target.setAttribute("data-start", "0");
@@ -1102,7 +1100,7 @@ export function initSandboxRuntimeModular(): void {
if (!(el instanceof HTMLElement)) continue;
if (el === rootComp) continue;
if (el.hasAttribute("data-start")) continue;
- if (findTimedClipAncestor(el, rootComp)) continue;
+ if (hasAuthoredTimedAncestor(el)) continue;
if (seen.has(el)) continue;
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
seen.add(el);
@@ -1439,6 +1437,46 @@ export function initSandboxRuntimeModular(): void {
};
// fallow-ignore-next-line complexity
+ // Whether a timed clip participates in normal flow (static/relative/sticky).
+ // In-flow clips must leave the flow when hidden — `visibility:hidden` reserves
+ // their layout box, so a split sibling would stack below the active half
+ // instead of overlapping it. Positioned clips keep `visibility:hidden` (cheaper,
+ // and avoids disturbing absolute media playback). Computed once per element.
+ let timedClipInFlow = new WeakMap();
+ const isTimedClipInFlow = (el: HTMLElement): boolean => {
+ const cached = timedClipInFlow.get(el);
+ if (cached !== undefined) return cached;
+ const pos = window.getComputedStyle(el).position;
+ const inFlow = pos === "static" || pos === "relative" || pos === "sticky";
+ timedClipInFlow.set(el, inFlow);
+ return inFlow;
+ };
+
+ // `display:none` is only safe on a LEAF timed clip (no nested timed clips). On a
+ // container it removes the whole subtree, hiding descendants that are still inside
+ // their OWN visibility window — e.g. an in-flow composition root whose window
+ // clamps to the timeline end would black out a child video that should still
+ // show. `visibility:hidden` doesn't have this problem (a child can override it
+ // with `visibility:visible`), so containers keep that and only leaves leave-flow.
+ let timedClipIsLeaf = new WeakMap();
+ const isTimedClipLeaf = (el: HTMLElement): boolean => {
+ const cached = timedClipIsLeaf.get(el);
+ if (cached !== undefined) return cached;
+ const leaf = el.querySelector("[data-start]") === null;
+ timedClipIsLeaf.set(el, leaf);
+ return leaf;
+ };
+
+ // Both caches key on live DOM facts that change when the timed-element set
+ // changes: leaf status flips when a clip gains/loses a nested `[data-start]`
+ // descendant (sub-composition load/unload, studio insert/delete), and a swapped
+ // element can reuse an identity whose in-flow status differs. WeakMap has no
+ // `clear()`, so drop both maps wholesale — re-derived lazily on next access.
+ const invalidateTimedClipCaches = () => {
+ timedClipInFlow = new WeakMap();
+ timedClipIsLeaf = new WeakMap();
+ };
+
const syncMediaForCurrentState = () => {
const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => {
const compositionRoot = element.closest("[data-composition-id]");
@@ -1544,6 +1582,11 @@ export function initSandboxRuntimeModular(): void {
if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow);
}
+ if (isVisibleNow) {
+ if (isTimedClipInFlow(rawNode)) rawNode.style.removeProperty("display");
+ } else if (isTimedClipInFlow(rawNode) && isTimedClipLeaf(rawNode)) {
+ rawNode.style.display = "none";
+ }
}
};
@@ -1605,6 +1648,10 @@ export function initSandboxRuntimeModular(): void {
window.__clipManifest = payload;
const currentSignature = computeClipTreeSignature();
+ if (clipTreeSignature !== currentSignature) {
+ // The timed-element set changed — leaf/in-flow caches may be stale.
+ invalidateTimedClipCaches();
+ }
if (!window.__clipTree || clipTreeSignature !== currentSignature) {
const runtimeWindow = window as Window & {
__timelines?: Record;
diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts
index 06bb7155e4..a0f3dedcb1 100644
--- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts
+++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts
@@ -508,6 +508,28 @@ describe("splitElementInHtml", () => {
expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false);
});
+ it("splits a GSAP element with no authored timing using fallback timing", () => {
+ // #title has no data-start/data-duration (GSAP-driven); the store supplies the range.
+ const gsapSource = `Hi
`;
+ const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", {
+ start: 0,
+ duration: 6,
+ });
+ expect(result.matched).toBe(true);
+ // original windowed to [0, 2], clone to [2, 4] (attribute order is serializer-defined)
+ const original = result.html.match(/]*\bid="title"[^>]*>/)![0];
+ expect(original).toContain('data-start="0"');
+ expect(original).toContain('data-duration="2"');
+ const clone = result.html.match(/]*\bid="title-split"[^>]*>/)![0];
+ expect(clone).toContain('data-start="2"');
+ expect(clone).toContain('data-duration="4"');
+ });
+
+ it("still rejects a no-timing element when no fallback timing is given", () => {
+ const gsapSource = `
Hi
`;
+ expect(splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split").matched).toBe(false);
+ });
+
it("adjusts media playback-start for the second half", () => {
const mediaSource = source.replace(
'id="box" class="clip" data-start="1" data-duration="6"',
diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts
index ddbf1c1036..727cd326a2 100644
--- a/packages/core/src/studio-api/helpers/sourceMutation.ts
+++ b/packages/core/src/studio-api/helpers/sourceMutation.ts
@@ -292,12 +292,23 @@ export function splitElementInHtml(
target: SourceMutationTarget,
splitTime: number,
newId: string,
+ fallbackTiming?: { start: number; duration: number },
): SplitElementResult {
const { document, wrappedFragment } = parseSourceDocument(source);
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null };
- const { start, duration, usesDataEnd } = resolveElementTiming(el);
+ const timing = resolveElementTiming(el);
+ const { usesDataEnd } = timing;
+ let { start, duration } = timing;
+ // GSAP-animated elements carry their timing in the script, not in data-* attrs,
+ // so the source has no authored duration. Fall back to the store's (GSAP-derived)
+ // range — the runtime windows visibility off data-start/data-duration regardless
+ // of class, so stamping both halves below makes each half show only in its window.
+ if (duration <= 0 && fallbackTiming && fallbackTiming.duration > 0) {
+ start = fallbackTiming.start;
+ duration = fallbackTiming.duration;
+ }
if (duration <= 0 || splitTime <= start || splitTime >= start + duration) {
return { html: source, matched: false, newId: null };
}
@@ -316,6 +327,9 @@ export function splitElementInHtml(
const clone = el.cloneNode(true) as HTMLElement;
clone.setAttribute("id", newId);
clone.removeAttribute("data-hf-id");
+ // Descendants carry their own data-hf-id; leaving them duplicates the id of
+ // every nested node (e.g. an inner ), so strip them on the clone too.
+ for (const node of clone.querySelectorAll("[data-hf-id]")) node.removeAttribute("data-hf-id");
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
setElementDuration(clone, splitTime, secondDuration, usesDataEnd);
@@ -344,7 +358,9 @@ export function splitElementInHtml(
duplicateCssRulesForId(document, originalId, newId);
}
- // Trim the original element's duration
+ // Trim the original element's duration. A GSAP element had no data-start; stamp
+ // it so the runtime windows the first half (visibility selects on [data-start]).
+ el.setAttribute("data-start", String(Math.round(start * 1000) / 1000));
setElementDuration(el, start, firstDuration, usesDataEnd);
// Insert clone after original
diff --git a/packages/core/src/studio-api/routes/files.test.ts b/packages/core/src/studio-api/routes/files.test.ts
index 302831dba5..c65238a089 100644
--- a/packages/core/src/studio-api/routes/files.test.ts
+++ b/packages/core/src/studio-api/routes/files.test.ts
@@ -503,6 +503,46 @@ const tl = gsap.timeline();
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 = `
+
+
+`;
+ 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);
diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts
index 0ce977592d..b6b288b540 100644
--- a/packages/core/src/studio-api/routes/files.ts
+++ b/packages/core/src/studio-api/routes/files.ts
@@ -28,6 +28,7 @@ import {
type UnsafeMutationValue,
} from "../helpers/finiteMutation.js";
import type { GsapAnimation } from "../../parsers/gsapSerialize.js";
+import { classifyPropertyGroup } from "../../parsers/gsapConstants.js";
import { parseGsapScriptAcorn } from "../../parsers/gsapParserAcorn.js";
import { unrollComputedTimeline } from "../../parsers/gsapUnroll.js";
import {
@@ -315,21 +316,49 @@ function stripStudioEditsFromTarget(document: Document, selector: string): numbe
let stripped = 0;
try {
for (const el of document.querySelectorAll(selector)) {
- if (!el.getAttribute("data-hf-studio-path-offset")) continue;
if (!isHTMLElement(el)) continue;
const htmlEl = el;
- const originalTranslate = el.getAttribute("data-hf-studio-original-inline-translate");
- htmlEl.style.removeProperty("--hf-studio-offset-x");
- htmlEl.style.removeProperty("--hf-studio-offset-y");
- if (originalTranslate) {
- htmlEl.style.setProperty("translate", originalTranslate);
- } else {
- htmlEl.style.removeProperty("translate");
+ let touched = false;
+ // Manual path offset (--hf-studio-offset / translate) — a GSAP position tween
+ // now owns position, so the stale offset channel must go.
+ if (el.getAttribute("data-hf-studio-path-offset")) {
+ const originalTranslate = el.getAttribute("data-hf-studio-original-inline-translate");
+ htmlEl.style.removeProperty("--hf-studio-offset-x");
+ htmlEl.style.removeProperty("--hf-studio-offset-y");
+ if (originalTranslate) {
+ htmlEl.style.setProperty("translate", originalTranslate);
+ } else {
+ htmlEl.style.removeProperty("translate");
+ }
+ el.removeAttribute("data-hf-studio-path-offset");
+ el.removeAttribute("data-hf-studio-original-translate");
+ el.removeAttribute("data-hf-studio-original-inline-translate");
+ touched = true;
+ }
+ // Manual rotation (--hf-studio-rotation / rotate) — likewise, a GSAP rotation
+ // set/tween now owns rotation, so clear the legacy CSS-var channel.
+ if (el.getAttribute("data-hf-studio-rotation")) {
+ const originalRotate = el.getAttribute("data-hf-studio-original-inline-rotate");
+ const originalOrigin = el.getAttribute("data-hf-studio-original-rotation-transform-origin");
+ htmlEl.style.removeProperty("--hf-studio-rotation");
+ if (originalRotate) {
+ htmlEl.style.setProperty("rotate", originalRotate);
+ } else {
+ htmlEl.style.removeProperty("rotate");
+ }
+ if (originalOrigin) {
+ htmlEl.style.setProperty("transform-origin", originalOrigin);
+ } else {
+ htmlEl.style.removeProperty("transform-origin");
+ }
+ el.removeAttribute("data-hf-studio-rotation");
+ el.removeAttribute("data-hf-studio-rotation-draft");
+ el.removeAttribute("data-hf-studio-original-rotate");
+ el.removeAttribute("data-hf-studio-original-inline-rotate");
+ el.removeAttribute("data-hf-studio-original-rotation-transform-origin");
+ touched = true;
}
- el.removeAttribute("data-hf-studio-path-offset");
- el.removeAttribute("data-hf-studio-original-translate");
- el.removeAttribute("data-hf-studio-original-inline-translate");
- stripped++;
+ if (touched) stripped++;
}
} catch {
// Invalid selector — skip silently.
@@ -337,6 +366,30 @@ function stripStudioEditsFromTarget(document: Document, selector: string): numbe
return stripped;
}
+// A studio path-offset (--hf-studio-offset / data-hf-studio-path-offset) and a GSAP
+// position tween both drive translate — keeping both stacks the offsets (a gesture or
+// drag recorded over a stale offset plays shoved off-position). When a committed tween
+// writes a position property, the tween owns position, so the stale offset must go.
+function keyframesWritePosition(
+ keyframes: Array<{ properties: Record }>,
+): boolean {
+ return keyframes.some((kf) =>
+ Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
+ );
+}
+
+// A studio rotation edit (--hf-studio-rotation / data-hf-studio-rotation) and a GSAP
+// rotation tween both drive rotate — keeping both stacks them. When a committed keyframe
+// set writes a rotation property, the tween owns rotation, so the stale CSS-var channel
+// must go (the position twin of this is `keyframesWritePosition`).
+function keyframesWriteRotation(
+ keyframes: Array<{ properties: Record }>,
+): boolean {
+ return keyframes.some((kf) =>
+ Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "rotation"),
+ );
+}
+
function lastKeyframeOpacity(kfs: GsapAnimation["keyframes"]): number | string | undefined {
if (!kfs) return undefined;
for (let i = kfs.keyframes.length - 1; i >= 0; i--) {
@@ -468,6 +521,24 @@ type GsapMutationRequest =
cp1?: { x: number; y: number };
cp2?: { x: number; y: number };
}
+ | {
+ type: "update-motion-path-point";
+ animationId: string;
+ pointIndex: number;
+ x: number;
+ y: number;
+ }
+ | { type: "add-motion-path-point"; animationId: string; index: number; x: number; y: number }
+ | { type: "remove-motion-path-point"; animationId: string; index: number }
+ | {
+ type: "add-motion-path";
+ targetSelector: string;
+ position: number;
+ duration: number;
+ x: number;
+ y: number;
+ ease?: string;
+ }
| { type: "remove-arc-path"; animationId: string }
| {
type: "add-with-keyframes";
@@ -535,6 +606,41 @@ type GsapMutationRequest =
type GsapMutationResult = string | { script: string; skippedSelectors: string[] };
+// Mutations that can change a position tween's first keyframe (value/existence/timing)
+// and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards.
+// `syncPositionHoldsBeforeKeyframes` rebuilds all `hf-hold` sets from scratch: it acts
+// on every tween that has keyframes whose first percentage carries a position prop and
+// whose start is > 0. So any mutation that creates such a tween, retargets it, or moves
+// its start across the t=0 boundary must trigger a re-sync.
+const HOLD_SYNC_MUTATION_TYPES = new Set([
+ "add-keyframe",
+ "update-keyframe",
+ "remove-keyframe",
+ "remove-all-keyframes",
+ "add-with-keyframes",
+ "replace-with-keyframes",
+ "convert-to-keyframes",
+ "materialize-keyframes",
+ "update-motion-path-point",
+ "add-motion-path-point",
+ "remove-motion-path-point",
+ // Authors a fresh motionPath tween whose parsed first keyframe is (0,0); if it lands
+ // at position > 0 the element snaps home at t=0 without a pre-tween hold-`set`.
+ "add-motion-path",
+ // Can move a tween's `position` (start) across the t=0 boundary, which flips whether a
+ // keyframed position tween needs a hold (started at 0 → moved later, or vice versa).
+ "update-meta",
+ // Time-shift / time-scale tweens, which can move a keyframed position tween's start
+ // across t=0, flipping hold need; stale holds are not repositioned by these ops.
+ "shift-positions",
+ "scale-positions",
+ // Retargets keyframed position tweens to a cloned element's selector; the old hold is
+ // keyed to the prior selector, so holds must be rebuilt for the new target.
+ "split-animations",
+ "delete",
+ "delete-all-for-selector",
+]);
+
async function executeGsapMutation(
body: GsapMutationRequest,
block: NonNullable>,
@@ -824,6 +930,10 @@ async function executeGsapMutationRecast(
unrollDynamicAnimations,
setArcPathInScript,
updateArcSegmentInScript,
+ updateMotionPathPointInScript,
+ addMotionPathPointInScript,
+ removeMotionPathPointInScript,
+ addMotionPathToScript,
removeArcPathFromScript,
addAnimationWithKeyframesToScript,
splitAnimationsInScript,
@@ -877,6 +987,17 @@ async function executeGsapMutationRecast(
if (body.fromProperties && body.method !== "fromTo") {
return respond({ error: "fromProperties is only valid for method=fromTo" }, 400);
}
+ // A new position/rotation animation owns that channel — strip the matching
+ // legacy studio CSS var (--hf-studio-offset / --hf-studio-rotation) so it can't
+ // double with the tween, matching add-with-keyframes/replace-with-keyframes.
+ if (
+ Object.keys(body.properties).some((k) => {
+ const group = classifyPropertyGroup(k);
+ return group === "position" || group === "rotation";
+ })
+ ) {
+ stripStudioEditsFromTarget(block.document, body.targetSelector);
+ }
const result = addAnimationToScript(block.scriptText, {
targetSelector: body.targetSelector,
method: body.method,
@@ -987,10 +1108,39 @@ async function executeGsapMutationRecast(
...(body.cp2 ? { cp2: body.cp2 } : {}),
});
}
+ case "update-motion-path-point": {
+ return updateMotionPathPointInScript(block.scriptText, body.animationId, body.pointIndex, {
+ x: body.x,
+ y: body.y,
+ });
+ }
+ case "add-motion-path-point": {
+ return addMotionPathPointInScript(block.scriptText, body.animationId, body.index, {
+ x: body.x,
+ y: body.y,
+ });
+ }
+ case "remove-motion-path-point": {
+ return removeMotionPathPointInScript(block.scriptText, body.animationId, body.index);
+ }
+ case "add-motion-path": {
+ const result = addMotionPathToScript(
+ block.scriptText,
+ body.targetSelector,
+ body.position,
+ body.duration,
+ { x: body.x, y: body.y },
+ body.ease,
+ );
+ return result.script;
+ }
case "remove-arc-path": {
return removeArcPathFromScript(block.scriptText, body.animationId);
}
case "add-with-keyframes": {
+ if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) {
+ stripStudioEditsFromTarget(block.document, body.targetSelector);
+ }
const result = addAnimationWithKeyframesToScript(
block.scriptText,
body.targetSelector,
@@ -1002,6 +1152,9 @@ async function executeGsapMutationRecast(
return result.script;
}
case "replace-with-keyframes": {
+ if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) {
+ stripStudioEditsFromTarget(block.document, body.targetSelector);
+ }
const script = removeAnimationFromScript(block.scriptText, body.animationId);
const added = addAnimationWithKeyframesToScript(
script,
@@ -1277,11 +1430,18 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
target?: { id?: string; selector?: string; selectorIndex?: number };
splitTime?: number;
newId?: string;
+ elementStart?: number;
+ elementDuration?: number;
}>(c);
if ("error" in parsed) return parsed.error;
if (typeof parsed.body.splitTime !== "number" || !parsed.body.newId) {
return c.json({ error: "target, splitTime, and newId required" }, 400);
}
+ const fallbackTiming =
+ typeof parsed.body.elementStart === "number" &&
+ typeof parsed.body.elementDuration === "number"
+ ? { start: parsed.body.elementStart, duration: parsed.body.elementDuration }
+ : undefined;
let originalContent: string;
try {
@@ -1294,6 +1454,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
parsed.target,
parsed.body.splitTime,
parsed.body.newId,
+ fallbackTiming,
);
if (!result.matched) {
return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath });
@@ -1537,7 +1698,15 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const result = await executeGsapMutation(body, block, respond);
if (result instanceof Response) return result;
- const newScript = typeof result === "string" ? result : result.script;
+ let newScript = typeof result === "string" ? result : result.script;
+ // Keep the "hold before first keyframe" sets in sync after any mutation that can
+ // change a position tween's first keyframe or its existence. Without it, an
+ // element snaps to its CSS base before the tween starts instead of holding its
+ // first keyframe (the universal NLE behavior).
+ if (HOLD_SYNC_MUTATION_TYPES.has(body.type)) {
+ const parser = await loadGsapParser();
+ newScript = parser.syncPositionHoldsBeforeKeyframes(newScript);
+ }
const changed = newScript !== block.scriptText;
const newHtml = changed ? block.replaceScript(newScript) : html;
let backupPath: string | null = null;