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
100 changes: 0 additions & 100 deletions packages/core/src/lint/rules/gsap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="headline" style="position:absolute;left:120px;top:200px;">Hello</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.set("#headline", { opacity: 0 });
tl.to("#headline", { opacity: 1, duration: 0.5 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div class="box" style="position:absolute;left:120px;top:200px;"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from(".box", { y: 80, opacity: 0, duration: 0.4 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="box"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 100, duration: 1 }, 0);
</script>
</body></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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="title"></div>
<div id="sub"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from("#title", { opacity: 0, duration: 0.3 }, 0);
tl.from("#sub", { opacity: 0, duration: 0.3 }, 0.2);
window.__timelines["c1"] = tl;
</script>
</body></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 = `
<html><body>
Expand Down
36 changes: 0 additions & 36 deletions packages/core/src/lint/rules/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
truncateSnippet,
stripJsComments,
WINDOW_TIMELINE_ASSIGN_PATTERN,
TIMELINE_REGISTRY_ASSIGN_PATTERN,
} from "../utils";

// ── GSAP-specific types ────────────────────────────────────────────────────
Expand Down Expand Up @@ -855,39 +854,4 @@ export const gsapRules: LintRule<LintContext>[] = [
}
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<string>();
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;
},
];
25 changes: 25 additions & 0 deletions packages/core/src/parsers/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
85 changes: 66 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,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<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;
};

// `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<Element, boolean>();
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<Element, boolean>();
timedClipIsLeaf = new WeakMap<Element, boolean>();
};

const syncMediaForCurrentState = () => {
const resolveMediaCompositionContext = (element: HTMLVideoElement | HTMLAudioElement) => {
const compositionRoot = element.closest("[data-composition-id]");
Expand Down Expand Up @@ -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";
}
}
};

Expand Down Expand Up @@ -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<string, RuntimeTimelineLike | undefined>;
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 @@ -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 };
}
Expand All @@ -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 <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 @@ -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
Expand Down
Loading
Loading