mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
366 lines
15 KiB
TypeScript
366 lines
15 KiB
TypeScript
/**
|
||
* Manages gesture recording state and commit logic for the Studio.
|
||
* Extracted from App.tsx to keep file sizes under the 600-line limit.
|
||
*/
|
||
import { useState, useCallback, useRef, useEffect } from "react";
|
||
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 type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
||
import { roundTo3 } from "../utils/rounding";
|
||
import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
|
||
import { isInstantHold, idSelector } from "./gsapShared";
|
||
|
||
type RecordedKeyframe = {
|
||
percentage: number;
|
||
properties: Record<string, number | string>;
|
||
ease?: string;
|
||
};
|
||
|
||
/**
|
||
* Split recorded keyframes into one keyframe-set per property group (position /
|
||
* scale / rotation / …), each keyframe carrying only that group's props.
|
||
*
|
||
* A mixed-prop gesture (e.g. x/y + opacity) emitted as ONE add-with-keyframes
|
||
* mutation parses back as an untagged legacy mixed tween, which breaks the
|
||
* position-only drag intercept (it can't find a pure position tween to edit).
|
||
* 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[] = [];
|
||
const byGroup = new Map<string, RecordedKeyframe[]>();
|
||
for (const kf of keyframes) {
|
||
const perGroup = new Map<string, Record<string, number | string>>();
|
||
for (const [key, value] of Object.entries(kf.properties)) {
|
||
const group = classifyPropertyGroup(key);
|
||
let props = perGroup.get(group);
|
||
if (!props) {
|
||
props = {};
|
||
perGroup.set(group, props);
|
||
}
|
||
props[key] = value;
|
||
}
|
||
for (const [group, props] of perGroup) {
|
||
let set = byGroup.get(group);
|
||
if (!set) {
|
||
set = [];
|
||
byGroup.set(group, set);
|
||
groupOrder.push(group);
|
||
}
|
||
set.push({
|
||
percentage: kf.percentage,
|
||
properties: props,
|
||
...(kf.ease ? { ease: kf.ease } : {}),
|
||
});
|
||
}
|
||
}
|
||
return groupOrder.map((group) => byGroup.get(group)!);
|
||
}
|
||
|
||
// Minimal subset of the session used by gesture commit
|
||
interface GestureSessionRef {
|
||
domEditSelection: DomEditSelection | null;
|
||
selectedGsapAnimations?: GsapAnimation[];
|
||
commitMutation?: (
|
||
mutation: Record<string, unknown>,
|
||
options: CommitMutationOptions,
|
||
) => Promise<void>;
|
||
}
|
||
|
||
/** Only the LAST group in a per-group commit loop reloads the preview; the
|
||
* earlier ones skip it, so a multi-group gesture recording is one reload. */
|
||
function reloadOnlyLast(index: number, count: number): Partial<CommitMutationOptions> {
|
||
return index === count - 1 ? { softReload: true } : { skipReload: true };
|
||
}
|
||
|
||
let gestureRecordingCommitCounter = 0;
|
||
|
||
interface UseGestureCommitParams {
|
||
domEditSessionRef: React.MutableRefObject<GestureSessionRef>;
|
||
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||
showToast: (message: string, tone?: "error" | "info") => void;
|
||
isGestureRecordingRef: React.MutableRefObject<boolean>;
|
||
}
|
||
|
||
export interface UseGestureCommitResult {
|
||
gestureState: "idle" | "recording";
|
||
gestureRecording: ReturnType<typeof useGestureRecording>;
|
||
handleToggleRecording: () => void;
|
||
}
|
||
|
||
// fallow-ignore-next-line complexity
|
||
export function useGestureCommit({
|
||
domEditSessionRef,
|
||
previewIframeRef,
|
||
showToast,
|
||
isGestureRecordingRef,
|
||
}: UseGestureCommitParams): UseGestureCommitResult {
|
||
const gestureRecording = useGestureRecording();
|
||
const [gestureState, setGestureState] = useState<"idle" | "recording">("idle");
|
||
const gestureStateRef = useRef<"idle" | "recording">("idle");
|
||
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), []);
|
||
|
||
// fallow-ignore-next-line complexity
|
||
const stopAndCommitRecording = useCallback(async () => {
|
||
clearInterval(recordingAutoStopRef.current);
|
||
if (commitInFlightRef.current) {
|
||
return;
|
||
}
|
||
commitInFlightRef.current = true;
|
||
const coalesceOptions = {
|
||
coalesceKey: `gesture-recording:${++gestureRecordingCommitCounter}`,
|
||
coalesceMs: Number.POSITIVE_INFINITY,
|
||
};
|
||
gestureStateRef.current = "idle";
|
||
isGestureRecordingRef.current = false;
|
||
const frozenSamples = gestureRecording.stopRecording();
|
||
const store = usePlayerStore.getState();
|
||
store.setIsPlaying(false);
|
||
try {
|
||
const liveSession = domEditSessionRef.current;
|
||
const sel = capturedSelectionRef.current;
|
||
if (!sel) {
|
||
if (frozenSamples.length > 2) {
|
||
showToast("Selection lost during recording", "error");
|
||
}
|
||
return;
|
||
}
|
||
const duration =
|
||
frozenSamples.length > 0 ? (frozenSamples[frozenSamples.length - 1]?.time ?? 0) : 0;
|
||
|
||
if (frozenSamples.length <= 2) {
|
||
showToast("No gesture detected — move the pointer while recording", "error");
|
||
return;
|
||
}
|
||
if (duration <= 0) {
|
||
showToast("Recording too short — try again", "error");
|
||
return;
|
||
}
|
||
|
||
// 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;
|
||
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
|
||
if (!simplified.has(0) && frozenSamples.length > 0) {
|
||
simplified.set(0, frozenSamples[0]!.properties);
|
||
if (!sortedPcts.includes(0)) sortedPcts.unshift(0);
|
||
}
|
||
|
||
const selector = sel.id ? idSelector(sel.id) : sel.selector;
|
||
if (!selector) {
|
||
showToast("Cannot save — element has no selector", "error");
|
||
return;
|
||
}
|
||
if (liveSession.commitMutation) {
|
||
const recStart = recordingStartTimeRef.current;
|
||
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"),
|
||
);
|
||
const allAnims = liveSession.selectedGsapAnimations ?? [];
|
||
const existingPositionTween = hasPositionProps
|
||
? allAnims.find((a) => a.propertyGroup === "position" && a.targetSelector === selector)
|
||
: undefined;
|
||
if (existingPositionTween) {
|
||
if (isInstantHold(existingPositionTween)) {
|
||
// An instant hold is not a tween to merge into — replace it with the
|
||
// recorded motion (which already starts from the held position).
|
||
await liveSession.commitMutation(
|
||
{
|
||
type: "replace-with-keyframes",
|
||
animationId: existingPositionTween.id,
|
||
targetSelector: selector,
|
||
position: roundTo3(recStart),
|
||
duration: roundTo3(duration),
|
||
keyframes,
|
||
},
|
||
{ label: "Gesture recording (replace set)", softReload: true },
|
||
);
|
||
} else {
|
||
const tweenStart = existingPositionTween.resolvedStart ?? 0;
|
||
const tweenDur = existingPositionTween.duration ?? duration;
|
||
const tweenEnd = tweenStart + tweenDur;
|
||
const recEnd = recStart + duration;
|
||
|
||
// Only merge if the recording overlaps the existing tween's time range.
|
||
// No overlap → fall through to add-with-keyframes (creates a separate tween).
|
||
const overlaps = recStart < tweenEnd + 0.05 && recEnd > tweenStart - 0.05;
|
||
|
||
if (overlaps) {
|
||
const existingKfs = existingPositionTween.keyframes?.keyframes ?? [];
|
||
const rangeStartPct =
|
||
tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0;
|
||
const rangeEndPct =
|
||
tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100;
|
||
|
||
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 } : {}),
|
||
}));
|
||
|
||
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);
|
||
|
||
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 {
|
||
// Emit one tween per property group so a mixed-prop gesture (e.g.
|
||
// x/y + opacity) doesn't collapse into an untagged legacy mixed
|
||
// tween that the position-only drag intercept can't edit.
|
||
const keyframeGroups = partitionKeyframesByGroup(keyframes);
|
||
for (const [index, groupKfs] of keyframeGroups.entries()) {
|
||
await liveSession.commitMutation(
|
||
{
|
||
type: "add-with-keyframes",
|
||
targetSelector: selector,
|
||
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)",
|
||
...coalesceOptions,
|
||
...reloadOnlyLast(index, keyframeGroups.length),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// No existing tween — same per-group split as the new-range branch above.
|
||
const keyframeGroups = partitionKeyframesByGroup(keyframes);
|
||
for (const [index, groupKfs] of keyframeGroups.entries()) {
|
||
await liveSession.commitMutation(
|
||
{
|
||
type: "add-with-keyframes",
|
||
targetSelector: selector,
|
||
position: roundTo3(recStart),
|
||
duration: roundTo3(duration),
|
||
keyframes: groupKfs,
|
||
// Linear fallback (see above) — constant-speed segments stay linear.
|
||
easeEach: "none",
|
||
},
|
||
{
|
||
label: "Gesture recording",
|
||
...coalesceOptions,
|
||
...reloadOnlyLast(index, keyframeGroups.length),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
|
||
} catch (err) {
|
||
console.error("[GR:error]", err);
|
||
showToast(`Gesture commit failed: ${err}`, "error");
|
||
} finally {
|
||
store.requestSeek(recordingStartTimeRef.current);
|
||
gestureRecording.clearSamples();
|
||
setGestureState("idle");
|
||
commitInFlightRef.current = false;
|
||
}
|
||
}, [gestureRecording, showToast, isGestureRecordingRef, domEditSessionRef]);
|
||
|
||
// fallow-ignore-next-line complexity
|
||
const handleToggleRecording = useCallback(() => {
|
||
if (gestureStateRef.current === "recording") {
|
||
void stopAndCommitRecording();
|
||
return;
|
||
}
|
||
const sel = domEditSessionRef.current.domEditSelection;
|
||
if (!sel) {
|
||
showToast("Select an element first", "error");
|
||
return;
|
||
}
|
||
const iframe = previewIframeRef.current;
|
||
if (!iframe) {
|
||
showToast("Preview not ready — try again", "error");
|
||
return;
|
||
}
|
||
|
||
const store = usePlayerStore.getState();
|
||
recordingStartTimeRef.current = store.currentTime;
|
||
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;
|
||
setGestureState("recording");
|
||
|
||
clearInterval(recordingAutoStopRef.current);
|
||
const autoStopAt = elementEnd ?? Infinity;
|
||
recordingAutoStopRef.current = setInterval(() => {
|
||
const { currentTime: t, duration: d } = usePlayerStore.getState();
|
||
const limit = Math.min(autoStopAt, d);
|
||
if (limit > 0 && t >= limit - 0.05) {
|
||
void stopAndCommitRecording();
|
||
}
|
||
}, 100);
|
||
}, [
|
||
gestureRecording,
|
||
showToast,
|
||
stopAndCommitRecording,
|
||
previewIframeRef,
|
||
domEditSessionRef,
|
||
isGestureRecordingRef,
|
||
]);
|
||
|
||
return { gestureState, gestureRecording, handleToggleRecording };
|
||
}
|