mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
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:
@@ -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…"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -435,29 +242,43 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
|
||||
{animation.method !== "set" && (
|
||||
<>
|
||||
<SelectField
|
||||
label="Speed"
|
||||
value={easeName.startsWith("custom(") ? "custom" : easeName}
|
||||
options={[...SUPPORTED_EASES, "custom"]}
|
||||
onChange={(next) => {
|
||||
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})` });
|
||||
} else {
|
||||
onUpdateMeta(animation.id, { ease: next });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<EaseCurveSection
|
||||
ease={easeName}
|
||||
duration={animation.duration}
|
||||
onCustomEaseCommit={(customEase) =>
|
||||
onUpdateMeta(animation.id, { ease: customEase })
|
||||
}
|
||||
/>
|
||||
{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, { [easeKey]: `custom(${path})` });
|
||||
} else {
|
||||
onUpdateMeta(animation.id, { [easeKey]: next });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<EaseCurveSection
|
||||
ease={easeName}
|
||||
duration={animation.duration}
|
||||
onCustomEaseCommit={(customEase) => {
|
||||
const easeKey = animation.keyframes ? "easeEach" : "ease";
|
||||
onUpdateMeta(animation.id, { [easeKey]: customEase });
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,6 +234,55 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
const displayH = gsapRuntimeValues?.height ?? resolvedHeight;
|
||||
const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
|
||||
|
||||
// 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 ?? "";
|
||||
if (src && element.id) {
|
||||
const idx = src.indexOf(`id="${element.id}"`);
|
||||
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
||||
}
|
||||
if (!lineNum && element.selector) {
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const cls = element.selector.startsWith(".")
|
||||
? element.selector.slice(1).split(".")[0]
|
||||
: null;
|
||||
const search = cls ? `class="${cls}` : `<${tag}`;
|
||||
const idx = src.indexOf(search);
|
||||
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
||||
}
|
||||
} catch {}
|
||||
const fileLoc = lineNum ? `${file}:${lineNum}` : file;
|
||||
const lines = [
|
||||
`Element: ${element.label} (${sourceLabel})`,
|
||||
`File: ${fileLoc}`,
|
||||
`Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
|
||||
`Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
|
||||
`Tag: <${element.tagName}>`,
|
||||
];
|
||||
if (element.computedStyles["z-index"] && element.computedStyles["z-index"] !== "auto") {
|
||||
lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
|
||||
}
|
||||
if (gsapAnimations.length > 0) {
|
||||
const anim = gsapAnimations[0];
|
||||
lines.push(
|
||||
`Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
|
||||
);
|
||||
const props = Object.entries(anim.properties)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(", ");
|
||||
if (props) lines.push(`Properties: ${props}`);
|
||||
}
|
||||
const text = lines.join("\n");
|
||||
void navigator.clipboard.writeText(text);
|
||||
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">
|
||||
@@ -246,60 +296,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const file = element.sourceFile ?? "index.html";
|
||||
let lineNum: number | null = null;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
if (!lineNum && element.selector) {
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const cls = element.selector.startsWith(".")
|
||||
? element.selector.slice(1).split(".")[0]
|
||||
: null;
|
||||
const search = cls ? `class="${cls}` : `<${tag}`;
|
||||
const idx = src.indexOf(search);
|
||||
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
||||
}
|
||||
} catch {}
|
||||
const fileLoc = lineNum ? `${file}:${lineNum}` : file;
|
||||
const lines = [
|
||||
`Element: ${element.label} (${sourceLabel})`,
|
||||
`File: ${fileLoc}`,
|
||||
`Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
|
||||
`Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
|
||||
`Tag: <${element.tagName}>`,
|
||||
];
|
||||
if (
|
||||
element.computedStyles["z-index"] &&
|
||||
element.computedStyles["z-index"] !== "auto"
|
||||
) {
|
||||
lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
|
||||
}
|
||||
if (gsapAnimations.length > 0) {
|
||||
const anim = gsapAnimations[0];
|
||||
lines.push(
|
||||
`Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
|
||||
);
|
||||
const props = Object.entries(anim.properties)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(", ");
|
||||
if (props) lines.push(`Properties: ${props}`);
|
||||
}
|
||||
const text = lines.join("\n");
|
||||
void navigator.clipboard.writeText(text);
|
||||
showToast(
|
||||
`Copied element info for ${element.label} — paste into any AI agent`,
|
||||
"info",
|
||||
);
|
||||
setClipboardCopied(true);
|
||||
clearTimeout(clipboardTimerRef.current);
|
||||
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
|
||||
}}
|
||||
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): {
|
||||
|
||||
@@ -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 0–1, scale ~0.01–10)
|
||||
// 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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user