feat(studio): keyframe diamonds, navigation, context menu [4/6] (#1170)

* feat(core): GSAP keyframe parsing, mutations, and API routes

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* fix(producer): use video stream duration for PSNR checkpoint range

The regression harness used container duration (format.duration) to
compute PSNR checkpoints. Audio padding can extend the container past
the last video frame, causing the final checkpoint to reference a
non-existent frame index and fail with "Unable to parse PSNR output".

Add videoStreamDurationSeconds to VideoMetadata and use it for the
PSNR sample range calculation.

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* test(producer): allow 2-frame PSNR tolerance for style-9-prod

A single transition frame at 10.742s renders with marginal PSNR
(26.6 dB vs 30 threshold) on CI runners but passes on the devbox
Docker image. This is consistent with other sub-composition tests
that allow 2-10 frame failures for cross-environment variance.

* feat(studio): GSAP runtime bridge + optimistic update pattern

* feat(studio): keyframe diamonds, navigation controls, context menu
This commit is contained in:
Miguel Ángel
2026-06-05 11:51:44 -04:00
committed by GitHub
parent 12e87e05b4
commit 5984c58846
4 changed files with 513 additions and 0 deletions
@@ -0,0 +1,49 @@
import { memo } from "react";
export type DiamondState = "active" | "inactive" | "ghost";
interface KeyframeDiamondProps {
state: DiamondState;
onClick: () => void;
title?: string;
size?: number;
}
// fallow-ignore-next-line complexity
export const KeyframeDiamond = memo(function KeyframeDiamond({
state,
onClick,
title,
size = 10,
}: KeyframeDiamondProps) {
const isFilled = state === "active";
const opacity = state === "ghost" ? 0.25 : state === "inactive" ? 0.6 : 1;
const color = state === "active" ? "#3b82f6" : "#a3a3a3";
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className="flex-shrink-0 p-0.5 transition-opacity hover:opacity-100"
style={{ color, opacity }}
title={title}
>
<svg width={size} height={size} viewBox="0 0 10 10">
<rect
x="5"
y="0.7"
width="6"
height="6"
rx="1"
transform="rotate(45 5 0.7)"
fill={isFilled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.2"
/>
</svg>
</button>
);
});
@@ -0,0 +1,139 @@
import { memo } from "react";
import { KeyframeDiamond, type DiamondState } from "./KeyframeDiamond";
interface KeyframeNavigationProps {
property: string;
/** All keyframes for this element's tween, or null if no keyframes exist */
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}> | null;
/** Current playhead percentage within the element's lifetime (0-100) */
currentPercentage: number;
onSeek: (percentage: number) => void;
onAddKeyframe: (percentage: number) => void;
onRemoveKeyframe: (percentage: number) => void;
onConvertToKeyframes: () => void;
}
const TOLERANCE = 0.5;
function ArrowLeft({ disabled }: { disabled: boolean }) {
return (
<svg
width="6"
height="10"
viewBox="0 0 6 10"
fill="none"
style={{ opacity: disabled ? 0.25 : 1 }}
>
<path
d="M5 1L1 5L5 9"
stroke="#a3a3a3"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ArrowRight({ disabled }: { disabled: boolean }) {
return (
<svg
width="6"
height="10"
viewBox="0 0 6 10"
fill="none"
style={{ opacity: disabled ? 0.25 : 1 }}
>
<path
d="M1 1L5 5L1 9"
stroke="#a3a3a3"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
// fallow-ignore-next-line complexity
export const KeyframeNavigation = memo(function KeyframeNavigation({
property,
keyframes,
currentPercentage,
onSeek,
onAddKeyframe,
onRemoveKeyframe,
onConvertToKeyframes,
}: KeyframeNavigationProps) {
// Find keyframes that contain this property
const propertyKeyframes = keyframes?.filter((kf) => property in kf.properties) ?? [];
const prevKf =
propertyKeyframes.filter((kf) => kf.percentage < currentPercentage - TOLERANCE).at(-1) ?? null;
const nextKf =
propertyKeyframes.find((kf) => kf.percentage > currentPercentage + TOLERANCE) ?? null;
const atCurrent =
propertyKeyframes.find((kf) => Math.abs(kf.percentage - currentPercentage) <= TOLERANCE) ??
null;
// Diamond state
let diamondState: DiamondState;
if (!keyframes || keyframes.length === 0) {
diamondState = "ghost";
} else if (atCurrent) {
diamondState = "active";
} else if (propertyKeyframes.length > 0) {
diamondState = "inactive";
} else {
diamondState = "ghost";
}
const handleDiamondClick = () => {
if (diamondState === "ghost") {
onConvertToKeyframes();
} else if (diamondState === "active") {
onRemoveKeyframe(currentPercentage);
} else {
onAddKeyframe(currentPercentage);
}
};
return (
<div className="flex h-5 items-center gap-0.5">
<button
type="button"
disabled={!prevKf}
onClick={() => prevKf && onSeek(prevKf.percentage)}
className="flex h-5 w-3 items-center justify-center disabled:cursor-default"
>
<ArrowLeft disabled={!prevKf} />
</button>
<KeyframeDiamond
state={diamondState}
onClick={handleDiamondClick}
size={9}
title={
diamondState === "ghost"
? `Convert ${property} to keyframes`
: diamondState === "active"
? `Remove ${property} keyframe`
: `Add ${property} keyframe`
}
/>
<button
type="button"
disabled={!nextKf}
onClick={() => nextKf && onSeek(nextKf.percentage)}
className="flex h-5 w-3 items-center justify-center disabled:cursor-default"
>
<ArrowRight disabled={!nextKf} />
</button>
</div>
);
});
@@ -0,0 +1,151 @@
import { memo, useCallback, useEffect, useRef } from "react";
import { EASE_LABELS } from "../../components/editor/gsapAnimationConstants";
export interface KeyframeDiamondContextMenuState {
x: number;
y: number;
elementId: string;
percentage: number;
currentEase?: string;
}
interface KeyframeDiamondContextMenuProps {
state: KeyframeDiamondContextMenuState;
onClose: () => void;
onDelete: (elementId: string, percentage: number) => void;
onChangeEase: (elementId: string, percentage: number, ease: string) => void;
onCopyProperties: (elementId: string, percentage: number) => void;
}
const EASE_PRESETS = [
"none",
"power1.out",
"power2.out",
"power3.out",
"power1.in",
"power2.in",
"power1.inOut",
"power2.inOut",
"back.out",
"elastic.out",
"bounce.out",
"expo.out",
] as const;
export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
state,
onClose,
onDelete,
onChangeEase,
onCopyProperties,
}: KeyframeDiamondContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const easeSubmenuRef = useRef<HTMLDivElement>(null);
const dismiss = useCallback(
(e: MouseEvent | KeyboardEvent) => {
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
onClose();
},
[onClose],
);
useEffect(() => {
document.addEventListener("mousedown", dismiss);
document.addEventListener("keydown", dismiss);
return () => {
document.removeEventListener("mousedown", dismiss);
document.removeEventListener("keydown", dismiss);
};
}, [dismiss]);
const adjustedX = Math.min(state.x, window.innerWidth - 200);
const adjustedY = Math.min(state.y, window.innerHeight - 300);
const currentEaseLabel = state.currentEase
? (EASE_LABELS[state.currentEase] ?? state.currentEase)
: "Default";
return (
<div
ref={menuRef}
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
style={{ left: adjustedX, top: adjustedY }}
>
{/* Ease submenu */}
<div className="relative group">
<button
type="button"
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
>
<span>
Ease: <span className="text-neutral-500">{currentEaseLabel}</span>
</span>
<svg width="8" height="8" viewBox="0 0 8 8" className="text-neutral-500 ml-2">
<path d="M3 1l4 3-4 3" fill="none" stroke="currentColor" strokeWidth="1.2" />
</svg>
</button>
<div
ref={easeSubmenuRef}
className="absolute left-full top-0 ml-0.5 hidden group-hover:block bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px] max-h-[300px] overflow-y-auto"
>
{EASE_PRESETS.map((ease) => (
<button
key={ease}
type="button"
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-neutral-800 cursor-pointer text-left ${
ease === state.currentEase ? "text-white font-medium" : "text-neutral-300"
}`}
onClick={() => {
onChangeEase(state.elementId, state.percentage, ease);
onClose();
}}
>
{ease === state.currentEase && (
<svg
width="8"
height="8"
viewBox="0 0 8 8"
className="text-green-400 flex-shrink-0"
>
<path d="M1 4l2 2 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
</svg>
)}
<span className={ease === state.currentEase ? "" : "ml-[16px]"}>
{EASE_LABELS[ease] ?? ease}
</span>
</button>
))}
</div>
</div>
{/* Separator */}
<div className="my-1 border-t border-neutral-700/60" />
{/* Delete */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
onClick={() => {
onDelete(state.elementId, state.percentage);
onClose();
}}
>
Delete Keyframe
</button>
{/* Copy Properties */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
onClick={() => {
onCopyProperties(state.elementId, state.percentage);
onClose();
}}
>
Copy Properties
</button>
</div>
);
});
@@ -0,0 +1,174 @@
import { memo, useRef } from "react";
interface KeyframeEntry {
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}
interface KeyframeCacheEntry {
format: string;
keyframes: KeyframeEntry[];
ease?: string;
easeEach?: string;
}
interface TimelineClipDiamondsProps {
keyframesData: KeyframeCacheEntry;
clipWidthPx: number;
clipHeightPx: number;
accentColor: string;
isSelected: boolean;
currentPercentage: number;
elementId: string;
selectedKeyframes: Set<string>;
onClickKeyframe?: (percentage: number) => void;
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
onDragKeyframe?: (percentage: number, newPercentage: number) => void;
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
}
const DIAMOND_RATIO = 0.8;
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
keyframesData,
clipWidthPx,
clipHeightPx,
accentColor,
isSelected,
currentPercentage,
elementId,
selectedKeyframes,
onClickKeyframe,
onShiftClickKeyframe,
onDragKeyframe,
onContextMenuKeyframe,
}: TimelineClipDiamondsProps) {
const dragRef = useRef<{ startX: number; startPct: number } | null>(null);
if (clipWidthPx < 20) return null;
const diamondSize = Math.round(clipHeightPx * DIAMOND_RATIO);
const half = diamondSize / 2;
const sorted = keyframesData.keyframes.slice().sort((a, b) => a.percentage - b.percentage);
const baseColor = isSelected ? accentColor : "#a3a3a3";
const baseOpacity = isSelected ? 0.4 : 0.25;
const handleClick = (e: React.MouseEvent, pct: number) => {
e.stopPropagation();
if (e.shiftKey) {
onShiftClickKeyframe?.(elementId, pct);
} else {
onClickKeyframe?.(pct);
}
};
const handlePointerDown = (e: React.PointerEvent, pct: number) => {
if (e.button !== 0) return;
e.stopPropagation();
const startX = e.clientX;
const handleMove = (me: PointerEvent) => {
const dx = me.clientX - startX;
if (Math.abs(dx) > 4) {
dragRef.current = { startX, startPct: pct };
}
};
const handleUp = (ue: PointerEvent) => {
document.removeEventListener("pointermove", handleMove);
document.removeEventListener("pointerup", handleUp);
const start = dragRef.current;
dragRef.current = null;
if (!start) return;
const dx = ue.clientX - start.startX;
const dPct = (dx / clipWidthPx) * 100;
const newPct = Math.max(0, Math.min(100, Math.round(start.startPct + dPct)));
if (Math.abs(newPct - start.startPct) > 0.5) {
onDragKeyframe?.(start.startPct, newPct);
}
};
document.addEventListener("pointermove", handleMove);
document.addEventListener("pointerup", handleUp);
};
return (
<div className="absolute inset-0" style={{ zIndex: 3, pointerEvents: "none" }}>
{sorted.map((kf, i) => {
if (i === 0) return null;
const prev = sorted[i - 1]!;
const x1 = (prev.percentage / 100) * clipWidthPx;
const x2 = (kf.percentage / 100) * clipWidthPx;
return (
<div
key={`line-${prev.percentage}-${kf.percentage}`}
className="absolute"
style={{
left: x1,
top: "50%",
width: x2 - x1,
height: 2,
transform: "translateY(-1px)",
background: baseColor,
opacity: baseOpacity,
borderRadius: 1,
}}
/>
);
})}
{sorted.map((kf) => {
const leftPx = (kf.percentage / 100) * clipWidthPx - half;
const kfKey = `${elementId}:${kf.percentage}`;
const isKfSelected = selectedKeyframes.has(kfKey);
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.05;
const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3";
return (
<button
key={kf.percentage}
type="button"
className="absolute"
style={{
left: leftPx,
top: "50%",
transform: "translateY(-50%)",
width: diamondSize,
height: diamondSize,
pointerEvents: "auto",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
}}
onClick={(e) => handleClick(e, kf.percentage)}
onPointerDown={(e) => handlePointerDown(e, kf.percentage)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenuKeyframe?.(e, elementId, kf.percentage);
}}
title={`${kf.percentage}%`}
>
<svg width={diamondSize} height={diamondSize} viewBox="0 0 10 10">
{isKfSelected && (
<path
d="M5 0L10 5L5 10L0 5Z"
fill="none"
stroke={accentColor}
strokeWidth="0.8"
opacity={0.5}
/>
)}
<path
d="M5 1L9 5L5 9L1 5Z"
fill={color}
opacity={isKfSelected || atPlayhead ? 1 : 0.55}
/>
</svg>
</button>
);
})}
</div>
);
});