Files
heygen-com--hyperframes/packages/parsers/src/gsapSerialize.ts
T
wehub-resource-sync 85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08: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} }`;
}