chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,604 @@
|
||||
/**
|
||||
* 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} }`;
|
||||
}
|
||||
Reference in New Issue
Block a user