Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 37 additions & 19 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -1073,6 +1056,21 @@ export function initSandboxRuntimeModular(): void {
const dur = String(rootDuration > 0 ? rootDuration : 1);
const seen = new Set<Element>();

// 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<Element>(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 {
Expand All @@ -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");
Expand All @@ -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);
Expand Down Expand Up @@ -1439,6 +1437,21 @@ 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.
const timedClipInFlow = new WeakMap<Element, boolean>();
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;
};

const syncMediaForCurrentState = () => {
const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => {
const compositionRoot = element.closest("[data-composition-id]");
Expand Down Expand Up @@ -1544,6 +1557,11 @@ export function initSandboxRuntimeModular(): void {
if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow);
}
if (isVisibleNow) {
if (timedClipInFlow.get(rawNode)) rawNode.style.removeProperty("display");
} else if (isTimedClipInFlow(rawNode)) {
rawNode.style.display = "none";
}
}
};

Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/studio-api/helpers/sourceMutation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<html><body><div data-composition-id="root"><h1 id="title" class="title">Hi</h1></div></body></html>`;
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(/<h1[^>]*\bid="title"[^>]*>/)![0];
expect(original).toContain('data-start="0"');
expect(original).toContain('data-duration="2"');
const clone = result.html.match(/<h1[^>]*\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 = `<html><body><div data-composition-id="root"><h1 id="title">Hi</h1></div></body></html>`;
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"',
Expand Down
20 changes: 18 additions & 2 deletions packages/core/src/studio-api/helpers/sourceMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,12 +381,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 };
}
Expand All @@ -405,6 +416,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 <span>), 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);

Expand Down Expand Up @@ -433,7 +447,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
Expand Down
103 changes: 102 additions & 1 deletion packages/core/src/studio-api/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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 {
Expand Down Expand Up @@ -289,6 +290,18 @@ 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<string, number | string> }>,
): boolean {
return keyframes.some((kf) =>
Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
);
}

function lastKeyframeOpacity(kfs: GsapAnimation["keyframes"]): number | string | undefined {
if (!kfs) return undefined;
for (let i = kfs.keyframes.length - 1; i >= 0; i--) {
Expand Down Expand Up @@ -431,6 +444,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";
Expand Down Expand Up @@ -498,6 +529,24 @@ 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.
const HOLD_SYNC_MUTATION_TYPES = new Set<string>([
"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",
"delete",
"delete-all-for-selector",
]);

async function executeGsapMutation(
body: GsapMutationRequest,
block: NonNullable<ReturnType<typeof extractGsapScriptBlock>>,
Expand All @@ -517,6 +566,10 @@ async function executeGsapMutation(
unrollDynamicAnimations,
setArcPathInScript,
updateArcSegmentInScript,
updateMotionPathPointInScript,
addMotionPathPointInScript,
removeMotionPathPointInScript,
addMotionPathToScript,
removeArcPathFromScript,
addAnimationWithKeyframesToScript,
splitAnimationsInScript,
Expand Down Expand Up @@ -680,10 +733,39 @@ async function executeGsapMutation(
...(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)) {
stripStudioEditsFromTarget(block.document, body.targetSelector);
}
const result = addAnimationWithKeyframesToScript(
block.scriptText,
body.targetSelector,
Expand All @@ -695,6 +777,9 @@ async function executeGsapMutation(
return result.script;
}
case "replace-with-keyframes": {
if (keyframesWritePosition(body.keyframes)) {
stripStudioEditsFromTarget(block.document, body.targetSelector);
}
const script = removeAnimationFromScript(block.scriptText, body.animationId);
const added = addAnimationWithKeyframesToScript(
script,
Expand Down Expand Up @@ -970,11 +1055,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 {
Expand All @@ -987,6 +1079,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 });
Expand Down Expand Up @@ -1230,7 +1323,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;
Expand Down
Loading