diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx new file mode 100644 index 00000000..32f39174 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useEffect, useRef, useState, type PointerEvent } from "react"; +import { getBezierPoint } from "@/lib/animation/bezier"; +import type { NormalizedCubicBezier } from "@/lib/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; + points.push( + `${toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) })},${toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) })}`, + ); + } + 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(null); + const [activeHandle, setActiveHandle] = useState(null); + const isShiftPressedRef = useRef(false); + const latestValueRef = useRef(value); + + latestValueRef.current = value; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Shift") isShiftPressedRef.current = true; + }; + const onKeyUp = (event: KeyboardEvent) => { + if (event.key === "Shift") isShiftPressedRef.current = false; + }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + }; + }, []); + + 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) => { + event.preventDefault(); + event.stopPropagation(); + setActiveHandle(handle); + event.currentTarget.setPointerCapture(event.pointerId); + }; + } + + function onPointerMove({ event }: { event: PointerEvent }) { + 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 ( + onPointerMove({ event })} + onPointerUp={onPointerUp} + onPointerCancel={onPointerCancel} + > + Bezier curve editor + + + + + + + + + + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx new file mode 100644 index 00000000..86665392 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx @@ -0,0 +1,328 @@ +"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 "@/lib/animation/bezier"; +import type { NormalizedCubicBezier } from "@/lib/animation/types"; +import type { GraphEditorComponentOption } from "./session"; +import { + BUILTIN_PRESETS, + PRESET_MATCH_TOLERANCE, + removePreset, + savePreset, + type EasingPreset, + useCustomPresets, +} from "./presets"; +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 ( + { + if (!nextOpen) { + onCancelPreview?.(); + } + onOpenChange?.(nextOpen); + }} + > + {children} + + {componentOptions.length > 1 && ( +
+
+ {componentOptions.map((component) => ( + + ))} +
+
+ )} + +
+ {value ? ( + + ) : ( + + )} +
+ + + + + Presets + + + Saved + + + + COLLAPSED_MAX} + onExpand={() => setIsExpanded(true)} + > + {BUILTIN_PRESETS.map((preset) => ( + onCommitValue?.(preset.value)} + /> + ))} + + + +
+ {custom.map((preset) => ( + onCommitValue?.(preset.value)} + onDelete={() => removePreset(preset.id)} + /> + ))} + +
+
+
+
+
+ ); +} + +function GraphEditorEmptyState({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +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 ( +
+
+ {children} +
+ {!isExpanded && shouldExpand && ( +
+ +
+ )} +
+ ); +} + +function PresetItem({ + preset, + isActive, + onSelect, + onDelete, + disabled, +}: { + preset: EasingPreset; + isActive: boolean; + onSelect: () => void; + onDelete?: () => void; + disabled?: boolean; +}) { + return ( + + )} + + ); +} + +function CurveThumb({ value }: { value: NormalizedCubicBezier }) { + const points: string[] = []; + for (let i = 0; i <= THUMB_SEGMENTS; i++) { + const progress = i / THUMB_SEGMENTS; + points.push( + `${THUMB_PADDING_X + getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) * (THUMB_WIDTH - THUMB_PADDING_X * 2)},${THUMB_PADDING_Y + (1 - getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 })) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2)}`, + ); + } + return ( + + Curve preset preview + + + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts new file mode 100644 index 00000000..cbcdf566 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts @@ -0,0 +1,90 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import type { NormalizedCubicBezier } from "@/lib/animation/types"; + +const STORAGE_KEY = "opencut:graph-editor-presets"; +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] }, +]; + +let cachedPresets: EasingPreset[] | null = null; +const listeners = new Set<() => void>(); + +function readFromStorage(): EasingPreset[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + // JSON.parse can throw if the stored value is corrupted + return raw ? (JSON.parse(raw) as EasingPreset[]) : []; + } catch { + return []; + } +} + +function writeToStorage(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 subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +if (typeof window !== "undefined") { + window.addEventListener("storage", (event) => { + if (event.key === STORAGE_KEY) notify(); + }); +} + +export function useCustomPresets(): EasingPreset[] { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +export function savePreset(value: NormalizedCubicBezier): void { + const current = getSnapshot(); + writeToStorage([ + ...current, + { + id: `custom-${Date.now()}`, + label: `Custom ${current.length + 1}`, + value, + isCustom: true, + }, + ]); + notify(); +} + +export function removePreset(id: string): void { + writeToStorage(getSnapshot().filter((preset) => preset.id !== id)); + notify(); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts new file mode 100644 index 00000000..148fa7b4 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts @@ -0,0 +1,448 @@ +"use client"; + +import { + getCurveHandlesForNormalizedCubicBezier, + getEditableScalarChannels, + getNormalizedCubicBezierForScalarSegment, + getScalarKeyframeContext, + updateScalarKeyframeCurve, +} from "@/lib/animation"; +import type { + AnimationPath, + ElementAnimations, + NormalizedCubicBezier, + ScalarCurveKeyframePatch, + ScalarGraphKeyframeContext, + SelectedKeyframeRef, +} from "@/lib/animation/types"; +import type { TimelineElement, TimelineTrack } from "@/lib/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-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 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; + propertyPath: SelectedKeyframeRef["propertyPath"]; + keyframeId: string; + element: TimelineElement; + context: ScalarGraphKeyframeContext; + 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: TimelineTrack[]; + keyframe: SelectedKeyframeRef; +}): { element: TimelineElement; trackId: string; elementId: string } | null { + for (const track of tracks) { + 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 getComponentLabel({ componentKey }: { componentKey: string }): string { + switch (componentKey) { + case "value": + return "Value"; + default: + return componentKey.toUpperCase(); + } +} + +function isFlatSegment({ + context, +}: { + context: ScalarGraphKeyframeContext; +}): boolean { + if (!context.nextKey) { + return true; + } + + return ( + Math.abs(context.nextKey.value - context.keyframe.value) <= + FLAT_VALUE_EPSILON + ); +} + +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 + ); +} + +export function resolveGraphEditorSelectionState({ + tracks, + selectedKeyframes, + preferredComponentKey, +}: { + tracks: TimelineTrack[]; + selectedKeyframes: SelectedKeyframeRef[]; + preferredComponentKey?: string | null; +}): GraphEditorSelectionState { + if (selectedKeyframes.length === 0) { + return createUnavailableState({ + reason: "no-keyframe-selected", + message: "Select a keyframe to edit its curve.", + }); + } + + if (selectedKeyframes.length > 2) { + return createUnavailableState({ + reason: "multiple-keyframes-selected", + message: "Select one or two adjacent keyframes to edit a curve.", + }); + } + + if (selectedKeyframes.length === 2) { + const [kf1, kf2] = selectedKeyframes; + if ( + kf1.trackId !== kf2.trackId || + kf1.elementId !== kf2.elementId || + kf1.propertyPath !== kf2.propertyPath + ) { + return createUnavailableState({ + reason: "multiple-keyframes-selected", + message: "Selected keyframes must be on the same element and property.", + }); + } + } + + const primaryKeyframe = selectedKeyframes[0]; + const secondaryKeyframeId = + selectedKeyframes.length === 2 ? selectedKeyframes[1].keyframeId : null; + + const selectedElement = findElementByKeyframe({ + tracks, + keyframe: primaryKeyframe, + }); + if (!selectedElement) { + return createUnavailableState({ + reason: "selected-element-missing", + message: "The selected keyframe could not be resolved.", + }); + } + + if (!selectedElement.element.animations) { + return createUnavailableState({ + reason: "selected-element-has-no-animations", + message: "The selected keyframe has no editable graph.", + }); + } + + const scalarChannels = getEditableScalarChannels({ + animations: selectedElement.element.animations, + propertyPath: primaryKeyframe.propertyPath, + }); + if (scalarChannels.length === 0) { + return createUnavailableState({ + reason: "selected-keyframe-has-no-scalar-channel", + message: "The selected keyframe has no editable graph channel.", + }); + } + + // When 2 keyframes are selected, resolve the earlier one as the outgoing-segment + // anchor so the graph editor edits the curve between the two selected keyframes. + let resolvedKeyframeId = primaryKeyframe.keyframeId; + if (secondaryKeyframeId !== null) { + const time1 = findKeyframeTime({ + animations: selectedElement.element.animations, + propertyPath: primaryKeyframe.propertyPath, + keyframeId: primaryKeyframe.keyframeId, + }); + const time2 = findKeyframeTime({ + animations: selectedElement.element.animations, + propertyPath: primaryKeyframe.propertyPath, + keyframeId: secondaryKeyframeId, + }); + if (time2 !== null && (time1 === null || time2 < time1)) { + resolvedKeyframeId = secondaryKeyframeId; + } + } + + const contexts = scalarChannels.flatMap((channel) => { + const context = getScalarKeyframeContext({ + animations: selectedElement.element.animations, + propertyPath: primaryKeyframe.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 createUnavailableState({ + reason: "selected-keyframe-missing-on-channel", + message: "The selected keyframe is not editable as a graph segment.", + }); + } + + const nextSegmentContexts = contexts.filter( + ({ context }) => context.nextKey !== null, + ); + const preferredContext = + contexts.find(({ option }) => option.key === preferredComponentKey) ?? null; + const activeContext = + preferredContext ?? nextSegmentContexts[0] ?? contexts[0]; + const componentOptions = contexts.map(({ option }) => option); + + if (!activeContext.context.nextKey) { + return createUnavailableState({ + reason: "selected-keyframe-has-no-next-segment", + message: "Select a keyframe that has an outgoing segment.", + componentOptions, + activeComponentKey: activeContext.option.key, + }); + } + + if (isFlatSegment({ context: activeContext.context })) { + return createUnavailableState({ + reason: "selected-segment-is-flat", + message: "Flat segments are not graph-editable in this popover yet.", + componentOptions, + activeComponentKey: activeContext.option.key, + }); + } + + if (activeContext.context.keyframe.segmentToNext === "step") { + return createUnavailableState({ + reason: "selected-segment-is-hold", + message: "Hold segments are not graph-editable in this popover yet.", + componentOptions, + activeComponentKey: activeContext.option.key, + }); + } + + const cubicBezier = + activeContext.context.keyframe.segmentToNext === "linear" + ? GRAPH_LINEAR_CURVE + : getNormalizedCubicBezierForScalarSegment({ + leftKey: activeContext.context.keyframe, + rightKey: activeContext.context.nextKey, + }); + if (!cubicBezier) { + return createUnavailableState({ + reason: "selected-segment-is-flat", + message: "The selected segment cannot be represented in this graph view.", + componentOptions, + activeComponentKey: activeContext.option.key, + }); + } + + return { + status: "ready", + message: "Edit graph", + componentOptions, + activeComponentKey: activeContext.option.key, + trackId: selectedElement.trackId, + elementId: selectedElement.elementId, + propertyPath: primaryKeyframe.propertyPath, + keyframeId: resolvedKeyframeId, + element: selectedElement.element, + context: activeContext.context, + cubicBezier, + }; +} + +export function buildGraphEditorCurvePatches({ + context, + cubicBezier, +}: { + context: ScalarGraphKeyframeContext; + cubicBezier: NormalizedCubicBezier; +}): 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, + }); + 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, +}: { + animations: ElementAnimations | undefined; + context: ScalarGraphKeyframeContext; + cubicBezier: NormalizedCubicBezier; +}): ElementAnimations | undefined { + const patches = buildGraphEditorCurvePatches({ + context, + cubicBezier, + }); + if (!patches) { + return animations; + } + + return patches.reduce( + (nextAnimations, { keyframeId, patch }) => + updateScalarKeyframeCurve({ + animations: nextAnimations, + propertyPath: context.propertyPath, + componentKey: context.componentKey, + keyframeId, + patch, + }), + animations, + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts new file mode 100644 index 00000000..3b7520e6 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts @@ -0,0 +1,153 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { registerCanceller } from "@/lib/cancel-interaction"; +import type { NormalizedCubicBezier } from "@/lib/animation/types"; +import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; +import { + applyGraphEditorCurvePreview, + buildGraphEditorCurvePatches, + resolveGraphEditorSelectionState, + type GraphEditorSelectionState, +} from "./session"; + +export function useGraphEditorController() { + const editor = useEditor(); + const renderTracks = useEditor((currentEditor) => + currentEditor.timeline.getRenderTracks(), + ); + const { selectedKeyframes } = useKeyframeSelection(); + const [open, setOpen] = useState(false); + const [activeComponentKey, setActiveComponentKey] = useState( + null, + ); + const hasPreviewRef = useRef(false); + + const state = useMemo( + () => + resolveGraphEditorSelectionState({ + tracks: renderTracks, + selectedKeyframes, + preferredComponentKey: activeComponentKey, + }), + [activeComponentKey, renderTracks, selectedKeyframes], + ); + + const stateKey = + state.status === "ready" + ? `${state.trackId}:${state.elementId}:${state.propertyPath}:${state.keyframeId}:${state.activeComponentKey}` + : `${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], + ); + + function handlePreviewValue(nextValue: NormalizedCubicBezier) { + if (state.status !== "ready") { + return; + } + + const nextAnimations = applyGraphEditorCurvePreview({ + animations: state.element.animations, + context: state.context, + cubicBezier: nextValue, + }); + editor.timeline.previewElements({ + updates: [ + { + trackId: state.trackId, + elementId: state.elementId, + updates: { + animations: nextAnimations, + }, + }, + ], + }); + hasPreviewRef.current = true; + } + + function handleCommitValue(nextValue: NormalizedCubicBezier) { + if (state.status !== "ready") { + return; + } + + const patches = buildGraphEditorCurvePatches({ + context: state.context, + cubicBezier: nextValue, + }); + if (!patches) { + return; + } + + editor.timeline.updateKeyframeCurves({ + keyframes: patches.map(({ keyframeId, patch }) => ({ + trackId: state.trackId, + elementId: state.elementId, + propertyPath: state.propertyPath, + componentKey: state.context.componentKey, + keyframeId, + patch, + })), + }); + hasPreviewRef.current = false; + } + + 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, + }; +} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index cb636d3c..80ea10d0 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -14,9 +14,7 @@ import { SplitButtonSeparator, } from "@/components/ui/split-button"; import { Slider } from "@/components/ui/slider"; -import { - TIMELINE_ZOOM_BUTTON_FACTOR, -} from "./interaction"; +import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction"; import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale"; import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils"; import { ScenesView } from "@/components/editor/scenes-view"; @@ -36,7 +34,6 @@ import { SnowIcon, ScissorIcon, MagnetIcon, - Link04Icon, SearchAddIcon, SearchMinusIcon, Copy01Icon, @@ -49,7 +46,9 @@ import { } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { OcRippleIcon } from "@/components/icons"; - +import { GraphEditorPopover } from "./graph-editor/popover"; +import { PopoverTrigger } from "@/components/ui/popover"; +import { useGraphEditorController } from "./graph-editor/use-controller"; export function TimelineToolbar({ zoomLevel, @@ -63,10 +62,7 @@ export function TimelineToolbar({ const handleZoom = ({ direction }: { direction: "in" | "out" }) => { const newZoomLevel = direction === "in" - ? Math.min( - TIMELINE_ZOOM_MAX, - zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR, - ) + ? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR) : Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR); setZoomLevel({ zoom: newZoomLevel }); }; @@ -91,8 +87,11 @@ export function TimelineToolbar({ function ToolbarLeftSection() { const editor = useEditor(); - const mediaAssets = useEditor((currentEditor) => currentEditor.media.getAssets()); + const mediaAssets = useEditor((currentEditor) => + currentEditor.media.getAssets(), + ); const { selectedElements } = useElementSelection(); + const graphEditor = useGraphEditorController(); const isCurrentlyBookmarked = useEditor((e) => e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }), ); @@ -164,11 +163,11 @@ function ToolbarLeftSection() { /> - } + icon={ + + } tooltip={sourceAudioLabel} disabled={!canToggleSelectedSourceAudio} onClick={({ event }) => @@ -212,13 +211,35 @@ function ToolbarLeftSection() { /> - + } - tooltip="Open graph editor" - onClick={() => {}} + tooltip={graphEditor.tooltip} + disabled={!graphEditor.canOpen} + buttonWrapper={(button) => + graphEditor.canOpen ? ( + {button} + ) : ( + button + ) + } /> - + ); @@ -338,12 +359,17 @@ function ToolbarButton({ {icon} ); + const trigger = disabled ? ( + {button} + ) : buttonWrapper ? ( + buttonWrapper(button) + ) : ( + button + ); return ( - - {buttonWrapper ? buttonWrapper(button) : button} - + {trigger} {tooltip} ); diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index 4fb028c8..5e70f88d 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -72,7 +72,7 @@ const TabsContent = React.forwardRef< ; + }): void { + if (keyframes.length === 0) { + return; + } + + const commands = keyframes.map( + ({ + trackId, + elementId, + propertyPath, + componentKey, + keyframeId, + patch, + }) => + new UpdateScalarKeyframeCurveCommand({ + trackId, + elementId, + propertyPath, + componentKey, + keyframeId, + patch, + }), + ); + const command = + commands.length === 1 ? commands[0] : new BatchCommand(commands); + this.editor.command.execute({ command }); + } + upsertEffectParamKeyframe({ trackId, elementId, diff --git a/apps/web/src/lib/animation/curve-bridge.ts b/apps/web/src/lib/animation/curve-bridge.ts new file mode 100644 index 00000000..55f96933 --- /dev/null +++ b/apps/web/src/lib/animation/curve-bridge.ts @@ -0,0 +1,82 @@ +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { + getDefaultLeftHandle, + getDefaultRightHandle, +} from "@/lib/animation/bezier"; +import type { + CurveHandle, + NormalizedCubicBezier, + ScalarAnimationKey, +} from "@/lib/animation/types"; + +const VALUE_EPSILON = 1e-6; + +function clamp01({ value }: { value: number }): number { + return Math.max(0, Math.min(1, value)); +} + +export function getNormalizedCubicBezierForScalarSegment({ + leftKey, + rightKey, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}): NormalizedCubicBezier | null { + const spanTime = rightKey.time - leftKey.time; + const spanValue = rightKey.value - leftKey.value; + if ( + Math.abs(spanTime) <= TIME_EPSILON_SECONDS || + Math.abs(spanValue) <= VALUE_EPSILON + ) { + return null; + } + + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + + return [ + clamp01({ value: rightHandle.dt / spanTime }), + rightHandle.dv / spanValue, + clamp01({ value: 1 + leftHandle.dt / spanTime }), + 1 + leftHandle.dv / spanValue, + ]; +} + +export function getCurveHandlesForNormalizedCubicBezier({ + leftKey, + rightKey, + cubicBezier, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; + cubicBezier: NormalizedCubicBezier; +}): { + rightHandle: CurveHandle; + leftHandle: CurveHandle; +} | null { + const spanTime = rightKey.time - leftKey.time; + const spanValue = rightKey.value - leftKey.value; + if ( + Math.abs(spanTime) <= TIME_EPSILON_SECONDS || + Math.abs(spanValue) <= VALUE_EPSILON + ) { + return null; + } + + const [rawX1, y1, rawX2, y2] = cubicBezier; + const x1 = clamp01({ value: rawX1 }); + const x2 = clamp01({ value: rawX2 }); + + return { + rightHandle: { + dt: spanTime * x1, + dv: spanValue * y1, + }, + leftHandle: { + dt: spanTime * (x2 - 1), + dv: spanValue * (y2 - 1), + }, + }; +} diff --git a/apps/web/src/lib/animation/graph-channels.ts b/apps/web/src/lib/animation/graph-channels.ts new file mode 100644 index 00000000..240b00ed --- /dev/null +++ b/apps/web/src/lib/animation/graph-channels.ts @@ -0,0 +1,95 @@ +import type { + AnimationPath, + ElementAnimations, + ScalarAnimationChannel, + ScalarGraphChannel, + ScalarGraphKeyframeContext, +} from "@/lib/animation/types"; + +function isScalarAnimationChannel( + channel: ElementAnimations["channels"][string], +): channel is ScalarAnimationChannel { + return channel?.kind === "scalar"; +} + +export function getEditableScalarChannels({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; +}): ScalarGraphChannel[] { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return []; + } + + return binding.components.flatMap((component) => { + const channel = animations?.channels[component.channelId]; + if (!isScalarAnimationChannel(channel)) { + return []; + } + + return [ + { + propertyPath, + componentKey: component.key, + channelId: component.channelId, + channel, + }, + ]; + }); +} + +export function getEditableScalarChannel({ + animations, + propertyPath, + componentKey, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; +}): ScalarGraphChannel | null { + return ( + getEditableScalarChannels({ + animations, + propertyPath, + }).find((channel) => channel.componentKey === componentKey) ?? null + ); +} + +export function getScalarKeyframeContext({ + animations, + propertyPath, + componentKey, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; +}): ScalarGraphKeyframeContext | null { + const scalarChannel = getEditableScalarChannel({ + animations, + propertyPath, + componentKey, + }); + if (!scalarChannel) { + return null; + } + + const keyframeIndex = scalarChannel.channel.keys.findIndex( + (keyframe) => keyframe.id === keyframeId, + ); + if (keyframeIndex < 0) { + return null; + } + + return { + ...scalarChannel, + keyframe: scalarChannel.channel.keys[keyframeIndex], + keyframeIndex, + previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null, + nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null, + }; +} diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts index 9cb85d2a..7d2646d9 100644 --- a/apps/web/src/lib/animation/index.ts +++ b/apps/web/src/lib/animation/index.ts @@ -12,8 +12,10 @@ export { getChannel, removeElementKeyframe, retimeElementKeyframe, + setBindingComponentChannel, setChannel, splitAnimationsAtTime, + updateScalarKeyframeCurve, upsertElementKeyframe, upsertPathKeyframe, } from "./keyframes"; @@ -46,6 +48,17 @@ export { hasKeyframesForPath, } from "./keyframe-query"; +export { + getEditableScalarChannel, + getEditableScalarChannels, + getScalarKeyframeContext, +} from "./graph-channels"; + +export { + getCurveHandlesForNormalizedCubicBezier, + getNormalizedCubicBezierForScalarSegment, +} from "./curve-bridge"; + export { buildGraphicParamPath, isGraphicParamPath, diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts index 8c17f8a6..7eaa5ed6 100644 --- a/apps/web/src/lib/animation/keyframes.ts +++ b/apps/web/src/lib/animation/keyframes.ts @@ -11,6 +11,7 @@ import type { ElementAnimations, ScalarAnimationChannel, ScalarAnimationKey, + ScalarCurveKeyframePatch, ScalarSegmentType, } from "@/lib/animation/types"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; @@ -209,7 +210,7 @@ function getBinding({ propertyPath, }: { animations: ElementAnimations | undefined; - propertyPath: string; + propertyPath: AnimationPath; }): AnimationBindingInstance | undefined { return animations?.bindings[propertyPath]; } @@ -224,6 +225,16 @@ function getChannelById({ return animations?.channels[channelId]; } +function getBindingComponent({ + binding, + componentKey, +}: { + binding: AnimationBindingInstance; + componentKey: string; +}) { + return binding.components.find((component) => component.key === componentKey) ?? null; +} + function getTargetKeyMetadata({ channel, time, @@ -402,7 +413,7 @@ export function getChannel({ propertyPath, }: { animations: ElementAnimations | undefined; - propertyPath: string; + propertyPath: AnimationPath; }): AnimationChannel | undefined { const binding = getBinding({ animations, propertyPath }); const primaryChannelId = @@ -651,7 +662,7 @@ export function setChannel({ channel, }: { animations: ElementAnimations | undefined; - propertyPath: string; + propertyPath: AnimationPath; channel: AnimationChannel | undefined; }): ElementAnimations | undefined { const binding = getBinding({ animations, propertyPath }); @@ -659,29 +670,66 @@ export function setChannel({ return animations; } - const primaryComponent = getPrimaryComponent({ binding }); - if (!primaryComponent) { - return animations; - } - - const nextAnimations = cloneAnimationsState({ animations }); - if (!channel || !hasChannelKeys({ channel })) { - for (const component of binding.components) { - delete nextAnimations.channels[component.channelId]; - } - delete nextAnimations.bindings[propertyPath]; - return toAnimation({ - animations: nextAnimations, - }); - } - if (binding.components.length !== 1) { throw new Error( `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`, ); } - nextAnimations.channels[primaryComponent.channelId] = normalizeChannel({ + const primaryComponent = getPrimaryComponent({ binding }); + if (!primaryComponent) { + return animations; + } + + return setBindingComponentChannel({ + animations, + propertyPath, + componentKey: primaryComponent.key, + channel, + }); +} + +export function setBindingComponentChannel({ + animations, + propertyPath, + componentKey, + channel, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + channel: AnimationChannel | undefined; +}): ElementAnimations | undefined { + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const component = getBindingComponent({ + binding, + componentKey, + }); + if (!component) { + return animations; + } + + const nextAnimations = cloneAnimationsState({ animations }); + if (!channel || !hasChannelKeys({ channel })) { + delete nextAnimations.channels[component.channelId]; + const hasRemainingKeys = binding.components.some((candidate) => + hasChannelKeys({ + channel: nextAnimations.channels[candidate.channelId], + }), + ); + if (!hasRemainingKeys) { + delete nextAnimations.bindings[propertyPath]; + } + return toAnimation({ + animations: nextAnimations, + }); + } + + nextAnimations.channels[component.channelId] = normalizeChannel({ channel, }); return toAnimation({ @@ -689,6 +737,73 @@ export function setChannel({ }); } +export function updateScalarKeyframeCurve({ + animations, + propertyPath, + componentKey, + keyframeId, + patch, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; + patch: ScalarCurveKeyframePatch; +}): ElementAnimations | undefined { + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const component = getBindingComponent({ + binding, + componentKey, + }); + if (!component) { + return animations; + } + + const channel = getChannelById({ + animations, + channelId: component.channelId, + }); + if (channel?.kind !== "scalar") { + return animations; + } + + const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId); + if (keyframeIndex < 0) { + return animations; + } + + const nextKeys = [...channel.keys]; + const currentKey = nextKeys[keyframeIndex]; + nextKeys[keyframeIndex] = { + ...currentKey, + leftHandle: + patch.leftHandle === undefined + ? currentKey.leftHandle + : patch.leftHandle ?? undefined, + rightHandle: + patch.rightHandle === undefined + ? currentKey.rightHandle + : patch.rightHandle ?? undefined, + segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext, + tangentMode: patch.tangentMode ?? currentKey.tangentMode, + }; + + return setBindingComponentChannel({ + animations, + propertyPath, + componentKey, + channel: { + kind: "scalar", + keys: nextKeys, + extrapolation: channel.extrapolation, + }, + }); +} + export function cloneAnimations({ animations, shouldRegenerateKeyframeIds = false, diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts index 3de97026..67f4a487 100644 --- a/apps/web/src/lib/animation/resolve.ts +++ b/apps/web/src/lib/animation/resolve.ts @@ -1,5 +1,9 @@ import type { + AnimationColorPropertyPath, + AnimationNumericPropertyPath, + AnimationPath, AnimationPropertyPath, + AnimationValueForPath, ElementAnimations, } from "@/lib/animation/types"; import type { Transform } from "@/lib/rendering"; @@ -96,7 +100,7 @@ export function resolveNumberAtTime({ }: { baseValue: number; animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationNumericPropertyPath; localTime: number; }): number { return resolveAnimationPathValueAtTime({ @@ -115,7 +119,7 @@ export function resolveColorAtTime({ }: { baseColor: string; animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationColorPropertyPath; localTime: number; }): string { return resolveAnimationPathValueAtTime({ @@ -126,19 +130,17 @@ export function resolveColorAtTime({ }); } -export function resolveAnimationPathValueAtTime< - T extends number | string | boolean | Transform["position"], ->({ +export function resolveAnimationPathValueAtTime({ animations, propertyPath, localTime, fallbackValue, }: { animations: ElementAnimations | undefined; - propertyPath: string; + propertyPath: TPath; localTime: number; - fallbackValue: T; -}): T { + fallbackValue: AnimationValueForPath; +}): AnimationValueForPath { const binding = animations?.bindings[propertyPath]; if (!binding) { return fallbackValue; @@ -170,5 +172,5 @@ export function resolveAnimationPathValueAtTime< return (composeAnimationValue({ binding, componentValues, - }) ?? fallbackValue) as T; + }) ?? fallbackValue) as AnimationValueForPath; } diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts index 1d07e81d..453723b0 100644 --- a/apps/web/src/lib/animation/types.ts +++ b/apps/web/src/lib/animation/types.ts @@ -1,3 +1,5 @@ +import type { ParamValues } from "@/lib/params"; + export const ANIMATION_PROPERTY_PATHS = [ "transform.position", "transform.scaleX", @@ -31,6 +33,34 @@ export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS; export type VectorValue = { x: number; y: number }; export type DiscreteValue = boolean | string; export type AnimationValue = number | string | boolean | VectorValue; +export interface AnimationPropertyValueMap { + "transform.position": VectorValue; + "transform.scaleX": number; + "transform.scaleY": number; + "transform.rotate": number; + opacity: number; + volume: number; + color: string; + "background.color": string; + "background.paddingX": number; + "background.paddingY": number; + "background.offsetX": number; + "background.offsetY": number; + "background.cornerRadius": number; +} +export type DynamicAnimationPathValue = ParamValues[string]; +export type AnimationValueForPath = + TPath extends AnimationPropertyPath + ? AnimationPropertyValueMap[TPath] + : TPath extends GraphicParamPath | EffectParamPath + ? DynamicAnimationPathValue + : never; +export type AnimationNumericPropertyPath = { + [K in AnimationPropertyPath]: AnimationValueForPath extends number ? K : never; +}[AnimationPropertyPath]; +export type AnimationColorPropertyPath = { + [K in AnimationPropertyPath]: AnimationValueForPath extends string ? K : never; +}[AnimationPropertyPath]; export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier"; export type DiscreteKeyframeInterpolation = "hold"; @@ -144,6 +174,36 @@ export interface ElementAnimations { channels: ElementAnimationChannelMap; } +export type NormalizedCubicBezier = [number, number, number, number]; + +export interface ScalarGraphChannelTarget { + propertyPath: AnimationPath; + componentKey: string; + channelId: string; +} + +export interface ScalarGraphChannel extends ScalarGraphChannelTarget { + channel: ScalarAnimationChannel; +} + +export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget { + keyframeId: string; +} + +export interface ScalarGraphKeyframeContext extends ScalarGraphChannel { + keyframe: ScalarAnimationKey; + keyframeIndex: number; + previousKey: ScalarAnimationKey | null; + nextKey: ScalarAnimationKey | null; +} + +export interface ScalarCurveKeyframePatch { + leftHandle?: CurveHandle | null; + rightHandle?: CurveHandle | null; + segmentToNext?: ScalarSegmentType; + tangentMode?: TangentMode; +} + export interface ElementKeyframe { propertyPath: AnimationPath; id: string; diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts index ea19286c..319a827b 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts @@ -1,5 +1,6 @@ -export * from "./remove-effect-param-keyframe"; -export * from "./remove-keyframe"; -export * from "./retime-keyframe"; -export * from "./upsert-effect-param-keyframe"; -export * from "./upsert-keyframe"; +export * from "./remove-effect-param-keyframe"; +export * from "./remove-keyframe"; +export * from "./retime-keyframe"; +export * from "./update-scalar-keyframe-curve"; +export * from "./upsert-effect-param-keyframe"; +export * from "./upsert-keyframe"; diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts new file mode 100644 index 00000000..f1eaab9d --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts @@ -0,0 +1,85 @@ +import { EditorCore } from "@/core"; +import { + resolveAnimationTarget, + updateScalarKeyframeCurve, +} from "@/lib/animation"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { updateElementInTracks } from "@/lib/timeline"; +import type { + AnimationPath, + ScalarCurveKeyframePatch, +} from "@/lib/animation/types"; +import type { TimelineTrack } from "@/lib/timeline"; + +export class UpdateScalarKeyframeCurveCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPath; + private readonly componentKey: string; + private readonly keyframeId: string; + private readonly patch: ScalarCurveKeyframePatch; + + constructor({ + trackId, + elementId, + propertyPath, + componentKey, + keyframeId, + patch, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; + patch: ScalarCurveKeyframePatch; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.componentKey = componentKey; + this.keyframeId = keyframeId; + this.patch = patch; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => { + if (!resolveAnimationTarget({ element, path: this.propertyPath })) { + return element; + } + + return { + ...element, + animations: updateScalarKeyframeCurve({ + animations: element.animations, + propertyPath: this.propertyPath, + componentKey: this.componentKey, + keyframeId: this.keyframeId, + patch: this.patch, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +}