Skip to content
Merged
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
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
29 changes: 29 additions & 0 deletions packages/studio/src/hooks/useEnableKeyframes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { resolveNewTweenRange } from "./useEnableKeyframes";

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);
});
});
50 changes: 37 additions & 13 deletions packages/studio/src/hooks/useEnableKeyframes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ function readElementPosition(
const element = sel.element;
if (!element?.isConnected || !gsap?.getProperty) return result;

const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"];
// ponytail: a brand-new tween captures position only — bundling opacity made it
// a mixed group that the position-only drag intercept couldn't resolve.
const props = anim ? Object.keys(anim.properties) : ["x", "y"];
for (const prop of props) {
const val = Number(gsap.getProperty(element, prop));
if (!Number.isFinite(val)) continue;
Expand All @@ -65,6 +67,32 @@ function readElementPosition(
return result;
}

/**
* Range for a brand-new keyframe tween created via "Enable keyframes" on an element
* with no existing animation. "Add a keyframe" must land at the PLAYHEAD.
*
* The runtime auto-stamps `data-start="0"` + `data-duration=<rootDuration>` on every
* timeline element, so we can't treat `data-start` as authored timing (doing so put
* the keyframe at 0). Instead, clamp the playhead into the element's [start, end]
* range: the auto-stamp's full-composition range passes the playhead through
* unchanged, while a genuinely narrow authored clip still clamps sensibly.
*/
export function resolveNewTweenRange(
authoredStart: string | undefined,
authoredDuration: string | undefined,
currentTime: number,
): { start: number; duration: number } {
const t = Math.max(0, roundTo3(currentTime));
const start = authoredStart != null ? Number.parseFloat(authoredStart) : Number.NaN;
const duration = authoredDuration != null ? Number.parseFloat(authoredDuration) : Number.NaN;
if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0) {
return { start: t, duration: 1 };
}
const end = start + duration;
const clampedStart = Math.min(Math.max(t, start), end);
return { start: clampedStart, duration: Math.max(0.5, roundTo3(end - clampedStart)) };
}

async function fetchAnimationsForElement(sel: DomEditSelection): Promise<GsapAnimation[]> {
const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1];
if (!projectId) return [];
Expand Down Expand Up @@ -122,9 +150,11 @@ export function useEnableKeyframes(
}
} else {
const position = readElementPosition(iframe, sel, null);
const pct = computeElementPercentage(t, sel);
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
const { start: elStart, duration: elDuration } = resolveNewTweenRange(
sel.dataAttributes?.start,
sel.dataAttributes?.duration,
t,
);
const selector = selectorFromSelection(sel);

if (!selector) {
Expand All @@ -135,19 +165,13 @@ export function useEnableKeyframes(
if (Object.keys(position).length === 0) {
position.x = 0;
position.y = 0;
position.opacity = 1;
}

// One keyframe at the playhead — a single diamond capturing the current
// value. Motion comes from the user adding/dragging more keyframes later;
// creating 0%+100% up front showed two diamonds for a single "add keyframe".
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
[{ percentage: 0, properties: { ...position } }];
if (pct > 1 && pct < 99) {
keyframes.push({ percentage: pct, properties: { ...position } });
}
keyframes.push({
percentage: 100,
properties: { ...position },
auto: true,
} as (typeof keyframes)[number]);

if (session.commitMutation) {
await session.commitMutation(
Expand Down
Loading
Loading