Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/studio/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<title>HyperFrames Studio</title>
</head>
<body>
<div id="root"></div>
<div data-hf-id="hf-aph5" id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
1 change: 1 addition & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ export function StudioApp() {
domEditSelection: domEditSession.domEditSelection,
buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget,
applyDomSelection: domEditSession.applyDomSelection,
setRightPanelTab: panelLayout.setRightPanelTab,
initialState: initialUrlStateRef.current,
});
const studioCtxValue = buildStudioContextValue({
Expand Down
5 changes: 2 additions & 3 deletions packages/studio/src/components/StudioHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
import { getHistoryShortcutLabel } from "../utils/studioHelpers";
import { useStudioShellContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useDomEditActionsContext } from "../contexts/DomEditContext";
import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext";
import { trackStudioEvent } from "../utils/studioTelemetry";

Expand Down Expand Up @@ -194,7 +193,6 @@ export function StudioHeader({
}: StudioHeaderProps) {
const { projectId, editHistory, handleUndo, handleRedo } = useStudioShellContext();
const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
const { clearDomSelection } = useDomEditActionsContext();

return (
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
Expand Down Expand Up @@ -279,7 +277,8 @@ export function StudioHeader({
return;
}
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
clearDomSelection();
// Keep the current selection when collapsing the Inspector — closing
// the panel shouldn't deselect the element.
setRightCollapsed(true);
}}
disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
Expand Down
45 changes: 29 additions & 16 deletions packages/studio/src/components/TimelineToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { useRef } from "react";
import { useEnableKeyframes, type EnableKeyframesSession } from "../hooks/useEnableKeyframes";
import {
useEnableKeyframes,
isPlayheadWithinTween,
type EnableKeyframesSession,
} from "../hooks/useEnableKeyframes";
import { computeElementPercentage } from "../hooks/gsapShared";
import {
getNextTimelineZoomPercent,
getTimelineZoomPercent,
Expand Down Expand Up @@ -44,23 +49,25 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
const anims = session.selectedGsapAnimations;
const kfAnim = anims.find((a) => a.keyframes);

const computePct = (time: number) => {
const elStart = Number.parseFloat(sel?.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(sel?.dataAttributes?.duration ?? "1") || 1;
return elDuration > 0
? Math.max(0, Math.min(100, Math.round(((time - elStart) / elDuration) * 1000) / 10))
: 0;
};

let state: "active" | "inactive" | "none" = "none";
// Outside the tween, clicking extends the animation to the playhead rather than
// toggling a (clamped) edge keyframe — so the button stays an "add" affordance.
let willExtend = false;
if (kfAnim?.keyframes && sel) {
const pct = computePct(currentTime);
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
? "active"
: "inactive";
if (!isPlayheadWithinTween(kfAnim, currentTime)) {
state = "inactive";
willExtend = true;
} else {
// Tween-relative percentage (not the clip range) so the button state matches
// where the keyframe would actually land.
const pct = computeElementPercentage(currentTime, sel, kfAnim);
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
? "active"
: "inactive";
}
}

return { state, onToggle: sel ? onToggle : undefined };
return { state, willExtend, onToggle: sel ? onToggle : undefined };
}

// fallow-ignore-next-line complexity
Expand All @@ -76,7 +83,11 @@ export function TimelineToolbar({
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
const {
state: keyframeState,
willExtend: keyframeWillExtend,
onToggle: onToggleKeyframe,
} = useKeyframeToggle(domEditSession);

return (
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
Expand Down Expand Up @@ -124,7 +135,9 @@ export function TimelineToolbar({
keyframeState === "active"
? "Remove keyframe at playhead"
: keyframeState === "inactive"
? "Add keyframe at playhead"
? keyframeWillExtend
? "Add keyframe at playhead (extends animation)"
: "Add keyframe at playhead"
: "Enable keyframes"
}
>
Expand Down
36 changes: 36 additions & 0 deletions packages/studio/src/components/editor/KeyframeNavigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { clipToTweenPercentage } from "./KeyframeNavigation";

/**
* Regression: keyframe add/remove are keyed by TWEEN-relative percentage (what the
* GSAP writer + runtime use), NOT the clip-relative playhead used for display/seek.
* The Layout-panel diamond used to emit clip-relative %, so the mutation missed
* every keyframe (off by the tween's offset/scale) → a silent no-op on disk that
* the optimistic cache hid, so the motion path never refreshed.
*/

// A tween that starts partway through the element's lifetime and is shorter than
// it: the clip→tween map is linear with tween% = (clip% - 20) * 2.5 over [20, 60].
const KEYFRAMES = [
{ percentage: 20, tweenPercentage: 0, properties: { x: 0 } },
{ percentage: 30, tweenPercentage: 25, properties: { x: -180 } },
{ percentage: 50, tweenPercentage: 75, properties: { x: -320 } },
{ percentage: 60, tweenPercentage: 100, properties: { x: -460 } },
];

describe("clipToTweenPercentage", () => {
it("maps anchor keyframes to their tween-relative percentages", () => {
expect(clipToTweenPercentage(KEYFRAMES, 20)).toBeCloseTo(0, 5);
expect(clipToTweenPercentage(KEYFRAMES, 60)).toBeCloseTo(100, 5);
});

it("linearly interpolates a clip-relative playhead into tween space", () => {
// clip 40% is the midpoint of the tween's clip span [20, 60] → tween 50%.
expect(clipToTweenPercentage(KEYFRAMES, 40)).toBeCloseTo(50, 5);
});

it("falls back to the input when there's no usable mapping", () => {
expect(clipToTweenPercentage([], 40)).toBe(40);
expect(clipToTweenPercentage([{ percentage: 10 }], 40)).toBe(40);
});
});
38 changes: 34 additions & 4 deletions packages/studio/src/components/editor/KeyframeNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import { KeyframeDiamond, type DiamondState } from "./KeyframeDiamond";

interface KeyframeNavigationProps {
property: string;
/** All keyframes for this element's tween, or null if no keyframes exist */
/** All keyframes for this element's tween, or null if no keyframes exist.
* `percentage` is clip-relative (element lifetime) for display/seek;
* `tweenPercentage` is the tween-relative value the writer/runtime key on. */
keyframes: Array<{
percentage: number;
tweenPercentage?: number;
properties: Record<string, number | string>;
ease?: string;
}> | null;
Expand All @@ -19,6 +22,26 @@ interface KeyframeNavigationProps {

const TOLERANCE = 0.5;

/**
* Convert a clip-relative percentage (element lifetime, used for display/seek) to
* the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween
* map is linear, recovered from the keyframes' own (percentage, tweenPercentage)
* pairs. Falls back to the input when there's no usable mapping (e.g. parser
* keyframes that are already tween-relative, or fewer than two anchors).
*/
export function clipToTweenPercentage(
keyframes: ReadonlyArray<{ percentage: number; tweenPercentage?: number }>,
clipPct: number,
): number {
const mapped = keyframes.filter((kf) => typeof kf.tweenPercentage === "number");
if (mapped.length < 2) return clipPct;
const a = mapped[0]!;
const b = mapped[mapped.length - 1]!;
if (b.percentage === a.percentage) return a.tweenPercentage!;
const slope = (b.tweenPercentage! - a.tweenPercentage!) / (b.percentage - a.percentage);
return a.tweenPercentage! + (clipPct - a.percentage) * slope;
}

function ArrowLeft({ disabled }: { disabled: boolean }) {
return (
<svg
Expand Down Expand Up @@ -94,13 +117,20 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
diamondState = "ghost";
}

// Keyframe add/remove are keyed by TWEEN-relative percentage (what the GSAP
// writer + runtime use), not the clip-relative `currentPercentage` used for
// display/seek. Removing on an existing keyframe uses its own tweenPercentage;
// adding converts the clip-relative playhead through the keyframes' own
// clip→tween linear mapping. Passing clip-relative % made the mutation miss
// every keyframe (off by the tween's offset/scale) → a silent no-op on disk
// while the optimistic cache hid it, so the motion path never refreshed.
const handleDiamondClick = () => {
if (diamondState === "ghost") {
onConvertToKeyframes();
} else if (diamondState === "active") {
onRemoveKeyframe(currentPercentage);
} else if (diamondState === "active" && atCurrent) {
onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage);
} else {
onAddKeyframe(currentPercentage);
onAddKeyframe(clipToTweenPercentage(propertyKeyframes, currentPercentage));
}
};

Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/nle/NLELayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ export const NLELayout = memo(function NLELayout({
{/* Preview + player controls */}
<div className="flex-1 min-h-0 flex flex-col">
<div
className="flex-1 min-h-0 relative"
className="flex-1 min-h-0 relative overflow-hidden"
data-preview-pan-surface="true"
onPointerDown={(e) => {
const el = iframeRef.current?.parentElement ?? iframeRef.current;
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/renders/RenderQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string
mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." },
mov: {
label: "MOV (ProRes 4444)",
desc: "Transparent video. Works in CapCut, Final Cut Pro, Premiere, DaVinci Resolve, After Effects. Large files.",
desc: "Transparent video. Works in Final Cut Pro, DaVinci Resolve, and most video editors. Large files.",
},
webm: {
label: "WebM (VP9)",
Expand Down
17 changes: 12 additions & 5 deletions packages/studio/src/hooks/useDomSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {
getAllPreviewTargetsFromPointer,
getPreviewTargetFromPointer,
} from "../utils/studioPreviewHelpers";
import { findMatchingTimelineElementId, type RightPanelTab } from "../utils/studioHelpers";
import {
findMatchingTimelineElementId,
findTimelineIdByAncestor,
type RightPanelTab,
} from "../utils/studioHelpers";
import {
domEditSelectionsTargetSame,
domEditSelectionInGroup,
Expand Down Expand Up @@ -178,10 +182,13 @@ export function useDomSelection({
setRightCollapsed(false);
setRightPanelTab("design");
}
const nextSelectedTimelineId = findMatchingTimelineElementId(
nextSelection,
timelineElements,
);
const nextSelectedTimelineId =
findMatchingTimelineElementId(nextSelection, timelineElements) ??
findTimelineIdByAncestor(
nextSelection.element,
timelineElements,
nextSelection.sourceFile || "index.html",
);
setSelectedTimelineElementId(nextSelectedTimelineId);
return;
}
Expand Down
130 changes: 130 additions & 0 deletions packages/studio/src/hooks/useEnableKeyframes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import {
animatedProps,
buildExtendedKeyframes,
isPlayheadWithinTween,
resolveNewTweenRange,
} from "./useEnableKeyframes";

function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
return {
id: "#el-to-0-position",
targetSelector: "#el",
method: "to",
position: 0,
properties: {},
...overrides,
};
}

describe("resolveNewTweenRange", () => {
// Regression: "add a keyframe" must land at the PLAYHEAD. The runtime auto-stamps
// data-start="0" + data-duration=<rootDuration> on every GSAP element, so honoring
// data-start as authored timing put the keyframe at 0. Clamping the playhead into
// the element's range fixes it (auto-stamp's full range passes the playhead through).
it("anchors at the playhead through the auto-stamped full-composition range", () => {
// data-start="0", data-duration="14" (the auto-stamp), playhead 4.9 → 4.9
expect(resolveNewTweenRange("0", "14", 4.9)).toEqual({ start: 4.9, duration: 9.1 });
});

it("anchors at the playhead when the element has no authored range", () => {
expect(resolveNewTweenRange(undefined, undefined, 4)).toEqual({ start: 4, duration: 1 });
expect(resolveNewTweenRange(undefined, undefined, 6.123456).start).toBe(6.123);
});

it("never returns a negative start", () => {
expect(resolveNewTweenRange(undefined, undefined, -2).start).toBe(0);
});

it("clamps the playhead into a genuinely narrow authored clip", () => {
// clip [2.5, 8]: inside → playhead; before → start; after → end
expect(resolveNewTweenRange("2.5", "5.5", 4)).toEqual({ start: 4, duration: 4 });
expect(resolveNewTweenRange("2.5", "5.5", 1).start).toBe(2.5);
expect(resolveNewTweenRange("2.5", "5.5", 99).start).toBe(8);
});
});

describe("animatedProps", () => {
it("uses top-level properties when present (flat tween)", () => {
expect(animatedProps(anim({ properties: { x: -260 } }))).toEqual(["x"]);
});

it("derives props from keyframe stops when top-level properties is empty (array form)", () => {
// Regression: array-form `keyframes: [{x,y},…]` leaves `properties` empty, so
// add-keyframe read an empty prop list → empty position → silent no-op.
const a = anim({
properties: {},
keyframes: {
format: "object-array",
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 100, properties: { x: -460, y: -20 } },
],
},
});
expect(animatedProps(a).sort()).toEqual(["x", "y"]);
});

it("falls back to x/y for a null anim or one with no resolvable props", () => {
expect(animatedProps(null)).toEqual(["x", "y"]);
expect(animatedProps(anim({ properties: {} }))).toEqual(["x", "y"]);
});
});

describe("isPlayheadWithinTween", () => {
const tween = anim({ position: 1.0, duration: 3.4 }); // range [1.0, 4.4]

it("is true inside the range (incl. boundaries)", () => {
expect(isPlayheadWithinTween(tween, 3.0)).toBe(true);
expect(isPlayheadWithinTween(tween, 1.0)).toBe(true);
expect(isPlayheadWithinTween(tween, 4.4)).toBe(true);
});

it("is false outside the tween range", () => {
expect(isPlayheadWithinTween(tween, 5.767)).toBe(false);
expect(isPlayheadWithinTween(tween, 0.5)).toBe(false);
});

it("does not block when the start can't be resolved", () => {
expect(isPlayheadWithinTween(anim({ position: "+=1" }), 99)).toBe(true);
});
});

describe("buildExtendedKeyframes", () => {
// puck-b: tween [1.0, 4.4], four evenly-distributed stops.
const kfAnim = anim({
position: 1.0,
duration: 3.4,
keyframes: {
format: "object-array",
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 33.3, properties: { x: -180, y: -60 } },
{ percentage: 66.7, properties: { x: -320, y: 40 } },
{ percentage: 100, properties: { x: -460, y: -20 } },
],
},
});

it("extends the end and rescales existing stops to keep their absolute timing", () => {
const out = buildExtendedKeyframes(kfAnim, 5.767, { x: -460, y: -20 });
expect(out.position).toBe(1.0); // start unchanged
expect(out.duration).toBe(4.767); // grown to reach the playhead
// old end (abs 4.4) is no longer 100% — it slid back inside the longer range
const last = out.keyframes[out.keyframes.length - 1]!;
expect(last.percentage).toBe(100); // the new keyframe sits at the new end
expect(last.properties).toEqual({ x: -460, y: -20 });
expect(out.keyframes[0]!.percentage).toBe(0); // old start still anchors 0%
expect(out.keyframes.some((k) => k.percentage > 0 && k.percentage < 100)).toBe(true);
});

it("extends the start when the playhead precedes the tween", () => {
const out = buildExtendedKeyframes(kfAnim, 0, { x: 0, y: 0 });
expect(out.position).toBe(0); // start moved back to the playhead
expect(out.duration).toBe(4.4); // end (abs 4.4) unchanged
expect(out.keyframes[0]).toEqual({ percentage: 0, properties: { x: 0, y: 0 } });
// the old first stop (abs 1.0) is now partway in: 1.0 / 4.4 ≈ 22.7%
expect(out.keyframes[1]!.percentage).toBeCloseTo(22.7, 1);
});
});
Loading
Loading