Skip to content

Commit 070799e

Browse files
committed
feat(core): route motion-path mutations through studio-api + fix clip stamping
Wire the new mutations into the file save route. Only authored clips suppress descendant stamping, so auto-stamped animated scenes can inline-expand.
1 parent c8aee05 commit 070799e

4 files changed

Lines changed: 179 additions & 32 deletions

File tree

packages/core/src/runtime/init.ts

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,6 @@ import { createGsapAdapter } from "./adapters/gsap";
66
import { createAnimeJsAdapter } from "./adapters/animejs";
77
import { createLottieAdapter } from "./adapters/lottie";
88
import { createThreeAdapter } from "./adapters/three";
9-
import { createMapboxAdapter } from "./adapters/mapbox";
10-
import { createLeafletAdapter } from "./adapters/leaflet";
11-
import { createGoogleMapsAdapter } from "./adapters/google-maps";
12-
import { createMaplibreAdapter } from "./adapters/maplibre";
13-
import { createD3Adapter } from "./adapters/d3";
149
import { createTypegpuAdapter } from "./adapters/typegpu";
1510
import {
1611
patchVideoTextureCompat,
@@ -410,23 +405,6 @@ export function initSandboxRuntimeModular(): void {
410405
return resolveStartForElement(element, fallback);
411406
};
412407

413-
const findTimedClipAncestor = (
414-
element: HTMLElement,
415-
rootComp: HTMLElement | null,
416-
): HTMLElement | null => {
417-
let node = element.parentElement;
418-
while (node) {
419-
// rootComp may be null when no composition is mounted; the walk still
420-
// terminates via `while (node)` — node === null is never true here.
421-
if (node === rootComp) break;
422-
if (node.hasAttribute("data-start")) {
423-
return node;
424-
}
425-
node = node.parentElement;
426-
}
427-
return null;
428-
};
429-
430408
const isTimedElementVisibleAt = (rawNode: HTMLElement, currentTime: number): boolean => {
431409
const tag = rawNode.tagName.toLowerCase();
432410
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") {
@@ -1073,6 +1051,21 @@ export function initSandboxRuntimeModular(): void {
10731051
const dur = String(rootDuration > 0 ? rootDuration : 1);
10741052
const seen = new Set<Element>();
10751053

1054+
// Only an AUTHORED clip (data-start already in the source, captured before
1055+
// we stamp anything) should suppress stamping its descendants. An animated
1056+
// scene container we auto-stamp below (e.g. an opacity-crossfaded scene)
1057+
// must NOT suppress its own animated children — otherwise those children
1058+
// never become timeline clips and that scene can't inline-expand.
1059+
const authoredTimed = new Set<Element>(document.querySelectorAll("[data-start]"));
1060+
const hasAuthoredTimedAncestor = (element: HTMLElement): boolean => {
1061+
let node = element.parentElement;
1062+
while (node && node !== rootComp) {
1063+
if (authoredTimed.has(node)) return true;
1064+
node = node.parentElement;
1065+
}
1066+
return false;
1067+
};
1068+
10761069
// Stamp GSAP-targeted elements
10771070
if (state.capturedTimeline.getChildren) {
10781071
try {
@@ -1082,7 +1075,7 @@ export function initSandboxRuntimeModular(): void {
10821075
if (!(target instanceof HTMLElement)) continue;
10831076
if (target === rootComp) continue;
10841077
if (target.hasAttribute("data-start")) continue;
1085-
if (findTimedClipAncestor(target, rootComp)) continue;
1078+
if (hasAuthoredTimedAncestor(target)) continue;
10861079
if (seen.has(target)) continue;
10871080
seen.add(target);
10881081
target.setAttribute("data-start", "0");
@@ -1102,7 +1095,7 @@ export function initSandboxRuntimeModular(): void {
11021095
if (!(el instanceof HTMLElement)) continue;
11031096
if (el === rootComp) continue;
11041097
if (el.hasAttribute("data-start")) continue;
1105-
if (findTimedClipAncestor(el, rootComp)) continue;
1098+
if (hasAuthoredTimedAncestor(el)) continue;
11061099
if (seen.has(el)) continue;
11071100
if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue;
11081101
seen.add(el);
@@ -1439,6 +1432,21 @@ export function initSandboxRuntimeModular(): void {
14391432
};
14401433

14411434
// fallow-ignore-next-line complexity
1435+
// Whether a timed clip participates in normal flow (static/relative/sticky).
1436+
// In-flow clips must leave the flow when hidden — `visibility:hidden` reserves
1437+
// their layout box, so a split sibling would stack below the active half
1438+
// instead of overlapping it. Positioned clips keep `visibility:hidden` (cheaper,
1439+
// and avoids disturbing absolute media playback). Computed once per element.
1440+
const timedClipInFlow = new WeakMap<Element, boolean>();
1441+
const isTimedClipInFlow = (el: HTMLElement): boolean => {
1442+
const cached = timedClipInFlow.get(el);
1443+
if (cached !== undefined) return cached;
1444+
const pos = window.getComputedStyle(el).position;
1445+
const inFlow = pos === "static" || pos === "relative" || pos === "sticky";
1446+
timedClipInFlow.set(el, inFlow);
1447+
return inFlow;
1448+
};
1449+
14421450
const syncMediaForCurrentState = () => {
14431451
const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => {
14441452
const compositionRoot = element.closest("[data-composition-id]");
@@ -1544,6 +1552,11 @@ export function initSandboxRuntimeModular(): void {
15441552
if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
15451553
colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow);
15461554
}
1555+
if (isVisibleNow) {
1556+
if (timedClipInFlow.get(rawNode)) rawNode.style.removeProperty("display");
1557+
} else if (isTimedClipInFlow(rawNode)) {
1558+
rawNode.style.display = "none";
1559+
}
15471560
}
15481561
};
15491562

@@ -1918,11 +1931,6 @@ export function initSandboxRuntimeModular(): void {
19181931
createAnimeJsAdapter(),
19191932
createLottieAdapter(),
19201933
createThreeAdapter(),
1921-
createMapboxAdapter(),
1922-
createLeafletAdapter(),
1923-
createGoogleMapsAdapter(),
1924-
createMaplibreAdapter(),
1925-
createD3Adapter(),
19261934
createTypegpuAdapter(),
19271935
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
19281936
] as RuntimeDeterministicAdapter[];

packages/core/src/studio-api/helpers/sourceMutation.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,28 @@ describe("splitElementInHtml", () => {
508508
expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false);
509509
});
510510

511+
it("splits a GSAP element with no authored timing using fallback timing", () => {
512+
// #title has no data-start/data-duration (GSAP-driven); the store supplies the range.
513+
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title" class="title">Hi</h1></div></body></html>`;
514+
const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", {
515+
start: 0,
516+
duration: 6,
517+
});
518+
expect(result.matched).toBe(true);
519+
// original windowed to [0, 2], clone to [2, 4] (attribute order is serializer-defined)
520+
const original = result.html.match(/<h1[^>]*\bid="title"[^>]*>/)![0];
521+
expect(original).toContain('data-start="0"');
522+
expect(original).toContain('data-duration="2"');
523+
const clone = result.html.match(/<h1[^>]*\bid="title-split"[^>]*>/)![0];
524+
expect(clone).toContain('data-start="2"');
525+
expect(clone).toContain('data-duration="4"');
526+
});
527+
528+
it("still rejects a no-timing element when no fallback timing is given", () => {
529+
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title">Hi</h1></div></body></html>`;
530+
expect(splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split").matched).toBe(false);
531+
});
532+
511533
it("adjusts media playback-start for the second half", () => {
512534
const mediaSource = source.replace(
513535
'id="box" class="clip" data-start="1" data-duration="6"',

packages/core/src/studio-api/helpers/sourceMutation.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,12 +381,23 @@ export function splitElementInHtml(
381381
target: SourceMutationTarget,
382382
splitTime: number,
383383
newId: string,
384+
fallbackTiming?: { start: number; duration: number },
384385
): SplitElementResult {
385386
const { document, wrappedFragment } = parseSourceDocument(source);
386387
const el = findTargetElement(document, target);
387388
if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null };
388389

389-
const { start, duration, usesDataEnd } = resolveElementTiming(el);
390+
const timing = resolveElementTiming(el);
391+
const { usesDataEnd } = timing;
392+
let { start, duration } = timing;
393+
// GSAP-animated elements carry their timing in the script, not in data-* attrs,
394+
// so the source has no authored duration. Fall back to the store's (GSAP-derived)
395+
// range — the runtime windows visibility off data-start/data-duration regardless
396+
// of class, so stamping both halves below makes each half show only in its window.
397+
if (duration <= 0 && fallbackTiming && fallbackTiming.duration > 0) {
398+
start = fallbackTiming.start;
399+
duration = fallbackTiming.duration;
400+
}
390401
if (duration <= 0 || splitTime <= start || splitTime >= start + duration) {
391402
return { html: source, matched: false, newId: null };
392403
}
@@ -405,6 +416,9 @@ export function splitElementInHtml(
405416
const clone = el.cloneNode(true) as HTMLElement;
406417
clone.setAttribute("id", newId);
407418
clone.removeAttribute("data-hf-id");
419+
// Descendants carry their own data-hf-id; leaving them duplicates the id of
420+
// every nested node (e.g. an inner <span>), so strip them on the clone too.
421+
for (const node of clone.querySelectorAll("[data-hf-id]")) node.removeAttribute("data-hf-id");
408422
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
409423
setElementDuration(clone, splitTime, secondDuration, usesDataEnd);
410424

@@ -433,7 +447,9 @@ export function splitElementInHtml(
433447
duplicateCssRulesForId(document, originalId, newId);
434448
}
435449

436-
// Trim the original element's duration
450+
// Trim the original element's duration. A GSAP element had no data-start; stamp
451+
// it so the runtime windows the first half (visibility selects on [data-start]).
452+
el.setAttribute("data-start", String(Math.round(start * 1000) / 1000));
437453
setElementDuration(el, start, firstDuration, usesDataEnd);
438454

439455
// Insert clone after original

packages/core/src/studio-api/routes/files.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
type UnsafeMutationValue,
2525
} from "../helpers/finiteMutation.js";
2626
import type { GsapAnimation } from "../../parsers/gsapSerialize.js";
27+
import { classifyPropertyGroup } from "../../parsers/gsapConstants.js";
2728
import { parseGsapScriptAcorn } from "../../parsers/gsapParserAcorn.js";
2829
import { unrollComputedTimeline } from "../../parsers/gsapUnroll.js";
2930
import {
@@ -289,6 +290,18 @@ function stripStudioEditsFromTarget(document: Document, selector: string): numbe
289290
return stripped;
290291
}
291292

293+
// A studio path-offset (--hf-studio-offset / data-hf-studio-path-offset) and a GSAP
294+
// position tween both drive translate — keeping both stacks the offsets (a gesture or
295+
// drag recorded over a stale offset plays shoved off-position). When a committed tween
296+
// writes a position property, the tween owns position, so the stale offset must go.
297+
function keyframesWritePosition(
298+
keyframes: Array<{ properties: Record<string, number | string> }>,
299+
): boolean {
300+
return keyframes.some((kf) =>
301+
Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
302+
);
303+
}
304+
292305
function lastKeyframeOpacity(kfs: GsapAnimation["keyframes"]): number | string | undefined {
293306
if (!kfs) return undefined;
294307
for (let i = kfs.keyframes.length - 1; i >= 0; i--) {
@@ -431,6 +444,24 @@ type GsapMutationRequest =
431444
cp1?: { x: number; y: number };
432445
cp2?: { x: number; y: number };
433446
}
447+
| {
448+
type: "update-motion-path-point";
449+
animationId: string;
450+
pointIndex: number;
451+
x: number;
452+
y: number;
453+
}
454+
| { type: "add-motion-path-point"; animationId: string; index: number; x: number; y: number }
455+
| { type: "remove-motion-path-point"; animationId: string; index: number }
456+
| {
457+
type: "add-motion-path";
458+
targetSelector: string;
459+
position: number;
460+
duration: number;
461+
x: number;
462+
y: number;
463+
ease?: string;
464+
}
434465
| { type: "remove-arc-path"; animationId: string }
435466
| {
436467
type: "add-with-keyframes";
@@ -498,6 +529,24 @@ type GsapMutationRequest =
498529

499530
type GsapMutationResult = string | { script: string; skippedSelectors: string[] };
500531

532+
// Mutations that can change a position tween's first keyframe (value/existence/timing)
533+
// and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards.
534+
const HOLD_SYNC_MUTATION_TYPES = new Set<string>([
535+
"add-keyframe",
536+
"update-keyframe",
537+
"remove-keyframe",
538+
"remove-all-keyframes",
539+
"add-with-keyframes",
540+
"replace-with-keyframes",
541+
"convert-to-keyframes",
542+
"materialize-keyframes",
543+
"update-motion-path-point",
544+
"add-motion-path-point",
545+
"remove-motion-path-point",
546+
"delete",
547+
"delete-all-for-selector",
548+
]);
549+
501550
async function executeGsapMutation(
502551
body: GsapMutationRequest,
503552
block: NonNullable<ReturnType<typeof extractGsapScriptBlock>>,
@@ -517,6 +566,10 @@ async function executeGsapMutation(
517566
unrollDynamicAnimations,
518567
setArcPathInScript,
519568
updateArcSegmentInScript,
569+
updateMotionPathPointInScript,
570+
addMotionPathPointInScript,
571+
removeMotionPathPointInScript,
572+
addMotionPathToScript,
520573
removeArcPathFromScript,
521574
addAnimationWithKeyframesToScript,
522575
splitAnimationsInScript,
@@ -680,10 +733,39 @@ async function executeGsapMutation(
680733
...(body.cp2 ? { cp2: body.cp2 } : {}),
681734
});
682735
}
736+
case "update-motion-path-point": {
737+
return updateMotionPathPointInScript(block.scriptText, body.animationId, body.pointIndex, {
738+
x: body.x,
739+
y: body.y,
740+
});
741+
}
742+
case "add-motion-path-point": {
743+
return addMotionPathPointInScript(block.scriptText, body.animationId, body.index, {
744+
x: body.x,
745+
y: body.y,
746+
});
747+
}
748+
case "remove-motion-path-point": {
749+
return removeMotionPathPointInScript(block.scriptText, body.animationId, body.index);
750+
}
751+
case "add-motion-path": {
752+
const result = addMotionPathToScript(
753+
block.scriptText,
754+
body.targetSelector,
755+
body.position,
756+
body.duration,
757+
{ x: body.x, y: body.y },
758+
body.ease,
759+
);
760+
return result.script;
761+
}
683762
case "remove-arc-path": {
684763
return removeArcPathFromScript(block.scriptText, body.animationId);
685764
}
686765
case "add-with-keyframes": {
766+
if (keyframesWritePosition(body.keyframes)) {
767+
stripStudioEditsFromTarget(block.document, body.targetSelector);
768+
}
687769
const result = addAnimationWithKeyframesToScript(
688770
block.scriptText,
689771
body.targetSelector,
@@ -695,6 +777,9 @@ async function executeGsapMutation(
695777
return result.script;
696778
}
697779
case "replace-with-keyframes": {
780+
if (keyframesWritePosition(body.keyframes)) {
781+
stripStudioEditsFromTarget(block.document, body.targetSelector);
782+
}
698783
const script = removeAnimationFromScript(block.scriptText, body.animationId);
699784
const added = addAnimationWithKeyframesToScript(
700785
script,
@@ -970,11 +1055,18 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
9701055
target?: { id?: string; selector?: string; selectorIndex?: number };
9711056
splitTime?: number;
9721057
newId?: string;
1058+
elementStart?: number;
1059+
elementDuration?: number;
9731060
}>(c);
9741061
if ("error" in parsed) return parsed.error;
9751062
if (typeof parsed.body.splitTime !== "number" || !parsed.body.newId) {
9761063
return c.json({ error: "target, splitTime, and newId required" }, 400);
9771064
}
1065+
const fallbackTiming =
1066+
typeof parsed.body.elementStart === "number" &&
1067+
typeof parsed.body.elementDuration === "number"
1068+
? { start: parsed.body.elementStart, duration: parsed.body.elementDuration }
1069+
: undefined;
9781070

9791071
let originalContent: string;
9801072
try {
@@ -987,6 +1079,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
9871079
parsed.target,
9881080
parsed.body.splitTime,
9891081
parsed.body.newId,
1082+
fallbackTiming,
9901083
);
9911084
if (!result.matched) {
9921085
return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath });
@@ -1230,7 +1323,15 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
12301323
const result = await executeGsapMutation(body, block, respond);
12311324
if (result instanceof Response) return result;
12321325

1233-
const newScript = typeof result === "string" ? result : result.script;
1326+
let newScript = typeof result === "string" ? result : result.script;
1327+
// Keep the "hold before first keyframe" sets in sync after any mutation that can
1328+
// change a position tween's first keyframe or its existence. Without it, an
1329+
// element snaps to its CSS base before the tween starts instead of holding its
1330+
// first keyframe (the universal NLE behavior).
1331+
if (HOLD_SYNC_MUTATION_TYPES.has(body.type)) {
1332+
const parser = await loadGsapParser();
1333+
newScript = parser.syncPositionHoldsBeforeKeyframes(newScript);
1334+
}
12341335
const changed = newScript !== block.scriptText;
12351336
const newHtml = changed ? block.replaceScript(newScript) : html;
12361337
let backupPath: string | null = null;

0 commit comments

Comments
 (0)