From a74929580424186389bb3240267b069722339067 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 14 Jul 2026 15:31:47 -0400 Subject: [PATCH 01/30] feat(studio): keyframe timeline (lanes, ease editor) reconciled onto main Squash-merge of the keyframe-timeline feature onto the current main, which had refactored the timeline in parallel. Preserves main's work (z-order mirror, track gap menu, gap highlights, hook extraction) and layers the keyframe feature on top: expandable property lanes, per-segment ease editor (curve/spring/wiggle), flat-tween lanes, prev/next keyframe nav. Unified the horizontal origin on the threaded contentOrigin parameter (GUTTER collapsed, LABEL_COL_W in keyframe mode); main's TRACKS_LEFT_PAD horizontal pad is superseded by the label-column model. --- packages/core/package.json | 10 + packages/core/src/figma/motionEase.test.ts | 2 +- packages/core/src/figma/motionEase.ts | 2 +- packages/core/src/index.ts | 1 - packages/core/src/parsers/springEase.test.ts | 29 + packages/core/src/parsers/springEase.ts | 27 +- packages/core/src/runtime/customEase.test.ts | 33 ++ packages/core/src/runtime/customEase.ts | 100 ++++ packages/core/src/runtime/init.test.ts | 133 +++++ packages/core/src/runtime/init.ts | 12 + packages/core/src/runtime/wiggleEase.test.ts | 112 ++++ packages/core/src/runtime/wiggleEase.ts | 63 ++ packages/core/src/runtime/window.d.ts | 5 + packages/core/tsconfig.json | 3 +- packages/parsers/src/gsapConstants.ts | 4 + packages/parsers/src/gsapParser.test.ts | 9 + packages/parsers/src/gsapParser.ts | 32 +- packages/parsers/src/gsapWriter.acorn.test.ts | 21 + packages/studio/src/App.tsx | 3 + .../studio/src/components/EditorShell.tsx | 3 + .../components/editor/AnimationCard.test.tsx | 146 ++++- .../src/components/editor/AnimationCard.tsx | 34 +- .../editor/EaseCurveSection.test.tsx | 315 ++++++++++ .../components/editor/EaseCurveSection.tsx | 559 ++++++++++-------- .../editor/EaseParamFields.test.tsx | 151 +++++ .../src/components/editor/EaseParamFields.tsx | 169 ++++++ .../editor/GsapAnimationSection.tsx | 7 + .../components/editor/KeyframeEaseList.tsx | 6 +- .../components/editor/KeyframeNavigation.tsx | 48 +- .../src/components/editor/easeCurveSvg.tsx | 65 ++ .../editor/easePresetLibrary.test.ts | 114 ++++ .../components/editor/easePresetLibrary.ts | 49 ++ .../editor/gsapAnimationConstants.ts | 28 +- .../components/editor/holdEaseSeek.test.ts | 34 ++ .../src/components/nle/PreviewOverlays.tsx | 4 +- .../nle/useTimelineEditCallbacks.test.tsx | 223 +++++++ .../nle/useTimelineEditCallbacks.ts | 71 ++- .../src/contexts/TimelineEditContext.tsx | 1 + .../hooks/gsapKeyframeCacheHelpers.test.ts | 94 ++- .../src/hooks/gsapKeyframeCacheHelpers.ts | 48 +- packages/studio/src/hooks/gsapTweenSynth.ts | 29 +- packages/studio/src/hooks/useDomSelection.ts | 4 + .../src/hooks/useGsapKeyframeOps.test.tsx | 22 + .../src/hooks/useGsapSelectionHandlers.ts | 22 +- .../studio/src/hooks/useGsapTweenCache.ts | 14 + .../src/hooks/useStudioContextValue.test.ts | 93 +++ .../studio/src/hooks/useStudioContextValue.ts | 17 +- .../studio/src/hooks/useStudioTestHooks.ts | 51 ++ .../components/KeyframeDiamondContextMenu.tsx | 34 +- .../player/components/LayerDisclosureRow.tsx | 56 ++ .../src/player/components/Timeline.test.ts | 391 ++++++++++-- .../studio/src/player/components/Timeline.tsx | 122 ++-- .../src/player/components/TimelineCanvas.tsx | 49 +- .../src/player/components/TimelineClip.tsx | 4 +- .../components/TimelineClipDiamonds.tsx | 193 ++++-- .../player/components/TimelineDragGhost.tsx | 59 -- .../src/player/components/TimelineLanes.tsx | 241 +++++--- .../player/components/TimelineOverlays.tsx | 6 +- .../components/TimelinePropertyLanes.test.tsx | 433 ++++++++++++++ .../components/TimelinePropertyLanes.tsx | 163 +++++ .../src/player/components/TimelineRuler.tsx | 32 +- .../components/TimelineTrackHeader.test.tsx | 262 ++++++++ .../player/components/TimelineTrackHeader.tsx | 552 +++++++++++++++++ .../player/components/timelineCallbacks.ts | 32 +- .../timelineClipDragPreview.test.ts | 53 +- .../components/timelineClipDragPreview.ts | 52 +- .../player/components/timelineCollision.ts | 17 +- .../src/player/components/timelineDragDrop.ts | 11 +- .../components/timelineKeyframeIdentity.ts | 17 + .../player/components/timelineLayout.test.ts | 88 ++- .../src/player/components/timelineLayout.ts | 198 +++++-- .../player/components/timelineMarquee.test.ts | 97 ++- .../src/player/components/timelineMarquee.ts | 31 +- .../components/useAutoExpandKeyframedClips.ts | 27 + .../player/components/useTimelineClipDrag.ts | 5 +- .../player/components/useTimelineGeometry.ts | 5 +- .../useTimelineKeyframeHandlers.test.tsx | 107 ++++ .../components/useTimelineKeyframeHandlers.ts | 56 +- .../player/components/useTimelinePlayhead.ts | 29 +- .../components/useTimelineRangeSelection.ts | 35 +- .../components/useTimelineTrackLayout.test.ts | 53 ++ .../components/useTimelineTrackLayout.ts | 179 ++++++ .../studio/src/player/store/keyframeSlice.ts | 100 ++++ .../src/player/store/playerStore.test.ts | 25 + .../studio/src/player/store/playerStore.ts | 49 +- packages/studio/src/telemetry/events.test.ts | 15 + packages/studio/src/telemetry/events.ts | 13 + 87 files changed, 6085 insertions(+), 828 deletions(-) create mode 100644 packages/core/src/parsers/springEase.test.ts create mode 100644 packages/core/src/runtime/customEase.test.ts create mode 100644 packages/core/src/runtime/customEase.ts create mode 100644 packages/core/src/runtime/wiggleEase.test.ts create mode 100644 packages/core/src/runtime/wiggleEase.ts create mode 100644 packages/studio/src/components/editor/EaseCurveSection.test.tsx create mode 100644 packages/studio/src/components/editor/EaseParamFields.test.tsx create mode 100644 packages/studio/src/components/editor/EaseParamFields.tsx create mode 100644 packages/studio/src/components/editor/easeCurveSvg.tsx create mode 100644 packages/studio/src/components/editor/easePresetLibrary.test.ts create mode 100644 packages/studio/src/components/editor/easePresetLibrary.ts create mode 100644 packages/studio/src/components/editor/holdEaseSeek.test.ts create mode 100644 packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx create mode 100644 packages/studio/src/hooks/useStudioContextValue.test.ts create mode 100644 packages/studio/src/hooks/useStudioTestHooks.ts create mode 100644 packages/studio/src/player/components/LayerDisclosureRow.tsx delete mode 100644 packages/studio/src/player/components/TimelineDragGhost.tsx create mode 100644 packages/studio/src/player/components/TimelinePropertyLanes.test.tsx create mode 100644 packages/studio/src/player/components/TimelinePropertyLanes.tsx create mode 100644 packages/studio/src/player/components/TimelineTrackHeader.test.tsx create mode 100644 packages/studio/src/player/components/TimelineTrackHeader.tsx create mode 100644 packages/studio/src/player/components/timelineKeyframeIdentity.ts create mode 100644 packages/studio/src/player/components/useAutoExpandKeyframedClips.ts create mode 100644 packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx create mode 100644 packages/studio/src/player/components/useTimelineTrackLayout.test.ts create mode 100644 packages/studio/src/player/components/useTimelineTrackLayout.ts create mode 100644 packages/studio/src/player/store/keyframeSlice.ts diff --git a/packages/core/package.json b/packages/core/package.json index 263c5b551d..300f6eab14 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -239,6 +239,12 @@ "import": "./src/parsers/springEase.ts", "types": "./src/parsers/springEase.ts" }, + "./wiggle-ease": { + "bun": "./src/runtime/wiggleEase.ts", + "node": "./dist/runtime/wiggleEase.js", + "import": "./src/runtime/wiggleEase.ts", + "types": "./src/runtime/wiggleEase.ts" + }, "./fonts/aliases": { "bun": "./src/fonts/aliases.ts", "node": "./dist/fonts/aliases.js", @@ -413,6 +419,10 @@ "import": "./dist/parsers/springEase.js", "types": "./dist/parsers/springEase.d.ts" }, + "./wiggle-ease": { + "import": "./dist/runtime/wiggleEase.js", + "types": "./dist/runtime/wiggleEase.d.ts" + }, "./fonts/aliases": { "import": "./dist/fonts/aliases.js", "types": "./dist/fonts/aliases.d.ts" diff --git a/packages/core/src/figma/motionEase.test.ts b/packages/core/src/figma/motionEase.test.ts index 0ca0d75eae..727984a34c 100644 --- a/packages/core/src/figma/motionEase.test.ts +++ b/packages/core/src/figma/motionEase.test.ts @@ -19,7 +19,7 @@ describe("mapEase", () => { ease: "power2.inOut", }); expect(mapEase("backOut")).toEqual({ kind: "named", ease: "back.out" }); - expect(mapEase("HOLD")).toEqual({ kind: "named", ease: "steps(1)" }); + expect(mapEase("HOLD")).toEqual({ kind: "named", ease: "hold" }); }); it("falls back to none for unknown named eases", () => { expect(mapEase("wobble")).toEqual({ kind: "named", ease: "none" }); diff --git a/packages/core/src/figma/motionEase.ts b/packages/core/src/figma/motionEase.ts index 5f52d6d1bd..1079487a16 100644 --- a/packages/core/src/figma/motionEase.ts +++ b/packages/core/src/figma/motionEase.ts @@ -28,7 +28,7 @@ const NAMED_EASE: Record = { elasticinout: "elastic.inOut", anticipate: "back.in", spring: "elastic.out", - hold: "steps(1)", + hold: "hold", }; function isBezier4(ease: unknown[]): ease is [number, number, number, number] { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5c39d474fe..94efee91a7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -95,7 +95,6 @@ export { keyframesToGsapAnimations, gsapAnimationsToKeyframes, } from "@hyperframes/parsers"; - export type { ParsedHtml, CompositionMetadata } from "@hyperframes/parsers"; export { diff --git a/packages/core/src/parsers/springEase.test.ts b/packages/core/src/parsers/springEase.test.ts new file mode 100644 index 0000000000..909f74425f --- /dev/null +++ b/packages/core/src/parsers/springEase.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { evaluateSpringEase, parseSpringBounce } from "./springEase"; + +describe("single-parameter spring ease", () => { + it("parses and clamps the bounce parameter", () => { + expect(parseSpringBounce("spring(0.5)")).toBe(0.5); + expect(parseSpringBounce(" spring(2) ")).toBe(1); + expect(parseSpringBounce("spring(-1)")).toBe(0); + expect(parseSpringBounce("spring(nope)")).toBeNull(); + }); + + it("starts at zero, overshoots, and settles exactly at one", () => { + const samples = Array.from({ length: 101 }, (_, index) => evaluateSpringEase(index / 100, 0.5)); + expect(samples[0]).toBe(0); + expect(samples.at(-1)).toBe(1); + expect(Math.max(...samples)).toBeGreaterThan(1); + }); + + it("is deterministic and makes higher bounce values more oscillatory", () => { + const progress = Array.from({ length: 41 }, (_, index) => index / 40); + expect(progress.map((value) => evaluateSpringEase(value, 0.75))).toEqual( + progress.map((value) => evaluateSpringEase(value, 0.75)), + ); + + const lowBounce = progress.map((value) => evaluateSpringEase(value, 0.25)); + const highBounce = progress.map((value) => evaluateSpringEase(value, 0.75)); + expect(Math.max(...highBounce)).toBeGreaterThan(Math.max(...lowBounce)); + }); +}); diff --git a/packages/core/src/parsers/springEase.ts b/packages/core/src/parsers/springEase.ts index b7ae233b85..8b0783d566 100644 --- a/packages/core/src/parsers/springEase.ts +++ b/packages/core/src/parsers/springEase.ts @@ -1,2 +1,25 @@ -/** @deprecated Import from @hyperframes/parsers/spring-ease */ -export * from "@hyperframes/parsers/spring-ease"; +const SPRING_TOKEN = /^\s*spring\(\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*\)\s*$/; + +function clampBounce(bounce: number): number { + return Math.max(0, Math.min(1, bounce)); +} + +/** Parse Studio's single-parameter spring token into a normalized bounce value. */ +export function parseSpringBounce(ease: string): number | null { + const match = SPRING_TOKEN.exec(ease); + if (!match) return null; + const bounce = Number(match[1]); + return Number.isFinite(bounce) ? clampBounce(bounce) : null; +} + +/** Evaluate Studio's deterministic, endpoint-normalized damped-cosine spring. */ +export function evaluateSpringEase(progress: number, bounce: number): number { + if (progress <= 0) return 0; + if (progress >= 1) return 1; + + const normalizedBounce = clampBounce(bounce); + const decay = 12 - normalizedBounce * 6; + const angularFrequency = Math.PI * 2 * (1 + normalizedBounce * 1.5); + const endpoint = 1 - Math.exp(-decay) * Math.cos(angularFrequency); + return (1 - Math.exp(-decay * progress) * Math.cos(angularFrequency * progress)) / endpoint; +} diff --git a/packages/core/src/runtime/customEase.test.ts b/packages/core/src/runtime/customEase.test.ts new file mode 100644 index 0000000000..d4686a47b3 --- /dev/null +++ b/packages/core/src/runtime/customEase.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest"; +import { installStudioCustomEase } from "./customEase"; + +describe("installStudioCustomEase", () => { + it("resolves wiggle while preserving hold, spring, custom, and named eases", () => { + const namedEase = (progress: number) => progress * progress; + const originalParseEase = vi.fn(() => namedEase); + const gsap = { parseEase: originalParseEase }; + + expect(installStudioCustomEase(gsap)).toBe(true); + + const wiggleEase = gsap.parseEase("wiggle(6,easeInOut)"); + expect(wiggleEase).toBeTypeOf("function"); + const samples = Array.from({ length: 401 }, (_, index) => wiggleEase(index / 400)); + expect(samples[0]).toBe(0); + expect(samples.at(-1)).toBe(1); + expect(samples).toEqual(Array.from({ length: 401 }, (_, index) => wiggleEase(index / 400))); + const directions = samples + .slice(1) + .map((value, index) => Math.sign(value - samples[index]!)) + .filter((direction) => direction !== 0); + expect( + directions.filter((direction, index) => index > 0 && direction !== directions[index - 1]) + .length, + ).toBeGreaterThanOrEqual(8); + + expect(gsap.parseEase("hold")(0.5)).toBe(0); + expect(gsap.parseEase("spring(0.5)")(0)).toBe(0); + expect(gsap.parseEase("custom(M0,0 C0.25,0.1 0.25,1 1,1)")(1)).toBe(1); + expect(gsap.parseEase("power2.out")).toBe(namedEase); + expect(originalParseEase).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/runtime/customEase.ts b/packages/core/src/runtime/customEase.ts new file mode 100644 index 0000000000..f189aaf3fa --- /dev/null +++ b/packages/core/src/runtime/customEase.ts @@ -0,0 +1,100 @@ +import { evaluateSpringEase, parseSpringBounce } from "../parsers/springEase"; +import { resolveWiggleEase } from "./wiggleEase"; + +type RuntimeEase = (progress: number) => number; + +type GsapEaseApi = { + parseEase?: (ease: string | RuntimeEase, ...args: unknown[]) => RuntimeEase | null; +}; + +const BISECTION_STEPS = 24; +const HOLD_EASE: RuntimeEase = (progress) => (progress >= 1 ? 1 : 0); +const NUMBER_SOURCE = String.raw`([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)`; +const CUSTOM_CUBIC_PATH = new RegExp( + String.raw`^\s*M\s*0\s*,\s*0\s+C\s*${NUMBER_SOURCE}\s*,\s*${NUMBER_SOURCE}\s+${NUMBER_SOURCE}\s*,\s*${NUMBER_SOURCE}\s+1\s*,\s*1\s*$`, + "i", +); + +function cubicCoordinate(t: number, point1: number, point2: number): number { + const inverse = 1 - t; + return 3 * inverse * inverse * t * point1 + 3 * inverse * t * t * point2 + t * t * t; +} + +function evaluateCubicBezier( + progress: number, + x1: number, + y1: number, + x2: number, + y2: number, +): number { + if (progress <= 0) return 0; + if (progress >= 1) return 1; + + let low = 0; + let high = 1; + for (let step = 0; step < BISECTION_STEPS; step += 1) { + const t = (low + high) / 2; + if (cubicCoordinate(t, x1, x2) < progress) low = t; + else high = t; + } + return cubicCoordinate((low + high) / 2, y1, y2); +} + +function createCubicBezierEase(path: string): RuntimeEase | null { + const match = CUSTOM_CUBIC_PATH.exec(path); + if (!match) return null; + const x1 = Number(match[1]); + const y1 = Number(match[2]); + const x2 = Number(match[3]); + const y2 = Number(match[4]); + if (Math.min(x1, x2) < 0 || Math.max(x1, x2) > 1) return null; + return (progress) => evaluateCubicBezier(progress, x1, y1, x2, y2); +} + +function resolveSpringEase( + ease: string, + springEaseCache: Map, +): RuntimeEase | null { + if (!ease.startsWith("spring(")) return null; + const bounce = parseSpringBounce(ease); + if (bounce === null) return null; + const cached = springEaseCache.get(bounce); + if (cached) return cached; + const springEase: RuntimeEase = (progress) => evaluateSpringEase(progress, bounce); + springEaseCache.set(bounce, springEase); + return springEase; +} + +function resolveCustomEase( + ease: string, + customEaseCache: Map, +): RuntimeEase | null { + if (!ease.startsWith("custom(") || !ease.endsWith(")")) return null; + const path = ease.slice(7, -1); + const cached = customEaseCache.get(path); + if (cached) return cached; + const customEase = createCubicBezierEase(path); + if (customEase) customEaseCache.set(path, customEase); + return customEase; +} + +export function installStudioCustomEase(gsap: GsapEaseApi): boolean { + const originalParseEase = gsap.parseEase; + if (!originalParseEase) return false; + + const customEaseCache = new Map(); + const springEaseCache = new Map(); + gsap.parseEase = function parseHyperframesEase(ease, ...args) { + if (typeof ease === "string") { + if (ease === "hold") return HOLD_EASE; + const wiggleEase = resolveWiggleEase(ease); + if (wiggleEase) return wiggleEase; + const springEase = resolveSpringEase(ease, springEaseCache); + if (springEase) return springEase; + const customEase = resolveCustomEase(ease, customEaseCache); + if (customEase) return customEase; + } + return originalParseEase.call(this, ease, ...args); + }; + return true; +} diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index c83fa4d30b..17b1d66b33 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -114,11 +114,144 @@ describe("initSandboxRuntimeModular", () => { delete (window as { __HF_EXPORT_RENDER_SEEK_CONFIG?: unknown }).__HF_EXPORT_RENDER_SEEK_CONFIG; delete window.__hfTimelinesBuilding; delete (window as { THREE?: unknown }).THREE; + delete (window as { __hfAutoNoopRegistered?: boolean }).__hfAutoNoopRegistered; + delete (window as { __hfCustomEaseRegistered?: boolean }).__hfCustomEaseRegistered; + delete window.gsap; vi.restoreAllMocks(); window.requestAnimationFrame = originalRequestAnimationFrame; window.cancelAnimationFrame = originalCancelAnimationFrame; }); + it("resolves Studio hold as a deterministic step at the segment end", () => { + const defaultEase = (progress: number) => progress; + const originalParseEase = vi.fn(() => defaultEase); + window.gsap = { + timeline: () => createMockTimeline(1), + parseEase: originalParseEase, + registerPlugin: vi.fn(), + }; + + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "1"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + window.__timelines = { main: createMockTimeline(1) }; + + initSandboxRuntimeModular(); + + const first = window.gsap.parseEase?.("hold"); + const second = window.gsap.parseEase?.("hold"); + expect(first).toBeTypeOf("function"); + expect(second).toBe(first); + if (typeof first !== "function") return; + + expect([0, 0.25, 0.5, 0.99].map(first)).toEqual([0, 0, 0, 0]); + expect(first(1)).toBe(1); + expect(first(1.01)).toBe(1); + expect(originalParseEase).not.toHaveBeenCalledWith("hold"); + }); + + it("resolves Studio custom cubic-bezier eases on the composition GSAP instance", () => { + const defaultEase = (progress: number) => 1 - (1 - progress) ** 2; + const originalParseEase = vi.fn(() => defaultEase); + window.gsap = { + timeline: () => createMockTimeline(1), + parseEase: originalParseEase, + registerPlugin: vi.fn(), + }; + + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "1"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + window.__timelines = { main: createMockTimeline(1) }; + + initSandboxRuntimeModular(); + + const custom = "custom(M0,0 C0.42,0 0.58,1 1,1)"; + const first = window.gsap.parseEase?.(custom); + const second = window.gsap.parseEase?.(custom); + expect(first).toBeTypeOf("function"); + expect(second).toBe(first); + if (typeof first !== "function" || typeof second !== "function") return; + + const progressSamples = [0, 0.25, 0.5, 0.75, 1]; + expect(progressSamples.map(first)).toEqual(progressSamples.map(second)); + expect(first(0.25)).toBeCloseTo(0.1292, 4); + expect(first(0.5)).toBeCloseTo(0.5, 6); + expect(first(0.5)).not.toBeCloseTo(defaultEase(0.5), 6); + expect(first(0.75)).toBeCloseTo(0.8708, 4); + expect(originalParseEase).not.toHaveBeenCalledWith(custom); + + expect(window.gsap.parseEase?.("power1.out")).toBe(defaultEase); + expect(originalParseEase).toHaveBeenCalledWith("power1.out"); + expect(window.gsap.parseEase?.("custom(not-a-path)")).toBe(defaultEase); + expect(originalParseEase).toHaveBeenCalledWith("custom(not-a-path)"); + + const installedParseEase = window.gsap.parseEase; + initSandboxRuntimeModular(); + expect(window.gsap.parseEase).toBe(installedParseEase); + + delete (window as { __hfCustomEaseRegistered?: boolean }).__hfCustomEaseRegistered; + window.gsap = { + timeline: () => createMockTimeline(1), + parseEase: vi.fn(() => defaultEase), + registerPlugin: vi.fn(), + }; + initSandboxRuntimeModular(); + const freshResolution = window.gsap.parseEase?.(custom); + expect(freshResolution).toBeTypeOf("function"); + if (typeof freshResolution === "function") { + expect(progressSamples.map(freshResolution)).toEqual(progressSamples.map(first)); + } + }); + + it("resolves Studio spring eases as deterministic oscillations that settle exactly", () => { + const defaultEase = (progress: number) => progress; + const originalParseEase = vi.fn(() => defaultEase); + window.gsap = { + timeline: () => createMockTimeline(1), + parseEase: originalParseEase, + registerPlugin: vi.fn(), + }; + + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "1"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + window.__timelines = { main: createMockTimeline(1) }; + + initSandboxRuntimeModular(); + + const first = window.gsap.parseEase?.("spring(0.5)"); + const second = window.gsap.parseEase?.("spring(0.5)"); + expect(first).toBeTypeOf("function"); + expect(second).toBe(first); + if (typeof first !== "function" || typeof second !== "function") return; + + const samples = Array.from({ length: 101 }, (_, index) => first(index / 100)); + expect(first(0)).toBe(0); + expect(first(1)).toBe(1); + expect(Math.max(...samples)).toBeGreaterThan(1); + expect(samples).toEqual(Array.from({ length: 101 }, (_, index) => second(index / 100))); + expect(originalParseEase).not.toHaveBeenCalledWith("spring(0.5)"); + + expect(window.gsap.parseEase?.("power1.out")).toBe(defaultEase); + expect(originalParseEase).toHaveBeenCalledWith("power1.out"); + }); + it("keeps authored composition hosts visible when the live child timeline is shorter", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index c6f6060124..5c52e1db91 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -49,6 +49,7 @@ import type { import type { PlayerAPI } from "../core.types"; import { swallow } from "./diagnostics"; import { shouldAttemptPeriodicTimelineBind } from "./timelineRebindPolicy"; +import { installStudioCustomEase } from "./customEase"; const AUTHORED_DURATION_ATTR = "data-hf-authored-duration"; const AUTHORED_END_ATTR = "data-hf-authored-end"; @@ -173,7 +174,18 @@ export function initSandboxRuntimeModular(): void { // a stray warning is preferable to a broken runtime } }; + const ensureStudioCustomEase = (): void => { + const g = window.gsap; + const w = window as Window & { __hfCustomEaseRegistered?: boolean }; + if (!g || w.__hfCustomEaseRegistered) return; + try { + if (installStudioCustomEase(g)) w.__hfCustomEaseRegistered = true; + } catch { + // falling back to GSAP's default ease is preferable to a broken runtime + } + }; ensureAutoMarkerNoop(); + ensureStudioCustomEase(); // Normalize html/body so browser defaults (8px margin, white background) never // bleed into renders as white bars. Runs in both preview and render contexts, // eliminating the preview/render parity gap that existed when only the React diff --git a/packages/core/src/runtime/wiggleEase.test.ts b/packages/core/src/runtime/wiggleEase.test.ts new file mode 100644 index 0000000000..dd71ec8985 --- /dev/null +++ b/packages/core/src/runtime/wiggleEase.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { evaluateWiggleEase, parseWiggleEase, resolveWiggleEase } from "./wiggleEase"; + +function countExtrema(samples: number[]): number { + let previousDirection = 0; + let extrema = 0; + for (let index = 1; index < samples.length; index += 1) { + const delta = samples[index]! - samples[index - 1]!; + const direction = Math.sign(delta); + if (direction !== 0 && previousDirection !== 0 && direction !== previousDirection) extrema += 1; + if (direction !== 0) previousDirection = direction; + } + return extrema; +} + +function maximumDeviation( + type: "easeOut" | "easeInOut" | "uniform", + from: number, + to: number, + amplitude?: number, +) { + return Math.max( + ...Array.from({ length: 101 }, (_, index) => { + const progress = from + ((to - from) * index) / 100; + return Math.abs(evaluateWiggleEase(progress, 6, type, amplitude) - progress); + }), + ); +} + +describe("wiggle ease", () => { + it("parses positive integer wiggle counts and supported types", () => { + expect(parseWiggleEase(" wiggle(6, easeInOut) ")).toEqual({ + wiggles: 6, + type: "easeInOut", + }); + expect(parseWiggleEase("wiggle(3,easeInOut,0.12)")).toEqual({ + wiggles: 3, + type: "easeInOut", + amplitude: 0.12, + }); + expect(parseWiggleEase("wiggle(3,easeInOut,0)")?.amplitude).toBe(0); + expect(parseWiggleEase("wiggle(3,easeInOut,1)")?.amplitude).toBe(1); + expect(parseWiggleEase("wiggle(0,easeOut)")).toBeNull(); + expect(parseWiggleEase("wiggle(2.5,uniform)")).toBeNull(); + expect(parseWiggleEase("wiggle(3,unknown)")).toBeNull(); + expect(parseWiggleEase("wiggle(3,easeInOut,-0.1)")).toBeNull(); + expect(parseWiggleEase("wiggle(3,easeInOut,1.1)")).toBeNull(); + }); + + it("is deterministic, endpoint-normalized, and oscillatory", () => { + const progress = Array.from({ length: 401 }, (_, index) => index / 400); + const first = progress.map((value) => evaluateWiggleEase(value, 6, "easeInOut")); + const second = progress.map((value) => evaluateWiggleEase(value, 6, "easeInOut")); + + expect(first).toEqual(second); + expect(first[0]).toBe(0); + expect(first.at(-1)).toBe(1); + expect(countExtrema(first)).toBeGreaterThanOrEqual(8); + + const explicitFirst = progress.map((value) => evaluateWiggleEase(value, 6, "easeInOut", 0.2)); + const explicitSecond = progress.map((value) => evaluateWiggleEase(value, 6, "easeInOut", 0.2)); + expect(explicitFirst).toEqual(explicitSecond); + expect(explicitFirst[0]).toBe(0); + expect(explicitFirst.at(-1)).toBe(1); + }); + + it("uses an explicit amplitude as the peak envelope amplitude", () => { + expect(maximumDeviation("easeInOut", 0, 1, 0.2)).toBeGreaterThan( + maximumDeviation("easeInOut", 0, 1, 0.1), + ); + }); + + it("preserves the per-type defaults when amplitude is omitted", () => { + const progress = [0.125, 0.25, 0.5, 0.75, 0.875]; + expect(progress.map((value) => evaluateWiggleEase(value, 6, "easeInOut"))).toEqual( + progress.map((value) => evaluateWiggleEase(value, 6, "easeInOut", 0.08)), + ); + expect(progress.map((value) => evaluateWiggleEase(value, 6, "easeOut"))).toEqual( + progress.map((value) => evaluateWiggleEase(value, 6, "easeOut", 0.16)), + ); + expect(progress.map((value) => evaluateWiggleEase(value, 6, "uniform"))).toEqual( + progress.map((value) => evaluateWiggleEase(value, 6, "uniform", 0.14)), + ); + }); + + it("applies the CustomWiggle-style amplitude envelopes", () => { + expect(maximumDeviation("easeOut", 0, 0.25)).toBeGreaterThan( + maximumDeviation("easeOut", 0.75, 1), + ); + expect(maximumDeviation("easeInOut", 0.375, 0.625)).toBeGreaterThan( + maximumDeviation("easeInOut", 0, 0.125), + ); + expect(maximumDeviation("uniform", 0, 0.25)).toBeCloseTo( + maximumDeviation("uniform", 0.75, 1), + 6, + ); + expect(evaluateWiggleEase(0.025, 5, "anticipate")).toBeLessThan(0); + }); + + it("caches resolved functions by normalized parameters", () => { + const first = resolveWiggleEase("wiggle(6,easeInOut,0.2)"); + const second = resolveWiggleEase(" wiggle(6, easeInOut, 0.20) "); + const differentAmplitude = resolveWiggleEase("wiggle(6,easeInOut,0.1)"); + const legacyFirst = resolveWiggleEase("wiggle(6,easeInOut)"); + const legacySecond = resolveWiggleEase(" wiggle(6, easeInOut) "); + + expect(first).not.toBeNull(); + expect(first).toBe(second); + expect(first).not.toBe(differentAmplitude); + expect(legacyFirst).toBe(legacySecond); + }); +}); diff --git a/packages/core/src/runtime/wiggleEase.ts b/packages/core/src/runtime/wiggleEase.ts new file mode 100644 index 0000000000..8d453718f5 --- /dev/null +++ b/packages/core/src/runtime/wiggleEase.ts @@ -0,0 +1,63 @@ +export type WiggleType = "easeOut" | "easeInOut" | "anticipate" | "uniform"; + +export type WiggleEaseConfig = { + wiggles: number; + type: WiggleType; + amplitude?: number; +}; + +type WiggleEase = (progress: number) => number; + +const WIGGLE_TOKEN = + /^\s*wiggle\(\s*(\d+)\s*,\s*(easeOut|easeInOut|anticipate|uniform)\s*(?:,\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*)?\)\s*$/; +const WIGGLE_CACHE = new Map(); +const TAU = Math.PI * 2; + +export function parseWiggleEase(ease: string): WiggleEaseConfig | null { + const match = WIGGLE_TOKEN.exec(ease); + if (!match) return null; + const wiggles = Number(match[1]); + if (!Number.isSafeInteger(wiggles) || wiggles < 1) return null; + const type = match[2]; + if (type !== "easeOut" && type !== "easeInOut" && type !== "anticipate" && type !== "uniform") { + return null; + } + if (match[3] === undefined) return { wiggles, type }; + const amplitude = Number(match[3]); + if (!Number.isFinite(amplitude) || amplitude < 0 || amplitude > 1) return null; + return { wiggles, type, amplitude }; +} + +export function evaluateWiggleEase( + progress: number, + wiggles: number, + type: WiggleType, + amplitude?: number, +): number { + if (progress <= 0) return 0; + if (progress >= 1) return 1; + + const peakAmplitude = + amplitude ?? + (type === "easeInOut" ? 0.08 : type === "uniform" ? 0.14 : type === "anticipate" ? 0.12 : 0.16); + const envelope = + type === "easeInOut" + ? peakAmplitude * Math.sin(Math.PI * progress) + : type === "uniform" + ? peakAmplitude + : peakAmplitude * (1 - progress); + const direction = type === "anticipate" ? -1 : 1; + return progress + direction * envelope * Math.sin(TAU * wiggles * progress); +} + +export function resolveWiggleEase(ease: string): WiggleEase | null { + const config = parseWiggleEase(ease); + if (!config) return null; + const key = `${config.wiggles}:${config.type}:${config.amplitude ?? "default"}`; + const cached = WIGGLE_CACHE.get(key); + if (cached) return cached; + const wiggleEase: WiggleEase = (progress) => + evaluateWiggleEase(progress, config.wiggles, config.type, config.amplitude); + WIGGLE_CACHE.set(key, wiggleEase); + return wiggleEase; +} diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index 5779df0c1d..d616c7ee79 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -75,6 +75,11 @@ declare global { __HF_PICKER_API?: HyperframePickerApi; gsap?: { timeline: (params?: { paused?: boolean }) => RuntimeTimelineLike; + parseEase?: ( + ease: string | ((progress: number) => number), + ...args: unknown[] + ) => ((progress: number) => number) | null; + registerPlugin?: (plugin: unknown) => void; ticker?: { tick: () => void; }; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 7cb20a06c7..d37173bc73 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -18,7 +18,8 @@ "src/runtime/mediaVolumeEnvelope.ts", "src/runtime/positionEdits.ts", "src/runtime/protocol.ts", - "src/runtime/stackingContext.ts" + "src/runtime/stackingContext.ts", + "src/runtime/wiggleEase.ts" ], "include": ["src/**/*"], "exclude": [ diff --git a/packages/parsers/src/gsapConstants.ts b/packages/parsers/src/gsapConstants.ts index 8bea681c7b..563ca09c7f 100644 --- a/packages/parsers/src/gsapConstants.ts +++ b/packages/parsers/src/gsapConstants.ts @@ -112,13 +112,17 @@ export const SUPPORTED_EASES = [ "bounce.in", "bounce.out", "bounce.inOut", + "circ.inOut", "expo.in", "expo.out", "expo.inOut", + "elastic.out(1,0.3)", + "elastic.inOut(1,0.3)", "spring-gentle", "spring-bouncy", "spring-stiff", "spring-wobbly", "spring-heavy", + "hold", "steps(1)", ]; diff --git a/packages/parsers/src/gsapParser.test.ts b/packages/parsers/src/gsapParser.test.ts index 11b592f9ae..c59c278d20 100644 --- a/packages/parsers/src/gsapParser.test.ts +++ b/packages/parsers/src/gsapParser.test.ts @@ -1919,6 +1919,15 @@ describe("keyframe mutations", () => { expect([kf[2]!.properties.x, kf[2]!.properties.y]).toEqual([1040, 0]); }); + it("updateKeyframeInScript — array-form ease-only update preserves existing properties", () => { + const id = getAnimId(ARRAY_KF_SCRIPT); + const updated = updateKeyframeInScript(ARRAY_KF_SCRIPT, id, 33.3, {}, "power2.inOut"); + const kf = parseGsapScript(updated).animations[0].keyframes!.keyframes; + expect(kf[1]!.properties.x).toBe(520); + expect(kf[1]!.properties.y).toBe(120); + expect(kf[1]!.ease).toBe("power2.inOut"); + }); + it("addKeyframeToScript — array-form: normalizes to object form + inserts 50%", () => { const id = getAnimId(ARRAY_KF_SCRIPT); const updated = addKeyframeToScript(ARRAY_KF_SCRIPT, id, 50, { x: 780, y: 60 }); diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index 315c238c8f..c3e68b3901 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -2014,6 +2014,18 @@ function buildKeyframeValueNode( return parseExpr(`{ ${entries.join(", ")} }`); } +function setObjectExpressionEase(node: AstNode, ease: string): boolean { + if (node?.type !== "ObjectExpression") return false; + const props = (node.properties ?? []) as AstNode[]; + const easeIdx = props.findIndex( + (property: AstNode) => isObjectProperty(property) && propKeyName(property) === "ease", + ); + const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0]; + if (easeIdx >= 0) props[easeIdx] = easeNode; + else props.push(easeNode); + return true; +} + /** Parse + locate a target animation, returning null on failure. */ function locateAnimation( script: string, @@ -2467,7 +2479,12 @@ export function updateKeyframeInScript( } } if (matchIdx === -1) return script; - const realIdx = arrVal.elements.indexOf(elements[matchIdx]); + const matchEl = elements[matchIdx]; + if (!matchEl) return script; + const realIdx = arrVal.elements.indexOf(matchEl); + if (Object.keys(properties).length === 0 && ease && setObjectExpressionEase(matchEl, ease)) { + return recast.print(arrLoc.parsed.ast).code; + } arrVal.elements[realIdx] = buildKeyframeValueNode(properties, ease); return recast.print(arrLoc.parsed.ast).code; } @@ -2481,18 +2498,7 @@ export function updateKeyframeInScript( if (Object.keys(properties).length === 0 && ease) { // Ease-only update: preserve existing properties, just add/replace ease - const existing = match.prop.value; - if (existing?.type === "ObjectExpression") { - const props = (existing.properties ?? []) as AstNode[]; - const easeIdx = props.findIndex( - (p: AstNode) => isObjectProperty(p) && propKeyName(p) === "ease", - ); - const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0]; - if (easeIdx >= 0) { - props[easeIdx] = easeNode; - } else { - props.push(easeNode); - } + if (setObjectExpressionEase(match.prop.value, ease)) { return recast.print(loc.parsed.ast).code; } // Non-object keyframe value (primitive shorthand, e.g. "50%": "0.5"): there diff --git a/packages/parsers/src/gsapWriter.acorn.test.ts b/packages/parsers/src/gsapWriter.acorn.test.ts index e7ddd41dc8..96d7147750 100644 --- a/packages/parsers/src/gsapWriter.acorn.test.ts +++ b/packages/parsers/src/gsapWriter.acorn.test.ts @@ -15,7 +15,9 @@ import { updateAnimationInScript, updateKeyframeInScript, } from "./gsapWriterAcorn.js"; +import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; import { parseGsapScript } from "./gsapParser.js"; +import { validateCompositionGsap } from "./gsapSerialize.js"; // --------------------------------------------------------------------------- // Fixture scripts @@ -376,6 +378,25 @@ describe("T6c — keyframe write ops", () => { expect(result.indexOf('"25%"')).toBeLessThan(result.indexOf('"50%"')); }); + it("addKeyframeToScript converts a flat tween and round-trips three sane points", () => { + const script = `\ +var tl = gsap.timeline({ paused: true }); +tl.to("#box", { x: 420, duration: 1 }, 0); +window.__timelines["t"] = tl;`; + const animationId = parseGsapScriptAcorn(script).animations[0]!.id; + + const result = addKeyframeToScript(script, animationId, 50, { x: 210 }); + const reparsed = parseGsapScriptAcorn(result); + + expect(reparsed.animations).toHaveLength(1); + expect(reparsed.animations[0]?.keyframes?.keyframes).toEqual([ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 210 } }, + { percentage: 100, properties: { x: 420 } }, + ]); + expect(validateCompositionGsap(result)).toEqual({ valid: true, errors: [], warnings: [] }); + }); + it("addKeyframeToScript replaces value when percentage already exists", () => { const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { opacity: 0.99 }); expect(result).toContain("opacity: 0.99"); diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 6b2df53800..3cd9144687 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -400,12 +400,14 @@ export function StudioApp() { designPanelActive, inspectorPanelActive, inspectorButtonActive, + shouldShowMotionPath, shouldShowSelectedDomBounds, } = useInspectorState( panelLayout.rightPanelTab, panelLayout.rightInspectorPanes, panelLayout.rightCollapsed, isPlaying, + domEditSession.domEditSelection, gestureState === "recording", ); useStudioUrlState({ @@ -553,6 +555,7 @@ export function StudioApp() { handleRazorSplitAll={timelineEditing.handleRazorSplitAll} setCompIdToSrc={setCompIdToSrc} setCompositionLoading={setCompositionLoading} + shouldShowMotionPath={shouldShowMotionPath} shouldShowSelectedDomBounds={shouldShowSelectedDomBounds} isGestureRecording={gestureState === "recording"} recordingState={gestureState} diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx index a5d42e732c..e44524f0c0 100644 --- a/packages/studio/src/components/EditorShell.tsx +++ b/packages/studio/src/components/EditorShell.tsx @@ -52,6 +52,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps { ) => Promise | void; setCompIdToSrc: (map: Map) => void; setCompositionLoading: (loading: boolean) => void; + shouldShowMotionPath: boolean; shouldShowSelectedDomBounds: boolean; blockPreview?: BlockPreviewInfo | null; isGestureRecording?: boolean; @@ -85,6 +86,7 @@ export function EditorShell({ handleRazorSplitAll, setCompIdToSrc, setCompositionLoading, + shouldShowMotionPath, shouldShowSelectedDomBounds, isGestureRecording, recordingState, @@ -143,6 +145,7 @@ export function EditorShell({ onDeleteElement={handleTimelineElementDelete} previewOverlay={ vi.fn()); +vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit })); (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const ANIMATION: GsapAnimation = { + id: "position-tween", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 2, + ease: "power1.out", + properties: { x: 200 }, + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 100 } }, + { percentage: 100, properties: { x: 200 } }, + ], + }, +}; + +const FLAT_ANIMATION: GsapAnimation = { + ...ANIMATION, + id: "flat-position-tween", + keyframes: undefined, +}; + afterEach(() => { document.body.innerHTML = ""; + trackStudioSegmentEaseEdit.mockClear(); +}); + +function renderCard( + focusedSegment: { tweenPercentage: number } | null, + onEaseCommit = vi.fn(), + defaultExpanded = false, + animation = ANIMATION, + onUpdateMeta = vi.fn(), +) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const render = (nextFocusedSegment: { tweenPercentage: number } | null) => { + act(() => { + root.render( + , + ); + }); + }; + render(focusedSegment); + return { host, root, render }; +} + +function findButton(host: HTMLElement, text: string): HTMLButtonElement | undefined { + return Array.from(host.querySelectorAll("button")).find((button) => + button.textContent?.includes(text), + ); +} + +function selectPreset(host: HTMLElement, presetId: string): string { + const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId); + if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`); + const dropdown = host.querySelector("[data-ease-type-dropdown]"); + expect(dropdown).not.toBeNull(); + act(() => dropdown?.click()); + + const preset = host.querySelector(`[data-ease-preset-id="${presetId}"]`); + expect(preset).not.toBeNull(); + act(() => preset?.click()); + return presetConfig.ease; +} + +function restoreScrollIntoView(descriptor: PropertyDescriptor | undefined): void { + if (descriptor) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", descriptor); + else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); +} + +describe("AnimationCard", () => { + it("scrolls a focused segment into view but not a manually toggled segment", () => { + const originalScrollIntoView = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "scrollIntoView", + ); + const scrollIntoView = vi.fn(); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoView, + }); + + const view = renderCard({ tweenPercentage: 50 }); + try { + expect(scrollIntoView).toHaveBeenCalledOnce(); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" }); + + view.render(null); + const manualToggle = findButton(view.host, "50% → 100%"); + expect(manualToggle).toBeDefined(); + act(() => manualToggle?.click()); + expect(scrollIntoView).toHaveBeenCalledOnce(); + } finally { + act(() => view.root.unmount()); + restoreScrollIntoView(originalScrollIntoView); + } + }); + + it("tracks a committed segment ease alongside the existing update", () => { + const onEaseCommit = vi.fn(); + const view = renderCard(null, onEaseCommit, true); + const segment = findButton(view.host, "0% → 50%"); + expect(segment).toBeDefined(); + act(() => segment?.click()); + const ease = selectPreset(view.host, "quad-out"); + + expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease); + expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "commit", ease }); + act(() => view.root.unmount()); + }); + + it("commits a focused flat tween segment ease through tween metadata", () => { + const onUpdateMeta = vi.fn(); + const onUpdateKeyframeEase = vi.fn(); + const view = renderCard( + { tweenPercentage: 100 }, + onUpdateKeyframeEase, + false, + FLAT_ANIMATION, + onUpdateMeta, + ); + const ease = selectPreset(view.host, "quad-out"); + + expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(FLAT_ANIMATION.id, { ease }); + expect(onUpdateKeyframeEase).not.toHaveBeenCalled(); + act(() => view.root.unmount()); + }); }); function baseAnimation(overrides: Partial = {}): GsapAnimation { diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 97f8efa404..b69bc3464c 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -1,6 +1,7 @@ -import { memo, useCallback, useMemo, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants"; +import { trackStudioSegmentEaseEdit } from "../../telemetry/events"; import { RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { MetricField, SelectField } from "./propertyPanelPrimitives"; import { controlPointsForGsapEase } from "./studioMotion"; @@ -22,6 +23,8 @@ interface AnimationCardProps extends GsapAnimationEditCallbacks { animation: GsapAnimation; defaultExpanded: boolean; flat?: boolean; + focusedSegment?: { tweenPercentage: number } | null; + onFocusSegmentConsumed?: () => void; } // fallow-ignore-next-line complexity @@ -29,6 +32,8 @@ export const AnimationCard = memo(function AnimationCard({ animation, defaultExpanded, flat, + focusedSegment, + onFocusSegmentConsumed, onUpdateProperty, onUpdateMeta, onDeleteAnimation, @@ -49,6 +54,25 @@ export const AnimationCard = memo(function AnimationCard({ const [addingProp, setAddingProp] = useState(false); const [addingFromProp, setAddingFromProp] = useState(false); const [expandedKfPct, setExpandedKfPct] = useState(null); + const cardRef = useRef(null); + const pendingAutoScrollRef = useRef(false); + + useEffect(() => { + if (!focusedSegment) return; + setExpanded(true); + pendingAutoScrollRef.current = true; + setExpandedKfPct(focusedSegment.tweenPercentage); + onFocusSegmentConsumed?.(); + }, [focusedSegment, onFocusSegmentConsumed]); + + useEffect(() => { + if (!pendingAutoScrollRef.current || expandedKfPct === null) return; + const segment = cardRef.current?.querySelector( + `[data-ease-segment-pct="${expandedKfPct}"]`, + ); + segment?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + pendingAutoScrollRef.current = false; + }, [expandedKfPct]); const usedProps = useMemo( () => new Set(Object.keys(animation.properties)), @@ -153,6 +177,7 @@ export const AnimationCard = memo(function AnimationCard({ return (
+ ); @@ -101,56 +78,182 @@ const DRAG_VMIN = -1; const ACCENT = "#3CE6AC"; type Pts = [number, number, number, number]; +const DEFAULT_CURVE: Pts = EASE_CURVES["power2.out"]; +const MODE_LABELS = { curve: "Curve", spring: "Spring", wiggle: "Wiggle" } satisfies Record< + EaseMode, + string +>; +const DEFAULT_EASE_BY_MODE = { + curve: `custom(M0,0 C${DEFAULT_CURVE[0]},${DEFAULT_CURVE[1]} ${DEFAULT_CURVE[2]},${DEFAULT_CURVE[3]} 1,1)`, + spring: "spring(0.42)", + wiggle: "wiggle(3,easeInOut,0.12)", +} satisfies Record; + +function EaseModeToggle({ mode, onCommit }: { mode: EaseMode; onCommit: (ease: string) => void }) { + return ( +
+ {EASE_MODES.map((candidateMode) => { + const active = candidateMode === mode; + return ( + + ); + })} +
+ ); +} + +// Figma-style ease-type dropdown: the current ease (glyph + name) as a button +// that opens the preset grid in a popover. This is where a preset is selected — +// the grid is no longer shown inline. +function EaseTypeDropdown({ + kind, + ease, + label, + onSelect, +}: { + kind: EaseMode; + ease: string; + label: string; + onSelect: (ease: string) => void; +}) { + const [open, setOpen] = useState(false); + return ( +
+ + {open && ( +
+ { + onSelect(next); + setOpen(false); + }} + /> +
+ )} +
+ ); +} + +function resolveEditableCurve(ease: string, springBounce: number | null): Pts | null { + if (springBounce !== null) return DEFAULT_CURVE; + if (ease === "hold") return DEFAULT_CURVE; + if (ease.startsWith("custom(") || ease in EASE_CURVES) return resolveEaseCurveTuple(ease); + return null; +} + +function resolveEditorLabel(ease: string, springBounce: number | null, isWiggle: boolean): string { + const presetLabel = easePresetLabel(ease); + if (presetLabel !== null) return presetLabel; + if (springBounce !== null) return "Custom spring"; + if (isWiggle) return "Custom wiggle"; + if (ease.startsWith("custom(")) return "Custom bezier"; + return EASE_LABELS[ease] ?? ease; +} const xToSvg = (px: number) => PADH + S * px; const yToSvg = (py: number) => HR + S * (1 - py); const clampView = (py: number) => Math.max(VMIN, Math.min(VMAX, py)); -function cubicAt(t: number, c0: number, c1: number, c2: number, c3: number): number { - const mt = 1 - t; - return mt * mt * mt * c0 + 3 * mt * mt * t * c1 + 3 * mt * t * t * c2 + t * t * t * c3; +function curvePathFor( + ease: string, + springBounce: number | null, + wiggleConfig: WiggleEaseConfig | null, + tuple: Pts, +): string { + if (ease === "hold") return holdCurvePath(xToSvg(0), yToSvg(0), xToSvg(1), yToSvg(1)); + if (wiggleConfig !== null) { + return sampledPath(64, xToSvg, yToSvg, (progress) => + evaluateWiggleEase(progress, wiggleConfig.wiggles, wiggleConfig.type, wiggleConfig.amplitude), + ); + } + if (springBounce !== null) { + return sampledPath(64, xToSvg, yToSvg, (progress) => + evaluateSpringEase(progress, springBounce), + ); + } + const [x1, y1, x2, y2] = tuple; + return `M${xToSvg(0)},${yToSvg(0)} C${xToSvg(x1)},${yToSvg(y1)} ${xToSvg(x2)},${yToSvg(y2)} ${xToSvg(1)},${yToSvg(1)}`; +} + +function EaseParameterField({ + springBounce, + wiggleConfig, + tuple, + onCommit, +}: { + springBounce: number | null; + wiggleConfig: WiggleEaseConfig | null; + tuple: Pts; + onCommit: (ease: string) => void; +}) { + if (springBounce !== null) { + return ; + } + if (wiggleConfig !== null) return ; + return ; } export function EaseCurveSection({ ease, - duration, onCustomEaseCommit, }: { ease: string; - duration?: number; onCustomEaseCommit: (ease: string) => void; }) { - const isCustom = ease.startsWith("custom("); - const curveFromPreset = EASE_CURVES[ease]; - const customPoints = isCustom ? parseCustomEaseFromString(ease) : null; - const curve: Pts | null = - isCustom && customPoints - ? [customPoints.x1, customPoints.y1, customPoints.x2, customPoints.y2] - : (curveFromPreset ?? null); + const springBounce = parseSpringBounce(ease); + const isSpring = springBounce !== null; + const wiggleConfig = parseWiggleEase(ease); + const isWiggle = wiggleConfig !== null; + const mode: EaseMode = isSpring ? "spring" : isWiggle ? "wiggle" : "curve"; + const curve = resolveEditableCurve(ease, springBounce); const [draft, setDraft] = useState(null); - const [progress, setProgress] = useState(null); const [hover, setHover] = useState<"p1" | "p2" | null>(null); const draggingRef = useRef<"p1" | "p2" | null>(null); const svgRef = useRef(null); - const rafRef = useRef(0); - const play = useCallback(() => { - const start = performance.now(); - const dur = 1100; - const tick = (now: number) => { - const t = Math.min((now - start) / dur, 1); - setProgress(t); - if (t < 1) rafRef.current = requestAnimationFrame(tick); - else setTimeout(() => setProgress(null), 450); - }; - cancelAnimationFrame(rafRef.current); - rafRef.current = requestAnimationFrame(tick); - }, []); - - const active = draft ?? curve; - if (!active) return null; - const [x1, y1, x2, y2] = active; + const activeTuple = draft ?? curve; + const displayTuple = activeTuple ?? DEFAULT_CURVE; + const [x1, y1, x2, y2] = displayTuple; // Anchors + control handles. Handle *display* is clamped to the view so an // extreme loaded overshoot rides the edge instead of disappearing. @@ -158,18 +261,9 @@ export function EaseCurveSection({ const a1 = { x: xToSvg(1), y: yToSvg(1) }; const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) }; const p2 = { x: xToSvg(x2), y: yToSvg(clampView(y2)) }; - // Curve drawn from the true control points (so its shape is exact). - const cp1 = { x: xToSvg(x1), y: yToSvg(y1) }; - const cp2 = { x: xToSvg(x2), y: yToSvg(y2) }; - const curvePath = `M${a0.x},${a0.y} C${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${a1.x},${a1.y}`; - - let dot: { x: number; y: number } | null = null; - if (progress !== null) { - dot = { - x: xToSvg(cubicAt(progress, 0, x1, x2, 1)), - y: yToSvg(cubicAt(progress, 0, y1, y2, 1)), - }; - } + const curvePath = curvePathFor(ease, springBounce, wiggleConfig, displayTuple); + const showGraph = activeTuple !== null || isWiggle; + const showHandles = !isSpring && !isWiggle; const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => { e.preventDefault(); @@ -211,156 +305,149 @@ export function EaseCurveSection({ const bottom = yToSvg(0); const left = xToSvg(0); const right = xToSvg(1); - const label = isCustom ? "Custom curve" : (EASE_LABELS[ease] ?? ease); - const bezierText = `${x1} · ${y1} · ${x2} · ${y2}`; + const label = resolveEditorLabel(ease, springBounce, isWiggle); return (
- onCustomEaseCommit(name)} /> -
- Speed curve - -
-
- - {/* Grid — quarter lines inside the unit square */} - {[0.25, 0.5, 0.75].map((q) => ( - - ))} - {[0.25, 0.5, 0.75].map((q) => ( - - ))} - {/* Unit-square frame (progress 0 → 1) */} - - {/* Linear reference diagonal */} - - {/* Tangent handle lines */} - - - {/* The curve */} - - {/* Anchors at (0,0) and (1,1) */} - - - {/* Animated preview dot */} - {dot && ( - <> - - - - )} - {/* Draggable control handles (large transparent hit area + visible dot) */} - {[["p1", p1] as const, ["p2", p2] as const].map(([key, pt]) => ( - - handlePointerDown(key, e)} - onPointerEnter={() => setHover(key)} - onPointerLeave={() => setHover((h) => (h === key ? null : h))} + + + {showGraph ? ( + <> +
+ + {/* Grid — quarter lines inside the unit square */} + {[0.25, 0.5, 0.75].map((q) => ( + + ))} + {[0.25, 0.5, 0.75].map((q) => ( + + ))} + {/* Unit-square frame (progress 0 → 1) */} + + {/* Linear reference diagonal */} + - + + + + )} + {/* The curve */} + - - ))} - -
- {/* Axis + value readout */} -
- {duration != null && duration > 0 ? "0s" : "start"} - time → - {duration != null && duration > 0 ? `${duration}s` : "end"} -
-
- {label} - - {bezierText} - -
+ {/* Anchors at (0,0) and (1,1) */} + + + {/* Draggable control handles (large transparent hit area + visible dot) */} + {showHandles && + [["p1", p1] as const, ["p2", p2] as const].map(([key, pt]) => ( + + handlePointerDown(key, e)} + onPointerEnter={() => setHover(key)} + onPointerLeave={() => setHover((h) => (h === key ? null : h))} + /> + + + ))} + +
+ + + ) : ( +

+ {label} preset — switch to Curve, Spring, or Wiggle above to shape it by hand. +

+ )}
); } diff --git a/packages/studio/src/components/editor/EaseParamFields.test.tsx b/packages/studio/src/components/editor/EaseParamFields.test.tsx new file mode 100644 index 0000000000..386bab2de4 --- /dev/null +++ b/packages/studio/src/components/editor/EaseParamFields.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { parseWiggleEase } from "@hyperframes/core/wiggle-ease"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EaseBezierField, SpringBounceField, WiggleField } from "./EaseParamFields"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const roots: Root[] = []; + +afterEach(() => { + act(() => roots.splice(0).forEach((root) => root.unmount())); + document.body.innerHTML = ""; +}); + +function renderField(field: React.ReactNode): HTMLElement { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + roots.push(root); + act(() => root.render(field)); + return host; +} + +function parsedWiggle(ease: string) { + const config = parseWiggleEase(ease); + expect(config).not.toBeNull(); + if (!config) throw new Error(`Expected a valid wiggle ease: ${ease}`); + return config; +} + +function expectWiggleCommit(onCommit: ReturnType, ease: string): void { + expect(onCommit).toHaveBeenLastCalledWith(ease); + const emitted = onCommit.mock.lastCall?.[0]; + expect(typeof emitted === "string" ? parseWiggleEase(emitted) : null).not.toBeNull(); +} + +function inputValue(input: HTMLInputElement, value: string): void { + act(() => { + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +// Render a default wiggle field, type `value` into the input labelled `label`, +// and return the commit spy — the shared body of the input-edit cases. +function editWiggle(label: string, value: string): ReturnType { + const onCommit = vi.fn(); + const host = renderField( + , + ); + const input = host.querySelector(`[aria-label="${label}"]`); + expect(input).not.toBeNull(); + if (input) inputValue(input, value); + return onCommit; +} + +describe("WiggleField", () => { + it("reflects a parsed wiggle config", () => { + const host = renderField( + , + ); + + expect(host.querySelector('[aria-label="Wiggle count"]')?.value).toBe("6"); + expect(host.querySelector('[aria-label="Wiggle type"]')?.value).toBe( + "easeOut", + ); + expect(host.querySelector('[aria-label="Wiggle amplitude"]')?.value).toBe( + "0.26", + ); + }); + + it("commits an edited count", () => { + expectWiggleCommit(editWiggle("Wiggle count", "4"), "wiggle(4,easeOut,0.26)"); + }); + + it("commits an edited type", () => { + const onCommit = vi.fn(); + const host = renderField( + , + ); + + const select = host.querySelector('[aria-label="Wiggle type"]'); + expect(select).not.toBeNull(); + act(() => { + if (!select) return; + select.value = "anticipate"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expectWiggleCommit(onCommit, "wiggle(6,anticipate,0.26)"); + }); + + it("commits an edited amplitude", () => { + expectWiggleCommit(editWiggle("Wiggle amplitude", "0.1"), "wiggle(6,easeOut,0.1)"); + }); + + it("seeds and explicitly commits the per-type default amplitude", () => { + const onCommit = vi.fn(); + const host = renderField( + , + ); + + expect(host.querySelector('[aria-label="Wiggle amplitude"]')?.value).toBe( + "0.08", + ); + const count = host.querySelector('[aria-label="Wiggle count"]'); + expect(count).not.toBeNull(); + if (count) inputValue(count, "4"); + + expectWiggleCommit(onCommit, "wiggle(4,easeInOut,0.08)"); + }); + + it("ignores an empty amplitude", () => { + expect(editWiggle("Wiggle amplitude", "")).not.toHaveBeenCalled(); + }); + + it("rejects a count below one", () => { + expect(editWiggle("Wiggle count", "0")).not.toHaveBeenCalled(); + }); +}); + +describe("moved ease parameter fields", () => { + it("keeps the spring bounce commit behavior", () => { + const onCommit = vi.fn(); + const host = renderField(); + const input = host.querySelector('[aria-label="Spring bounce"]'); + expect(input).not.toBeNull(); + if (input) inputValue(input, "0.333"); + + expect(onCommit).toHaveBeenLastCalledWith("spring(0.33)"); + }); + + it("keeps the cubic-bezier tuple commit behavior", () => { + const onCommit = vi.fn(); + const host = renderField(); + const input = host.querySelector( + '[aria-label="Cubic bezier control points"]', + ); + expect(input).not.toBeNull(); + act(() => { + if (!input) return; + input.value = "0.111, 0.222, 0.777, 0.888"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + + expect(onCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.11,0.22 0.78,0.89 1,1)"); + }); +}); diff --git a/packages/studio/src/components/editor/EaseParamFields.tsx b/packages/studio/src/components/editor/EaseParamFields.tsx new file mode 100644 index 0000000000..c5bc4573a4 --- /dev/null +++ b/packages/studio/src/components/editor/EaseParamFields.tsx @@ -0,0 +1,169 @@ +import { parseSpringBounce } from "@hyperframes/core/spring-ease"; +import { + parseWiggleEase, + type WiggleEaseConfig, + type WiggleType, +} from "@hyperframes/core/wiggle-ease"; +import { roundToCenti } from "../../utils/rounding"; +import { MiniCurveSvg } from "./easeCurveSvg"; + +type Pts = [number, number, number, number]; + +const round2 = roundToCenti; +const WIGGLE_TYPES = ["easeOut", "easeInOut", "anticipate", "uniform"] as const; +const WIGGLE_DEFAULT_AMPLITUDE = { + easeOut: 0.16, + easeInOut: 0.08, + anticipate: 0.12, + uniform: 0.14, +} satisfies Record; + +function isWiggleType(value: string): value is WiggleType { + return WIGGLE_TYPES.some((type) => type === value); +} + +function commitWiggle( + onCommit: (ease: string) => void, + count: number, + type: WiggleType, + amplitude: number, +): void { + const ease = `wiggle(${count},${type},${roundToCenti(amplitude)})`; + if (parseWiggleEase(ease)) onCommit(ease); +} + +// Editable cubic-bezier control points, Figma-style ("0.33, 0, 0, 1"). Commits +// on Enter/blur; remounts (via key) when the tuple changes from a drag or preset +// pick so the text always mirrors the live curve. +export function EaseBezierField({ + tuple, + onCommit, +}: { + tuple: Pts; + onCommit: (ease: string) => void; +}) { + const text = tuple.join(", "); + const commit = (raw: string) => { + const nums = raw + .split(/[\s,]+/) + .map(Number) + .filter((value) => Number.isFinite(value)); + if (nums.length !== 4) return; + const [x1, y1, x2, y2] = nums as [number, number, number, number]; + onCommit(`custom(M0,0 C${round2(x1)},${round2(y1)} ${round2(x2)},${round2(y2)} 1,1)`); + }; + return ( +
+ + { + if (event.key === "Enter") commit(event.currentTarget.value); + }} + onBlur={(event) => commit(event.currentTarget.value)} + className="w-full rounded border border-white/10 bg-black/20 px-1.5 py-1 font-mono text-[10px] text-neutral-300 outline-none focus:border-panel-accent/50" + /> +
+ ); +} + +export function SpringBounceField({ + springBounce, + onCommit, +}: { + springBounce: number; + onCommit: (ease: string) => void; +}) { + return ( + + ); +} + +export function WiggleField({ + config, + onCommit, +}: { + config: WiggleEaseConfig; + onCommit: (ease: string) => void; +}) { + const amplitude = config.amplitude ?? WIGGLE_DEFAULT_AMPLITUDE[config.type]; + return ( +
+ + + +
+ ); +} diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 33f6201b2f..cd31fffc74 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -9,6 +9,7 @@ import { type GsapAnimationEditCallbacks, } from "./gsapAnimationCallbacks"; import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; +import { usePlayerStore } from "../../player"; interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks { animations: GsapAnimation[]; @@ -56,6 +57,8 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ trackAnimationMetaUpdate(track, updates); onUpdateMeta(animationId, updates); }; + const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment); + const setFocusedEaseSegment = usePlayerStore((s) => s.setFocusedEaseSegment); return (
}> @@ -79,6 +82,10 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ key={anim.id} animation={anim} defaultExpanded={index === 0} + focusedSegment={ + focusedEaseSegment?.animationId === anim.id ? focusedEaseSegment : null + } + onFocusSegmentConsumed={() => setFocusedEaseSegment(null)} onUpdateProperty={(animationId, property, value) => { trackProperty(property); onUpdateProperty(animationId, property, value); diff --git a/packages/studio/src/components/editor/KeyframeEaseList.tsx b/packages/studio/src/components/editor/KeyframeEaseList.tsx index 4574433d7b..9e53dd6118 100644 --- a/packages/studio/src/components/editor/KeyframeEaseList.tsx +++ b/packages/studio/src/components/editor/KeyframeEaseList.tsx @@ -93,7 +93,11 @@ export function KeyframeEaseList({ ? "Custom" : (EASE_LABELS[segEase] ?? segEase); return ( -
+
+ + ◇ + + {name} +
+ ); +} diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index e3db29f4a3..410507d4c7 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -19,8 +19,11 @@ import { shouldAutoScrollTimeline, } from "./Timeline"; import { + CLIP_Y, FIT_ZOOM_HEADROOM, GUTTER, + LABEL_COL_W, + LANE_H, MIN_TIMELINE_EXTENT_S, PLAYHEAD_HEAD_W, RULER_H, @@ -28,6 +31,7 @@ import { TRACKS_LEFT_PAD, getTimelineDisplayContentWidth, getTimelineFitPps, + getTimelineLaneTop, } from "./timelineLayout"; import { formatTime } from "../lib/time"; import { usePlayerStore } from "../store/playerStore"; @@ -40,7 +44,178 @@ afterEach(() => { usePlayerStore.getState().reset(); }); +function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) { + const clip = host.querySelector(`[data-el-id="${clipId}"]`); + if (!clip) throw new Error(`Missing timeline clip ${clipId}`); + const trackContent = clip.parentElement; + if (!trackContent) throw new Error(`Missing content row for ${clipId}`); + const trackHeader = trackContent.previousElementSibling; + if (!(trackHeader instanceof HTMLElement)) throw new Error(`Missing track header for ${clipId}`); + const rulerTickLabel = Array.from(host.querySelectorAll("span")).find( + (node) => node.textContent === tickLabel, + ); + const rulerTick = rulerTickLabel?.parentElement; + if (!rulerTick) throw new Error(`Missing ruler tick ${tickLabel}`); + const ruler = rulerTick.parentElement; + if (!ruler) throw new Error("Missing timeline ruler"); + const rulerOrigin = ruler.previousElementSibling; + if (!(rulerOrigin instanceof HTMLElement)) throw new Error("Missing timeline ruler origin"); + const playhead = Array.from(host.querySelectorAll("div")).find( + (node) => node.style.zIndex === "100", + ); + if (!playhead) throw new Error("Missing timeline playhead"); + return { clip, trackHeader, rulerTick, rulerOrigin, playhead }; +} + +function renderTimelineGeometry(clipId: string) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(React.createElement(Timeline)); + }); + return { host, root, ...getHorizontalGeometry(host, clipId, "00:10") }; +} + describe("Timeline provider boundary", () => { + it("keeps all-collapsed horizontal positions at the 32px gutter", () => { + usePlayerStore.setState({ + duration: 11, + timelineReady: true, + currentTime: 10, + zoomMode: "manual", + manualZoomPercent: 100, + elements: [{ id: "clip-1", tag: "div", start: 10, duration: 1, track: 0 }], + }); + + const { root, clip, trackHeader, rulerTick, rulerOrigin, playhead } = + renderTimelineGeometry("clip-1"); + + expect(trackHeader.style.width).toBe("32px"); + expect(clip.style.left).toBe("1000px"); + expect(clip.style.height).toBe(""); + expect(clip.style.bottom).toBe(`${CLIP_Y}px`); + expect(rulerOrigin.style.width).toBe("32px"); + expect(rulerTick.style.left).toBe("999.5px"); + expect(playhead.style.left).toBe(`${1032 - PLAYHEAD_HEAD_W / 2}px`); + expect(playhead.style.width).toBe(`${PLAYHEAD_HEAD_W}px`); + expect( + resolveTimelineAssetDrop( + { + rectLeft: 100, + rectTop: 0, + scrollLeft: 0, + scrollTop: 0, + contentOrigin: GUTTER, + pixelsPerSecond: 100, + duration: 60, + trackOrder: [0], + }, + 1132, + 100, + ).start, + ).toBe(10); + expect(getTimelineFitPps(640, 11, GUTTER)).toBe(10.1); + + act(() => root.unmount()); + }); + + it("reserves the label column and keeps expanded keyframes aligned with ruler time", () => { + usePlayerStore.setState({ + duration: 20, + timelineReady: true, + currentTime: 10, + zoomMode: "manual", + manualZoomPercent: 100, + selectedElementId: "clip-1", + expandedClipIds: new Set(["clip-1"]), + elements: [ + { id: "clip-1", label: "Hero card", tag: "div", start: 0, duration: 20, track: 0 }, + { id: "clip-2", label: "Outro", tag: "div", start: 2, duration: 1, track: 1 }, + ], + gsapAnimations: new Map([ + [ + "clip-1", + [ + { + id: "position-tween", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 20, + properties: {}, + propertyGroup: "position", + keyframes: { + format: "percentage", + keyframes: [{ percentage: 50, properties: { x: 100 } }], + }, + }, + ], + ], + ]), + }); + + const { host, root, clip, trackHeader, rulerTick, rulerOrigin, playhead } = + renderTimelineGeometry("clip-1"); + const { trackHeader: collapsedHeader } = getHorizontalGeometry(host, "clip-2", "00:10"); + const diamond = host.querySelector( + '[data-keyframe-group="position"][data-keyframe-percentage="50"]', + ); + if (!diamond) throw new Error("Missing expanded position keyframe"); + const propertyLane = diamond.closest("[data-timeline-property-lane]"); + if (!propertyLane) throw new Error("Missing flat position property lane"); + const headerLane = trackHeader.querySelector('[data-property-group="position"]'); + if (!headerLane) throw new Error("Missing position property header"); + // Absolute x rebuilds from the content origin (the ruler-origin spacer), + // which now insets a GUTTER past the LABEL_COL_W label column so a 0% + // diamond has room to its left. The content row reaches that same origin via + // header (LABEL_COL_W) + its gutter margin, so ruler tick and diamond still + // coincide on the shared time x. + const contentOrigin = Number.parseFloat(rulerOrigin.style.width); + const rulerX = contentOrigin + Number.parseFloat(rulerTick.style.left) + 0.5; + const diamondX = + contentOrigin + + Number.parseFloat(propertyLane.style.left) + + Number.parseFloat(diamond.style.left) + + Number.parseFloat(diamond.style.width) / 2; + + expect(clip.contains(propertyLane)).toBe(false); + expect(clip.style.height).toBe(`${TRACK_H - 2 * CLIP_Y}px`); + expect(clip.style.bottom).toBe(""); + expect(propertyLane.style.top).toBe(`${getTimelineLaneTop(0)}px`); + expect(propertyLane.style.top).toBe(headerLane.style.top); + expect(propertyLane.style.background).toBe(""); + expect(propertyLane.style.border).toBe(""); + expect(propertyLane.style.borderRadius).toBe(""); + expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`); + expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`); + expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`); + expect(diamondX).toBe(rulerX); + expect(rulerX).toBe(LABEL_COL_W + GUTTER + 1000); + expect(collapsedHeader.textContent).toContain("Outro"); + expect(getTimelineFitPps(640, 20, LABEL_COL_W + GUTTER)).toBeCloseTo( + (640 - (LABEL_COL_W + GUTTER) - 2) / MIN_TIMELINE_EXTENT_S, + ); + expect( + resolveTimelineAssetDrop( + { + rectLeft: 100, + rectTop: 0, + scrollLeft: 0, + scrollTop: 0, + contentOrigin: LABEL_COL_W + GUTTER, + pixelsPerSecond: 100, + duration: 60, + trackOrder: [0], + }, + 100 + LABEL_COL_W + GUTTER + 1000, + 100, + ).start, + ).toBe(10); + + act(() => root.unmount()); + }); + // fallow-ignore-next-line code-duplication it("renders the public Timeline export without TimelineEditProvider", () => { const host = document.createElement("div"); @@ -153,8 +328,8 @@ describe("Timeline provider boundary", () => { }); const row = button.parentElement?.parentElement; - // Row children: [sticky gutter, TRACKS_LEFT_PAD spacer, time-mapped content]. - const trackContent = row?.children.item(2); + // Row children: [TimelineTrackHeader (sticky column), time-mapped content]. + const trackContent = row?.children.item(1); expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false); expect(trackContent).toBeInstanceOf(HTMLElement); if (!(trackContent instanceof HTMLElement)) { @@ -215,6 +390,106 @@ describe("Timeline provider boundary", () => { act(() => root.unmount()); }); + it("shows a disclosure only for grouped keyframes and toggles the track height", () => { + const host = document.createElement("div"); + document.body.append(host); + Object.defineProperty(host, "clientWidth", { + configurable: true, + value: 720, + }); + + usePlayerStore.setState({ + duration: 4, + timelineReady: true, + elements: [ + { id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }, + { id: "clip-2", tag: "div", start: 2, duration: 2, track: 1 }, + ], + keyframeCache: new Map([ + [ + "clip-1", + { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 }, propertyGroup: "position" }, + { percentage: 50, properties: { x: 100 }, propertyGroup: "position" }, + { percentage: 100, properties: { opacity: 0 }, propertyGroup: "visual" }, + ], + }, + ], + ]), + gsapAnimations: new Map([ + [ + "clip-1", + [ + { + id: "clip-1-position", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 2, + properties: {}, + propertyGroup: "position", + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 100 } }, + ], + }, + }, + { + id: "clip-1-visual", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 2, + properties: {}, + propertyGroup: "visual", + keyframes: { + format: "percentage", + keyframes: [{ percentage: 100, properties: { opacity: 0 } }], + }, + }, + ], + ], + ]), + }); + + const root = createRoot(host); + act(() => { + root.render(React.createElement(Timeline)); + }); + + // Keyframed clip-1 is expanded by default (AE/Figma default); its disclosure + // lives in the left column. clip-2 has no keyframes so it never shows one. + const collapseButton = host.querySelector( + 'button[aria-label="Collapse clip-1 keyframes"]', + ); + expect(collapseButton).not.toBeNull(); + expect(host.querySelector('button[aria-label="Expand clip-2 keyframes"]')).toBeNull(); + expect(host.querySelector('button[aria-label="Collapse clip-2 keyframes"]')).toBeNull(); + + const clip = host.querySelector('[data-el-id="clip-1"]'); + const row = clip?.parentElement?.parentElement; + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"])); + expect(row?.style.height).toBe(`${TRACK_H + 2 * LANE_H}px`); + + // Collapsing sticks (does not bounce back open via auto-expand). + act(() => collapseButton?.click()); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); + expect(row?.style.height).toBe(`${TRACK_H}px`); + + const expandButton = host.querySelector( + 'button[aria-label="Expand clip-1 keyframes"]', + ); + expect(expandButton).not.toBeNull(); + act(() => expandButton?.click()); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"])); + expect(row?.style.height).toBe(`${TRACK_H + 2 * LANE_H}px`); + act(() => root.unmount()); + }); + it("marks every clip in selectedElementIds as selected", () => { const host = document.createElement("div"); document.body.append(host); @@ -461,23 +736,29 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => { it("computes fit pps against the 60s floor for short compositions", () => { // A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width. // (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.) - const pps = getTimelineFitPps(viewport, 10); - expect(pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S); - expect(10 * pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / 6); + const pps = getTimelineFitPps(viewport, 10, GUTTER + TRACKS_LEFT_PAD); + expect(pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S); + expect(10 * pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / 6); }); it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => { - expect(getTimelineFitPps(viewport, 60)).toBeCloseTo( - (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (60 * FIT_ZOOM_HEADROOM), + expect(getTimelineFitPps(viewport, 60, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo( + (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / (60 * FIT_ZOOM_HEADROOM), + ); + expect(getTimelineFitPps(viewport, 120, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo( + (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / (120 * FIT_ZOOM_HEADROOM), ); - expect(getTimelineFitPps(viewport, 120)).toBeCloseTo( - (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (120 * FIT_ZOOM_HEADROOM), + }); + + it("subtracts the expanded keyframe label column before fitting headroom", () => { + expect(getTimelineFitPps(viewport, 120, LABEL_COL_W)).toBeCloseTo( + (viewport - LABEL_COL_W - 2) / (120 * FIT_ZOOM_HEADROOM), ); }); it("leaves CapCut-style trailing headroom: the comp ends at 1/1.2 of the usable width", () => { - const usable = viewport - GUTTER - TRACKS_LEFT_PAD - 2; - const pps = getTimelineFitPps(viewport, 120); + const usable = viewport - (GUTTER + TRACKS_LEFT_PAD) - 2; + const pps = getTimelineFitPps(viewport, 120, GUTTER + TRACKS_LEFT_PAD); // Composition content occupies usable/1.2 px; the remaining ~17% is empty // droppable ruler/lane surface past the end. expect(120 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM); @@ -485,17 +766,17 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => { }); it("falls back to 100 pps before the viewport is measured", () => { - expect(getTimelineFitPps(0, 10)).toBe(100); - expect(getTimelineFitPps(GUTTER + TRACKS_LEFT_PAD, 10)).toBe(100); - expect(getTimelineFitPps(Number.NaN, 10)).toBe(100); + expect(getTimelineFitPps(0, 10, GUTTER)).toBe(100); + expect(getTimelineFitPps(GUTTER, 10, GUTTER)).toBe(100); + expect(getTimelineFitPps(Number.NaN, 10, GUTTER)).toBe(100); }); it("uses the floor for zero/invalid durations", () => { - expect(getTimelineFitPps(viewport, 0)).toBeCloseTo( - (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S, + expect(getTimelineFitPps(viewport, 0, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo( + (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S, ); - expect(getTimelineFitPps(viewport, Number.NaN)).toBeCloseTo( - (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S, + expect(getTimelineFitPps(viewport, Number.NaN, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo( + (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S, ); }); }); @@ -504,14 +785,24 @@ describe("getTimelineDisplayContentWidth", () => { it("always spans at least MIN_TIMELINE_EXTENT_S seconds of content", () => { // 10s of content at 20 pps = 200px; the floor keeps 60s (1200px) rendered. expect( - getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 400, pps: 20 }), + getTimelineDisplayContentWidth({ + trackContentWidth: 200, + viewportWidth: 400, + contentOrigin: GUTTER, + pps: 20, + }), ).toBe(MIN_TIMELINE_EXTENT_S * 20); }); it("still fills the viewport when that is larger than the 60s floor", () => { expect( - getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 2000, pps: 5 }), - ).toBe(2000 - GUTTER - TRACKS_LEFT_PAD - 2); + getTimelineDisplayContentWidth({ + trackContentWidth: 200, + viewportWidth: 2000, + contentOrigin: GUTTER + TRACKS_LEFT_PAD, + pps: 5, + }), + ).toBe(2000 - (GUTTER + TRACKS_LEFT_PAD) - 2); }); it("tracks a drag ghost past every other bound (drag-to-extend)", () => { @@ -519,6 +810,7 @@ describe("getTimelineDisplayContentWidth", () => { getTimelineDisplayContentWidth({ trackContentWidth: 500, viewportWidth: 400, + contentOrigin: GUTTER, pps: 5, dragGhostEndPx: 5000, }), @@ -530,6 +822,7 @@ describe("getTimelineDisplayContentWidth", () => { getTimelineDisplayContentWidth({ trackContentWidth: 500, viewportWidth: 400, + contentOrigin: GUTTER, pps: 5, resizeGhostEndPx: 4200, }), @@ -538,7 +831,12 @@ describe("getTimelineDisplayContentWidth", () => { it("keeps long content authoritative", () => { expect( - getTimelineDisplayContentWidth({ trackContentWidth: 9000, viewportWidth: 400, pps: 50 }), + getTimelineDisplayContentWidth({ + trackContentWidth: 9000, + viewportWidth: 400, + contentOrigin: GUTTER, + pps: 50, + }), ).toBe(9000); }); }); @@ -565,7 +863,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => { getTimelineScrollLeftForZoomAnchor({ pointerX: 300, currentScrollLeft: 200, - gutter: 32, + contentOrigin: GUTTER, currentPixelsPerSecond: 10, nextPixelsPerSecond: 20, duration: 120, @@ -578,7 +876,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => { getTimelineScrollLeftForZoomAnchor({ pointerX: 300, currentScrollLeft: 0, - gutter: 32, + contentOrigin: GUTTER, currentPixelsPerSecond: 20, nextPixelsPerSecond: 5, duration: 120, @@ -591,7 +889,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => { getTimelineScrollLeftForZoomAnchor({ pointerX: 300, currentScrollLeft: 120, - gutter: 32, + contentOrigin: GUTTER, currentPixelsPerSecond: 0, nextPixelsPerSecond: 20, duration: 120, @@ -601,28 +899,47 @@ describe("getTimelineScrollLeftForZoomAnchor", () => { }); describe("getTimelinePlayheadLeft", () => { - it("offsets the wrapper by half the head width so the line CENTER = GUTTER + TRACKS_LEFT_PAD + t*pps", () => { + it("offsets the wrapper by half the head width so the line CENTER = contentOrigin + t*pps", () => { // Wrapper left + PLAYHEAD_HEAD_W/2 (where the 1px line is centered) must - // equal GUTTER + TRACKS_LEFT_PAD + t*pps at any zoom. - expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe( + // equal contentOrigin + t*pps at any zoom, for both the padded default + // origin and the plain gutter origin. + expect(getTimelinePlayheadLeft(4, 20, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe( GUTTER + TRACKS_LEFT_PAD + 4 * 20, ); - expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe( + expect(getTimelinePlayheadLeft(10, 7.5, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe( GUTTER + TRACKS_LEFT_PAD + 75, ); + expect(getTimelinePlayheadLeft(4, 20, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 4 * 20); + expect(getTimelinePlayheadLeft(10, 7.5, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 75); + }); + + it("uses the expanded keyframe label column as the playhead origin", () => { + expect(getTimelinePlayheadLeft(4, 20, LABEL_COL_W) + PLAYHEAD_HEAD_W / 2).toBe( + LABEL_COL_W + 4 * 20, + ); }); it("centers the line exactly on the left pad's end (the 00:00 tick) at t = 0", () => { - expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + TRACKS_LEFT_PAD); + expect(getTimelinePlayheadLeft(0, 20, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe( + GUTTER + TRACKS_LEFT_PAD, + ); + }); + + it("centers the line exactly on the gutter (the 00:00 tick) at t = 0", () => { + expect(getTimelinePlayheadLeft(0, 20, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER); }); it("guards invalid input", () => { - expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe( + expect(getTimelinePlayheadLeft(Number.NaN, 20, GUTTER + TRACKS_LEFT_PAD)).toBe( GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2, ); - expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe( + expect(getTimelinePlayheadLeft(4, Number.NaN, GUTTER + TRACKS_LEFT_PAD)).toBe( GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2, ); + expect(getTimelinePlayheadLeft(Number.NaN, 20, GUTTER)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2); + expect(getTimelinePlayheadLeft(4, Number.NaN, LABEL_COL_W)).toBe( + LABEL_COL_W - PLAYHEAD_HEAD_W / 2, + ); }); }); @@ -686,14 +1003,15 @@ describe("resolveTimelineAssetDrop", () => { rectTop: 200, scrollLeft: 0, scrollTop: 0, + contentOrigin: GUTTER, pixelsPerSecond: 100, duration: 10, trackHeight: 72, trackOrder: [0, 3, 7], }, - 480, // rectLeft(100) + GUTTER + TRACKS_LEFT_PAD + 3s*100pps - // clientY updated for TRACKS_TOP_PAD=72: rectTop(200) + RULER_H(24) + - // TRACKS_TOP_PAD(72) + TRACK_H(48) + TRACK_H/2(24) = 368 → row 1 → track 3. + 432, // rectLeft(100) + GUTTER(32) + 3s*100pps (contentOrigin = GUTTER) + // clientY: rectTop(200) + RULER_H(24) + TRACKS_TOP_PAD(72) + TRACK_H(48) + // + TRACK_H/2(24) = 368 → row 1 → track 3. 368, ), ).toEqual({ start: 3, track: 3 }); @@ -707,12 +1025,13 @@ describe("resolveTimelineAssetDrop", () => { rectTop: 200, scrollLeft: 0, scrollTop: 0, + contentOrigin: GUTTER, pixelsPerSecond: 100, duration: 10, trackHeight: 72, trackOrder: [0, 3, 7], }, - 250 + TRACKS_LEFT_PAD, + 250, // rectLeft(100) + GUTTER(32) + 1.18s*100pps (contentOrigin = GUTTER) 600, ), ).toEqual({ start: 1.18, track: 8 }); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 2e215381f6..6ae23b9c7b 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -9,7 +9,6 @@ import { defaultTimelineTheme } from "./timelineTheme"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; import { useTimelineActiveClips } from "./useTimelineActiveClips"; -import { getTrackStyle } from "./timelineIcons"; import { useTimelineZoom } from "./useTimelineZoom"; import { useTimelineAssetDrop } from "./timelineDragDrop"; import { TimelineEmptyState } from "./TimelineEmptyState"; @@ -20,12 +19,24 @@ import { TimelineOverlays } from "./TimelineOverlays"; import { useTimelineEditPinning } from "./useTimelineEditPinning"; import { useTimelineStackingSync } from "./useTimelineStackingSync"; import { useTimelineGeometry } from "./useTimelineGeometry"; -import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations"; -import { GUTTER, TRACKS_LEFT_PAD, generateTicks, getTimelineCanvasHeight } from "./timelineLayout"; +import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips"; +import { + GUTTER, + LABEL_COL_W, + generateTicks, + getTimelineContentXFromClient, +} from "./timelineLayout"; import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; import { STUDIO_PREVIEW_FPS } from "../lib/time"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import type { TimelineProps } from "./TimelineTypes"; +import { + getTrackStyle, + useTimelineDisplayLayout, + useTimelineTrackLayout, +} from "./useTimelineTrackLayout"; +import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers"; +import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; import { useTrackGapMenu } from "./useTrackGapMenu"; import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; @@ -99,6 +110,19 @@ export const Timeline = memo(function Timeline({ const timelineReady = usePlayerStore((s) => s.timelineReady); const selectedElementId = usePlayerStore((s) => s.selectedElementId); const selectedElementIds = usePlayerStore((s) => s.selectedElementIds); + const gsapAnimations = usePlayerStore((s) => s.gsapAnimations); + // Label mode = comp has keyframed clips (not just when expanded): keeps the layer + // disclosure + property column visible and reserves a GUTTER before 0s (Figma). + const hasKeyframedClips = useMemo( + () => + Array.from(gsapAnimations.values()).some((list) => + list.some((animation) => animation.keyframes), + ), + [gsapAnimations], + ); + const labelMode = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips; + const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER; + const contentGutter = labelMode ? GUTTER : 0; const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); const currentTime = usePlayerStore((s) => s.currentTime); const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom(); @@ -147,9 +171,10 @@ export const Timeline = memo(function Timeline({ return Number.isFinite(result) ? result : safeDur; }, [rawElements, duration]); - const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements); - const trackOrderRef = useRef(trackOrder); - trackOrderRef.current = trackOrder; + const keyframeCache = usePlayerStore((s) => s.keyframeCache); + useAutoExpandKeyframedClips(gsapAnimations); + const { tracks, trackStyles, trackOrder, trackOrderRef, laneCounts, rowHeights, rowHeightsRef } = + useTimelineTrackLayout(expandedElements, gsapAnimations, selectedElementId, selectedElementIds); const expandedElementsRef = useRef(expandedElements); expandedElementsRef.current = expandedElements; @@ -214,6 +239,7 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, + rowHeightsRef, onMoveElement: pinnedOnMoveElement, onMoveElements: pinnedOnMoveElements, onResizeElement: pinnedOnResizeElement, @@ -231,25 +257,31 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, + rowHeightsRef, + contentOrigin, onFileDrop: pinnedOnFileDrop, onAssetDrop: pinnedOnAssetDrop, onBlockDrop: pinnedOnBlockDrop, }); - const displayTrackOrder = useMemo(() => { - if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder; - return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b); - }, [draggedClip, trackOrder]); - - const totalH = getTimelineCanvasHeight(displayTrackOrder.length); + const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights); const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [ timelineReady, expandedElements.length, - totalH, + displayLayout.totalH, ]); - const keyframeCache = usePlayerStore((s) => s.keyframeCache); const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); + const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } = + useTimelineKeyframeHandlers({ + expandedElements, + keyframeCache, + onSelectElement, + onSeek, + setSelectedElementId, + setKfContextMenu, + toggleSelectedKeyframe, + }); const selectedElement = useMemo( () => @@ -280,6 +312,7 @@ export const Timeline = memo(function Timeline({ isDragging, scrollRef, lastScrollLeftRef, + contentOrigin, }); const laneGapStrips = useTimelineGapHighlights({ @@ -312,6 +345,7 @@ export const Timeline = memo(function Timeline({ setZoomMode, setManualZoomPercent, onSeek, + contentOrigin, }); useTimelineActiveClips({ scrollRef, @@ -341,7 +375,9 @@ export const Timeline = memo(function Timeline({ setShowPopover, elementsRef: expandedElementsRef, trackOrderRef, + rowHeightsRef, onSelectElement, + contentOrigin, }); setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag @@ -424,10 +460,17 @@ export const Timeline = memo(function Timeline({ onDragLeave={() => clearDropPreview()} onDrop={handleAssetDrop} onPointerDown={(e) => { + // Let interactive controls (keyframe nav/toggle, caret, inputs) handle + // their own clicks — scrubbing here would preventDefault and eat them. + if ((e.target as HTMLElement).closest("button, input, select, a")) return; if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) { const rect = scrollRef.current.getBoundingClientRect(); - const x = - e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER - TRACKS_LEFT_PAD; + const x = getTimelineContentXFromClient({ + clientX: e.clientX, + rectLeft: rect.left, + scrollLeft: scrollRef.current.scrollLeft, + contentOrigin, + }); const splitTime = Math.max(0, x / pps); onRazorSplitAll?.(splitTime); return; @@ -442,18 +485,22 @@ export const Timeline = memo(function Timeline({ major={major} minor={minor} pps={pps} + contentOrigin={contentOrigin} + contentGutter={contentGutter} trackContentWidth={displayContentWidth} - totalH={totalH} + totalH={displayLayout.totalH} effectiveDuration={effectiveDuration} majorTickInterval={majorTickInterval} rangeSelection={rangeSelection} marqueeRect={marqueeRect} laneGapStrips={laneGapStrips} theme={theme} - displayTrackOrder={displayTrackOrder} + displayTrackOrder={displayLayout.displayTrackOrder} + rowHeights={displayLayout.displayRowHeights} trackOrder={trackOrder} tracks={tracks} trackStyles={trackStyles} + laneCounts={laneCounts} selectedElementId={selectedElementId} selectedElementIds={selectedElementIds} hoveredClip={hoveredClip} @@ -479,43 +526,16 @@ export const Timeline = memo(function Timeline({ getPreviewElement={getPreviewElement} getTrackStyle={getTrackStyle} keyframeCache={keyframeCache} + gsapAnimations={gsapAnimations} selectedKeyframes={selectedKeyframes} currentTime={currentTime} + onSeek={onSeek} beatAnalysis={adjustedBeatAnalysis} - onClickKeyframe={(el, pct) => { - usePlayerStore.getState().clearSelectedKeyframes(); - const elKey = el.key ?? el.id; - setSelectedElementId(elKey); - onSelectElement?.(el); - // Select the clicked diamond (matches shift-click); cleared above so this single-selects. - toggleSelectedKeyframe(`${elKey}:${pct}`); - const absTime = el.start + (pct / 100) * el.duration; - onSeek?.(absTime); - const kfData = keyframeCache?.get(elKey); - const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5); - usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null); - }} - onShiftClickKeyframe={(elId, pct) => { - toggleSelectedKeyframe(`${elId}:${pct}`); - }} + onSelectSegment={onSelectSegment} + onClickKeyframe={onClickKeyframe} + onShiftClickKeyframe={onShiftClickKeyframe} onMoveKeyframe={onMoveKeyframe} - onContextMenuKeyframe={(e, elId, pct) => { - const el = expandedElements.find((x) => (x.key ?? x.id) === elId); - if (el) { - setSelectedElementId(elId); - onSelectElement?.(el); - } - const kfData = keyframeCache.get(elId); - const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2); - setKfContextMenu({ - x: e.clientX + 4, - y: e.clientY + 2, - elementId: elId, - percentage: pct, - tweenPercentage: kf?.tweenPercentage, - currentEase: kf?.ease ?? kfData?.ease, - }); - }} + onContextMenuKeyframe={onContextMenuKeyframe} onContextMenuClip={(e, el) => { e.preventDefault(); setSelectedElementId(el.key ?? el.id); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 462acc2885..181ca290b8 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -4,16 +4,15 @@ import { PlayheadIndicator } from "./PlayheadIndicator"; import { getTimelineEditCapabilities, type TimelineRangeSelection } from "./timelineEditing"; import { getRenderedTimelineElement } from "./timelineTheme"; import { - GUTTER, - TRACK_H, RULER_H, CLIP_Y, TRACKS_TOP_PAD, TRACKS_BOTTOM_PAD, - TRACKS_LEFT_PAD, + TRACK_H, PLAYHEAD_HEAD_W, getTimelinePlayheadLeft, getTimelineRowTop, + getTimelineRowHeight, } from "./timelineLayout"; import { usePlayerStore } from "../store/playerStore"; import type { ResizingClipState } from "./useTimelineClipDrag"; @@ -45,8 +44,17 @@ interface TimelineCanvasProps extends TimelineLaneBaseProps { export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvasProps) { const { draggedClip, scrollRef, selectedElementIds, displayTrackOrder } = props; - const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } = - useTimelineEditContextOptional(); + const draggedRowIndex = + draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1; + const draggedRowHeight = getTimelineRowHeight(draggedRowIndex, props.rowHeights); + const { + onResizeElement, + onMoveElement, + onToggleTrackHidden, + onTogglePropertyGroupKeyframe, + onRazorSplit, + onRazorSplitAll, + } = useTimelineEditContextOptional(); const beatDragging = usePlayerStore((s) => s.beatDragging); // Scroll a clip into view when the sidebar (asset card) requests a reveal. useTimelineRevealClip(scrollRef); @@ -63,8 +71,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas // The drag ghost follows the cursor freely (both axes) — CapCut-style. The // "magnetic" affordance is a highlight on the destination lane (draggedRowIndex), // which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it. - const draggedRowIndex = - draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1; // Live multi-selection drag: while a selected clip is dragged, ALL selected // clips move together as one rigid formation. The GRABBED clip is the free // ghost below; its co-selected "passengers" slide by the SAME group-clamped @@ -101,7 +107,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas return (
{/* Breathing room between the sticky ruler and the first track lane — the @@ -124,6 +131,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas draggedElement={draggedElement} multiDragPreview={multiDragPreview} onToggleTrackHidden={onToggleTrackHidden} + onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe} onResizeElement={onResizeElement} onMoveElement={onMoveElement} onRazorSplit={onRazorSplit} @@ -148,8 +156,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas key={`gap-${strip.kind}-${strip.track}-${gap.start}`} className="pointer-events-none absolute" style={{ - top: getTimelineRowTop(rowIndex) + CLIP_Y, - left: GUTTER + TRACKS_LEFT_PAD + gap.start * props.pps, + top: getTimelineRowTop(rowIndex, props.rowHeights) + CLIP_Y, + left: props.contentOrigin + gap.start * props.pps, width: Math.max((gap.end - gap.start) * props.pps, 2), height: TRACK_H - CLIP_Y * 2, background: loud ? "rgba(60,230,172,0.18)" : "rgba(60,230,172,0.055)", @@ -166,10 +174,10 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
@@ -280,8 +288,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas className="absolute pointer-events-none" style={{ left: - GUTTER + - TRACKS_LEFT_PAD + + props.contentOrigin + Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps, width: Math.abs(props.rangeSelection.end - props.rangeSelection.start) * props.pps, top: RULER_H, @@ -297,13 +304,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas {/* Playhead — hidden while dragging a beat so its guideline doesn't track the scrub and clutter the beat being moved. Explicit width + the half-head offset baked into getTimelinePlayheadLeft keep the - inner 1px line's CENTER exactly on GUTTER + t * pps (the ruler + inner 1px line's CENTER exactly on contentOrigin + t * pps (the ruler ticks' center), instead of relying on shrink-wrap sizing. */}
; ease?: string; } interface KeyframeCacheEntry { format: string; - keyframes: KeyframeEntry[]; + keyframes: TimelineDiamondKeyframe[]; ease?: string; easeEach?: string; } @@ -32,7 +40,7 @@ interface TimelineClipDiamondsProps { isSelected: boolean; currentPercentage: number; elementId: string; - selectedKeyframes: Set; + selectedKeyframes: ReadonlySet; onClickKeyframe?: (percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; @@ -51,6 +59,19 @@ interface TimelineClipDiamondsProps { suppressClickRef?: React.RefObject; } +interface TimelineDiamondLaneProps extends Omit< + TimelineClipDiamondsProps, + "onClickKeyframe" | "onShiftClickKeyframe" | "onContextMenuKeyframe" | "onMoveKeyframe" +> { + groupAware?: boolean; + globalEase?: string; + onSelectSegment?: (target: TimelineKeyframeTarget) => void; + onClickKeyframe?: (target: TimelineKeyframeTarget) => void; + onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void; + onContextMenuKeyframe?: (e: React.MouseEvent, target: TimelineKeyframeTarget) => void; + onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => void; +} + const DIAMOND_RATIO = 0.8; // Percentage tolerance for rendering keyframes near clip boundaries. Keyframes // slightly outside [0, 100] (from rounding or stale cache during the async @@ -66,7 +87,21 @@ type DragState = { moved: boolean; }; -export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ +function keyframeTarget( + keyframe: TimelineDiamondKeyframe, + groupAware: boolean, +): TimelineKeyframeTarget { + return groupAware + ? { + percentage: keyframe.percentage, + tweenPercentage: keyframe.tweenPercentage, + propertyGroup: keyframe.propertyGroup, + animationId: keyframe.animationId, + } + : { percentage: keyframe.percentage }; +} + +export const TimelineDiamondLane = memo(function TimelineDiamondLane({ keyframesData, clipWidthPx, clipHeightPx, @@ -80,14 +115,21 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ onShiftClickKeyframe, onContextMenuKeyframe, onMoveKeyframe, + onSelectSegment, suppressClickRef, -}: TimelineClipDiamondsProps) { + groupAware = false, + globalEase = "none", +}: TimelineDiamondLaneProps) { // Hooks must run before the early return below. const dragRef = useRef(null); // Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold // (that optimistic hold was the #1763 flake). The atomic move-keyframe commit // on drop re-keys the diamond from source. const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null); + // Index of the segment whose mid-point ease button is revealed on hover, like + // Figma. Null = no segment hovered → no button shown (resting state is just + // the connector line + diamonds). + const [hoveredSegment, setHoveredSegment] = useState(null); // The button element can re-render (reposition/unmount) synchronously from // the state updates onClickKeyframe/onMoveKeyframe trigger, before the // browser gets to auto-synthesize the "click" event that normally follows @@ -108,7 +150,12 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ // When the beat strip occupies the top band, shrink the diamonds and center // them in the remaining bottom region so they don't collide with it. - const diamondSize = Math.round(clipHeightPx * (beatsActive ? 0.45 : DIAMOND_RATIO)); + // One consistent keyframe-diamond size everywhere (clip bars + property lanes), + // matching the property-lane size (LANE_H · ratio). Beat-strip tracks still + // shrink to fit under the strip. + const diamondSize = beatsActive + ? Math.round(clipHeightPx * 0.45) + : Math.round(LANE_H * DIAMOND_RATIO); const half = diamondSize / 2; const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2; const sorted = keyframesData.keyframes @@ -141,32 +188,84 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx)); const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx)); if (x2 - x1 < 1) return null; + const target = keyframeTarget(kf, groupAware); + const ease = kf.ease ?? globalEase; return ( -
+ +
+ {groupAware && onSelectSegment && ( +
setHoveredSegment(i)} + onMouseLeave={() => setHoveredSegment((h) => (h === i ? null : h))} + > + {hoveredSegment === i && ( + + )} +
+ )} + ); })} {sorted.map((kf, i) => { - const kfKey = `${elementId}:${kf.percentage}`; + const target = keyframeTarget(kf, groupAware); + const kfKey = timelineKeyframeSelectionKey(elementId, target); // While dragging this diamond, render it at the live preview clip-%. const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage; - // Center the diamond ON its keyframe %: left = (% · width) − half so the - // diamond's midpoint sits exactly at the percentage. At 0% the midpoint - // is the clip's left edge (the left half overflows, which the - // overflow-visible clip shows) — NOT shifted fully inside. + // Center the diamond ON its keyframe %: left = (% · width) − half, so the + // diamond's midpoint sits exactly on the playhead/ruler x for that time. + // The 0% diamond's left half lands in the reserved left gutter (the + // content origin is inset past the label column, Figma-style) so it stays + // fully visible instead of being clipped by the sticky label column. const leftPx = (renderPct / 100) * clipWidthPx - half; const isKfSelected = selectedKeyframes.has(kfKey); const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5; @@ -212,8 +311,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ if (!d || d.kfKey !== kfKey) { if (e.button !== 0) return; suppressNextClick(); - if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage); - else onClickKeyframe?.(kf.percentage); + if (e.shiftKey) onShiftClickKeyframe?.(target); + else onClickKeyframe?.(target); return; } e.stopPropagation(); @@ -235,14 +334,14 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ // back onto ~the same position — no real retime, so treat it as the // click it was. Otherwise a normal click with a few px of mouse/ // trackpad drift silently does nothing: no selection, no move. - if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage); - else onClickKeyframe?.(kf.percentage); + if (e.shiftKey) onShiftClickKeyframe?.(target); + else onClickKeyframe?.(target); } else if (res.kind === "move" && res.toClipPct != null) { - onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct); + onMoveKeyframe?.(target, res.toClipPct); // A retime still targeted this exact diamond — park/select it at its // new position, same as a plain click, or a drag that actually moved // something looks identical to one that silently did nothing. - onClickKeyframe?.(res.toClipPct); + onClickKeyframe?.({ ...target, percentage: res.toClipPct }); } }; @@ -251,6 +350,10 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ key={`${i}-${kf.percentage}`} type="button" className="absolute" + data-keyframe-group={groupAware ? kf.propertyGroup : undefined} + data-keyframe-percentage={ + groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined + } style={{ left: leftPx, top: centerY, @@ -271,7 +374,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); - onContextMenuKeyframe?.(e, elementId, kf.percentage); + onContextMenuKeyframe?.(e, target); }} title={`${kf.percentage}%`} > @@ -297,3 +400,27 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
); }); + +export const TimelineClipDiamonds = memo(function TimelineClipDiamonds( + props: TimelineClipDiamondsProps, +) { + return ( + props.onClickKeyframe?.(target.percentage)} + onShiftClickKeyframe={(target) => + props.onShiftClickKeyframe?.(props.elementId, target.percentage) + } + onContextMenuKeyframe={(e, target) => + props.onContextMenuKeyframe?.(e, props.elementId, target.percentage) + } + onMoveKeyframe={ + props.onMoveKeyframe + ? (target, toClipPercentage) => + props.onMoveKeyframe?.(props.elementId, target.percentage, toClipPercentage) + : undefined + } + /> + ); +}); diff --git a/packages/studio/src/player/components/TimelineDragGhost.tsx b/packages/studio/src/player/components/TimelineDragGhost.tsx deleted file mode 100644 index 771bddb65d..0000000000 --- a/packages/studio/src/player/components/TimelineDragGhost.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { ReactNode } from "react"; -import { TimelineClip } from "./TimelineClip"; -import { getTimelineEditCapabilities } from "./timelineEditing"; -import { CLIP_Y, TRACK_H } from "./timelineLayout"; -import type { TimelineTheme } from "./timelineTheme"; -import type { TimelineElement } from "../store/playerStore"; - -interface TimelineDragGhostProps { - element: TimelineElement; - position: { left: number; top: number }; - pps: number; - selectedElementId: string | null; - hasCustomContent: boolean; - theme: TimelineTheme; - children: ReactNode; -} - -export function TimelineDragGhost({ - element, - position, - pps, - selectedElementId, - hasCustomContent, - theme, - children, -}: TimelineDragGhostProps) { - return ( -
- {}} - onHoverEnd={() => {}} - onResizeStart={() => {}} - onClick={() => {}} - onDoubleClick={() => {}} - > - {children} - -
- ); -} diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 75069c1a0b..f266331cbc 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -1,12 +1,16 @@ import { type ReactNode } from "react"; -import { Eye, EyeSlash } from "@phosphor-icons/react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; +import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; +import { TimelineTrackHeader } from "./TimelineTrackHeader"; +import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; +import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity"; import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing"; import type { TimelineTheme } from "./timelineTheme"; -import { GUTTER, TRACK_H, TRACKS_LEFT_PAD, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout"; +import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout"; import { usePlayerStore, type TimelineElement, @@ -21,9 +25,9 @@ import { import type { TrackVisualStyle } from "./timelineIcons"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; +import { trackStudioKeyframeLaneExpand } from "../../telemetry/events"; import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit"; import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector"; -import { Music } from "../../icons/SystemIcons"; import { renderClipChildren } from "./timelineClipChildren"; /** @@ -34,12 +38,16 @@ import { renderClipChildren } from "./timelineClipChildren"; */ export interface TimelineLaneBaseProps { pps: number; + contentOrigin: number; + contentGutter: number; trackContentWidth: number; theme: TimelineTheme; displayTrackOrder: number[]; + rowHeights: readonly number[]; trackOrder: number[]; tracks: [number, TimelineElement[]][]; trackStyles: Map; + laneCounts: ReadonlyMap; selectedElementId: string | null; selectedElementIds: Set; hoveredClip: string | null; @@ -69,15 +77,25 @@ export interface TimelineLaneBaseProps { getPreviewElement: (element: TimelineElement) => TimelineElement; getTrackStyle: (tag: string) => TrackVisualStyle; keyframeCache?: Map; + gsapAnimations: Map; selectedKeyframes: Set; currentTime: number; - onClickKeyframe?: (element: TimelineElement, percentage: number) => void; - onShiftClickKeyframe?: (elementId: string, percentage: number) => void; - onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; + onSeek?: (time: number) => void; + onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void; + onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void; + onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void; + onContextMenuKeyframe?: ( + e: React.MouseEvent, + elementId: string, + target: TimelineKeyframeTarget, + ) => void; onMoveKeyframe?: ( elementId: string, fromClipPercentage: number, toClipPercentage: number, + propertyGroup?: string, + tweenPercentage?: number, + animationId?: string, ) => void; onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; /** @@ -94,6 +112,7 @@ interface TimelineLanesProps extends TimelineLaneBaseProps { draggedElement: TimelineElement | null; multiDragPreview: MultiDragPreviewInput | null; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; + onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; onResizeElement: TimelineEditCallbacks["onResizeElement"]; onMoveElement: TimelineEditCallbacks["onMoveElement"]; onRazorSplit: TimelineEditCallbacks["onRazorSplit"]; @@ -102,12 +121,16 @@ interface TimelineLanesProps extends TimelineLaneBaseProps { export function TimelineLanes({ pps, + contentOrigin, + contentGutter, trackContentWidth, theme, displayTrackOrder, + rowHeights, trackOrder, tracks, trackStyles, + laneCounts, selectedElementId, selectedElementIds, hoveredClip, @@ -132,8 +155,11 @@ export function TimelineLanes({ getPreviewElement, getTrackStyle, keyframeCache, + gsapAnimations, selectedKeyframes, currentTime, + onSeek, + onSelectSegment, onClickKeyframe, onShiftClickKeyframe, onContextMenuKeyframe, @@ -142,11 +168,19 @@ export function TimelineLanes({ onContextMenuLane, beatAnalysis, onToggleTrackHidden, + onTogglePropertyGroupKeyframe, onResizeElement, onMoveElement, onRazorSplit, onRazorSplitAll, }: TimelineLanesProps) { + const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); + const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded); + const toggleClipExpandedTracked = (key: string) => { + const willExpand = !expandedClipIds.has(key); + trackStudioKeyframeLaneExpand({ expanded: willExpand }); + toggleClipExpanded(key); + }; return ( <> { @@ -156,7 +190,8 @@ export function TimelineLanes({ // bounded and virtualization's complexity isn't worth it. TODO: revisit and swap // in a virtualizer if editorial workflows ever push very high clip counts. // fallow-ignore-next-line complexity - displayTrackOrder.map((trackNum) => { + displayTrackOrder.map((trackNum, row) => { + const rowHeight = getTimelineRowHeight(row, rowHeights); const els = tracks.find(([t]) => t === trackNum)?.[1] ?? []; const ts = trackStyles.get(trackNum) ?? getTrackStyle(""); const isPendingTrack = @@ -173,58 +208,54 @@ export function TimelineLanes({ : els.some(isMusicTrack)); const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true); const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement); + // The one keyframed element this track shows lanes for (selected, else + // most lanes). A track can hold several elements; scoping to one keeps + // their keyframes from cramming into a single row. + const keyframeClip = STUDIO_KEYFRAMES_ENABLED + ? resolveTrackKeyframeClip(els, laneCounts, selectedElementId, selectedElementIds) + : null; + const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id; + const keyframeClipExpanded = + keyframeClipKey != null && expandedClipIds.has(keyframeClipKey); return ( -
-
+ { + if (keyframeClipKey) { + toggleClipExpandedTracked(keyframeClipKey); + } }} - > - {isAudioTrack && ( -
- {/* Left breathing pad — empty lane surface before t=0, scrolling - with the content (the horizontal TRACKS_TOP_PAD). Sits OUTSIDE - the time-mapped content div so clip/beat/menu math stays - content-relative (clip left = t·pps). */} -