From 5ab1ce7acc87b31ea623d048c63bd6305261d1c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 20 Jun 2026 14:19:48 -0400 Subject: [PATCH 1/2] feat(studio): keyframes flag, gesture recording + timeline/selection refinements --- packages/studio/index.html | 2 +- packages/studio/src/App.tsx | 1 + .../studio/src/components/StudioHeader.tsx | 5 +- .../studio/src/components/TimelineToolbar.tsx | 45 ++- .../editor/KeyframeNavigation.test.ts | 36 ++ .../components/editor/KeyframeNavigation.tsx | 38 ++- .../studio/src/components/nle/NLELayout.tsx | 2 +- .../src/components/renders/RenderQueue.tsx | 2 +- packages/studio/src/hooks/useDomSelection.ts | 17 +- .../src/hooks/useEnableKeyframes.test.ts | 130 ++++++++ .../studio/src/hooks/useEnableKeyframes.ts | 314 +++++++++++++++--- .../studio/src/hooks/useGestureRecording.ts | 78 +++-- packages/studio/src/hooks/useRazorSplit.ts | 20 +- .../studio/src/hooks/useStudioContextValue.ts | 5 +- .../studio/src/hooks/useStudioUrlState.ts | 111 +++++-- .../hooks/useExpandedTimelineElements.test.ts | 34 ++ .../hooks/useExpandedTimelineElements.ts | 22 +- .../player/hooks/useTimelineSyncCallbacks.ts | 10 +- .../studio/src/player/store/playerStore.ts | 18 +- .../studio/src/utils/studioHelpers.test.ts | 32 +- packages/studio/src/utils/studioHelpers.ts | 24 ++ .../src/utils/studioPreviewHelpers.test.ts | 27 +- .../studio/src/utils/studioPreviewHelpers.ts | 36 +- 23 files changed, 864 insertions(+), 145 deletions(-) create mode 100644 packages/studio/src/components/editor/KeyframeNavigation.test.ts create mode 100644 packages/studio/src/hooks/useEnableKeyframes.test.ts diff --git a/packages/studio/index.html b/packages/studio/index.html index 7e8dc88ae3..b3659e0763 100644 --- a/packages/studio/index.html +++ b/packages/studio/index.html @@ -7,7 +7,7 @@ HyperFrames Studio -
+
diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 15fddfda71..d3c761a7a9 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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({ diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index b6b60ec3d8..f998a8db25 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -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"; @@ -194,7 +193,6 @@ export function StudioHeader({ }: StudioHeaderProps) { const { projectId, editHistory, handleUndo, handleRedo } = useStudioShellContext(); const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext(); - const { clearDomSelection } = useDomEditActionsContext(); return (
@@ -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} diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index ecd584a145..fd9d9ac134 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -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, @@ -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 @@ -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 (
@@ -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" } > diff --git a/packages/studio/src/components/editor/KeyframeNavigation.test.ts b/packages/studio/src/components/editor/KeyframeNavigation.test.ts new file mode 100644 index 0000000000..0ce9a694de --- /dev/null +++ b/packages/studio/src/components/editor/KeyframeNavigation.test.ts @@ -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); + }); +}); diff --git a/packages/studio/src/components/editor/KeyframeNavigation.tsx b/packages/studio/src/components/editor/KeyframeNavigation.tsx index 48f2f51770..c54047c305 100644 --- a/packages/studio/src/components/editor/KeyframeNavigation.tsx +++ b/packages/studio/src/components/editor/KeyframeNavigation.tsx @@ -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; ease?: string; }> | null; @@ -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 ( { 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)); } }; diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 65649bb0d6..2ec2b88a7a 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -427,7 +427,7 @@ export const NLELayout = memo(function NLELayout({ {/* Preview + player controls */}
{ const el = iframeRef.current?.parentElement ?? iframeRef.current; diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index f3362843e2..e1de4f21c2 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -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)", diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index c6b7b78ba4..898cede6e2 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -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, @@ -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; } diff --git a/packages/studio/src/hooks/useEnableKeyframes.test.ts b/packages/studio/src/hooks/useEnableKeyframes.test.ts new file mode 100644 index 0000000000..5abc183c30 --- /dev/null +++ b/packages/studio/src/hooks/useEnableKeyframes.test.ts @@ -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 { + 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= 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); + }); +}); diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts index 4a7858fa8b..9cb25bab55 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -8,13 +8,19 @@ * Reads GSAP runtime values only (no CSS offset — it applies separately via translate). */ import { useCallback } from "react"; -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache"; import { selectorFromSelection, computeElementPercentage } from "./gsapShared"; +import { + resolveTweenStart, + resolveTweenDuration, + isTimeWithinTween, +} from "../utils/globalTimeCompiler"; import { POSITION_PROPS } from "./gsapRuntimeReaders"; import { roundTo3 } from "../utils/rounding"; +import { nearestPointOnPath } from "../components/editor/motionPathGeometry"; export interface EnableKeyframesSession { domEditSelection: DomEditSelection | null; @@ -37,6 +43,70 @@ export interface EnableKeyframesSession { ) => Promise; } +/** + * Which animated properties to capture from the live element. Array-form keyframe + * tweens (`keyframes: [{x,y},…]`) leave `anim.properties` empty — the props live in + * the keyframe stops — so fall back to the union of the stops' keys, then to x/y. + */ +export function animatedProps(anim: GsapAnimation | null): string[] { + if (!anim) return ["x", "y"]; + const own = Object.keys(anim.properties ?? {}); + if (own.length > 0) return own; + const stops = anim.keyframes?.keyframes; + if (stops?.length) { + const keys = new Set(); + for (const stop of stops) for (const k of Object.keys(stop.properties ?? {})) keys.add(k); + if (keys.size > 0) return [...keys]; + } + return ["x", "y"]; +} + +/** + * Whether the playhead sits inside an animation's tween range. When the tween's + * start can't be resolved we don't block (the percentage falls back to clip range, + * preserving prior behavior for elements without explicit timing). + */ +export function isPlayheadWithinTween(anim: GsapAnimation, currentTime: number): boolean { + const start = resolveTweenStart(anim); + if (start === null) return true; + return isTimeWithinTween(currentTime, start, resolveTweenDuration(anim)); +} + +/** + * Grow a keyframe tween's range to reach a playhead that sits outside it, and add a + * keyframe there. Existing keyframes keep their *absolute* timing (percentages + * rescale into the new range), so the current motion is preserved — the playhead + * just becomes a new hold at the start or end. Used when "add keyframe at playhead" + * fires beyond the tween instead of disabling the action. + */ +export function buildExtendedKeyframes( + anim: GsapAnimation, + currentTime: number, + position: Record, +): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } { + const oldStart = resolveTweenStart(anim) ?? 0; + const oldDuration = resolveTweenDuration(anim); + const newStart = Math.min(oldStart, currentTime); + const newEnd = Math.max(oldStart + oldDuration, currentTime); + const newDuration = roundTo3(newEnd - newStart); + const toPct = (absoluteTime: number) => + newDuration > 0 + ? Math.max( + 0, + Math.min(100, Math.round(((absoluteTime - newStart) / newDuration) * 1000) / 10), + ) + : 0; + const stops = anim.keyframes?.keyframes ?? []; + const rescaled: GsapPercentageKeyframe[] = stops.map((stop) => ({ + percentage: toPct(oldStart + (stop.percentage / 100) * oldDuration), + properties: stop.properties, + ...(stop.ease ? { ease: stop.ease } : {}), + })); + const added: GsapPercentageKeyframe = { percentage: toPct(currentTime), properties: position }; + const keyframes = [...rescaled, added].sort((a, b) => a.percentage - b.percentage); + return { position: roundTo3(newStart), duration: newDuration, keyframes }; +} + function readElementPosition( iframe: HTMLIFrameElement | null, sel: DomEditSelection, @@ -55,7 +125,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 = animatedProps(anim); for (const prop of props) { const val = Number(gsap.getProperty(element, prop)); if (!Number.isFinite(val)) continue; @@ -65,6 +137,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=` 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 { const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1]; if (!projectId) return []; @@ -77,6 +175,153 @@ async function fetchAnimationsForElement(sel: DomEditSelection): Promise { + if (!isPlayheadWithinTween(kfAnim, t)) { + const position = readElementPosition(iframe, sel, kfAnim); + const selector = selectorFromSelection(sel); + if (selector && Object.keys(position).length > 0 && session.commitMutation) { + const extended = buildExtendedKeyframes(kfAnim, t, position); + await session.commitMutation( + { + type: "replace-with-keyframes", + animationId: kfAnim.id, + targetSelector: selector, + position: extended.position, + duration: extended.duration, + keyframes: extended.keyframes, + ease: kfAnim.ease, + }, + { label: "Add keyframe", softReload: true }, + ); + } + return; + } + const pct = computeElementPercentage(t, sel, kfAnim); + const existing = kfAnim.keyframes?.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1); + if (existing) { + session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); + return; + } + if (session.handleGsapAddKeyframeBatch) { + const position = readElementPosition(iframe, sel, kfAnim); + if (Object.keys(position).length > 0) { + await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position); + } + } +} + +/** + * A set() is an instantaneous hold. "Add keyframe at playhead" promotes it to a + * two-stop tween from the set's time to the playhead — the held value at 0%, the + * live value at 100% — giving the user something to animate. No-op if the playhead + * is at or before the set. + */ +async function promoteSetToKeyframes( + session: EnableKeyframesSession, + sel: DomEditSelection, + setAnim: GsapAnimation, + t: number, + iframe: HTMLIFrameElement | null, +): Promise { + const selector = selectorFromSelection(sel); + const setStart = resolveTweenStart(setAnim) ?? 0; + if (!selector || !session.commitMutation || t <= setStart) return; + const endPosition = readElementPosition(iframe, sel, setAnim); + if (Object.keys(endPosition).length === 0) return; + const startPosition: Record = {}; + for (const key of Object.keys(endPosition)) { + const held = setAnim.properties?.[key]; + if (typeof held === "number") startPosition[key] = held; + } + await session.commitMutation( + { + type: "replace-with-keyframes", + animationId: setAnim.id, + targetSelector: selector, + position: roundTo3(setStart), + duration: roundTo3(t - setStart), + keyframes: [ + { + percentage: 0, + properties: Object.keys(startPosition).length > 0 ? startPosition : endPosition, + }, + { percentage: 100, properties: endPosition }, + ], + ease: setAnim.ease, + }, + { label: "Add keyframe", softReload: true }, + ); +} + +/** + * An arc (motionPath) tween — its waypoints are reconstructed onto `keyframes`, so + * it must be edited as waypoints (not x/y keyframes, which would break the curve). + * "Add keyframe at playhead" drops a waypoint where the element currently sits on + * the path, inserted at the matching segment so the curve is preserved. Outside the + * range, extend the duration so the motion reaches the playhead. + */ +async function applyArcWaypointAtPlayhead( + session: EnableKeyframesSession, + sel: DomEditSelection, + arcAnim: GsapAnimation, + t: number, + iframe: HTMLIFrameElement | null, +): Promise { + if (!session.commitMutation) return; + if (!isPlayheadWithinTween(arcAnim, t)) { + const start = resolveTweenStart(arcAnim) ?? 0; + if (t > start) { + await session.commitMutation( + { + type: "update-meta", + animationId: arcAnim.id, + updates: { duration: roundTo3(t - start) }, + }, + { label: "Extend motion path", softReload: true }, + ); + } + return; + } + const live = readElementPosition(iframe, sel, arcAnim); + if (typeof live.x !== "number" || typeof live.y !== "number") return; + const liveX = live.x; + const liveY = live.y; + const nodes = (arcAnim.keyframes?.keyframes ?? []) + .map((k) => ({ x: k.properties.x, y: k.properties.y })) + .filter( + (p): p is { x: number; y: number } => typeof p.x === "number" && typeof p.y === "number", + ); + // Don't duplicate a waypoint that already sits where the element is (e.g. at the + // path endpoints). + const WAYPOINT_MERGE_PX = 6; + if (nodes.some((n) => Math.hypot(n.x - liveX, n.y - liveY) <= WAYPOINT_MERGE_PX)) return; + const proj = nearestPointOnPath(liveX, liveY, nodes); + if (!proj) return; + await session.commitMutation( + { + type: "add-motion-path-point", + animationId: arcAnim.id, + index: proj.segIndex + 1, + x: liveX, + y: liveY, + }, + { label: "Add waypoint", softReload: true }, + ); +} + // fallow-ignore-next-line complexity export function useEnableKeyframes( sessionRef: React.RefObject, @@ -95,36 +340,35 @@ export function useEnableKeyframes( anims = await fetchAnimationsForElement(sel); } - const kfAnim = anims.find((a) => a.keyframes); - const flatAnim = anims.find((a) => !a.keyframes); - - if (kfAnim?.keyframes) { - const pct = computeElementPercentage(t, sel); - const existing = kfAnim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1); - if (existing) { - session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); - } else if (session.handleGsapAddKeyframeBatch) { - const position = readElementPosition(iframe, sel, kfAnim); - if (Object.keys(position).length > 0) { - await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position); - } - } - } else if (flatAnim) { - const position = readElementPosition(iframe, sel, flatAnim); - const hasPosition = Object.keys(position).length > 0; - - await session.handleGsapConvertToKeyframes(flatAnim.id, hasPosition ? position : undefined); + // An arc/motionPath tween carries reconstructed x/y keyframes too, so match it + // first and edit it as waypoints — treating it as plain keyframes would break + // the curve. + const arcAnim = anims.find((a) => a.arcPath); + const kfAnim = anims.find((a) => a.keyframes && !a.arcPath); + const setAnim = anims.find((a) => a.method === "set" && !a.keyframes && !a.arcPath); + const flatAnim = anims.find((a) => !a.keyframes && !a.arcPath && a.method !== "set"); - const pct = computeElementPercentage(t, sel); - if (pct > 1 && pct < 99 && hasPosition && session.handleGsapAddKeyframeBatch) { - await session.handleGsapAddKeyframeBatch(flatAnim.id, pct, position); - await session.handleGsapAddKeyframeBatch(flatAnim.id, 100, position); - } + if (arcAnim) { + await applyArcWaypointAtPlayhead(session, sel, arcAnim, t, iframe); + } else if (kfAnim) { + await applyKeyframeAtPlayhead(session, sel, kfAnim, t, iframe); + } else if (setAnim) { + await promoteSetToKeyframes(session, sel, setAnim, t, iframe); + } else if (flatAnim) { + // Convert the flat tween (to/from/fromTo) to its natural keyframes — no + // resolvedFromValues, so the 0%/100% stops keep the real start→end motion + // (passing the playhead value would flatten it). Then apply uniformly so an + // out-of-range playhead extends the range just like a keyframe tween. + await session.handleGsapConvertToKeyframes(flatAnim.id); + const converted = (await fetchAnimationsForElement(sel)).find((a) => a.keyframes); + if (converted) await applyKeyframeAtPlayhead(session, sel, converted, t, iframe); } 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) { @@ -135,19 +379,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 }> = [{ 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( diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index 8dabae8e09..a16c94e0fd 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -29,7 +29,7 @@ interface BasePosition { interface GsapRuntime { seek: (t: number) => void; - set: (target: string, vars: Record) => void; + set: (target: string, vars: Record) => void; selector: string; element: HTMLElement; startTime: number; @@ -85,11 +85,18 @@ function connectGsapRuntime( ): GsapRuntime | null { try { const win = iframeEl.contentWindow as Window & { - gsap?: { set: (t: string, v: Record) => void }; + gsap?: { set: (t: string, v: Record) => void }; __timelines?: Record void; duration: () => number }>; __player?: { getTime: () => number }; }; - const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null; + // Pick the first REAL timeline. `__timelines` also carries the studio's + // `__proxied` marker (a boolean, no `.seek`); `Object.values(...)[0]` would grab + // it and fail the connect — the cause of the no-live-preview gesture bug. + const tl = win?.__timelines + ? (Object.entries(win.__timelines).find( + ([key, value]) => key !== "__proxied" && typeof value?.seek === "function", + )?.[1] ?? null) + : null; if (win?.gsap?.set && tl?.seek && selector) { const tlDuration = tl.duration(); return { @@ -105,7 +112,7 @@ function connectGsapRuntime( }; } } catch { - /* cross-origin or missing runtime */ + /* connect failed */ } return null; } @@ -125,14 +132,14 @@ function applyRuntimePreview( } function recordSample(r: RecordingRefs, time: number, properties: Record): void { - const sampleProps = { ...properties }; - // 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 }); + // Record the FULL position the live preview shows (element centered on the + // pointer, with any manual path offset folded into basePosition). Do NOT + // subtract the path offset: when this gesture commits as a position tween the + // server strips the element's --hf-studio-offset (the tween owns position — see + // stripStudioEditsFromTarget in studio-api), so the keyframes must already + // include it. Subtracting it made the committed gesture play shoved off by the + // offset (the offset was removed twice). + r.samples.push({ time, properties: { ...properties } }); r.trail.push({ x: r.pointer.x, y: r.pointer.y }); } @@ -280,30 +287,34 @@ export function useGestureRecording() { r.accumulated = { opacity: base.baseOpacity, scale: base.baseScale, z: 0 }; r.basePosition = { x: base.baseX, y: base.baseY }; - if (base.cssOffX || base.cssOffY) { - element.style.setProperty("--hf-studio-offset-x", "0px"); - element.style.setProperty("--hf-studio-offset-y", "0px"); - } - - // --- Phase 2: Connect to the iframe GSAP runtime --- - const selector = element.id ? `#${element.id}` : null; - r.runtime = connectGsapRuntime(element, iframeEl, selector, elementEndTime); - - // --- Phase 3: Compute iframe viewport → composition scale --- + // --- Phase 2: scale + element center, measured BEFORE clearing the path offset --- + // baseX/baseY fold in the CSS path offset (`--hf-studio-offset`, see + // readBasePosition), so the element's on-screen center must be read while that + // offset is still applied — otherwise the pointer-centering offset is wrong by + // exactly the path offset and the element doesn't sit under the pointer (it + // looked correct only for elements that had no path offset). + // element.getBoundingClientRect() is in the iframe's viewport; convert to the + // studio (parent) viewport using the iframe's position and scale. r.scale = computeIframeScale(iframeEl); - - // --- Phase 4: Element center for pointer-element offset --- - // element.getBoundingClientRect() is in the iframe's viewport. - // Convert to the studio (parent) viewport using the iframe's position and scale. + const iframeScale = r.scale || 1; const iframeRect = iframeEl.getBoundingClientRect(); const elRect = element.getBoundingClientRect(); - const iframeScale = r.scale || 1; const elCenterViewport = { x: iframeRect.left + (elRect.left + elRect.width / 2) * iframeScale, y: iframeRect.top + (elRect.top + elRect.height / 2) * iframeScale, }; r.pointerElementOffset = { x: 0, y: 0 }; + // Now clear the optimistic path offset (already folded into baseX/baseY). + if (base.cssOffX || base.cssOffY) { + element.style.setProperty("--hf-studio-offset-x", "0px"); + element.style.setProperty("--hf-studio-offset-y", "0px"); + } + + // --- Phase 3: Connect to the iframe GSAP runtime --- + const selector = element.id ? `#${element.id}` : null; + r.runtime = connectGsapRuntime(element, iframeEl, selector, elementEndTime); + // --- Phase 5: Attach event listeners --- const handlePointerMove = (e: PointerEvent) => { r.pointer = { x: e.clientX, y: e.clientY }; @@ -391,6 +402,7 @@ export function useGestureRecording() { } recordSample(r, time, properties); + setRecordingDuration(time); r.rafId = requestAnimationFrame(tick); }; @@ -418,6 +430,18 @@ export function useGestureRecording() { const { element: el, savedVisibility, savedTranslate } = r.runtime; el.style.visibility = savedVisibility; el.style.setProperty("translate", savedTranslate || ""); + // Drop the gesture's inline gsap transform before re-applying the path + // offset below, so the two don't briefly stack (the recorded keyframes + // already encode the full position, offset included). On commit the + // re-seek lands on the gesture's first keyframe; on cancel this leaves the + // element at its pre-recording position. + try { + r.runtime.set(r.runtime.selector, { + clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z", + }); + } catch { + /* runtime gone */ + } } if (r.cssVarOffset.x || r.cssVarOffset.y) { const el = r.runtime?.element; diff --git a/packages/studio/src/hooks/useRazorSplit.ts b/packages/studio/src/hooks/useRazorSplit.ts index 292d33232b..7fd8a72a69 100644 --- a/packages/studio/src/hooks/useRazorSplit.ts +++ b/packages/studio/src/hooks/useRazorSplit.ts @@ -38,13 +38,21 @@ async function splitHtmlElement( patchTarget: NonNullable>, splitTime: number, newId: string, + elementStart: number, + elementDuration: number, ): Promise<{ ok: boolean; changed?: boolean; content?: string }> { const response = await fetch( `/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ target: patchTarget, splitTime, newId }), + body: JSON.stringify({ + target: patchTarget, + splitTime, + newId, + elementStart, + elementDuration, + }), }, ); if (!response.ok) throw new Error("Split request failed"); @@ -114,7 +122,15 @@ async function executeSplit( const originalContent = await readFileContent(pid, targetPath); const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip"); - const splitResult = await splitHtmlElement(pid, targetPath, patchTarget, splitTime, newId); + const splitResult = await splitHtmlElement( + pid, + targetPath, + patchTarget, + splitTime, + newId, + element.start, + element.duration, + ); if (!splitResult.ok) throw new Error("Failed to split clip."); if (!splitResult.changed) { return { targetPath, originalContent, patchedContent: originalContent, changed: false }; diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index aa360586c8..177bd21a3f 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -90,8 +90,9 @@ export function useInspectorState( inspectorPanelActive, inspectorButtonActive: STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive, - shouldShowSelectedDomBounds: - inspectorPanelActive && !rightCollapsed && !isPlaying && !isGestureRecording, + // Keep the selection box + motion path drawn even when the Inspector is + // collapsed — closing the panel shouldn't visually deselect the element. + shouldShowSelectedDomBounds: inspectorPanelActive && !isPlaying && !isGestureRecording, }; }, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]); } diff --git a/packages/studio/src/hooks/useStudioUrlState.ts b/packages/studio/src/hooks/useStudioUrlState.ts index 6ab0b2f80e..b52dbbcfa6 100644 --- a/packages/studio/src/hooks/useStudioUrlState.ts +++ b/packages/studio/src/hooks/useStudioUrlState.ts @@ -2,8 +2,10 @@ import { useCallback, useEffect, useRef } from "react"; import { usePlayerStore } from "../player"; import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing"; import { clampNumber, type RightPanelTab } from "../utils/studioHelpers"; +import { parseProjectIdFromHash } from "../utils/projectRouting"; import { buildStudioHash, + parseStudioUrlStateFromHash, type StudioUrlSelectionState, type StudioUrlState, } from "../utils/studioUrlState"; @@ -33,6 +35,7 @@ interface UseStudioUrlStateParams { preserveGroup?: boolean; }, ) => void; + setRightPanelTab: (tab: RightPanelTab) => void; initialState: StudioUrlState; } @@ -68,6 +71,7 @@ export function useStudioUrlState({ domEditSelection, buildDomSelectionFromTarget, applyDomSelection, + setRightPanelTab, initialState, }: UseStudioUrlStateParams) { const currentTime = usePlayerStore((s) => s.currentTime); @@ -91,12 +95,55 @@ export function useStudioUrlState({ [activeCompPath, domEditSelection, rightCollapsed, rightPanelTab, timelineVisible], ); + // Resolve a URL selection to a live element and apply it. Shared by the initial + // hydration effect and the external-navigation (hashchange) handler. Returns + // false ONLY when the iframe document isn't ready yet (caller should retry); + // a missing element or null selection clears the selection and returns true. + const applyUrlSelection = useCallback( + (selection: StudioUrlSelectionState | null): boolean => { + if (!selection) { + applyDomSelection(null, { revealPanel: false }); + return true; + } + let doc: Document | null = null; + try { + doc = previewIframeRef.current?.contentDocument ?? null; + } catch { + return false; + } + if (!doc) return false; + const element = findElementForSelection( + doc, + { + sourceFile: selection.sourceFile ?? "", + id: selection.id, + selector: selection.selector, + selectorIndex: selection.selectorIndex, + }, + activeCompPath, + ); + if (!element) { + applyDomSelection(null, { revealPanel: false }); + return true; + } + void buildDomSelectionFromTarget(element, { preferClipAncestor: false }).then((resolved) => { + applyDomSelection(resolved, { revealPanel: false }); + }); + return true; + }, + [activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef], + ); + useEffect(() => { if (!projectId || hydratedSeekRef.current || compositionLoading) return; const nextTime = duration > 0 ? clampNumber(initialState.currentTime ?? 0, 0, duration) : Math.max(0, initialState.currentTime ?? 0); + // The request is honored even if it fires before the player runtime mounts: + // initializeAdapter reconciles the store's requestedSeekTime when the adapter + // becomes ready. currentTime then settles to nextTime, releasing the selection + // hydration below. usePlayerStore.getState().requestSeek(nextTime); stableTimeRef.current = nextTime; hydratedSeekRef.current = true; @@ -113,45 +160,15 @@ export function useStudioUrlState({ hydratedSelectionRef.current = true; return; } - - let doc: Document | null = null; - try { - doc = previewIframeRef.current?.contentDocument ?? null; - } catch { - return; - } - if (!doc) return; - - const element = findElementForSelection( - doc, - { - sourceFile: pendingSelection.sourceFile ?? "", - id: pendingSelection.id, - selector: pendingSelection.selector, - selectorIndex: pendingSelection.selectorIndex, - }, - activeCompPath, - ); - if (!element) { - applyDomSelection(null, { revealPanel: false }); - hydratedSelectionRef.current = true; - pendingSelectionRef.current = null; - return; - } - + // Doc not ready yet → leave hydration pending so a later tick retries. + if (!applyUrlSelection(pendingSelection)) return; hydratedSelectionRef.current = true; pendingSelectionRef.current = null; - void buildDomSelectionFromTarget(element, { preferClipAncestor: false }).then((selection) => { - applyDomSelection(selection, { revealPanel: false }); - }); }, [ - activeCompPath, - applyDomSelection, - buildDomSelectionFromTarget, + applyUrlSelection, compositionLoading, currentTime, initialState.currentTime, - previewIframeRef, projectId, refreshKey, ]); @@ -185,4 +202,32 @@ export function useStudioUrlState({ if (!projectId) return; replaceHash(buildStudioHash(projectId, buildUrlState())); }, [activeCompPathHydrated, buildUrlState, projectId]); + + // Re-apply URL state when the hash changes externally (pasting a new link, + // back/forward) AFTER initial load. The app only reads the URL once on mount + // and otherwise WRITES the hash via replaceState (which never fires + // `hashchange`), so this listener sees only genuine external navigations — + // without it, opening a same-project deep link (different `t`, element, or + // tab) is silently ignored and then overwritten by the next hash-sync. + useEffect(() => { + if (!projectId) return; + const onHashChange = () => { + if (parseProjectIdFromHash(window.location.hash) !== projectId) return; // different project → remount handles it + const parsed = parseStudioUrlStateFromHash(window.location.hash); + if (parsed.currentTime != null) { + const clamped = + duration > 0 + ? clampNumber(parsed.currentTime, 0, duration) + : Math.max(0, parsed.currentTime); + if (Math.abs(usePlayerStore.getState().currentTime - clamped) > 0.05) { + usePlayerStore.getState().requestSeek(clamped); + stableTimeRef.current = clamped; + } + } + applyUrlSelection(parsed.selection); + if (parsed.rightPanelTab) setRightPanelTab(parsed.rightPanelTab); + }; + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, [projectId, duration, applyUrlSelection, setRightPanelTab]); } diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts index 51d5980b3d..a8399dee8e 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildExpandedElements } from "./useExpandedTimelineElements"; +import { buildTimelineElementKey } from "../lib/timelineElementHelpers"; import type { TimelineElement } from "../store/playerStore"; import type { ClipManifestClip } from "../lib/playbackTypes"; @@ -88,4 +89,37 @@ describe("buildExpandedElements", () => { expect(child.expandedParentStart).toBe(13); // C's start, not B's 12 or A's 10 expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html }); + + // Regression: an expanded child must share one identity (`key`) with the flat + // store element for the same DOM id. Before the fix the child key fell back to + // the colon form (`index.html:eyebrow:N`) while the store/selection used the + // hash form (`index.html#eyebrow`), so clicking an expanded child never + // highlighted it (isSelected compares the two keys). + it("keys expanded children in hash form, matching the flat store element", () => { + // Single composition (no sub-comps): scene `s1` with same-file children. + const elements = [el({ id: "s1", domId: "s1", start: 0, duration: 14 })]; + const manifest = [ + clip({ id: "s1", start: 0, duration: 14 }), + clip({ id: "eyebrow", start: 0, duration: 14 }), + clip({ id: "title", start: 0, duration: 14 }), + ]; + const parentMap = new Map([ + ["eyebrow", "s1"], + ["title", "s1"], + ]); + + const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1"); + const child = out.find((e) => e.domId === "eyebrow")!; + + const expectedStoreKey = buildTimelineElementKey({ + id: "eyebrow", + fallbackIndex: 0, + domId: "eyebrow", + selector: "#eyebrow", + sourceFile: undefined, + }); + expect(expectedStoreKey).toBe("index.html#eyebrow"); + expect(child.key).toBe("index.html#eyebrow"); + expect(child.key).toBe(expectedStoreKey); + }); }); diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index 6903402cc9..9649b2af74 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import type { ClipManifestClip } from "../lib/playbackTypes"; import { createTimelineElementFromManifestClip } from "../lib/timelineDOM"; +import { buildTimelineElementKey } from "../lib/timelineElementHelpers"; function findTopLevelAncestor(id: string, parentMap: Map): string | null { let current = parentMap.get(id); @@ -78,14 +79,31 @@ function buildChildElements( clip: child, fallbackIndex: result.length, }); + const domId = child.id ?? undefined; + const selector = child.id ? `#${child.id}` : undefined; + // `base.key` was built without a hostEl, so it fell back to the colon form + // (`index.html::`) even though we set domId below. Recompute it from + // the same inputs the store uses (`#`) so an expanded + // child shares one identity with its flat store element — otherwise selecting + // it sets `selectedElementId` to the store's hash key while the rendered row + // is keyed by the colon form, and `isSelected` never matches (no highlight). + const key = buildTimelineElementKey({ + id: base.id, + fallbackIndex: result.length, + domId, + selector, + selectorIndex: base.selectorIndex, + sourceFile: editBasis.sourceFile, + }); result.push({ ...base, + key, start: clamped.start, duration: clamped.duration, track: display.track + result.length, expandedParentStart: editBasis.start, - domId: child.id ?? undefined, - selector: child.id ? `#${child.id}` : undefined, + domId, + selector, sourceFile: editBasis.sourceFile, timingSource: "authored" as const, }); diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts index 6f74c94091..03c02e1f99 100644 --- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts +++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts @@ -174,8 +174,16 @@ export function useTimelineSyncCallbacks({ if (!adapter || adapter.getDuration() <= 0) return false; adapter.pause(); - const seekTo = pendingSeekRef.current; + // Honor a seek requested before the adapter was ready. It may sit in either + // place: `pendingSeekRef` if the store subscription was mounted when requestSeek + // fired, or only in the store's `requestedSeekTime` if it fired earlier still + // (deep-link hydration runs before the player subscription mounts, so the request + // never reaches pendingSeekRef). Reconciling with the store here is what makes a + // deep-linked `?t=` land instead of starting at 0. + const storeSeek = usePlayerStore.getState().requestedSeekTime; + const seekTo = pendingSeekRef.current ?? storeSeek; pendingSeekRef.current = null; + if (storeSeek != null) usePlayerStore.getState().clearSeekRequest(); const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0; adapter.seek(startTime); diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 05b378d11d..1ce0b1a672 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -327,7 +327,16 @@ export const usePlayerStore = create((set, get) => ({ setTimelineReady: (ready) => set({ timelineReady: ready }), setBeatDragging: (dragging) => set({ beatDragging: dragging }), setElements: (elements) => set({ elements }), - setSelectedElementId: (id) => set({ selectedElementId: id }), + setSelectedElementId: (id) => + set((s) => + // Selecting a different element drops any active keyframe selection — otherwise + // a stale activeKeyframePct from a prior diamond click would force the next drag + // to "modify" a keyframe on the new element. A diamond click sets the pct AFTER + // calling setSelectedElementId, so this never clobbers a genuine keyframe select. + id !== s.selectedElementId + ? { selectedElementId: id, activeKeyframePct: null } + : { selectedElementId: id }, + ), updateElement: (elementId, updates) => set((state) => ({ elements: state.elements.map((el) => @@ -361,3 +370,10 @@ export const usePlayerStore = create((set, get) => ({ clipParentMap: new Map(), }), })); + +// Bug-bash aid: expose the store so a reproduction can dump live state from the +// console, e.g. `__playerStore.getState().selectedElementId`. Harmless read +// handle; no behavioural effect. +if (typeof window !== "undefined") { + (window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore; +} diff --git a/packages/studio/src/utils/studioHelpers.test.ts b/packages/studio/src/utils/studioHelpers.test.ts index 406585d6a4..c50abc548d 100644 --- a/packages/studio/src/utils/studioHelpers.test.ts +++ b/packages/studio/src/utils/studioHelpers.test.ts @@ -1,5 +1,11 @@ +// @vitest-environment happy-dom + import { describe, expect, it } from "vitest"; -import { findMatchingTimelineElementId, resolveTimelineSelectionSeekTime } from "./studioHelpers"; +import { + findMatchingTimelineElementId, + findTimelineIdByAncestor, + resolveTimelineSelectionSeekTime, +} from "./studioHelpers"; describe("resolveTimelineSelectionSeekTime", () => { it("keeps the current time when it is already inside the clip range", () => { @@ -42,3 +48,27 @@ describe("findMatchingTimelineElementId", () => { expect(findMatchingTimelineElementId({ id: "ghost", sourceFile: "index.html" }, [])).toBe(null); }); }); + +describe("findTimelineIdByAncestor", () => { + const el = (over: Record) => + ({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never; + + it("resolves a static descendant (.num) to its nearest clip ancestor", () => { + // #stat1 (a clip) > .num (selected, not a clip) + const stat1 = document.createElement("div"); + stat1.id = "stat1"; + const num = document.createElement("div"); + num.className = "num"; + stat1.appendChild(num); + + const els = [el({ id: "stat1", domId: "stat1", key: "index.html#stat1" })]; + expect(findTimelineIdByAncestor(num, els, "index.html")).toBe("index.html#stat1"); + }); + + it("returns null when no ancestor is a clip", () => { + const wrap = document.createElement("div"); + const child = document.createElement("span"); + wrap.appendChild(child); + expect(findTimelineIdByAncestor(child, [], "index.html")).toBe(null); + }); +}); diff --git a/packages/studio/src/utils/studioHelpers.ts b/packages/studio/src/utils/studioHelpers.ts index f927896673..fc5459bc2a 100644 --- a/packages/studio/src/utils/studioHelpers.ts +++ b/packages/studio/src/utils/studioHelpers.ts @@ -185,6 +185,30 @@ export function findMatchingTimelineElementId( return null; } +/** + * A selected DOM node may be a static descendant of a clip (e.g. the `.num` text + * inside a `#stat1` card) — not a timeline element itself. Walk up to the nearest + * ancestor that IS a clip so the timeline still selects + inline-expands around it. + */ +export function findTimelineIdByAncestor( + element: Element | null | undefined, + elements: TimelineElement[], + sourceFile: string, +): string | null { + let ancestor = element?.parentElement ?? null; + while (ancestor) { + const id = ancestor.id; + if (id) { + const match = elements.find( + (el) => el.domId === id && (el.sourceFile ?? "index.html") === sourceFile, + ); + if (match) return match.key ?? match.id; + } + ancestor = ancestor.parentElement; + } + return null; +} + export function resolveTimelineSelectionSeekTime( currentTime: number, element: Pick | null | undefined, diff --git a/packages/studio/src/utils/studioPreviewHelpers.test.ts b/packages/studio/src/utils/studioPreviewHelpers.test.ts index fce01d7b36..c673142e6a 100644 --- a/packages/studio/src/utils/studioPreviewHelpers.test.ts +++ b/packages/studio/src/utils/studioPreviewHelpers.test.ts @@ -1,5 +1,30 @@ import { describe, expect, it, vi } from "vitest"; -import { pauseStudioPreviewPlayback } from "./studioPreviewHelpers"; +import { coversComposition, pauseStudioPreviewPlayback } from "./studioPreviewHelpers"; + +describe("coversComposition (full-bleed canvas-pick exclusion)", () => { + const viewport = { width: 1920, height: 1080 }; + + it("treats a full-bleed scene wrapper as covering the composition", () => { + expect(coversComposition({ width: 1920, height: 1080 }, viewport)).toBe(true); + expect(coversComposition({ width: 1900, height: 1040 }, viewport)).toBe(true); // ~99%/96% + }); + + it("does NOT exclude inner content (a stat card, a heading)", () => { + expect(coversComposition({ width: 320, height: 180 }, viewport)).toBe(false); + expect(coversComposition({ width: 1900, height: 200 }, viewport)).toBe(false); // wide but short + expect(coversComposition({ width: 200, height: 1040 }, viewport)).toBe(false); // tall but narrow + }); + + it("needs BOTH axes near full-bleed (>=95%)", () => { + expect(coversComposition({ width: 1800, height: 1080 }, viewport)).toBe(false); // 93.75% wide + expect(coversComposition({ width: 1920, height: 1000 }, viewport)).toBe(false); // 92.6% tall + }); + + it("guards against a degenerate viewport", () => { + expect(coversComposition({ width: 100, height: 100 }, { width: 0, height: 0 })).toBe(false); + expect(coversComposition({ width: 100, height: 100 }, { width: 1, height: 1 })).toBe(false); + }); +}); describe("pauseStudioPreviewPlayback", () => { it("pauses through __player without pausing sibling timelines directly", () => { diff --git a/packages/studio/src/utils/studioPreviewHelpers.ts b/packages/studio/src/utils/studioPreviewHelpers.ts index 6bb8cb8a5e..2ec9911bc5 100644 --- a/packages/studio/src/utils/studioPreviewHelpers.ts +++ b/packages/studio/src/utils/studioPreviewHelpers.ts @@ -1,5 +1,4 @@ import type { DomEditViewport } from "../components/editor/domEditing"; -import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing"; import { getDomLayerPatchTarget, isElementComputedVisible, @@ -13,6 +12,29 @@ interface PreviewLocalPointer { viewport: DomEditViewport; } +// An element is "full-bleed" when its box spans nearly the whole composition on +// BOTH axes. Such elements (scene wrappers, backdrops) are excluded from canvas +// click-picking so a click lands on inner content — or deselects on empty area — +// instead of grabbing the giant container. The Layers panel still selects them. +// ponytail: pure size heuristic; tighten the ratio if decorative full-bleed art +// should remain canvas-selectable. +const FULL_BLEED_RATIO = 0.95; + +export function coversComposition( + elRect: { width: number; height: number }, + viewport: DomEditViewport, +): boolean { + if (viewport.width <= 1 || viewport.height <= 1) return false; + return ( + elRect.width / viewport.width >= FULL_BLEED_RATIO && + elRect.height / viewport.height >= FULL_BLEED_RATIO + ); +} + +function isFullBleedTarget(el: HTMLElement, viewport: DomEditViewport): boolean { + return coversComposition(el.getBoundingClientRect(), viewport); +} + function resolvePreviewLocalPointer( iframe: HTMLIFrameElement, doc: Document, @@ -82,18 +104,19 @@ export function getPreviewTargetFromPointer( const overrideStyle = forcePointerEventsAuto(doc); try { if (typeof doc.elementsFromPoint === "function") { - const visualTarget = resolveVisualDomEditSelectionTarget( + const candidates = resolveAllVisualDomEditTargets( doc.elementsFromPoint(localPointer.x, localPointer.y), - { - activeCompositionPath, - }, + { activeCompositionPath }, ); + const visualTarget = + candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null; if (visualTarget) return visualTarget; } const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y)); if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null; if (!isElementComputedVisible(fallback)) return null; + if (isFullBleedTarget(fallback, localPointer.viewport)) return null; return fallback; } finally { removePointerEventsOverride(overrideStyle); @@ -125,11 +148,12 @@ export function getAllPreviewTargetsFromPointer( if (typeof doc.elementsFromPoint === "function") { return resolveAllVisualDomEditTargets(doc.elementsFromPoint(localPointer.x, localPointer.y), { activeCompositionPath, - }); + }).filter((el) => !isFullBleedTarget(el, localPointer.viewport)); } const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y)); if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return []; if (!isElementComputedVisible(fallback)) return []; + if (isFullBleedTarget(fallback, localPointer.viewport)) return []; return [fallback]; } finally { removePointerEventsOverride(overrideStyle); From 151f36b5f44ab3f38c4638f2e0d8ee7be593cf2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 20 Jun 2026 16:37:10 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(studio):=20address=20#1611=20review=20?= =?UTF-8?q?=E2=80=94=20fetch-first=20keyframe=20path,=20gated=20hydration,?= =?UTF-8?q?=20dev-gated=20debug=20+=20gesture=20warn,=20per-group=20gestur?= =?UTF-8?q?e=20tweens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's empty parse isn't overridden by a stale selectedGsapAnimations cache. - useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick). - useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent). - playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV). - useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per group, so a mixed gesture no longer yields an untagged legacy tween. --- .../studio/src/hooks/useEnableKeyframes.ts | 29 ++++++-- packages/studio/src/hooks/useGestureCommit.ts | 66 +++++++++++++++---- .../studio/src/hooks/useGestureRecording.ts | 18 ++++- .../studio/src/hooks/useStudioUrlState.ts | 25 +++++-- .../studio/src/player/store/playerStore.ts | 11 +++- 5 files changed, 122 insertions(+), 27 deletions(-) diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts index 9cb25bab55..eea4496cda 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -163,18 +163,28 @@ export function resolveNewTweenRange( return { start: clampedStart, duration: Math.max(0.5, roundTo3(end - clampedStart)) }; } -async function fetchAnimationsForElement(sel: DomEditSelection): Promise { +// Authoritative parse of the current source for `sel`. Returns `null` when the +// fetch can't run (no projectId / request failed) so callers can distinguish +// "unavailable" from a genuine empty result (e.g. after a delete-all). An empty +// array means the source was read and the element has no animations. +async function tryFetchAnimationsForElement( + sel: DomEditSelection, +): Promise { const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1]; - if (!projectId) return []; + if (!projectId) return null; const sourceFile = sel.sourceFile || "index.html"; const parsed = await fetchParsedAnimations(projectId, sourceFile); - if (!parsed) return []; + if (!parsed) return null; return getAnimationsForElement(parsed.animations, { id: sel.id, selector: sel.selector, }); } +async function fetchAnimationsForElement(sel: DomEditSelection): Promise { + return (await tryFetchAnimationsForElement(sel)) ?? []; +} + /** * Apply "add keyframe at playhead" to a tween that already has x/y keyframes: * toggle off an existing stop, add one at the playhead's tween-relative %, or — @@ -335,10 +345,15 @@ export function useEnableKeyframes( const t = usePlayerStore.getState().currentTime; const iframe = session.previewIframeRef?.current ?? null; - let anims = session.selectedGsapAnimations; - if (anims.length === 0) { - anims = await fetchAnimationsForElement(sel); - } + // `selectedGsapAnimations` is a studio-side selection cache that can lag a + // mutation — e.g. right after a delete-all it may still hold the just-removed + // tween, which would route us into the wrong branch below (editing a tween + // that no longer exists in source). Prefer the authoritative parse of the + // current source; an empty parse is a valid "no animations" result and is + // honored. Fall back to the cache only when the fetch couldn't run at all + // (no projectId / request failed), preserving prior behavior offline. + const fetched = await tryFetchAnimationsForElement(sel); + const anims = fetched ?? session.selectedGsapAnimations; // An arc/motionPath tween carries reconstructed x/y keyframes too, so match it // first and edit it as waypoints — treating it as plain keyframes would break diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts index 35bd494270..53969c4c9f 100644 --- a/packages/studio/src/hooks/useGestureCommit.ts +++ b/packages/studio/src/hooks/useGestureCommit.ts @@ -21,6 +21,42 @@ interface GestureSessionRef { ) => Promise; } +type RecordedKeyframe = { + percentage: number; + properties: Record; +}; + +// Split a recorded gesture's keyframes by property group so each emitted tween +// carries a single group's props. A single-group `.to(...)` parses back with a +// concrete `propertyGroup` (position/scale/...); a mixed-prop tween parses as a +// legacy untagged tween, which the position-only drag intercept can't target. +// A gesture spanning multiple groups (e.g. x/y + opacity) therefore yields one +// correctly-tagged tween per group rather than one mixed tween. +function partitionKeyframesByGroup(keyframes: RecordedKeyframe[]): RecordedKeyframe[][] { + const byGroup = new Map(); + for (const kf of keyframes) { + const perGroup = new Map>(); + 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 arr = byGroup.get(group); + if (!arr) { + arr = []; + byGroup.set(group, arr); + } + arr.push({ percentage: kf.percentage, properties: props }); + } + } + return Array.from(byGroup.values()); +} + interface UseGestureCommitParams { domEditSessionRef: React.MutableRefObject; previewIframeRef: React.RefObject; @@ -169,28 +205,32 @@ export function useGestureCommit({ { label: "Gesture recording (merge)", softReload: true }, ); } else { + for (const groupKfs of partitionKeyframesByGroup(keyframes)) { + await liveSession.commitMutation( + { + type: "add-with-keyframes", + targetSelector: selector, + position: roundTo3(recStart), + duration: roundTo3(duration), + keyframes: groupKfs, + }, + { label: "Gesture recording (new range)", softReload: true }, + ); + } + } + } else { + for (const groupKfs of partitionKeyframesByGroup(keyframes)) { await liveSession.commitMutation( { type: "add-with-keyframes", targetSelector: selector, position: roundTo3(recStart), duration: roundTo3(duration), - keyframes, + keyframes: groupKfs, }, - { label: "Gesture recording (new range)", softReload: true }, + { label: "Gesture recording", softReload: true }, ); } - } else { - await liveSession.commitMutation( - { - type: "add-with-keyframes", - targetSelector: selector, - position: roundTo3(recStart), - duration: roundTo3(duration), - keyframes, - }, - { label: "Gesture recording", softReload: true }, - ); } } showToast(`Recorded ${sortedPcts.length} keyframes`, "info"); diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index a16c94e0fd..1598917b97 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -1,6 +1,16 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { usePlayerStore, liveTime } from "../player/store/playerStore"; +// `import.meta.env` may be undefined in non-Vite bundlers (Next.js Turbopack), +// so guard the access like the telemetry client does. +function isDevBuild(): boolean { + try { + return import.meta.env.DEV === true; + } catch { + return false; + } +} + export interface GestureSample { time: number; properties: Record; @@ -396,7 +406,13 @@ export function useGestureRecording() { if (r.runtime) { try { applyRuntimePreview(r.runtime, time, properties); - } catch { + } catch (err) { + // Preview failed — disable it for the rest of the gesture (recording + // continues). Surface in dev so a dead preview isn't silent; `r.runtime` + // is nulled below so this warns at most once per gesture. + if (isDevBuild()) { + console.warn("[GR] live preview disabled — runtime threw:", err); + } r.runtime = null; } } diff --git a/packages/studio/src/hooks/useStudioUrlState.ts b/packages/studio/src/hooks/useStudioUrlState.ts index b52dbbcfa6..7407c38e92 100644 --- a/packages/studio/src/hooks/useStudioUrlState.ts +++ b/packages/studio/src/hooks/useStudioUrlState.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { usePlayerStore } from "../player"; import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing"; import { clampNumber, type RightPanelTab } from "../utils/studioHelpers"; @@ -78,6 +78,10 @@ export function useStudioUrlState({ const hydratedSeekRef = useRef(initialState.currentTime == null); const hydratedInitialTimeRef = useRef(initialState.currentTime == null); const hydratedSelectionRef = useRef(initialState.selection == null); + // Mirrors hydratedSelectionRef as state so the selection-hydration effect can + // drop its currentTime subscription once hydration completes — otherwise it + // re-runs on every playhead tick for the lifetime of the session. + const [selectionHydrated, setSelectionHydrated] = useState(initialState.selection == null); const pendingSelectionRef = useRef(initialState.selection); const stableTimeRef = useRef(initialState.currentTime); @@ -149,25 +153,36 @@ export function useStudioUrlState({ hydratedSeekRef.current = true; }, [projectId, compositionLoading, duration, initialState.currentTime]); + // Once hydration completes the selection effect no longer needs the playhead, + // so freeze its time dependency. This stops the effect re-running on every tick + // for the rest of the session (cosmetic perf) while still retrying as the seek + // settles before hydration. + const selectionHydrationTime = selectionHydrated ? 0 : currentTime; useEffect(() => { if (!projectId || hydratedSelectionRef.current || compositionLoading) return; if (!hydratedSeekRef.current) return; const targetTime = initialState.currentTime; - if (targetTime != null && Math.abs(currentTime - stableTimeRef.current!) > 0.05) return; + if (targetTime != null && Math.abs(selectionHydrationTime - stableTimeRef.current!) > 0.05) { + return; + } + const markHydrated = () => { + hydratedSelectionRef.current = true; + setSelectionHydrated(true); + }; const pendingSelection = pendingSelectionRef.current; if (!pendingSelection) { - hydratedSelectionRef.current = true; + markHydrated(); return; } // Doc not ready yet → leave hydration pending so a later tick retries. if (!applyUrlSelection(pendingSelection)) return; - hydratedSelectionRef.current = true; + markHydrated(); pendingSelectionRef.current = null; }, [ applyUrlSelection, compositionLoading, - currentTime, + selectionHydrationTime, initialState.currentTime, projectId, refreshKey, diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 1ce0b1a672..7ce13dbe0c 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -374,6 +374,15 @@ export const usePlayerStore = create((set, get) => ({ // Bug-bash aid: expose the store so a reproduction can dump live state from the // console, e.g. `__playerStore.getState().selectedElementId`. Harmless read // handle; no behavioural effect. -if (typeof window !== "undefined") { +// Only in dev. `import.meta.env` may be undefined in non-Vite bundlers (Next.js +// Turbopack), so guard the access like the telemetry client does. +function isDevBuild(): boolean { + try { + return import.meta.env.DEV === true; + } catch { + return false; + } +} +if (isDevBuild() && typeof window !== "undefined") { (window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore; }