mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): gesture recording replaces existing position keyframes (#1359)
* fix(core): per-property-group keyframe foundations
Add PropertyGroupName type system (position/scale/size/rotation/visual/other),
PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup
functions. Parser generates group-aware animation IDs, resolves position strings
(+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs
across all mutations.
* fix(core): add split-into-property-groups and replace-with-keyframes mutations
Server-side mutations for atomic property-group splitting and keyframe
replacement. Client commitMutation returns early on changed:false instead
of throwing.
* fix(studio): per-property-group intercept routing + drag/resize fixes
Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y}
to position group, resize routes to scale group via data-hf-studio-original-width,
rotation routes to rotation group. Add resolveGroupTween helper, from-extend
with split-first-then-position-only pattern, autoKeyframeEnabled guards,
GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs.
* fix(studio): keyframe cache propertyGroup tagging + timeline UI fixes
Tag cached keyframes with propertyGroup for group-aware operations.
Add tweenPercentage for accurate keyframe matching, activeKeyframePct
for diamond-click targeting, context menu offset, selected diamond z-index,
clearProps after kill in soft reload.
* fix(studio): property panel group-aware keyframe routing
Add animIdForProp helper routing keyframe diamonds to correct property-group
animation. Wire StudioPreviewArea delete/move/toggle handlers to use
propertyGroup for routing. Fix per-property epsilon in rdpSimplify.
* fix(studio): gesture recording replaces existing position keyframes
Gesture recording uses replace-with-keyframes mutation to replace existing
position-group tween. Fix N1 sign inversion and N9 wheel startPointer
with pointerElementOffset subtraction.
This commit is contained in:
@@ -7,10 +7,13 @@ import { useGestureRecording } from "./useGestureRecording";
|
||||
import { simplifyGestureSamples } from "../utils/rdpSimplify";
|
||||
import { usePlayerStore } from "../player";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
||||
|
||||
// Minimal subset of the session used by gesture commit
|
||||
interface GestureSessionRef {
|
||||
domEditSelection: DomEditSelection | null;
|
||||
selectedGsapAnimations?: GsapAnimation[];
|
||||
commitMutation?: (
|
||||
mutation: Record<string, unknown>,
|
||||
options: { label: string; softReload?: boolean },
|
||||
@@ -43,6 +46,9 @@ export function useGestureCommit({
|
||||
const recordingAutoStopRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const recordingStartTimeRef = useRef(0);
|
||||
const commitInFlightRef = useRef(false);
|
||||
// Capture selection at recording start so commit always targets the recorded element,
|
||||
// even if the user's selection changes mid-recording.
|
||||
const capturedSelectionRef = useRef<DomEditSelection | null>(null);
|
||||
|
||||
// Unmount: clear auto-stop interval
|
||||
useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
|
||||
@@ -59,7 +65,7 @@ export function useGestureCommit({
|
||||
store.setIsPlaying(false);
|
||||
try {
|
||||
const liveSession = domEditSessionRef.current;
|
||||
const sel = liveSession.domEditSelection;
|
||||
const sel = capturedSelectionRef.current;
|
||||
if (!sel) {
|
||||
if (frozenSamples.length > 2) {
|
||||
showToast("Selection lost during recording", "error");
|
||||
@@ -77,7 +83,13 @@ export function useGestureCommit({
|
||||
return;
|
||||
}
|
||||
|
||||
const simplified = simplifyGestureSamples(frozenSamples, duration, 5);
|
||||
// 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).
|
||||
const simplified = simplifyGestureSamples(frozenSamples, duration, (key) => {
|
||||
if (key === "opacity") return 0.01;
|
||||
if (key === "scale" || key === "scaleX" || key === "scaleY") return 0.01;
|
||||
return 5;
|
||||
});
|
||||
const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b);
|
||||
|
||||
// Ensure a 0% keyframe exists with the element's start-of-recording position
|
||||
@@ -98,16 +110,74 @@ export function useGestureCommit({
|
||||
properties: simplified.get(pct) as Record<string, number | string>,
|
||||
}));
|
||||
|
||||
await liveSession.commitMutation(
|
||||
{
|
||||
type: "add-with-keyframes",
|
||||
targetSelector: selector,
|
||||
position: Math.round(recStart * 1000) / 1000,
|
||||
duration: Math.round(duration * 1000) / 1000,
|
||||
keyframes,
|
||||
},
|
||||
{ label: "Gesture recording", softReload: true },
|
||||
// Check if the recorded gesture contains position properties.
|
||||
// If so, and a position-group tween already exists for this element,
|
||||
// replace it atomically instead of adding a duplicate.
|
||||
const hasPositionProps = keyframes.some((kf) =>
|
||||
Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
|
||||
);
|
||||
const existingPositionTween = hasPositionProps
|
||||
? liveSession.selectedGsapAnimations?.find(
|
||||
(a) => a.propertyGroup === "position" && a.targetSelector === selector,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (existingPositionTween) {
|
||||
const tweenStart = existingPositionTween.resolvedStart ?? 0;
|
||||
const tweenDur = existingPositionTween.duration ?? duration;
|
||||
const existingKfs = existingPositionTween.keyframes?.keyframes ?? [];
|
||||
|
||||
// Map the recording window (recStart..recStart+duration) into the
|
||||
// existing tween's 0-100% space so we can merge instead of nuke.
|
||||
const rangeStartPct = tweenDur > 0 ? ((recStart - tweenStart) / tweenDur) * 100 : 0;
|
||||
const rangeEndPct =
|
||||
tweenDur > 0 ? ((recStart + duration - tweenStart) / tweenDur) * 100 : 100;
|
||||
|
||||
// Keep existing keyframes that fall outside the recording window
|
||||
const preserved = existingKfs
|
||||
.filter(
|
||||
(kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5,
|
||||
)
|
||||
.map((kf) => ({
|
||||
percentage: kf.percentage,
|
||||
properties: kf.properties,
|
||||
...(kf.ease ? { ease: kf.ease } : {}),
|
||||
}));
|
||||
|
||||
// Map recorded keyframes (0-100% of recording) into tween percentage space
|
||||
const mapped = keyframes.map((kf) => ({
|
||||
percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct),
|
||||
properties: kf.properties,
|
||||
}));
|
||||
|
||||
const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
|
||||
|
||||
await liveSession.commitMutation(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: existingPositionTween.id,
|
||||
targetSelector: selector,
|
||||
position:
|
||||
typeof existingPositionTween.position === "number"
|
||||
? existingPositionTween.position
|
||||
: tweenStart,
|
||||
duration: tweenDur,
|
||||
keyframes: merged,
|
||||
},
|
||||
{ label: "Gesture recording (merge)", softReload: true },
|
||||
);
|
||||
} else {
|
||||
await liveSession.commitMutation(
|
||||
{
|
||||
type: "add-with-keyframes",
|
||||
targetSelector: selector,
|
||||
position: Math.round(recStart * 1000) / 1000,
|
||||
duration: Math.round(duration * 1000) / 1000,
|
||||
keyframes,
|
||||
},
|
||||
{ label: "Gesture recording", softReload: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
|
||||
} finally {
|
||||
@@ -139,6 +209,7 @@ export function useGestureCommit({
|
||||
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
||||
const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0;
|
||||
const elementEnd = elDur > 0 ? elStart + elDur : undefined;
|
||||
capturedSelectionRef.current = sel;
|
||||
gestureRecording.startRecording(sel.element, iframe, elementEnd);
|
||||
gestureStateRef.current = "recording";
|
||||
isGestureRecordingRef.current = true;
|
||||
|
||||
@@ -126,8 +126,12 @@ function applyRuntimePreview(
|
||||
|
||||
function recordSample(r: RecordingRefs, time: number, properties: Record<string, number>): void {
|
||||
const sampleProps = { ...properties };
|
||||
if ("x" in sampleProps) sampleProps.x -= r.cssVarOffset.x;
|
||||
if ("y" in sampleProps) sampleProps.y -= r.cssVarOffset.y;
|
||||
// Subtract both the CSS var offset AND the pointer-element snap offset
|
||||
// so the first sample doesn't include the snap-to-cursor jump.
|
||||
if ("x" in sampleProps)
|
||||
sampleProps.x -= r.cssVarOffset.x + r.pointerElementOffset.x / (r.scale || 1);
|
||||
if ("y" in sampleProps)
|
||||
sampleProps.y -= r.cssVarOffset.y + r.pointerElementOffset.y / (r.scale || 1);
|
||||
r.samples.push({ time, properties: sampleProps });
|
||||
r.trail.push({ x: r.pointer.x, y: r.pointer.y });
|
||||
}
|
||||
@@ -307,6 +311,18 @@ export function useGestureRecording() {
|
||||
};
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// Capture startPointer on first wheel if no pointermove has fired yet,
|
||||
// preventing an enormous bogus first keyframe from stale startPointer.
|
||||
if (!r.hasMoved) {
|
||||
r.startPointer = { x: r.pointer.x, y: r.pointer.y };
|
||||
r.pointerElementOffset = {
|
||||
x: r.pointer.x - elCenterViewport.x,
|
||||
y: r.pointer.y - elCenterViewport.y,
|
||||
};
|
||||
r.basePosition.x += r.pointerElementOffset.x / iframeScale;
|
||||
r.basePosition.y += r.pointerElementOffset.y / iframeScale;
|
||||
r.hasMoved = true;
|
||||
}
|
||||
r.scrollDelta += e.deltaY;
|
||||
r.modifiers = { shift: e.shiftKey, alt: e.altKey, meta: e.metaKey || e.ctrlKey };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user