feat(studio): per-keyframe ease presets, velocity fitting, gesture smoothing (#1694)

Per-keyframe speed-curve editing, velocity-based ease fitting, and Gaussian gesture smoothing. Easy Ease presets, per-segment KeyframeEaseList with a bezier editor, AE-convention ease fitting, position-only set-tween rows, and AnimationCard extraction.
This commit is contained in:
Miguel Ángel
2026-06-24 18:43:37 -04:00
committed by GitHub
parent 8ae010bf51
commit 97db811a2f
18 changed files with 779 additions and 322 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node -e \"\nconst chunks = [];\nprocess.stdin.on('data', d => chunks.push(d));\nprocess.stdin.on('end', () => {\n const input = JSON.parse(Buffer.concat(chunks).toString());\n const cmd = input.tool_input?.command || '';\n if (!/git\\\\s+commit\\\\b/.test(cmd)) process.exit(0);\n const { execSync } = require('child_process');\n const cwd = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();\n const steps = [\n ['bun run build', 'Build'],\n ['bun run lint', 'Lint'],\n ['bun run --filter \\'*\\' typecheck 2>&1 | grep -v \\'vitest\\\\|test\\\\.ts\\' || true', 'Typecheck'],\n ];\n const failures = [];\n for (const [script, label] of steps) {\n try { execSync(script, { cwd, stdio: 'pipe' }); }\n catch (e) {\n failures.push(label + ':\\\\n' + (e.stdout?.toString() || e.message).slice(0, 400));\n }\n }\n if (failures.length > 0) {\n process.stdout.write(JSON.stringify({\n continue: false,\n stopReason: '\\u274c Pre-commit checks failed:\\\\n\\\\n' + failures.join('\\\\n\\\\n') + '\\\\n\\\\nFix the issues above before committing.',\n }));\n }\n});\"",
"timeout": 180,
"statusMessage": "Running build + lint + typecheck before commit…"
}
]
}
]
}
}
+3 -1
View File
@@ -437,7 +437,7 @@ type GsapMutationRequest =
| {
type: "update-meta";
animationId: string;
updates: { duration?: number; ease?: string; position?: number };
updates: { duration?: number; ease?: string; easeEach?: string; position?: number };
}
| {
type: "add";
@@ -552,6 +552,7 @@ type GsapMutationRequest =
auto?: boolean;
}>;
ease?: string;
easeEach?: string;
}
| {
type: "replace-with-keyframes";
@@ -827,6 +828,7 @@ function executeGsapMutationAcorn(
body.duration,
body.keyframes,
body.ease,
body.easeEach,
);
return result.script;
}
@@ -4,237 +4,19 @@ import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constan
import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
import { MetricField, SelectField } from "./propertyPanelPrimitives";
import { controlPointsForGsapEase } from "./studioMotion";
import {
EASE_LABELS,
METHOD_LABELS,
METHOD_TOOLTIPS,
PERCENT_PROPS,
PROP_CONSTRAINTS,
PROP_LABELS,
PROP_TOOLTIPS,
PROP_UNITS,
clampPropertyValue,
} from "./gsapAnimationConstants";
import { EASE_LABELS, METHOD_LABELS, METHOD_TOOLTIPS, PROP_LABELS } from "./gsapAnimationConstants";
import { buildTweenSummary } from "./gsapAnimationHelpers";
import { EaseCurveSection } from "./EaseCurveSection";
import { ArcPathControls } from "./ArcPathControls";
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
import { ComputedTweenNotice } from "./ComputedTweenNotice";
import { P } from "./panelTokens";
const BOOLEAN_PROPS = new Set(["visibility"]);
const STRING_PROPS = new Set(["filter", "clipPath"]);
const FILTER_PRESETS = [
{ label: "Blur", value: "blur(4px)" },
{ label: "Bright", value: "brightness(1.5)" },
{ label: "Gray", value: "grayscale(1)" },
{ label: "None", value: "none" },
];
const CLIP_PATH_PRESETS = [
{ label: "Circle", value: "circle(50% at 50% 50%)" },
{ label: "Inset", value: "inset(10%)" },
{ label: "None", value: "none" },
];
function isPercentProp(prop: string): boolean {
return PERCENT_PROPS.has(prop);
}
function displayValue(prop: string, val: number | string): string {
if (isPercentProp(prop)) return String(Math.round(Math.max(0, Math.min(1, Number(val))) * 100));
return String(val);
}
function adjustedValue(prop: string, raw: string): string {
if (isPercentProp(prop)) return String(clampPropertyValue(prop, Number(raw) / 100));
const num = Number(raw);
if (!Number.isNaN(num) && PROP_CONSTRAINTS[prop]) {
return String(clampPropertyValue(prop, num));
}
return raw;
}
function RemoveButton({ onClick, title }: { onClick: () => void; title: string }) {
return (
<button
type="button"
onClick={onClick}
className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
title={title}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
);
}
function PropertyRow({
prop,
val,
onCommit,
onRemove,
removeTitle,
}: {
prop: string;
val: number | string;
onCommit: (adjusted: string) => void;
onRemove: () => void;
removeTitle: string;
}) {
if (BOOLEAN_PROPS.has(prop)) {
const isVisible = val === "visible" || val === 1;
return (
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
<span className="flex-1 text-[11px] font-medium text-neutral-500">
{PROP_LABELS[prop] ?? prop}
</span>
<button
type="button"
onClick={() => onCommit(isVisible ? "hidden" : "visible")}
className={`flex-shrink-0 rounded-full transition-all duration-150 relative`}
style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
>
<span
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
style={{
width: 12,
height: 12,
background: isVisible ? P.white : P.textMuted,
transform: isVisible ? "translateX(14px)" : "translateX(2px)",
}}
/>
</button>
</div>
<RemoveButton onClick={onRemove} title={removeTitle} />
</div>
);
}
if (STRING_PROPS.has(prop)) {
const presets =
prop === "filter" ? FILTER_PRESETS : prop === "clipPath" ? CLIP_PATH_PRESETS : [];
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
<span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">
{PROP_LABELS[prop] ?? prop}
</span>
<input
type="text"
defaultValue={String(val)}
className="flex-1 bg-transparent text-[11px] text-neutral-200 outline-none"
onBlur={(e) => onCommit(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
/>
</div>
<RemoveButton onClick={onRemove} title={removeTitle} />
</div>
{presets.length > 0 && (
<div className="flex gap-1 pl-1">
{presets.map((p) => (
<button
key={p.value}
type="button"
onClick={() => onCommit(p.value)}
className="px-1.5 py-0.5 rounded text-[9px] font-medium text-neutral-500 bg-neutral-800/50 hover:bg-neutral-800 hover:text-neutral-300 transition-colors"
>
{p.label}
</button>
))}
</div>
)}
</div>
);
}
return (
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1">
<MetricField
label={PROP_LABELS[prop] ?? prop}
value={displayValue(prop, val)}
suffix={PROP_UNITS[prop]}
tooltip={PROP_TOOLTIPS[prop]}
scrub
liveCommit
onCommit={(raw) => onCommit(adjustedValue(prop, raw))}
/>
</div>
<RemoveButton onClick={onRemove} title={removeTitle} />
</div>
);
}
function AddPropertyTrigger({
adding,
available,
addLabel,
addTitle,
onAdd,
onOpen,
onClose,
buttonClassName,
}: {
adding: boolean;
available: string[];
addLabel: string;
addTitle: string;
onAdd: (prop: string) => void;
onOpen: () => void;
onClose: () => void;
buttonClassName: string;
}) {
if (adding && available.length > 0) {
return (
<select
autoFocus
className="min-w-0 rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-[11px] text-neutral-100 outline-none"
defaultValue=""
onChange={(e) => {
if (e.target.value) onAdd(e.target.value);
onClose();
}}
onBlur={onClose}
>
<option value="" disabled>
Choose property
</option>
{available.map((p) => (
<option key={p} value={p}>
{PROP_LABELS[p] ?? p}
</option>
))}
</select>
);
}
if (available.length === 0) return null;
return (
<button type="button" onClick={onOpen} className={buttonClassName} title={addTitle}>
{addLabel}
</button>
);
}
function parseNumericOrString(raw: string): number | string {
const num = Number(raw);
return Number.isFinite(num) ? num : raw;
}
import { KeyframeEaseList } from "./KeyframeEaseList";
import {
PropertyRow,
AddPropertyTrigger,
parseNumericOrString,
BOOLEAN_PROPS,
} from "./AnimationCardParts";
interface AnimationCardProps extends GsapAnimationEditCallbacks {
animation: GsapAnimation;
@@ -257,11 +39,13 @@ export const AnimationCard = memo(function AnimationCard({
onLivePreviewEnd,
onSetArcPath,
onUpdateArcSegment,
onUpdateKeyframeEase,
onUnroll,
}: AnimationCardProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [addingProp, setAddingProp] = useState(false);
const [addingFromProp, setAddingFromProp] = useState(false);
const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
const usedProps = useMemo(
() => new Set(Object.keys(animation.properties)),
@@ -330,7 +114,8 @@ export const AnimationCard = memo(function AnimationCard({
const [copied, setCopied] = useState(false);
const methodLabel = METHOD_LABELS[animation.method] ?? animation.method;
const easeName = animation.ease ?? animation.keyframes?.easeEach ?? "none";
const easeName =
(animation.keyframes ? animation.keyframes.easeEach : undefined) ?? animation.ease ?? "none";
const easeLabel = easeName.startsWith("custom(")
? "Custom curve"
: (EASE_LABELS[easeName] ?? easeName);
@@ -340,6 +125,28 @@ export const AnimationCard = memo(function AnimationCard({
: animation.position;
const summary = useMemo(() => buildTweenSummary(animation), [animation]);
const setKeys = Object.keys(animation.properties);
if (
animation.method === "set" &&
// `every` is vacuously true on an empty bag — require at least one key so a
// property-less set doesn't masquerade as a position row.
(setKeys.includes("x") || setKeys.includes("y")) &&
setKeys.every((k) => k === "x" || k === "y" || k === "immediateRender")
)
return (
<div className="border-b border-neutral-800 pb-2">
<div className="flex items-center gap-2 py-1.5">
<span className="rounded bg-neutral-800 px-1.5 py-0.5 text-[10px] font-medium text-neutral-400">
Position
</span>
<span className="text-[11px] text-neutral-500">
x: {Math.round(Number(animation.properties.x ?? 0))}, y:{" "}
{Math.round(Number(animation.properties.y ?? 0))}
</span>
<span className="ml-auto text-[9px] text-neutral-600">drag to move</span>
</div>
</div>
);
return (
<div className="border-b border-neutral-800 pb-3">
@@ -393,7 +200,7 @@ export const AnimationCard = memo(function AnimationCard({
clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)",
}}
/>
Keyframed edit values in the Layout panel above
Keyframed click a segment below to edit its curve
</p>
)}
</div>
@@ -434,32 +241,46 @@ export const AnimationCard = memo(function AnimationCard({
</div>
{animation.method !== "set" && (
<>
{animation.keyframes && onUpdateKeyframeEase ? (
<KeyframeEaseList
keyframes={animation.keyframes.keyframes}
globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
expandedPct={expandedKfPct}
onToggle={setExpandedKfPct}
onEaseCommit={(pct, ease) => onUpdateKeyframeEase(animation.id, pct, ease)}
/>
) : (
<>
<SelectField
label="Speed"
value={easeName.startsWith("custom(") ? "custom" : easeName}
options={[...SUPPORTED_EASES, "custom"]}
onChange={(next) => {
const easeKey = animation.keyframes ? "easeEach" : "ease";
if (next === "custom") {
const points = controlPointsForGsapEase(
easeName !== "none" ? easeName : "power2.out",
);
const path = `M0,0 C${points.x1},${points.y1} ${points.x2},${points.y2} 1,1`;
onUpdateMeta(animation.id, { ease: `custom(${path})` });
onUpdateMeta(animation.id, { [easeKey]: `custom(${path})` });
} else {
onUpdateMeta(animation.id, { ease: next });
onUpdateMeta(animation.id, { [easeKey]: next });
}
}}
/>
<EaseCurveSection
ease={easeName}
duration={animation.duration}
onCustomEaseCommit={(customEase) =>
onUpdateMeta(animation.id, { ease: customEase })
}
onCustomEaseCommit={(customEase) => {
const easeKey = animation.keyframes ? "easeEach" : "ease";
onUpdateMeta(animation.id, { [easeKey]: customEase });
}}
/>
</>
)}
</>
)}
{animation.method === "fromTo" && (
<div className="space-y-1">
@@ -0,0 +1,220 @@
import { MetricField } from "./propertyPanelPrimitives";
import {
PERCENT_PROPS,
PROP_CONSTRAINTS,
PROP_LABELS,
PROP_TOOLTIPS,
PROP_UNITS,
clampPropertyValue,
} from "./gsapAnimationConstants";
import { P } from "./panelTokens";
export const BOOLEAN_PROPS = new Set(["visibility"]);
const STRING_PROPS = new Set(["filter", "clipPath"]);
const FILTER_PRESETS = [
{ label: "Blur", value: "blur(4px)" },
{ label: "Bright", value: "brightness(1.5)" },
{ label: "Gray", value: "grayscale(1)" },
{ label: "None", value: "none" },
];
const CLIP_PATH_PRESETS = [
{ label: "Circle", value: "circle(50% at 50% 50%)" },
{ label: "Inset", value: "inset(10%)" },
{ label: "None", value: "none" },
];
function isPercentProp(prop: string): boolean {
return PERCENT_PROPS.has(prop);
}
function displayValue(prop: string, val: number | string): string {
if (isPercentProp(prop)) return String(Math.round(Math.max(0, Math.min(1, Number(val))) * 100));
return String(val);
}
function adjustedValue(prop: string, raw: string): string {
if (isPercentProp(prop)) return String(clampPropertyValue(prop, Number(raw) / 100));
const num = Number(raw);
if (!Number.isNaN(num) && PROP_CONSTRAINTS[prop]) {
return String(clampPropertyValue(prop, num));
}
return raw;
}
function RemoveButton({ onClick, title }: { onClick: () => void; title: string }) {
return (
<button
type="button"
onClick={onClick}
className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
title={title}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
);
}
// fallow-ignore-next-line complexity
export function PropertyRow({
prop,
val,
onCommit,
onRemove,
removeTitle,
}: {
prop: string;
val: number | string;
onCommit: (adjusted: string) => void;
onRemove: () => void;
removeTitle: string;
}) {
if (BOOLEAN_PROPS.has(prop)) {
const isVisible = val === "visible" || val === 1;
return (
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
<span className="flex-1 text-[11px] font-medium text-neutral-500">
{PROP_LABELS[prop] ?? prop}
</span>
<button
type="button"
onClick={() => onCommit(isVisible ? "hidden" : "visible")}
className="flex-shrink-0 rounded-full transition-all duration-150 relative"
style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
>
<span
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
style={{
width: 12,
height: 12,
background: isVisible ? P.white : P.textMuted,
transform: isVisible ? "translateX(14px)" : "translateX(2px)",
}}
/>
</button>
</div>
<RemoveButton onClick={onRemove} title={removeTitle} />
</div>
);
}
if (STRING_PROPS.has(prop)) {
const presets =
prop === "filter" ? FILTER_PRESETS : prop === "clipPath" ? CLIP_PATH_PRESETS : [];
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
<span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">
{PROP_LABELS[prop] ?? prop}
</span>
<input
type="text"
defaultValue={String(val)}
className="flex-1 bg-transparent text-[11px] text-neutral-200 outline-none"
onBlur={(e) => onCommit(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
/>
</div>
<RemoveButton onClick={onRemove} title={removeTitle} />
</div>
{presets.length > 0 && (
<div className="flex gap-1 pl-1">
{presets.map((p) => (
<button
key={p.value}
type="button"
onClick={() => onCommit(p.value)}
className="px-1.5 py-0.5 rounded text-[9px] font-medium text-neutral-500 bg-neutral-800/50 hover:bg-neutral-800 hover:text-neutral-300 transition-colors"
>
{p.label}
</button>
))}
</div>
)}
</div>
);
}
return (
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1">
<MetricField
label={PROP_LABELS[prop] ?? prop}
value={displayValue(prop, val)}
suffix={PROP_UNITS[prop]}
tooltip={PROP_TOOLTIPS[prop]}
scrub
liveCommit
onCommit={(raw) => onCommit(adjustedValue(prop, raw))}
/>
</div>
<RemoveButton onClick={onRemove} title={removeTitle} />
</div>
);
}
export function AddPropertyTrigger({
adding,
available,
addLabel,
addTitle,
onAdd,
onOpen,
onClose,
buttonClassName,
}: {
adding: boolean;
available: string[];
addLabel: string;
addTitle: string;
onAdd: (prop: string) => void;
onOpen: () => void;
onClose: () => void;
buttonClassName: string;
}) {
if (adding && available.length > 0) {
return (
<select
autoFocus
className="min-w-0 rounded-lg border border-neutral-700 bg-neutral-900 px-2 py-1 text-[11px] text-neutral-100 outline-none"
defaultValue=""
onChange={(e) => {
if (e.target.value) onAdd(e.target.value);
onClose();
}}
onBlur={onClose}
>
<option value="" disabled>
Choose property
</option>
{available.map((p) => (
<option key={p} value={p}>
{PROP_LABELS[p] ?? p}
</option>
))}
</select>
);
}
if (available.length === 0) return null;
return (
<button type="button" onClick={onOpen} className={buttonClassName} title={addTitle}>
{addLabel}
</button>
);
}
export function parseNumericOrString(raw: string): number | string {
const num = Number(raw);
return Number.isFinite(num) ? num : raw;
}
@@ -1,16 +1,16 @@
import { memo, useCallback, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants";
import { roundToCenti } from "../../utils/rounding";
const PRESET_GRID_EASES = [
"ae-ease",
"ae-ease-in",
"ae-ease-out",
"none",
"power2.out",
"power2.in",
"power2.inOut",
"power3.out",
"back.out",
"expo.out",
"elastic.out",
] as const;
function MiniCurveSvg({
@@ -40,7 +40,7 @@ function MiniCurveSvg({
);
}
const EasePresetGrid = memo(function EasePresetGrid({
const EasePresetGrid = function EasePresetGrid({
currentEase,
onSelect,
}: {
@@ -74,7 +74,7 @@ const EasePresetGrid = memo(function EasePresetGrid({
})}
</div>
);
});
};
const round2 = roundToCenti;
@@ -30,6 +30,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
onLivePreviewEnd,
onSetArcPath,
onUpdateArcSegment,
onUpdateKeyframeEase,
onUnroll,
}: GsapAnimationSectionProps) {
const [addMenuOpen, setAddMenuOpen] = useState(false);
@@ -68,6 +69,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
onLivePreviewEnd={onLivePreviewEnd}
onSetArcPath={onSetArcPath}
onUpdateArcSegment={onUpdateArcSegment}
onUpdateKeyframeEase={onUpdateKeyframeEase}
onUnroll={onUnroll}
/>
))}
@@ -0,0 +1,63 @@
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
import { EASE_LABELS } from "./gsapAnimationConstants";
import { EaseCurveSection } from "./EaseCurveSection";
export function KeyframeEaseList({
keyframes,
globalEase,
expandedPct,
onToggle,
onEaseCommit,
}: {
keyframes: GsapPercentageKeyframe[];
globalEase: string;
expandedPct: number | null;
onToggle: (pct: number | null) => void;
onEaseCommit: (pct: number, ease: string) => void;
}) {
return (
<div className="space-y-1">
<p className="text-[9px] font-semibold uppercase tracking-wider text-neutral-500">
Per-keyframe easing
</p>
{keyframes.map((kf, i) => {
if (i === 0) return null;
const segEase = kf.ease ?? globalEase;
const isExpanded = expandedPct === kf.percentage;
const label = `${keyframes[i - 1].percentage}% → ${kf.percentage}%`;
const easeLabel = segEase.startsWith("custom(")
? "Custom"
: (EASE_LABELS[segEase] ?? segEase);
return (
<div key={`${i}-${kf.percentage}`} className="rounded-md bg-neutral-900/50">
<button
type="button"
onClick={() => onToggle(isExpanded ? null : kf.percentage)}
className="flex w-full items-center gap-2 px-2 py-1.5 text-left"
>
<span className="text-[10px] font-medium text-neutral-400">{label}</span>
<span className="ml-auto text-[9px] text-neutral-500">{easeLabel}</span>
<svg
width="8"
height="8"
viewBox="0 0 10 10"
fill="currentColor"
className={`text-neutral-500 transition-transform ${isExpanded ? "" : "-rotate-90"}`}
>
<path d="M2 3l3 4 3-4z" />
</svg>
</button>
{isExpanded && (
<div className="px-2 pb-2">
<EaseCurveSection
ease={segEase}
onCustomEaseCommit={(ease) => onEaseCommit(kf.percentage, ease)}
/>
</div>
)}
</div>
);
})}
</div>
);
}
@@ -84,6 +84,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onSetArcPath,
onUpdateArcSegment,
onUnroll,
onUpdateKeyframeEase,
onAddKeyframe,
onRemoveKeyframe,
onConvertToKeyframes,
@@ -233,25 +234,12 @@ export const PropertyPanel = memo(function PropertyPanel({
const displayH = gsapRuntimeValues?.height ?? resolvedHeight;
const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
<div className="px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-neutral-100">
{element.label}
</div>
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => {
// fallow-ignore-next-line complexity
const handleCopyElementInfo = () => {
const file = element.sourceFile ?? "index.html";
let lineNum: number | null = null;
try {
const src =
previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
const src = previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
if (src && element.id) {
const idx = src.indexOf(`id="${element.id}"`);
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
@@ -274,10 +262,7 @@ export const PropertyPanel = memo(function PropertyPanel({
`Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
`Tag: <${element.tagName}>`,
];
if (
element.computedStyles["z-index"] &&
element.computedStyles["z-index"] !== "auto"
) {
if (element.computedStyles["z-index"] && element.computedStyles["z-index"] !== "auto") {
lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
}
if (gsapAnimations.length > 0) {
@@ -292,14 +277,26 @@ export const PropertyPanel = memo(function PropertyPanel({
}
const text = lines.join("\n");
void navigator.clipboard.writeText(text);
showToast(
`Copied element info for ${element.label} — paste into any AI agent`,
"info",
);
showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info");
setClipboardCopied(true);
clearTimeout(clipboardTimerRef.current);
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
}}
};
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
<div className="px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-neutral-100">
{element.label}
</div>
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleCopyElementInfo}
className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
clipboardCopied
? "text-studio-accent"
@@ -558,6 +555,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onSetArcPath={onSetArcPath}
onUpdateArcSegment={onUpdateArcSegment}
onUnroll={onUnroll}
onUpdateKeyframeEase={onUpdateKeyframeEase}
/>
)}
@@ -28,6 +28,7 @@ export interface GsapAnimationEditCallbacks {
segmentIndex: number,
update: Partial<ArcPathSegment>,
) => void;
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
/** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
onUnroll?: (animationId: string) => void;
}
@@ -119,6 +119,9 @@ export const EASE_LABELS: Record<string, string> = {
"spring-stiff": "Stiff spring",
"spring-wobbly": "Wobbly spring",
"spring-heavy": "Heavy spring",
"ae-ease": "Easy Ease (AE)",
"ae-ease-in": "Easy Ease In (AE)",
"ae-ease-out": "Easy Ease Out (AE)",
};
export const EASE_CURVES: Record<string, [number, number, number, number]> = {
@@ -141,6 +144,11 @@ export const EASE_CURVES: Record<string, [number, number, number, number]> = {
"expo.out": [0.16, 1, 0.3, 1],
"expo.in": [0.7, 0, 0.84, 0],
"expo.inOut": [0.87, 0, 0.13, 1],
// After Effects polarity: "in" eases into the keyframe (slow END, CP2 y=1),
// "out" eases out of it (slow START, CP1 y=0). Matches the "(AE)" labels.
"ae-ease": [0.333, 0, 0.667, 1],
"ae-ease-in": [0.333, 0.333, 0.667, 1],
"ae-ease-out": [0.333, 0, 0.667, 0.667],
};
export function parseCustomEaseFromString(ease: string): {
+26 -3
View File
@@ -6,13 +6,19 @@ import { useState, useCallback, useRef, useEffect } from "react";
import { editLog } from "../utils/editDebugLog";
import { useGestureRecording } from "./useGestureRecording";
import { simplifyGestureSamples } from "../utils/rdpSimplify";
import { fitEasesFromVelocity } from "../utils/velocityEaseFitter";
import { smoothGestureKeyframes } from "../utils/gestureSmoother";
import { usePlayerStore } from "../player";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { roundTo3 } from "../utils/rounding";
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
type RecordedKeyframe = { percentage: number; properties: Record<string, number | string> };
type RecordedKeyframe = {
percentage: number;
properties: Record<string, number | string>;
ease?: string;
};
/**
* Split recorded keyframes into one keyframe-set per property group (position /
@@ -24,6 +30,7 @@ type RecordedKeyframe = { percentage: number; properties: Record<string, number
* Emitting one tween per group keeps the position tween tagged and editable.
* Keyframes with no prop in a group are dropped from that group's set.
*/
// fallow-ignore-next-line complexity
function partitionKeyframesByGroup(keyframes: RecordedKeyframe[]): RecordedKeyframe[][] {
// Preserve first-seen group order for deterministic, stable mutation ordering.
const groupOrder: string[] = [];
@@ -46,7 +53,11 @@ function partitionKeyframesByGroup(keyframes: RecordedKeyframe[]): RecordedKeyfr
byGroup.set(group, set);
groupOrder.push(group);
}
set.push({ percentage: kf.percentage, properties: props });
set.push({
percentage: kf.percentage,
properties: props,
...(kf.ease ? { ease: kf.ease } : {}),
});
}
}
return groupOrder.map((group) => byGroup.get(group)!);
@@ -130,6 +141,7 @@ export function useGestureCommit({
// Per-property epsilon: small-range properties (opacity 01, scale ~0.0110)
// need a much tighter tolerance than positional properties (x/y in px).
// fallow-ignore-next-line complexity
const simplified = simplifyGestureSamples(frozenSamples, duration, (key) => {
if (key === "opacity") return 0.01;
if (key === "scale" || key === "scaleX" || key === "scaleY") return 0.01;
@@ -150,10 +162,12 @@ export function useGestureCommit({
}
if (liveSession.commitMutation) {
const recStart = recordingStartTimeRef.current;
const keyframes = sortedPcts.map((pct) => ({
const rawKeyframes = sortedPcts.map((pct) => ({
percentage: pct,
properties: simplified.get(pct) as Record<string, number | string>,
}));
const smoothed = smoothGestureKeyframes(rawKeyframes, 3);
const keyframes = fitEasesFromVelocity(smoothed, frozenSamples, duration);
const hasPositionProps = keyframes.some((kf) =>
Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
);
@@ -206,6 +220,7 @@ export function useGestureCommit({
const mapped = keyframes.map((kf) => ({
percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct),
properties: kf.properties,
...(kf.ease ? { ease: kf.ease } : {}),
}));
const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
@@ -236,6 +251,11 @@ export function useGestureCommit({
position: roundTo3(recStart),
duration: roundTo3(duration),
keyframes: groupKfs,
// Linear fallback: the velocity fitter assigns a per-keyframe
// ease to non-constant segments and intentionally leaves
// constant-speed segments undefined → they must stay linear,
// not inherit a sigmoid.
easeEach: "none",
},
{ label: "Gesture recording (new range)", softReload: true },
);
@@ -252,6 +272,8 @@ export function useGestureCommit({
position: roundTo3(recStart),
duration: roundTo3(duration),
keyframes: groupKfs,
// Linear fallback (see above) — constant-speed segments stay linear.
easeEach: "none",
},
{ label: "Gesture recording", softReload: true },
);
@@ -270,6 +292,7 @@ export function useGestureCommit({
}
}, [gestureRecording, showToast, isGestureRecordingRef, domEditSessionRef]);
// fallow-ignore-next-line complexity
const handleToggleRecording = useCallback(() => {
editLog("gesture", gestureStateRef.current === "recording" ? "stop" : "start", {
id: domEditSessionRef.current.domEditSelection?.id,
@@ -41,7 +41,7 @@ export function useGsapAnimationOps({
async (
selection: DomEditSelection,
animationId: string,
updates: { duration?: number; ease?: string; position?: number },
updates: { duration?: number; ease?: string; easeEach?: string; position?: number },
) => {
if (sdkSession && sdkDeps) {
const targetPath = selection.sourceFile || activeCompPath || "index.html";
@@ -57,7 +57,7 @@ export function useGsapAnimationOps({
commitMutationSafely(
selection,
{ type: "update-meta", animationId, updates },
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta` },
{ label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, softReload: true },
);
},
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
+32 -4
View File
@@ -24,6 +24,7 @@ function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercenta
return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
}
// fallow-ignore-next-line complexity
function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
if (anim.method === "set") {
return {
@@ -107,6 +108,9 @@ export async function fetchParsedAnimations(
try {
const res = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
// Always re-read the freshly-parsed source; no per-call timestamp (which
// would defeat caching forever and is a deterministic-render no-no).
{ cache: "no-store" },
);
if (!res.ok) return null;
const parsed = (await res.json()) as ParsedGsap;
@@ -156,7 +160,9 @@ export function useGsapAnimationsForElement(
let cancelled = false;
fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
if (cancelled) return;
if (cancelled) {
return;
}
if (!parsed) {
setAllAnimations([]);
setMultipleTimelines(false);
@@ -169,7 +175,7 @@ export function useGsapAnimationsForElement(
// Retry once if initial fetch returned 0 animations — handles
// cold-load race where the sourceFile isn't resolved yet.
if (parsed.animations.length === 0 && target) {
if (parsed.animations.length === 0 && targetKey) {
retryTimerRef.current = setTimeout(() => {
if (cancelled) return;
fetchParsedAnimations(projectId, sourceFile).then((retryParsed) => {
@@ -189,7 +195,7 @@ export function useGsapAnimationsForElement(
retryTimerRef.current = null;
}
};
}, [projectId, sourceFile, version, target]);
}, [projectId, sourceFile, version, target?.id, target?.selector]);
const targetId = target?.id ?? null;
const targetSelector = target?.selector ?? null;
@@ -201,6 +207,7 @@ export function useGsapAnimationsForElement(
[allAnimations, targetId, targetSelector],
);
// fallow-ignore-next-line complexity
const animations = useMemo(() => {
const iframe = iframeRef?.current;
let result = rawAnimations;
@@ -268,6 +275,7 @@ export function useGsapAnimationsForElement(
// Merges keyframes from ALL animations targeting this element and synthesizes
// flat tweens so the cache is never downgraded vs the bulk populate.
const elementId = target?.id ?? null;
// fallow-ignore-next-line complexity
useEffect(() => {
if (!elementId) return;
@@ -287,6 +295,11 @@ export function useGsapAnimationsForElement(
let ease: string | undefined;
let easeEach: string | undefined;
for (const anim of animations) {
if (
anim.method === "set" &&
Object.keys(anim.properties).every((k) => k === "x" || k === "y")
)
continue;
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kf) continue;
// Convert tween-relative percentages to clip-relative so diamonds
@@ -366,6 +379,7 @@ export function usePopulateKeyframeCacheForFile(
if (!projectId) return;
const sf = sourceFile;
// fallow-ignore-next-line complexity
fetchParsedAnimations(projectId, sf).then((parsed) => {
if (!parsed) return;
const { setKeyframeCache } = usePlayerStore.getState();
@@ -376,6 +390,14 @@ export function usePopulateKeyframeCacheForFile(
const id = extractIdFromSelector(anim.targetSelector);
if (!id) continue;
if (anim.hasUnresolvedKeyframes) continue;
// Position-only set tweens are static holds (created by drag), not
// keyframed animations — skip them so they don't show timeline diamonds.
if (anim.method === "set") {
const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
if (propKeys.every((k) => k === "x" || k === "y")) {
continue;
}
}
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
if (!kfData) continue;
const tweenPos =
@@ -430,6 +452,7 @@ export function usePopulateKeyframeCacheForFile(
let attempts = 0;
const maxAttempts = 10;
// fallow-ignore-next-line complexity
const tryRuntimeScan = () => {
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
const iframe =
@@ -449,7 +472,12 @@ export function usePopulateKeyframeCacheForFile(
const fallbackKey = `index.html#${id}`;
const alreadyCached =
keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id);
if (alreadyCached) {
if (alreadyCached) continue;
// Skip position-only set tweens from runtime too — same filter as AST path
const isPosOnly =
data.keyframes.length === 1 &&
Object.keys(data.keyframes[0].properties).every((k) => k === "x" || k === "y");
if (isPosOnly) {
continue;
}
const entry = {
@@ -39,7 +39,8 @@ const ICONS: Record<string, ReactNode> = {
};
export function getTrackStyle(tag: string): TrackVisualStyle {
// Defensive: callers may pass an empty/undefined tag; fall back to "div".
// Defensive: callers may pass an empty/undefined tag; fall back to "div"
// (restores the #1679 null-guard that a restack had dropped).
const safeTag = tag || "div";
const trackStyle = getTimelineTrackStyle(safeTag);
const normalized = safeTag.toLowerCase();
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { smoothGestureKeyframes } from "./gestureSmoother";
describe("smoothGestureKeyframes", () => {
it("returns input unchanged for ≤2 keyframes", () => {
const kfs = [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 100, properties: { x: 100, y: 100 } },
];
expect(smoothGestureKeyframes(kfs, 3)).toEqual(kfs);
});
it("pins first and last keyframes", () => {
const kfs = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 999 } },
{ percentage: 100, properties: { x: 200 } },
];
const result = smoothGestureKeyframes(kfs, 3);
expect(result[0].properties.x).toBe(0);
expect(result[result.length - 1].properties.x).toBe(200);
});
it("smooths a zigzag into a gentler curve", () => {
const kfs = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 25, properties: { x: 100 } },
{ percentage: 50, properties: { x: 0 } },
{ percentage: 75, properties: { x: 100 } },
{ percentage: 100, properties: { x: 0 } },
];
const result = smoothGestureKeyframes(kfs, 2);
const mid = result[2].properties.x as number;
// The sharp 0→100→0 zigzag should be softened — mid should be
// pulled toward the neighbors, not stay at exactly 0.
expect(mid).toBeGreaterThan(0);
expect(mid).toBeLessThan(100);
});
it("returns input unchanged with radius 0", () => {
const kfs = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 999 } },
{ percentage: 100, properties: { x: 0 } },
];
expect(smoothGestureKeyframes(kfs, 0)).toEqual(kfs);
});
});
@@ -0,0 +1,46 @@
// ponytail: Gaussian-weighted moving average over gesture keyframes.
// Rounds off jittery corners from raw pointer input while preserving
// overall path shape. First/last keyframes are pinned (never moved).
// Upgrade path: Catmull-Rom spline if users need curve-fitted paths.
interface Keyframe {
percentage: number;
properties: Record<string, number | string>;
}
function gaussianWeight(distance: number, sigma: number): number {
return Math.exp(-(distance * distance) / (2 * sigma * sigma));
}
export function smoothGestureKeyframes(keyframes: Keyframe[], radius: number): Keyframe[] {
if (keyframes.length <= 2 || radius <= 0) return keyframes;
const sigma = radius / 2;
const numericKeys = new Set<string>();
for (const kf of keyframes) {
for (const [k, v] of Object.entries(kf.properties)) {
if (typeof v === "number") numericKeys.add(k);
}
}
if (numericKeys.size === 0) return keyframes;
return keyframes.map((kf, i) => {
if (i === 0 || i === keyframes.length - 1) return kf;
const smoothed: Record<string, number | string> = { ...kf.properties };
for (const key of numericKeys) {
let weightSum = 0;
let valueSum = 0;
for (let j = Math.max(0, i - radius); j <= Math.min(keyframes.length - 1, i + radius); j++) {
const v = keyframes[j].properties[key];
if (typeof v !== "number") continue;
// Weight by index distance, not time. Samples here are roughly evenly
// spaced, so for the small radius (3) this is fine; switch to a
// percentage-domain distance if the window ever grows much larger.
const w = gaussianWeight(j - i, sigma);
weightSum += w;
valueSum += v * w;
}
if (weightSum > 0) smoothed[key] = Math.round((valueSum / weightSum) * 1000) / 1000;
}
return { percentage: kf.percentage, properties: smoothed };
});
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { fitEasesFromVelocity, type FittedKeyframe } from "./velocityEaseFitter";
function makeSamples(
count: number,
duration: number,
velocityFn: (t: number) => number,
): { time: number; properties: Record<string, number> }[] {
const samples = [];
let pos = 0;
for (let i = 0; i <= count; i++) {
const t = (i / count) * duration;
const v = velocityFn(t / duration);
pos += v * (duration / count);
samples.push({ time: t, properties: { x: pos } });
}
return samples;
}
describe("fitEasesFromVelocity", () => {
it("constant speed → no ease assigned", () => {
const kfs: FittedKeyframe[] = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 100 } },
];
const samples = makeSamples(60, 1, () => 100);
const result = fitEasesFromVelocity(kfs, samples, 1);
expect(result[1].ease).toBeUndefined();
});
it("decelerate at end → AE Easy Ease In (slow-end curve)", () => {
const kfs: FittedKeyframe[] = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 100 } },
];
// Start fast, end slow → playback must also be slow at the end (CP2 y=1).
const samples = makeSamples(60, 1, (t) => Math.max(0, 200 * (1 - t)));
const result = fitEasesFromVelocity(kfs, samples, 1);
expect(result[1].ease).toBe("custom(M0,0 C0.333,0.333 0.667,1 1,1)");
});
it("accelerate from start → AE Easy Ease Out (slow-start curve)", () => {
const kfs: FittedKeyframe[] = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 100 } },
];
// Start slow, end fast → playback must also be slow at the start (CP1 y=0).
const samples = makeSamples(60, 1, (t) => 200 * t);
const result = fitEasesFromVelocity(kfs, samples, 1);
expect(result[1].ease).toBe("custom(M0,0 C0.333,0 0.667,0.667 1,1)");
});
it("single keyframe → returns unchanged", () => {
const kfs: FittedKeyframe[] = [{ percentage: 0, properties: { x: 0 } }];
const result = fitEasesFromVelocity(kfs, [], 1);
expect(result).toEqual(kfs);
});
});
@@ -0,0 +1,121 @@
interface TimedSample {
time: number;
value: number;
}
// After Effects convention (ease named by the keyframe side it acts on):
// Easy Ease — slow at both ends (cubic-bezier 0.333,0 0.667,1)
// Easy Ease In — eases *into* the keyframe → decelerates → slow at the END
// Easy Ease Out — eases *out of* the keyframe → accelerates → slow at the START
// The control-point y values must match that polarity (a flat tangent at the
// slow side): slow-end pins CP2 at y=1, slow-start pins CP1 at y=0.
const AE_EASE = "custom(M0,0 C0.333,0 0.667,1 1,1)";
const AE_EASE_IN = "custom(M0,0 C0.333,0.333 0.667,1 1,1)";
const AE_EASE_OUT = "custom(M0,0 C0.333,0 0.667,0.667 1,1)";
const VELOCITY_THRESHOLD = 0.3;
function averageSpeed(samples: TimedSample[], from: number, to: number): number {
const seg = samples.filter((s) => s.time >= from && s.time <= to);
if (seg.length < 2) return 0;
let total = 0;
for (let i = 1; i < seg.length; i++) {
const dt = seg[i].time - seg[i - 1].time;
if (dt > 0) total += Math.abs(seg[i].value - seg[i - 1].value) / dt;
}
return total / (seg.length - 1);
}
function speedAtEdge(
samples: TimedSample[],
t: number,
window: number,
side: "start" | "end",
): number {
const near = samples.filter((s) =>
side === "start" ? s.time >= t && s.time <= t + window : s.time >= t - window && s.time <= t,
);
if (near.length < 2) return 0;
let total = 0;
for (let i = 1; i < near.length; i++) {
const dt = near[i].time - near[i - 1].time;
if (dt > 0) total += Math.abs(near[i].value - near[i - 1].value) / dt;
}
return total / (near.length - 1);
}
export interface FittedKeyframe {
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}
/**
* Analyze velocity profile of raw samples between keyframes and assign
* per-keyframe eases based on deceleration/acceleration patterns.
*
* For each segment between consecutive keyframes:
* - Constant speed linear ("none")
* - Decelerates at end Easy Ease In
* - Accelerates from start Easy Ease Out
* - Both Easy Ease (full)
*/
// fallow-ignore-next-line complexity
export function fitEasesFromVelocity(
keyframes: FittedKeyframe[],
rawSamples: { time: number; properties: Record<string, number> }[],
totalDuration: number,
): FittedKeyframe[] {
if (keyframes.length < 2 || rawSamples.length < 3) return keyframes;
const result = [...keyframes.map((kf) => ({ ...kf }))];
for (let i = 1; i < result.length; i++) {
const prevPct = result[i - 1].percentage;
const currPct = result[i].percentage;
const segStart = (prevPct / 100) * totalDuration;
const segEnd = (currPct / 100) * totalDuration;
const segDur = segEnd - segStart;
if (segDur <= 0) continue;
// Use the dominant property (largest range) for velocity analysis
const props = Object.keys(result[i].properties);
let bestProp = props[0] ?? "x";
let bestRange = 0;
for (const p of props) {
const startVal = Number(result[i - 1].properties[p] ?? 0);
const endVal = Number(result[i].properties[p] ?? 0);
const range = Math.abs(endVal - startVal);
if (range > bestRange) {
bestRange = range;
bestProp = p;
}
}
const propSamples: TimedSample[] = rawSamples
.filter((s) => s.time >= segStart && s.time <= segEnd)
.map((s) => ({ time: s.time, value: s.properties[bestProp] ?? 0 }));
if (propSamples.length < 3) continue;
const edgeWindow = segDur * 0.25;
const avgSpd = averageSpeed(propSamples, segStart, segEnd);
if (avgSpd < 1e-6) continue;
const startSpd = speedAtEdge(propSamples, segStart, edgeWindow, "start");
const endSpd = speedAtEdge(propSamples, segEnd, edgeWindow, "end");
const slowStart = startSpd / avgSpd < VELOCITY_THRESHOLD;
const slowEnd = endSpd / avgSpd < VELOCITY_THRESHOLD;
if (slowStart && slowEnd) {
result[i].ease = AE_EASE;
} else if (slowEnd) {
result[i].ease = AE_EASE_IN;
} else if (slowStart) {
result[i].ease = AE_EASE_OUT;
}
// Otherwise leave ease undefined → linear (constant speed)
}
return result;
}