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
605 lines
23 KiB
TypeScript
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} }`;
|
|
}
|