mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
fix(studio): fix array-form keyframe writes, diamond click-deselect, and nested video sync
- fs.watch's async 'error' event had no listener, crashing the preview
server on EMFILE (exhausted OS watch handles)
- moveKeyframeInScript/resizeKeyframedTweenInScript/removeAllKeyframesFromScript
required object-form keyframes: {"0%": {...}}, silently no-opping on
array-form keyframes: [{...}, {...}]
- a keyframe diamond click's auto-synthesized native click event bubbled
to the ancestor clip's onClick, which toggles selection off when the
clip is already selected (the state every diamond click happens in)
- the clip's trim-resize handles (z-index 4) visually and functionally
covered any keyframe diamond within their 14px edge strip
- synthesizeFlatTweenKeyframes didn't recognize a collapsed
duration:0 + immediateRender static hold (what remove-all-keyframes
produces) as non-animated, so it kept showing a phantom diamond after
Delete All Keyframes
- resolveMediaStartSeconds's fast path for elements with their own
data-start discarded the host composition's inherited start offset,
so a video nested inside a sub-composition played from the root
timeline's time instead of holding until its parent scene began
Fixes #1838
This commit is contained in:
@@ -446,6 +446,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
onShiftClickKeyframe={onShiftClickKeyframe}
|
||||
onContextMenuKeyframe={onContextMenuKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>
|
||||
)}
|
||||
</TimelineClip>
|
||||
|
||||
@@ -70,4 +70,146 @@ describe("TimelineClipDiamonds", () => {
|
||||
expect(onClickKeyframe).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// Regression: once the clip is selected, canDrag arms on every diamond
|
||||
// press. A real click's few px of mouse/trackpad jitter then resolves (via
|
||||
// the neighbour clamp) back onto ~the same position — "noop", not "move" —
|
||||
// which fell through neither branch and silently did nothing: no
|
||||
// selection, no retime. It must still count as the click it was.
|
||||
it("treats a drag-armed press that resolves to a no-op move as a click", () => {
|
||||
const onClickKeyframe = vi.fn();
|
||||
const onMoveKeyframe = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
],
|
||||
}}
|
||||
clipWidthPx={5000}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
||||
);
|
||||
// 4px of travel at a 5000px clip width is ~0.08 clip-% — above the drag
|
||||
// threshold (so resolveKeyframeDrag doesn't short-circuit to "click"
|
||||
// itself) but below the no-op epsilon once neighbour-clamped.
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
|
||||
});
|
||||
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith(50);
|
||||
expect(onMoveKeyframe).not.toHaveBeenCalled();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// Regression: a genuine retime (drag far enough to actually move the
|
||||
// keyframe) committed the move but never selected/parked on the result —
|
||||
// the diamond it was just dragged looked exactly like one nothing happened
|
||||
// to. Select it at its NEW position too.
|
||||
it("selects the keyframe at its new position after a real drag-retime", () => {
|
||||
const onClickKeyframe = vi.fn();
|
||||
const onMoveKeyframe = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 } },
|
||||
],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onClickKeyframe={onClickKeyframe}
|
||||
onMoveKeyframe={onMoveKeyframe}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(
|
||||
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
||||
);
|
||||
// 4px at a 200px clip width is 2 clip-% — well past the no-op epsilon,
|
||||
// a real retime.
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
|
||||
});
|
||||
|
||||
expect(onMoveKeyframe).toHaveBeenCalledWith("clip-1", 50, 52);
|
||||
expect(onClickKeyframe).toHaveBeenCalledWith(52);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
// Regression: onClickKeyframe's state updates can re-render the diamond
|
||||
// button out from under the gesture before the browser auto-synthesizes the
|
||||
// "click" event that follows a button's pointerdown+pointerup. That orphaned
|
||||
// click then bubbles to the ancestor clip's onClick, which toggles selection
|
||||
// off whenever the clip is already selected — the state a diamond click
|
||||
// always happens in — so every keyframe click immediately deselected its
|
||||
// own clip. suppressClickRef lets that ancestor ignore the stray click.
|
||||
it("arms suppressClickRef synchronously on a keyframe click", () => {
|
||||
const suppressClickRef = { current: false };
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineClipDiamonds
|
||||
keyframesData={{
|
||||
format: "percentage",
|
||||
keyframes: [{ percentage: 50, properties: { x: 100 } }],
|
||||
}}
|
||||
clipWidthPx={200}
|
||||
clipHeightPx={48}
|
||||
accentColor="#4ba3d2"
|
||||
isSelected
|
||||
currentPercentage={0}
|
||||
elementId="clip-1"
|
||||
selectedKeyframes={new Set()}
|
||||
onClickKeyframe={vi.fn()}
|
||||
suppressClickRef={suppressClickRef}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
||||
expect(diamond).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
|
||||
});
|
||||
|
||||
expect(suppressClickRef.current).toBe(true);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,10 @@ interface TimelineClipDiamondsProps {
|
||||
fromClipPercentage: number,
|
||||
toClipPercentage: number,
|
||||
) => void;
|
||||
/** Set while resolving a diamond press so the ancestor clip's onClick (which
|
||||
* toggles selection off when already selected) ignores the native "click"
|
||||
* the browser auto-synthesizes after this button's pointerdown+pointerup. */
|
||||
suppressClickRef?: React.RefObject<boolean>;
|
||||
}
|
||||
|
||||
const DIAMOND_RATIO = 0.8;
|
||||
@@ -76,6 +80,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
onShiftClickKeyframe,
|
||||
onContextMenuKeyframe,
|
||||
onMoveKeyframe,
|
||||
suppressClickRef,
|
||||
}: TimelineClipDiamondsProps) {
|
||||
// Hooks must run before the early return below.
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
@@ -83,6 +88,21 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
|
||||
// on drop re-keys the diamond from source.
|
||||
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
|
||||
// The button element can re-render (reposition/unmount) synchronously from
|
||||
// the state updates onClickKeyframe/onMoveKeyframe trigger, before the
|
||||
// browser gets to auto-synthesize the "click" event that normally follows
|
||||
// pointerdown+pointerup on a button. That orphaned click then fires on
|
||||
// whatever ancestor is still there — the clip wrapper — whose own onClick
|
||||
// toggles selection off when the clip is already selected (the state a
|
||||
// diamond click always happens in). Suppressing it here is the same fix
|
||||
// already used for clip drag/resize in useTimelineClipDrag.ts.
|
||||
const suppressNextClick = () => {
|
||||
if (!suppressClickRef) return;
|
||||
suppressClickRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
suppressClickRef.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
if (clipWidthPx < 20) return null;
|
||||
|
||||
@@ -102,7 +122,19 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
const canDrag = isSelected && !!onMoveKeyframe;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ zIndex: 3, pointerEvents: "none" }}>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
// Above the clip's trim-handle strips (TimelineClip.tsx, z-index 4) so
|
||||
// a keyframe sitting in the first/last ~14px of the clip stays
|
||||
// clickable instead of being covered by the resize handle. This div
|
||||
// establishes its own stacking context (position + z-index), so the
|
||||
// diamonds' own z-index (1/2) can't escape it on their own — the bump
|
||||
// has to happen here.
|
||||
zIndex: 5,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{sorted.map((kf, i) => {
|
||||
if (i === 0) return null;
|
||||
const prev = sorted[i - 1]!;
|
||||
@@ -179,6 +211,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
// No drag armed (canDrag false / non-primary press) → treat as a click.
|
||||
if (!d || d.kfKey !== kfKey) {
|
||||
if (e.button !== 0) return;
|
||||
suppressNextClick();
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
||||
else onClickKeyframe?.(kf.percentage);
|
||||
return;
|
||||
@@ -187,6 +220,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
dragRef.current = null;
|
||||
setPreview(null);
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
suppressNextClick();
|
||||
const res = resolveKeyframeDrag({
|
||||
pointerDownX: d.startX,
|
||||
pointerUpX: e.clientX,
|
||||
@@ -195,11 +229,20 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
draggedIndex: i,
|
||||
sortedClipPcts,
|
||||
});
|
||||
if (res.kind === "click") {
|
||||
if (res.kind === "click" || res.kind === "noop") {
|
||||
// "noop" is a press with enough pointer jitter to arm a drag (canDrag
|
||||
// is on for every diamond once the clip is selected) that resolved
|
||||
// back onto ~the same position — no real retime, so treat it as the
|
||||
// click it was. Otherwise a normal click with a few px of mouse/
|
||||
// trackpad drift silently does nothing: no selection, no move.
|
||||
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
|
||||
else onClickKeyframe?.(kf.percentage);
|
||||
} else if (res.kind === "move" && res.toClipPct != null) {
|
||||
onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct);
|
||||
// A retime still targeted this exact diamond — park/select it at its
|
||||
// new position, same as a plain click, or a drag that actually moved
|
||||
// something looks identical to one that silently did nothing.
|
||||
onClickKeyframe?.(res.toClipPct);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user