diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/components/graph-editor | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/components/graph-editor')
6 files changed, 1571 insertions, 0 deletions
diff --git a/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx new file mode 100644 index 00000000..7d77b985 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useRef, useState, type PointerEvent } from "react"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { getBezierPoint } from "@/animation/bezier"; +import type { NormalizedCubicBezier } from "@/animation/types"; +import { cn } from "@/utils/ui"; + +const GRAPH_WIDTH = 140; +const GRAPH_HEIGHT = 94; +const GRAPH_PADDING = 12; +const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2; +const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2; +const HANDLE_RADIUS = 3.5; +const ENDPOINT_RADIUS = 2; +const SNAP_THRESHOLD = 0.06; +const SNAP_TARGETS = [0, 1]; +const CURVE_SEGMENTS = 64; +const Y_CLAMP_MIN = -0.5; +const Y_CLAMP_MAX = 1.5; + +type BezierHandle = "c1" | "c2"; + +export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT; + +function snap({ + value, + targets, + isEnabled, +}: { + value: number; + targets: number[]; + isEnabled: boolean; +}) { + if (!isEnabled) return value; + for (const target of targets) { + if (Math.abs(value - target) < SNAP_THRESHOLD) return target; + } + return value; +} + +function toSvgX({ value }: { value: number }) { + return GRAPH_PADDING + value * GRAPH_WIDTH; +} + +function toSvgY({ value }: { value: number }) { + return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT; +} + +function fromSvgX({ svgX }: { svgX: number }) { + return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH)); +} + +function fromSvgY({ svgY }: { svgY: number }) { + return Math.max( + Y_CLAMP_MIN, + Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT), + ); +} + +function curvePath({ curve }: { curve: NormalizedCubicBezier }) { + const points: string[] = []; + for (let i = 0; i <= CURVE_SEGMENTS; i++) { + const progress = i / CURVE_SEGMENTS; + const x = toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) }); + const y = toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) }); + points.push(`${x},${y}`); + } + return `M${points.join("L")}`; +} + +function clampHandleY({ svgY }: { svgY: number }) { + return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY)); +} + +export function BezierGraph({ + value, + onChange, + onChangeEnd, + onCancel, +}: { + value: NormalizedCubicBezier; + onChange?: (value: NormalizedCubicBezier) => void; + onChangeEnd?: (value: NormalizedCubicBezier) => void; + onCancel?: () => void; +}) { + const svgRef = useRef<SVGSVGElement>(null); + const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null); + const isShiftPressedRef = useShiftKey(); + const latestValueRef = useRef(value); + + latestValueRef.current = value; + + function getPointerPosition({ + event, + }: { + event: PointerEvent; + }): { x: number; y: number } { + const svg = svgRef.current; + if (!svg) return { x: 0, y: 0 }; + const rect = svg.getBoundingClientRect(); + const scale = SVG_WIDTH / rect.width; + return { + x: (event.clientX - rect.left) * scale, + y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height), + }; + } + + function onHandlePointerDown({ handle }: { handle: BezierHandle }) { + return (event: PointerEvent<SVGCircleElement>) => { + event.preventDefault(); + event.stopPropagation(); + setActiveHandle(handle); + event.currentTarget.setPointerCapture(event.pointerId); + }; + } + + function onPointerMove({ event }: { event: PointerEvent<SVGSVGElement> }) { + if (!activeHandle) return; + const pointerPos = getPointerPosition({ event }); + const x = fromSvgX({ svgX: pointerPos.x }); + const y = snap({ + value: fromSvgY({ svgY: pointerPos.y }), + targets: SNAP_TARGETS, + isEnabled: !isShiftPressedRef.current, + }); + const next: NormalizedCubicBezier = [...value]; + if (activeHandle === "c1") { + next[0] = x; + next[1] = y; + } else { + next[2] = x; + next[3] = y; + } + latestValueRef.current = next; + onChange?.(next); + } + + function onPointerUp() { + if (!activeHandle) return; + setActiveHandle(null); + onChangeEnd?.(latestValueRef.current); + } + + function onPointerCancel() { + if (!activeHandle) return; + setActiveHandle(null); + onCancel?.(); + } + + const path = curvePath({ curve: value }); + const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) }; + const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) }; + const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) }; + const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) }; + const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) }; + const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) }; + + return ( + <svg + ref={svgRef} + viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`} + className="bg-foreground/3 w-full cursor-crosshair select-none" + onPointerMove={(event) => onPointerMove({ event })} + onPointerUp={onPointerUp} + onPointerCancel={onPointerCancel} + > + <title>Bezier curve editor</title> + <line + x1={p0.x} + y1={p0.y} + x2={p1.x} + y2={p1.y} + className="stroke-foreground/8" + strokeWidth={1} + strokeDasharray="3 3" + /> + <line + x1={p0.x} + y1={p0.y} + x2={c1Clamped.x} + y2={c1Clamped.y} + className="stroke-primary/30" + strokeWidth={1} + /> + <line + x1={p1.x} + y1={p1.y} + x2={c2Clamped.x} + y2={c2Clamped.y} + className="stroke-primary/30" + strokeWidth={1} + /> + <path + d={path} + fill="none" + className="stroke-primary" + strokeWidth={2} + strokeLinecap="round" + /> + <circle + cx={p0.x} + cy={p0.y} + r={ENDPOINT_RADIUS} + className="fill-foreground/20" + /> + <circle + cx={p1.x} + cy={p1.y} + r={ENDPOINT_RADIUS} + className="fill-foreground/20" + /> + <circle + cx={c1Clamped.x} + cy={c1Clamped.y} + r={HANDLE_RADIUS} + className={cn( + "fill-primary cursor-grab", + activeHandle === "c1" && "cursor-grabbing", + )} + onPointerDown={onHandlePointerDown({ handle: "c1" })} + /> + <circle + cx={c2Clamped.x} + cy={c2Clamped.y} + r={HANDLE_RADIUS} + className={cn( + "fill-primary cursor-grab", + activeHandle === "c2" && "cursor-grabbing", + )} + onPointerDown={onHandlePointerDown({ handle: "c2" })} + /> + </svg> + ); +} diff --git a/apps/web/src/timeline/components/graph-editor/custom-presets-store.ts b/apps/web/src/timeline/components/graph-editor/custom-presets-store.ts new file mode 100644 index 00000000..4be2adfd --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/custom-presets-store.ts @@ -0,0 +1,101 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { generateUUID } from "@/utils/id"; +import type { NormalizedCubicBezier } from "@/animation/types"; +import type { EasingPreset } from "./easing-presets"; + +const STORAGE_KEY = "graph-editor-presets"; + +let cachedPresets: EasingPreset[] | null = null; +const listeners = new Set<() => void>(); + +function isValidPresetArray(value: unknown): value is EasingPreset[] { + return ( + Array.isArray(value) && + value.every( + (item) => + typeof item === "object" && + item !== null && + typeof item.id === "string" && + typeof item.label === "string" && + Array.isArray(item.value) && + item.value.length === 4 && + item.value.every((number: unknown) => typeof number === "number"), + ) + ); +} + +function readFromStorage(): EasingPreset[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + return isValidPresetArray(parsed) ? parsed : []; + } catch { + // Silently recover — corrupted localStorage shouldn't crash the editor + return []; + } +} + +function writeToStorage({ presets }: { presets: EasingPreset[] }): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); +} + +function getSnapshot(): EasingPreset[] { + cachedPresets ??= readFromStorage(); + return cachedPresets; +} + +function getServerSnapshot(): EasingPreset[] { + return []; +} + +function notify(): void { + cachedPresets = null; + for (const listener of listeners) { + listener(); + } +} + +function onStorageChange(event: StorageEvent): void { + if (event.key === STORAGE_KEY) notify(); +} + +function subscribe(listener: () => void): () => void { + if (listeners.size === 0 && typeof window !== "undefined") { + window.addEventListener("storage", onStorageChange); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0 && typeof window !== "undefined") { + window.removeEventListener("storage", onStorageChange); + } + }; +} + +export function useCustomPresets(): EasingPreset[] { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +export function savePreset({ value }: { value: NormalizedCubicBezier }): void { + const current = getSnapshot(); + writeToStorage({ + presets: [ + ...current, + { + id: generateUUID(), + label: `Custom ${current.length + 1}`, + value, + isCustom: true, + }, + ], + }); + notify(); +} + +export function removePreset({ id }: { id: string }): void { + writeToStorage({ presets: getSnapshot().filter((preset) => preset.id !== id) }); + notify(); +} diff --git a/apps/web/src/timeline/components/graph-editor/easing-presets.ts b/apps/web/src/timeline/components/graph-editor/easing-presets.ts new file mode 100644 index 00000000..78a605d4 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/easing-presets.ts @@ -0,0 +1,19 @@ +import type { NormalizedCubicBezier } from "@/animation/types"; + +export const PRESET_MATCH_TOLERANCE = 0.02; + +export interface EasingPreset { + id: string; + label: string; + value: NormalizedCubicBezier; + isCustom?: boolean; +} + +export const BUILTIN_PRESETS: EasingPreset[] = [ + { id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] }, + { id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] }, + { id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] }, + { id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] }, + { id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] }, + { id: "linear", label: "Linear", value: [0, 0, 1, 1] }, +]; diff --git a/apps/web/src/timeline/components/graph-editor/popover.tsx b/apps/web/src/timeline/components/graph-editor/popover.tsx new file mode 100644 index 00000000..1b8194c0 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/popover.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useState } from "react"; +import { Popover, PopoverContent } from "@/components/ui/popover"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ArrowDown01Icon, + Delete02Icon, + PlusSignIcon, +} from "@hugeicons/core-free-icons"; +import { getBezierPoint } from "@/animation/bezier"; +import type { NormalizedCubicBezier } from "@/animation/types"; +import type { GraphEditorComponentOption } from "./session"; +import { + BUILTIN_PRESETS, + PRESET_MATCH_TOLERANCE, + type EasingPreset, +} from "./easing-presets"; +import { removePreset, savePreset, useCustomPresets } from "./custom-presets-store"; +import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph"; + +const COLLAPSED_MAX = 6; +const THUMB_SEGMENTS = 24; +const THUMB_WIDTH = 40; +const THUMB_HEIGHT = 22; +const THUMB_PADDING_X = 4; +const THUMB_PADDING_Y = 3; +const COLLAPSED_GRID_MAX_HEIGHT = 120; +const EXPANDED_GRID_MAX_HEIGHT = 240; + +export function GraphEditorPopover({ + children, + side, + open, + onOpenChange, + value, + message, + componentOptions, + activeComponentKey, + onActiveComponentKeyChange, + onPreviewValue, + onCommitValue, + onCancelPreview, +}: { + children: React.ReactNode; + side?: "top" | "bottom" | "left" | "right"; + open?: boolean; + onOpenChange?: (open: boolean) => void; + value: NormalizedCubicBezier | null; + message: string; + componentOptions: GraphEditorComponentOption[]; + activeComponentKey: string | null; + onActiveComponentKeyChange?: (componentKey: string) => void; + onPreviewValue?: (value: NormalizedCubicBezier) => void; + onCommitValue?: (value: NormalizedCubicBezier) => void; + onCancelPreview?: () => void; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const custom = useCustomPresets(); + const allPresets = [...BUILTIN_PRESETS, ...custom]; + const canEdit = value !== null; + const activePresetId = + value == null + ? null + : (allPresets.find((preset) => + preset.value.every( + (presetValue, index) => + Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE, + ), + )?.id ?? null); + + return ( + <Popover + open={open} + onOpenChange={(nextOpen) => { + if (!nextOpen) { + onCancelPreview?.(); + } + onOpenChange?.(nextOpen); + }} + > + {children} + <PopoverContent + side={side} + sideOffset={8} + className="w-60 overflow-hidden px-0" + > + {componentOptions.length > 1 && ( + <div className="border-b px-3 py-2"> + <div className="bg-muted/40 inline-flex rounded-md p-0.5"> + {componentOptions.map((component) => ( + <button + key={component.key} + type="button" + onClick={() => onActiveComponentKeyChange?.(component.key)} + className={cn( + "cursor-pointer rounded-sm px-2 py-1 text-xs font-medium", + activeComponentKey === component.key + ? "bg-background text-foreground shadow-xs" + : "text-muted-foreground hover:text-foreground", + )} + > + {component.label} + </button> + ))} + </div> + </div> + )} + + <div className="px-3 py-3"> + {value ? ( + <BezierGraph + value={value} + onChange={onPreviewValue} + onChangeEnd={onCommitValue} + onCancel={onCancelPreview} + /> + ) : ( + <GraphEditorEmptyState message={message} /> + )} + </div> + + <Tabs variant="underline" defaultValue="presets" className="flex flex-col gap-2"> + <TabsList className="px-3"> + <TabsTrigger value="presets" className="text-xs"> + Presets + </TabsTrigger> + <TabsTrigger value="saved" className="text-xs"> + Saved + </TabsTrigger> + </TabsList> + <TabsContent value="presets" className="px-3 pb-0"> + <ExpandableGrid + isExpanded={isExpanded} + shouldExpand={BUILTIN_PRESETS.length > COLLAPSED_MAX} + onExpand={() => setIsExpanded(true)} + > + {BUILTIN_PRESETS.map((preset) => ( + <PresetItem + key={preset.id} + preset={preset} + isActive={activePresetId === preset.id} + disabled={!canEdit} + onSelect={() => onCommitValue?.(preset.value)} + /> + ))} + </ExpandableGrid> + </TabsContent> + <TabsContent value="saved" className="px-3"> + <div className="grid grid-cols-3 gap-1"> + {custom.map((preset) => ( + <PresetItem + key={preset.id} + preset={preset} + isActive={activePresetId === preset.id} + disabled={!canEdit} + onSelect={() => onCommitValue?.(preset.value)} + onDelete={() => removePreset({ id: preset.id })} + /> + ))} + <button + type="button" + onClick={() => value && savePreset({ value })} + disabled={!canEdit} + className={cn( + "text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1", + canEdit + ? "hover:bg-foreground/5 cursor-pointer" + : "cursor-not-allowed opacity-50", + )} + > + <div className="border-foreground/10 flex aspect-video w-full items-center justify-center rounded-sm border border-dashed"> + <HugeiconsIcon + icon={PlusSignIcon} + className="size-3.5 opacity-40" + /> + </div> + <span className="text-[10px] leading-tight">Save</span> + </button> + </div> + </TabsContent> + </Tabs> + </PopoverContent> + </Popover> + ); +} + +function GraphEditorEmptyState({ message }: { message: string }) { + return ( + <div + style={{ minHeight: BEZIER_GRAPH_MIN_HEIGHT }} + className="bg-muted/20 text-muted-foreground flex items-center justify-center rounded-sm border border-dashed px-3 text-center text-xs leading-relaxed" + > + {message} + </div> + ); +} + +function ExpandableGrid({ + children, + isExpanded, + shouldExpand, + onExpand, +}: { + children: React.ReactNode; + isExpanded: boolean; + shouldExpand: boolean; + onExpand: () => void; +}) { + const gridStyle = shouldExpand + ? isExpanded + ? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const } + : { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const } + : undefined; + + return ( + <div className="relative"> + <div className="grid grid-cols-3 gap-1" style={gridStyle}> + {children} + </div> + {!isExpanded && shouldExpand && ( + <div className="from-popover/0 to-popover absolute inset-x-0 bottom-0 flex h-8 items-center justify-center bg-linear-to-b"> + <Button + variant="ghost" + size="icon" + className="size-5" + onClick={onExpand} + > + <HugeiconsIcon + icon={ArrowDown01Icon} + className="text-muted-foreground size-3" + /> + </Button> + </div> + )} + </div> + ); +} + +function PresetItem({ + preset, + isActive, + onSelect, + onDelete, + disabled, +}: { + preset: EasingPreset; + isActive: boolean; + onSelect: () => void; + onDelete?: () => void; + disabled?: boolean; +}) { + return ( + <button + type="button" + onClick={onSelect} + disabled={disabled} + className={cn( + "group relative flex flex-col items-center gap-1 rounded-sm px-1 py-1", + disabled + ? "cursor-not-allowed opacity-50" + : "hover:bg-foreground/5 cursor-pointer", + isActive && "bg-primary/5! text-primary", + )} + > + <div + className={cn( + "flex aspect-video w-full items-center justify-center rounded-sm bg-foreground/5", + isActive && "bg-primary/5!", + )} + > + <CurveThumb value={preset.value} /> + </div> + <span + className={cn( + "text-[10px] leading-tight", + isActive ? "text-primary" : "text-muted-foreground", + )} + > + {preset.label} + </span> + {onDelete && ( + <Button + variant="destructive" + size="icon" + className="absolute -right-0.5 -top-0.5 hidden size-4.5 rounded-full [&_svg]:size-3 group-hover:flex" + onClick={(event) => { + event.stopPropagation(); + onDelete(); + }} + > + <HugeiconsIcon icon={Delete02Icon} /> + </Button> + )} + </button> + ); +} + +function toThumbX({ value }: { value: number }) { + return THUMB_PADDING_X + value * (THUMB_WIDTH - THUMB_PADDING_X * 2); +} + +function toThumbY({ value }: { value: number }) { + return THUMB_PADDING_Y + (1 - value) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2); +} + +function CurveThumb({ value }: { value: NormalizedCubicBezier }) { + const points: string[] = []; + for (let i = 0; i <= THUMB_SEGMENTS; i++) { + const progress = i / THUMB_SEGMENTS; + const x = toThumbX({ value: getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) }); + const y = toThumbY({ value: getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 }) }); + points.push(`${x},${y}`); + } + return ( + <svg + width={THUMB_WIDTH} + height={THUMB_HEIGHT} + viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`} + > + <title>Curve preset preview</title> + <path + d={`M${points.join("L")}`} + fill="none" + className="stroke-current" + strokeWidth={1.5} + strokeLinecap="round" + /> + </svg> + ); +} diff --git a/apps/web/src/timeline/components/graph-editor/session.ts b/apps/web/src/timeline/components/graph-editor/session.ts new file mode 100644 index 00000000..d7c6b001 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/session.ts @@ -0,0 +1,704 @@ +import { + getCurveHandlesForNormalizedCubicBezier, + getEditableScalarChannels, + getEasingModeForKind, + getNormalizedCubicBezierForScalarSegment, + getScalarKeyframeContext, + updateScalarKeyframeCurve, +} from "@/animation"; +import type { + AnimationPath, + ElementAnimations, + NormalizedCubicBezier, + ScalarCurveKeyframePatch, + ScalarGraphKeyframeContext, + SelectedKeyframeRef, +} from "@/animation/types"; +import type { SceneTracks, TimelineElement } from "@/timeline"; + +const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1]; +const FLAT_VALUE_EPSILON = 1e-6; +const LINEAR_CURVE_EPSILON = 1e-6; + +export type GraphEditorUnavailableReason = + | "no-keyframe-selected" + | "multiple-keyframes-selected" + | "selected-keyframes-span-multiple-elements" + | "selected-keyframes-are-not-adjacent" + | "selected-properties-have-no-shared-component" + | "selected-element-missing" + | "selected-element-has-no-animations" + | "selected-keyframe-has-no-scalar-channel" + | "selected-keyframe-missing-on-channel" + | "selected-keyframe-has-no-next-segment" + | "selected-segment-is-hold" + | "selected-segment-is-flat"; + +export interface GraphEditorComponentOption { + key: string; + label: string; +} + +interface GraphEditorPropertyOption { + key: string; + label: string; + context: ScalarGraphKeyframeContext; + allContexts: ScalarGraphKeyframeContext[]; +} + +export interface GraphEditorResolvedSegment { + propertyPath: SelectedKeyframeRef["propertyPath"]; + keyframeId: string; + context: ScalarGraphKeyframeContext; + allContexts: ScalarGraphKeyframeContext[]; + cubicBezier: NormalizedCubicBezier; + referenceSpanValue: number; +} + +interface GraphEditorBaseSelectionState { + componentOptions: GraphEditorComponentOption[]; + activeComponentKey: string | null; + message: string; +} + +export interface GraphEditorUnavailableState + extends GraphEditorBaseSelectionState { + status: "unavailable"; + reason: GraphEditorUnavailableReason; +} + +export interface GraphEditorReadyState extends GraphEditorBaseSelectionState { + status: "ready"; + trackId: string; + elementId: string; + element: TimelineElement; + segments: GraphEditorResolvedSegment[]; + cubicBezier: NormalizedCubicBezier; +} + +export type GraphEditorSelectionState = + | GraphEditorUnavailableState + | GraphEditorReadyState; + +export interface GraphEditorCurvePatch { + keyframeId: string; + patch: ScalarCurveKeyframePatch; +} + +function createUnavailableState({ + reason, + message, + componentOptions = [], + activeComponentKey = null, +}: { + reason: GraphEditorUnavailableReason; + message: string; + componentOptions?: GraphEditorComponentOption[]; + activeComponentKey?: string | null; +}): GraphEditorUnavailableState { + return { + status: "unavailable", + reason, + message, + componentOptions, + activeComponentKey, + }; +} + +function findElementByKeyframe({ + tracks, + keyframe, +}: { + tracks: SceneTracks; + keyframe: SelectedKeyframeRef; +}): { element: TimelineElement; trackId: string; elementId: string } | null { + for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) { + if (track.id !== keyframe.trackId) { + continue; + } + + const element = track.elements.find( + (trackElement) => trackElement.id === keyframe.elementId, + ); + if (!element) { + return null; + } + + return { + element, + trackId: track.id, + elementId: element.id, + }; + } + + return null; +} + +function findKeyframeTime({ + animations, + propertyPath, + keyframeId, +}: { + animations: ElementAnimations; + propertyPath: AnimationPath; + keyframeId: string; +}): number | null { + const binding = animations.bindings[propertyPath]; + if (!binding) return null; + + for (const component of binding.components) { + const channel = animations.channels[component.channelId]; + if (channel?.kind !== "scalar") continue; + const key = channel.keys.find((k) => k.id === keyframeId); + if (key !== undefined) return key.time; + } + + return null; +} + +function groupSelectedKeyframesByProperty({ + selectedKeyframes, +}: { + selectedKeyframes: SelectedKeyframeRef[]; +}) { + const groups = new Map< + string, + { + trackId: string; + elementId: string; + propertyPath: SelectedKeyframeRef["propertyPath"]; + keyframes: SelectedKeyframeRef[]; + } + >(); + + for (const keyframe of selectedKeyframes) { + const groupKey = `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}`; + const existingGroup = groups.get(groupKey); + if (existingGroup) { + existingGroup.keyframes.push(keyframe); + continue; + } + + groups.set(groupKey, { + trackId: keyframe.trackId, + elementId: keyframe.elementId, + propertyPath: keyframe.propertyPath, + keyframes: [keyframe], + }); + } + + return [...groups.values()]; +} + +function getComponentLabel({ componentKey }: { componentKey: string }): string { + switch (componentKey) { + case "value": + return "Value"; + default: + return componentKey.toUpperCase(); + } +} + +/** + * Returns the absolute value span of the nearest non-flat adjacent segment, + * used as the Y-axis scale when editing a flat segment in the graph editor. + * Falls back to 1.0 if all surrounding segments are also flat. + */ +function getReferenceSpanValue({ + context, +}: { + context: ScalarGraphKeyframeContext; +}): number { + const sorted = [...context.channel.keys].sort((a, b) => a.time - b.time); + const leftIndex = sorted.findIndex((k) => k.id === context.keyframe.id); + const rightIndex = context.nextKey + ? sorted.findIndex((k) => k.id === context.nextKey?.id) + : -1; + + for (let i = leftIndex - 1; i >= 0; i--) { + const span = Math.abs(sorted[i + 1].value - sorted[i].value); + if (span > FLAT_VALUE_EPSILON) return span; + } + + if (rightIndex !== -1) { + for (let i = rightIndex; i < sorted.length - 1; i++) { + const span = Math.abs(sorted[i + 1].value - sorted[i].value); + if (span > FLAT_VALUE_EPSILON) return span; + } + } + + return 1.0; +} + +interface GraphEditorPropertySelection { + propertyPath: SelectedKeyframeRef["propertyPath"]; + keyframeId: string; + secondaryKeyframeId: string | null; + options: GraphEditorPropertyOption[]; +} + +function resolvePropertySelection({ + element, + propertyKeyframes, +}: { + element: TimelineElement; + propertyKeyframes: ReturnType< + typeof groupSelectedKeyframesByProperty + >[number]; +}): + | GraphEditorPropertySelection + | { + reason: GraphEditorUnavailableReason; + message: string; + } { + if (propertyKeyframes.keyframes.length > 2) { + return { + reason: "multiple-keyframes-selected", + message: "Select at most two adjacent keyframes per property.", + }; + } + + if (!element.animations) { + return { + reason: "selected-element-has-no-animations", + message: "The selected keyframe has no editable graph.", + }; + } + + const scalarResult = getEditableScalarChannels({ + animations: element.animations, + propertyPath: propertyKeyframes.propertyPath, + }); + if (!scalarResult || scalarResult.channels.length === 0) { + return { + reason: "selected-keyframe-has-no-scalar-channel", + message: "The selected keyframe has no editable graph channel.", + }; + } + + const primaryKeyframe = propertyKeyframes.keyframes[0]; + let resolvedKeyframeId = primaryKeyframe.keyframeId; + let secondaryKeyframeId = + propertyKeyframes.keyframes.length === 2 + ? propertyKeyframes.keyframes[1].keyframeId + : null; + + if (secondaryKeyframeId !== null) { + const time1 = findKeyframeTime({ + animations: element.animations, + propertyPath: propertyKeyframes.propertyPath, + keyframeId: primaryKeyframe.keyframeId, + }); + const time2 = findKeyframeTime({ + animations: element.animations, + propertyPath: propertyKeyframes.propertyPath, + keyframeId: secondaryKeyframeId, + }); + if (time2 !== null && (time1 === null || time2 < time1)) { + resolvedKeyframeId = secondaryKeyframeId; + secondaryKeyframeId = primaryKeyframe.keyframeId; + } + } + + const { binding: resolvedBinding, channels: scalarChannels } = scalarResult; + const easingMode = getEasingModeForKind(resolvedBinding.kind); + const contexts = scalarChannels.flatMap((channel) => { + const context = getScalarKeyframeContext({ + animations: element.animations, + propertyPath: propertyKeyframes.propertyPath, + componentKey: channel.componentKey, + keyframeId: resolvedKeyframeId, + }); + if (!context) { + return []; + } + + return [ + { + context, + option: { + key: channel.componentKey, + label: getComponentLabel({ componentKey: channel.componentKey }), + }, + }, + ]; + }); + + if (contexts.length === 0) { + return { + reason: "selected-keyframe-missing-on-channel", + message: "The selected keyframe is not editable as a graph segment.", + }; + } + + // For shared-easing bindings (e.g. color), all components always use the same + // curve. Collapse them to a single "value" option so the key is compatible with + // single-component scalar bindings (e.g. opacity), enabling mixed selections. + const options = + easingMode === "shared" + ? [ + { + key: "value", + label: "Curve", + context: contexts[0].context, + allContexts: contexts.map(({ context }) => context), + }, + ] + : contexts.map(({ context, option }) => ({ + key: option.key, + label: option.label, + context, + allContexts: [context], + })); + + return { + propertyPath: propertyKeyframes.propertyPath, + keyframeId: resolvedKeyframeId, + secondaryKeyframeId, + options, + }; +} + +function isLinearCurve({ + cubicBezier, +}: { + cubicBezier: NormalizedCubicBezier; +}): boolean { + return ( + Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON + ); +} + +function resolveSegmentForOption({ + propertySelection, + componentKey, +}: { + propertySelection: GraphEditorPropertySelection; + componentKey: string; +}): + | { + segment: GraphEditorResolvedSegment; + } + | { + reason: GraphEditorUnavailableReason; + message: string; + } { + const option = propertySelection.options.find( + (propertyOption) => propertyOption.key === componentKey, + ); + if (!option) { + return { + reason: "selected-properties-have-no-shared-component", + message: "Selected properties do not share a graph-editable channel.", + }; + } + + if (!option.context.nextKey) { + return { + reason: "selected-keyframe-has-no-next-segment", + message: "Select a keyframe that has an outgoing segment.", + }; + } + + if ( + propertySelection.secondaryKeyframeId !== null && + option.context.nextKey.id !== propertySelection.secondaryKeyframeId + ) { + return { + reason: "selected-keyframes-are-not-adjacent", + message: "Selected keyframes must be adjacent on each property.", + }; + } + + if (option.context.keyframe.segmentToNext === "step") { + return { + reason: "selected-segment-is-hold", + message: "Hold segments have a fixed value - easing has no effect here.", + }; + } + + const referenceSpanValue = getReferenceSpanValue({ context: option.context }); + const cubicBezier = + option.context.keyframe.segmentToNext === "linear" + ? GRAPH_LINEAR_CURVE + : getNormalizedCubicBezierForScalarSegment({ + leftKey: option.context.keyframe, + rightKey: option.context.nextKey, + referenceSpanValue, + }); + if (!cubicBezier) { + return { + reason: "selected-segment-is-flat", + message: + "Cannot edit a segment where both keyframes are at the same time.", + }; + } + + return { + segment: { + propertyPath: propertySelection.propertyPath, + keyframeId: propertySelection.keyframeId, + context: option.context, + allContexts: option.allContexts, + cubicBezier, + referenceSpanValue, + }, + }; +} + +export function resolveGraphEditorSelectionState({ + tracks, + selectedKeyframes, + preferredComponentKey, +}: { + tracks: SceneTracks; + selectedKeyframes: SelectedKeyframeRef[]; + preferredComponentKey?: string | null; +}): GraphEditorSelectionState { + if (selectedKeyframes.length === 0) { + return createUnavailableState({ + reason: "no-keyframe-selected", + message: "Select a keyframe to edit its curve.", + }); + } + + const propertyKeyframes = groupSelectedKeyframesByProperty({ + selectedKeyframes, + }); + const primaryKeyframe = propertyKeyframes[0]?.keyframes[0]; + if (!primaryKeyframe) { + return createUnavailableState({ + reason: "no-keyframe-selected", + message: "Select a keyframe to edit its curve.", + }); + } + + const selectedElement = findElementByKeyframe({ + tracks, + keyframe: primaryKeyframe, + }); + if (!selectedElement) { + return createUnavailableState({ + reason: "selected-element-missing", + message: "The selected keyframe could not be resolved.", + }); + } + + const spansMultipleElements = propertyKeyframes.some( + (propertySelection) => + propertySelection.trackId !== selectedElement.trackId || + propertySelection.elementId !== selectedElement.elementId, + ); + if (spansMultipleElements) { + return createUnavailableState({ + reason: "selected-keyframes-span-multiple-elements", + message: "Selected keyframes must be on the same element.", + }); + } + + const propertySelections = propertyKeyframes.map((propertySelection) => + resolvePropertySelection({ + element: selectedElement.element, + propertyKeyframes: propertySelection, + }), + ); + const unavailablePropertySelection = propertySelections.find( + (propertySelection) => "reason" in propertySelection, + ); + if ( + unavailablePropertySelection && + "reason" in unavailablePropertySelection + ) { + return createUnavailableState({ + reason: unavailablePropertySelection.reason, + message: unavailablePropertySelection.message, + }); + } + + const resolvedPropertySelections = propertySelections.filter( + (propertySelection): propertySelection is GraphEditorPropertySelection => + !("reason" in propertySelection), + ); + const sharedComponentOptions = + resolvedPropertySelections[0]?.options.filter((componentOption) => + resolvedPropertySelections.every((propertySelection) => + propertySelection.options.some( + (option) => option.key === componentOption.key, + ), + ), + ) ?? []; + const componentOptions = sharedComponentOptions.map(({ key, label }) => ({ + key, + label, + })); + if (componentOptions.length === 0) { + return createUnavailableState({ + reason: "selected-properties-have-no-shared-component", + message: "Selected properties do not share a graph-editable channel.", + }); + } + + // Try each component option in preference order (preferred first, then the rest) + // and stop at the first key where every property resolves to a valid segment. + // This single pass both selects the active key and produces the segment list. + const candidateKeys = [ + ...(preferredComponentKey && + componentOptions.some((option) => option.key === preferredComponentKey) + ? [preferredComponentKey] + : []), + ...componentOptions + .filter((option) => option.key !== preferredComponentKey) + .map((option) => option.key), + ]; + + let activeComponentKey = componentOptions[0].key; + let segmentResults: ReturnType<typeof resolveSegmentForOption>[] = []; + + for (const candidateKey of candidateKeys) { + const results = resolvedPropertySelections.map((propertySelection) => + resolveSegmentForOption({ + propertySelection, + componentKey: candidateKey, + }), + ); + activeComponentKey = candidateKey; + segmentResults = results; + if (results.every((result) => "segment" in result)) { + break; + } + } + + const unavailableSegment = segmentResults.find( + (result) => !("segment" in result), + ); + if (unavailableSegment && !("segment" in unavailableSegment)) { + return createUnavailableState({ + reason: unavailableSegment.reason, + message: unavailableSegment.message, + componentOptions, + activeComponentKey, + }); + } + + const segments = segmentResults.flatMap((result) => + "segment" in result ? [result.segment] : [], + ); + const primarySegment = segments[0]; + if (!primarySegment) { + return createUnavailableState({ + reason: "selected-keyframe-missing-on-channel", + message: "The selected keyframe is not editable as a graph segment.", + componentOptions, + activeComponentKey, + }); + } + + return { + status: "ready", + message: + segments.length === 1 + ? "Edit graph" + : `Edit graph for ${segments.length} properties`, + componentOptions, + activeComponentKey, + trackId: selectedElement.trackId, + elementId: selectedElement.elementId, + element: selectedElement.element, + segments, + cubicBezier: primarySegment.cubicBezier, + }; +} + +export function buildGraphEditorCurvePatches({ + context, + cubicBezier, + referenceSpanValue, +}: { + context: ScalarGraphKeyframeContext; + cubicBezier: NormalizedCubicBezier; + referenceSpanValue: number; +}): GraphEditorCurvePatch[] | null { + if (!context.nextKey) { + return null; + } + + if (isLinearCurve({ cubicBezier })) { + return [ + { + keyframeId: context.keyframe.id, + patch: { + segmentToNext: "linear", + rightHandle: null, + }, + }, + { + keyframeId: context.nextKey.id, + patch: { + leftHandle: null, + }, + }, + ]; + } + + const handles = getCurveHandlesForNormalizedCubicBezier({ + leftKey: context.keyframe, + rightKey: context.nextKey, + cubicBezier, + referenceSpanValue, + }); + if (!handles) { + return null; + } + + return [ + { + keyframeId: context.keyframe.id, + patch: { + segmentToNext: "bezier", + rightHandle: handles.rightHandle, + }, + }, + { + keyframeId: context.nextKey.id, + patch: { + leftHandle: handles.leftHandle, + }, + }, + ]; +} + +export function applyGraphEditorCurvePreview({ + animations, + context, + cubicBezier, + referenceSpanValue, +}: { + animations: ElementAnimations | undefined; + context: ScalarGraphKeyframeContext; + cubicBezier: NormalizedCubicBezier; + referenceSpanValue: number; +}): ElementAnimations | undefined { + const patches = buildGraphEditorCurvePatches({ + context, + cubicBezier, + referenceSpanValue, + }); + if (!patches) { + return animations; + } + + return patches.reduce<ElementAnimations | undefined>( + (nextAnimations, { keyframeId, patch }) => + updateScalarKeyframeCurve({ + animations: nextAnimations, + propertyPath: context.propertyPath, + componentKey: context.componentKey, + keyframeId, + patch, + }), + animations, + ); +} diff --git a/apps/web/src/timeline/components/graph-editor/use-controller.ts b/apps/web/src/timeline/components/graph-editor/use-controller.ts new file mode 100644 index 00000000..ea091485 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/use-controller.ts @@ -0,0 +1,178 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEditor } from "@/editor/use-editor"; +import { registerCanceller } from "@/editor/cancel-interaction"; +import type { NormalizedCubicBezier } from "@/animation/types"; +import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection"; +import { + applyGraphEditorCurvePreview, + buildGraphEditorCurvePatches, + resolveGraphEditorSelectionState, + type GraphEditorSelectionState, +} from "./session"; + +export function useGraphEditorController() { + const editor = useEditor(); + const renderTracks = useEditor( + (currentEditor) => + currentEditor.timeline.getPreviewTracks() ?? + currentEditor.scenes.getActiveScene().tracks, + ); + const { selectedKeyframes } = useKeyframeSelection(); + const [open, setOpen] = useState(false); + const [activeComponentKey, setActiveComponentKey] = useState<string | null>( + null, + ); + const hasPreviewRef = useRef(false); + + const state = useMemo<GraphEditorSelectionState>( + () => + resolveGraphEditorSelectionState({ + tracks: renderTracks, + selectedKeyframes, + preferredComponentKey: activeComponentKey, + }), + [activeComponentKey, renderTracks, selectedKeyframes], + ); + + const stateKey = + state.status === "ready" + ? `${state.trackId}:${state.elementId}:${state.activeComponentKey}:${state.segments + .map( + (segment) => + `${segment.propertyPath}:${segment.keyframeId}:${segment.context.componentKey}`, + ) + .join("|")}` + : `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`; + const previousStateKeyRef = useRef(stateKey); + + const discardPreview = useCallback(() => { + if (!hasPreviewRef.current) { + return; + } + + editor.timeline.discardPreview(); + hasPreviewRef.current = false; + }, [editor]); + + useEffect(() => { + if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) { + discardPreview(); + } + + previousStateKeyRef.current = stateKey; + }, [discardPreview, stateKey]); + + useEffect(() => { + if (!open) { + return; + } + + return registerCanceller({ + fn: () => { + discardPreview(); + setOpen(false); + }, + }); + }, [discardPreview, open]); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + discardPreview(); + } + + setOpen(nextOpen); + }, + [discardPreview], + ); + + const handleActiveComponentKeyChange = useCallback( + (nextComponentKey: string) => { + discardPreview(); + setActiveComponentKey(nextComponentKey); + }, + [discardPreview], + ); + + const handlePreviewValue = useCallback( + (nextValue: NormalizedCubicBezier) => { + if (state.status !== "ready") { + return; + } + + const nextAnimations = state.segments.reduce( + (animations, segment) => + segment.allContexts.reduce( + (nextAnimationsForSegment, context) => + applyGraphEditorCurvePreview({ + animations: nextAnimationsForSegment, + context, + cubicBezier: nextValue, + referenceSpanValue: segment.referenceSpanValue, + }), + animations, + ), + state.element.animations, + ); + editor.timeline.previewElements({ + updates: [ + { + trackId: state.trackId, + elementId: state.elementId, + updates: { animations: nextAnimations }, + }, + ], + }); + hasPreviewRef.current = true; + }, + [editor, state], + ); + + const handleCommitValue = useCallback( + (nextValue: NormalizedCubicBezier) => { + if (state.status !== "ready") { + return; + } + + editor.timeline.updateKeyframeCurves({ + keyframes: state.segments.flatMap((segment) => { + const patches = buildGraphEditorCurvePatches({ + context: segment.context, + cubicBezier: nextValue, + referenceSpanValue: segment.referenceSpanValue, + }); + if (!patches) { + return []; + } + + return segment.allContexts.flatMap((context) => + patches.map(({ keyframeId, patch }) => ({ + trackId: state.trackId, + elementId: state.elementId, + propertyPath: segment.propertyPath, + componentKey: context.componentKey, + keyframeId, + patch, + })), + ); + }), + }); + hasPreviewRef.current = false; + }, + [editor, state], + ); + + return { + open, + onOpenChange: handleOpenChange, + canOpen: state.status === "ready", + tooltip: state.status === "ready" ? "Open graph editor" : state.message, + state, + onActiveComponentKeyChange: handleActiveComponentKeyChange, + onPreviewValue: handlePreviewValue, + onCommitValue: handleCommitValue, + onCancelPreview: discardPreview, + }; +} |
