diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 5976e4b664..8bea681c7b 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -21,6 +21,7 @@ export const SUPPORTED_PROPS = [ "rotationY", "rotationZ", "perspective", + "transformPerspective", "transformOrigin", // Visibility "opacity", diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 71a5b38997..1be1712278 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -578,6 +578,47 @@ describe("stagger/yoyo/repeat round-trip", () => { expect(updatedScript).toContain("opacity: 0.5"); }); + it("converts a static set into a keyframed to() with a duration (keyframable 3D)", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.set("#card", { rotationX: 50, rotationY: 20, immediateRender: true }, 0); + `; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0].id; + const result = convertToKeyframesInScript(script, animId, undefined, 4); + // Flips set → to, drops the hold marker, gains a duration + keyframes. + expect(result).toContain('tl.to("#card"'); + expect(result).not.toContain("immediateRender"); + expect(result).toContain("duration: 4"); + expect(result).toContain("keyframes:"); + // Both endpoints start at the set's value (visual unchanged until edited). + const reparsed = parseGsapScript(result).animations[0]; + expect(reparsed.keyframes).toBeTruthy(); + expect(reparsed.keyframes!.keyframes[0]!.properties.rotationX).toBe(50); + expect(reparsed.keyframes!.keyframes.at(-1)!.properties.rotationX).toBe(50); + }); + + it("converts a GLOBAL gsap.set into a timeline-rooted to() (seekable, not gsap.to)", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + gsap.set("#card", { rotationX: 50, rotationY: 20 }); + `; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0].id; + expect(parsed.animations[0].global).toBe(true); + const result = convertToKeyframesInScript(script, animId, undefined, 4); + // Must re-root onto the master timeline (tl.to), NOT emit an off-timeline + // gsap.to that fires once at load and can't be seeked/rendered. + expect(result).toMatch(/tl\.to\(\s*"#card"/); + expect(result).not.toMatch(/gsap\.to\(/); + expect(result).toContain("duration: 4"); + expect(result).toContain("keyframes:"); + // Re-parsed tween is a real timeline keyframe tween, no longer global. + const reparsed = parseGsapScript(result).animations[0]; + expect(reparsed.keyframes).toBeTruthy(); + expect(reparsed.global).toBeFalsy(); + }); + it("apply-to-all (resetKeyframeEases) sets easeEach and strips every per-keyframe ease", () => { const script = ` const tl = gsap.timeline({ paused: true }); @@ -2812,3 +2853,57 @@ tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`; expect(parsed.animations[1].position).toBe("+=0.5"); }); }); + +describe("base gsap.set (off-timeline global hold)", () => { + const SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + gsap.set("#box", { rotationX: 17, rotationY: 93 }); + tl.to("#box", { x: 260, duration: 1 }, 0.3); + window.__timelines = { main: tl }; + `; + + it("parses a string-literal gsap.set as a global set animation", () => { + const anims = parseGsapScript(SCRIPT).animations.filter((a) => a.targetSelector === "#box"); + const set = anims.find((a) => a.method === "set"); + expect(set?.global).toBe(true); + expect(set?.properties).toEqual({ rotationX: 17, rotationY: 93 }); + expect(anims.find((a) => a.method === "to")?.global).toBeUndefined(); + }); + + it("creates a base gsap.set (not tl.set) when global is set", () => { + const base = `const tl = gsap.timeline({ paused: true });\ntl.to("#box", { x: 1, duration: 1 }, 0);\nwindow.__timelines = { main: tl };`; + const { script } = addAnimationToScript(base, { + targetSelector: "#box", + method: "set", + position: 0, + properties: { rotationX: 30 }, + global: true, + }); + expect(script).toContain('gsap.set("#box"'); + expect(script).not.toContain('tl.set("#box"'); + }); + + it("updates a global set in place, keeping it gsap.set", () => { + const set = parseGsapScript(SCRIPT).animations.find( + (a) => a.targetSelector === "#box" && a.method === "set", + )!; + const updated = updateAnimationInScript(SCRIPT, set.id, { + properties: { rotationX: 99, rotationY: 93 }, + }); + expect(updated).toContain('gsap.set("#box"'); + expect(updated).toContain("99"); + expect(updated).not.toContain('tl.set("#box"'); + }); + + it("leaves a VARIABLE-target gsap.set as surrounding source (not parsed)", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + const el = document.querySelector("#box"); + gsap.set(el, { rotationX: 5 }); + tl.to("#box", { x: 10, duration: 1 }, 0); + window.__timelines = { main: tl }; + `; + const sets = parseGsapScript(script).animations.filter((a) => a.method === "set"); + expect(sets).toHaveLength(0); + }); +}); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index e6941f2f66..927f59e550 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -440,6 +440,8 @@ interface TweenCallInfo { varsArg: AstNode; fromArg?: AstNode; positionArg?: AstNode; + /** True for a base `gsap.set(...)` (off-timeline) rather than `tl.set(...)`. */ + global?: boolean; } /** @@ -465,10 +467,24 @@ function findAllTweenCalls( visitCallExpression(path: AstPath) { const node = path.node; const callee = node.callee; + // A base `gsap.set("#sel", props)` is an off-timeline static hold (no position, + // no keyframe marker). Treat it as an editable `set` animation so a static + // value (e.g. a 3D transform) round-trips and re-edits in place. Restricted to + // a STRING-LITERAL selector: variable-target `gsap.set(el, ...)` holds stay + // opaque surrounding source (editing them by selector would be ambiguous). + const gsapSetArg = node.arguments?.[0]; + const isGlobalSet = + callee?.type === "MemberExpression" && + callee.object?.type === "Identifier" && + callee.object.name === "gsap" && + callee.property?.type === "Identifier" && + callee.property.name === "set" && + (gsapSetArg?.type === "StringLiteral" || + (gsapSetArg?.type === "Literal" && typeof gsapSetArg.value === "string")); if ( callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && - isTimelineRootedCall(node, timelineVar) + (isTimelineRootedCall(node, timelineVar) || isGlobalSet) ) { const method = callee.property.name; if (!GSAP_METHODS.has(method)) { @@ -501,6 +517,7 @@ function findAllTweenCalls( selector: selectorValue, varsArg: args[1], positionArg: args[2], + ...(isGlobalSet ? { global: true } : {}), }); } } @@ -968,6 +985,7 @@ function tweenCallToAnimation( group = classifyTweenPropertyGroup(kfProps); } if (group) anim.propertyGroup = group; + if (call.global) anim.global = true; if (Object.keys(extras).length > 0) anim.extras = extras; if (keyframesData) anim.keyframes = keyframesData; if (motionPathResult) anim.arcPath = motionPathResult.arcPath; @@ -1306,8 +1324,9 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit `${safeKey(k)}: ${valueToCode(v)}`); // immediateRender forces GSAP to apply the set when added to the timeline, // not on the first seek — without it, tl.set at position 0 on a paused - // timeline is invisible until the playhead moves past 0. - if (anim.method === "set") entries.push("immediateRender: true"); + // timeline is invisible until the playhead moves past 0. A base `gsap.set` + // already runs immediately, so it doesn't need (or get) the flag. + if (anim.method === "set" && !anim.global) entries.push("immediateRender: true"); if (anim.extras) { for (const [k, v] of Object.entries(anim.extras)) { entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`); @@ -1324,6 +1343,10 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit, + setDuration = 1, ): string { let loc = locateAnimationWithFallback(script, animationId); if (!loc) return script; const anim = loc.target.animation; - if (anim.keyframes || anim.method === "set") return script; + if (anim.keyframes) return script; const { fromProps, toProps } = resolveConversionProps(anim, resolvedFromValues); const varsArg = loc.target.call.varsArg; @@ -2326,6 +2350,27 @@ export function convertToKeyframesInScript( if (anim.method === "fromTo") loc.target.call.node.arguments.splice(1, 1); } + // A static `set` becomes an animatable `to`: flip the method, drop the + // immediateRender hold marker, and give it a real duration so the keyframes + // span time. This is what makes a static 3D transform keyframeable. + if (anim.method === "set") { + // A GLOBAL `gsap.set(...)` is off-timeline; flipping only the method would + // emit `gsap.to(...)`, which fires once at load and is NOT on the paused + // master timeline (the engine can't seek/render it). Re-root it onto the + // timeline var and add the position arg (a gsap.set has none) so the + // converted tween is seekable. A `tl.set` already has the right object. + const calleeObj = loc.target.call.node.callee.object; + if (anim.global && calleeObj?.type === "Identifier") { + calleeObj.name = loc.parsed.timelineVar; + if (loc.target.call.node.arguments.length < 3) { + loc.target.call.node.arguments.push(parseExpr("0")); + } + } + loc.target.call.node.callee.property.name = "to"; + removeVarsKey(varsArg, "immediateRender"); + setVarsKey(varsArg, "duration", Math.max(0.001, setDuration)); + } + return recast.print(loc.parsed.ast).code; } diff --git a/packages/core/src/parsers/gsapParserAcorn.ts b/packages/core/src/parsers/gsapParserAcorn.ts index 34dadaeb1e..7775d3763b 100644 --- a/packages/core/src/parsers/gsapParserAcorn.ts +++ b/packages/core/src/parsers/gsapParserAcorn.ts @@ -443,6 +443,8 @@ export interface TweenCallInfo { varsArg: any; fromArg?: any; positionArg?: any; + /** True for a base `gsap.set(...)` (off-timeline) rather than `tl.set(...)`. */ + global?: boolean; } /** True when callee chain is rooted at the timeline variable. */ @@ -477,10 +479,22 @@ function findAllTweenCalls( // Fire BEFORE children (pre-order) so chained outer calls come first. if (node.type === "CallExpression") { const callee = node.callee; + // A base `gsap.set("#sel", props)` is an off-timeline static hold — parse it as + // an editable global `set` so a static value round-trips and re-edits in place. + // STRING-LITERAL selectors only: variable-target holds stay surrounding source. + const gsapSetArg = node.arguments?.[0]; + const isGlobalSet = + callee?.type === "MemberExpression" && + callee.object?.type === "Identifier" && + callee.object.name === "gsap" && + callee.property?.type === "Identifier" && + callee.property.name === "set" && + (gsapSetArg?.type === "StringLiteral" || + (gsapSetArg?.type === "Literal" && typeof gsapSetArg.value === "string")); if ( callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && - isTimelineRootedCall(node, timelineVar) && + (isTimelineRootedCall(node, timelineVar) || isGlobalSet) && GSAP_METHODS.has(callee.property.name) ) { const method = callee.property.name; @@ -509,6 +523,7 @@ function findAllTweenCalls( selector: selectorValue, varsArg: args[1], positionArg: args[2], + ...(isGlobalSet ? { global: true } : {}), }); } } @@ -923,6 +938,7 @@ function tweenCallToAnimation( group = classifyTweenPropertyGroup(kfProps); } if (group) anim.propertyGroup = group; + if (call.global) anim.global = true; if (Object.keys(extras).length > 0) anim.extras = extras; if (keyframesData) anim.keyframes = keyframesData; if (motionPathResult) anim.arcPath = motionPathResult.arcPath; diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index af11a1b817..c5832c7156 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -73,6 +73,11 @@ export interface GsapAnimation { /** Which property group this tween belongs to (position, scale, size, rotation, visual, other). * Undefined for legacy mixed tweens that bundle multiple groups. */ propertyGroup?: PropertyGroupName; + /** True for a base `gsap.set(...)` (a static hold that runs immediately, OFF the + * timeline) rather than `tl.set(...)`. Carries no timeline position and shows no + * keyframe marker — used to persist a static value (e.g. a 3D transform) without + * introducing a 0% keyframe. */ + global?: boolean; /** How this tween was constructed in source. Absent ⇒ literal. */ provenance?: GsapProvenance; } @@ -202,7 +207,10 @@ export function serializeGsapAnimations( const posStr = typeof anim.position === "string" ? `"${anim.position}"` : anim.position; switch (anim.method) { case "set": - return ` ${timelineVar}.set(${selector}, ${propsStr}, ${posStr});`; + // A global set is a base `gsap.set` — off the timeline, no position arg. + return anim.global + ? ` gsap.set(${selector}, ${propsStr});` + : ` ${timelineVar}.set(${selector}, ${propsStr}, ${posStr});`; case "to": return ` ${timelineVar}.to(${selector}, ${propsStr}, ${posStr});`; case "from": @@ -476,6 +484,12 @@ export function resolveConversionProps( anim: GsapAnimation, resolvedFromValues?: Record, ): { fromProps: Record; toProps: Record } { + if (anim.method === "set") { + // A static hold becomes a keyframed `to` whose 0% and 100% both start at the + // set's value — the visual is unchanged until the user edits a keyframe to + // animate it. (The caller flips the call from `set` to `to` + adds a duration.) + return { fromProps: { ...anim.properties }, toProps: { ...anim.properties } }; + } if (anim.method === "to") { const identity = buildIdentityMap(anim.properties); const fromProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity; diff --git a/packages/core/src/parsers/gsapWriter.acorn.test.ts b/packages/core/src/parsers/gsapWriter.acorn.test.ts index c2b520e292..1c40a9364f 100644 --- a/packages/core/src/parsers/gsapWriter.acorn.test.ts +++ b/packages/core/src/parsers/gsapWriter.acorn.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest"; import { addAnimationToScript, addKeyframeToScript, + convertToKeyframesFromScript, removeAnimationFromScript, removeKeyframeFromScript, updateAnimationInScript, @@ -347,3 +348,22 @@ describe("T6c — keyframe write ops", () => { expect(result).toBe(SCRIPT_D); }); }); + +describe("T6c — convertToKeyframesFromScript: global gsap.set", () => { + const SCRIPT_GLOBAL_SET = `\ +var tl = gsap.timeline({ paused: true }); +gsap.set("#card", { rotationX: 50, rotationY: 20 }); +window.__timelines["t"] = tl;`; + + it("re-roots a global gsap.set onto the timeline (tl.to + position), not gsap.to", () => { + const animId = parseGsapScript(SCRIPT_GLOBAL_SET).animations[0].id; + const result = convertToKeyframesFromScript(SCRIPT_GLOBAL_SET, animId, undefined, 4); + // Off-timeline gsap.to would fire once at load and be unseekable; must be tl.to. + expect(result).toMatch(/tl\.to\(\s*"#card"/); + expect(result).not.toMatch(/gsap\.to\(/); + expect(result).toContain("keyframes:"); + const reparsed = parseGsapScript(result).animations[0]; + expect(reparsed.keyframes).toBeTruthy(); + expect(reparsed.global).toBeFalsy(); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 9a7245c382..3d9bf70027 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -72,6 +72,10 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit, varsNode: Node, source: string, + setDuration?: number, ): string { const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); @@ -1215,7 +1220,14 @@ function buildKeyframesVarsCode( // Preserve every non-editable key (duration/delay/callbacks/stagger/yoyo/…) // verbatim from source — rebuilding from the animation object alone dropped // `delay` (not a GsapAnimation field), shifting the tween's start time. - const parts: string[] = [`keyframes: ${kfCode}`, ...preservedVarsEntries(varsNode, source)]; + let preserved = preservedVarsEntries(varsNode, source); + // Converting a static `set` → drop its hold markers and give it a real duration + // so the keyframes span time. + if (setDuration !== undefined) { + preserved = preserved.filter((e) => !/^\s*(immediateRender|data|duration)\s*:/.test(e)); + } + const parts: string[] = [`keyframes: ${kfCode}`, ...preserved]; + if (setDuration !== undefined) parts.push(`duration: ${Math.max(0.001, setDuration)}`); if (animation.ease) parts.push(`ease: "none"`); return `{ ${parts.join(", ")} }`; } @@ -1229,18 +1241,36 @@ export function convertToKeyframesFromScript( script: string, animationId: string, resolvedFromValues?: Record, + setDuration = 1, ): string { const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return script; const target = parsed.located.find((l) => l.id === animationId); if (!target) return script; const { animation, call } = target; - if (animation.keyframes || call.method === "set") return script; + if (animation.keyframes) return script; + const isSet = call.method === "set"; const { fromProps, toProps } = resolveConversionProps(animation, resolvedFromValues); const ms = new MagicString(script); - if (call.method === "from" || call.method === "fromTo") { + // A GLOBAL `gsap.set(...)` is off-timeline; rewriting only the method emits + // `gsap.to(...)`, which fires once at load and isn't on the paused master + // timeline (the engine can't seek/render it). Re-root onto the timeline var + // and add the position arg the set lacks so the converted tween is seekable. + if (isSet && animation.global) { + const calleeObj = call.node.callee.object; + if (calleeObj?.type === "Identifier") { + ms.overwrite(calleeObj.start, calleeObj.end, parsed.timelineVar); + } + const args = call.node.arguments; + if (args.length > 0 && args.length < 3) { + ms.appendLeft(args[args.length - 1].end, ", 0"); + } + } + + // set/from/fromTo all become `to`; fromTo also drops its `from` argument. + if (call.method === "from" || call.method === "fromTo" || isSet) { ms.overwrite(call.node.callee.property.start, call.node.callee.property.end, "to"); } if (call.method === "fromTo" && call.fromArg) { @@ -1249,7 +1279,14 @@ export function convertToKeyframesFromScript( overwriteVarsArg( ms, call, - buildKeyframesVarsCode(animation, fromProps, toProps, call.varsArg, script), + buildKeyframesVarsCode( + animation, + fromProps, + toProps, + call.varsArg, + script, + isSet ? setDuration : undefined, + ), ); return ms.toString(); diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 215c5cada9..1b272e7941 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -371,6 +371,49 @@ describe("initSandboxRuntimeModular", () => { expect(window.__player?.getDuration()).toBe(12); }); + // #6: a single timeline registered under a key that does NOT match the root's + // data-composition-id must still bind (sole-timeline fallback) instead of + // silently rendering the frozen t=0 DOM. + it("binds the sole registered timeline when its key does not match the root id", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + // Registered under "wrong-key", not "main". + window.__timelines = { + "wrong-key": createMockTimeline(7), + }; + + initSandboxRuntimeModular(); + + expect(window.__player?.getDuration()).toBe(7); + }); + + // #6: when the root id is missing AND two timelines are registered, the + // fallback is ambiguous, so nothing is bound (the loud warning fires instead). + it("does not bind any timeline when the root id is unmatched and multiple are registered", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + window.__timelines = { + "wrong-key-a": createMockTimeline(7), + "wrong-key-b": createMockTimeline(9), + }; + + initSandboxRuntimeModular(); + + expect(window.__player?.getDuration()).toBe(0); + }); + it("pauses nested media that is outside the timed-media cache after a seek", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 599e89c31b..1c5e5f82a1 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -639,6 +639,29 @@ export function initSandboxRuntimeModular(): void { const resolveRootTimelineFromDocument = (): TimelineResolution => { const timelines = (window.__timelines ?? {}) as Record; + // DX fallback (#6): when the root timeline cannot be resolved by id but + // EXACTLY ONE usable timeline is registered, bind it rather than silently + // rendering the frozen t=0 DOM. Safe because with a single registered + // timeline there is no ambiguity about which one is the composition's + // root. Multiple registered → ambiguous, so we still return null and let + // the loud warning fire. + const resolveSoleTimelineFallback = (reason: string): TimelineResolution => { + const usable = Object.entries(timelines).filter( + (entry): entry is [string, RuntimeTimelineLike] => + !!entry[1] && typeof entry[1].play === "function" && typeof entry[1].pause === "function", + ); + if (usable.length !== 1) return { timeline: null }; + const [soleId, soleTimeline] = usable[0]; + return { + timeline: soleTimeline, + selectedTimelineIds: [soleId], + selectedDurationSeconds: getTimelineDurationSeconds(soleTimeline), + diagnostics: { + code: "root_timeline_sole_registered_fallback", + details: { reason, soleTimelineId: soleId }, + }, + }; + }; const startResolver = createRuntimeStartTimeResolver({ timelineRegistry: timelines, includeAuthoredTimingAttrs: true, @@ -740,7 +763,7 @@ export function initSandboxRuntimeModular(): void { const rootCompositionNode = resolveRootCompositionElement(); const rootCompositionId = rootCompositionNode?.getAttribute("data-composition-id") ?? null; if (!rootCompositionId) { - return { timeline: null }; + return resolveSoleTimelineFallback("root_missing_composition_id"); } const rootTimeline = timelines[rootCompositionId] ?? null; const collectRootChildCandidates = (): Array<{ @@ -1003,7 +1026,7 @@ export function initSandboxRuntimeModular(): void { }; } } - return { timeline: null }; + return resolveSoleTimelineFallback("root_composition_id_unmatched_in_registry"); }; // Track whether child composition timelines have been added to the root. @@ -2102,6 +2125,39 @@ export function initSandboxRuntimeModular(): void { clock.setDuration(boundDuration); } runAdapters("discover", state.currentTime); + // Loud, specific diagnostic for the #1 "looks fine, ships broken" trap: + // a root timeline never bound even though timelines ARE registered. Without + // this the render silently proceeds on the static build-time DOM (frozen at + // t=0). Only warn when GSAP timelines exist (CSS/WAAPI/Lottie-only + // compositions legitimately bind no GSAP timeline and use adapters). + if (!state.capturedTimeline) { + const registry = (window.__timelines ?? {}) as Record; + const registeredKeys = Object.keys(registry).filter((k) => registry[k]); + if (registeredKeys.length > 0) { + const rootEl = resolveRootCompositionElement(); + const rootCompositionId = rootEl?.getAttribute("data-composition-id") ?? null; + postRuntimeDiagnosticOnce( + "root_timeline_unbound_registry_present", + { + reason: rootCompositionId + ? "root data-composition-id has no matching key in window.__timelines" + : "root composition element has no data-composition-id attribute", + rootCompositionId, + registeredTimelineKeys: registeredKeys, + }, + "root_timeline_unbound_registry_present", + ); + // eslint-disable-next-line no-console -- loud author-facing warning; this render would otherwise freeze at t=0 + console.warn( + `[hyperframes] Root timeline not bound — render will freeze at t=0. ` + + (rootCompositionId + ? `Root data-composition-id is "${rootCompositionId}" but window.__timelines has no such key. ` + : `Root composition element has no data-composition-id. `) + + `Registered timeline keys: [${registeredKeys.join(", ")}]. ` + + `Register the root timeline under its data-composition-id (window.__timelines["${rootCompositionId ?? ""}"] = tl).`, + ); + } + } // __renderReady = timeline binding attempted, safe for deterministic seeking. // Set after any GSAP batching has completed. renderSeek works with or // without a GSAP timeline (CSS/WAAPI/Lottie compositions use adapters only). @@ -2232,11 +2288,30 @@ export function initSandboxRuntimeModular(): void { if (opts?.activateChildren) { activateSiblingTimelines(tl); } + // #10: when data-duration exceeds the timeline's intrinsic length the + // engine requests frames past the last tween. Seeking a paused GSAP + // timeline past its end can revert from()-tweens to their empty initial + // state, blanking the final poster. Clamp the MASTER seek to the + // timeline's full extent so it holds the final computed frame instead. + // Adapters still receive the raw `t` (their media may run longer). + // totalDuration() includes repeats; Infinity (infinite repeat) → no clamp. + const tlWithTotal = tl as RuntimeTimelineLike & { totalDuration?: () => number }; + let tlSeekTime = t; + if (typeof tlWithTotal.totalDuration === "function") { + try { + const total = Number(tlWithTotal.totalDuration()); + if (Number.isFinite(total) && total > 0 && t > total) { + tlSeekTime = total; + } + } catch (err) { + swallow("runtime.init.transport.clampDuration", err); + } + } try { if (typeof tl.totalTime === "function") { - tl.totalTime(t, false); + tl.totalTime(tlSeekTime, false); } else { - tl.seek(t, false); + tl.seek(tlSeekTime, false); } } catch (err) { swallow("runtime.init.transport.seek", err); diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index df5cde235d..08d4fdd202 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -428,6 +428,14 @@ type GsapMutationRequest = property: string; value: number | string; } + | { + // Merge MULTIPLE properties into an animation in ONE call. A per-property + // loop on a `set` can shift its group-derived id mid-way (e.g. adding `scale` + // to a rotation set), 404-ing the next update; this lands them all at once. + type: "update-properties"; + animationId: string; + properties: Record; + } | { type: "update-from-property"; animationId: string; @@ -454,6 +462,8 @@ type GsapMutationRequest = ease?: string; properties: Record; fromProperties?: Record; + /** Emit a base `gsap.set` (off-timeline, no keyframe marker) instead of `tl.set`. */ + global?: boolean; } | { type: "delete"; animationId: string; stripStudioEdits?: boolean } | { @@ -490,6 +500,8 @@ type GsapMutationRequest = type: "convert-to-keyframes"; animationId: string; resolvedFromValues?: Record; + /** Duration (s) to give a converted static `set`, which has none. */ + duration?: number; } | { type: "remove-all-keyframes"; animationId: string } | { @@ -697,6 +709,13 @@ function executeGsapMutationAcorn( properties: { ...r.anim.properties, [body.property]: val }, }); } + case "update-properties": { + const r = requireAnimation(block.scriptText, body.animationId); + if ("err" in r) return r.err; + return updateAnimationInScript(block.scriptText, body.animationId, { + properties: { ...r.anim.properties, ...body.properties }, + }); + } case "update-from-property": case "add-from-property": { const r = requireFromToAnimation(block.scriptText, body.animationId); @@ -721,6 +740,7 @@ function executeGsapMutationAcorn( ease: body.ease, properties: body.properties, fromProperties: body.fromProperties, + ...(body.global ? { global: true } : {}), }); return result.script; } @@ -788,6 +808,7 @@ function executeGsapMutationAcorn( block.scriptText, body.animationId, body.resolvedFromValues, + body.duration, ); } case "remove-all-keyframes": { @@ -979,6 +1000,13 @@ async function executeGsapMutationRecast( properties: { ...r.anim.properties, [body.property]: val }, }); } + case "update-properties": { + const r = requireAnimation(block.scriptText, body.animationId); + if ("err" in r) return r.err; + return updateAnimationInScript(block.scriptText, body.animationId, { + properties: { ...r.anim.properties, ...body.properties }, + }); + } case "update-from-property": case "add-from-property": { const r = requireFromToAnimation(block.scriptText, body.animationId); @@ -1014,6 +1042,7 @@ async function executeGsapMutationRecast( ease: body.ease, properties: body.properties, fromProperties: body.fromProperties, + ...(body.global ? { global: true } : {}), }); return result.script; } @@ -1081,6 +1110,7 @@ async function executeGsapMutationRecast( block.scriptText, body.animationId, body.resolvedFromValues, + body.duration, ); } case "remove-all-keyframes": { diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index aa0f028343..07df9b84fa 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -118,6 +118,7 @@ export function StudioRightPanel({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, commitAnimatedProperty, + commitAnimatedProperties, handleSetArcPath, handleUpdateArcSegment, handleUnroll, @@ -269,9 +270,12 @@ export function StudioRightPanel({ onRemoveGsapFromProperty={handleGsapRemoveFromProperty} onAddGsapAnimation={handleGsapAddAnimation} onCommitAnimatedProperty={commitAnimatedProperty} + onCommitAnimatedProperties={commitAnimatedProperties} onAddKeyframe={handleGsapAddKeyframe} onRemoveKeyframe={handleGsapRemoveKeyframe} - onConvertToKeyframes={handleGsapConvertToKeyframes} + onConvertToKeyframes={(animId, duration) => + handleGsapConvertToKeyframes(animId, undefined, duration) + } onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)} onSetArcPath={handleSetArcPath} onUpdateArcSegment={handleUpdateArcSegment} diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 188d474ddb..bc1394afaa 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "reac import { useMountEffect } from "../../hooks/useMountEffect"; import { type DomEditSelection } from "./domEditing"; import { useMarqueeGestures } from "./marqueeCommit"; +import { MarqueeOverlay } from "./MarqueeOverlay"; import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry"; import { collectDomEditLayerItems } from "./domEditingLayers"; import { isElementComputedVisible } from "./domEditingElement"; @@ -101,6 +102,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const onMarqueeSelectRef = useRef(onMarqueeSelect); onMarqueeSelectRef.current = onMarqueeSelect; + // fallow-ignore-next-line complexity const selectionShapeStyles = (() => { const fallback = { borderRadius: 8 as string | number, @@ -226,6 +228,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ // outside the composition bounds so users can find them. const offCanvasElementsRef = useRef>(new Map()); const [offCanvasRects, setOffCanvasRects] = useState([]); + // fallow-ignore-next-line complexity useEffect(() => { const iframe = iframeRef.current; const overlay = overlayRef.current; @@ -568,18 +571,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ activeCompositionPathRef={activeCompositionPathRef} onSelectionChangeRef={onSelectionChangeRef} /> - {marquee.marqueeRect && ( -