From 1bd9fac203f09736c1225bbbe19e5d7cc31c0b93 Mon Sep 17 00:00:00 2001 From: Maze Date: Sun, 5 Apr 2026 13:15:58 +0200 Subject: refactor: rewrite animation system to use bindings and scalar channels --- .../hooks/use-keyframed-param-property.ts | 298 +++++++++++---------- .../editor/panels/timeline/timeline-element.tsx | 5 +- .../editor/panels/timeline/timeline-toolbar.tsx | 38 +-- 3 files changed, 172 insertions(+), 169 deletions(-) (limited to 'apps/web/src/components/editor') 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} -- cgit v1.3.1