Files
heygen-com__hyperframes/packages/parsers/src/gsapSerialize.ts
Miguel Ángel cdf9c817e1 refactor: extract @hyperframes/parsers from core (#1755)
## Summary

Extracts the GSAP parser/writer suite, HTML parser, hf-ids, spring-ease, and the shared composition data types out of `@hyperframes/core/src/parsers/` into a new, independently-publishable **`@hyperframes/parsers`** package.

This is the foundation of the [#1749](https://github.com/heygen-com/hyperframes/issues/1749) effort: make HyperFrames' parsing/linting/validation usable as plain libraries in a Node app, without shelling out to the CLI. Parsers is the standalone base every other extracted package builds on.

**Part 1 of 3** — splits #1754 into independently-reviewable pieces. Parts 2 (lint) and 3 (studio-server) stack on this branch.

## What moves

| | |
|---|---|
| Source moved out of core | **~9,900 LOC** (`src/parsers/` → `packages/parsers/src/`) |
| Total lines removed from core (incl. tests + goldens) | ~19,600 |
| Files relocated | 39 |
| Tests carried over | **660 passing** (5 skipped, 3 todo) |

The big movers: `gsapParser` / `gsapParserAcorn` (the recast + acorn dual parsers), `gsapWriterAcorn`, `gsapSerialize`, `gsapUnroll`, `htmlParser`, `hfIds`, `springEase`, `stableIds`, plus the `__goldens__` corpus.

## Bundle footprint of the new package

| Artifact | Size |
|---|---|
| `dist/` (unpacked) | 1.7 MB |
| npm tarball (packed) | 409 KB |
| `dist/index.js` | 90 KB (**~21 KB gzipped**) |
| Heaviest entries | `gsapWriterAcorn.js` 93 KB · `gsapParser.js` 91 KB |

Most of the weight is the GSAP AST machinery (recast/babel/acorn). It's tree-shakeable via subpath entries (`@hyperframes/parsers/hf-ids`, `/gsap-constants`, etc.) so a consumer that only needs `hf-ids` (2 KB) doesn't pull the parsers.

## How `@hyperframes/core` changes

The interesting part: **core sheds its entire AST toolchain.**

| core `dependencies` | before | after |
|---|---|---|
| count | 9 | 6 |
| removed | — | `@babel/parser`, `acorn`, `acorn-walk`, `magic-string`, `recast` |
| added | — | `@hyperframes/parsers`, `linkedom` |

Before this PR, importing `@hyperframes/core` at all dragged in babel + recast + acorn just to construct types. Now those live behind `@hyperframes/parsers`, and a consumer that only wants core's runtime/compiler types never resolves the parser stack. Core keeps thin `@deprecated` re-export stubs at the old subpaths (`@hyperframes/core/gsap-parser`, `/gsap-constants`, …) so nothing downstream breaks.

## Design notes

- **`"bun"` export condition before `"node"`** in every package export. Bun resolves the TypeScript source directly (no pre-built `dist/`), while Node/tsx/Docker contexts fall through to `"node"` → `dist/`. This keeps the dev loop zero-build while published artifacts stay Node-consumable.
- `@hyperframes/parsers` is **standalone** — zero `@hyperframes/*` dependencies — so it can be the base of the stack.

## Test plan

- [x] `bun run --filter @hyperframes/parsers test` — 660 tests pass
- [x] `bun run --filter @hyperframes/sdk test` — 382 tests pass
- [x] `bun run build` — full monorepo build succeeds
- [x] Fallow audit passes on CI
2026-06-27 00:46:26 -04:00

605 lines
23 KiB
TypeScript

/**
* Recast-free GSAP helpers: serialization, keyframe<->animation conversion,
* validation, and shared types.
*
* This module MUST NOT import recast / @babel/parser. It is part of the
* isomorphic core layer that the barrel and browser code depend on. AST
* parsing of GSAP source lives in the Node-only `./gsapParser` module.
*/
import type { Keyframe, KeyframeProperties, ValidationResult } from "./types.js";
import type { PropertyGroupName } from "./gsapConstants";
export type GsapMethod = "set" | "to" | "from" | "fromTo";
/** How a tween was constructed in source — drives display classification and editability. */
export type GsapProvenanceKind = "literal" | "helper" | "loop" | "runtime-dynamic";
/**
* Origin of a parsed tween. `literal` tweens map 1:1 to a source call and edit
* directly; `helper`/`loop` tweens are expanded from a reused construct (unroll
* to edit); `runtime-dynamic` tweens come from live introspection (override to
* edit). Absent provenance is treated as `literal`.
*/
export interface GsapProvenance {
kind: GsapProvenanceKind;
/** Helper function name (kind === "helper"). */
fn?: string;
/** 1-based ordinal of the originating call site / loop construct in source order. */
callSite?: number;
/** 0-based iteration index (kind === "loop"). */
iteration?: number;
/** Source offset [start, end] of the originating call/loop, when known. */
sourceRange?: [number, number];
}
/** How a tween's keyframes can be edited, derived from its provenance. */
export type KeyframeEditability = "direct" | "unroll" | "source";
/**
* Map provenance to an editing strategy:
* - `direct` — literal tween, maps 1:1 to source; edit in place.
* - `unroll` — helper/loop expansion; unroll to literal tweens, then edit.
* - `source` — runtime-dynamic value; not statically editable, edit the code.
*/
export function editabilityForProvenance(provenance?: GsapProvenance): KeyframeEditability {
if (!provenance || provenance.kind === "literal") return "direct";
if (provenance.kind === "runtime-dynamic") return "source";
return "unroll";
}
export interface GsapAnimation {
id: string;
targetSelector: string;
method: GsapMethod;
position: number | string;
properties: Record<string, number | string>;
fromProperties?: Record<string, number | string>;
duration?: number;
ease?: string;
/** Non-editable GSAP config (stagger, yoyo, repeat, etc.) preserved for round-trips. */
extras?: Record<string, unknown>;
/** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */
keyframes?: GsapKeyframesData;
/** Arc motion path config — present when the tween uses motionPath for curved position interpolation. */
arcPath?: ArcPathConfig;
/** True when the tween has a `keyframes` property that couldn't be statically resolved (dynamic). */
hasUnresolvedKeyframes?: boolean;
/** True when the tween's target selector couldn't be statically resolved (dynamic). */
hasUnresolvedSelector?: boolean;
/** Absolute start time computed by walking the timeline chain (handles +=, -=, <, >, labels). */
resolvedStart?: number;
/** True when no position arg was authored — the tween is sequentially placed by GSAP. */
implicitPosition?: boolean;
/** 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;
}
export interface GsapPercentageKeyframe {
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}
export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array";
export interface GsapKeyframesData {
format: GsapKeyframeFormat;
keyframes: GsapPercentageKeyframe[];
ease?: string;
easeEach?: string;
}
export interface ArcPathSegment {
curviness: number;
cp1?: { x: number; y: number };
cp2?: { x: number; y: number };
}
export interface ArcPathConfig {
enabled: boolean;
autoRotate: boolean | number;
segments: ArcPathSegment[];
}
export interface MotionPathShape {
arcPath: ArcPathConfig;
waypoints: Array<{ x: number; y: number }>;
}
/**
* Build arcPath segments + waypoints from resolved path coordinates. Shared by
* the AST parser (coords from literal nodes) and the runtime scanner (coords
* from a live `vars.motionPath`), so both produce identical arc config.
*/
export function buildArcPath(
coords: Array<{ x: number; y: number }>,
curviness: number,
autoRotate: boolean | number,
isCubic: boolean,
): MotionPathShape | undefined {
const first = coords[0];
if (coords.length < 2 || !first) return undefined;
const segments: ArcPathSegment[] = [];
let waypoints: Array<{ x: number; y: number }>;
if (isCubic && coords.length >= 4) {
// coords are [anchor, cp1, cp2, anchor, cp1, cp2, anchor, ...].
waypoints = [first];
for (let i = 1; i + 2 < coords.length; i += 3) {
const cp1 = coords[i];
const cp2 = coords[i + 1];
const anchor = coords[i + 2];
if (!cp1 || !cp2 || !anchor) continue;
waypoints.push(anchor);
segments.push({ curviness, cp1, cp2 });
}
} else {
waypoints = coords;
for (let i = 0; i < waypoints.length - 1; i++) segments.push({ curviness });
}
return { arcPath: { enabled: true, autoRotate, segments }, waypoints };
}
export interface ParsedGsap {
animations: GsapAnimation[];
timelineVar: string;
preamble: string;
postamble: string;
multipleTimelines?: boolean;
unsupportedTimelinePattern?: boolean;
}
export { SUPPORTED_PROPS, SUPPORTED_EASES } from "./gsapConstants";
// ── Split-animation types (used by gsapWriterAcorn) ─────────────────────────
export interface SplitAnimationsOptions {
originalId: string;
newId: string;
splitTime: number;
elementStart: number;
elementDuration: number;
}
export interface SplitAnimationsResult {
script: string;
/** Non-ID-selector animations that the engine cannot safely retarget. */
skippedSelectors: string[];
}
// ── Serialization ───────────────────────────────────────────────────────────
export function serializeGsapAnimations(
animations: GsapAnimation[],
timelineVar = "tl",
options?: { includeMediaSync?: boolean; preamble?: string; postamble?: string },
): string {
const sorted = [...animations].sort((a, b) => {
const aNum =
a.resolvedStart ?? (typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER);
const bNum =
b.resolvedStart ?? (typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER);
return aNum - bNum;
});
// fallow-ignore-next-line complexity
const lines = sorted.map((anim) => {
const selector = `"${anim.targetSelector}"`;
const props: Record<string, number | string> = { ...anim.properties };
if (anim.duration !== undefined) props.duration = anim.duration;
if (anim.ease) props.ease = anim.ease;
let propsStr = serializeObject(props);
if (anim.extras && Object.keys(anim.extras).length > 0) {
const extrasStr = serializeExtras(anim.extras);
if (Object.keys(props).length === 0) {
propsStr = `{ ${extrasStr} }`;
} else {
// Insert extras before the closing brace
propsStr = propsStr.slice(0, -2) + `, ${extrasStr} }`;
}
}
const posStr = typeof anim.position === "string" ? `"${anim.position}"` : anim.position;
switch (anim.method) {
case "set":
// 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":
return ` ${timelineVar}.from(${selector}, ${propsStr}, ${posStr});`;
case "fromTo": {
const fromStr = serializeObject(anim.fromProperties || {});
return ` ${timelineVar}.fromTo(${selector}, ${fromStr}, ${propsStr}, ${posStr});`;
}
}
});
let mediaSync = "";
if (options?.includeMediaSync) {
mediaSync = `
${timelineVar}.eventCallback("onUpdate", function() {
const time = ${timelineVar}.time();
document.querySelectorAll("video[data-start], audio[data-start]").forEach(function(media) {
const start = parseFloat(media.dataset.start);
const end = parseFloat(media.dataset.end) || Infinity;
const mediaTime = time - start;
if (time >= start && time < end) {
if (Math.abs(media.currentTime - mediaTime) > 0.1) {
media.currentTime = mediaTime;
}
if (media.paused && !${timelineVar}.paused()) {
media.play().catch(function() {});
}
} else if (!media.paused) {
media.pause();
}
});
});`;
}
const preamble = options?.preamble || `const ${timelineVar} = gsap.timeline({ paused: true });`;
const postamble = options?.postamble ? `\n ${options.postamble}` : "";
return `
${preamble}
${lines.join("\n")}${mediaSync}${postamble}
`;
}
export function serializeValue(value: unknown): string {
if (typeof value === "string" && value.startsWith("__raw:")) {
return value.slice(6);
}
if (typeof value === "string") return JSON.stringify(value);
return String(value);
}
export function safeJsKey(key: string): string {
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
}
function serializeObject(obj: Record<string, number | string>): string {
const entries = Object.entries(obj).map(([key, value]) => {
return `${safeJsKey(key)}: ${serializeValue(value)}`;
});
return `{ ${entries.join(", ")} }`;
}
function serializeExtras(extras: Record<string, unknown>): string {
return Object.entries(extras)
.map(([key, value]) => {
return `${safeJsKey(key)}: ${serializeValue(value)}`;
})
.join(", ");
}
// ── Element filtering ─────────────────────────────────────────────────────────
/**
* Filter animations to those targeting `#<elementId>` (id-only match). For the
* studio panel's id-OR-selector matching, see `getAnimationsForElement` in
* `useGsapTweenCache.ts` — distinct on purpose, hence the distinct name.
*/
export function getAnimationsForElementId(
animations: GsapAnimation[],
elementId: string,
): GsapAnimation[] {
const selector = `#${elementId}`;
return animations.filter((a) => a.targetSelector === selector);
}
// ── Validation (regex-based, no AST needed) ─────────────────────────────────
const FORBIDDEN_GSAP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
{ pattern: /\.call\s*\(/, message: "call() method not allowed" },
{ pattern: /\.add\s*\(/, message: "add() method not allowed" },
{ pattern: /\.addPause\s*\(/, message: "addPause() method not allowed" },
{ pattern: /gsap\.registerEffect\s*\(/, message: "registerEffect() not allowed" },
{ pattern: /ScrollTrigger/, message: "ScrollTrigger not allowed" },
{ pattern: /onComplete\s*:/, message: "onComplete callback not allowed" },
{ pattern: /onUpdate\s*:/, message: "onUpdate callback not allowed" },
{ pattern: /onStart\s*:/, message: "onStart callback not allowed" },
{ pattern: /onRepeat\s*:/, message: "onRepeat callback not allowed" },
{ pattern: /onReverseComplete\s*:/, message: "onReverseComplete callback not allowed" },
{ pattern: /repeat\s*:\s*-1/, message: "Infinite repeat (repeat: -1) not allowed" },
{ pattern: /Math\.random\s*\(/, message: "Random values (Math.random) not allowed" },
{ pattern: /Date\.now\s*\(/, message: "Date-dependent values (Date.now) not allowed" },
{ pattern: /new\s+Date\s*\(/, message: "Date constructor not allowed" },
{ pattern: /setTimeout\s*\(/, message: "setTimeout not allowed" },
{ pattern: /setInterval\s*\(/, message: "setInterval not allowed" },
{ pattern: /requestAnimationFrame\s*\(/, message: "requestAnimationFrame not allowed" },
];
export function validateCompositionGsap(script: string): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
for (const { pattern, message } of FORBIDDEN_GSAP_PATTERNS) {
if (pattern.test(script)) errors.push(message);
}
if (/yoyo\s*:\s*true/.test(script)) {
warnings.push("yoyo animations may behave unexpectedly when scrubbing");
}
if (/stagger\s*:/.test(script)) {
warnings.push("stagger animations may not serialize correctly");
}
return { valid: errors.length === 0, errors, warnings };
}
// ── Keyframe Conversion Helpers ─────────────────────────────────────────────
export function keyframesToGsapAnimations(
elementId: string,
keyframes: Keyframe[],
elementStartTime: number,
base?: { x?: number; y?: number; scale?: number },
): GsapAnimation[] {
const sorted = [...keyframes].sort((a, b) => a.time - b.time);
const animations: GsapAnimation[] = [];
const baseX = base?.x ?? 0;
const baseY = base?.y ?? 0;
const baseScale = base?.scale ?? 1;
// fallow-ignore-next-line complexity
sorted.forEach((kf, i) => {
const absoluteTime = elementStartTime + kf.time;
const isFirst = i === 0;
const prevKf = i > 0 ? sorted[i - 1] : null;
const duration = prevKf ? kf.time - prevKf.time : undefined;
const position = prevKf ? elementStartTime + prevKf.time : absoluteTime;
const properties: Record<string, number | string> = {};
for (const [key, value] of Object.entries(kf.properties)) {
if (typeof value !== "number") continue;
if (key === "x") properties.x = baseX + value;
else if (key === "y") properties.y = baseY + value;
else if (key === "scale") properties.scale = baseScale * value;
else properties[key] = value;
}
animations.push({
id: `${elementId}-kf-${kf.id}`,
targetSelector: `#${elementId}`,
method: isFirst ? "set" : "to",
position,
properties,
duration: isFirst ? undefined : duration,
ease: kf.ease,
});
});
return animations;
}
export function gsapAnimationsToKeyframes(
animations: GsapAnimation[],
elementStartTime: number,
options?: {
baseX?: number;
baseY?: number;
baseScale?: number;
clampTimeToZero?: boolean;
skipBaseSet?: boolean;
},
): Keyframe[] {
const validMethods: GsapMethod[] = ["set", "to", "from", "fromTo"];
const baseX = options?.baseX ?? 0;
const baseY = options?.baseY ?? 0;
const baseScale = options?.baseScale ?? 1;
const clampTimeToZero = options?.clampTimeToZero ?? true;
const skipBaseSet = options?.skipBaseSet ?? false;
const baseTimeEpsilon = 0.001;
const baseValueEpsilon = 0.00001;
return (
animations
.filter(
(a): a is GsapAnimation & { position: number } =>
validMethods.includes(a.method) && typeof a.position === "number",
)
// fallow-ignore-next-line complexity
.map((a) => {
const relativeTimeRaw = a.position - elementStartTime;
const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw;
const properties: Partial<KeyframeProperties> = {};
for (const [key, value] of Object.entries(a.properties)) {
if (typeof value !== "number") continue;
if (key === "x") properties.x = value - baseX;
else if (key === "y") properties.y = value - baseY;
else if (key === "scale") {
properties.scale = baseScale !== 0 ? value / baseScale : value;
} else {
(properties as Record<string, number>)[key] = value;
}
}
if (
skipBaseSet &&
a.method === "set" &&
time < baseTimeEpsilon &&
Object.values(properties).every(
(v) => typeof v === "number" && Math.abs(v) < baseValueEpsilon,
)
) {
return null;
}
return {
id: a.id.replace(/^.*-kf-/, ""),
time,
properties: properties as KeyframeProperties,
ease: a.ease,
};
})
.filter((kf): kf is NonNullable<typeof kf> => kf !== null)
);
}
// ── Keyframe-conversion transforms (pure; shared by recast + acorn writers) ────
/**
* CSS identity values for properties whose "rest" state isn't 0 — used to
* synthesize the missing endpoint when converting a flat tween to keyframes.
*/
const CSS_IDENTITY: Record<string, number> = {
opacity: 1,
autoAlpha: 1,
scale: 1,
scaleX: 1,
scaleY: 1,
};
function cssIdentityValue(prop: string): number {
return CSS_IDENTITY[prop] ?? 0;
}
/** Build the identity-endpoint map for a flat tween's properties. */
function buildIdentityMap(props: Record<string, number | string>): Record<string, number | string> {
const identity: Record<string, number | string> = {};
for (const [key, val] of Object.entries(props)) {
if (val != null) identity[key] = typeof val === "number" ? cssIdentityValue(key) : val;
}
return identity;
}
/**
* Resolve the 0% (from) and 100% (to) property maps for a tween being
* converted to percentage keyframes.
*
* @param resolvedFromValues — Despite the "from" in the name (historical), these
* are runtime-captured DOM values that override the conversion endpoint:
* - For to(): overrides fromProps (the 0% state / where the element is now).
* - For from(): overrides toProps (the 100% state / where the element rests).
* - For fromTo(): merges into toProps (the 100% endpoint the user is editing).
*/
export function resolveConversionProps(
anim: GsapAnimation,
resolvedFromValues?: Record<string, number | string>,
): { fromProps: Record<string, number | string>; toProps: Record<string, number | string> } {
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;
return { fromProps, toProps: { ...anim.properties } };
}
if (anim.method === "from") {
const identity = buildIdentityMap(anim.properties);
const toProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity;
return { fromProps: { ...anim.properties }, toProps };
}
// fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state),
// anim.properties = toVars (100% state). resolvedFromValues contains the
// current DOM position from a drag — it represents the NEW destination, so
// it merges into toProps (the 100% endpoint the user is editing), NOT into
// fromProps. This is intentional and not inverted.
const toProps = resolvedFromValues
? { ...anim.properties, ...resolvedFromValues }
: { ...anim.properties };
return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps };
}
// ── Arc path serialization helpers (shared by recast + acorn writers) ─────────
function numericXY(props: Record<string, number | string>): { x: number; y: number } | null {
const vx = props.x;
const vy = props.y;
return typeof vx === "number" && typeof vy === "number" ? { x: vx, y: vy } : null;
}
export function extractArcWaypoints(anim: GsapAnimation): Array<{ x: number; y: number }> {
const keyframeWps = (anim.keyframes?.keyframes ?? [])
.map((kf) => numericXY(kf.properties))
.filter((pt): pt is { x: number; y: number } => pt !== null);
if (keyframeWps.length >= 2) return keyframeWps;
const propX = anim.properties.x;
const propY = anim.properties.y;
if (typeof propX !== "number" && typeof propY !== "number") return keyframeWps;
const destX = typeof propX === "number" ? propX : 0;
const destY = typeof propY === "number" ? propY : 0;
return [
{ x: 0, y: 0 },
{ x: destX, y: destY },
];
}
function autoRotateSuffix(autoRotate: boolean | number): string {
if (autoRotate === true) return ", autoRotate: true";
if (typeof autoRotate === "number") return `, autoRotate: ${autoRotate}`;
return "";
}
function cubicControlPoints(
seg: ArcPathSegment,
wp: { x: number; y: number },
nextWp: { x: number; y: number },
): string[] {
if (seg.cp1 && seg.cp2) {
return [`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`, `{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`];
}
const dx = nextWp.x - wp.x;
const dy = nextWp.y - wp.y;
const c = seg.curviness ?? 1;
return [
`{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`,
`{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`,
];
}
function buildCubicPathEntries(
waypoints: Array<{ x: number; y: number }>,
segments: ArcPathSegment[],
): string[] {
const first = waypoints[0];
if (!first) return [];
const entries = [`{x: ${first.x}, y: ${first.y}}`];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const wp = waypoints[i];
const nextWp = waypoints[i + 1];
if (!seg || !wp || !nextWp) continue;
entries.push(...cubicControlPoints(seg, wp, nextWp));
entries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`);
}
return entries;
}
export function buildMotionPathObjectCode(config: {
waypoints: Array<{ x: number; y: number }>;
segments: ArcPathSegment[];
autoRotate: boolean | number;
}): string {
const { waypoints, segments, autoRotate } = config;
const arSuffix = autoRotateSuffix(autoRotate);
// GSAP's simple `path` array supports only ONE scalar `curviness` for the whole
// path, so per-segment curviness can only be expressed in the cubic form (each
// segment's curviness baked into its control points). Emit cubic when segments
// carry explicit control points OR when their curviness values differ — the
// simple branch would otherwise serialize only segments[0].curviness and drop
// every other segment's curve.
const hasExplicitCp = segments.some((s) => s.cp1 && s.cp2);
const curvinessVaries = segments.some(
(s) => (s.curviness ?? 1) !== (segments[0]?.curviness ?? 1),
);
if ((hasExplicitCp || curvinessVaries) && waypoints.length >= 2) {
const pathStr = buildCubicPathEntries(waypoints, segments).join(", ");
return `{ path: [${pathStr}], type: "cubic"${arSuffix} }`;
}
const pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`);
const curviness = segments[0]?.curviness ?? 1;
const curvPart = curviness !== 1 ? `, curviness: ${curviness}` : "";
return `{ path: [${pathEntries.join(", ")}]${curvPart}${arSuffix} }`;
}