From 78c6c497f10e477aab350e2c84beb29c3904aab9 Mon Sep 17 00:00:00 2001 From: Maze Date: Sat, 4 Apr 2026 08:53:10 +0200 Subject: [PATCH 1/6] chore: remove transcription envs --- .github/workflows/bun-ci.yml | 5 ----- apps/web/.env.example | 7 ------- apps/web/Dockerfile | 5 ----- apps/web/src/lib/env/web.ts | 5 ----- docker-compose.yml | 6 ------ turbo.json | 7 +------ 6 files changed, 1 insertion(+), 34 deletions(-) diff --git a/.github/workflows/bun-ci.yml b/.github/workflows/bun-ci.yml index 58198586..386b42bc 100644 --- a/.github/workflows/bun-ci.yml +++ b/.github/workflows/bun-ci.yml @@ -31,11 +31,6 @@ jobs: MARBLE_WORKSPACE_KEY: "placeholder" FREESOUND_CLIENT_ID: "placeholder" FREESOUND_API_KEY: "placeholder" - CLOUDFLARE_ACCOUNT_ID: "placeholder" - R2_ACCESS_KEY_ID: "placeholder" - R2_SECRET_ACCESS_KEY: "placeholder" - R2_BUCKET_NAME: "placeholder" - MODAL_TRANSCRIPTION_URL: "https://placeholder.example.com" steps: - name: Checkout repository diff --git a/apps/web/.env.example b/apps/web/.env.example index 48d48b1d..b15779a5 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -19,10 +19,3 @@ MARBLE_WORKSPACE_KEY=your_workspace_key_here FREESOUND_CLIENT_ID=your_client_id_here FREESOUND_API_KEY=your_api_key_here - -CLOUDFLARE_ACCOUNT_ID=your_account_id_here -R2_ACCESS_KEY_ID=your_access_key_here -R2_SECRET_ACCESS_KEY=your_secret_key_here -R2_BUCKET_NAME=opencut-transcription # whatever you named your r2 bucket - -MODAL_TRANSCRIPTION_URL=your_modal_url_here \ No newline at end of file diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 34b63e3c..a08d9b68 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -29,11 +29,6 @@ ENV UPSTASH_REDIS_REST_TOKEN="example_token" ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000" ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL ENV MARBLE_WORKSPACE_KEY=$MARBLE_WORKSPACE_KEY -ENV CLOUDFLARE_ACCOUNT_ID="build-placeholder" -ENV R2_ACCESS_KEY_ID="build-placeholder" -ENV R2_SECRET_ACCESS_KEY="build-placeholder" -ENV R2_BUCKET_NAME="build-placeholder" -ENV MODAL_TRANSCRIPTION_URL="http://localhost:0" ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID ENV FREESOUND_API_KEY=$FREESOUND_API_KEY diff --git a/apps/web/src/lib/env/web.ts b/apps/web/src/lib/env/web.ts index 591b36ad..aa051615 100644 --- a/apps/web/src/lib/env/web.ts +++ b/apps/web/src/lib/env/web.ts @@ -23,11 +23,6 @@ const webEnvSchema = z.object({ MARBLE_WORKSPACE_KEY: z.string(), FREESOUND_CLIENT_ID: z.string(), FREESOUND_API_KEY: z.string(), - CLOUDFLARE_ACCOUNT_ID: z.string(), - R2_ACCESS_KEY_ID: z.string(), - R2_SECRET_ACCESS_KEY: z.string(), - R2_BUCKET_NAME: z.string(), - MODAL_TRANSCRIPTION_URL: z.url(), }); export type WebEnv = z.infer; diff --git a/docker-compose.yml b/docker-compose.yml index a20c2654..0c4983b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,12 +70,6 @@ services: - MARBLE_WORKSPACE_KEY=${MARBLE_WORKSPACE_KEY:-placeholder} - FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID} - FREESOUND_API_KEY=${FREESOUND_API_KEY} - # Transcription (Optional - leave blank to disable auto-captions) - - CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID:-placeholder} - - R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID:-placeholder} - - R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY:-placeholder} - - R2_BUCKET_NAME=${R2_BUCKET_NAME:-opencut-transcription} - - MODAL_TRANSCRIPTION_URL=${MODAL_TRANSCRIPTION_URL:-http://localhost:0} depends_on: db: condition: service_healthy diff --git a/turbo.json b/turbo.json index 244b2f95..f1431ec3 100644 --- a/turbo.json +++ b/turbo.json @@ -17,12 +17,7 @@ "UPSTASH_REDIS_REST_TOKEN", "MARBLE_WORKSPACE_KEY", "FREESOUND_CLIENT_ID", - "FREESOUND_API_KEY", - "CLOUDFLARE_ACCOUNT_ID", - "R2_ACCESS_KEY_ID", - "R2_SECRET_ACCESS_KEY", - "R2_BUCKET_NAME", - "MODAL_TRANSCRIPTION_URL" + "FREESOUND_API_KEY" ] }, "check-types": { From cb99925d7519a7a0e6b3c9d4918aa18f49192829 Mon Sep 17 00:00:00 2001 From: Maze Date: Sat, 4 Apr 2026 11:58:55 +0200 Subject: [PATCH 2/6] refactor: swap out manual tabs withthe shared tabs component in stickers --- .../editor/panels/assets/views/stickers.tsx | 49 +++++++------------ 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx index 285b37d3..5b076466 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -8,6 +8,7 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item" import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useEditor } from "@/hooks/use-editor"; import { resolveStickerIntrinsicSize } from "@/lib/stickers"; import { @@ -70,41 +71,25 @@ export function StickersView() { /> -
-
-
- {Object.entries(STICKER_CATEGORIES).map(([key, label]) => { - const isActive = key === selectedCategory; - return ( - - ); - })} -
-
+ { + setSelectedCategory({ category: value as StickerCategory }); + }} + variant="underline" + className="mt-2 flex min-h-0 flex-1 flex-col" + > + + {Object.entries(STICKER_CATEGORIES).map(([key, label]) => ( + + {label} + + ))} +
-
+ ); } From 69da5bee0c50f331ec59a510d0cf0d45779bab31 Mon Sep 17 00:00:00 2001 From: Maze Date: Sat, 4 Apr 2026 11:59:04 +0200 Subject: [PATCH 3/6] style: tabs --- apps/web/src/components/ui/tabs.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index e436e876..4fb028c8 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -50,11 +50,11 @@ const TabsTrigger = React.forwardRef< Date: Sun, 5 Apr 2026 13:15:58 +0200 Subject: [PATCH 4/6] refactor: rewrite animation system to use bindings and scalar channels --- .../hooks/use-keyframed-param-property.ts | 298 ++-- .../panels/timeline/timeline-element.tsx | 5 +- .../panels/timeline/timeline-toolbar.tsx | 38 +- .../src/hooks/actions/use-editor-actions.ts | 7 +- .../timeline/element/use-keyframe-drag.ts | 10 +- apps/web/src/hooks/use-transform-handles.ts | 1248 +++++++-------- .../__tests__/binding-values.test.ts | 26 + .../__tests__/keyframe-query.test.ts | 139 ++ apps/web/src/lib/animation/bezier.ts | 88 ++ apps/web/src/lib/animation/binding-values.ts | 335 ++++ apps/web/src/lib/animation/color-channel.ts | 19 - .../src/lib/animation/effect-param-channel.ts | 80 +- .../lib/animation/graphic-param-channel.ts | 150 +- apps/web/src/lib/animation/index.ts | 152 +- apps/web/src/lib/animation/interpolation.ts | 541 ++++--- apps/web/src/lib/animation/keyframe-query.ts | 370 ++++- apps/web/src/lib/animation/keyframes.ts | 1358 +++++++++++------ apps/web/src/lib/animation/number-channel.ts | 20 - .../src/lib/animation/property-registry.ts | 746 ++++----- apps/web/src/lib/animation/resolve.ts | 309 ++-- apps/web/src/lib/animation/target-resolver.ts | 562 +++---- apps/web/src/lib/animation/types.ts | 279 ++-- apps/web/src/lib/animation/vector-channel.ts | 72 - .../element/keyframes/remove-keyframe.ts | 38 +- .../keyframes/upsert-effect-param-keyframe.ts | 33 +- .../element/keyframes/upsert-keyframe.ts | 4 +- .../element/toggle-source-audio-separation.ts | 4 +- apps/web/src/lib/masks/__tests__/snap.test.ts | 472 +++--- .../audio-separation/__tests__/index.test.ts | 60 +- .../lib/timeline/audio-separation/index.ts | 56 +- .../migrations/__tests__/v21-to-v22.test.ts | 241 +++ .../migrations/__tests__/v5-to-v6.test.ts | 186 +-- .../src/services/storage/migrations/index.ts | 4 +- .../migrations/transformers/v21-to-v22.ts | 511 +++++++ .../services/storage/migrations/v21-to-v22.ts | 16 + 35 files changed, 5242 insertions(+), 3235 deletions(-) create mode 100644 apps/web/src/lib/animation/__tests__/binding-values.test.ts create mode 100644 apps/web/src/lib/animation/__tests__/keyframe-query.test.ts create mode 100644 apps/web/src/lib/animation/bezier.ts create mode 100644 apps/web/src/lib/animation/binding-values.ts delete mode 100644 apps/web/src/lib/animation/color-channel.ts delete mode 100644 apps/web/src/lib/animation/number-channel.ts delete mode 100644 apps/web/src/lib/animation/vector-channel.ts create mode 100644 apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts create mode 100644 apps/web/src/services/storage/migrations/v21-to-v22.ts diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index 92b5d634..c044ff5f 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -1,148 +1,150 @@ -"use client"; - -import { useEditor } from "@/hooks/use-editor"; -import { - buildGraphicParamPath, - getKeyframeAtTime, - getParamDefaultInterpolation, - getParamValueKind, - hasKeyframesForPath, - upsertPathKeyframe, -} from "@/lib/animation"; -import type { - ElementAnimations, -} from "@/lib/animation/types"; -import type { ParamDefinition } from "@/lib/params"; -import type { TimelineElement } from "@/lib/timeline"; - -export interface KeyframedParamPropertyResult { - hasAnimatedKeyframes: boolean; - isKeyframedAtTime: boolean; - keyframeIdAtTime: string | null; - onPreview: (value: number | string | boolean) => void; - onCommit: () => void; - toggleKeyframe: () => void; -} - -export function useKeyframedParamProperty({ - param, - trackId, - elementId, - animations, - localTime, - isPlayheadWithinElementRange, - resolvedValue, - buildBaseUpdates, -}: { - param: ParamDefinition; - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedValue: number | string | boolean; - buildBaseUpdates: ({ - value, - }: { - value: number | string | boolean; - }) => Partial; -}): KeyframedParamPropertyResult { - const editor = useEditor(); - const propertyPath = buildGraphicParamPath({ paramKey: param.key }); - const hasAnimatedKeyframes = hasKeyframesForPath({ - animations, - propertyPath, - }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ - animations, - propertyPath, - time: localTime, - }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => { - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertPathKeyframe({ - animations, - propertyPath, - time: localTime, - value, - valueKind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ - param, - }), - numericRange: - param.type === "number" - ? { min: param.min, max: param.max, step: param.step } - : undefined, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value }), - }, - ], - }); - }; - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - keyframeId: keyframeIdAtTime, - }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: resolvedValue, - }, - ], - }); - }; - - return { - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - onPreview: previewValue, - onCommit: () => editor.timeline.commitPreview(), - toggleKeyframe, - }; -} +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { + buildGraphicParamPath, + coerceAnimationValueForParam, + getKeyframeAtTime, + getParamDefaultInterpolation, + getParamValueKind, + hasKeyframesForPath, + upsertPathKeyframe, +} from "@/lib/animation"; +import type { + ElementAnimations, +} from "@/lib/animation/types"; +import type { ParamDefinition } from "@/lib/params"; +import type { TimelineElement } from "@/lib/timeline"; + +export interface KeyframedParamPropertyResult { + hasAnimatedKeyframes: boolean; + isKeyframedAtTime: boolean; + keyframeIdAtTime: string | null; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; + toggleKeyframe: () => void; +} + +export function useKeyframedParamProperty({ + param, + trackId, + elementId, + animations, + localTime, + isPlayheadWithinElementRange, + resolvedValue, + buildBaseUpdates, +}: { + param: ParamDefinition; + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedValue: number | string | boolean; + buildBaseUpdates: ({ + value, + }: { + value: number | string | boolean; + }) => Partial; +}): KeyframedParamPropertyResult { + const editor = useEditor(); + const propertyPath = buildGraphicParamPath({ paramKey: param.key }); + const hasAnimatedKeyframes = hasKeyframesForPath({ + animations, + propertyPath, + }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ + animations, + propertyPath, + time: localTime, + }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertPathKeyframe({ + animations, + propertyPath, + time: localTime, + value, + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ + param, + }), + coerceValue: (nextValue) => + coerceAnimationValueForParam({ + param, + value: nextValue, + }), + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value }), + }, + ], + }); + }; + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: resolvedValue, + }, + ], + }); + }; + + return { + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + onPreview: previewValue, + onCommit: () => editor.timeline.commitPreview(), + toggleKeyframe, + }; +} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 25218d78..1d3606e6 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -292,10 +292,7 @@ export function TimelineElement({ const canToggleCurrentSourceAudio = selectedElements.length === 1 && isCurrentElementSelected && - canToggleSourceAudio({ - element, - mediaAsset, - }); + canToggleSourceAudio(element, mediaAsset); const sourceAudioLabel = element.type === "video" ? getSourceAudioActionLabel({ element }) 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 ecaf37e2..cb636d3c 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -50,6 +50,7 @@ import { import { HugeiconsIcon } from "@hugeicons/react"; import { OcRippleIcon } from "@/components/icons"; + export function TimelineToolbar({ zoomLevel, minZoom, @@ -115,10 +116,7 @@ function ToolbarLeftSection() { })(); const canToggleSelectedSourceAudio = !!selectedElement && - canToggleSourceAudio({ - element: selectedElement.element, - mediaAsset: selectedMediaAsset, - }); + canToggleSourceAudio(selectedElement.element, selectedMediaAsset); const sourceAudioLabel = selectedElement?.element.type === "video" ? getSourceAudioActionLabel({ @@ -317,28 +315,34 @@ function ToolbarButton({ onClick, disabled, isActive, + buttonWrapper, }: { icon: React.ReactNode; tooltip: string; - onClick: ({ event }: { event: React.MouseEvent }) => void; + onClick?: ({ event }: { event: React.MouseEvent }) => void; disabled?: boolean; isActive?: boolean; + buttonWrapper?: (button: React.ReactElement) => React.ReactElement; }) { + const button = ( + + ); + return ( - + {buttonWrapper ? buttonWrapper(button) : button} {tooltip} diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index 87aa6b48..356d0e2b 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -294,12 +294,7 @@ export function useEditorActions() { null ); })(); - if ( - !canToggleSourceAudio({ - element: selectedElement.element, - mediaAsset, - }) - ) { + if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) { return; } diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts index bbe843f2..b66bbea8 100644 --- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts +++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts @@ -6,6 +6,7 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import { useEditor } from "@/hooks/use-editor"; +import { getKeyframeById } from "@/lib/animation"; import { useKeyframeSelection } from "./use-keyframe-selection"; import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm"; import { timelineTimeToSnappedPixels } from "@/lib/timeline"; @@ -84,10 +85,11 @@ export function useKeyframeDrag({ deltaTime: number; }) => { const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { - const channel = element.animations?.channels[keyframeRef.propertyPath]; - const keyframe = channel?.keyframes.find( - (keyframe) => keyframe.id === keyframeRef.keyframeId, - ); + const keyframe = getKeyframeById({ + animations: element.animations, + propertyPath: keyframeRef.propertyPath, + keyframeId: keyframeRef.keyframeId, + }); if (!keyframe) return []; const nextTime = Math.max( 0, diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts index af4bb69f..659aa598 100644 --- a/apps/web/src/hooks/use-transform-handles.ts +++ b/apps/web/src/hooks/use-transform-handles.ts @@ -1,619 +1,629 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; -import { useEditor } from "@/hooks/use-editor"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { - getVisibleElementsWithBounds, - type ElementWithBounds, -} from "@/lib/preview/element-bounds"; -import { - MIN_SCALE, - SNAP_THRESHOLD_SCREEN_PIXELS, - snapRotation, - snapScale, - snapScaleAxes, - type ScaleEdgePreference, - type SnapLine, -} from "@/lib/preview/preview-snap"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import { - getElementLocalTime, - resolveTransformAtTime, - setChannel, -} from "@/lib/animation"; -import type { Transform } from "@/lib/rendering"; -import type { ElementAnimations } from "@/lib/animation/types"; -import { registerCanceller } from "@/lib/cancel-interaction"; - -type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; -type Edge = "right" | "left" | "bottom"; -type HandleType = Corner | Edge | "rotation"; - -function getPreferredEdge({ - edge, -}: { - edge: Edge; -}): ScaleEdgePreference { - return edge === "right" - ? { right: true } - : edge === "left" - ? { left: true } - : { bottom: true }; -} - -interface ScaleState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialDistance: number; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -interface RotationState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialAngle: number; - initialBoundsCx: number; - initialBoundsCy: number; -} - -interface EdgeScaleState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - edge: Edge; - rotationRad: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -function clampScaleNonZero(scale: number): number { - if (Math.abs(scale) < MIN_SCALE) { - return scale < 0 ? -MIN_SCALE : MIN_SCALE; - } - return scale; -} - -function getCornerDistance({ - bounds, - corner, -}: { - bounds: { - cx: number; - cy: number; - width: number; - height: number; - rotation: number; - }; - corner: Corner; -}): number { - const halfWidth = bounds.width / 2; - const halfHeight = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - - const localX = - corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; - const localY = - corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; - - const rotatedX = localX * cos - localY * sin; - const rotatedY = localX * sin + localY * cos; - return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; -} - -export function useTransformHandles({ - onSnapLinesChange, -}: { - onSnapLinesChange?: OnSnapLinesChange; -}) { - const editor = useEditor(); - const isShiftHeldRef = useShiftKey(); - const viewport = usePreviewViewport(); - const [activeHandle, setActiveHandle] = useState(null); - const scaleStateRef = useRef(null); - const rotationStateRef = useRef(null); - const edgeScaleStateRef = useRef(null); - const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( - null, - ); - - const selectedElements = useEditor((e) => e.selection.getSelectedElements()); - const tracks = useEditor((e) => e.timeline.getRenderTracks()); - const currentTime = useEditor((e) => e.playback.getCurrentTime()); - const currentTimeRef = useRef(currentTime); - currentTimeRef.current = currentTime; - const mediaAssets = useEditor((e) => e.media.getAssets()); - const canvasSize = useEditor( - (e) => e.project.getActive().settings.canvasSize, - ); - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const selectedWithBounds: ElementWithBounds | null = - selectedElements.length === 1 - ? (elementsWithBounds.find( - (entry) => - entry.trackId === selectedElements[0].trackId && - entry.elementId === selectedElements[0].elementId, - ) ?? null) - : null; - - const hasVisualSelection = - selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); - - const clearActiveHandleState = useCallback(() => { - scaleStateRef.current = null; - rotationStateRef.current = null; - edgeScaleStateRef.current = null; - setActiveHandle(null); - onSnapLinesChange?.([]); - }, [onSnapLinesChange]); - - const releaseCapturedPointer = useCallback(() => { - const capture = captureRef.current; - if (!capture) return; - - if (capture.element.hasPointerCapture(capture.pointerId)) { - capture.element.releasePointerCapture(capture.pointerId); - } - - captureRef.current = null; - }, []); - - useEffect(() => { - if (!activeHandle) return; - - return registerCanceller({ - fn: () => { - editor.timeline.discardPreview(); - clearActiveHandleState(); - releaseCapturedPointer(); - }, - }); - }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]); - - const handleCornerPointerDown = useCallback( - ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const initialDistance = getCornerDistance({ bounds, corner }); - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const shouldClearScaleAnimation = - !!element.animations?.channels["transform.scaleX"] || - !!element.animations?.channels["transform.scaleY"]; - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: setChannel({ - animations: element.animations, - propertyPath: "transform.scaleX", - channel: undefined, - }), - propertyPath: "transform.scaleY", - channel: undefined, - }) - : element.animations; - - scaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialDistance, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(corner); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds], - ); - - const handleRotationPointerDown = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - const deltaX = position.x - bounds.cx; - const deltaY = position.y - bounds.cy; - const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - - rotationStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialAngle, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - }; - setActiveHandle("rotation"); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds, viewport], - ); - - const handleEdgePointerDown = useCallback( - ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const rotationRad = (bounds.rotation * Math.PI) / 180; - - const propertyPath = - edge === "right" || edge === "left" - ? "transform.scaleX" - : "transform.scaleY"; - const shouldClearScaleAnimation = - !!element.animations?.channels[propertyPath]; - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: element.animations, - propertyPath, - channel: undefined, - }) - : element.animations; - - edgeScaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - edge, - rotationRad, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(edge); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds], - ); - - const handlePointerMove = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if ( - !scaleStateRef.current && - !rotationStateRef.current && - !edgeScaleStateRef.current - ) - return; - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - - if ( - scaleStateRef.current && - activeHandle && - activeHandle !== "rotation" - ) { - const { - trackId, - elementId, - initialTransform, - initialDistance, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - } = scaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentDistance = - Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; - const scaleFactor = currentDistance / initialDistance; - - // Use actual element dimensions (base * current scale) so snap - // computes the correct edges when scaleX ≠ scaleY - const effectiveWidth = baseWidth * initialTransform.scaleX; - const effectiveHeight = baseHeight * initialTransform.scaleY; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { snappedScale: snappedFactor, activeLines } = - isShiftHeldRef.current - ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } - : snapScale({ - proposedScale: scaleFactor, - position: initialTransform.position, - baseWidth: effectiveWidth, - baseHeight: effectiveHeight, - rotation: initialTransform.rotate, - canvasSize, - snapThreshold, - }); - - onSnapLinesChange?.(activeLines); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: clampScaleNonZero( - initialTransform.scaleX * snappedFactor, - ), - scaleY: clampScaleNonZero( - initialTransform.scaleY * snappedFactor, - ), - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } - - if ( - edgeScaleStateRef.current && - (activeHandle === "right" || - activeHandle === "left" || - activeHandle === "bottom") - ) { - const { - trackId, - elementId, - initialTransform, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - edge, - rotationRad, - shouldClearScaleAnimation, - animationsWithoutScale, - } = edgeScaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const xProjection = - deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad); - const yProjection = - -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad); - const projection = - edge === "right" - ? xProjection - : edge === "left" - ? -xProjection - : yProjection; - - const baseAxisHalf = - edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2; - const proposedScale = clampScaleNonZero(projection / baseAxisHalf); - - const proposedScaleX = - edge === "right" || edge === "left" - ? proposedScale - : initialTransform.scaleX; - const proposedScaleY = - edge === "bottom" ? proposedScale : initialTransform.scaleY; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { x: xSnap, y: ySnap } = isShiftHeldRef.current - ? { - x: { - snappedScale: proposedScaleX, - snapDistance: Infinity, - activeLines: [] as SnapLine[], - }, - y: { - snappedScale: proposedScaleY, - snapDistance: Infinity, - activeLines: [] as SnapLine[], - }, - } - : snapScaleAxes({ - proposedScaleX, - proposedScaleY, - position: initialTransform.position, - baseWidth, - baseHeight, - rotation: initialTransform.rotate, - canvasSize, - snapThreshold, - preferredEdges: getPreferredEdge({ edge }), - }); - - const relevantSnap = - edge === "right" || edge === "left" ? xSnap : ySnap; - onSnapLinesChange?.(relevantSnap.activeLines); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: - edge === "right" || edge === "left" - ? xSnap.snappedScale - : initialTransform.scaleX, - scaleY: - edge === "bottom" - ? ySnap.snappedScale - : initialTransform.scaleY, - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } - - if (rotationStateRef.current && activeHandle === "rotation") { - const { - trackId, - elementId, - initialTransform, - initialAngle, - initialBoundsCx, - initialBoundsCy, - } = rotationStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - let deltaAngle = currentAngle - initialAngle; - if (deltaAngle > 180) deltaAngle -= 360; - if (deltaAngle < -180) deltaAngle += 360; - const newRotate = initialTransform.rotate + deltaAngle; - const { snappedRotation } = isShiftHeldRef.current - ? { snappedRotation: newRotate } - : snapRotation({ proposedRotation: newRotate }); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { ...initialTransform, rotate: snappedRotation }, - }, - }, - ], - }); - } - }, - [ - activeHandle, - canvasSize, - editor, - isShiftHeldRef, - onSnapLinesChange, - viewport, - ], - ); - - const handlePointerUp = useCallback(() => { - if ( - scaleStateRef.current || - rotationStateRef.current || - edgeScaleStateRef.current - ) { - editor.timeline.commitPreview(); - clearActiveHandleState(); - } - releaseCapturedPointer(); - }, - [clearActiveHandleState, editor, releaseCapturedPointer], - ); - - return { - selectedWithBounds, - hasVisualSelection, - activeHandle, - handleCornerPointerDown, - handleEdgePointerDown, - handleRotationPointerDown, - handlePointerMove, - handlePointerUp, - }; -} +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; +import { useEditor } from "@/hooks/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { + getVisibleElementsWithBounds, + type ElementWithBounds, +} from "@/lib/preview/element-bounds"; +import { + MIN_SCALE, + SNAP_THRESHOLD_SCREEN_PIXELS, + snapRotation, + snapScale, + snapScaleAxes, + type ScaleEdgePreference, + type SnapLine, +} from "@/lib/preview/preview-snap"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { + getElementLocalTime, + hasKeyframesForPath, + resolveTransformAtTime, + setChannel, +} from "@/lib/animation"; +import type { Transform } from "@/lib/rendering"; +import type { ElementAnimations } from "@/lib/animation/types"; +import { registerCanceller } from "@/lib/cancel-interaction"; + +type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +type Edge = "right" | "left" | "bottom"; +type HandleType = Corner | Edge | "rotation"; + +function getPreferredEdge({ + edge, +}: { + edge: Edge; +}): ScaleEdgePreference { + return edge === "right" + ? { right: true } + : edge === "left" + ? { left: true } + : { bottom: true }; +} + +interface ScaleState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialDistance: number; + initialBoundsCx: number; + initialBoundsCy: number; + baseWidth: number; + baseHeight: number; + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} + +interface RotationState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialAngle: number; + initialBoundsCx: number; + initialBoundsCy: number; +} + +interface EdgeScaleState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialBoundsCx: number; + initialBoundsCy: number; + baseWidth: number; + baseHeight: number; + edge: Edge; + rotationRad: number; + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} + +function clampScaleNonZero(scale: number): number { + if (Math.abs(scale) < MIN_SCALE) { + return scale < 0 ? -MIN_SCALE : MIN_SCALE; + } + return scale; +} + +function getCornerDistance({ + bounds, + corner, +}: { + bounds: { + cx: number; + cy: number; + width: number; + height: number; + rotation: number; + }; + corner: Corner; +}): number { + const halfWidth = bounds.width / 2; + const halfHeight = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; + const localY = + corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; + + const rotatedX = localX * cos - localY * sin; + const rotatedY = localX * sin + localY * cos; + return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; +} + +export function useTransformHandles({ + onSnapLinesChange, +}: { + onSnapLinesChange?: OnSnapLinesChange; +}) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const viewport = usePreviewViewport(); + const [activeHandle, setActiveHandle] = useState(null); + const scaleStateRef = useRef(null); + const rotationStateRef = useRef(null); + const edgeScaleStateRef = useRef(null); + const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( + null, + ); + + const selectedElements = useEditor((e) => e.selection.getSelectedElements()); + const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const currentTime = useEditor((e) => e.playback.getCurrentTime()); + const currentTimeRef = useRef(currentTime); + currentTimeRef.current = currentTime; + const mediaAssets = useEditor((e) => e.media.getAssets()); + const canvasSize = useEditor( + (e) => e.project.getActive().settings.canvasSize, + ); + + const elementsWithBounds = getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, + }); + + const selectedWithBounds: ElementWithBounds | null = + selectedElements.length === 1 + ? (elementsWithBounds.find( + (entry) => + entry.trackId === selectedElements[0].trackId && + entry.elementId === selectedElements[0].elementId, + ) ?? null) + : null; + + const hasVisualSelection = + selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); + + const clearActiveHandleState = useCallback(() => { + scaleStateRef.current = null; + rotationStateRef.current = null; + edgeScaleStateRef.current = null; + setActiveHandle(null); + onSnapLinesChange?.([]); + }, [onSnapLinesChange]); + + const releaseCapturedPointer = useCallback(() => { + const capture = captureRef.current; + if (!capture) return; + + if (capture.element.hasPointerCapture(capture.pointerId)) { + capture.element.releasePointerCapture(capture.pointerId); + } + + captureRef.current = null; + }, []); + + useEffect(() => { + if (!activeHandle) return; + + return registerCanceller({ + fn: () => { + editor.timeline.discardPreview(); + clearActiveHandleState(); + releaseCapturedPointer(); + }, + }); + }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]); + + const handleCornerPointerDown = useCallback( + ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const initialDistance = getCornerDistance({ bounds, corner }); + const baseWidth = bounds.width / resolvedTransform.scaleX; + const baseHeight = bounds.height / resolvedTransform.scaleY; + const shouldClearScaleAnimation = + hasKeyframesForPath({ + animations: element.animations, + propertyPath: "transform.scaleX", + }) || + hasKeyframesForPath({ + animations: element.animations, + propertyPath: "transform.scaleY", + }); + const animationsWithoutScale = shouldClearScaleAnimation + ? setChannel({ + animations: setChannel({ + animations: element.animations, + propertyPath: "transform.scaleX", + channel: undefined, + }), + propertyPath: "transform.scaleY", + channel: undefined, + }) + : element.animations; + + scaleStateRef.current = { + trackId, + elementId, + initialTransform: resolvedTransform, + initialDistance, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + baseWidth, + baseHeight, + shouldClearScaleAnimation, + animationsWithoutScale, + }; + setActiveHandle(corner); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithBounds], + ); + + const handleRotationPointerDown = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const position = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + const deltaX = position.x - bounds.cx; + const deltaY = position.y - bounds.cy; + const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + + rotationStateRef.current = { + trackId, + elementId, + initialTransform: resolvedTransform, + initialAngle, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + }; + setActiveHandle("rotation"); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithBounds, viewport], + ); + + const handleEdgePointerDown = useCallback( + ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const baseWidth = bounds.width / resolvedTransform.scaleX; + const baseHeight = bounds.height / resolvedTransform.scaleY; + const rotationRad = (bounds.rotation * Math.PI) / 180; + + const propertyPath = + edge === "right" || edge === "left" + ? "transform.scaleX" + : "transform.scaleY"; + const shouldClearScaleAnimation = + hasKeyframesForPath({ + animations: element.animations, + propertyPath, + }); + const animationsWithoutScale = shouldClearScaleAnimation + ? setChannel({ + animations: element.animations, + propertyPath, + channel: undefined, + }) + : element.animations; + + edgeScaleStateRef.current = { + trackId, + elementId, + initialTransform: resolvedTransform, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + baseWidth, + baseHeight, + edge, + rotationRad, + shouldClearScaleAnimation, + animationsWithoutScale, + }; + setActiveHandle(edge); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithBounds], + ); + + const handlePointerMove = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if ( + !scaleStateRef.current && + !rotationStateRef.current && + !edgeScaleStateRef.current + ) + return; + + const position = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + + if ( + scaleStateRef.current && + activeHandle && + activeHandle !== "rotation" + ) { + const { + trackId, + elementId, + initialTransform, + initialDistance, + initialBoundsCx, + initialBoundsCy, + baseWidth, + baseHeight, + shouldClearScaleAnimation, + animationsWithoutScale, + } = scaleStateRef.current; + + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const currentDistance = + Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; + const scaleFactor = currentDistance / initialDistance; + + // Use actual element dimensions (base * current scale) so snap + // computes the correct edges when scaleX ≠ scaleY + const effectiveWidth = baseWidth * initialTransform.scaleX; + const effectiveHeight = baseHeight * initialTransform.scaleY; + + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { snappedScale: snappedFactor, activeLines } = + isShiftHeldRef.current + ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } + : snapScale({ + proposedScale: scaleFactor, + position: initialTransform.position, + baseWidth: effectiveWidth, + baseHeight: effectiveHeight, + rotation: initialTransform.rotate, + canvasSize, + snapThreshold, + }); + + onSnapLinesChange?.(activeLines); + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + scaleX: clampScaleNonZero( + initialTransform.scaleX * snappedFactor, + ), + scaleY: clampScaleNonZero( + initialTransform.scaleY * snappedFactor, + ), + }, + ...(shouldClearScaleAnimation && { + animations: animationsWithoutScale, + }), + }, + }, + ], + }); + return; + } + + if ( + edgeScaleStateRef.current && + (activeHandle === "right" || + activeHandle === "left" || + activeHandle === "bottom") + ) { + const { + trackId, + elementId, + initialTransform, + initialBoundsCx, + initialBoundsCy, + baseWidth, + baseHeight, + edge, + rotationRad, + shouldClearScaleAnimation, + animationsWithoutScale, + } = edgeScaleStateRef.current; + + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const xProjection = + deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad); + const yProjection = + -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad); + const projection = + edge === "right" + ? xProjection + : edge === "left" + ? -xProjection + : yProjection; + + const baseAxisHalf = + edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2; + const proposedScale = clampScaleNonZero(projection / baseAxisHalf); + + const proposedScaleX = + edge === "right" || edge === "left" + ? proposedScale + : initialTransform.scaleX; + const proposedScaleY = + edge === "bottom" ? proposedScale : initialTransform.scaleY; + + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { x: xSnap, y: ySnap } = isShiftHeldRef.current + ? { + x: { + snappedScale: proposedScaleX, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + y: { + snappedScale: proposedScaleY, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + } + : snapScaleAxes({ + proposedScaleX, + proposedScaleY, + position: initialTransform.position, + baseWidth, + baseHeight, + rotation: initialTransform.rotate, + canvasSize, + snapThreshold, + preferredEdges: getPreferredEdge({ edge }), + }); + + const relevantSnap = + edge === "right" || edge === "left" ? xSnap : ySnap; + onSnapLinesChange?.(relevantSnap.activeLines); + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + scaleX: + edge === "right" || edge === "left" + ? xSnap.snappedScale + : initialTransform.scaleX, + scaleY: + edge === "bottom" + ? ySnap.snappedScale + : initialTransform.scaleY, + }, + ...(shouldClearScaleAnimation && { + animations: animationsWithoutScale, + }), + }, + }, + ], + }); + return; + } + + if (rotationStateRef.current && activeHandle === "rotation") { + const { + trackId, + elementId, + initialTransform, + initialAngle, + initialBoundsCx, + initialBoundsCy, + } = rotationStateRef.current; + + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + let deltaAngle = currentAngle - initialAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + const newRotate = initialTransform.rotate + deltaAngle; + const { snappedRotation } = isShiftHeldRef.current + ? { snappedRotation: newRotate } + : snapRotation({ proposedRotation: newRotate }); + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { ...initialTransform, rotate: snappedRotation }, + }, + }, + ], + }); + } + }, + [ + activeHandle, + canvasSize, + editor, + isShiftHeldRef, + onSnapLinesChange, + viewport, + ], + ); + + const handlePointerUp = useCallback(() => { + if ( + scaleStateRef.current || + rotationStateRef.current || + edgeScaleStateRef.current + ) { + editor.timeline.commitPreview(); + clearActiveHandleState(); + } + releaseCapturedPointer(); + }, + [clearActiveHandleState, editor, releaseCapturedPointer], + ); + + return { + selectedWithBounds, + hasVisualSelection, + activeHandle, + handleCornerPointerDown, + handleEdgePointerDown, + handleRotationPointerDown, + handlePointerMove, + handlePointerUp, + }; +} diff --git a/apps/web/src/lib/animation/__tests__/binding-values.test.ts b/apps/web/src/lib/animation/__tests__/binding-values.test.ts new file mode 100644 index 00000000..c5a354e4 --- /dev/null +++ b/apps/web/src/lib/animation/__tests__/binding-values.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { + composeAnimationValue, + createAnimationBinding, +} from "@/lib/animation/binding-values"; + +describe("binding values", () => { + test("formats composed animated colors as hex", () => { + const binding = createAnimationBinding({ + path: "color", + kind: "color", + }); + + expect( + composeAnimationValue({ + binding, + componentValues: { + r: 1, + g: 0, + b: 0, + a: 1, + }, + }), + ).toBe("#ff0000"); + }); +}); diff --git a/apps/web/src/lib/animation/__tests__/keyframe-query.test.ts b/apps/web/src/lib/animation/__tests__/keyframe-query.test.ts new file mode 100644 index 00000000..319493c8 --- /dev/null +++ b/apps/web/src/lib/animation/__tests__/keyframe-query.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test"; +import { + getElementKeyframes, + getKeyframeById, + getKeyframeAtTime, +} from "@/lib/animation/keyframe-query"; +import type { + ElementAnimations, + ScalarAnimationKey, +} from "@/lib/animation/types"; + +function createScalarKey({ + id, + time, + value, +}: { + id: string; + time: number; + value: number; +}): ScalarAnimationKey { + return { + id, + time, + value, + segmentToNext: "linear", + tangentMode: "flat", + }; +} + +function buildPositionAnimations({ + xKeys, + yKeys, +}: { + xKeys: ScalarAnimationKey[]; + yKeys: ScalarAnimationKey[]; +}): ElementAnimations { + return { + bindings: { + "transform.position": { + path: "transform.position", + kind: "vector2", + components: [ + { key: "x", channelId: "transform.position:x" }, + { key: "y", channelId: "transform.position:y" }, + ], + }, + }, + channels: { + "transform.position:x": { + kind: "scalar", + keys: xKeys, + }, + "transform.position:y": { + kind: "scalar", + keys: yKeys, + }, + }, + }; +} + +describe("keyframe query", () => { + test("returns keyframes from any component channel", () => { + const animations = buildPositionAnimations({ + xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })], + yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })], + }); + + expect( + getElementKeyframes({ animations }).map(({ id, time }) => ({ + id, + time, + })), + ).toEqual([ + { id: "x-1", time: 1 }, + { id: "y-2", time: 2 }, + ]); + }); + + test("finds a keyframe at time on a non-primary component", () => { + const animations = buildPositionAnimations({ + xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })], + yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })], + }); + + expect( + getKeyframeAtTime({ + animations, + propertyPath: "transform.position", + time: 2, + }), + ).toMatchObject({ + id: "y-2", + time: 2, + }); + }); + + test("finds a keyframe by id on a non-primary component", () => { + const animations = buildPositionAnimations({ + xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })], + yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })], + }); + + expect( + getKeyframeById({ + animations, + propertyPath: "transform.position", + keyframeId: "y-2", + }), + ).toMatchObject({ + id: "y-2", + time: 2, + value: { x: 10, y: 20 }, + }); + }); + + test("prefers the primary component when multiple components share a time", () => { + const animations = buildPositionAnimations({ + xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })], + yKeys: [createScalarKey({ id: "y-1", time: 1, value: 20 })], + }); + + expect( + getElementKeyframes({ animations }).map(({ id, time }) => ({ + id, + time, + })), + ).toEqual([{ id: "x-1", time: 1 }]); + expect( + getKeyframeAtTime({ + animations, + propertyPath: "transform.position", + time: 1, + }), + ).toMatchObject({ + id: "x-1", + time: 1, + }); + }); +}); diff --git a/apps/web/src/lib/animation/bezier.ts b/apps/web/src/lib/animation/bezier.ts new file mode 100644 index 00000000..4dce027e --- /dev/null +++ b/apps/web/src/lib/animation/bezier.ts @@ -0,0 +1,88 @@ +import type { ScalarAnimationKey } from "@/lib/animation/types"; + +export function getBezierPoint({ + progress, + p0, + p1, + p2, + p3, +}: { + progress: number; + p0: number; + p1: number; + p2: number; + p3: number; +}) { + const mt = 1 - progress; + return ( + mt * mt * mt * p0 + + 3 * mt * mt * progress * p1 + + 3 * mt * progress * progress * p2 + + progress * progress * progress * p3 + ); +} + +export function getDefaultRightHandle({ + leftKey, + rightKey, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + const span = rightKey.time - leftKey.time; + const valueDelta = rightKey.value - leftKey.value; + return { + dt: span / 3, + dv: valueDelta / 3, + }; +} + +export function getDefaultLeftHandle({ + leftKey, + rightKey, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + const span = rightKey.time - leftKey.time; + const valueDelta = rightKey.value - leftKey.value; + return { + dt: -span / 3, + dv: -valueDelta / 3, + }; +} + +export function solveBezierProgressForTime({ + time, + leftKey, + rightKey, +}: { + time: number; + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + let lower = 0; + let upper = 1; + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + + for (let iteration = 0; iteration < 20; iteration++) { + const mid = (lower + upper) / 2; + const estimate = getBezierPoint({ + progress: mid, + p0: leftKey.time, + p1: leftKey.time + rightHandle.dt, + p2: rightKey.time + leftHandle.dt, + p3: rightKey.time, + }); + if (estimate < time) { + lower = mid; + } else { + upper = mid; + } + } + + return (lower + upper) / 2; +} diff --git a/apps/web/src/lib/animation/binding-values.ts b/apps/web/src/lib/animation/binding-values.ts new file mode 100644 index 00000000..6f7ce995 --- /dev/null +++ b/apps/web/src/lib/animation/binding-values.ts @@ -0,0 +1,335 @@ +import { converter, formatHex, formatHex8, parse } from "culori"; +import type { + AnimationBindingComponent, + AnimationBindingOfKind, + AnimationBindingInstance, + AnimationBindingKind, + ColorAnimationBinding, + DiscreteAnimationBinding, + NumberAnimationBinding, + AnimationPath, + AnimationValue, + DiscreteValue, + Vector2AnimationBinding, + VectorValue, +} from "@/lib/animation/types"; + +interface LinearRgba { + r: number; + g: number; + b: number; + a: number; +} + +export type AnimationComponentValue = number | DiscreteValue; + +const toRgb = converter("rgb"); + +function clamp01({ value }: { value: number }): number { + return Math.max(0, Math.min(1, value)); +} + +function srgbToLinear({ value }: { value: number }): number { + return value <= 0.04045 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); +} + +function linearToSrgb({ value }: { value: number }): number { + const clamped = clamp01({ value }); + return clamped <= 0.0031308 + ? clamped * 12.92 + : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function isVectorValue(value: unknown): value is VectorValue { + return isRecord(value) && typeof value.x === "number" && typeof value.y === "number"; +} + +export function getBindingComponentKeys({ + kind, +}: { + kind: AnimationBindingKind; +}): string[] { + if (kind === "vector2") { + return ["x", "y"]; + } + + if (kind === "color") { + return ["r", "g", "b", "a"]; + } + + return ["value"]; +} + +export function buildBindingChannelId({ + path, + componentKey, +}: { + path: AnimationPath; + componentKey: string; +}): string { + return `${path}:${componentKey}`; +} + +function createBindingComponent({ + path, + key, +}: { + path: AnimationPath; + key: TKey; +}): AnimationBindingComponent { + return { + key, + channelId: buildBindingChannelId({ path, componentKey: key }), + }; +} + +function cloneBindingComponents({ + components, +}: { + components: AnimationBindingComponent[]; +}): AnimationBindingComponent[] { + return components.map((component) => ({ ...component })); +} + +const animationBindingFactories = { + color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({ + path, + kind: "color", + colorSpace: "srgb-linear", + components: [ + createBindingComponent({ path, key: "r" }), + createBindingComponent({ path, key: "g" }), + createBindingComponent({ path, key: "b" }), + createBindingComponent({ path, key: "a" }), + ], + }), + vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({ + path, + kind: "vector2", + components: [ + createBindingComponent({ path, key: "x" }), + createBindingComponent({ path, key: "y" }), + ], + }), + number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({ + path, + kind: "number", + components: [createBindingComponent({ path, key: "value" })], + }), + discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({ + path, + kind: "discrete", + components: [createBindingComponent({ path, key: "value" })], + }), +} satisfies { + [K in AnimationBindingKind]: ({ + path, + }: { + path: AnimationPath; + }) => AnimationBindingOfKind; +}; + +export function createAnimationBinding({ + path, + kind, +}: { + path: AnimationPath; + kind: TKind; +}): AnimationBindingOfKind; +export function createAnimationBinding({ + path, + kind, +}: { + path: AnimationPath; + kind: AnimationBindingKind; +}): AnimationBindingInstance { + return animationBindingFactories[kind]({ path }); +} + +const animationBindingCloners = { + color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + vector2: ({ + binding, + }: { + binding: Vector2AnimationBinding; + }): Vector2AnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + number: ({ + binding, + }: { + binding: NumberAnimationBinding; + }): NumberAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + discrete: ({ + binding, + }: { + binding: DiscreteAnimationBinding; + }): DiscreteAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), +} satisfies { + [K in AnimationBindingKind]: ({ + binding, + }: { + binding: AnimationBindingOfKind; + }) => AnimationBindingOfKind; +}; + +export function cloneAnimationBinding({ + binding, +}: { + binding: AnimationBindingOfKind; +}): AnimationBindingOfKind; +export function cloneAnimationBinding({ + binding, +}: { + binding: AnimationBindingInstance; +}): AnimationBindingInstance { + switch (binding.kind) { + case "color": + return animationBindingCloners.color({ binding }); + case "vector2": + return animationBindingCloners.vector2({ binding }); + case "number": + return animationBindingCloners.number({ binding }); + case "discrete": + return animationBindingCloners.discrete({ binding }); + } +} + +export function parseColorToLinearRgba({ + color, +}: { + color: string; +}): LinearRgba | null { + const parsed = parse(color); + const rgb = parsed ? toRgb(parsed) : null; + if (!rgb) { + return null; + } + + return { + r: srgbToLinear({ value: rgb.r ?? 0 }), + g: srgbToLinear({ value: rgb.g ?? 0 }), + b: srgbToLinear({ value: rgb.b ?? 0 }), + a: clamp01({ value: rgb.alpha ?? 1 }), + }; +} + +export function formatLinearRgba({ + color, +}: { + color: LinearRgba; +}): string { + const rgb = { + mode: "rgb", + r: linearToSrgb({ value: color.r }), + g: linearToSrgb({ value: color.g }), + b: linearToSrgb({ value: color.b }), + alpha: clamp01({ value: color.a }), + } as const; + return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb); +} + +export function decomposeAnimationValue({ + kind, + value, +}: { + kind: AnimationBindingKind; + value: AnimationValue; +}): Record | null { + if (kind === "number") { + return typeof value === "number" ? { value } : null; + } + + if (kind === "vector2") { + return isVectorValue(value) ? { x: value.x, y: value.y } : null; + } + + if (kind === "color") { + if (typeof value !== "string") { + return null; + } + const parsed = parseColorToLinearRgba({ color: value }); + if (!parsed) { + return null; + } + return { + r: parsed.r, + g: parsed.g, + b: parsed.b, + a: parsed.a, + }; + } + + return typeof value === "string" || typeof value === "boolean" + ? { value } + : null; +} + +export function composeAnimationValue({ + binding, + componentValues, +}: { + binding: AnimationBindingInstance; + componentValues: Record; +}): AnimationValue | null { + if (binding.kind === "number") { + const value = componentValues.value; + return typeof value === "number" ? value : null; + } + + if (binding.kind === "vector2") { + const x = componentValues.x; + const y = componentValues.y; + return typeof x === "number" && typeof y === "number" ? { x, y } : null; + } + + if (binding.kind === "color") { + const r = componentValues.r; + const g = componentValues.g; + const b = componentValues.b; + const a = componentValues.a; + if ( + typeof r !== "number" || + typeof g !== "number" || + typeof b !== "number" || + typeof a !== "number" + ) { + return null; + } + return formatLinearRgba({ + color: { + r, + g, + b, + a, + }, + }); + } + + const value = componentValues.value; + return typeof value === "string" || typeof value === "boolean" ? value : null; +} diff --git a/apps/web/src/lib/animation/color-channel.ts b/apps/web/src/lib/animation/color-channel.ts deleted file mode 100644 index c1c05f9a..00000000 --- a/apps/web/src/lib/animation/color-channel.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { - AnimationPropertyPath, - ColorAnimationChannel, - ElementAnimations, -} from "@/lib/animation/types"; - -export function getColorChannelForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; -}): ColorAnimationChannel | undefined { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.valueKind !== "color") { - return undefined; - } - return channel; -} diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts index a614c14f..3080e6ff 100644 --- a/apps/web/src/lib/animation/effect-param-channel.ts +++ b/apps/web/src/lib/animation/effect-param-channel.ts @@ -3,15 +3,9 @@ import type { Effect } from "@/lib/effects/types"; import type { ElementAnimations, EffectParamPath, - NumberAnimationChannel, } from "@/lib/animation/types"; -import { - getChannel, - removeKeyframe, - setChannel, - upsertKeyframe, -} from "./keyframes"; -import { getChannelValueAtTime } from "./interpolation"; +import { removeElementKeyframe } from "./keyframes"; +import { resolveAnimationPathValueAtTime } from "./resolve"; export const EFFECT_PARAM_PATH_PREFIX = "effects."; export const EFFECT_PARAM_PATH_SUFFIX = ".params."; @@ -74,64 +68,19 @@ export function resolveEffectParamsAtTime({ for (const [paramKey, staticValue] of Object.entries(effect.params)) { const path = buildEffectParamPath({ effectId: effect.id, paramKey }); - const channel = getChannel({ animations, propertyPath: path }); - if (channel && channel.keyframes.length > 0) { - resolved[paramKey] = getChannelValueAtTime({ - channel, - time: localTime, - fallbackValue: staticValue, - }) as number | string | boolean; - } else { - resolved[paramKey] = staticValue; - } + resolved[paramKey] = animations?.bindings[path] + ? resolveAnimationPathValueAtTime({ + animations, + propertyPath: path, + localTime, + fallbackValue: staticValue, + }) + : staticValue; } return resolved; } -const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = { - valueKind: "number", - keyframes: [], -}; - -export function upsertEffectParamKeyframe({ - animations, - effectId, - paramKey, - time, - value, - interpolation, - keyframeId, -}: { - animations: ElementAnimations | undefined; - effectId: string; - paramKey: string; - time: number; - value: number; - interpolation?: "linear" | "hold"; - keyframeId?: string; -}): ElementAnimations | undefined { - const path = buildEffectParamPath({ effectId, paramKey }); - const channel = getChannel({ animations, propertyPath: path }); - const targetChannel = - channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL; - const updatedChannel = upsertKeyframe({ - channel: targetChannel, - time, - value, - interpolation: interpolation ?? "linear", - keyframeId, - }); - - return ( - setChannel({ - animations, - propertyPath: path, - channel: updatedChannel, - }) ?? { channels: {} } - ); -} - export function removeEffectParamKeyframe({ animations, effectId, @@ -143,12 +92,9 @@ export function removeEffectParamKeyframe({ paramKey: string; keyframeId: string; }): ElementAnimations | undefined { - const path = buildEffectParamPath({ effectId, paramKey }); - const channel = getChannel({ animations, propertyPath: path }); - const updatedChannel = removeKeyframe({ channel, keyframeId }); - return setChannel({ + return removeElementKeyframe({ animations, - propertyPath: path, - channel: updatedChannel, + propertyPath: buildEffectParamPath({ effectId, paramKey }), + keyframeId, }); } diff --git a/apps/web/src/lib/animation/graphic-param-channel.ts b/apps/web/src/lib/animation/graphic-param-channel.ts index 22f78ba5..de595060 100644 --- a/apps/web/src/lib/animation/graphic-param-channel.ts +++ b/apps/web/src/lib/animation/graphic-param-channel.ts @@ -1,77 +1,73 @@ -import type { - ElementAnimations, - GraphicParamPath, -} from "@/lib/animation/types"; -import type { ParamValues } from "@/lib/params"; -import { - getGraphicDefinition, - resolveGraphicParams, -} from "@/lib/graphics"; -import { getChannel } from "./keyframes"; -import { getChannelValueAtTime } from "./interpolation"; - -export const GRAPHIC_PARAM_PATH_PREFIX = "params."; - -export function buildGraphicParamPath({ - paramKey, -}: { - paramKey: string; -}): GraphicParamPath { - return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`; -} - -export function isGraphicParamPath( - propertyPath: string, -): propertyPath is GraphicParamPath { - return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX); -} - -export function parseGraphicParamPath({ - propertyPath, -}: { - propertyPath: string; -}): { paramKey: string } | null { - if (!isGraphicParamPath(propertyPath)) { - return null; - } - - const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length); - return paramKey.length > 0 ? { paramKey } : null; -} - -export function resolveGraphicParamsAtTime({ - element, - localTime, -}: { - element: { - definitionId: string; - params: ParamValues; - animations?: ElementAnimations; - }; - localTime: number; -}): ParamValues { - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const baseParams = resolveGraphicParams(definition, element.params); - const resolved: ParamValues = { ...baseParams }; - - for (const param of definition.params) { - const path = buildGraphicParamPath({ paramKey: param.key }); - const channel = getChannel({ - animations: element.animations, - propertyPath: path, - }); - if (!channel || channel.keyframes.length === 0) { - continue; - } - - resolved[param.key] = getChannelValueAtTime({ - channel, - time: Math.max(0, localTime), - fallbackValue: baseParams[param.key] ?? param.default, - }) as number | string | boolean; - } - - return resolved; -} +import type { + ElementAnimations, + GraphicParamPath, +} from "@/lib/animation/types"; +import type { ParamValues } from "@/lib/params"; +import { + getGraphicDefinition, + resolveGraphicParams, +} from "@/lib/graphics"; +import { resolveAnimationPathValueAtTime } from "./resolve"; + +export const GRAPHIC_PARAM_PATH_PREFIX = "params."; + +export function buildGraphicParamPath({ + paramKey, +}: { + paramKey: string; +}): GraphicParamPath { + return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`; +} + +export function isGraphicParamPath( + propertyPath: string, +): propertyPath is GraphicParamPath { + return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX); +} + +export function parseGraphicParamPath({ + propertyPath, +}: { + propertyPath: string; +}): { paramKey: string } | null { + if (!isGraphicParamPath(propertyPath)) { + return null; + } + + const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length); + return paramKey.length > 0 ? { paramKey } : null; +} + +export function resolveGraphicParamsAtTime({ + element, + localTime, +}: { + element: { + definitionId: string; + params: ParamValues; + animations?: ElementAnimations; + }; + localTime: number; +}): ParamValues { + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + const baseParams = resolveGraphicParams(definition, element.params); + const resolved: ParamValues = { ...baseParams }; + + for (const param of definition.params) { + const path = buildGraphicParamPath({ paramKey: param.key }); + if (!element.animations?.bindings[path]) { + continue; + } + + resolved[param.key] = resolveAnimationPathValueAtTime({ + animations: element.animations, + propertyPath: path, + localTime: Math.max(0, localTime), + fallbackValue: baseParams[param.key] ?? param.default, + }); + } + + return resolved; +} diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts index 819a59e5..9cb85d2a 100644 --- a/apps/web/src/lib/animation/index.ts +++ b/apps/web/src/lib/animation/index.ts @@ -1,71 +1,81 @@ -export { - getChannelValueAtTime, - getNumberChannelValueAtTime, - getVectorChannelValueAtTime, - normalizeChannel, -} from "./interpolation"; - -export { - clampAnimationsToDuration, - cloneAnimations, - getChannel, - removeElementKeyframe, - retimeElementKeyframe, - setChannel, - splitAnimationsAtTime, - upsertElementKeyframe, - upsertPathKeyframe, -} from "./keyframes"; - -export { - getElementLocalTime, - resolveColorAtTime, - resolveNumberAtTime, - resolveOpacityAtTime, - resolveTransformAtTime, -} from "./resolve"; - -export { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getDefaultInterpolationForProperty, - getElementBaseValueForProperty, - isAnimationPropertyPath, - supportsAnimationProperty, - type AnimationPropertyDefinition, - type NumericSpec, - type NumericRange, - withElementBaseValueForProperty, -} from "./property-registry"; - -export { - getElementKeyframes, - getKeyframeAtTime, - hasKeyframesForPath, -} from "./keyframe-query"; - -export { - buildGraphicParamPath, - isGraphicParamPath, - parseGraphicParamPath, - resolveGraphicParamsAtTime, -} from "./graphic-param-channel"; - -export { - isAnimationPath, - resolveAnimationTarget, - getParamValueKind, - getParamDefaultInterpolation, - type AnimationPathDescriptor, -} from "./target-resolver"; - -export { - getGroupKeyframesAtTime, - hasGroupKeyframeAtTime, - type GroupKeyframeRef, -} from "./property-groups"; - -export { - getVectorChannelForPath, - isVectorValue, -} from "./vector-channel"; +export { + getChannelValueAtTime, + getDiscreteChannelValueAtTime, + getScalarChannelValueAtTime, + getScalarSegmentInterpolation, + normalizeChannel, +} from "./interpolation"; + +export { + clampAnimationsToDuration, + cloneAnimations, + getChannel, + removeElementKeyframe, + retimeElementKeyframe, + setChannel, + splitAnimationsAtTime, + upsertElementKeyframe, + upsertPathKeyframe, +} from "./keyframes"; + +export { + getElementLocalTime, + resolveAnimationPathValueAtTime, + resolveColorAtTime, + resolveNumberAtTime, + resolveOpacityAtTime, + resolveTransformAtTime, +} from "./resolve"; + +export { + coerceAnimationValueForProperty, + getAnimationPropertyDefinition, + getDefaultInterpolationForProperty, + getElementBaseValueForProperty, + isAnimationPropertyPath, + supportsAnimationProperty, + type AnimationPropertyDefinition, + type NumericSpec, + withElementBaseValueForProperty, +} from "./property-registry"; + +export { + getElementKeyframes, + getKeyframeById, + getKeyframeAtTime, + hasKeyframesForPath, +} from "./keyframe-query"; + +export { + buildGraphicParamPath, + isGraphicParamPath, + parseGraphicParamPath, + resolveGraphicParamsAtTime, +} from "./graphic-param-channel"; + +export { + buildEffectParamPath, + isEffectParamPath, + parseEffectParamPath, + removeEffectParamKeyframe, + resolveEffectParamsAtTime, +} from "./effect-param-channel"; + +export { + isAnimationPath, + coerceAnimationValueForParam, + resolveAnimationTarget, + getParamValueKind, + getParamDefaultInterpolation, + type AnimationPathDescriptor, +} from "./target-resolver"; + +export { + getGroupKeyframesAtTime, + hasGroupKeyframeAtTime, + type GroupKeyframeRef, +} from "./property-groups"; + +export { + isVectorValue, +} from "./binding-values"; diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts index c5b9ea80..9609441c 100644 --- a/apps/web/src/lib/animation/interpolation.ts +++ b/apps/web/src/lib/animation/interpolation.ts @@ -1,15 +1,20 @@ import type { AnimationChannel, + AnimationInterpolation, AnimationValue, - ColorAnimationChannel, - DiscreteValue, DiscreteAnimationChannel, - NumberAnimationChannel, - VectorAnimationChannel, - VectorValue, + DiscreteValue, + ScalarAnimationChannel, + ScalarAnimationKey, + ScalarSegmentType, } from "@/lib/animation/types"; -import { isVectorValue } from "./vector-channel"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { + getBezierPoint, + getDefaultLeftHandle, + getDefaultRightHandle, + solveBezierProgressForTime, +} from "./bezier"; function byTimeAscending({ leftTime, @@ -40,69 +45,6 @@ function clamp01({ value }: { value: number }): number { return Math.max(0, Math.min(1, value)); } -function parseHexChannel({ hex }: { hex: string }): number | null { - const value = Number.parseInt(hex, 16); - return Number.isNaN(value) ? null : value; -} - -function parseHexColor({ - color, -}: { - color: string; -}): { red: number; green: number; blue: number; alpha: number } | null { - const trimmed = color.trim(); - if (!trimmed.startsWith("#")) { - return null; - } - - const rawHex = trimmed.slice(1); - if (rawHex.length === 3 || rawHex.length === 4) { - const [redHex, greenHex, blueHex, alphaHex = "f"] = rawHex.split(""); - const red = parseHexChannel({ hex: `${redHex}${redHex}` }); - const green = parseHexChannel({ hex: `${greenHex}${greenHex}` }); - const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` }); - const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` }); - if (red === null || green === null || blue === null || alpha === null) { - return null; - } - - return { red, green, blue, alpha: alpha / 255 }; - } - - if (rawHex.length === 6 || rawHex.length === 8) { - const red = parseHexChannel({ hex: rawHex.slice(0, 2) }); - const green = parseHexChannel({ hex: rawHex.slice(2, 4) }); - const blue = parseHexChannel({ hex: rawHex.slice(4, 6) }); - const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff"; - const alpha = parseHexChannel({ hex: alphaHex }); - if (red === null || green === null || blue === null || alpha === null) { - return null; - } - - return { red, green, blue, alpha: alpha / 255 }; - } - - return null; -} - -function formatRgbaColor({ - red, - green, - blue, - alpha, -}: { - red: number; - green: number; - blue: number; - alpha: number; -}): string { - const roundedRed = Math.round(red); - const roundedGreen = Math.round(green); - const roundedBlue = Math.round(blue); - const roundedAlpha = Math.round(clamp01({ value: alpha }) * 1000) / 1000; - return `rgba(${roundedRed}, ${roundedGreen}, ${roundedBlue}, ${roundedAlpha})`; -} - function lerpNumber({ leftValue, rightValue, @@ -115,43 +57,99 @@ function lerpNumber({ return leftValue + (rightValue - leftValue) * progress; } -function interpolateColor({ - leftColor, - rightColor, - progress, +function normalizeRightHandle({ + handle, + leftKey, + rightKey, }: { - leftColor: string; - rightColor: string; - progress: number; -}): string { - const leftParsed = parseHexColor({ color: leftColor }); - const rightParsed = parseHexColor({ color: rightColor }); - if (!leftParsed || !rightParsed) { - return progress >= 1 ? rightColor : leftColor; + handle: ScalarAnimationKey["rightHandle"]; + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + if (!handle) { + return undefined; } - return formatRgbaColor({ - red: lerpNumber({ - leftValue: leftParsed.red, - rightValue: rightParsed.red, - progress, - }), - green: lerpNumber({ - leftValue: leftParsed.green, - rightValue: rightParsed.green, - progress, - }), - blue: lerpNumber({ - leftValue: leftParsed.blue, - rightValue: rightParsed.blue, - progress, - }), - alpha: lerpNumber({ - leftValue: leftParsed.alpha, - rightValue: rightParsed.alpha, - progress, - }), + const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time); + return { + dt: Math.min(span, Math.max(0, handle.dt)), + dv: handle.dv, + }; +} + +function normalizeLeftHandle({ + handle, + leftKey, + rightKey, +}: { + handle: ScalarAnimationKey["leftHandle"]; + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + if (!handle) { + return undefined; + } + + const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time); + return { + dt: Math.max(-span, Math.min(0, handle.dt)), + dv: handle.dv, + }; +} + +function normalizeScalarKey({ + key, +}: { + key: ScalarAnimationKey; +}): ScalarAnimationKey { + return { + ...key, + tangentMode: key.tangentMode ?? "flat", + segmentToNext: key.segmentToNext ?? "linear", + }; +} + +function normalizeScalarChannel({ + channel, +}: { + channel: ScalarAnimationChannel; +}): ScalarAnimationChannel { + const sortedKeys = [...channel.keys] + .map((key) => normalizeScalarKey({ key })) + .sort((leftKey, rightKey) => + byTimeAscending({ + leftTime: leftKey.time, + rightTime: rightKey.time, + }), + ); + const nextKeys = sortedKeys.map((key, index) => { + const previousKey = sortedKeys[index - 1]; + const nextKey = sortedKeys[index + 1]; + return { + ...key, + leftHandle: + previousKey != null + ? normalizeLeftHandle({ + handle: key.leftHandle, + leftKey: previousKey, + rightKey: key, + }) + : undefined, + rightHandle: + nextKey != null + ? normalizeRightHandle({ + handle: key.rightHandle, + leftKey: key, + rightKey: nextKey, + }) + : undefined, + }; }); + + return { + ...channel, + keys: nextKeys, + }; } export function normalizeChannel({ @@ -159,9 +157,15 @@ export function normalizeChannel({ }: { channel: TChannel; }): TChannel { + if (channel.kind === "scalar") { + return normalizeScalarChannel({ + channel, + }) as TChannel; + } + return { ...channel, - keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) => + keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) => byTimeAscending({ leftTime: leftKeyframe.time, rightTime: rightKeyframe.time, @@ -170,171 +174,152 @@ export function normalizeChannel({ } as TChannel; } -function evaluateChannelValueAtTime< - TKeyframe extends { time: number; value: TValue }, - TValue, ->({ - keyframes, +function extrapolateScalarEdge({ + mode, + edgeKey, + neighborKey, time, - fallbackValue, - getInterpolatedValue, }: { - keyframes: TKeyframe[] | undefined; + mode: "hold" | "linear"; + edgeKey: ScalarAnimationKey; + neighborKey: ScalarAnimationKey | undefined; time: number; - fallbackValue: TValue; - getInterpolatedValue: ({ - leftKeyframe, - rightKeyframe, - progress, - }: { - leftKeyframe: TKeyframe; - rightKeyframe: TKeyframe; - progress: number; - }) => TValue; -}): TValue { - if (!keyframes || keyframes.length === 0) { - return fallbackValue; +}) { + if (mode === "hold" || !neighborKey) { + return edgeKey.value; } - const firstKeyframe = keyframes[0]; - const lastKeyframe = keyframes[keyframes.length - 1]; - if (!firstKeyframe || !lastKeyframe) { - return fallbackValue; + const span = neighborKey.time - edgeKey.time; + if (Math.abs(span) <= TIME_EPSILON_SECONDS) { + return edgeKey.value; } - if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) { - return firstKeyframe.value; - } - - if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) { - return lastKeyframe.value; - } - - for ( - let keyframeIndex = 0; - keyframeIndex < keyframes.length - 1; - keyframeIndex++ - ) { - const leftKeyframe = keyframes[keyframeIndex]; - const rightKeyframe = keyframes[keyframeIndex + 1]; - - if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) { - return rightKeyframe.value; - } - - const isBetweenPair = isWithinTimePair({ - time, - leftTime: leftKeyframe.time, - rightTime: rightKeyframe.time, - }); - if (!isBetweenPair) { - continue; - } - - const span = rightKeyframe.time - leftKeyframe.time; - if (Math.abs(span) <= TIME_EPSILON_SECONDS) { - return rightKeyframe.value; - } - - const progress = clamp01({ - value: (time - leftKeyframe.time) / span, - }); - - return getInterpolatedValue({ - leftKeyframe, - rightKeyframe, - progress, - }); - } - - return lastKeyframe.value; + return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value); } -export function getNumberChannelValueAtTime({ +export function getScalarSegmentInterpolation({ + segment, +}: { + segment: ScalarSegmentType; +}): AnimationInterpolation { + if (segment === "step") { + return "hold"; + } + + return segment === "bezier" ? "bezier" : "linear"; +} + +export function getScalarChannelValueAtTime({ channel, time, fallbackValue, }: { - channel: NumberAnimationChannel | undefined; + channel: ScalarAnimationChannel | undefined; time: number; fallbackValue: number; }): number { - return evaluateChannelValueAtTime({ - keyframes: channel?.keyframes, - time, - fallbackValue, - getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => { - if (leftKeyframe.interpolation === "hold") { - return leftKeyframe.value; - } + if (!channel || channel.keys.length === 0) { + return fallbackValue; + } + const normalizedChannel = normalizeChannel({ + channel, + }); + const firstKey = normalizedChannel.keys[0]; + const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1]; + if (!firstKey || !lastKey) { + return fallbackValue; + } + + if (time <= firstKey.time + TIME_EPSILON_SECONDS) { + if (time < firstKey.time - TIME_EPSILON_SECONDS) { + return extrapolateScalarEdge({ + mode: normalizedChannel.extrapolation?.before ?? "hold", + edgeKey: firstKey, + neighborKey: normalizedChannel.keys[1], + time, + }); + } + + return firstKey.value; + } + + if (time >= lastKey.time - TIME_EPSILON_SECONDS) { + if (time > lastKey.time + TIME_EPSILON_SECONDS) { + return extrapolateScalarEdge({ + mode: normalizedChannel.extrapolation?.after ?? "hold", + edgeKey: lastKey, + neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2], + time, + }); + } + + return lastKey.value; + } + + for ( + let keyIndex = 0; + keyIndex < normalizedChannel.keys.length - 1; + keyIndex++ + ) { + const leftKey = normalizedChannel.keys[keyIndex]; + const rightKey = normalizedChannel.keys[keyIndex + 1]; + if (Math.abs(time - rightKey.time) <= TIME_EPSILON_SECONDS) { + return rightKey.value; + } + + if ( + !isWithinTimePair({ + time, + leftTime: leftKey.time, + rightTime: rightKey.time, + }) + ) { + continue; + } + + if (leftKey.segmentToNext === "step") { + return leftKey.value; + } + + const span = rightKey.time - leftKey.time; + if (Math.abs(span) <= TIME_EPSILON_SECONDS) { + return rightKey.value; + } + + const progress = clamp01({ + value: (time - leftKey.time) / span, + }); + if (leftKey.segmentToNext === "linear") { return lerpNumber({ - leftValue: leftKeyframe.value, - rightValue: rightKeyframe.value, + leftValue: leftKey.value, + rightValue: rightKey.value, progress, }); - }, - }); + } + + const curveProgress = solveBezierProgressForTime({ + time, + leftKey, + rightKey, + }); + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + return getBezierPoint({ + progress: curveProgress, + p0: leftKey.value, + p1: leftKey.value + rightHandle.dv, + p2: rightKey.value + leftHandle.dv, + p3: rightKey.value, + }); + } + + return lastKey.value; } -export function getColorValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: ColorAnimationChannel | undefined; - time: number; - fallbackValue: string; -}): string { - return evaluateChannelValueAtTime({ - keyframes: channel?.keyframes, - time, - fallbackValue, - getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => { - if (leftKeyframe.interpolation === "hold") { - return leftKeyframe.value; - } - - return interpolateColor({ - leftColor: leftKeyframe.value, - rightColor: rightKeyframe.value, - progress, - }); - }, - }); -} - -export function getVectorChannelValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: VectorAnimationChannel | undefined; - time: number; - fallbackValue: VectorValue; -}): VectorValue { - return evaluateChannelValueAtTime({ - keyframes: channel?.keyframes, - time, - fallbackValue, - getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => { - if (leftKeyframe.interpolation === "hold") { - return leftKeyframe.value; - } - - return { - x: - leftKeyframe.value.x + - (rightKeyframe.value.x - leftKeyframe.value.x) * progress, - y: - leftKeyframe.value.y + - (rightKeyframe.value.y - leftKeyframe.value.y) * progress, - }; - }, - }); -} - -function getDiscreteValueAtTime({ +export function getDiscreteChannelValueAtTime({ channel, time, fallbackValue, @@ -343,12 +328,21 @@ function getDiscreteValueAtTime({ time: number; fallbackValue: DiscreteValue; }): DiscreteValue { - return evaluateChannelValueAtTime({ - keyframes: channel?.keyframes, - time, - fallbackValue, - getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value, + if (!channel || channel.keys.length === 0) { + return fallbackValue; + } + + const normalizedChannel = normalizeChannel({ + channel, }); + let currentValue = fallbackValue; + for (const key of normalizedChannel.keys) { + if (time + TIME_EPSILON_SECONDS < key.time) { + break; + } + currentValue = key.value; + } + return currentValue; } export function getChannelValueAtTime({ @@ -360,50 +354,25 @@ export function getChannelValueAtTime({ time: number; fallbackValue: AnimationValue; }): AnimationValue { - if (!channel || channel.keyframes.length === 0) { + if (!channel || channel.keys.length === 0) { return fallbackValue; } - if (channel.valueKind === "number") { - if (typeof fallbackValue !== "number") { - return fallbackValue; - } - - return getNumberChannelValueAtTime({ - channel, - time, - fallbackValue, - }); - } - - if (channel.valueKind === "color") { - if (typeof fallbackValue !== "string") { - return fallbackValue; - } - - return getColorValueAtTime({ - channel, - time, - fallbackValue, - }); - } - - if (channel.valueKind === "vector") { - if (!isVectorValue(fallbackValue)) { - return fallbackValue; - } - return getVectorChannelValueAtTime({ - channel, - time, - fallbackValue: fallbackValue as VectorValue, - }); + if (channel.kind === "scalar") { + return typeof fallbackValue === "number" + ? getScalarChannelValueAtTime({ + channel, + time, + fallbackValue, + }) + : fallbackValue; } if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") { return fallbackValue; } - return getDiscreteValueAtTime({ + return getDiscreteChannelValueAtTime({ channel, time, fallbackValue, diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts index 62c8a171..304a6698 100644 --- a/apps/web/src/lib/animation/keyframe-query.ts +++ b/apps/web/src/lib/animation/keyframe-query.ts @@ -1,72 +1,298 @@ -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; -import type { - AnimationPath, - ElementAnimations, - ElementKeyframe, -} from "@/lib/animation/types"; -import { isAnimationPath } from "./target-resolver"; - -export function getElementKeyframes({ - animations, -}: { - animations: ElementAnimations | undefined; -}): ElementKeyframe[] { - if (!animations) { - return []; - } - - return Object.entries(animations.channels).flatMap( - ([propertyPath, channel]) => { - if ( - !channel || - channel.keyframes.length === 0 || - !isAnimationPath(propertyPath) - ) { - return []; - } - - return channel.keyframes.map((keyframe) => ({ - propertyPath, - id: keyframe.id, - time: keyframe.time, - value: keyframe.value, - interpolation: keyframe.interpolation, - })); - }, - ); -} - -export function hasKeyframesForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): boolean { - const channel = animations?.channels[propertyPath]; - return Boolean(channel && channel.keyframes.length > 0); -} - -export function getKeyframeAtTime({ - animations, - propertyPath, - time, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - time: number; -}): ElementKeyframe | null { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.keyframes.length === 0) return null; - const keyframe = channel.keyframes.find( - (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS, - ); - if (!keyframe) return null; - return { - propertyPath, - id: keyframe.id, - time: keyframe.time, - value: keyframe.value, - interpolation: keyframe.interpolation, - }; -} +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import type { + AnimationBindingInstance, + AnimationChannel, + AnimationPath, + ElementAnimations, + ElementKeyframe, +} from "@/lib/animation/types"; +import { + type AnimationComponentValue, + composeAnimationValue, +} from "./binding-values"; +import { + getChannelValueAtTime, + getScalarSegmentInterpolation, +} from "./interpolation"; +import { isAnimationPath } from "./target-resolver"; + +function getBindingFallbackValue({ + channel, +}: { + channel: ElementAnimations["channels"][string]; +}) { + if (!channel || channel.keys.length === 0) { + return channel?.kind === "discrete" ? false : 0; + } + + return channel.keys[0].value; +} + +interface BindingKeyframeMatch { + componentIndex: number; + channel: AnimationChannel; + keyframe: AnimationChannel["keys"][number]; +} + +function getBindingKeyframeMatches({ + animations, + binding, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; +}): BindingKeyframeMatch[] { + return binding.components.flatMap((component, componentIndex) => { + const channel = animations.channels[component.channelId]; + if (!channel || channel.keys.length === 0) { + return []; + } + + return channel.keys.map((keyframe) => ({ + componentIndex, + channel, + keyframe, + })); + }); +} + +function getUniqueBindingKeyframeMatches({ + animations, + binding, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; +}): BindingKeyframeMatch[] { + const sortedMatches = getBindingKeyframeMatches({ + animations, + binding, + }).sort( + (leftMatch, rightMatch) => + leftMatch.keyframe.time - rightMatch.keyframe.time || + leftMatch.componentIndex - rightMatch.componentIndex, + ); + const uniqueMatches: BindingKeyframeMatch[] = []; + + for (const match of sortedMatches) { + const previousMatch = uniqueMatches[uniqueMatches.length - 1]; + if ( + !previousMatch || + Math.abs(previousMatch.keyframe.time - match.keyframe.time) > + TIME_EPSILON_SECONDS + ) { + uniqueMatches.push(match); + continue; + } + + if ( + previousMatch.componentIndex !== 0 && + match.componentIndex === 0 + ) { + uniqueMatches[uniqueMatches.length - 1] = match; + } + } + + return uniqueMatches; +} + +function getPreferredBindingKeyframeMatch({ + matches, +}: { + matches: BindingKeyframeMatch[]; +}): BindingKeyframeMatch | null { + return ( + matches.find((match) => match.componentIndex === 0) ?? + matches[0] ?? + null + ); +} + +function getComposedBindingValueAtTime({ + animations, + binding, + time, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; + time: number; +}) { + const componentValues = Object.fromEntries( + binding.components.map((component) => { + const channel = animations.channels[component.channelId]; + return [ + component.key, + getChannelValueAtTime({ + channel, + time, + fallbackValue: getBindingFallbackValue({ channel }), + }), + ]; + }), + ) as Record; + + return composeAnimationValue({ + binding, + componentValues, + }); +} + +function getKeyframeInterpolation({ + channel, + keyframe, +}: { + channel: AnimationChannel; + keyframe: AnimationChannel["keys"][number]; +}) { + return channel.kind === "scalar" && "segmentToNext" in keyframe + ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext }) + : "hold"; +} + +function toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; + propertyPath: AnimationPath; + keyframeMatch: BindingKeyframeMatch; +}): ElementKeyframe | null { + const value = getComposedBindingValueAtTime({ + animations, + binding, + time: keyframeMatch.keyframe.time, + }); + if (value === null) { + return null; + } + + return { + propertyPath, + id: keyframeMatch.keyframe.id, + time: keyframeMatch.keyframe.time, + value, + interpolation: getKeyframeInterpolation({ + channel: keyframeMatch.channel, + keyframe: keyframeMatch.keyframe, + }), + }; +} + +export function getElementKeyframes({ + animations, +}: { + animations: ElementAnimations | undefined; +}): ElementKeyframe[] { + if (!animations) { + return []; + } + + return Object.entries(animations.bindings).flatMap( + ([propertyPath, binding]) => { + if (!binding || !isAnimationPath(propertyPath)) { + return []; + } + + return getUniqueBindingKeyframeMatches({ + animations, + binding, + }).flatMap((keyframeMatch) => { + const keyframe = toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); + if (!keyframe) { + return []; + } + + return [keyframe]; + }); + }, + ); +} + +export function hasKeyframesForPath({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; +}): boolean { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return false; + } + + return binding.components.some((component) => + Boolean(animations?.channels[component.channelId]?.keys.length), + ); +} + +export function getKeyframeAtTime({ + animations, + propertyPath, + time, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + time: number; +}): ElementKeyframe | null { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return null; + } + + const keyframeMatch = getPreferredBindingKeyframeMatch({ + matches: getBindingKeyframeMatches({ + animations, + binding, + }).filter(({ keyframe }) => + Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS, + ), + }); + if (!keyframeMatch) { + return null; + } + + return toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); +} + +export function getKeyframeById({ + animations, + propertyPath, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + keyframeId: string; +}): ElementKeyframe | null { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return null; + } + + const keyframeMatch = getPreferredBindingKeyframeMatch({ + matches: getBindingKeyframeMatches({ + animations, + binding, + }).filter(({ keyframe }) => keyframe.id === keyframeId), + }); + if (!keyframeMatch) { + return null; + } + + return toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); +} diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts index 13cdf238..8c17f8a6 100644 --- a/apps/web/src/lib/animation/keyframes.ts +++ b/apps/web/src/lib/animation/keyframes.ts @@ -1,28 +1,39 @@ import type { + AnimationBindingInstance, + AnimationBindingKind, AnimationChannel, AnimationInterpolation, - AnimationKeyframe, AnimationPath, AnimationPropertyPath, AnimationValue, - AnimationValueKind, - ColorAnimationChannel, DiscreteAnimationChannel, + DiscreteAnimationKey, ElementAnimations, - NumberAnimationChannel, - VectorAnimationChannel, + ScalarAnimationChannel, + ScalarAnimationKey, + ScalarSegmentType, } from "@/lib/animation/types"; -import { isVectorValue } from "./vector-channel"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; import { generateUUID } from "@/utils/id"; -import { snapToStep } from "@/utils/math"; -import { getChannelValueAtTime, normalizeChannel } from "./interpolation"; +import { + cloneAnimationBinding, + createAnimationBinding, + decomposeAnimationValue, +} from "./binding-values"; +import { + getBezierPoint, + getDefaultLeftHandle, + getDefaultRightHandle, + solveBezierProgressForTime, +} from "./bezier"; +import { + getChannelValueAtTime, + getScalarSegmentInterpolation, + normalizeChannel, +} from "./interpolation"; import { coerceAnimationValueForProperty, - getDefaultInterpolationForProperty, getAnimationPropertyDefinition, - isAnimationPropertyPath, - type NumericRange, } from "./property-registry"; function isNearlySameTime({ @@ -35,32 +46,354 @@ function isNearlySameTime({ return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS; } -function toAnimation({ - channelEntries, +function hasChannelKeys({ + channel, }: { - channelEntries: Array<[string, AnimationChannel]>; + channel: AnimationChannel | undefined; +}): boolean { + return Boolean(channel && channel.keys.length > 0); +} + +function toAnimation({ + animations, +}: { + animations: ElementAnimations; }): ElementAnimations | undefined { - if (channelEntries.length === 0) { + const nextBindings = Object.fromEntries( + Object.entries(animations.bindings).filter(([, binding]) => binding), + ); + const nextChannels = Object.fromEntries( + Object.entries(animations.channels).filter(([, channel]) => + hasChannelKeys({ channel }), + ), + ); + if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) { return undefined; } return { - channels: Object.fromEntries(channelEntries), + bindings: nextBindings, + channels: nextChannels, }; } -function toChannel({ - keyframes, - valueKind, +function cloneAnimationsState({ + animations, }: { - keyframes: AnimationKeyframe[]; - valueKind: AnimationValueKind; + animations: ElementAnimations | undefined; +}): ElementAnimations { + return { + bindings: { ...(animations?.bindings ?? {}) }, + channels: { ...(animations?.channels ?? {}) }, + }; +} + +function getBindingChannelKind({ + kind, +}: { + kind: AnimationBindingKind; +}): AnimationChannel["kind"] { + return kind === "discrete" ? "discrete" : "scalar"; +} + +function getPrimaryComponent({ + binding, +}: { + binding: AnimationBindingInstance; +}) { + return binding.components[0] ?? null; +} + +function getPrimaryChannelId({ + binding, +}: { + binding: AnimationBindingInstance; +}) { + return getPrimaryComponent({ binding })?.channelId ?? null; +} + +function getScalarSegmentType({ + interpolation, +}: { + interpolation: AnimationInterpolation; +}): ScalarSegmentType { + if (interpolation === "hold") { + return "step"; + } + return interpolation === "bezier" ? "bezier" : "linear"; +} + +function getInterpolationForBinding({ + kind, + interpolation, +}: { + kind: AnimationBindingKind; + interpolation: AnimationInterpolation | undefined; +}): AnimationInterpolation { + if (kind === "discrete") { + return "hold"; + } + + if ( + interpolation === "linear" || + interpolation === "hold" || + interpolation === "bezier" + ) { + return interpolation; + } + + return "linear"; +} + +function createEmptyChannelForBindingKind({ + kind, +}: { + kind: AnimationBindingKind; }): AnimationChannel { + if (kind === "discrete") { + return { + kind: "discrete", + keys: [], + } satisfies DiscreteAnimationChannel; + } + + return { + kind: "scalar", + keys: [], + } satisfies ScalarAnimationChannel; +} + +function createScalarKey({ + id, + time, + value, + interpolation, + previousKey, +}: { + id: string; + time: number; + value: number; + interpolation: AnimationInterpolation; + previousKey?: ScalarAnimationKey; +}): ScalarAnimationKey { + return { + id, + time, + value, + leftHandle: previousKey?.leftHandle, + rightHandle: previousKey?.rightHandle, + segmentToNext: + previousKey?.segmentToNext ?? getScalarSegmentType({ interpolation }), + tangentMode: previousKey?.tangentMode ?? "flat", + }; +} + +function createDiscreteKey({ + id, + time, + value, +}: { + id: string; + time: number; + value: string | boolean; +}): DiscreteAnimationKey { + return { + id, + time, + value, + }; +} + +function getBinding({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: string; +}): AnimationBindingInstance | undefined { + return animations?.bindings[propertyPath]; +} + +function getChannelById({ + animations, + channelId, +}: { + animations: ElementAnimations | undefined; + channelId: string; +}): AnimationChannel | undefined { + return animations?.channels[channelId]; +} + +function getTargetKeyMetadata({ + channel, + time, + keyframeId, +}: { + channel: AnimationChannel | undefined; + time: number; + keyframeId?: string; +}) { + const normalizedChannel = + channel != null ? normalizeChannel({ channel }) : undefined; + const keys = normalizedChannel?.keys ?? []; + if (keyframeId) { + const keyById = keys.find((key) => key.id === keyframeId); + if (keyById) { + return { + id: keyById.id, + time, + }; + } + } + + const keyAtTime = keys.find((key) => + isNearlySameTime({ leftTime: key.time, rightTime: time }), + ); + if (keyAtTime) { + return { + id: keyAtTime.id, + time: keyAtTime.time, + }; + } + + return { + id: keyframeId ?? generateUUID(), + time, + }; +} + +function upsertDiscreteChannelKey({ + channel, + time, + value, + keyframeId, +}: { + channel: DiscreteAnimationChannel | undefined; + time: number; + value: string | boolean; + keyframeId?: string; +}): DiscreteAnimationChannel { + const normalizedChannel = normalizeChannel({ + channel: channel ?? { kind: "discrete", keys: [] }, + }); + const keys = [...normalizedChannel.keys]; + if (keyframeId) { + const existingIndex = keys.findIndex((key) => key.id === keyframeId); + if (existingIndex >= 0) { + keys[existingIndex] = createDiscreteKey({ + id: keys[existingIndex].id, + time, + value, + }); + return normalizeChannel({ + channel: { kind: "discrete", keys }, + }); + } + } + + const existingAtTimeIndex = keys.findIndex((key) => + isNearlySameTime({ leftTime: key.time, rightTime: time }), + ); + if (existingAtTimeIndex >= 0) { + keys[existingAtTimeIndex] = createDiscreteKey({ + id: keys[existingAtTimeIndex].id, + time: keys[existingAtTimeIndex].time, + value, + }); + return normalizeChannel({ + channel: { kind: "discrete", keys }, + }); + } + + keys.push( + createDiscreteKey({ + id: keyframeId ?? generateUUID(), + time, + value, + }), + ); + return normalizeChannel({ + channel: { kind: "discrete", keys }, + }); +} + +function upsertScalarChannelKey({ + channel, + time, + value, + interpolation, + keyframeId, +}: { + channel: ScalarAnimationChannel | undefined; + time: number; + value: number; + interpolation: AnimationInterpolation; + keyframeId?: string; +}): ScalarAnimationChannel { + const normalizedChannel = normalizeChannel({ + channel: channel ?? { kind: "scalar", keys: [] }, + }); + const keys = [...normalizedChannel.keys]; + if (keyframeId) { + const existingIndex = keys.findIndex((key) => key.id === keyframeId); + if (existingIndex >= 0) { + keys[existingIndex] = createScalarKey({ + id: keys[existingIndex].id, + time, + value, + interpolation, + previousKey: { + ...keys[existingIndex], + segmentToNext: getScalarSegmentType({ interpolation }), + }, + }); + return normalizeChannel({ + channel: { + kind: "scalar", + keys, + extrapolation: normalizedChannel.extrapolation, + }, + }); + } + } + + const existingAtTimeIndex = keys.findIndex((key) => + isNearlySameTime({ leftTime: key.time, rightTime: time }), + ); + if (existingAtTimeIndex >= 0) { + keys[existingAtTimeIndex] = createScalarKey({ + id: keys[existingAtTimeIndex].id, + time: keys[existingAtTimeIndex].time, + value, + interpolation, + previousKey: { + ...keys[existingAtTimeIndex], + segmentToNext: getScalarSegmentType({ interpolation }), + }, + }); + return normalizeChannel({ + channel: { + kind: "scalar", + keys, + extrapolation: normalizedChannel.extrapolation, + }, + }); + } + + keys.push( + createScalarKey({ + id: keyframeId ?? generateUUID(), + time, + value, + interpolation, + }), + ); return normalizeChannel({ channel: { - valueKind, - keyframes, - } as AnimationChannel, + kind: "scalar", + keys, + extrapolation: normalizedChannel.extrapolation, + }, }); } @@ -71,171 +404,10 @@ export function getChannel({ animations: ElementAnimations | undefined; propertyPath: string; }): AnimationChannel | undefined { - return animations?.channels[propertyPath]; -} - -function getInterpolationForChannel({ - channel, - interpolation, -}: { - channel: AnimationChannel; - interpolation: AnimationInterpolation | undefined; -}): AnimationInterpolation { - if (channel.valueKind === "discrete") { - return "hold"; - } - - if (interpolation === "linear" || interpolation === "hold") { - return interpolation; - } - - return "linear"; -} - -function buildKeyframe({ - channel, - id, - time, - value, - interpolation, -}: { - channel: AnimationChannel; - id: string; - time: number; - value: AnimationValue; - interpolation: AnimationInterpolation; -}): AnimationKeyframe { - if (channel.valueKind === "number") { - if (typeof value !== "number") { - throw new Error("Number channel keyframes require numeric values"); - } - - return { - id, - time, - value, - interpolation: interpolation === "hold" ? "hold" : "linear", - }; - } - - if (channel.valueKind === "color") { - if (typeof value !== "string") { - throw new Error("Color channel keyframes require string values"); - } - - return { - id, - time, - value, - interpolation: interpolation === "hold" ? "hold" : "linear", - }; - } - - if (channel.valueKind === "vector") { - if (!isVectorValue(value)) { - throw new Error("Vector channel keyframes require {x, y} values"); - } - - return { - id, - time, - value, - interpolation: interpolation === "hold" ? "hold" : "linear", - }; - } - - if (typeof value !== "string" && typeof value !== "boolean") { - throw new Error( - "Discrete channel keyframes require boolean or string values", - ); - } - - return { - id, - time, - value, - interpolation: "hold", - }; -} - -function createEmptyChannelForValueKind({ - valueKind, -}: { - valueKind: AnimationValueKind; -}): AnimationChannel { - if (valueKind === "number") { - return { - valueKind: "number", - keyframes: [], - } satisfies NumberAnimationChannel; - } - - if (valueKind === "color") { - return { - valueKind: "color", - keyframes: [], - } satisfies ColorAnimationChannel; - } - - if (valueKind === "vector") { - return { - valueKind: "vector", - keyframes: [], - } satisfies VectorAnimationChannel; - } - - return { - valueKind: "discrete", - keyframes: [], - } satisfies DiscreteAnimationChannel; -} - -function clampNumericRange({ - value, - numericRange, -}: { - value: number; - numericRange: NumericRange | undefined; -}): number { - if (!numericRange) { - return value; - } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); -} - -function coerceAnimationValueForPath({ - value, - valueKind, - numericRange, -}: { - value: AnimationValue; - valueKind: AnimationValueKind; - numericRange?: NumericRange; -}): AnimationValue | null { - if (valueKind === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return clampNumericRange({ value, numericRange }); - } - - if (valueKind === "color") { - return typeof value === "string" ? value : null; - } - - if (valueKind === "vector") { - return isVectorValue(value) ? value : null; - } - - return typeof value === "string" || typeof value === "boolean" ? value : null; + const binding = getBinding({ animations, propertyPath }); + const primaryChannelId = + binding != null ? getPrimaryChannelId({ binding }) : null; + return primaryChannelId ? animations?.channels[primaryChannelId] : undefined; } export function upsertPathKeyframe({ @@ -245,9 +417,9 @@ export function upsertPathKeyframe({ value, interpolation, keyframeId, - valueKind, + kind, defaultInterpolation, - numericRange, + coerceValue, }: { animations: ElementAnimations | undefined; propertyPath: AnimationPath; @@ -255,39 +427,80 @@ export function upsertPathKeyframe({ value: AnimationValue; interpolation?: AnimationInterpolation; keyframeId?: string; - valueKind: AnimationValueKind; + kind: AnimationBindingKind; defaultInterpolation: AnimationInterpolation; - numericRange?: NumericRange; + coerceValue: (value: AnimationValue) => AnimationValue | null; }): ElementAnimations | undefined { - const coercedValue = coerceAnimationValueForPath({ - value, - valueKind, - numericRange, - }); + const coercedValue = coerceValue(value); if (coercedValue === null) { return animations; } - const channel = getChannel({ animations, propertyPath }); - const targetChannel = - channel && channel.valueKind === valueKind - ? channel - : createEmptyChannelForValueKind({ valueKind }); - const updatedChannel = upsertKeyframe({ - channel: targetChannel, + const nextAnimations = cloneAnimationsState({ animations }); + const existingBinding = getBinding({ + animations, + propertyPath, + }); + const binding = + existingBinding && existingBinding.kind === kind + ? cloneAnimationBinding({ binding: existingBinding }) + : createAnimationBinding({ path: propertyPath, kind }); + const primaryChannel = getChannel({ + animations, + propertyPath, + }); + const targetKey = getTargetKeyMetadata({ + channel: primaryChannel, time, - value: coercedValue, - interpolation: interpolation ?? defaultInterpolation, keyframeId, }); + const componentValues = decomposeAnimationValue({ + kind, + value: coercedValue, + }); + if (!componentValues) { + return animations; + } - return ( - setChannel({ + const nextInterpolation = getInterpolationForBinding({ + kind, + interpolation: interpolation ?? defaultInterpolation, + }); + nextAnimations.bindings[propertyPath] = binding; + for (const component of binding.components) { + const nextValue = componentValues[component.key]; + if (nextValue == null) { + continue; + } + + const currentChannel = getChannelById({ animations, - propertyPath, - channel: updatedChannel, - }) ?? { channels: {} } - ); + channelId: component.channelId, + }); + const targetChannel = + currentChannel?.kind === getBindingChannelKind({ kind }) + ? currentChannel + : createEmptyChannelForBindingKind({ kind }); + nextAnimations.channels[component.channelId] = + targetChannel.kind === "discrete" + ? upsertDiscreteChannelKey({ + channel: targetChannel, + time: targetKey.time, + value: nextValue as string | boolean, + keyframeId: targetKey.id, + }) + : upsertScalarChannelKey({ + channel: targetChannel, + time: targetKey.time, + value: nextValue as number, + interpolation: nextInterpolation, + keyframeId: targetKey.id, + }); + } + + return toAnimation({ + animations: nextAnimations, + }); } export function upsertElementKeyframe({ @@ -321,15 +534,16 @@ export function upsertElementKeyframe({ value: coercedValue, interpolation, keyframeId, - valueKind: propertyDefinition.valueKind, - defaultInterpolation: getDefaultInterpolationForProperty({ - propertyPath, - }), - numericRange: propertyDefinition.numericRange, + kind: propertyDefinition.kind, + defaultInterpolation: propertyDefinition.defaultInterpolation, + coerceValue: (nextValue) => + coerceAnimationValueForProperty({ + propertyPath, + value: nextValue, + }), }); } - export function upsertKeyframe({ channel, time, @@ -347,61 +561,29 @@ export function upsertKeyframe({ return undefined; } - const currentKeyframes = channel.keyframes; - const nextKeyframes = [...currentKeyframes]; - const nextInterpolation = getInterpolationForChannel({ - channel, - interpolation, - }); - if (keyframeId) { - const keyframeByIdIndex = nextKeyframes.findIndex( - (keyframe) => keyframe.id === keyframeId, - ); - if (keyframeByIdIndex >= 0) { - nextKeyframes[keyframeByIdIndex] = buildKeyframe({ - channel, - id: nextKeyframes[keyframeByIdIndex].id, - time, - value, - interpolation: nextInterpolation, - }); - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, - }); + if (channel.kind === "discrete") { + if (typeof value !== "string" && typeof value !== "boolean") { + return channel; } - } - const keyframeAtTimeIndex = nextKeyframes.findIndex((keyframe) => - isNearlySameTime({ leftTime: keyframe.time, rightTime: time }), - ); - if (keyframeAtTimeIndex >= 0) { - nextKeyframes[keyframeAtTimeIndex] = buildKeyframe({ + return upsertDiscreteChannelKey({ channel, - id: nextKeyframes[keyframeAtTimeIndex].id, - time: nextKeyframes[keyframeAtTimeIndex].time, - value, - interpolation: nextInterpolation, - }); - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, - }); - } - - nextKeyframes.push( - buildKeyframe({ - channel, - id: keyframeId ?? generateUUID(), time, value, - interpolation: nextInterpolation, - }), - ); + keyframeId, + }); + } - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, + if (typeof value !== "number") { + return channel; + } + + return upsertScalarChannelKey({ + channel, + time, + value, + interpolation: interpolation ?? "linear", + keyframeId, }); } @@ -416,16 +598,16 @@ export function removeKeyframe({ return undefined; } - const nextKeyframes = channel.keyframes.filter( - (keyframe) => keyframe.id !== keyframeId, - ); - if (nextKeyframes.length === 0) { + const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId); + if (nextKeys.length === 0) { return undefined; } - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, + return normalizeChannel({ + channel: { + ...channel, + keys: nextKeys, + } as AnimationChannel, }); } @@ -442,22 +624,24 @@ export function retimeKeyframe({ return undefined; } - const keyframeByIdIndex = channel.keyframes.findIndex( + const keyframeByIdIndex = channel.keys.findIndex( (keyframe) => keyframe.id === keyframeId, ); if (keyframeByIdIndex < 0) { return channel; } - const nextKeyframes = [...channel.keyframes]; - nextKeyframes[keyframeByIdIndex] = { - ...nextKeyframes[keyframeByIdIndex], + const nextKeys = [...channel.keys]; + nextKeys[keyframeByIdIndex] = { + ...nextKeys[keyframeByIdIndex], time, }; - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, + return normalizeChannel({ + channel: { + ...channel, + keys: nextKeys, + } as AnimationChannel, }); } @@ -470,19 +654,38 @@ export function setChannel({ propertyPath: string; channel: AnimationChannel | undefined; }): ElementAnimations | undefined { - const currentChannels = animations?.channels ?? {}; - - const nextChannelEntries = Object.entries(currentChannels) - .filter(([path]) => path !== propertyPath) - .filter(([, ch]) => ch && ch.keyframes.length > 0) - .map(([path, ch]) => [path, ch] as [string, AnimationChannel]); - - if (channel && channel.keyframes.length > 0) { - nextChannelEntries.push([propertyPath, channel]); + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + 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({ + channel, + }); return toAnimation({ - channelEntries: nextChannelEntries, + animations: nextAnimations, }); } @@ -497,31 +700,57 @@ export function cloneAnimations({ return undefined; } - const clonedEntries = Object.entries(animations.channels).flatMap( - ([propertyPath, channel]) => { - if (!channel || channel.keyframes.length === 0) { - return []; + const nextAnimations = cloneAnimationsState({ animations }); + nextAnimations.bindings = Object.fromEntries( + Object.entries(animations.bindings).map(([path, binding]) => [ + path, + binding ? cloneAnimationBinding({ binding }) : binding, + ]), + ); + nextAnimations.channels = {}; + + for (const binding of Object.values(nextAnimations.bindings)) { + if (!binding) { + continue; + } + + const primaryChannel = getChannelById({ + animations, + channelId: getPrimaryChannelId({ binding }) ?? "", + }); + const keyIdMap = new Map(); + if (primaryChannel) { + for (const key of primaryChannel.keys) { + keyIdMap.set( + key.id, + shouldRegenerateKeyframeIds ? generateUUID() : key.id, + ); + } + } + + for (const component of binding.components) { + const currentChannel = getChannelById({ + animations, + channelId: component.channelId, + }); + if (!currentChannel) { + continue; } - const clonedKeyframes = channel.keyframes.map((keyframe) => ({ - ...keyframe, - id: shouldRegenerateKeyframeIds ? generateUUID() : keyframe.id, - })); - - return [ - [ - propertyPath, - toChannel({ - keyframes: clonedKeyframes, - valueKind: channel.valueKind, - }), - ] as [string, AnimationChannel], - ]; - }, - ); + nextAnimations.channels[component.channelId] = normalizeChannel({ + channel: { + ...currentChannel, + keys: currentChannel.keys.map((key) => ({ + ...key, + id: keyIdMap.get(key.id) ?? key.id, + })), + } as AnimationChannel, + }); + } + } return toAnimation({ - channelEntries: clonedEntries, + animations: nextAnimations, }); } @@ -532,38 +761,310 @@ export function clampAnimationsToDuration({ animations: ElementAnimations | undefined; duration: number; }): ElementAnimations | undefined { - if (!animations) { + if (!animations || duration <= 0) { return undefined; } - const clampedEntries = Object.entries(animations.channels).flatMap( - ([propertyPath, channel]) => { - if (!channel) { - return []; - } + return splitAnimationsAtTime({ + animations, + splitTime: duration, + shouldIncludeSplitBoundary: true, + }).leftAnimations; +} - const nextKeyframes = channel.keyframes.filter( - (keyframe) => keyframe.time >= 0 && keyframe.time <= duration, - ); - if (nextKeyframes.length === 0) { - return []; - } +function lerpPoint({ + left, + right, + progress, +}: { + left: { x: number; y: number }; + right: { x: number; y: number }; + progress: number; +}) { + return { + x: left.x + (right.x - left.x) * progress, + y: left.y + (right.y - left.y) * progress, + }; +} - return [ - [ - propertyPath, - toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, - }), - ] as [string, AnimationChannel], +function splitDiscreteChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, +}: { + channel: DiscreteAnimationChannel | undefined; + splitTime: number; + leftBoundaryId: string; + rightBoundaryId: string; + shouldIncludeSplitBoundary: boolean; +}) { + if (!channel || channel.keys.length === 0) { + return { + leftChannel: undefined, + rightChannel: undefined, + }; + } + + const normalizedChannel = normalizeChannel({ channel }); + let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); + let rightKeys = normalizedChannel.keys + .filter((key) => key.time >= splitTime) + .map((key) => ({ ...key, time: key.time - splitTime })); + + if (shouldIncludeSplitBoundary) { + const hasBoundaryOnLeft = leftKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), + ); + const hasBoundaryOnRight = rightKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: 0 }), + ); + const boundaryValue = getChannelValueAtTime({ + channel: normalizedChannel, + time: splitTime, + fallbackValue: normalizedChannel.keys[0].value, + }); + if (!hasBoundaryOnLeft) { + leftKeys = [ + ...leftKeys, + createDiscreteKey({ + id: leftBoundaryId, + time: splitTime, + value: boundaryValue as string | boolean, + }), ]; - }, - ); + } + if (!hasBoundaryOnRight) { + rightKeys = [ + createDiscreteKey({ + id: rightBoundaryId, + time: 0, + value: boundaryValue as string | boolean, + }), + ...rightKeys, + ]; + } + } - return toAnimation({ - channelEntries: clampedEntries, - }); + return { + leftChannel: leftKeys.length + ? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } }) + : undefined, + rightChannel: rightKeys.length + ? normalizeChannel({ channel: { kind: "discrete", keys: rightKeys } }) + : undefined, + }; +} + +function splitScalarChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, +}: { + channel: ScalarAnimationChannel | undefined; + splitTime: number; + leftBoundaryId: string; + rightBoundaryId: string; + shouldIncludeSplitBoundary: boolean; +}) { + if (!channel || channel.keys.length === 0) { + return { + leftChannel: undefined, + rightChannel: undefined, + }; + } + + const normalizedChannel = normalizeChannel({ channel }); + let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); + let rightKeys = normalizedChannel.keys + .filter((key) => key.time >= splitTime) + .map((key) => ({ ...key, time: key.time - splitTime })); + + const hasBoundaryOnLeft = leftKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), + ); + const hasBoundaryOnRight = rightKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: 0 }), + ); + if (!shouldIncludeSplitBoundary || (hasBoundaryOnLeft && hasBoundaryOnRight)) { + return { + leftChannel: leftKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: leftKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + rightChannel: rightKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: rightKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + }; + } + + for (let keyIndex = 0; keyIndex < normalizedChannel.keys.length - 1; keyIndex++) { + const leftKey = normalizedChannel.keys[keyIndex]; + const rightKey = normalizedChannel.keys[keyIndex + 1]; + if ( + !( + splitTime > leftKey.time + TIME_EPSILON_SECONDS && + splitTime < rightKey.time - TIME_EPSILON_SECONDS + ) + ) { + continue; + } + + const boundaryValue = getChannelValueAtTime({ + channel: normalizedChannel, + time: splitTime, + fallbackValue: leftKey.value, + }) as number; + + if (leftKey.segmentToNext === "bezier") { + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + const progress = solveBezierProgressForTime({ + time: splitTime, + leftKey, + rightKey, + }); + const p0 = { x: leftKey.time, y: leftKey.value }; + const p1 = { + x: leftKey.time + rightHandle.dt, + y: leftKey.value + rightHandle.dv, + }; + const p2 = { + x: rightKey.time + leftHandle.dt, + y: rightKey.value + leftHandle.dv, + }; + const p3 = { x: rightKey.time, y: rightKey.value }; + const q0 = lerpPoint({ left: p0, right: p1, progress }); + const q1 = lerpPoint({ left: p1, right: p2, progress }); + const q2 = lerpPoint({ left: p2, right: p3, progress }); + const r0 = lerpPoint({ left: q0, right: q1, progress }); + const r1 = lerpPoint({ left: q1, right: q2, progress }); + const splitPoint = lerpPoint({ left: r0, right: r1, progress }); + leftKeys = [ + ...normalizedChannel.keys.filter((key) => key.time < splitTime), + { + ...leftKey, + rightHandle: { + dt: q0.x - p0.x, + dv: q0.y - p0.y, + }, + }, + { + id: leftBoundaryId, + time: splitTime, + value: boundaryValue, + leftHandle: { + dt: r0.x - splitPoint.x, + dv: r0.y - splitPoint.y, + }, + segmentToNext: leftKey.segmentToNext, + tangentMode: leftKey.tangentMode, + }, + ]; + rightKeys = [ + { + id: rightBoundaryId, + time: 0, + value: boundaryValue, + rightHandle: { + dt: r1.x - splitPoint.x, + dv: r1.y - splitPoint.y, + }, + segmentToNext: "bezier", + tangentMode: leftKey.tangentMode, + }, + { + ...rightKey, + time: rightKey.time - splitTime, + leftHandle: { + dt: q2.x - p3.x, + dv: q2.y - p3.y, + }, + }, + ...normalizedChannel.keys + .filter((key) => key.time > rightKey.time) + .map((key) => ({ + ...key, + time: key.time - splitTime, + })), + ]; + } else { + leftKeys = [ + ...leftKeys, + createScalarKey({ + id: leftBoundaryId, + time: splitTime, + value: boundaryValue, + interpolation: "linear", + }), + ]; + rightKeys = [ + createScalarKey({ + id: rightBoundaryId, + time: 0, + value: boundaryValue, + interpolation: getScalarSegmentInterpolation({ + segment: leftKey.segmentToNext, + }), + }), + ...rightKeys, + ]; + } + + return { + leftChannel: normalizeChannel({ + channel: { + kind: "scalar", + keys: leftKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }), + rightChannel: normalizeChannel({ + channel: { + kind: "scalar", + keys: rightKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }), + }; + } + + return { + leftChannel: leftKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: leftKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + rightChannel: rightKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: rightKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + }; } export function splitAnimationsAtTime({ @@ -582,101 +1083,63 @@ export function splitAnimationsAtTime({ return { leftAnimations: undefined, rightAnimations: undefined }; } - const leftChannels: Array<[string, AnimationChannel]> = []; - const rightChannels: Array<[string, AnimationChannel]> = []; + const leftAnimations = cloneAnimationsState({ animations: undefined }); + const rightAnimations = cloneAnimationsState({ animations: undefined }); - for (const [propertyPath, channel] of Object.entries(animations.channels)) { - if (!channel || channel.keyframes.length === 0) { + for (const [propertyPath, binding] of Object.entries(animations.bindings)) { + if (!binding) { continue; } - const normalizedChannel = normalizeChannel({ channel }); - let leftKeyframes = normalizedChannel.keyframes.filter( - (keyframe) => keyframe.time <= splitTime, - ); - let rightKeyframes = normalizedChannel.keyframes - .filter((keyframe) => keyframe.time >= splitTime) - .map((keyframe) => ({ - ...keyframe, - time: keyframe.time - splitTime, - })); + const leftBinding = cloneAnimationBinding({ binding }); + const rightBinding = cloneAnimationBinding({ binding }); + const leftBoundaryId = generateUUID(); + const rightBoundaryId = generateUUID(); + let hasLeftKeys = false; + let hasRightKeys = false; - const hasBoundaryOnLeft = leftKeyframes.some((keyframe) => - isNearlySameTime({ leftTime: keyframe.time, rightTime: splitTime }), - ); - const hasBoundaryOnRight = rightKeyframes.some((keyframe) => - isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }), - ); - if ( - shouldIncludeSplitBoundary && - (!hasBoundaryOnLeft || !hasBoundaryOnRight) - ) { - const boundaryValue = getChannelValueAtTime({ - channel: normalizedChannel, - time: splitTime, - fallbackValue: normalizedChannel.keyframes[0].value, + for (const component of binding.components) { + const channel = getChannelById({ + animations, + channelId: component.channelId, }); - const knownPropertyPath = isAnimationPropertyPath(propertyPath) - ? propertyPath - : null; - const boundaryInterpolation = knownPropertyPath - ? getDefaultInterpolationForProperty({ - propertyPath: knownPropertyPath, - }) - : normalizedChannel.valueKind === "discrete" - ? "hold" - : "linear"; - - if (!hasBoundaryOnLeft) { - leftKeyframes = [ - ...leftKeyframes, - buildKeyframe({ - channel: normalizedChannel, - id: generateUUID(), - time: splitTime, - value: boundaryValue, - interpolation: boundaryInterpolation, - }), - ]; + const splitResult = + channel?.kind === "discrete" + ? splitDiscreteChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, + }) + : splitScalarChannelAtTime({ + channel: channel as ScalarAnimationChannel | undefined, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, + }); + if (splitResult.leftChannel) { + leftAnimations.channels[component.channelId] = splitResult.leftChannel; + hasLeftKeys = true; } - - if (!hasBoundaryOnRight) { - rightKeyframes = [ - buildKeyframe({ - channel: normalizedChannel, - id: generateUUID(), - time: 0, - value: boundaryValue, - interpolation: boundaryInterpolation, - }), - ...rightKeyframes, - ]; + if (splitResult.rightChannel) { + rightAnimations.channels[component.channelId] = splitResult.rightChannel; + hasRightKeys = true; } } - const leftChannel = leftKeyframes.length - ? toChannel({ - keyframes: leftKeyframes, - valueKind: normalizedChannel.valueKind, - }) - : undefined; - const rightChannel = rightKeyframes.length - ? toChannel({ - keyframes: rightKeyframes, - valueKind: normalizedChannel.valueKind, - }) - : undefined; - if (leftChannel) { - leftChannels.push([propertyPath, leftChannel]); + if (hasLeftKeys) { + leftAnimations.bindings[propertyPath] = leftBinding; } - if (rightChannel) { - rightChannels.push([propertyPath, rightChannel]); + if (hasRightKeys) { + rightAnimations.bindings[propertyPath] = rightBinding; } } return { - leftAnimations: toAnimation({ channelEntries: leftChannels }), - rightAnimations: toAnimation({ channelEntries: rightChannels }), + leftAnimations: toAnimation({ animations: leftAnimations }), + rightAnimations: toAnimation({ animations: rightAnimations }), }; } @@ -689,15 +1152,31 @@ export function removeElementKeyframe({ propertyPath: AnimationPath; keyframeId: string; }): ElementAnimations | undefined { - const channel = getChannel({ animations, propertyPath }); - const updatedChannel = removeKeyframe({ - channel, - keyframeId, - }); - return setChannel({ - animations, - propertyPath, - channel: updatedChannel, + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const nextAnimations = cloneAnimationsState({ animations }); + for (const component of binding.components) { + nextAnimations.channels[component.channelId] = removeKeyframe({ + channel: nextAnimations.channels[component.channelId], + keyframeId, + }); + } + const hasRemainingKeys = binding.components.some((component) => + hasChannelKeys({ + channel: nextAnimations.channels[component.channelId], + }), + ); + if (!hasRemainingKeys) { + delete nextAnimations.bindings[propertyPath]; + for (const component of binding.components) { + delete nextAnimations.channels[component.channelId]; + } + } + return toAnimation({ + animations: nextAnimations, }); } @@ -712,15 +1191,20 @@ export function retimeElementKeyframe({ keyframeId: string; time: number; }): ElementAnimations | undefined { - const channel = getChannel({ animations, propertyPath }); - const updatedChannel = retimeKeyframe({ - channel, - keyframeId, - time, - }); - return setChannel({ - animations, - propertyPath, - channel: updatedChannel, + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const nextAnimations = cloneAnimationsState({ animations }); + for (const component of binding.components) { + nextAnimations.channels[component.channelId] = retimeKeyframe({ + channel: nextAnimations.channels[component.channelId], + keyframeId, + time, + }); + } + return toAnimation({ + animations: nextAnimations, }); } diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts deleted file mode 100644 index 3e193131..00000000 --- a/apps/web/src/lib/animation/number-channel.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { - AnimationPropertyPath, - ElementAnimations, - NumberAnimationChannel, -} from "@/lib/animation/types"; - -export function getNumberChannelForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; -}): NumberAnimationChannel | undefined { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.valueKind !== "number") { - return undefined; - } - - return channel; -} diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts index 6d20e324..ac15805e 100644 --- a/apps/web/src/lib/animation/property-registry.ts +++ b/apps/web/src/lib/animation/property-registry.ts @@ -1,369 +1,377 @@ -import type { - AnimationInterpolation, - AnimationPropertyPath, - AnimationValue, - AnimationValueKind, - DiscreteValue, - VectorValue, -} from "@/lib/animation/types"; -import { isVectorValue } from "./vector-channel"; -import type { TimelineElement } from "@/lib/timeline"; -import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, -} from "@/constants/text-constants"; -import { - canElementHaveAudio, - isVisualElement, -} from "@/lib/timeline/element-utils"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { snapToStep } from "@/utils/math"; - -export interface NumericSpec { - min?: number; - max?: number; - step?: number; -} - -export type NumericRange = NumericSpec; - -export interface AnimationPropertyDefinition { - valueKind: AnimationValueKind; - defaultInterpolation: AnimationInterpolation; - numericRange?: NumericSpec; - supportsElement: ({ element }: { element: TimelineElement }) => boolean; - getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; - setValue: ({ - element, - value, - }: { - element: TimelineElement; - value: AnimationValue; - }) => TimelineElement; -} - -const ANIMATION_PROPERTY_REGISTRY: Record< - AnimationPropertyPath, - AnimationPropertyDefinition -> = { - "transform.position": { - valueKind: "vector", - defaultInterpolation: "linear", - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: value as VectorValue, - }, - } - : element, - }, - "transform.scaleX": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleX : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleX: value as number }, - } - : element, - }, - "transform.scaleY": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleY : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleY: value as number }, - } - : element, - }, - "transform.rotate": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: -360, max: 360, step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.rotate : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, rotate: value as number }, - } - : element, - }, - opacity: { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: 0, max: 1, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.opacity : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { ...element, opacity: value as number } - : element, - }, - volume: { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, - supportsElement: ({ element }) => canElementHaveAudio(element), - getValue: ({ element }) => - canElementHaveAudio(element) ? element.volume ?? 0 : null, - setValue: ({ element, value }) => - canElementHaveAudio(element) - ? { ...element, volume: value as number } - : element, - }, - color: { - valueKind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => (element.type === "text" ? element.color : null), - setValue: ({ element, value }) => - element.type === "text" - ? { ...element, color: value as string } - : element, - }, - "background.color": { - valueKind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" ? element.background.color : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, color: value as string }, - } - : element, - }, - "background.paddingX": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingX: value as number }, - } - : element, - }, - "background.paddingY": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingY: value as number }, - } - : element, - }, - "background.offsetX": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetX: value as number }, - } - : element, - }, - "background.offsetY": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetY: value as number }, - } - : element, - }, - "background.cornerRadius": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, cornerRadius: value as number }, - } - : element, - }, -}; - -export function isAnimationPropertyPath( - propertyPath: string, -): propertyPath is AnimationPropertyPath { - return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); -} - -export function getAnimationPropertyDefinition({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationPropertyDefinition { - return ANIMATION_PROPERTY_REGISTRY[propertyPath]; -} - -export function supportsAnimationProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): boolean { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.supportsElement({ element }); -} - -export function getElementBaseValueForProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): AnimationValue | null { - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return null; - } - return definition.getValue({ element }); -} - -export function withElementBaseValueForProperty({ - element, - propertyPath, - value, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): TimelineElement { - const coercedValue = coerceAnimationValueForProperty({ propertyPath, value }); - if (coercedValue === null) { - return element; - } - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return element; - } - return definition.setValue({ element, value: coercedValue }); -} - -export function getDefaultInterpolationForProperty({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationInterpolation { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.defaultInterpolation; -} - -function applyNumericSpec({ - value, - numericRange, -}: { - value: number; - numericRange: NumericSpec | undefined; -}): number { - if (!numericRange) { - return value; - } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); -} - -export function coerceAnimationValueForProperty({ - propertyPath, - value, -}: { - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): AnimationValue | null { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - - if (propertyDefinition.valueKind === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return applyNumericSpec({ - value, - numericRange: propertyDefinition.numericRange, - }); - } - - if (propertyDefinition.valueKind === "color") { - return typeof value === "string" ? value : null; - } - - if (propertyDefinition.valueKind === "vector") { - return isVectorValue(value) ? value : null; - } - - if (typeof value === "string" || typeof value === "boolean") { - return value as DiscreteValue; - } - - return null; -} +import type { + AnimationBindingKind, + AnimationInterpolation, + AnimationPropertyPath, + AnimationValue, + VectorValue, +} from "@/lib/animation/types"; +import { isVectorValue, parseColorToLinearRgba } from "./binding-values"; +import type { TimelineElement } from "@/lib/timeline"; +import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants"; +import { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, +} from "@/constants/text-constants"; +import { + canElementHaveAudio, + isVisualElement, +} from "@/lib/timeline/element-utils"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { snapToStep } from "@/utils/math"; + +export interface NumericSpec { + min?: number; + max?: number; + step?: number; +} + +export interface AnimationPropertyDefinition { + kind: AnimationBindingKind; + defaultInterpolation: AnimationInterpolation; + numericRanges?: Partial>; + supportsElement: ({ element }: { element: TimelineElement }) => boolean; + getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; + coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; + setValue: ({ + element, + value, + }: { + element: TimelineElement; + value: AnimationValue; + }) => TimelineElement; +} + +function applyNumericSpec({ + value, + numericRange, +}: { + value: number; + numericRange: NumericSpec | undefined; +}): number { + if (!numericRange) { + return value; + } + + const steppedValue = + numericRange.step != null + ? snapToStep({ value, step: numericRange.step }) + : value; + const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; + const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); +} + +function coerceNumberValue({ + value, + numericRange, +}: { + value: AnimationValue; + numericRange?: NumericSpec; +}): number | null { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + return applyNumericSpec({ value, numericRange }); +} + +function coerceColorValue({ + value, +}: { + value: AnimationValue; +}): string | null { + return typeof value === "string" && parseColorToLinearRgba({ color: value }) + ? value + : null; +} + +function createNumberPropertyDefinition({ + numericRange, + supportsElement, + getValue, + setValue, +}: { + numericRange?: NumericSpec; + supportsElement: AnimationPropertyDefinition["supportsElement"]; + getValue: AnimationPropertyDefinition["getValue"]; + setValue: AnimationPropertyDefinition["setValue"]; +}): AnimationPropertyDefinition { + return { + kind: "number", + defaultInterpolation: "linear", + numericRanges: numericRange ? { value: numericRange } : undefined, + supportsElement, + getValue, + coerceValue: ({ value }) => + coerceNumberValue({ + value, + numericRange, + }), + setValue, + }; +} + +const ANIMATION_PROPERTY_REGISTRY: Record< + AnimationPropertyPath, + AnimationPropertyDefinition +> = { + "transform.position": { + kind: "vector2", + defaultInterpolation: "linear", + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.position : null, + coerceValue: ({ value }) => (isVectorValue(value) ? value : null), + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { + ...element.transform, + position: value as VectorValue, + }, + } + : element, + }, + "transform.scaleX": createNumberPropertyDefinition({ + numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.scaleX : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, scaleX: value as number }, + } + : element, + }), + "transform.scaleY": createNumberPropertyDefinition({ + numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.scaleY : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, scaleY: value as number }, + } + : element, + }), + "transform.rotate": createNumberPropertyDefinition({ + numericRange: { min: -360, max: 360, step: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.rotate : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, rotate: value as number }, + } + : element, + }), + opacity: createNumberPropertyDefinition({ + numericRange: { min: 0, max: 1, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.opacity : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { ...element, opacity: value as number } + : element, + }), + volume: createNumberPropertyDefinition({ + numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, + supportsElement: ({ element }) => canElementHaveAudio(element), + getValue: ({ element }) => + canElementHaveAudio(element) ? element.volume ?? 0 : null, + setValue: ({ element, value }) => + canElementHaveAudio(element) + ? { ...element, volume: value as number } + : element, + }), + color: { + kind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => (element.type === "text" ? element.color : null), + coerceValue: ({ value }) => coerceColorValue({ value }), + setValue: ({ element, value }) => + element.type === "text" + ? { ...element, color: value as string } + : element, + }, + "background.color": { + kind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" ? element.background.color : null, + coerceValue: ({ value }) => coerceColorValue({ value }), + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, color: value as string }, + } + : element, + }, + "background.paddingX": createNumberPropertyDefinition({ + numericRange: { min: 0, step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingX: value as number }, + } + : element, + }), + "background.paddingY": createNumberPropertyDefinition({ + numericRange: { min: 0, step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingY: value as number }, + } + : element, + }), + "background.offsetX": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetX: value as number }, + } + : element, + }), + "background.offsetY": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetY: value as number }, + } + : element, + }), + "background.cornerRadius": createNumberPropertyDefinition({ + numericRange: { + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + step: 1, + }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, cornerRadius: value as number }, + } + : element, + }), +}; + +export function isAnimationPropertyPath( + propertyPath: string, +): propertyPath is AnimationPropertyPath { + return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); +} + +export function getAnimationPropertyDefinition({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationPropertyDefinition { + return ANIMATION_PROPERTY_REGISTRY[propertyPath]; +} + +export function supportsAnimationProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): boolean { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.supportsElement({ element }); +} + +export function getElementBaseValueForProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): AnimationValue | null { + const definition = getAnimationPropertyDefinition({ propertyPath }); + if (!definition.supportsElement({ element })) { + return null; + } + return definition.getValue({ element }); +} + +export function withElementBaseValueForProperty({ + element, + propertyPath, + value, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): TimelineElement { + const definition = getAnimationPropertyDefinition({ propertyPath }); + const coercedValue = definition.coerceValue({ value }); + if (coercedValue === null || !definition.supportsElement({ element })) { + return element; + } + return definition.setValue({ element, value: coercedValue }); +} + +export function getDefaultInterpolationForProperty({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationInterpolation { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.defaultInterpolation; +} + +export function coerceAnimationValueForProperty({ + propertyPath, + value, +}: { + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): AnimationValue | null { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.coerceValue({ value }); +} diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts index 5c356f28..3de97026 100644 --- a/apps/web/src/lib/animation/resolve.ts +++ b/apps/web/src/lib/animation/resolve.ts @@ -1,135 +1,174 @@ -import type { - AnimationPropertyPath, - ElementAnimations, -} from "@/lib/animation/types"; -import type { Transform } from "@/lib/rendering"; -import { - getColorValueAtTime, - getNumberChannelValueAtTime, - getVectorChannelValueAtTime, -} from "./interpolation"; -import { getColorChannelForPath } from "./color-channel"; -import { getNumberChannelForPath } from "./number-channel"; -import { getVectorChannelForPath } from "./vector-channel"; - -export function getElementLocalTime({ - timelineTime, - elementStartTime, - elementDuration, -}: { - timelineTime: number; - elementStartTime: number; - elementDuration: number; -}): number { - const localTime = timelineTime - elementStartTime; - if (localTime <= 0) { - return 0; - } - - if (localTime >= elementDuration) { - return elementDuration; - } - - return localTime; -} - -export function resolveTransformAtTime({ - baseTransform, - animations, - localTime, -}: { - baseTransform: Transform; - animations: ElementAnimations | undefined; - localTime: number; -}): Transform { - const safeLocalTime = Math.max(0, localTime); - return { - position: getVectorChannelValueAtTime({ - channel: getVectorChannelForPath({ - animations, - propertyPath: "transform.position", - }), - time: safeLocalTime, - fallbackValue: baseTransform.position, - }), - scaleX: getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "transform.scaleX", - }), - time: safeLocalTime, - fallbackValue: baseTransform.scaleX, - }), - scaleY: getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "transform.scaleY", - }), - time: safeLocalTime, - fallbackValue: baseTransform.scaleY, - }), - rotate: getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "transform.rotate", - }), - time: safeLocalTime, - fallbackValue: baseTransform.rotate, - }), - }; -} - -export function resolveOpacityAtTime({ - baseOpacity, - animations, - localTime, -}: { - baseOpacity: number; - animations: ElementAnimations | undefined; - localTime: number; -}): number { - return getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "opacity", - }), - time: Math.max(0, localTime), - fallbackValue: baseOpacity, - }); -} - -export function resolveNumberAtTime({ - baseValue, - animations, - propertyPath, - localTime, -}: { - baseValue: number; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; -}): number { - return getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ animations, propertyPath }), - time: Math.max(0, localTime), - fallbackValue: baseValue, - }); -} - -export function resolveColorAtTime({ - baseColor, - animations, - propertyPath, - localTime, -}: { - baseColor: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; -}): string { - return getColorValueAtTime({ - channel: getColorChannelForPath({ animations, propertyPath }), - time: Math.max(0, localTime), - fallbackValue: baseColor, - }); -} +import type { + AnimationPropertyPath, + ElementAnimations, +} from "@/lib/animation/types"; +import type { Transform } from "@/lib/rendering"; +import { + type AnimationComponentValue, + composeAnimationValue, + decomposeAnimationValue, +} from "./binding-values"; +import { + getChannelValueAtTime, +} from "./interpolation"; + +export function getElementLocalTime({ + timelineTime, + elementStartTime, + elementDuration, +}: { + timelineTime: number; + elementStartTime: number; + elementDuration: number; +}): number { + const localTime = timelineTime - elementStartTime; + if (localTime <= 0) { + return 0; + } + + if (localTime >= elementDuration) { + return elementDuration; + } + + return localTime; +} + +export function resolveTransformAtTime({ + baseTransform, + animations, + localTime, +}: { + baseTransform: Transform; + animations: ElementAnimations | undefined; + localTime: number; +}): Transform { + const safeLocalTime = Math.max(0, localTime); + return { + position: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.position", + localTime: safeLocalTime, + fallbackValue: baseTransform.position, + }), + scaleX: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.scaleX", + localTime: safeLocalTime, + fallbackValue: baseTransform.scaleX, + }), + scaleY: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.scaleY", + localTime: safeLocalTime, + fallbackValue: baseTransform.scaleY, + }), + rotate: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.rotate", + localTime: safeLocalTime, + fallbackValue: baseTransform.rotate, + }), + }; +} + +export function resolveOpacityAtTime({ + baseOpacity, + animations, + localTime, +}: { + baseOpacity: number; + animations: ElementAnimations | undefined; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath: "opacity", + localTime: Math.max(0, localTime), + fallbackValue: baseOpacity, + }); +} + +export function resolveNumberAtTime({ + baseValue, + animations, + propertyPath, + localTime, +}: { + baseValue: number; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseValue, + }); +} + +export function resolveColorAtTime({ + baseColor, + animations, + propertyPath, + localTime, +}: { + baseColor: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; +}): string { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseColor, + }); +} + +export function resolveAnimationPathValueAtTime< + T extends number | string | boolean | Transform["position"], +>({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: string; + localTime: number; + fallbackValue: T; +}): T { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return fallbackValue; + } + + const fallbackComponents = decomposeAnimationValue({ + kind: binding.kind, + value: fallbackValue, + }); + if (!fallbackComponents) { + return fallbackValue; + } + + const componentValues = Object.fromEntries( + binding.components.map((component) => { + const channel = animations?.channels[component.channelId]; + return [ + component.key, + getChannelValueAtTime({ + channel, + time: localTime, + fallbackValue: + fallbackComponents[component.key] ?? + (channel?.kind === "discrete" ? false : 0), + }), + ]; + }), + ) as Record; + return (composeAnimationValue({ + binding, + componentValues, + }) ?? fallbackValue) as T; +} diff --git a/apps/web/src/lib/animation/target-resolver.ts b/apps/web/src/lib/animation/target-resolver.ts index 6675d443..06c9e140 100644 --- a/apps/web/src/lib/animation/target-resolver.ts +++ b/apps/web/src/lib/animation/target-resolver.ts @@ -1,277 +1,285 @@ -import type { - AnimationInterpolation, - AnimationPath, - AnimationValue, - AnimationValueKind, -} from "@/lib/animation/types"; -import { - parseEffectParamPath, - isEffectParamPath, -} from "@/lib/animation/effect-param-channel"; -import { - isGraphicParamPath, - parseGraphicParamPath, -} from "@/lib/animation/graphic-param-channel"; -import type { ParamDefinition } from "@/lib/params"; -import { effectsRegistry, registerDefaultEffects } from "@/lib/effects"; -import { getGraphicDefinition } from "@/lib/graphics"; -import type { TimelineElement } from "@/lib/timeline"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import { snapToStep } from "@/utils/math"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getElementBaseValueForProperty, - isAnimationPropertyPath, - type NumericSpec, - withElementBaseValueForProperty, -} from "./property-registry"; - -export interface AnimationPathDescriptor { - valueKind: AnimationValueKind; - defaultInterpolation: AnimationInterpolation; - numericRange?: NumericSpec; - getBaseValue(): AnimationValue | null; - setBaseValue(value: AnimationValue): TimelineElement; -} - -export function getParamValueKind({ - param, -}: { - param: ParamDefinition; -}): AnimationValueKind { - if (param.type === "number") { - return "number"; - } - - if (param.type === "color") { - return "color"; - } - - return "discrete"; -} - -export function getParamDefaultInterpolation({ - param, -}: { - param: ParamDefinition; -}): AnimationInterpolation { - return param.type === "number" || param.type === "color" ? "linear" : "hold"; -} - -function getParamNumericRange({ - param, -}: { - param: ParamDefinition; -}): NumericSpec | undefined { - if (param.type !== "number") { - return undefined; - } - - return { - min: param.min, - max: param.max, - step: param.step, - }; -} - -function coerceParamValue({ - param, - value, -}: { - param: ParamDefinition; - value: AnimationValue; -}): number | string | boolean | null { - if (param.type === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - const steppedValue = snapToStep({ value, step: param.step }); - const minValue = param.min; - const maxValue = param.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); - } - - if (param.type === "color") { - return typeof value === "string" ? value : null; - } - - if (param.type === "boolean") { - return typeof value === "boolean" ? value : null; - } - - if (typeof value !== "string") { - return null; - } - - return param.options.some((option) => option.value === value) ? value : null; -} - -function buildGraphicParamDescriptor({ - element, - paramKey, -}: { - element: TimelineElement; - paramKey: string; -}): AnimationPathDescriptor | null { - if (element.type !== "graphic") { - return null; - } - - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { - return null; - } - - return { - valueKind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRange: getParamNumericRange({ param }), - getBaseValue: () => element.params[param.key] ?? param.default, - setBaseValue: (value) => { - const coercedValue = coerceParamValue({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - params: { - ...element.params, - [param.key]: coercedValue, - }, - }; - }, - }; -} - -function buildEffectParamDescriptor({ - element, - effectId, - paramKey, -}: { - element: TimelineElement; - effectId: string; - paramKey: string; -}): AnimationPathDescriptor | null { - if (!isVisualElement(element)) { - return null; - } - - const effect = element.effects?.find((candidate) => candidate.id === effectId); - if (!effect) { - return null; - } - - registerDefaultEffects(); - const definition = effectsRegistry.get(effect.type); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { - return null; - } - - return { - valueKind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRange: getParamNumericRange({ param }), - getBaseValue: () => effect.params[param.key] ?? param.default, - setBaseValue: (value) => { - const coercedValue = coerceParamValue({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - effects: - element.effects?.map((candidate) => - candidate.id !== effectId - ? candidate - : { - ...candidate, - params: { - ...candidate.params, - [param.key]: coercedValue, - }, - }, - ) ?? element.effects, - }; - }, - }; -} - -export function isAnimationPath( - propertyPath: string, -): propertyPath is AnimationPath { - return ( - isAnimationPropertyPath(propertyPath) || - isGraphicParamPath(propertyPath) || - isEffectParamPath(propertyPath) - ); -} - -export function resolveAnimationTarget({ - element, - path, -}: { - element: TimelineElement; - path: AnimationPath; -}): AnimationPathDescriptor | null { - if (isAnimationPropertyPath(path)) { - const propertyDefinition = getAnimationPropertyDefinition({ - propertyPath: path, - }); - if (!propertyDefinition.supportsElement({ element })) { - return null; - } - - return { - valueKind: propertyDefinition.valueKind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - numericRange: propertyDefinition.numericRange, - getBaseValue: () => - getElementBaseValueForProperty({ - element, - propertyPath: path, - }), - setBaseValue: (value) => { - const coercedValue = coerceAnimationValueForProperty({ - propertyPath: path, - value, - }); - if (coercedValue === null) { - return element; - } - - return withElementBaseValueForProperty({ - element, - propertyPath: path, - value: coercedValue, - }); - }, - }; - } - - const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); - if (graphicParamTarget) { - return buildGraphicParamDescriptor({ - element, - paramKey: graphicParamTarget.paramKey, - }); - } - - const effectParamTarget = parseEffectParamPath({ propertyPath: path }); - if (effectParamTarget) { - return buildEffectParamDescriptor({ - element, - effectId: effectParamTarget.effectId, - paramKey: effectParamTarget.paramKey, - }); - } - - return null; -} +import type { + AnimationBindingKind, + AnimationInterpolation, + AnimationPath, + AnimationValue, +} from "@/lib/animation/types"; +import { + parseEffectParamPath, + isEffectParamPath, +} from "@/lib/animation/effect-param-channel"; +import { + isGraphicParamPath, + parseGraphicParamPath, +} from "@/lib/animation/graphic-param-channel"; +import type { ParamDefinition } from "@/lib/params"; +import { effectsRegistry, registerDefaultEffects } from "@/lib/effects"; +import { getGraphicDefinition } from "@/lib/graphics"; +import type { TimelineElement } from "@/lib/timeline"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { snapToStep } from "@/utils/math"; +import { + coerceAnimationValueForProperty, + getAnimationPropertyDefinition, + getElementBaseValueForProperty, + isAnimationPropertyPath, + type NumericSpec, + withElementBaseValueForProperty, +} from "./property-registry"; +import { parseColorToLinearRgba } from "./binding-values"; + +export interface AnimationPathDescriptor { + kind: AnimationBindingKind; + defaultInterpolation: AnimationInterpolation; + numericRanges?: Partial>; + coerceValue(value: AnimationValue): AnimationValue | null; + getBaseValue(): AnimationValue | null; + setBaseValue(value: AnimationValue): TimelineElement; +} + +export function getParamValueKind({ + param, +}: { + param: ParamDefinition; +}): AnimationBindingKind { + if (param.type === "number") { + return "number"; + } + + if (param.type === "color") { + return "color"; + } + + return "discrete"; +} + +export function getParamDefaultInterpolation({ + param, +}: { + param: ParamDefinition; +}): AnimationInterpolation { + return param.type === "number" || param.type === "color" ? "linear" : "hold"; +} + +function getParamNumericRange({ + param, +}: { + param: ParamDefinition; +}): Partial> | undefined { + if (param.type !== "number") { + return undefined; + } + + return { + value: { + min: param.min, + max: param.max, + step: param.step, + }, + }; +} + +export function coerceAnimationValueForParam({ + param, + value, +}: { + param: ParamDefinition; + value: AnimationValue; +}): number | string | boolean | null { + if (param.type === "number") { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + const steppedValue = snapToStep({ value, step: param.step }); + const minValue = param.min; + const maxValue = param.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); + } + + if (param.type === "color") { + return typeof value === "string" ? value : null; + } + + if (param.type === "boolean") { + return typeof value === "boolean" ? value : null; + } + + if (typeof value !== "string") { + return null; + } + + return param.options.some((option) => option.value === value) ? value : null; +} + +function buildGraphicParamDescriptor({ + element, + paramKey, +}: { + element: TimelineElement; + paramKey: string; +}): AnimationPathDescriptor | null { + if (element.type !== "graphic") { + return null; + } + + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return { + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), + numericRanges: getParamNumericRange({ param }), + coerceValue: (value) => coerceAnimationValueForParam({ param, value }), + getBaseValue: () => element.params[param.key] ?? param.default, + setBaseValue: (value) => { + const coercedValue = coerceAnimationValueForParam({ param, value }); + if (coercedValue === null) { + return element; + } + + return { + ...element, + params: { + ...element.params, + [param.key]: coercedValue, + }, + }; + }, + }; +} + +function buildEffectParamDescriptor({ + element, + effectId, + paramKey, +}: { + element: TimelineElement; + effectId: string; + paramKey: string; +}): AnimationPathDescriptor | null { + if (!isVisualElement(element)) { + return null; + } + + const effect = element.effects?.find((candidate) => candidate.id === effectId); + if (!effect) { + return null; + } + + registerDefaultEffects(); + const definition = effectsRegistry.get(effect.type); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return { + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), + numericRanges: getParamNumericRange({ param }), + coerceValue: (value) => coerceAnimationValueForParam({ param, value }), + getBaseValue: () => effect.params[param.key] ?? param.default, + setBaseValue: (value) => { + const coercedValue = coerceAnimationValueForParam({ param, value }); + if (coercedValue === null) { + return element; + } + + return { + ...element, + effects: + element.effects?.map((candidate) => + candidate.id !== effectId + ? candidate + : { + ...candidate, + params: { + ...candidate.params, + [param.key]: coercedValue, + }, + }, + ) ?? element.effects, + }; + }, + }; +} + +export function isAnimationPath( + propertyPath: string, +): propertyPath is AnimationPath { + return ( + isAnimationPropertyPath(propertyPath) || + isGraphicParamPath(propertyPath) || + isEffectParamPath(propertyPath) + ); +} + +export function resolveAnimationTarget({ + element, + path, +}: { + element: TimelineElement; + path: AnimationPath; +}): AnimationPathDescriptor | null { + if (isAnimationPropertyPath(path)) { + const propertyDefinition = getAnimationPropertyDefinition({ + propertyPath: path, + }); + if (!propertyDefinition.supportsElement({ element })) { + return null; + } + + return { + kind: propertyDefinition.kind, + defaultInterpolation: propertyDefinition.defaultInterpolation, + numericRanges: propertyDefinition.numericRanges, + coerceValue: (value) => + coerceAnimationValueForProperty({ + propertyPath: path, + value, + }), + getBaseValue: () => + getElementBaseValueForProperty({ + element, + propertyPath: path, + }), + setBaseValue: (value) => { + const coercedValue = propertyDefinition.coerceValue({ value }); + if (coercedValue === null) { + return element; + } + + return withElementBaseValueForProperty({ + element, + propertyPath: path, + value: coercedValue, + }); + }, + }; + } + + const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); + if (graphicParamTarget) { + return buildGraphicParamDescriptor({ + element, + paramKey: graphicParamTarget.paramKey, + }); + } + + const effectParamTarget = parseEffectParamPath({ propertyPath: path }); + if (effectParamTarget) { + return buildEffectParamDescriptor({ + element, + effectId: effectParamTarget.effectId, + paramKey: effectParamTarget.paramKey, + }); + } + + return null; +} diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts index ac2e0e78..1d07e81d 100644 --- a/apps/web/src/lib/animation/types.ts +++ b/apps/web/src/lib/animation/types.ts @@ -1,119 +1,160 @@ -export const ANIMATION_PROPERTY_PATHS = [ - "transform.position", - "transform.scaleX", - "transform.scaleY", - "transform.rotate", - "opacity", - "volume", - "color", - "background.color", - "background.paddingX", - "background.paddingY", - "background.offsetX", - "background.offsetY", - "background.cornerRadius", -] as const; - -export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; -export type GraphicParamPath = `params.${string}`; -export type EffectParamPath = `effects.${string}.params.${string}`; -export type AnimationPath = - | AnimationPropertyPath - | GraphicParamPath - | EffectParamPath; - -export const ANIMATION_PROPERTY_GROUPS = { - "transform.scale": ["transform.scaleX", "transform.scaleY"], -} as const satisfies Record>; - -export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS; - -export type VectorValue = { x: number; y: number }; - -export type AnimationValueKind = "number" | "color" | "discrete" | "vector"; -export type DiscreteValue = boolean | string; -export type AnimationValue = number | string | boolean | VectorValue; - -export type ContinuousKeyframeInterpolation = "linear" | "hold"; -export type DiscreteKeyframeInterpolation = "hold"; -export type AnimationInterpolation = - | ContinuousKeyframeInterpolation - | DiscreteKeyframeInterpolation; - -interface BaseAnimationKeyframe< - TValue extends AnimationValue, - TInterpolation extends AnimationInterpolation, -> { - id: string; - time: number; // relative to element start time - value: TValue; - interpolation: TInterpolation; -} - -export interface NumberKeyframe - extends BaseAnimationKeyframe {} - -export interface ColorKeyframe - extends BaseAnimationKeyframe {} - -export interface DiscreteKeyframe - extends BaseAnimationKeyframe {} - -export interface VectorKeyframe - extends BaseAnimationKeyframe {} - -export type AnimationKeyframe = - | NumberKeyframe - | ColorKeyframe - | DiscreteKeyframe - | VectorKeyframe; - -interface BaseAnimationChannel< - TValueKind extends AnimationValueKind, - TKeyframe extends AnimationKeyframe, -> { - valueKind: TValueKind; - keyframes: TKeyframe[]; -} - -export interface NumberAnimationChannel - extends BaseAnimationChannel<"number", NumberKeyframe> {} - -export interface ColorAnimationChannel - extends BaseAnimationChannel<"color", ColorKeyframe> {} - -export interface DiscreteAnimationChannel - extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {} - -export interface VectorAnimationChannel - extends BaseAnimationChannel<"vector", VectorKeyframe> {} - -export type AnimationChannel = - | NumberAnimationChannel - | ColorAnimationChannel - | DiscreteAnimationChannel - | VectorAnimationChannel; - -export type ElementAnimationChannelMap = Record< - string, - AnimationChannel | undefined ->; - -export interface ElementAnimations { - channels: ElementAnimationChannelMap; -} - -export interface ElementKeyframe { - propertyPath: AnimationPath; - id: string; - time: number; - value: AnimationValue; - interpolation: AnimationInterpolation; -} - -export interface SelectedKeyframeRef { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - keyframeId: string; -} +export const ANIMATION_PROPERTY_PATHS = [ + "transform.position", + "transform.scaleX", + "transform.scaleY", + "transform.rotate", + "opacity", + "volume", + "color", + "background.color", + "background.paddingX", + "background.paddingY", + "background.offsetX", + "background.offsetY", + "background.cornerRadius", +] as const; + +export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; +export type GraphicParamPath = `params.${string}`; +export type EffectParamPath = `effects.${string}.params.${string}`; +export type AnimationPath = + | AnimationPropertyPath + | GraphicParamPath + | EffectParamPath; + +export const ANIMATION_PROPERTY_GROUPS = { + "transform.scale": ["transform.scaleX", "transform.scaleY"], +} as const satisfies Record>; + +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 type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier"; +export type DiscreteKeyframeInterpolation = "hold"; +export type AnimationInterpolation = + | ContinuousKeyframeInterpolation + | DiscreteKeyframeInterpolation; + +export type PrimitiveAnimationChannelKind = "scalar" | "discrete"; +export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete"; +export type ScalarSegmentType = "step" | "linear" | "bezier"; +export type TangentMode = "auto" | "aligned" | "broken" | "flat"; +export type ChannelExtrapolationMode = "hold" | "linear"; + +export interface CurveHandle { + dt: number; + dv: number; +} + +interface BaseAnimationKeyframe { + id: string; + time: number; // relative to element start time + value: TValue; +} + +export interface ScalarAnimationKey extends BaseAnimationKeyframe { + leftHandle?: CurveHandle; + rightHandle?: CurveHandle; + segmentToNext: ScalarSegmentType; + tangentMode: TangentMode; +} + +export interface DiscreteAnimationKey + extends BaseAnimationKeyframe {} + +export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey; + +export interface ScalarAnimationChannel { + kind: "scalar"; + keys: ScalarAnimationKey[]; + extrapolation?: { + before: ChannelExtrapolationMode; + after: ChannelExtrapolationMode; + }; +} + +export interface DiscreteAnimationChannel { + kind: "discrete"; + keys: DiscreteAnimationKey[]; +} + +export type AnimationChannel = + | ScalarAnimationChannel + | DiscreteAnimationChannel; + +export type ElementAnimationChannelMap = Record< + string, + AnimationChannel | undefined +>; + +export interface AnimationBindingComponent { + key: TKey; + channelId: string; +} + +interface BaseAnimationBinding< + TKind extends AnimationBindingKind, + TComponentKey extends string, +> { + path: AnimationPath; + kind: TKind; + components: AnimationBindingComponent[]; +} + +export interface NumberAnimationBinding + extends BaseAnimationBinding<"number", "value"> {} + +export interface Vector2AnimationBinding + extends BaseAnimationBinding<"vector2", "x" | "y"> {} + +export interface ColorAnimationBinding + extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> { + colorSpace: "srgb-linear"; +} + +export interface DiscreteAnimationBinding + extends BaseAnimationBinding<"discrete", "value"> {} + +export type AnimationBindingInstance = + | NumberAnimationBinding + | Vector2AnimationBinding + | ColorAnimationBinding + | DiscreteAnimationBinding; + +export interface AnimationBindingByKind { + number: NumberAnimationBinding; + vector2: Vector2AnimationBinding; + color: ColorAnimationBinding; + discrete: DiscreteAnimationBinding; +} + +export type AnimationBindingOfKind = + AnimationBindingByKind[TKind]; + +export type ElementAnimationBindingMap = Record< + string, + AnimationBindingInstance | undefined +>; + +export interface ElementAnimations { + bindings: ElementAnimationBindingMap; + channels: ElementAnimationChannelMap; +} + +export interface ElementKeyframe { + propertyPath: AnimationPath; + id: string; + time: number; + value: AnimationValue; + interpolation: AnimationInterpolation; +} + +export interface SelectedKeyframeRef { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + keyframeId: string; +} diff --git a/apps/web/src/lib/animation/vector-channel.ts b/apps/web/src/lib/animation/vector-channel.ts deleted file mode 100644 index f7f65049..00000000 --- a/apps/web/src/lib/animation/vector-channel.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { - AnimationPropertyPath, - ElementAnimations, - VectorAnimationChannel, - VectorValue, -} from "@/lib/animation/types"; - -export function isVectorValue(value: unknown): value is VectorValue { - return ( - typeof value === "object" && - value !== null && - "x" in value && - "y" in value && - typeof (value as VectorValue).x === "number" && - typeof (value as VectorValue).y === "number" - ); -} - -export function getVectorChannelForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; -}): VectorAnimationChannel | undefined { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.valueKind !== "vector") { - return undefined; - } - return channel; -} - -export function getVectorChannelValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: VectorAnimationChannel | undefined; - time: number; - fallbackValue: VectorValue; -}): VectorValue { - if (!channel || channel.keyframes.length === 0) { - return fallbackValue; - } - - const keyframes = [...channel.keyframes].sort((a, b) => a.time - b.time); - const first = keyframes[0]; - const last = keyframes[keyframes.length - 1]; - - if (!first || !last) return fallbackValue; - if (time <= first.time) return first.value; - if (time >= last.time) return last.value; - - for (let i = 0; i < keyframes.length - 1; i++) { - const left = keyframes[i]; - const right = keyframes[i + 1]; - if (time < left.time || time > right.time) continue; - - if (left.interpolation === "hold") return left.value; - - const span = right.time - left.time; - if (span === 0) return right.value; - - const t = (time - left.time) / span; - return { - x: left.value.x + (right.value.x - left.value.x) * t, - y: left.value.y + (right.value.y - left.value.y) * t, - }; - } - - return last.value; -} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts index b5c7550d..71b2ac21 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts @@ -1,8 +1,9 @@ import { EditorCore } from "@/core"; import { - getChannel, - getChannelValueAtTime, + hasKeyframesForPath, + getKeyframeById, removeElementKeyframe, + resolveAnimationPathValueAtTime, resolveAnimationTarget, } from "@/lib/animation"; import { Command, type CommandResult } from "@/lib/commands/base-command"; @@ -19,17 +20,6 @@ function sampleValueBeforeRemoval({ propertyPath: AnimationPath; keyframeId: string; }): AnimationValue | null { - const channel = getChannel({ - animations: element.animations, - propertyPath, - }); - const keyframe = channel?.keyframes.find( - (candidate) => candidate.id === keyframeId, - ); - if (!channel || !keyframe) { - return null; - } - const target = resolveAnimationTarget({ element, path: propertyPath }); if (!target) { return null; @@ -39,9 +29,19 @@ function sampleValueBeforeRemoval({ return null; } - return getChannelValueAtTime({ - channel, - time: keyframe.time, + const keyframe = getKeyframeById({ + animations: element.animations, + propertyPath, + keyframeId, + }); + if (!keyframe) { + return null; + } + + return resolveAnimationPathValueAtTime({ + animations: element.animations, + propertyPath, + localTime: keyframe.time, fallbackValue: baseValue, }); } @@ -72,8 +72,10 @@ function removeKeyframeAndPersist({ keyframeId, }); - const isChannelNowEmpty = - getChannel({ animations: nextAnimations, propertyPath }) === undefined; + const isChannelNowEmpty = !hasKeyframesForPath({ + animations: nextAnimations, + propertyPath, + }); const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null; const baseElement = shouldPersistToBase diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index edbf5198..d0f01104 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -1,8 +1,13 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; +import { + buildEffectParamPath, + resolveAnimationTarget, + upsertPathKeyframe, +} from "@/lib/animation"; import { updateElementInTracks } from "@/lib/timeline"; import { isVisualElement } from "@/lib/timeline/element-utils"; +import type { AnimationInterpolation } from "@/lib/animation/types"; import type { TimelineTrack } from "@/lib/timeline"; export class UpsertEffectParamKeyframeCommand extends Command { @@ -12,8 +17,8 @@ export class UpsertEffectParamKeyframeCommand extends Command { private readonly effectId: string; private readonly paramKey: string; private readonly time: number; - private readonly value: number; - private readonly interpolation: "linear" | "hold" | undefined; + private readonly value: number | string | boolean; + private readonly interpolation: AnimationInterpolation | undefined; private readonly keyframeId: string | undefined; constructor({ @@ -31,8 +36,8 @@ export class UpsertEffectParamKeyframeCommand extends Command { effectId: string; paramKey: string; time: number; - value: number; - interpolation?: "linear" | "hold"; + value: number | string | boolean; + interpolation?: AnimationInterpolation; keyframeId?: string; }) { super(); @@ -57,14 +62,28 @@ export class UpsertEffectParamKeyframeCommand extends Command { elementPredicate: isVisualElement, update: (element) => { const boundedTime = Math.max(0, Math.min(this.time, element.duration)); - const animations = upsertEffectParamKeyframe({ - animations: element.animations, + const propertyPath = buildEffectParamPath({ effectId: this.effectId, paramKey: this.paramKey, + }); + const target = resolveAnimationTarget({ + element, + path: propertyPath, + }); + if (!target) { + return element; + } + + const animations = upsertPathKeyframe({ + animations: element.animations, + propertyPath, time: boundedTime, value: this.value, interpolation: this.interpolation, keyframeId: this.keyframeId, + kind: target.kind, + defaultInterpolation: target.defaultInterpolation, + coerceValue: target.coerceValue, }); return { ...element, animations }; }, diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts index 98589987..bd56749a 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -73,9 +73,9 @@ export class UpsertKeyframeCommand extends Command { value: this.value, interpolation: this.interpolation, keyframeId: this.keyframeId, - valueKind: target.valueKind, + kind: target.kind, defaultInterpolation: target.defaultInterpolation, - numericRange: target.numericRange, + coerceValue: target.coerceValue, }), }; }, diff --git a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts index c14a5a42..fc195cba 100644 --- a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts +++ b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts @@ -47,7 +47,7 @@ export class ToggleSourceAudioSeparationCommand extends Command { return; } - if (canRecoverSourceAudio({ element: sourceElement })) { + if (canRecoverSourceAudio(sourceElement)) { editor.timeline.updateTracks( updateSourceAudioEnabled({ tracks: this.savedState, @@ -63,7 +63,7 @@ export class ToggleSourceAudioSeparationCommand extends Command { .media .getAssets() .find((asset) => asset.id === sourceElement.mediaId); - if (!canExtractSourceAudio({ element: sourceElement, mediaAsset })) { + if (!canExtractSourceAudio(sourceElement, mediaAsset)) { return; } if (sourceElement.duration <= 0) { diff --git a/apps/web/src/lib/masks/__tests__/snap.test.ts b/apps/web/src/lib/masks/__tests__/snap.test.ts index 41093c88..72b2ca80 100644 --- a/apps/web/src/lib/masks/__tests__/snap.test.ts +++ b/apps/web/src/lib/masks/__tests__/snap.test.ts @@ -1,235 +1,237 @@ -import { describe, expect, test } from "bun:test"; -import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split"; -import { getMaskSnapGeometry } from "@/lib/masks/geometry"; -import { snapMaskInteraction } from "@/lib/masks/snap"; -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types"; - -const bounds: ElementBounds = { - cx: 200, - cy: 150, - width: 200, - height: 100, - rotation: 0, -}; - -const canvasSize = { - width: 400, - height: 300, -}; - -const snapThreshold = { - x: 8, - y: 8, -}; - -function buildSplitParams( - overrides: Partial = {}, -): SplitMaskParams { - return { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - centerX: 0, - centerY: 0, - rotation: 0, - ...overrides, - }; -} - -function buildRectangleParams( - overrides: Partial = {}, -): RectangleMaskParams { - return { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - centerX: 0, - centerY: 0, - width: 0.4, - height: 0.2, - rotation: 0, - scale: 1, - ...overrides, - }; -} - -function sortSegment( - segment: [{ x: number; y: number }, { x: number; y: number }], -): [{ x: number; y: number }, { x: number; y: number }] { - return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [ - { x: number; y: number }, - { x: number; y: number }, - ]; -} - -describe("mask geometry", () => { - test("resolves split mask center from centerX and centerY", () => { - expect( - getMaskSnapGeometry({ - params: buildSplitParams({ - centerX: 0.25, - centerY: -0.5, - rotation: 45, - }), - bounds, - }), - ).toEqual({ - position: { x: 50, y: -50 }, - size: { width: 0, height: 0 }, - rotation: 45, - }); - }); - - test("resolves box mask center and size from centerX and centerY", () => { - expect( - getMaskSnapGeometry({ - params: buildRectangleParams({ - centerX: -0.25, - centerY: 0.5, - width: 0.5, - height: 0.6, - rotation: 30, - }), - bounds, - }), - ).toEqual({ - position: { x: -50, y: 50 }, - size: { width: 100, height: 60 }, - rotation: 30, - }); - }); - - test("returns a vertical split stroke segment for rotation 0", () => { - const segment = getSplitMaskStrokeSegment({ - resolvedParams: buildSplitParams(), - width: bounds.width, - height: bounds.height, - }); - - expect(segment).not.toBeNull(); - if (!segment) { - throw new Error("Expected split stroke segment for rotation 0"); - } - expect(sortSegment(segment)).toEqual([ - { x: bounds.width / 2, y: 0 }, - { x: bounds.width / 2, y: bounds.height }, - ]); - }); - - test("returns a horizontal split stroke segment for rotation 90", () => { - const segment = getSplitMaskStrokeSegment({ - resolvedParams: buildSplitParams({ rotation: 90 }), - width: bounds.width, - height: bounds.height, - }); - - expect(segment).not.toBeNull(); - if (!segment) { - throw new Error("Expected split stroke segment for rotation 90"); - } - expect(sortSegment(segment)).toEqual([ - { x: 0, y: bounds.height / 2 }, - { x: bounds.width, y: bounds.height / 2 }, - ]); - }); -}); - -describe("mask snapping", () => { - test("snaps split mask movement using the shared position pipeline", () => { - const result = snapMaskInteraction({ - handleId: "position", - startParams: buildSplitParams({ - centerX: 0.03, - centerY: -0.04, - }), - proposedParams: buildSplitParams({ - centerX: 0.03, - centerY: -0.04, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.centerX).toBe(0); - expect(result.params.centerY).toBe(0); - expect(result.activeLines).toEqual([ - { type: "vertical", position: 0 }, - { type: "horizontal", position: 0 }, - ]); - }); - - test("snaps box mask movement against element center and edges", () => { - const result = snapMaskInteraction({ - handleId: "position", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - centerX: 0.29, - centerY: 0.03, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.centerX).toBeCloseTo(0.3); - expect(result.params.centerY).toBe(0); - expect(result.activeLines).toEqual([ - { type: "vertical", position: 100 }, - { type: "horizontal", position: 0 }, - ]); - }); - - test("snaps mask rotation through the shared rotation path", () => { - const result = snapMaskInteraction({ - handleId: "rotation", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - rotation: 88, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.rotation).toBe(90); - expect(result.activeLines).toEqual([]); - }); - - test("snaps edge resize for box masks", () => { - const result = snapMaskInteraction({ - handleId: "right", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - width: 0.98, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.width).toBe(1); - expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); - }); - - test("snaps corner resize for box masks", () => { - const result = snapMaskInteraction({ - handleId: "bottom-right", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - width: 0.99, - height: 0.495, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.width).toBe(1); - expect(result.params.height).toBe(0.5); - expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); - }); -}); +import { describe, expect, test } from "bun:test"; +import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split"; +import { getMaskSnapGeometry } from "@/lib/masks/geometry"; +import { snapMaskInteraction } from "@/lib/masks/snap"; +import type { ElementBounds } from "@/lib/preview/element-bounds"; +import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types"; + +const bounds: ElementBounds = { + cx: 200, + cy: 150, + width: 200, + height: 100, + rotation: 0, +}; + +const canvasSize = { + width: 400, + height: 300, +}; + +const snapThreshold = { + x: 8, + y: 8, +}; + +function buildSplitParams( + overrides: Partial = {}, +): SplitMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + centerX: 0, + centerY: 0, + rotation: 0, + ...overrides, + }; +} + +function buildRectangleParams( + overrides: Partial = {}, +): RectangleMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + centerX: 0, + centerY: 0, + width: 0.4, + height: 0.2, + rotation: 0, + scale: 1, + ...overrides, + }; +} + +function sortSegment( + segment: [{ x: number; y: number }, { x: number; y: number }], +): [{ x: number; y: number }, { x: number; y: number }] { + return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [ + { x: number; y: number }, + { x: number; y: number }, + ]; +} + +describe("mask geometry", () => { + test("resolves split mask center from centerX and centerY", () => { + expect( + getMaskSnapGeometry({ + params: buildSplitParams({ + centerX: 0.25, + centerY: -0.5, + rotation: 45, + }), + bounds, + }), + ).toEqual({ + position: { x: 50, y: -50 }, + size: { width: 0, height: 0 }, + rotation: 45, + }); + }); + + test("resolves box mask center and size from centerX and centerY", () => { + expect( + getMaskSnapGeometry({ + params: buildRectangleParams({ + centerX: -0.25, + centerY: 0.5, + width: 0.5, + height: 0.6, + rotation: 30, + }), + bounds, + }), + ).toEqual({ + position: { x: -50, y: 50 }, + size: { width: 100, height: 60 }, + rotation: 30, + }); + }); + + test("returns a vertical split stroke segment for rotation 0", () => { + const segment = getSplitMaskStrokeSegment({ + resolvedParams: buildSplitParams(), + width: bounds.width, + height: bounds.height, + }); + + expect(segment).not.toBeNull(); + if (!segment) { + throw new Error("Expected split stroke segment for rotation 0"); + } + expect(sortSegment(segment)).toEqual([ + { x: bounds.width / 2, y: 0 }, + { x: bounds.width / 2, y: bounds.height }, + ]); + }); + + test("returns a horizontal split stroke segment for rotation 90", () => { + const segment = getSplitMaskStrokeSegment({ + resolvedParams: buildSplitParams({ rotation: 90 }), + width: bounds.width, + height: bounds.height, + }); + + expect(segment).not.toBeNull(); + if (!segment) { + throw new Error("Expected split stroke segment for rotation 90"); + } + expect(sortSegment(segment)).toEqual([ + { x: 0, y: bounds.height / 2 }, + { x: bounds.width, y: bounds.height / 2 }, + ]); + }); +}); + +describe("mask snapping", () => { + test("snaps split mask movement using the shared position pipeline", () => { + const result = snapMaskInteraction({ + handleId: "position", + startParams: buildSplitParams({ + centerX: 0.03, + centerY: -0.04, + }), + proposedParams: buildSplitParams({ + centerX: 0.03, + centerY: -0.04, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.centerX).toBe(0); + expect(result.params.centerY).toBe(0); + expect(result.activeLines).toEqual([ + { type: "vertical", position: 0 }, + { type: "horizontal", position: 0 }, + ]); + }); + + test("snaps box mask movement against element center and edges", () => { + const result = snapMaskInteraction({ + handleId: "position", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + centerX: 0.29, + centerY: 0.03, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.centerX).toBeCloseTo(0.3); + expect(result.params.centerY).toBe(0); + expect(result.activeLines).toEqual([ + { type: "vertical", position: 100 }, + { type: "horizontal", position: 0 }, + ]); + }); + + test("snaps mask rotation through the shared rotation path", () => { + const result = snapMaskInteraction({ + handleId: "rotation", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + rotation: 88, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.rotation).toBe(90); + expect(result.activeLines).toEqual([]); + }); + + test("snaps edge resize for box masks", () => { + const result = snapMaskInteraction({ + handleId: "right", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + width: 0.98, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.width).toBe(1); + expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); + }); + + test("snaps corner resize for box masks", () => { + const result = snapMaskInteraction({ + handleId: "bottom-right", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + width: 0.99, + height: 0.495, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.width).toBe(1); + expect(result.params.height).toBe(0.5); + expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); + }); +}); diff --git a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts index 173b6402..ca54f4cd 100644 --- a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts +++ b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { upsertElementKeyframe } from "@/lib/animation"; +import type { MediaAsset } from "@/lib/media/types"; import type { AudioElement, VideoElement } from "@/lib/timeline"; import { buildSeparatedAudioElement, @@ -25,32 +27,7 @@ describe("audio separation", () => { volume: -6, muted: true, retime: { rate: 1.25, maintainPitch: true }, - animations: { - channels: { - volume: { - valueKind: "number", - keyframes: [ - { - id: "volume-keyframe", - time: 2, - value: -12, - interpolation: "linear", - }, - ], - }, - opacity: { - valueKind: "number", - keyframes: [ - { - id: "opacity-keyframe", - time: 1, - value: 0.5, - interpolation: "linear", - }, - ], - }, - }, - }, + animations: buildAnimations(), }); const separatedAudioElement = buildSeparatedAudioElement({ @@ -71,21 +48,22 @@ describe("audio separation", () => { muted: true, retime: { rate: 1.25, maintainPitch: true }, }); - expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([ + expect(Object.keys(separatedAudioElement.animations?.bindings ?? {})).toEqual([ "volume", ]); + expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([ + "volume:value", + ]); expect( - separatedAudioElement.animations?.channels.volume?.keyframes[0]?.id, + separatedAudioElement.animations?.channels["volume:value"]?.keys[0]?.id, ).not.toBe("volume-keyframe"); }); test("skips source audio collection when the source clip is separated", () => { - const mediaAsset = { + const mediaAsset: MediaAsset = { id: "media-1", type: "video", name: "Clip", - size: 1, - lastModified: 1, file: new File(["video"], "clip.mp4", { type: "video/mp4" }), url: "blob:clip", hasAudio: true, @@ -145,3 +123,23 @@ function buildVideoElement( ...overrides, }; } + +function buildAnimations() { + const withVolume = upsertElementKeyframe({ + animations: undefined, + propertyPath: "volume", + time: 2, + value: -12, + interpolation: "linear", + keyframeId: "volume-keyframe", + }); + + return upsertElementKeyframe({ + animations: withVolume, + propertyPath: "opacity", + time: 1, + value: 0.5, + interpolation: "linear", + keyframeId: "opacity-keyframe", + }); +} diff --git a/apps/web/src/lib/timeline/audio-separation/index.ts b/apps/web/src/lib/timeline/audio-separation/index.ts index b92cb79b..fc0ead2d 100644 --- a/apps/web/src/lib/timeline/audio-separation/index.ts +++ b/apps/web/src/lib/timeline/audio-separation/index.ts @@ -1,4 +1,4 @@ -import { cloneAnimations, getChannel } from "@/lib/animation"; +import { cloneAnimations } from "@/lib/animation"; import type { ElementAnimations } from "@/lib/animation/types"; import type { MediaAsset } from "@/lib/media/types"; import { DEFAULTS } from "@/lib/timeline/defaults"; @@ -25,13 +25,10 @@ export function isSourceAudioSeparated({ return !isSourceAudioEnabled({ element }); } -export function canExtractSourceAudio({ - element, - mediaAsset, -}: { - element: TimelineElement; - mediaAsset: MediaAsset | null | undefined; -}): element is VideoElement { +export function canExtractSourceAudio( + element: TimelineElement, + mediaAsset: MediaAsset | null | undefined, +): element is VideoElement { return ( element.type === "video" && isSourceAudioEnabled({ element }) && @@ -40,25 +37,17 @@ export function canExtractSourceAudio({ ); } -export function canRecoverSourceAudio({ - element, -}: { - element: TimelineElement; -}): element is VideoElement { +export function canRecoverSourceAudio( + element: TimelineElement, +): element is VideoElement { return element.type === "video" && isSourceAudioSeparated({ element }); } -export function canToggleSourceAudio({ - element, - mediaAsset, -}: { - element: TimelineElement; - mediaAsset: MediaAsset | null | undefined; -}): element is VideoElement { - return ( - canRecoverSourceAudio({ element }) || - canExtractSourceAudio({ element, mediaAsset }) - ); +export function canToggleSourceAudio( + element: TimelineElement, + mediaAsset: MediaAsset | null | undefined, +): element is VideoElement { + return canRecoverSourceAudio(element) || canExtractSourceAudio(element, mediaAsset); } export function doesElementHaveEnabledAudio({ @@ -117,16 +106,27 @@ function cloneVolumeAnimations({ }: { animations: ElementAnimations | undefined; }): ElementAnimations | undefined { - const volumeChannel = getChannel({ animations, propertyPath: "volume" }); - if (!volumeChannel) { + const volumeBinding = animations?.bindings.volume; + if (!volumeBinding) { + return undefined; + } + + const subsetChannels = Object.fromEntries( + volumeBinding.components.flatMap((component) => { + const channel = animations?.channels[component.channelId]; + return channel ? [[component.channelId, channel] as const] : []; + }), + ); + if (Object.keys(subsetChannels).length === 0) { return undefined; } return cloneAnimations({ animations: { - channels: { - volume: volumeChannel, + bindings: { + volume: volumeBinding, }, + channels: subsetChannels, }, shouldRegenerateKeyframeIds: true, }); diff --git a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts new file mode 100644 index 00000000..18b815b0 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV21ToV22 } from "../transformers/v21-to-v22"; + +describe("V21 to V22 Migration", () => { + test("migrates legacy animation channels to bindings and component channels", () => { + const result = transformProjectV21ToV22({ + project: { + id: "project-v21-animations", + version: 21, + scenes: [ + { + id: "scene-1", + tracks: [ + { + id: "track-1", + elements: [ + { + id: "element-1", + type: "text", + animations: { + channels: { + opacity: { + valueKind: "number", + keyframes: [ + { + id: "opacity-1", + time: 1, + value: 0.5, + interpolation: "linear", + }, + ], + }, + "transform.position": { + valueKind: "vector", + keyframes: [ + { + id: "position-1", + time: 2, + value: { x: 10, y: 20 }, + interpolation: "hold", + }, + ], + }, + color: { + valueKind: "color", + keyframes: [ + { + id: "color-1", + time: 3, + value: "#ff0000", + interpolation: "linear", + }, + ], + }, + "effects.effect-1.params.enabled": { + valueKind: "discrete", + keyframes: [ + { + id: "enabled-1", + time: 4, + value: true, + interpolation: "hold", + }, + ], + }, + }, + }, + }, + ], + }, + ], + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(22); + + const scenes = result.project.scenes as Array>; + const tracks = scenes[0].tracks as Array>; + const elements = tracks[0].elements as Array>; + const animations = elements[0].animations as Record; + const bindings = animations.bindings as Record>; + const channels = animations.channels as Record>; + + expect(bindings.opacity).toEqual({ + path: "opacity", + kind: "number", + components: [{ key: "value", channelId: "opacity:value" }], + }); + expect(bindings["transform.position"]).toEqual({ + path: "transform.position", + kind: "vector2", + components: [ + { key: "x", channelId: "transform.position:x" }, + { key: "y", channelId: "transform.position:y" }, + ], + }); + expect(bindings.color).toEqual({ + path: "color", + kind: "color", + colorSpace: "srgb-linear", + components: [ + { key: "r", channelId: "color:r" }, + { key: "g", channelId: "color:g" }, + { key: "b", channelId: "color:b" }, + { key: "a", channelId: "color:a" }, + ], + }); + expect(bindings["effects.effect-1.params.enabled"]).toEqual({ + path: "effects.effect-1.params.enabled", + kind: "discrete", + components: [ + { + key: "value", + channelId: "effects.effect-1.params.enabled:value", + }, + ], + }); + + expect(channels["opacity:value"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "opacity-1", + time: 1, + value: 0.5, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["transform.position:x"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "position-1", + time: 2, + value: 10, + segmentToNext: "step", + tangentMode: "flat", + }, + ], + }); + expect(channels["transform.position:y"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "position-1", + time: 2, + value: 20, + segmentToNext: "step", + tangentMode: "flat", + }, + ], + }); + expect(channels["color:r"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "color-1", + time: 3, + value: 1, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["color:g"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "color-1", + time: 3, + value: 0, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["color:b"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "color-1", + time: 3, + value: 0, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["color:a"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "color-1", + time: 3, + value: 1, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["effects.effect-1.params.enabled:value"]).toEqual({ + kind: "discrete", + keys: [ + { + id: "enabled-1", + time: 4, + value: true, + }, + ], + }); + }); + + test("skips projects already on v22", () => { + const result = transformProjectV21ToV22({ + project: { + id: "project-v22", + version: 22, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v22"); + }); + + test("skips projects not on v21", () => { + const result = transformProjectV21ToV22({ + project: { + id: "project-v20", + version: 20, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("not v21"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts index 66e87713..b05d1837 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts @@ -1,92 +1,94 @@ -import { describe, expect, test } from "bun:test"; -import { transformProjectV5ToV6 } from "../transformers/v5-to-v6"; -import { v5Project } from "./fixtures"; - -describe("V5 to V6 Migration", () => { - test("converts number bookmarks to Bookmark objects", async () => { - const result = transformProjectV5ToV6({ - project: v5Project as Parameters< - typeof transformProjectV5ToV6 - >[0]["project"], - }); - - expect(result.skipped).toBe(false); - expect(result.project.version).toBe(6); - - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; - expect(mainScene.bookmarks).toEqual([ - { time: 2.0 }, - { time: 5.5 }, - { time: 12.0 }, - ]); - - const introScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[1]; - expect(introScene.bookmarks).toEqual([]); - }); - - test("skips projects that are already v6", () => { - const result = transformProjectV5ToV6({ - project: { - ...v5Project, - version: 6, - scenes: [ - { - ...(v5Project as { scenes: unknown[] }).scenes[0], - bookmarks: [{ time: 2 }, { time: 5 }], - }, - ], - } as Parameters[0]["project"], - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("already v6"); - }); - - test("skips projects with no id", () => { - const result = transformProjectV5ToV6({ - project: { - version: 5, - scenes: [], - } as Parameters[0]["project"], - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("no project id"); - }); - - test("preserves existing Bookmark objects with note, color, duration", () => { - const projectWithRichBookmarks = { - ...v5Project, - version: 5, - scenes: [ - { - ...(v5Project as { scenes: Array> }) - .scenes[0], - bookmarks: [ - { time: 1, note: "Intro", color: "#ef4444" }, - { time: 5.5, duration: 2 }, - ], - }, - ], - }; - - const result = transformProjectV5ToV6({ - project: projectWithRichBookmarks as Parameters< - typeof transformProjectV5ToV6 - >[0]["project"], - }); - - expect(result.skipped).toBe(false); - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; - expect(mainScene.bookmarks).toEqual([ - { time: 1, note: "Intro", color: "#ef4444" }, - { time: 5.5, duration: 2 }, - ]); - }); -}); +import { describe, expect, test } from "bun:test"; +import { transformProjectV5ToV6 } from "../transformers/v5-to-v6"; +import { v5Project } from "./fixtures"; + +describe("V5 to V6 Migration", () => { + test("converts number bookmarks to Bookmark objects", async () => { + const result = transformProjectV5ToV6({ + project: v5Project as Parameters< + typeof transformProjectV5ToV6 + >[0]["project"], + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(6); + + const mainScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[0]; + expect(mainScene.bookmarks).toEqual([ + { time: 2.0 }, + { time: 5.5 }, + { time: 12.0 }, + ]); + + const introScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[1]; + expect(introScene.bookmarks).toEqual([]); + }); + + test("skips projects that are already v6", () => { + const firstScene = (v5Project as { scenes: Array> }) + .scenes[0]; + const result = transformProjectV5ToV6({ + project: { + ...v5Project, + version: 6, + scenes: [ + { + ...firstScene, + bookmarks: [{ time: 2 }, { time: 5 }], + }, + ], + } as Parameters[0]["project"], + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v6"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV5ToV6({ + project: { + version: 5, + scenes: [], + } as Parameters[0]["project"], + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); + + test("preserves existing Bookmark objects with note, color, duration", () => { + const projectWithRichBookmarks = { + ...v5Project, + version: 5, + scenes: [ + { + ...(v5Project as { scenes: Array> }) + .scenes[0], + bookmarks: [ + { time: 1, note: "Intro", color: "#ef4444" }, + { time: 5.5, duration: 2 }, + ], + }, + ], + }; + + const result = transformProjectV5ToV6({ + project: projectWithRichBookmarks as Parameters< + typeof transformProjectV5ToV6 + >[0]["project"], + }); + + expect(result.skipped).toBe(false); + const mainScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[0]; + expect(mainScene.bookmarks).toEqual([ + { time: 1, note: "Intro", color: "#ef4444" }, + { time: 5.5, duration: 2 }, + ]); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index ea596435..ef41673e 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -20,10 +20,11 @@ import { V17toV18Migration } from "./v17-to-v18"; import { V18toV19Migration } from "./v18-to-v19"; import { V19toV20Migration } from "./v19-to-v20"; import { V20toV21Migration } from "./v20-to-v21"; +import { V21toV22Migration } from "./v21-to-v22"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 21; +export const CURRENT_PROJECT_VERSION = 22; export const migrations = [ new V0toV1Migration(), @@ -47,4 +48,5 @@ export const migrations = [ new V18toV19Migration(), new V19toV20Migration(), new V20toV21Migration(), + new V21toV22Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts b/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts new file mode 100644 index 00000000..15fd9edf --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts @@ -0,0 +1,511 @@ +import { parseColorToLinearRgba } from "@/lib/animation/binding-values"; +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +const COLOR_COMPONENT_KEYS = ["r", "g", "b", "a"] as const; +type LegacyInterpolation = "linear" | "hold"; + +interface LegacyScalarKeyframe { + id: string; + time: number; + value: number; + interpolation: LegacyInterpolation; +} + +interface LegacyDiscreteKeyframe { + id: string; + time: number; + value: string | boolean; +} + +interface LegacyVectorValue { + x: number; + y: number; +} + +interface LegacyVectorKeyframe { + id: string; + time: number; + value: LegacyVectorValue; + interpolation: LegacyInterpolation; +} + +interface MigratedAnimationChannel { + binding: ProjectRecord; + channels: Record; +} + +export function transformProjectV21ToV22({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 22) { + return { project, skipped: true, reason: "already v22" }; + } + if (version !== 21) { + return { project, skipped: true, reason: "not v21" }; + } + + return { + project: { + ...migrateProjectAnimations({ project }), + version: 22, + }, + skipped: false, + }; +} + +function migrateProjectAnimations({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenes = project.scenes; + if (!Array.isArray(scenes)) { + return project; + } + + return { + ...project, + scenes: scenes.map((scene) => migrateSceneAnimations({ scene })), + }; +} + +function migrateSceneAnimations({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const tracks = scene.tracks; + if (!Array.isArray(tracks)) { + return scene; + } + + return { + ...scene, + tracks: tracks.map((track) => migrateTrackAnimations({ track })), + }; +} + +function migrateTrackAnimations({ track }: { track: unknown }): unknown { + if (!isRecord(track)) { + return track; + } + + const elements = track.elements; + if (!Array.isArray(elements)) { + return track; + } + + return { + ...track, + elements: elements.map((element) => migrateElementAnimations({ element })), + }; +} + +function migrateElementAnimations({ element }: { element: unknown }): unknown { + if (!isRecord(element)) { + return element; + } + + const animations = element.animations; + if (!isRecord(animations)) { + return element; + } + + if (isRecord(animations.bindings)) { + return element; + } + + const migratedAnimations = migrateLegacyAnimations({ animations }); + if (!migratedAnimations) { + const { animations: _unusedAnimations, ...elementWithoutAnimations } = element; + return elementWithoutAnimations; + } + + return { + ...element, + animations: migratedAnimations, + }; +} + +function migrateLegacyAnimations({ + animations, +}: { + animations: ProjectRecord; +}): ProjectRecord | null { + const legacyChannels = animations.channels; + if (!isRecord(legacyChannels)) { + return null; + } + + const nextBindings: Record = {}; + const nextChannels: Record = {}; + + for (const [propertyPath, channel] of Object.entries(legacyChannels)) { + const migratedChannel = migrateLegacyChannel({ + propertyPath, + channel, + }); + if (!migratedChannel) { + continue; + } + + nextBindings[propertyPath] = migratedChannel.binding; + Object.assign(nextChannels, migratedChannel.channels); + } + + if (Object.keys(nextBindings).length === 0) { + return null; + } + + return { + bindings: nextBindings, + channels: nextChannels, + }; +} + +function migrateLegacyChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: unknown; +}): MigratedAnimationChannel | null { + if (!isRecord(channel)) { + return null; + } + + switch (channel.valueKind) { + case "number": + return migrateNumberChannel({ propertyPath, channel }); + case "discrete": + return migrateDiscreteChannel({ propertyPath, channel }); + case "vector": + return migrateVectorChannel({ propertyPath, channel }); + case "color": + return migrateColorChannel({ propertyPath, channel }); + default: + return null; + } +} + +function migrateNumberChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: ProjectRecord; +}): MigratedAnimationChannel | null { + const legacyKeys = getLegacyScalarKeyframes({ + channel, + isValidValue: (value): value is number => + typeof value === "number" && Number.isFinite(value), + }); + if (legacyKeys.length === 0) { + return null; + } + + return { + binding: { + path: propertyPath, + kind: "number", + components: [ + { + key: "value", + channelId: buildChannelId({ + propertyPath, + componentKey: "value", + }), + }, + ], + }, + channels: { + [buildChannelId({ propertyPath, componentKey: "value" })]: { + kind: "scalar", + keys: legacyKeys.map((keyframe) => toScalarKeyframe({ keyframe })), + }, + }, + }; +} + +function migrateDiscreteChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: ProjectRecord; +}): MigratedAnimationChannel | null { + const legacyKeys = getLegacyDiscreteKeyframes({ channel }); + if (legacyKeys.length === 0) { + return null; + } + + return { + binding: { + path: propertyPath, + kind: "discrete", + components: [ + { + key: "value", + channelId: buildChannelId({ + propertyPath, + componentKey: "value", + }), + }, + ], + }, + channels: { + [buildChannelId({ propertyPath, componentKey: "value" })]: { + kind: "discrete", + keys: legacyKeys.map((keyframe) => ({ + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + })), + }, + }, + }; +} + +function migrateVectorChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: ProjectRecord; +}): MigratedAnimationChannel | null { + const legacyKeys = getLegacyScalarKeyframes({ + channel, + isValidValue: isLegacyVectorValue, + }); + if (legacyKeys.length === 0) { + return null; + } + + const xChannelId = buildChannelId({ propertyPath, componentKey: "x" }); + const yChannelId = buildChannelId({ propertyPath, componentKey: "y" }); + + return { + binding: { + path: propertyPath, + kind: "vector2", + components: [ + { key: "x", channelId: xChannelId }, + { key: "y", channelId: yChannelId }, + ], + }, + channels: { + [xChannelId]: { + kind: "scalar", + keys: legacyKeys.map((keyframe) => + toScalarKeyframe({ + keyframe: { + ...keyframe, + value: keyframe.value.x, + }, + }), + ), + }, + [yChannelId]: { + kind: "scalar", + keys: legacyKeys.map((keyframe) => + toScalarKeyframe({ + keyframe: { + ...keyframe, + value: keyframe.value.y, + }, + }), + ), + }, + }, + }; +} + +function migrateColorChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: ProjectRecord; +}): MigratedAnimationChannel | null { + const legacyKeys = getLegacyScalarKeyframes({ + channel, + isValidValue: (value): value is string => typeof value === "string", + }); + if (legacyKeys.length === 0) { + return null; + } + + const colorKeys = legacyKeys.flatMap((keyframe) => { + const linearRgba = parseColorToLinearRgba({ color: keyframe.value }); + if (!linearRgba) { + return []; + } + + return [ + { + id: keyframe.id, + time: keyframe.time, + interpolation: keyframe.interpolation, + values: linearRgba, + }, + ]; + }); + if (colorKeys.length === 0) { + return null; + } + + const channels = Object.fromEntries( + COLOR_COMPONENT_KEYS.map((componentKey) => [ + buildChannelId({ propertyPath, componentKey }), + { + kind: "scalar", + keys: colorKeys.map((keyframe) => + toScalarKeyframe({ + keyframe: { + id: keyframe.id, + time: keyframe.time, + value: keyframe.values[componentKey], + interpolation: keyframe.interpolation, + }, + }), + ), + }, + ]), + ); + + return { + binding: { + path: propertyPath, + kind: "color", + colorSpace: "srgb-linear", + components: COLOR_COMPONENT_KEYS.map((componentKey) => ({ + key: componentKey, + channelId: buildChannelId({ propertyPath, componentKey }), + })), + }, + channels, + }; +} + +function getLegacyScalarKeyframes({ + channel, + isValidValue, +}: { + channel: ProjectRecord; + isValidValue: (value: unknown) => value is TValue; +}): Array<{ + id: string; + time: number; + value: TValue; + interpolation: LegacyInterpolation; +}> { + const keyframes = channel.keyframes; + if (!Array.isArray(keyframes)) { + return []; + } + + return keyframes.flatMap((keyframe) => { + if (!isRecord(keyframe)) { + return []; + } + + if ( + typeof keyframe.id !== "string" || + typeof keyframe.time !== "number" || + !Number.isFinite(keyframe.time) || + !isValidValue(keyframe.value) + ) { + return []; + } + + return [ + { + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + interpolation: + keyframe.interpolation === "hold" ? "hold" : "linear", + }, + ]; + }); +} + +function getLegacyDiscreteKeyframes({ + channel, +}: { + channel: ProjectRecord; +}): LegacyDiscreteKeyframe[] { + const keyframes = channel.keyframes; + if (!Array.isArray(keyframes)) { + return []; + } + + return keyframes.flatMap((keyframe) => { + if (!isRecord(keyframe)) { + return []; + } + + if ( + typeof keyframe.id !== "string" || + typeof keyframe.time !== "number" || + !Number.isFinite(keyframe.time) || + (typeof keyframe.value !== "string" && typeof keyframe.value !== "boolean") + ) { + return []; + } + + return [ + { + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + }, + ]; + }); +} + +function isLegacyVectorValue(value: unknown): value is LegacyVectorValue { + return ( + isRecord(value) && + typeof value.x === "number" && + Number.isFinite(value.x) && + typeof value.y === "number" && + Number.isFinite(value.y) + ); +} + +function toScalarKeyframe({ + keyframe, +}: { + keyframe: LegacyScalarKeyframe; +}): ProjectRecord { + return { + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + segmentToNext: + keyframe.interpolation === "hold" ? "step" : "linear", + tangentMode: "flat", + }; +} + +function buildChannelId({ + propertyPath, + componentKey, +}: { + propertyPath: string; + componentKey: string; +}) { + return `${propertyPath}:${componentKey}`; +} diff --git a/apps/web/src/services/storage/migrations/v21-to-v22.ts b/apps/web/src/services/storage/migrations/v21-to-v22.ts new file mode 100644 index 00000000..212109a5 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v21-to-v22.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV21ToV22 } from "./transformers/v21-to-v22"; + +export class V21toV22Migration extends StorageMigration { + from = 21; + to = 22; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV21ToV22({ project }); + } +} From 099746633183cd061fc89a63aeb9fcfb909f6db2 Mon Sep 17 00:00:00 2001 From: Maze Date: Sun, 5 Apr 2026 13:37:29 +0200 Subject: [PATCH 5/6] fix: subscribe to playback time so keyframe button updates on frame change Made-with: Cursor --- .../editor/panels/properties/hooks/use-element-playhead.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts index 95f338e1..a900cb13 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts @@ -9,8 +9,7 @@ export function useElementPlayhead({ startTime: number; duration: number; }) { - const editor = useEditor(); - const playheadTime = editor.playback.getCurrentTime(); + const playheadTime = useEditor((editor) => editor.playback.getCurrentTime()); const localTime = getElementLocalTime({ timelineTime: playheadTime, elementStartTime: startTime, From dedb13546a16235e7d6649961d0c9b235f62aa34 Mon Sep 17 00:00:00 2001 From: Maze Date: Mon, 6 Apr 2026 01:13:14 +0200 Subject: [PATCH 6/6] feat: graphs popover --- .../timeline/graph-editor/bezier-graph.tsx | 249 ++++++++++ .../panels/timeline/graph-editor/popover.tsx | 328 +++++++++++++ .../panels/timeline/graph-editor/presets.ts | 90 ++++ .../panels/timeline/graph-editor/session.ts | 448 ++++++++++++++++++ .../timeline/graph-editor/use-controller.ts | 153 ++++++ .../panels/timeline/timeline-toolbar.tsx | 70 ++- apps/web/src/components/ui/tabs.tsx | 2 +- .../web/src/core/managers/timeline-manager.ts | 41 ++ apps/web/src/lib/animation/curve-bridge.ts | 82 ++++ apps/web/src/lib/animation/graph-channels.ts | 95 ++++ apps/web/src/lib/animation/index.ts | 13 + apps/web/src/lib/animation/keyframes.ts | 155 +++++- apps/web/src/lib/animation/resolve.ts | 20 +- apps/web/src/lib/animation/types.ts | 60 +++ .../timeline/element/keyframes/index.ts | 11 +- .../keyframes/update-scalar-keyframe-curve.ts | 85 ++++ 16 files changed, 1845 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts create mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/session.ts create mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts create mode 100644 apps/web/src/lib/animation/curve-bridge.ts create mode 100644 apps/web/src/lib/animation/graph-channels.ts create mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts 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); + } +}