feat(studio): runtime-synced design panel + 3D props + split polish (#1188)

* 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

* 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.

* feat(studio): design panel integration, timeline polish, feature flag

* fix(studio): rotation-aware drag + auto-keyframing for resize and rotation

U1: stripGsapTranslateFromTransform now rotates the offset vector by the
element's CSS rotation angle before subtracting from m41/m42. Fixes
elements drifting from cursor during drag when rotated.

U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the
runtime bridge. Resize and rotation handle changes now create keyframes
via the same async pipeline as position drag. CSS path guards prevent
double-persistence for GSAP-animated elements.

* fix(studio): counter-rotate drag offset for css-rotated elements

CSS compose order is translate → rotate → transform. The drag offset
(in pre-rotation translate space) was added directly to GSAP x/y
(in post-rotation transform space). Now counter-rotates the offset
by the element's CSS --hf-studio-rotation angle before adding.

* feat(studio): add 'delete all keyframes' to diamond context menu

* fix(studio): include all animated properties in every keyframe commit

Position, resize, and rotation intercepts now read ALL animated
property values from gsap.getProperty() at commit time and include
them in the keyframe. Prevents other properties from jumping to
interpolated values between surrounding keyframes when only one
property (e.g., width) was explicitly changed.

* 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

* 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

* ci: trigger regression run

* feat(studio): design panel integration, timeline polish, feature flag

* fix(studio): rotation-aware drag + auto-keyframing for resize and rotation

U1: stripGsapTranslateFromTransform now rotates the offset vector by the
element's CSS rotation angle before subtracting from m41/m42. Fixes
elements drifting from cursor during drag when rotated.

U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the
runtime bridge. Resize and rotation handle changes now create keyframes
via the same async pipeline as position drag. CSS path guards prevent
double-persistence for GSAP-animated elements.

* feat(studio): add 'delete all keyframes' to diamond context menu

* ci: trigger regression run

* ci: trigger regression run

* ci: trigger regression run

* fix(studio): overlay jump, delete-all-keyframes, split wiring, reapplyBoxSizes guard

- Fix overlay bounding box jump: reapplyBoxSizes now skips elements whose
  width/height are animated by GSAP (gsapAnimatesProperty check prevents
  studio CSS from overwriting GSAP interpolated values)
- Delete All Keyframes removes entire animation (handleGsapDeleteAnimation)
  with fallback to first animation when no keyframed anim exists
- Wire split clip through App → StudioPreviewArea → NLELayout → Timeline
  (onSplitElement prop, toolbar button, S hotkey, clip context menu)
- Add onContextMenu to TimelineClip for right-click clip context menu

* fix(studio): block split on sub-compositions

Sub-compositions (data-composition-src) cannot be meaningfully split —
the clone would load the same source and fight for the same timeline.
Block with a specific toast message and disable in the context menu.

* fix(studio): no toast when split is unavailable for compositions

* fix(studio): remove defensive toast from split handler — UI gates are sufficient

* fix(studio): hide split button for sub-compositions, use scissors icon

* ci: trigger regression run

* feat(studio): runtime-synced design panel values + 3D transform properties

The Layout section (X, Y, W, H, R) now reads GSAP-interpolated values
from the runtime via gsap.getProperty() at the current seek time. When
an element has GSAP animations, the fields reflect the actual interpolated
position/size/rotation instead of the CSS defaults.

Also adds 3D transform properties to SUPPORTED_PROPS: z, rotationX,
rotationY, rotationZ, perspective, transformOrigin.

* fix(studio): read ALL animated properties from runtime, not just hardcoded 7

* fix(studio): stronger clip selection border + wider keyframe playhead tolerance

- Selected clip: full accent border (was 38% opacity), subtle glow shadow
- Keyframe diamond at playhead: tolerance 0.5% (was 0.05% — too tight at
  high zoom levels, causing diamonds to never highlight)

* fix(studio): sync DOM selection to timeline selectedElementId on cold load

* fix(studio): use Phosphor Scissors icon for split button

* fix(studio): restrict split to media elements only (video, audio, img)

* feat(studio): unified commitAnimatedProperty for all GSAP property edits

Extract useAnimatedPropertyCommit hook that handles the three-case commit
logic: keyframed → add-keyframe, flat → convert + add, no animation →
create + convert + add. Wire Z, Scale, RotX, RotY design panel fields
and 2D Layout fields through this unified pipeline.

Export readAllAnimatedProperties and readGsapProperty from the runtime
bridge so the commit helper can read all animated props for backfill.

* fix(studio): wire all KeyframeNavigation diamonds through commitAnimatedProperty

* chore: remove committed plan files

These design plans were accidentally committed and should not be in
the PR.
This commit is contained in:
Miguel Ángel
2026-06-05 12:11:01 -04:00
committed by GitHub
parent c1699ec98b
commit 1bdb2d4ec0
20 changed files with 517 additions and 128 deletions
+3
View File
@@ -286,6 +286,7 @@ export function StudioApp() {
const appHotkeys = useAppHotkeys({
toggleTimelineVisibility,
handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit,
handleDomEditElementDelete: domEditDeleteBridge,
domEditSelectionRef: domEditSelectionBridgeRef,
clearDomSelectionRef,
@@ -489,6 +490,7 @@ export function StudioApp() {
<TimelineToolbar
toggleTimelineVisibility={toggleTimelineVisibility}
domEditSession={domEditSession}
onSplitElement={timelineEditing.handleTimelineElementSplit}
/>
);
return (
@@ -532,6 +534,7 @@ export function StudioApp() {
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
setCompIdToSrc={setCompIdToSrc}
setCompositionLoading={setCompositionLoading}
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
@@ -49,6 +49,7 @@ export interface StudioPreviewAreaProps {
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
setCompIdToSrc: (map: Map<string, string>) => void;
setCompositionLoading: (loading: boolean) => void;
shouldShowSelectedDomBounds: boolean;
@@ -67,6 +68,7 @@ export function StudioPreviewArea({
handleTimelineElementMove,
handleTimelineElementResize,
handleBlockedTimelineEdit,
handleTimelineElementSplit,
setCompIdToSrc,
setCompositionLoading,
shouldShowSelectedDomBounds,
@@ -107,7 +109,7 @@ export function StudioPreviewArea({
handleGsapUpdateMeta,
handleGsapAddKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleGsapDeleteAnimation,
} = useDomEditContext();
return (
@@ -127,10 +129,12 @@ export function StudioPreviewArea({
onMoveElement={handleTimelineElementMove}
onResizeElement={handleTimelineElementResize}
onBlockedEditAttempt={handleBlockedTimelineEdit}
onSplitElement={handleTimelineElementSplit}
onSelectTimelineElement={handleTimelineElementSelect}
onDeleteAllKeyframes={(_elId) => {
const anim = selectedGsapAnimations.find((a) => a.keyframes);
if (anim) handleGsapRemoveAllKeyframes(anim.id);
const anim =
selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0];
if (anim) handleGsapDeleteAnimation(anim.id);
}}
onDeleteKeyframe={(_elId, pct) => {
const anim = selectedGsapAnimations.find((a) => a.keyframes);
@@ -91,6 +91,7 @@ export function StudioRightPanel({
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
commitAnimatedProperty,
} = useDomEditContext();
const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } =
@@ -211,6 +212,7 @@ export function StudioRightPanel({
onImportAssets={handleImportFiles}
fontAssets={fontAssets}
onImportFonts={handleImportFonts}
previewIframeRef={previewIframeRef}
gsapAnimations={selectedGsapAnimations}
gsapMultipleTimelines={gsapMultipleTimelines}
gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
@@ -223,6 +225,7 @@ export function StudioRightPanel({
onAddGsapFromProperty={handleGsapAddFromProperty}
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
onAddGsapAnimation={handleGsapAddAnimation}
onCommitAnimatedProperty={commitAnimatedProperty}
/>
) : motionPanelActive ? (
<MotionPanel
@@ -6,6 +6,7 @@ import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
import { usePlayerStore, type TimelineElement } from "../player";
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
import { Tooltip } from "./ui";
import { Scissors } from "../icons/SystemIcons";
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";
@@ -218,38 +219,35 @@ export function TimelineToolbar({
</button>
</Tooltip>
)}
{onSplitElement && (
<Tooltip label="Split clip at playhead (S)">
<button
type="button"
onClick={() => {
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
if (!selectedElementId) return;
const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
if (el && currentTime > el.start && currentTime < el.start + el.duration) {
onSplitElement(el, currentTime);
}
}}
className="flex h-7 w-7 items-center justify-center rounded text-neutral-500 transition-colors hover:text-neutral-200"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
>
<line x1="8" y1="2" x2="8" y2="14" />
<polyline points="5,5 3,2" />
<polyline points="11,5 13,2" />
<polyline points="5,11 3,14" />
<polyline points="11,11 13,14" />
</svg>
</button>
</Tooltip>
)}
{onSplitElement &&
(() => {
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
const el = selectedElementId
? elements.find((e) => (e.key ?? e.id) === selectedElementId)
: null;
const splittable =
el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag);
if (!splittable) return null;
const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
return (
<Tooltip label="Split clip at playhead (S)">
<button
type="button"
disabled={!canSplit}
onClick={() => {
if (canSplit) onSplitElement(el, currentTime);
}}
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
canSplit
? "text-neutral-500 hover:text-neutral-200"
: "text-neutral-700 cursor-not-allowed"
}`}
>
<Scissors size={15} />
</button>
</Tooltip>
);
})()}
</div>
<div className="flex items-center gap-1">
<Tooltip label="Fit timeline to width">
@@ -52,6 +52,7 @@ interface PropertyPanelProps {
onImportAssets?: (files: FileList) => Promise<string[]>;
fontAssets?: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[];
gsapMultipleTimelines?: boolean;
gsapUnsupportedTimelinePattern?: boolean;
@@ -75,6 +76,11 @@ interface PropertyPanelProps {
) => void;
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
onConvertToKeyframes?: (animationId: string) => void;
onCommitAnimatedProperty?: (
selection: DomEditSelection,
property: string,
value: number | string,
) => Promise<void>;
onSeekToTime?: (time: number) => void;
}
@@ -169,6 +175,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onImportAssets,
fontAssets = [],
onImportFonts,
previewIframeRef,
gsapAnimations = [],
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
@@ -184,6 +191,7 @@ export const PropertyPanel = memo(function PropertyPanel({
onAddKeyframe,
onRemoveKeyframe,
onConvertToKeyframes,
onCommitAnimatedProperty,
onSeekToTime,
}: PropertyPanelProps) {
const styles = element?.computedStyles ?? EMPTY_STYLES;
@@ -238,6 +246,10 @@ export const PropertyPanel = memo(function PropertyPanel({
const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null) return;
if (onCommitAnimatedProperty && (gsapAnimId || gsapAnimations.length > 0)) {
void onCommitAnimatedProperty(element, axis, parsed);
return;
}
if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
const pct = Math.max(0, Math.min(100, Math.round(currentPct * 10) / 10));
onAddKeyframe(gsapAnimId, pct, axis, parsed);
@@ -286,6 +298,49 @@ export const PropertyPanel = memo(function PropertyPanel({
const gsapAnimId =
gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null;
// Read ALL GSAP-interpolated values at the current seek time.
// Discovers animated properties from the animation's keyframes/tween vars.
const gsapRuntimeValues: Record<string, number> | null = (() => {
if (!gsapAnimId || gsapAnimations.length === 0) return null;
const iframe = previewIframeRef?.current;
if (!iframe?.contentWindow) return null;
const selector = element.id ? `#${element.id}` : element.selector;
if (!selector) return null;
try {
const gsap = (
iframe.contentWindow as unknown as {
gsap?: { getProperty: (el: Element, prop: string) => number | string };
}
).gsap;
if (!gsap?.getProperty) return null;
const el = iframe.contentDocument?.querySelector(selector);
if (!el) return null;
const propKeys = new Set<string>();
for (const anim of gsapAnimations) {
if (anim.keyframes) {
for (const kf of anim.keyframes.keyframes) {
for (const p of Object.keys(kf.properties)) propKeys.add(p);
}
}
for (const p of Object.keys(anim.properties)) propKeys.add(p);
}
const result: Record<string, number> = {};
for (const prop of propKeys) {
const v = Number(gsap.getProperty(el, prop));
if (Number.isFinite(v)) result[prop] = Math.round(v * 100) / 100;
}
return Object.keys(result).length > 0 ? result : null;
} catch {
return null;
}
})();
const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
const displayW = gsapRuntimeValues?.width ?? resolvedWidth;
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-neutral-900 text-neutral-100">
<div className="border-b border-neutral-800 px-4 py-5">
@@ -351,7 +406,7 @@ export const PropertyPanel = memo(function PropertyPanel({
<div className="flex-1">
<MetricField
label="X"
value={formatPxMetricValue(manualOffset.x)}
value={formatPxMetricValue(displayX)}
disabled={manualOffsetEditingDisabled}
scrub
onCommit={(next) => commitManualOffset("x", next)}
@@ -363,7 +418,10 @@ export const PropertyPanel = memo(function PropertyPanel({
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "x", manualOffset.x)}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "x", displayX)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
/>
@@ -373,7 +431,7 @@ export const PropertyPanel = memo(function PropertyPanel({
<div className="flex-1">
<MetricField
label="Y"
value={formatPxMetricValue(manualOffset.y)}
value={formatPxMetricValue(displayY)}
disabled={manualOffsetEditingDisabled}
scrub
onCommit={(next) => commitManualOffset("y", next)}
@@ -385,7 +443,10 @@ export const PropertyPanel = memo(function PropertyPanel({
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "y", manualOffset.y)}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "y", displayY)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
/>
@@ -395,7 +456,7 @@ export const PropertyPanel = memo(function PropertyPanel({
<div className="flex-1">
<MetricField
label="W"
value={formatPxMetricValue(resolvedWidth)}
value={formatPxMetricValue(displayW)}
disabled={manualSizeEditingDisabled}
scrub
onCommit={(next) => commitManualSize("width", next)}
@@ -407,7 +468,10 @@ export const PropertyPanel = memo(function PropertyPanel({
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "width", resolvedWidth)}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "width", displayW)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
/>
@@ -417,7 +481,7 @@ export const PropertyPanel = memo(function PropertyPanel({
<div className="flex-1">
<MetricField
label="H"
value={formatPxMetricValue(resolvedHeight)}
value={formatPxMetricValue(displayH)}
disabled={manualSizeEditingDisabled}
scrub
onCommit={(next) => commitManualSize("height", next)}
@@ -429,8 +493,9 @@ export const PropertyPanel = memo(function PropertyPanel({
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={(pct) =>
onAddKeyframe?.(gsapAnimId, pct, "height", resolvedHeight)
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "height", displayH)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
@@ -441,7 +506,7 @@ export const PropertyPanel = memo(function PropertyPanel({
<div className="flex-1">
<MetricField
label="R"
value={`${manualRotation.angle}°`}
value={`${displayR}°`}
onCommit={(next) => commitManualRotation(next.replace("°", ""))}
/>
</div>
@@ -451,8 +516,9 @@ export const PropertyPanel = memo(function PropertyPanel({
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={(pct) =>
onAddKeyframe?.(gsapAnimId, pct, "rotation", manualRotation.angle)
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "rotation", displayR)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
@@ -460,7 +526,80 @@ export const PropertyPanel = memo(function PropertyPanel({
)}
</div>
</div>
{gsapRuntimeValues && (
<div className="mt-3 border-t border-neutral-800/40 pt-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
3D Transform
</div>
<div className={RESPONSIVE_GRID}>
<div className="flex items-center gap-1">
<div className="flex-1">
<MetricField
label="Z"
value={formatPxMetricValue(gsapRuntimeValues.z ?? 0)}
scrub
onCommit={(next) => {
const v = parsePxMetricValue(next);
if (v != null && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "z", v);
}
}}
/>
</div>
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
<KeyframeNavigation
property="z"
keyframes={gsapKeyframes}
currentPercentage={currentPct}
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
onAddKeyframe={() => {
if (onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
}
}}
onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
/>
)}
</div>
<MetricField
label="Scale"
value={String(gsapRuntimeValues.scale ?? 1)}
scrub
onCommit={(next) => {
const v = Number.parseFloat(next);
if (Number.isFinite(v) && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "scale", v);
}
}}
/>
<MetricField
label="RotX"
value={`${gsapRuntimeValues.rotationX ?? 0}°`}
onCommit={(next) => {
const v = Number.parseFloat(next.replace("°", ""));
if (Number.isFinite(v) && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "rotationX", v);
}
}}
/>
<MetricField
label="RotY"
value={`${gsapRuntimeValues.rotationY ?? 0}°`}
onCommit={(next) => {
const v = Number.parseFloat(next.replace("°", ""));
if (Number.isFinite(v) && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(element, "rotationY", v);
}
}}
/>
</div>
</div>
)}
<div className="mt-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
Stacking
</div>
<MetricField
label="Z-index"
value={String(parseInt(styles["z-index"] || "auto", 10) || 0)}
@@ -516,8 +516,58 @@ function reapplyPathOffsets(doc: Document): void {
}
}
function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
const win = el.ownerDocument.defaultView as
| (Window & {
__timelines?: Record<
string,
{
getChildren?: (
deep: boolean,
) => Array<{ targets?: () => Element[]; vars?: Record<string, unknown> }>;
}
>;
})
| null;
if (!win?.__timelines) return false;
const propSet = new Set(props);
for (const tl of Object.values(win.__timelines)) {
if (!tl?.getChildren) continue;
try {
for (const child of tl.getChildren(true)) {
if (!child.targets || !child.vars) continue;
let targetsEl = false;
for (const t of child.targets()) {
if (t === el || (el.id && t.id === el.id)) {
targetsEl = true;
break;
}
}
if (!targetsEl) continue;
const vars = child.vars;
for (const p of propSet) {
if (p in vars) return true;
}
if (vars.keyframes && typeof vars.keyframes === "object") {
for (const kfVal of Object.values(vars.keyframes as Record<string, unknown>)) {
if (kfVal && typeof kfVal === "object") {
for (const p of propSet) {
if (p in (kfVal as Record<string, unknown>)) return true;
}
}
}
}
}
} catch {
/* */
}
}
return false;
}
function reapplyBoxSizes(doc: Document): void {
for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
if (gsapAnimatesProperty(el, "width", "height")) continue;
const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
@@ -69,6 +69,7 @@ interface NLELayoutProps {
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onSelectTimelineElement?: (element: TimelineElement | null) => void;
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
onDeleteAllKeyframes?: (elementId: string) => void;
@@ -122,6 +123,7 @@ export const NLELayout = memo(function NLELayout({
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
onSplitElement,
onSelectTimelineElement,
onDeleteKeyframe,
onDeleteAllKeyframes,
@@ -457,6 +459,7 @@ export const NLELayout = memo(function NLELayout({
onMoveElement={onMoveElement}
onResizeElement={onResizeElement}
onBlockedEditAttempt={onBlockedEditAttempt}
onSplitElement={onSplitElement}
onSelectElement={onSelectTimelineElement}
onDeleteKeyframe={onDeleteKeyframe}
onDeleteAllKeyframes={onDeleteAllKeyframes}
@@ -68,9 +68,9 @@ export function DomEditProvider({
handleGsapAddKeyframe,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapMaterializeKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
commitAnimatedProperty,
invalidateGsapCache,
previewIframeRef,
},
@@ -136,9 +136,9 @@ export function DomEditProvider({
handleGsapAddKeyframe,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapMaterializeKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
commitAnimatedProperty,
invalidateGsapCache,
previewIframeRef,
}),
@@ -198,9 +198,9 @@ export function DomEditProvider({
handleGsapAddKeyframe,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapMaterializeKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
commitAnimatedProperty,
invalidateGsapCache,
previewIframeRef,
],
@@ -383,7 +383,7 @@ async function commitFromToPosition(
// ── Runtime property reader ───────────────────────────────────────────────
function readGsapProperty(
export function readGsapProperty(
iframe: HTMLIFrameElement | null,
selector: string | null,
prop: string,
@@ -401,7 +401,7 @@ function readGsapProperty(
}
}
function readAllAnimatedProperties(
export function readAllAnimatedProperties(
iframe: HTMLIFrameElement | null,
selector: string,
anim: GsapAnimation,
@@ -0,0 +1,131 @@
/**
* Unified helper for committing any GSAP property value from the design panel.
*
* Handles three cases:
* 1. Animation with keyframes add-keyframe at current percentage
* 2. Flat animation (no keyframes) convert to keyframes, then add-keyframe
* 3. No animation create tl.to(), convert to keyframes, then add-keyframe
*/
import { useCallback } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
interface CommitAnimatedPropertyDeps {
selectedGsapAnimations: GsapAnimation[];
gsapCommitMutation:
| ((
selection: DomEditSelection,
mutation: Record<string, unknown>,
options: {
label: string;
coalesceKey?: string;
softReload?: boolean;
skipReload?: boolean;
},
) => Promise<void>)
| null;
addGsapAnimation: (
selection: DomEditSelection,
method: "to" | "from" | "set" | "fromTo",
currentTime?: number,
) => void;
convertToKeyframes: (selection: DomEditSelection, animId: string) => void;
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
bumpGsapCache: () => void;
}
function computePercentage(selection: DomEditSelection): number {
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
const currentTime = usePlayerStore.getState().currentTime;
return elDuration > 0
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
: 0;
}
function selectorFor(selection: DomEditSelection): string | null {
if (selection.id) return `#${selection.id}`;
if (selection.selector) return selection.selector;
return null;
}
export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
const {
selectedGsapAnimations,
gsapCommitMutation,
addGsapAnimation,
previewIframeRef,
bumpGsapCache,
} = deps;
const commitAnimatedProperty = useCallback(
async (
selection: DomEditSelection,
property: string,
value: number | string,
): Promise<void> => {
if (!gsapCommitMutation) return;
const iframe = previewIframeRef.current;
const selector = selectorFor(selection);
const pct = computePercentage(selection);
let anim: GsapAnimation | undefined =
selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0];
// Case 3: No animation — create one first
if (!anim) {
addGsapAnimation(selection, "to");
// The addGsapAnimation triggers a reload. We need to wait for the cache
// to update. Use a small delay then bump cache to re-fetch.
await new Promise((r) => setTimeout(r, 500));
bumpGsapCache();
// After creation, we can't proceed in this call — the animation isn't
// in our local state yet. The user's next edit will find it.
// For immediate feedback, trigger a convert-to-keyframes on the new animation.
return;
}
// Case 2: Flat animation — convert to keyframes first
if (!anim.keyframes) {
await gsapCommitMutation(
selection,
{ type: "convert-to-keyframes", animationId: anim.id },
{ label: "Convert to keyframes", skipReload: true },
);
}
// Read all currently animated properties from runtime for backfill
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
// Build the properties object: all runtime props + the new value
const properties: Record<string, number | string> = { ...runtimeProps };
properties[property] = value;
// Compute backfill defaults for properties not in existing keyframes
const backfillDefaults: Record<string, number | string> = { ...runtimeProps };
if (!(property in runtimeProps) && selector) {
const cssVal = readGsapProperty(iframe, selector, property);
if (cssVal != null) backfillDefaults[property] = cssVal;
}
backfillDefaults[property] = typeof value === "number" ? value : value;
await gsapCommitMutation(
selection,
{
type: "add-keyframe",
animationId: anim.id,
percentage: pct,
properties,
backfillDefaults,
},
{ label: `Edit ${property} (keyframe ${pct}%)`, softReload: true },
);
},
[selectedGsapAnimations, gsapCommitMutation, addGsapAnimation, previewIframeRef, bumpGsapCache],
);
return commitAnimatedProperty;
}
@@ -62,6 +62,7 @@ interface EditHistoryHandle {
interface UseAppHotkeysParams {
toggleTimelineVisibility: () => void;
handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
clearDomSelectionRef: React.MutableRefObject<() => void>;
@@ -87,6 +88,7 @@ interface UseAppHotkeysParams {
export function useAppHotkeys({
toggleTimelineVisibility,
handleTimelineElementDelete,
handleTimelineElementSplit,
handleDomEditElementDelete,
domEditSelectionRef,
editHistory,
@@ -195,6 +197,8 @@ export function useAppHotkeys({
handleToggleRef.current = handleTimelineToggleHotkey;
const handleDeleteRef = useRef(handleTimelineElementDelete);
handleDeleteRef.current = handleTimelineElementDelete;
const handleSplitRef = useRef(handleTimelineElementSplit);
handleSplitRef.current = handleTimelineElementSplit;
const handleDomEditDeleteRef = useRef(handleDomEditElementDelete);
handleDomEditDeleteRef.current = handleDomEditElementDelete;
const handleUndoRef = useRef(handleUndo);
@@ -306,6 +310,30 @@ export function useAppHotkeys({
return;
}
// S — split selected clip at playhead
if (
event.key === "s" &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!isEditableTarget(event.target)
) {
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
if (selectedElementId) {
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
if (
element &&
["video", "audio", "img"].includes(element.tag) &&
currentTime > element.start &&
currentTime < element.start + element.duration
) {
event.preventDefault();
void handleSplitRef.current(element, currentTime);
return;
}
}
}
// Delete / Backspace — remove selected keyframes > reset keyframes > remove element
if (
(event.key === "Delete" || event.key === "Backspace") &&
+24 -47
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef } from "react";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player";
import {
STUDIO_INSPECTOR_PANELS_ENABLED,
STUDIO_GSAP_PANEL_ENABLED,
@@ -27,8 +28,8 @@ import {
tryGsapDragIntercept,
tryGsapResizeIntercept,
tryGsapRotationIntercept,
readRuntimeKeyframes,
} from "./gsapRuntimeBridge";
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
// ── Types ──
@@ -206,6 +207,18 @@ export function useDomEditSession({
onClickToSource,
});
// Sync DOM selection → timeline selectedElementId so that clip selection
// highlights and diamond playhead fills work on cold-load URL restore.
useEffect(() => {
if (!domEditSelection?.id) return;
const { selectedElementId, elements, setSelectedElementId } = usePlayerStore.getState();
const matchKey = elements.find(
(el) => el.domId === domEditSelection.id || el.id === domEditSelection.id,
);
const key = matchKey ? (matchKey.key ?? matchKey.id) : null;
if (key && key !== selectedElementId) setSelectedElementId(key);
}, [domEditSelection?.id]);
// ── GSAP script editing ──
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
@@ -216,7 +229,6 @@ export function useDomEditSession({
STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null,
gsapSourceFile,
gsapCacheVersion,
previewIframeRef,
);
const {
@@ -230,7 +242,6 @@ export function useDomEditSession({
? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null }
: null,
gsapCacheVersion,
previewIframeRef,
);
const {
@@ -494,49 +505,6 @@ export function useDomEditSession({
[domEditSelection, convertToKeyframes],
);
const handleGsapMaterializeKeyframes = useCallback(
async (animId: string) => {
if (!domEditSelection || !gsapCommitMutation) return;
const anim = selectedGsapAnimations.find((a) => a.id === animId);
if (!anim || (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) || !anim.keyframes)
return;
if (anim.hasUnresolvedSelector) {
const { scanAllRuntimeKeyframes } = await import("./gsapRuntimeKeyframes");
const allScanned = scanAllRuntimeKeyframes(previewIframeRef.current);
if (allScanned.size === 0) return;
const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
selector: `#${id}`,
keyframes: data.keyframes,
easeEach: data.easeEach,
}));
await gsapCommitMutation(
domEditSelection,
{
type: "materialize-keyframes",
animationId: animId,
keyframes: allScanned.get(domEditSelection.id ?? "")?.keyframes ?? [],
allElements,
},
{ label: "Unroll dynamic animations", skipReload: true },
);
return;
}
const runtime = readRuntimeKeyframes(previewIframeRef.current, anim.targetSelector);
if (!runtime || runtime.keyframes.length === 0) return;
await gsapCommitMutation(
domEditSelection,
{
type: "materialize-keyframes",
animationId: animId,
keyframes: runtime.keyframes,
easeEach: runtime.easeEach,
},
{ label: "Materialize dynamic keyframes", skipReload: true },
);
},
[domEditSelection, selectedGsapAnimations, gsapCommitMutation, previewIframeRef],
);
const handleGsapRemoveAllKeyframes = useCallback(
(animId: string) => {
if (!domEditSelection) return;
@@ -559,6 +527,15 @@ export function useDomEditSession({
return true;
}, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]);
const commitAnimatedProperty = useAnimatedPropertyCommit({
selectedGsapAnimations,
gsapCommitMutation,
addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time),
convertToKeyframes: (sel, animId) => convertToKeyframes(sel, animId),
previewIframeRef,
bumpGsapCache,
});
// Sync selection from preview document on load / refresh
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
@@ -702,9 +679,9 @@ export function useDomEditSession({
handleGsapAddKeyframe,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapMaterializeKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
commitAnimatedProperty,
invalidateGsapCache: bumpGsapCache,
previewIframeRef,
};
@@ -471,13 +471,15 @@ export function useTimelineEditing({
const pid = projectIdRef.current;
if (!pid) return;
const splittableTags = new Set(["video", "audio", "img"]);
if (
element.timelineLocked ||
element.timingSource === "implicit" ||
element.compositionSrc ||
!splittableTags.has(element.tag) ||
!element.duration ||
!Number.isFinite(element.duration)
) {
showToast("This clip cannot be split.", "error");
return;
}
@@ -20,6 +20,7 @@ import {
Camera as PhCamera,
ArrowClockwise,
Gear,
Scissors as PhScissors,
} from "@phosphor-icons/react";
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
@@ -55,3 +56,4 @@ export const RotateCcw = makeIcon(ArrowCounterClockwise);
export const Camera = makeIcon(PhCamera);
export const RotateCw = makeIcon(ArrowClockwise);
export const Settings = makeIcon(Gear);
export const Scissors = makeIcon(PhScissors);
@@ -43,11 +43,15 @@ export const ClipContextMenu = memo(function ClipContextMenu({
const adjustedX = Math.min(x, window.innerWidth - 200);
const adjustedY = Math.min(y, window.innerHeight - 200);
const canSplit = currentTime > element.start && currentTime < element.start + element.duration;
const isSplittable = ["video", "audio", "img"].includes(element.tag);
const canSplit =
isSplittable && currentTime > element.start && currentTime < element.start + element.duration;
const splitLabel = canSplit
? `Split at ${currentTime.toFixed(2)}s`
: "Split (move playhead inside clip)";
const splitLabel = !isSplittable
? null
: canSplit
? `Split at ${currentTime.toFixed(2)}s`
: "Split (move playhead inside clip)";
return (
<div
@@ -55,26 +59,29 @@ export const ClipContextMenu = memo(function ClipContextMenu({
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 }}
>
<button
type="button"
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
canSplit
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
: "text-neutral-600 cursor-not-allowed"
}`}
disabled={!canSplit}
onClick={() => {
if (canSplit) {
onSplit(element, currentTime);
onClose();
}
}}
>
<span>{splitLabel}</span>
<span className="text-neutral-500 text-[10px] ml-3">S</span>
</button>
<div className="my-1 border-t border-neutral-700/60" />
{splitLabel && (
<>
<button
type="button"
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
canSplit
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
: "text-neutral-600 cursor-not-allowed"
}`}
disabled={!canSplit}
onClick={() => {
if (canSplit) {
onSplit(element, currentTime);
onClose();
}
}}
>
<span>{splitLabel}</span>
<span className="text-neutral-500 text-[10px] ml-3">S</span>
</button>
<div className="my-1 border-t border-neutral-700/60" />
</>
)}
<button
type="button"
@@ -16,6 +16,7 @@ import {
type KeyframeDiamondContextMenuState,
} from "./KeyframeDiamondContextMenu";
import { useTimelineClipDrag } from "./useTimelineClipDrag";
import { ClipContextMenu } from "./ClipContextMenu";
import {
GUTTER,
TRACK_H,
@@ -70,6 +71,7 @@ interface TimelineProps {
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onSelectElement?: (element: TimelineElement | null) => void;
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
onDeleteAllKeyframes?: (elementId: string) => void;
@@ -91,6 +93,7 @@ export const Timeline = memo(function Timeline({
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
onSplitElement,
onSelectElement,
onDeleteKeyframe,
onDeleteAllKeyframes,
@@ -135,6 +138,11 @@ export const Timeline = memo(function Timeline({
const [showPopover, setShowPopover] = useState(false);
const [showShortcutHint, setShowShortcutHint] = useState(true);
const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
const [clipContextMenu, setClipContextMenu] = useState<{
x: number;
y: number;
element: TimelineElement;
} | null>(null);
const [viewportWidth, setViewportWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const shortcutHintRafRef = useRef(0);
@@ -532,6 +540,12 @@ export const Timeline = memo(function Timeline({
currentEase: kf?.ease ?? kfData?.ease,
});
}}
onContextMenuClip={(e, el) => {
e.preventDefault();
setSelectedElementId(el.key ?? el.id);
onSelectElement?.(el);
setClipContextMenu({ x: e.clientX, y: e.clientY, element: el });
}}
/>
</div>
@@ -583,6 +597,18 @@ export const Timeline = memo(function Timeline({
}}
/>
)}
{clipContextMenu && (
<ClipContextMenu
x={clipContextMenu.x}
y={clipContextMenu.y}
element={clipContextMenu.element}
currentTime={currentTime}
onClose={() => setClipContextMenu(null)}
onSplit={(el, time) => onSplitElement?.(el, time)}
onDelete={(el) => _onDeleteElement?.(el)}
/>
)}
</div>
);
});
@@ -67,6 +67,7 @@ interface TimelineCanvasProps {
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
}
@@ -116,6 +117,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
onShiftClickKeyframe,
onDragKeyframe,
onContextMenuKeyframe,
onContextMenuClip,
onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead,
}: TimelineCanvasProps) {
const draggedElement = draggedClip?.element ?? null;
@@ -249,6 +251,10 @@ export const TimelineCanvas = memo(function TimelineCanvas({
return (
<TimelineClip
key={clipKey}
onContextMenu={(e: React.MouseEvent) => {
e.preventDefault();
onContextMenuClip?.(e, el);
}}
el={previewElement}
pps={pps}
clipY={CLIP_Y}
@@ -23,6 +23,7 @@ interface TimelineClipProps {
onResizeStart?: (edge: "start" | "end", e: React.PointerEvent) => void;
onClick: (e: React.MouseEvent) => void;
onDoubleClick: (e: React.MouseEvent) => void;
onContextMenu?: (e: React.MouseEvent) => void;
children?: ReactNode;
}
@@ -44,6 +45,7 @@ export const TimelineClip = memo(function TimelineClip({
onResizeStart,
onClick,
onDoubleClick,
onContextMenu,
children,
}: TimelineClipProps) {
const leftPx = el.start * pps;
@@ -51,14 +53,14 @@ export const TimelineClip = memo(function TimelineClip({
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
const borderColor = isSelected
? trackStyle.accent + "60"
? trackStyle.accent
: isHovered
? theme.clipBorderHover
: theme.clipBorder;
const boxShadow = isDragging
? theme.clipShadowDragging
: isSelected
? `0 0 0 1px ${trackStyle.accent}40`
? `0 0 0 1px ${trackStyle.accent}80, 0 0 8px ${trackStyle.accent}25`
: isHovered
? theme.clipShadowHover
: theme.clipShadow;
@@ -98,6 +100,7 @@ export const TimelineClip = memo(function TimelineClip({
onPointerDown={onPointerDown}
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
>
{/* Left accent stripe */}
<div
@@ -122,7 +122,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
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 atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3";
return (
<button