diff options
Diffstat (limited to 'apps')
35 files changed, 5178 insertions, 3171 deletions
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<TimelineElement>;
-}): 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<TimelineElement>; +}): 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 = ( + <Button + variant={isActive ? "secondary" : "text"} + size="icon" + disabled={disabled} + onClick={onClick ? (event) => onClick({ event }) : undefined} + className={cn( + "rounded-sm", + disabled ? "cursor-not-allowed opacity-50" : "", + )} + > + {icon} + </Button> + ); + return ( <Tooltip delayDuration={200}> <TooltipTrigger asChild> - <Button - variant={isActive ? "secondary" : "text"} - size="icon" - disabled={disabled} - onClick={(event) => onClick({ event })} - className={cn( - "rounded-sm", - disabled ? "cursor-not-allowed opacity-50" : "", - )} - > - {icon} - </Button> + {buttonWrapper ? buttonWrapper(button) : button} </TooltipTrigger> <TooltipContent>{tooltip}</TooltipContent> </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<HandleType | null>(null);
- const scaleStateRef = useRef<ScaleState | null>(null);
- const rotationStateRef = useRef<RotationState | null>(null);
- const edgeScaleStateRef = useRef<EdgeScaleState | null>(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<HandleType | null>(null); + const scaleStateRef = useRef<ScaleState | null>(null); + const rotationStateRef = useRef<RotationState | null>(null); + const edgeScaleStateRef = useRef<EdgeScaleState | null>(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<string, unknown> { + 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<TKey extends string>({ + path, + key, +}: { + path: AnimationPath; + key: TKey; +}): AnimationBindingComponent<TKey> { + return { + key, + channelId: buildBindingChannelId({ path, componentKey: key }), + }; +} + +function cloneBindingComponents<TKey extends string>({ + components, +}: { + components: AnimationBindingComponent<TKey>[]; +}): AnimationBindingComponent<TKey>[] { + 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<K>; +}; + +export function createAnimationBinding<TKind extends AnimationBindingKind>({ + path, + kind, +}: { + path: AnimationPath; + kind: TKind; +}): AnimationBindingOfKind<TKind>; +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<K>; + }) => AnimationBindingOfKind<K>; +}; + +export function cloneAnimationBinding<TKind extends AnimationBindingKind>({ + binding, +}: { + binding: AnimationBindingOfKind<TKind>; +}): AnimationBindingOfKind<TKind>; +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<string, AnimationComponentValue> | 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<string, AnimationComponentValue | undefined>; +}): 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; + } + + 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; } - 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.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<TChannel extends AnimationChannel>({ @@ -159,9 +157,15 @@ export function normalizeChannel<TChannel extends AnimationChannel>({ }: { 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<TChannel extends AnimationChannel>({ } as TChannel; } -function evaluateChannelValueAtTime< - TKeyframe extends { time: number; value: TValue }, - TValue, ->({ - keyframes, +function extrapolateScalarEdge({ + mode, + edgeKey, + neighborKey, + time, +}: { + mode: "hold" | "linear"; + edgeKey: ScalarAnimationKey; + neighborKey: ScalarAnimationKey | undefined; + time: number; +}) { + if (mode === "hold" || !neighborKey) { + return edgeKey.value; + } + + const span = neighborKey.time - edgeKey.time; + if (Math.abs(span) <= TIME_EPSILON_SECONDS) { + return edgeKey.value; + } + + return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value); +} + +export function getScalarSegmentInterpolation({ + segment, +}: { + segment: ScalarSegmentType; +}): AnimationInterpolation { + if (segment === "step") { + return "hold"; + } + + return segment === "bezier" ? "bezier" : "linear"; +} + +export function getScalarChannelValueAtTime({ + channel, time, fallbackValue, - getInterpolatedValue, }: { - keyframes: TKeyframe[] | undefined; + channel: ScalarAnimationChannel | undefined; time: number; - fallbackValue: TValue; - getInterpolatedValue: ({ - leftKeyframe, - rightKeyframe, - progress, - }: { - leftKeyframe: TKeyframe; - rightKeyframe: TKeyframe; - progress: number; - }) => TValue; -}): TValue { - if (!keyframes || keyframes.length === 0) { + fallbackValue: number; +}): number { + if (!channel || channel.keys.length === 0) { return fallbackValue; } - const firstKeyframe = keyframes[0]; - const lastKeyframe = keyframes[keyframes.length - 1]; - if (!firstKeyframe || !lastKeyframe) { + const normalizedChannel = normalizeChannel({ + channel, + }); + const firstKey = normalizedChannel.keys[0]; + const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1]; + if (!firstKey || !lastKey) { return fallbackValue; } - if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) { - return firstKeyframe.value; + 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 >= lastKeyframe.time - TIME_EPSILON_SECONDS) { - return lastKeyframe.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 keyframeIndex = 0; - keyframeIndex < keyframes.length - 1; - keyframeIndex++ + let keyIndex = 0; + keyIndex < normalizedChannel.keys.length - 1; + keyIndex++ ) { - const leftKeyframe = keyframes[keyframeIndex]; - const rightKeyframe = keyframes[keyframeIndex + 1]; - - if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) { - return rightKeyframe.value; + const leftKey = normalizedChannel.keys[keyIndex]; + const rightKey = normalizedChannel.keys[keyIndex + 1]; + if (Math.abs(time - rightKey.time) <= TIME_EPSILON_SECONDS) { + return rightKey.value; } - const isBetweenPair = isWithinTimePair({ - time, - leftTime: leftKeyframe.time, - rightTime: rightKeyframe.time, - }); - if (!isBetweenPair) { + if ( + !isWithinTimePair({ + time, + leftTime: leftKey.time, + rightTime: rightKey.time, + }) + ) { continue; } - const span = rightKeyframe.time - leftKeyframe.time; + if (leftKey.segmentToNext === "step") { + return leftKey.value; + } + + const span = rightKey.time - leftKey.time; if (Math.abs(span) <= TIME_EPSILON_SECONDS) { - return rightKeyframe.value; + return rightKey.value; } const progress = clamp01({ - value: (time - leftKeyframe.time) / span, + value: (time - leftKey.time) / span, }); - - return getInterpolatedValue({ - leftKeyframe, - rightKeyframe, - progress, - }); - } - - return lastKeyframe.value; -} - -export function getNumberChannelValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: NumberAnimationChannel | 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 (leftKey.segmentToNext === "linear") { return lerpNumber({ - leftValue: leftKeyframe.value, - rightValue: rightKeyframe.value, + leftValue: leftKey.value, + rightValue: rightKey.value, progress, }); - }, - }); -} - -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; - } + 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 { - x: - leftKeyframe.value.x + - (rightKeyframe.value.x - leftKeyframe.value.x) * progress, - y: - leftKeyframe.value.y + - (rightKeyframe.value.y - leftKeyframe.value.y) * progress, - }; - }, - }); + return lastKey.value; } -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<string, AnimationComponentValue | undefined>; + + 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,207 +46,368 @@ function isNearlySameTime({ return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS; } +function hasChannelKeys({ + channel, +}: { + channel: AnimationChannel | undefined; +}): boolean { + return Boolean(channel && channel.keys.length > 0); +} + function toAnimation({ - channelEntries, + animations, }: { - channelEntries: Array<[string, AnimationChannel]>; + 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; -}): AnimationChannel { - return normalizeChannel({ - channel: { - valueKind, - keyframes, - } as AnimationChannel, - }); + animations: ElementAnimations | undefined; +}): ElementAnimations { + return { + bindings: { ...(animations?.bindings ?? {}) }, + channels: { ...(animations?.channels ?? {}) }, + }; } -export function getChannel({ - animations, - propertyPath, +function getBindingChannelKind({ + kind, }: { - animations: ElementAnimations | undefined; - propertyPath: string; -}): AnimationChannel | undefined { - return animations?.channels[propertyPath]; + kind: AnimationBindingKind; +}): AnimationChannel["kind"] { + return kind === "discrete" ? "discrete" : "scalar"; } -function getInterpolationForChannel({ - channel, +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, }: { - channel: AnimationChannel; + kind: AnimationBindingKind; interpolation: AnimationInterpolation | undefined; }): AnimationInterpolation { - if (channel.valueKind === "discrete") { + if (kind === "discrete") { return "hold"; } - if (interpolation === "linear" || interpolation === "hold") { + if ( + interpolation === "linear" || + interpolation === "hold" || + interpolation === "bezier" + ) { return interpolation; } return "linear"; } -function buildKeyframe({ - channel, +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, }: { - channel: AnimationChannel; id: string; time: number; - value: AnimationValue; + value: number; 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", - ); - } + 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, - interpolation: "hold", }; } -function createEmptyChannelForValueKind({ - valueKind, +function getBinding({ + animations, + propertyPath, }: { - valueKind: AnimationValueKind; -}): AnimationChannel { - if (valueKind === "number") { - return { - valueKind: "number", - keyframes: [], - } satisfies NumberAnimationChannel; - } + animations: ElementAnimations | undefined; + propertyPath: string; +}): AnimationBindingInstance | undefined { + return animations?.bindings[propertyPath]; +} - if (valueKind === "color") { - return { - valueKind: "color", - keyframes: [], - } satisfies ColorAnimationChannel; +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, + }; + } } - if (valueKind === "vector") { + const keyAtTime = keys.find((key) => + isNearlySameTime({ leftTime: key.time, rightTime: time }), + ); + if (keyAtTime) { return { - valueKind: "vector", - keyframes: [], - } satisfies VectorAnimationChannel; + id: keyAtTime.id, + time: keyAtTime.time, + }; } return { - valueKind: "discrete", - keyframes: [], - } satisfies DiscreteAnimationChannel; + id: keyframeId ?? generateUUID(), + time, + }; } -function clampNumericRange({ +function upsertDiscreteChannelKey({ + channel, + time, value, - numericRange, + keyframeId, }: { - value: number; - numericRange: NumericRange | undefined; -}): number { - if (!numericRange) { - return value; + 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 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)); + 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 coerceAnimationValueForPath({ +function upsertScalarChannelKey({ + channel, + time, value, - valueKind, - numericRange, + interpolation, + keyframeId, }: { - value: AnimationValue; - valueKind: AnimationValueKind; - numericRange?: NumericRange; -}): AnimationValue | null { - if (valueKind === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; + 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, + }, + }); } - - return clampNumericRange({ value, numericRange }); } - if (valueKind === "color") { - return typeof value === "string" ? value : null; + 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, + }, + }); } - if (valueKind === "vector") { - return isVectorValue(value) ? value : null; - } + keys.push( + createScalarKey({ + id: keyframeId ?? generateUUID(), + time, + value, + interpolation, + }), + ); + return normalizeChannel({ + channel: { + kind: "scalar", + keys, + extrapolation: normalizedChannel.extrapolation, + }, + }); +} - return typeof value === "string" || typeof value === "boolean" ? value : null; +export function getChannel({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: string; +}): AnimationChannel | undefined { + 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, + keyframeId, + }); + const componentValues = decomposeAnimationValue({ + kind, value: coercedValue, + }); + if (!componentValues) { + return animations; + } + + const nextInterpolation = getInterpolationForBinding({ + kind, interpolation: interpolation ?? defaultInterpolation, - keyframeId, }); + nextAnimations.bindings[propertyPath] = binding; + for (const component of binding.components) { + const nextValue = componentValues[component.key]; + if (nextValue == null) { + continue; + } - return ( - setChannel({ + 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, + time, value, - interpolation: nextInterpolation, - }); - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, + keyframeId, }); } - nextKeyframes.push( - buildKeyframe({ - channel, - id: keyframeId ?? generateUUID(), - time, - value, - interpolation: nextInterpolation, - }), - ); + if (typeof value !== "number") { + return channel; + } - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, + 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 binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const primaryComponent = getPrimaryComponent({ binding }); + if (!primaryComponent) { + return animations; + } - const nextChannelEntries = Object.entries(currentChannels) - .filter(([path]) => path !== propertyPath) - .filter(([, ch]) => ch && ch.keyframes.length > 0) - .map(([path, ch]) => [path, ch] as [string, AnimationChannel]); + 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 (channel && channel.keyframes.length > 0) { - nextChannelEntries.push([propertyPath, channel]); + 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<string, string>(); + if (primaryChannel) { + for (const key of primaryChannel.keys) { + keyIdMap.set( + key.id, + shouldRegenerateKeyframeIds ? generateUUID() : key.id, + ); } + } - const clonedKeyframes = channel.keyframes.map((keyframe) => ({ - ...keyframe, - id: shouldRegenerateKeyframeIds ? generateUUID() : keyframe.id, - })); + for (const component of binding.components) { + const currentChannel = getChannelById({ + animations, + channelId: component.channelId, + }); + if (!currentChannel) { + continue; + } - 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 { + 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, + }; + } - return toAnimation({ - channelEntries: clampedEntries, - }); + 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<Record<string, NumericSpec>>; + 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<string, AnimationComponentValue | undefined>; + 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<Record<string, NumericSpec>>; + 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<Record<string, NumericSpec>> | 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<string, ReadonlyArray<AnimationPropertyPath>>;
-
-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<number, ContinuousKeyframeInterpolation> {}
-
-export interface ColorKeyframe
- extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {}
-
-export interface DiscreteKeyframe
- extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
-
-export interface VectorKeyframe
- extends BaseAnimationKeyframe<VectorValue, ContinuousKeyframeInterpolation> {}
-
-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<string, ReadonlyArray<AnimationPropertyPath>>; + +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<TValue extends number | DiscreteValue> { + id: string; + time: number; // relative to element start time + value: TValue; +} + +export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> { + leftHandle?: CurveHandle; + rightHandle?: CurveHandle; + segmentToNext: ScalarSegmentType; + tangentMode: TangentMode; +} + +export interface DiscreteAnimationKey + extends BaseAnimationKeyframe<DiscreteValue> {} + +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<TKey extends string = string> { + key: TKey; + channelId: string; +} + +interface BaseAnimationBinding< + TKind extends AnimationBindingKind, + TComponentKey extends string, +> { + path: AnimationPath; + kind: TKind; + components: AnimationBindingComponent<TComponentKey>[]; +} + +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<TKind extends AnimationBindingKind> = + 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> = {},
-): SplitMaskParams {
- return {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- centerX: 0,
- centerY: 0,
- rotation: 0,
- ...overrides,
- };
-}
-
-function buildRectangleParams(
- overrides: Partial<RectangleMaskParams> = {},
-): 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> = {}, +): SplitMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + centerX: 0, + centerY: 0, + rotation: 0, + ...overrides, + }; +} + +function buildRectangleParams( + overrides: Partial<RectangleMaskParams> = {}, +): 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<Record<string, unknown>>; + const tracks = scenes[0].tracks as Array<Record<string, unknown>>; + const elements = tracks[0].elements as Array<Record<string, unknown>>; + const animations = elements[0].animations as Record<string, unknown>; + const bindings = animations.bindings as Record<string, Record<string, unknown>>; + const channels = animations.channels as Record<string, Record<string, unknown>>; + + 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<typeof transformProjectV5ToV6>[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<typeof transformProjectV5ToV6>[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<Record<string, unknown>> })
- .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<Record<string, unknown>> }) + .scenes[0]; + const result = transformProjectV5ToV6({ + project: { + ...v5Project, + version: 6, + scenes: [ + { + ...firstScene, + bookmarks: [{ time: 2 }, { time: 5 }], + }, + ], + } as Parameters<typeof transformProjectV5ToV6>[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<typeof transformProjectV5ToV6>[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<Record<string, unknown>> }) + .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<string, ProjectRecord>; +} + +export function transformProjectV21ToV22({ + project, +}: { + project: ProjectRecord; +}): MigrationResult<ProjectRecord> { + 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<string, ProjectRecord> = {}; + const nextChannels: Record<string, ProjectRecord> = {}; + + 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<TValue>({ + 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 }); + } +} |
